Files
tbd/test/tbd/mcp/routes_test.clj
ajet bf38f7b1be
CI / Test + coverage (push) Successful in 3m34s
scope tools by page
2026-09-21 14:01:34 -09:00

277 lines
16 KiB
Clojure

(ns tbd.mcp.routes-test
(:require [clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[muuntaja.core :as m]
[tbd.todo.schema :as todo-schema]
[malli.core :as malli]
[tbd.oauth.core :as oauth]
[tbd.oauth.db :as db]
[tbd.oauth.db-test :as db-test]
[tbd.oauth.routes-test :as o]
[tbd.auth.routes-test :as t])
(:import (java.io ByteArrayInputStream)))
;; The MCP handler sits behind its own transport, so these go through the real app (tbd.core)
;; and speak JSON-RPC to /mcp the way an MCP client does, with an OAuth access token.
(defn- mint-token!
"Puts a grant for `login` into the database at `path` and returns its access token, for
tests that aren't about how a client gets one (that is tbd.oauth.routes-test)."
[path login id & {:keys [expires-at] :or {expires-at (+ (System/currentTimeMillis) 60000)}}]
(let [token (oauth/random-token)]
(db/insert-grant! (db/open path) {:client-id "test-client" :user-id id :login login :scope "mcp"
:access-hash (oauth/hash-token token) :access-expires-at expires-at
:refresh-hash (oauth/hash-token (oauth/random-token))
:refresh-expires-at expires-at :created-at 1})
token))
(defn- decode-body
"The JSON-RPC message in a response body. plumcp answers a POST either with plain JSON or, when
it decides to stream (which one is a matter of timing, so a slow CI runner sees the other), as
Server-Sent Events: a seq of chunks whose `data:` lines hold the message. Clients accept both,
so the tests must too."
[{:keys [body headers]}]
(cond
(string? body) (m/decode "application/json" body)
(and (some? body) (str/starts-with? (str (get headers "Content-Type")) "text/event-stream"))
(let [data (->> (str/split-lines (if (instance? java.io.InputStream body) (slurp body) (apply str body)))
(keep #(when (str/starts-with? % "data:") (str/trim (subs % 5))))
(str/join "\n"))]
(m/decode "application/json" data))
:else body))
(defn- rpc
"POST a JSON-RPC message (as data) to /mcp with `token` as the bearer token, optionally within
the MCP session `sid`. Returns the response with its body decoded, whether plain JSON or an event
stream (nil when there is none)."
([app token message] (rpc app token message nil))
([app token message sid]
(let [body (m/encode "application/json" message)
resp (app {:request-method :post :uri "/mcp"
:scheme :http :server-name "localhost" :server-port 3000
:headers (cond-> {"content-type" "application/json"
"accept" "application/json, text/event-stream"}
token (assoc "authorization" (str "Bearer " token))
sid (assoc "mcp-session-id" sid))
:body (ByteArrayInputStream. (.readAllBytes ^java.io.InputStream body))})]
(cond-> resp
(some? (:body resp)) (assoc :body (decode-body resp))))))
(defn- initialize
"Runs the MCP handshake; returns the session id."
[app token]
(let [resp (rpc app token {:jsonrpc "2.0" :id 1 :method "initialize"
:params {:protocolVersion "2025-06-18" :capabilities {}
:clientInfo {:name "test" :version "1"}}})
sid (get-in resp [:headers "Mcp-Session-Id"])]
(is (= 200 (:status resp)))
(is (= {:name "tbd" :version "1.0.0"} (select-keys (get-in resp [:body :result :serverInfo]) [:name :version])))
(is (some? sid) "the server opens a session")
(is (= 202 (:status (rpc app token {:jsonrpc "2.0" :method "notifications/initialized"} sid))))
sid))
(defn- call-tool [app token sid tool arguments]
(:body (rpc app token {:jsonrpc "2.0" :id 2 :method "tools/call"
:params {:name tool :arguments arguments}}
sid)))
(defn- text [result] (get-in result [:result :content 0 :text]))
(defn- fixture
"An app on a fresh database, with the tokens of an admin (alice) and a regular user (bob)."
[]
(let [path (db-test/temp-db-path)]
{:path path
:app (t/make-app (t/gitea-stub "alice" 7) :oauth {:db-path path} :todo {:db-path (db-test/temp-db-path)})
:alice (mint-token! path "alice" 7)
:bob (mint-token! path "bob" 8)}))
(deftest list-tools-test
(let [{:keys [app alice]} (fixture)
sid (initialize app alice)
tools (get-in (rpc app alice {:jsonrpc "2.0" :id 2 :method "tools/list"} sid) [:body :result :tools])]
(is (= ["add" "create_todo" "delete_todo" "get_todo" "list_todos" "ping" "update_todo" "whoami"]
(sort (map :name tools))))
(let [input-schema (fn [tool] (:inputSchema (first (filter #(= tool (:name %)) tools))))]
(testing "a tool's input schema is generated from the Malli schema the HTTP API also uses"
(let [add (input-schema "add")]
(is (= ["x" "y"] (:required add)))
(is (= {:x "First addend" :y "Second addend"}
(update-vals (:properties add) :description)))
(is (= ["number" "number"] (map :type (vals (:properties add))))))
(let [get-todo (input-schema "get_todo")]
(is (= ["id"] (:required get-todo)))
(is (= "string" (get-in get-todo [:properties :id :type])) "an id is a string, not a number")
(is (re-find #"UUID" (get-in get-todo [:properties :id :description])))
(is (re-find (re-pattern (get-in get-todo [:properties :id :pattern])) (str (random-uuid)))))
(is (= ["id"] (:required (input-schema "update_todo"))) "title and done are optional")
(is (= #{"id" "title" "done"} (set (map name (keys (:properties (input-schema "update_todo"))))))))
(testing "and ones with no arguments say so"
(is (= {:type "object" :properties {}} (input-schema "list_todos")))))))
(deftest call-tool-test
(let [{:keys [app alice]} (fixture)
sid (initialize app alice)]
(testing "ping"
(is (= {:content [{:type "text" :text "pong"}] :isError false}
(:result (call-tool app alice sid "ping" {})))))
(testing "add goes through tbd.math.core"
(is (= {:content [{:type "text" :text "5"}] :isError false}
(:result (call-tool app alice sid "add" {:x 2 :y 3}))))
(is (= "3.5" (text (call-tool app alice sid "add" {:x 1.5 :y 2})))))
(testing "missing arguments are a tool error the model can read, not a failed request"
(let [result (:result (call-tool app alice sid "add" {:x 2}))]
(is (true? (:isError result)))
(is (re-find #"Missing tool param" (get-in result [:content 0 :text])))))
(testing "an unknown tool is a JSON-RPC error"
(is (contains? (call-tool app alice sid "nope" {}) :error)))))
(deftest whoami-test
(let [{:keys [app alice bob]} (fixture)]
(testing "tools know which user the token belongs to, and whether they are an admin"
(is (= "alice (Gitea user 7), admin" (text (call-tool app alice (initialize app alice) "whoami" {}))))
(is (= "bob (Gitea user 8)" (text (call-tool app bob (initialize app bob) "whoami" {})))))))
(deftest authentication-test
(let [{:keys [path app alice]} (fixture)
metadata "resource_metadata=\"http://tbd.test/.well-known/oauth-protected-resource/mcp\""
initialize-message {:jsonrpc "2.0" :id 1 :method "initialize"
:params {:protocolVersion "2025-06-18" :capabilities {}
:clientInfo {:name "test" :version "1"}}}]
(testing "no token: 401 pointing the client at the OAuth metadata (that is how it finds where to log in)"
(let [resp (rpc app nil initialize-message)
challenge (get-in resp [:headers "WWW-Authenticate"])]
(is (= 401 (:status resp)))
(is (.startsWith ^String challenge "Bearer "))
(is (.contains ^String challenge metadata))
(is (not (.contains ^String challenge "error=")) "no token isn't an invalid one")
(is (nil? (get-in resp [:headers "Mcp-Session-Id"])) "no session is opened")))
(testing "the wrong kind of credentials"
(doseq [authorization ["Basic YWxpY2U6cHc=" "Bearer" "Bearer " alice "bearer"]]
(let [resp ((:app (fixture)) {:request-method :post :uri "/mcp"
:headers {"authorization" authorization}
:body (ByteArrayInputStream. (.getBytes "{}"))})]
(is (= 401 (:status resp)) authorization))))
(testing "an unknown token: 401 saying so"
(let [resp (rpc app "not-a-token" initialize-message)
challenge (get-in resp [:headers "WWW-Authenticate"])]
(is (= 401 (:status resp)))
(is (.contains ^String challenge "error=\"invalid_token\""))
(is (.contains ^String challenge metadata))))
(testing "a refresh token or a stored hash isn't an access token"
(let [{:keys [tokens]} (o/get-tokens! app)]
(is (= 401 (:status (rpc app (:refresh_token tokens) initialize-message))))
(is (= 401 (:status (rpc app (oauth/hash-token alice) initialize-message))))))
(testing "an expired token"
(is (= 401 (:status (rpc app (mint-token! path "alice" 7 :expires-at (- (System/currentTimeMillis) 1000))
initialize-message)))))
(testing "the token is checked on every request, not only when the session is opened"
(let [sid (initialize app alice)]
(is (= 401 (:status (rpc app nil {:jsonrpc "2.0" :id 2 :method "tools/list"} sid))))
(is (= 401 (:status (rpc app "not-a-token" {:jsonrpc "2.0" :id 2 :method "tools/list"} sid))))
(is (= 401 (:status (app {:request-method :delete :uri "/mcp" :headers {"mcp-session-id" sid}}))))
(is (= 200 (:status (rpc app alice {:jsonrpc "2.0" :id 2 :method "tools/list"} sid))))))
(testing "and the body isn't read for a caller who isn't allowed in"
(let [resp (app {:request-method :post :uri "/mcp" :headers {}
:body (proxy [java.io.InputStream] [] (read ([] (throw (ex-info "body was read" {})))))})]
(is (= 401 (:status resp)))))))
(deftest admin-follows-the-allowlist-test
(let [path (db-test/temp-db-path)
token (mint-token! path "alice" 7)
app-with (fn [admins] (t/make-app (t/gitea-stub "alice") :oauth {:db-path path} :admin-users admins))]
(is (= "alice (Gitea user 7), admin" (text (call-tool (app-with #{"alice"}) token (initialize (app-with #{"alice"}) token) "whoami" {}))))
(testing "it is decided per request, not when the token was issued"
(let [app (app-with #{})]
(is (= "alice (Gitea user 7)" (text (call-tool app token (initialize app token) "whoami" {}))))))))
(deftest unconfigured-test
(let [app (t/make-app (t/gitea-stub "alice") :oauth {:db-path (db-test/temp-db-path)} :gitea-url nil)
resp (rpc app "anything" {:jsonrpc "2.0" :id 1 :method "initialize"})]
(is (= 503 (:status resp)) "with no login there is no way to get a token, so /mcp isn't open instead")))
(deftest end-to-end-test
(testing "a client that does the whole OAuth dance ends up as the user who approved it"
(let [app (o/app-for (db-test/temp-db-path) "carol" 9)
{:keys [client-id tokens]} (o/get-tokens! app)
whoami (fn [access-token] (text (call-tool app access-token (initialize app access-token) "whoami" {})))]
(is (= "carol (Gitea user 9)" (whoami (:access_token tokens))))
(testing "and after a refresh, when the first token no longer works"
(let [refreshed (:body (o/call app :post "/oauth/token"
:form {"grant_type" "refresh_token" "refresh_token" (:refresh_token tokens)
"client_id" client-id}))]
(is (= "carol (Gitea user 9)" (whoami (:access_token refreshed))))
(is (= 401 (:status (rpc app (:access_token tokens) {:jsonrpc "2.0" :id 1 :method "initialize"})))))))))
(deftest session-required-test
(testing "tools can't be used before the handshake opens a session"
(let [{:keys [app alice]} (fixture)
resp (rpc app alice {:jsonrpc "2.0" :id 2 :method "tools/list"})]
(is (<= 400 (:status resp) 499)))))
(deftest no-server-stream-test
(testing "GET /mcp (the optional server-to-client SSE stream) is a 405, which MCP clients accept"
(let [{:keys [app alice]} (fixture)
resp (app {:request-method :get :uri "/mcp"
:headers {"accept" "text/event-stream" "authorization" (str "Bearer " alice)}})]
(is (= 405 (:status resp))))))
(deftest close-session-test
(let [{:keys [app alice]} (fixture)
sid (initialize app alice)]
(is (= 202 (:status (app {:request-method :delete :uri "/mcp"
:headers {"mcp-session-id" sid "authorization" (str "Bearer " alice)}}))))
(testing "a closed session is gone"
(is (= 404 (:status (rpc app alice {:jsonrpc "2.0" :id 2 :method "tools/list"} sid)))))))
(deftest api-unaffected-test
(testing "the MCP route doesn't disturb the rest of the API (which it shares a router with)"
(let [{:keys [app]} (fixture)]
(is (= 200 (:status (app {:request-method :get :uri "/api/ping"}))))
(is (= 200 (:status (app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"})))))))
(deftest todo-tools-test
;; The rules (ownership, validation, not-found) are tbd.todo.service's and tested there; this is
;; the tools' side: arguments in, JSON out, the caller's identity passed on, errors as results.
(let [{:keys [app alice bob]} (fixture)
alice-sid (initialize app alice)
bob-sid (initialize app bob)
as-alice (partial call-tool app alice alice-sid)
as-bob (partial call-tool app bob bob-sid)
data (fn [resp] (m/decode "application/json" (text resp)))
error (fn [resp] (when (true? (get-in resp [:result :isError])) (text resp)))]
(is (= [] (data (as-alice "list_todos" {}))))
(let [created (data (as-alice "create_todo" {:title " write tests "}))
id (:id created)]
(is (= {:title "write tests" :done false :owner-id 7} (select-keys created [:title :done :owner-id])))
(is (= created (data (as-alice "get_todo" {:id id}))))
(is (= [created] (data (as-alice "list_todos" {}))))
(is (= {:title "ship it" :done true}
(select-keys (data (as-alice "update_todo" {:id id :title "ship it" :done true})) [:title :done])))
(testing "the tools act as whoever the token belongs to"
(is (= [] (data (as-bob "list_todos" {}))))
(is (= "todo not found" (error (as-bob "get_todo" {:id id})))))
(testing "a service error is an error result the model can read"
(is (= "title must not be blank" (error (as-alice "create_todo" {:title " "}))))
(is (= "nothing to change: give a title and/or done" (error (as-alice "update_todo" {:id id})))))
(is (= "deleted" (text (as-alice "delete_todo" {:id id}))))
(is (= "todo not found" (error (as-alice "get_todo" {:id id})))))))
(deftest tool-arguments-are-validated-test
(let [{:keys [app alice]} (fixture)
todo-id "3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b"
sid (initialize app alice)
error (fn [tool arguments]
(let [resp (call-tool app alice sid tool arguments)]
(is (true? (get-in resp [:result :isError])) (str tool " " (pr-str arguments)))
(text resp)))]
(testing "arguments of the wrong type are turned away, which plumcp alone doesn't do"
(is (= "invalid arguments: {:id [\"should be a UUID\"]}" (error "get_todo" {:id "abc"})))
(is (= "invalid arguments: {:id [\"should be a UUID\"]}" (error "get_todo" {:id 1})) "todo ids are not integers")
(is (= "invalid arguments: {:title [\"should be a string\"]}" (error "create_todo" {:title 5})))
(is (= "invalid arguments: {:done [\"should be a boolean\"]}" (error "update_todo" {:id todo-id :done "yes"})))
(is (= "invalid arguments: {:x [\"should be an integer\" \"should be a double\"]}" (error "add" {:x "2" :y 3}))))
(testing "the tools and the HTTP API validate against the same schemas"
(is (false? (malli/validate todo-schema/UpdateTodo {:id todo-id :done "yes"})))
(is (true? (malli/validate todo-schema/UpdateTodo {:id todo-id :done true}))))))