add webmcp
CI / Test + coverage (push) Successful in 59s

This commit is contained in:
2026-09-19 13:38:27 -09:00
parent aa4f8ea86a
commit 5aaf5a8d6c
12 changed files with 1919 additions and 17 deletions
+1301 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@
"release": "shadow-cljs release app"
},
"dependencies": {
"@jason.today/webmcp": "0.1.13",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
+8 -2
View File
@@ -1,6 +1,6 @@
;; The UI compiles straight into the server's resources dir, so the server picks it up from
;; the classpath (see `tbd.core`, which serves `resources/public` at `/js`).
{:source-paths ["src"]
{:source-paths ["src" "test"]
:dependencies
[[reagent "2.0.1"]
@@ -11,4 +11,10 @@
:output-dir "../resources/public/js"
:asset-path "/js"
:modules {:main {:init-fn tbd.ui.core/init}}
:devtools {:after-load tbd.ui.core/reload}}}}
:devtools {:after-load tbd.ui.core/reload}}
;; script/ui-test: the tests run under Node, against fakes of fetch and navigator.modelContext.
:test {:target :node-test
:output-to "target/ui-test/test.js"
:ns-regexp "-test$"
:autorun true}}}
+4 -1
View File
@@ -2,7 +2,8 @@
(:require [re-frame.core :as rf]
[reagent.dom.client :as rdc]
[tbd.ui.events :as events]
[tbd.ui.views :as views]))
[tbd.ui.views :as views]
[tbd.ui.webmcp :as webmcp]))
(defonce root (atom nil))
@@ -13,9 +14,11 @@
"Called by shadow-cljs after each hot reload."
[]
(rf/clear-subscription-cache!)
(webmcp/register!)
(render))
(defn init []
(rf/dispatch-sync [::events/initialize])
(reset! root (rdc/create-root (js/document.getElementById "app")))
(webmcp/register!)
(render))
+28 -3
View File
@@ -1,7 +1,8 @@
(ns tbd.ui.views
(:require [re-frame.core :as rf]
[tbd.ui.events :as events]
[tbd.ui.subs :as subs]))
[tbd.ui.subs :as subs]
[tbd.ui.webmcp :as webmcp]))
(defn- operand-input [k label]
[:label label " "
@@ -74,13 +75,20 @@
[]
(str "claude mcp add --transport http tbd " js/location.origin "/mcp"))
(defn- collapsible
"A section the reader can fold away by its heading (a native <details>, open to start with)."
[title & body]
(into [:details {:open true :style {:margin-top "2rem"}}
[:summary {:style {:cursor "pointer"}}
[:h2 {:style {:display "inline"}} title]]]
body))
(defn mcp-setup
"How to add this server to Claude Code: a command to copy, then signing in once."
[]
(let [command (mcp-command)
copied? @(rf/subscribe [::subs/mcp-copied?])]
[:section {:style {:margin-top "2rem"}}
[:h2 "Add this MCP server to Claude Code"]
[collapsible "Add this MCP server to Claude Code"
[:p "Run this in your terminal:"]
[:div {:style {:display "flex" :gap "0.5rem" :align-items "flex-start"}}
[:pre {:style {:flex 1 :margin 0 :padding "0.75rem" :overflow-x "auto"
@@ -97,6 +105,22 @@
". Your browser opens: log in with Gitea if asked, and click " [:strong "Approve"] "."]
[:li "Back in Claude Code, try: " [:em "\"use the tbd whoami tool\""] "."]]]))
(defn browser-tools
"The page's own tools for a browser agent (WebMCP, see tbd.ui.webmcp): how to connect an MCP
client to them. The token is pasted into the WebMCP widget in the corner of the page."
[]
[collapsible "Browser tools (WebMCP)"
[:p "This page offers " [:strong (count webmcp/tools)] " tools of its own (the calculator and the todos) to "
"your MCP client, over " [:a {:href "https://webmcp.dev" :target "_blank" :rel "noreferrer"} "WebMCP"]
", and you see them work on the page. To connect:"]
[:ol
[:li "Add the WebMCP server to your MCP client, once: " [:code "npx -y @jason.today/webmcp@latest --config claude"]
" (restart Claude Desktop afterwards)."]
[:li "Ask your MCP client: " [:em "\"make a webmcp token\""] "."]
[:li "Click the blue square in the bottom right corner of this page, paste the token, and press Connect. "
"(The token works once; to disconnect, press Disconnect there.)"]
[:li "The tools show up in your client, prefixed with this page's host."]]])
(defn- link-button [props label]
[:button (merge {:type "button"
:style {:background "none" :border 0 :padding 0 :font "inherit"
@@ -122,6 +146,7 @@
(when (= :logged-in (:status user))
[todos])
[mcp-setup]
[browser-tools]
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
(when-let [link (auth-link user)]
[:<> " · " link])]]))
+273
View File
@@ -0,0 +1,273 @@
(ns tbd.ui.webmcp
"The page's own MCP tools, for an agent running in the browser: the client-side counterpart of
tbd.mcp.tools. They are offered through WebMCP (https://webmcp.dev, the `@jason.today/webmcp`
library): it adds a small widget to the page, the user pastes a token from their MCP client
(Claude) into it, and the client then sees the tools registered here. Where tbd.mcp.tools works
on the server's data, these work the UI: each one does what a user could do on the page (type in
the form, press a button, tick a box) by dispatching the same re-frame events the views do, so
everything shows on screen, and reads what the page shows from the app-db.
A tool takes its arguments as a map and returns a value or a promise of one: a string, or data
that becomes JSON. Throwing (or rejecting) becomes an error result the agent can read. Input
schemas are JSON Schema, written here: the UI has no Malli to share with the server's."
(:require ["@jason.today/webmcp/src/webmcp.js" :as WebMCP]
[re-frame.core :as rf]
[re-frame.db :as rf-db]
[tbd.ui.events :as events]))
;; --- Reading and waiting for the app-db ---
(def ^:private timeout-ms
"How long a tool waits for the server to answer before giving up."
10000)
(defn- db [] @rf-db/app-db)
(defn- fail! [message] (throw (js/Error. message)))
(defn- settled
"A promise of the app-db as soon as `(pred db)` holds (right away if it already does), or a
rejection after `timeout-ms`. Requests made by events finish some time after the event is
dispatched, so a tool that changes something waits for the change to show up in the db."
[pred what]
(js/Promise.
(fn [resolve reject]
(if (pred (db))
(resolve (db))
(let [k (keyword "tbd.ui.webmcp" (str (gensym "await")))
timer (atom nil)
finish! (fn [f v]
(remove-watch rf-db/app-db k)
(js/clearTimeout @timer)
(f v))]
(reset! timer (js/setTimeout #(finish! reject (js/Error. (str "timed out waiting for " what)))
timeout-ms))
(add-watch rf-db/app-db k (fn [_ _ _ new-db]
(when (pred new-db) (finish! resolve new-db)))))))))
(defn- known-user
"A promise of the login state, once the page has found out what it is."
[]
(-> (settled #(not= :unknown (get-in % [:user :status])) "the login state")
(.then #(:user %))))
(defn- logged-in-todos
"A promise of the todo list as the page shows it, or a rejection if there is no login (the page
hides the todos then) or the list could not be loaded."
[]
(-> (known-user)
(.then (fn [{:keys [status message]}]
(case status
:logged-in nil
:anonymous (fail! "not logged in: log in on the page to use the todos")
:error (fail! (str "could not tell who is logged in: " message)))))
(.then #(settled (fn [d] (contains? #{:ok :error} (get-in d [:todos :status]))) "the todos"))
(.then (fn [d]
(let [{:keys [status items error]} (:todos d)]
(if (= :error status) (fail! error) items))))))
(defn- todo-failure
"Why the last change to the todos failed, or nil. A 401 drops the login, so that counts too."
[d]
(or (get-in d [:todos :error])
(when (not= :logged-in (get-in d [:user :status]))
"the login has expired: log in again on the page")))
(defn- change-todos
"Dispatches `event`, then waits for `(done? db)` (or for the change to fail, which throws why),
and returns the db."
[event done? what]
(rf/dispatch-sync event)
(-> (settled #(or (todo-failure %) (done? %)) what)
(.then (fn [d] (if-let [why (todo-failure d)] (fail! why) d)))))
(defn- find-todo [items id]
(first (filter #(= id (:id %)) items)))
(defn- find-todo! [items id]
(or (find-todo items id) (fail! "todo not found")))
;; --- Argument checks (the page can't trust that the agent honours the schema) ---
(defn- check-id! [id]
(when-not (and (number? id) (js/Number.isInteger id))
(fail! "invalid arguments: id must be an integer")))
;; --- The tools ---
(defn- ping [_] "pong")
(defn- whoami [_]
(-> (known-user)
(.then (fn [{:keys [status login message]}]
(case status
:logged-in (str "logged in as " login)
:anonymous "not logged in"
:error (str "could not tell who is logged in: " message))))))
(defn- get-calculator
[_]
(:calc (db)))
(defn- add
"Types the numbers into the calculator form and presses Add."
[{:keys [x y]}]
(when-not (and (number? x) (js/Number.isFinite x) (number? y) (js/Number.isFinite y))
(fail! "invalid arguments: x and y must be numbers"))
(when (= :loading (get-in (db) [:calc :result :status]))
(fail! "an addition is already in progress"))
(rf/dispatch-sync [::events/set-operand :x (str x)])
(rf/dispatch-sync [::events/set-operand :y (str y)])
(rf/dispatch-sync [::events/add])
(-> (settled #(not= :loading (get-in % [:calc :result :status])) "the sum")
(.then (fn [d]
(let [{:keys [status sum message]} (get-in d [:calc :result])]
(if (= :ok status)
{:x x :y y :sum sum}
(fail! message)))))))
(defn- list-todos [_]
(logged-in-todos))
(defn- get-todo [{:keys [id]}]
(check-id! id)
(-> (logged-in-todos)
(.then #(find-todo! % id))))
(defn- create-todo
"Types the title into the new-todo field and presses Add."
[{:keys [title]}]
(when-not (and (string? title) (seq title))
(fail! "invalid arguments: title must be a non-empty string"))
(-> (logged-in-todos)
(.then (fn [_]
(rf/dispatch-sync [::events/set-new-title title])
;; The field is emptied when the todo was added; a failure leaves it as it is.
(change-todos [::events/add-todo] #(= "" (get-in % [:todos :new-title])) "the new todo")))
(.then #(last (get-in % [:todos :items])))))
(defn- set-todo-done
"Ticks or unticks a todo's checkbox."
[{:keys [id done]}]
(check-id! id)
(when-not (boolean? done)
(fail! "invalid arguments: done must be true or false"))
(-> (logged-in-todos)
(.then (fn [items]
(let [todo (find-todo! items id)]
(if (= done (boolean (:done todo)))
todo
(-> (change-todos [::events/set-done id done]
#(= done (boolean (:done (find-todo (get-in % [:todos :items]) id))))
"the todo")
(.then #(find-todo (get-in % [:todos :items]) id)))))))))
(defn- delete-todo
"Presses a todo's Delete button."
[{:keys [id]}]
(check-id! id)
(-> (logged-in-todos)
(.then (fn [items]
(find-todo! items id)
(change-todos [::events/delete-todo id]
#(nil? (find-todo (get-in % [:todos :items]) id))
"the todo to go")))
(.then (fn [_] "deleted"))))
(defn- object-schema [properties required]
{:type "object" :properties properties :required required})
(def ^:private todo-id {:type "integer" :description "The todo's id"})
(def tools
"The tools, as {:name :description :input-schema :handler}."
[{:name "ping" :description "Health check: returns pong"
:input-schema (object-schema {} []) :handler ping}
{:name "whoami"
:description "Says who is logged in on this page, if anyone"
:input-schema (object-schema {} []) :handler whoami}
{:name "get_calculator"
:description (str "What the calculator form on this page holds (JSON): the x and y in its fields, and "
"the result it is showing (status idle, loading, ok with the sum, or error with the message)")
:input-schema (object-schema {} []) :handler get-calculator}
{:name "add"
:description "Types two numbers into the calculator form, presses Add, and returns the sum the page shows (JSON)"
:input-schema (object-schema {:x {:type "number" :description "The first number"}
:y {:type "number" :description "The second number"}}
["x" "y"])
:handler add}
{:name "list_todos"
:description (str "Lists the todos this page shows (JSON: id, title, done, created-at, updated-at), oldest first. "
"Needs a login on the page")
:input-schema (object-schema {} []) :handler list-todos}
{:name "get_todo"
:description "Gets one of the todos this page shows, by id (JSON)"
:input-schema (object-schema {:id todo-id} ["id"]) :handler get-todo}
{:name "create_todo"
:description "Types a title into the new-todo field, presses Add, and returns the todo that was created (JSON)"
:input-schema (object-schema {:title {:type "string" :minLength 1 :maxLength 200
:description "What needs doing"}}
["title"])
:handler create-todo}
{:name "set_todo_done"
:description "Ticks or unticks a todo's checkbox and returns the todo (JSON)"
:input-schema (object-schema {:id todo-id
:done {:type "boolean" :description "true to tick it off, false to untick it"}}
["id" "done"])
:handler set-todo-done}
{:name "delete_todo"
:description "Presses a todo's Delete button"
:input-schema (object-schema {:id todo-id} ["id"])
:handler delete-todo}])
;; --- Registering them with WebMCP ---
(defn- text-result [text error?]
;; Property names are strings: WebMCP's are not in the Closure externs, so an advanced build
;; would rename plain `.-isError` style access.
(let [content #js [#js {"type" "text" "text" text}]]
(if error?
#js {"content" content "isError" true}
#js {"content" content})))
(defn- ->text [value]
(cond
(string? value) value
(nil? value) "ok"
:else (js/JSON.stringify (clj->js value))))
(defn- execute
"A WebMCP `execute` callback for `handler`: runs it on the arguments, and turns what it
returns, or the error it throws, into a tool result."
[handler]
(fn [args]
(-> (js/Promise.resolve)
(.then #(handler (js->clj args :keywordize-keys true)))
(.then (fn [value] (text-result (->text value) false)))
(.catch (fn [e] (text-result (or (some-> e (aget "message")) (str e)) true))))))
(defn register-tools!
"Registers `tools` on `mcp`, a WebMCP instance: `registerTool(name, description, inputSchema,
execute)`. Registering a name again replaces the tool, so this is safe to call again (hot
reload). (WebMCP's methods are called by string: they are not in the Closure externs.)"
[mcp]
(doseq [{:keys [name description input-schema handler]} tools]
(js-invoke mcp "registerTool" name description (clj->js input-schema) (execute handler)))
mcp)
(defonce ^:private widget (atom nil))
(defn register!
"Offers `tools` to the agent through WebMCP. The first call creates the WebMCP widget (the small
square in the corner of the page, where the user pastes the token from their MCP client), later
ones only register the tools again. Returns the WebMCP instance."
[]
(register-tools! (or @widget (reset! widget (WebMCP.)))))
+230
View File
@@ -0,0 +1,230 @@
(ns tbd.ui.webmcp-test
(:require [cljs.reader :as reader]
[cljs.test :refer [deftest is testing async use-fixtures]]
[re-frame.core :as rf]
[re-frame.db :as rf-db]
[tbd.ui.events :as events]
[tbd.ui.webmcp :as webmcp]))
;; A fake browser: the page's server, and the WebMCP registry the tools end up in.
(def ^:private server
"What the fake server holds: {:user nil-or-{:login ..}, :todos [..], :next-id n}."
(atom nil))
(defn- respond [status body]
{:status status :body (pr-str body)})
(defn- route [method uri body]
(let [{:keys [user todos]} @server
todo-id (some->> (re-find #"^/api/todos/(\d+)$" uri) second js/parseInt)]
(cond
(= "/auth/me" uri) (if user (respond 200 user) (respond 401 {:error "not logged in"}))
(and (= "GET" method) (re-find #"^/api/add\?" uri))
(let [[_ x y] (re-find #"x=([^&]*)&y=([^&]*)" uri)]
(if-let [error (:add-error @server)]
(respond 400 {:error error :details {:x ["is wrong"]}})
(respond 200 {:result (+ (js/parseFloat x) (js/parseFloat y))})))
(not user) (respond 401 {:error "not logged in"})
(and (= "GET" method) (= "/api/todos" uri)) (respond 200 {:todos todos})
(and (= "POST" method) (= "/api/todos" uri))
(if (seq (.trim (:title body)))
(let [todo {:id (:next-id @server) :owner-id 7 :title (:title body) :done false
:created-at 1 :updated-at 1}]
(swap! server #(-> % (update :todos conj todo) (update :next-id inc)))
(respond 201 todo))
(respond 400 {:error "Invalid body" :details {:title ["must not be blank"]}}))
(and (= "PATCH" method) todo-id)
(if-let [todo (first (filter #(= todo-id (:id %)) todos))]
(let [changed (merge todo body)]
(swap! server update :todos (fn [ts] (mapv #(if (= todo-id (:id %)) changed %) ts)))
(respond 200 changed))
(respond 404 {:error "todo not found"}))
(and (= "DELETE" method) todo-id)
(do (swap! server update :todos (fn [ts] (filterv #(not= todo-id (:id %)) ts)))
(respond 200 {:deleted true}))
:else (respond 404 {:error "not found"}))))
(defn- fake-fetch [uri opts]
(let [opts (js->clj opts :keywordize-keys true)
{:keys [status body]} (route (:method opts) uri (some-> (:body opts) reader/read-string))]
(js/Promise.resolve
#js {:ok (< status 300) :status status :text (fn [] (js/Promise.resolve body))})))
(def ^:private registered
"What the page registered with WebMCP: name -> {:description :input-schema :execute}."
(atom {}))
(def ^:private fake-mcp
"A WebMCP instance, as far as the page uses it: registerTool(name, description, schema, execute)."
#js {:registerTool (fn [name description input-schema execute]
(swap! registered assoc name {:description description
:input-schema input-schema
:execute execute}))})
(def ^:private real-fetch (.-fetch js/globalThis))
(use-fixtures :each
{:before (fn []
(reset! registered {})
(reset! server {:user nil :todos [] :next-id 1})
(aset js/globalThis "fetch" fake-fetch))
:after (fn []
(aset js/globalThis "fetch" real-fetch))})
(defn- call
"A promise of the result of the tool `tool-name` on `args`, as {:text :error?}."
[tool-name args]
(-> ((:execute (get @registered tool-name)) (clj->js args))
(.then (fn [^js result]
{:text (.. result -content (at 0) -text)
:error? (true? (.-isError result))}))))
(defn- page-loaded
"A promise that is fulfilled once the page has found out who is logged in. A test that ends
sooner would leave that answer to arrive in the next test's page."
[]
(call "whoami" {}))
(defn- json [text] (js->clj (js/JSON.parse text) :keywordize-keys true))
(defn- open-page!
"Loads the page as core/init does (after the fake server is set up): the app starts up, asking
the server who is logged in, and registers its tools."
[]
(rf/dispatch-sync [::events/initialize])
(webmcp/register-tools! fake-mcp))
(defn- log-in! [] (swap! server assoc :user {:login "alice" :id 7}))
(deftest registers-the-tools
(is (identical? fake-mcp (webmcp/register-tools! fake-mcp)))
(is (= (set (map :name webmcp/tools)) (set (keys @registered))))
(doseq [[tool-name {:keys [description input-schema execute]}] @registered]
(is (and (string? description) (seq description)) tool-name)
(is (= "object" (.-type ^js input-schema)) tool-name)
(is (object? (.-properties ^js input-schema)) tool-name)
(is (fn? execute) tool-name))
(testing "what the tools need, as the agent sees it"
(is (= ["x" "y"] (vec (.. ^js (:input-schema (get @registered "add")) -required))))
(is (= ["id" "done"] (vec (.. ^js (:input-schema (get @registered "set_todo_done")) -required))))))
(deftest registering-again-replaces-the-tools
(webmcp/register-tools! fake-mcp)
(webmcp/register-tools! fake-mcp)
(is (= (count webmcp/tools) (count @registered))))
(deftest ping-and-add
(open-page!)
(async done
(-> (page-loaded)
(.then #(call "ping" {}))
(.then (fn [r]
(is (= {:text "pong" :error? false} r))
(call "add" {:x 2 :y 3})))
(.then (fn [r]
(is (false? (:error? r)))
(is (= {:x 2 :y 3 :sum 5} (json (:text r))))
;; It filled in the form, as a user would.
(is (= {:x "2" :y "3" :result {:status :ok :x "2" :y "3" :sum 5}}
(:calc @rf-db/app-db)))
(call "get_calculator" {})))
(.then (fn [r]
(is (= "2" (:x (json (:text r)))))
(is (= "ok" (:status (:result (json (:text r))))))
(call "add" {:x "two" :y 3})))
(.then (fn [r]
(is (:error? r))
(is (re-find #"must be numbers" (:text r)))
(done))))))
(deftest add-shows-the-servers-error
(swap! server assoc :add-error "Invalid parameters")
(open-page!)
(async done
(-> (page-loaded)
(.then #(call "add" {:x 1 :y 2}))
(.then (fn [r]
(is (:error? r))
(is (re-find #"Invalid parameters" (:text r)))
(call "add" {:x js/NaN :y 1})))
(.then (fn [r]
(is (:error? r) "not a number, so nothing was sent")
(is (re-find #"must be numbers" (:text r)))
(done))))))
(deftest anonymous-user
(open-page!)
(async done
(-> (call "whoami" {})
(.then (fn [r]
(is (= {:text "not logged in" :error? false} r))
(call "list_todos" {})))
(.then (fn [r]
(is (:error? r))
(is (re-find #"not logged in" (:text r)))
(call "create_todo" {:title "buy milk"})))
(.then (fn [r]
(is (:error? r))
(is (empty? (:todos @server)) "nothing was sent")
(done))))))
(deftest todos
(log-in!)
(swap! server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
:next-id 2)
(open-page!)
(async done
(-> (call "whoami" {})
(.then (fn [r]
(is (= "logged in as alice" (:text r)))
(call "list_todos" {})))
(.then (fn [r]
(is (= ["old"] (map :title (json (:text r)))))
(call "get_todo" {:id 1})))
(.then (fn [r]
(is (= "old" (:title (json (:text r)))))
(call "get_todo" {:id 99})))
(.then (fn [r]
(is (= {:text "todo not found" :error? true} r))
(call "create_todo" {:title "buy milk"})))
(.then (fn [r]
(is (false? (:error? r)))
(is (= {:id 2 :title "buy milk" :done false} (select-keys (json (:text r)) [:id :title :done])))
;; The page shows it, and the field is empty again.
(is (= ["old" "buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
(is (= "" (get-in @rf-db/app-db [:todos :new-title])))
(call "set_todo_done" {:id 2 :done true})))
(.then (fn [r]
(is (true? (:done (json (:text r)))))
(is (true? (:done (last (get-in @rf-db/app-db [:todos :items])))))
;; Already ticked: nothing to do, and no request.
(swap! server assoc :todos [])
(call "set_todo_done" {:id 2 :done true})))
(.then (fn [r]
(is (true? (:done (json (:text r)))))
(call "set_todo_done" {:id 2 :done "yes"})))
(.then (fn [r]
(is (:error? r))
(call "delete_todo" {:id 1})))
(.then (fn [r]
(is (= {:text "deleted" :error? false} r))
(is (= ["buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
(call "delete_todo" {:id 1})))
(.then (fn [r]
(is (= {:text "todo not found" :error? true} r))
(call "get_todo" {:id 1.5})))
(.then (fn [r]
(is (:error? r))
(done))))))
(deftest create-todo-shows-the-servers-error
(log-in!)
(open-page!)
(async done
(-> (call "create_todo" {:title " "})
(.then (fn [r]
(is (:error? r))
(is (re-find #"Invalid body" (:text r)))
(is (empty? (get-in @rf-db/app-db [:todos :items])))
(done))))))