diff --git a/.env-template b/.env-template index dc63caa..17cfbf9 100644 --- a/.env-template +++ b/.env-template @@ -36,6 +36,14 @@ ADMIN_USERS= # has to be approved again after a restart. BASE_URL (above) must be the address MCP clients use. # OAUTH_DB_PATH=data/oauth.db +# --- WebMCP origin trial ------------------------------------------------------ +# Chrome's WebMCP (the UI's browser tools) is in an origin trial. Register BASE_URL's origin at +# https://developer.chrome.com/origintrials and paste the token here: the server then sends it +# as an `Origin-Trial` header on the page, and Chrome turns WebMCP on for everyone who opens it. +# The token is only good for that exact origin (scheme, host and port), and expires with the trial. +# Empty = no header (in Chrome you can still enable chrome://flags/#enable-webmcp-testing). +WEBMCP_ORIGIN_TRIAL_TOKEN= + # --- nREPL debug server (POST /nrepl) ----------------------------------------- # nREPL has no authentication and evaluates arbitrary code, so it listens on loopback # only by default. Changing NREPL_BIND to 0.0.0.0 exposes it to the network: only do that diff --git a/README.md b/README.md index 460158e..41c8c9b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ it (plumcp only checks that required ones are present). A tool that touches a us something admin-only gets the caller from `current-user` there (`{:login :id :admin? :client-id}`), and must not trust anything else about who is asking. +(The UI has tools of its own for a browser agent, [see below](#browser-mcp-tools-webmcp); they are not part of this server.) + Sessions are kept in memory, so they end when the server restarts (clients just re-initialize; their tokens are in the database and keep working). Server-to-client streaming (`GET /mcp`) is deliberately off: nothing here pushes messages, and the response would hang under Jetty. PluMCP needs JDK 21+. @@ -218,6 +220,37 @@ directory at `/js`, and serves `resources/public/index.html`, the shell that mou The Docker image builds the UI in its own stage, so `docker build` needs no local Node. +### Browser MCP tools (WebMCP) + +The page also offers tools of its own to your MCP client (Claude), through [WebMCP](https://webmcp.dev): +a small library that adds a widget to the page and lets the page register tools with it. They are the browser-side twin of the +[server's MCP tools](#mcp-server): those work on your data on the server, these work *the page*, doing +what you could do with the mouse and keyboard, so you see it happen. They are in `ui/src/tbd/ui/webmcp.cljs`, +registered with `registerTool` on a `WebMCP` instance (the `@jason.today/webmcp` npm package): + +| Tool | What it does | +|------|--------------| +| `ping`, `whoami` | Health check; who is logged in on the page, if anyone | +| `get_calculator` | What the calculator form holds: its two fields and the result it shows | +| `add` | Types `x` and `y` into the calculator, presses Add, and returns the sum the page shows | +| `list_todos`, `get_todo` | The todos the page shows (login required, like the todo list itself) | +| `create_todo` | Types a `title` into the new-todo field, presses Add, returns the new todo | +| `set_todo_done` | Ticks or unticks a todo's checkbox (`id`, `done`) | +| `delete_todo` | Presses a todo's Delete button | + +Each tool dispatches the same re-frame events the views do and waits for the result to show up in +the app-db, so what the agent sees is what the page shows, server errors included. There is no +`update_todo` with a title as on the server, because the page can't edit one. + +To connect (the page's "Browser tools (WebMCP)" section says the same): + +1. Add the WebMCP server to your MCP client, once: `npx -y @jason.today/webmcp@latest --config claude` + (restart Claude Desktop afterwards). +2. Ask the client to *make a webmcp token*. +3. Click the blue square in the bottom right corner of the page, paste the token, and press Connect. + The token works once, and the page has no field for it: the widget is where it goes. +4. The tools show up in the client, prefixed with the page's host. + ## Development Every task is a bash script in `script/`. Start an nREPL server (with CIDER middleware) on the port in `.nrepl-port`, or `3000` if that @@ -232,6 +265,7 @@ script/repl | Task | What it does | |------|--------------| | `script/unit-test` | Runs the Clojure tests in `test/` | +| `script/ui-test` | Runs the ClojureScript tests in `ui/test/` under Node (needs Node and a JDK; CI doesn't run it yet) | | `script/integration-test` | Builds the app image, starts it on a random port, runs the [integration suite](integration-test/README.md) against it, then tears it down | | `script/test` | Unit tests, then integration tests (stops at the first failure) | | `script/docker-unit-test` | Same as `script/unit-test`, but inside the app image (Docker only) | @@ -265,7 +299,7 @@ src/tbd/ oauth/ tbd as the OAuth authorization server for MCP clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` (middleware) nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes todo/ Per-user todos: pure rules (core), Malli schemas of the input (schema), SQLite storage (db), `/api/todos` routes -ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs +ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,webmcp,core}.cljs, tests in ui/test/ .env-template Settings the server reads from the environment; copy to .env (git-ignored) resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored) test/ Unit tests diff --git a/script/ui-test b/script/ui-test new file mode 100755 index 0000000..f68fe37 --- /dev/null +++ b/script/ui-test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Run the UI's (ClojureScript) tests under Node. Needs Node and a JDK, like script/ui-build. +set -euo pipefail +cd "$(dirname "$0")/../ui" + +[[ -d node_modules ]] || npm ci +exec npx shadow-cljs compile test diff --git a/src/tbd/core.clj b/src/tbd/core.clj index 290430d..c009d21 100644 --- a/src/tbd/core.clj +++ b/src/tbd/core.clj @@ -1,5 +1,6 @@ (ns tbd.core - (:require [ring.adapter.jetty :as jetty] + (:require [clojure.string :as str] + [ring.adapter.jetty :as jetty] [ring.middleware.defaults :refer [wrap-defaults api-defaults]] [ring.middleware.head :refer [wrap-head]] [reitit.ring :as ring] @@ -45,10 +46,14 @@ :details (:humanized (reitit.coercion/encode-error (ex-data e)))})) (defn- index-handler - "The HTML shell for the re-frame UI (ui/); it loads the compiled JS from /js." - [_req] - (-> (response/resource-response "public/index.html") - (response/content-type "text/html; charset=utf-8"))) + "The HTML shell for the re-frame UI (ui/); it loads the compiled JS from /js. With a WebMCP + origin-trial token it also sends it as an `Origin-Trial` header, which is how Chrome enables the + trial for this origin before any script runs." + [{:keys [origin-trial-token]}] + (fn [_req] + (cond-> (-> (response/resource-response "public/index.html") + (response/content-type "text/html; charset=utf-8")) + (seq origin-trial-token) (response/header "Origin-Trial" origin-trial-token)))) (defn- decode-error-handler "A request body that isn't valid for its Content-Type -> 400, in the same shape (and format) the routes use." @@ -63,7 +68,7 @@ ;; Each logical group (health, users, orders, ...) owns a `tbd..routes` ;; namespace that returns its own route subtree; they're concatenated here. -(defn make-router [{:keys [auth nrepl todo oauth]}] +(defn make-router [{:keys [auth nrepl todo oauth webmcp]}] (let [oauth-ds (delay (oauth-db/open (:db-path oauth))) ;; Opened (and migrated) on first use, not when the app loads. todo-ds (delay (todo-db/open (:db-path todo)))] @@ -75,7 +80,7 @@ :swagger {:info {:title "tbd API" :version "1.0.0"}} :handler (swagger-handler)}}] ["/" {:get {:no-doc true - :handler index-handler}}] + :handler (index-handler webmcp)}}] (health/routes) (math/routes) (mcp-routes/routes auth oauth-ds todo-ds) @@ -116,7 +121,8 @@ {:auth (auth/env-config) :nrepl (nrepl/env-config) :todo (todo-db/env-config) - :oauth (oauth-db/env-config)}) + :oauth (oauth-db/env-config) + :webmcp {:origin-trial-token (some-> (System/getenv "WEBMCP_ORIGIN_TRIAL_TOKEN") str/trim not-empty)}}) (def app (make-app config)) diff --git a/test/tbd/core_test.clj b/test/tbd/core_test.clj index b4c8b7f..61961cf 100644 --- a/test/tbd/core_test.clj +++ b/test/tbd/core_test.clj @@ -22,6 +22,16 @@ (testing "HEAD is served like GET, without a body" (is (= 200 (:status (request :head "/")))))) +(deftest origin-trial-header-test + (let [get-root (fn [config] + ((core/make-app config) {:request-method :get :uri "/" :headers {}}))] + (testing "the WebMCP origin-trial token is sent as an Origin-Trial header on the page" + (is (= "tok-123" (get-in (get-root (assoc core/config :webmcp {:origin-trial-token "tok-123"})) + [:headers "Origin-Trial"])))) + (testing "no token, no header" + (is (nil? (get-in (get-root (assoc core/config :webmcp {:origin-trial-token nil})) + [:headers "Origin-Trial"])))))) + (deftest js-assets-test ;; main.js only exists once script/ui-build has run, so only the miss is asserted here; ;; the integration suite (which runs against the built image) checks the hit. diff --git a/ui/package-lock.json b/ui/package-lock.json index 11447d8..d8e30a1 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,7 @@ "name": "tbd-ui", "version": "0.0.0", "dependencies": { + "@jason.today/webmcp": "0.1.13", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -15,6 +16,125 @@ "shadow-cljs": "^3.5.3" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jason.today/webmcp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@jason.today/webmcp/-/webmcp-0.1.13.tgz", + "integrity": "sha512-FpFfgZFhLukAphGLK6SkM8NHvqdsVIRfqy2cOlyrU+TNGp7FugL8y2sXx87lrgEhlfdDdxdF7Ywaf4XN/IlXDA==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.6.1", + "child_process": "^1.0.2", + "crypto": "^1.0.1", + "dotenv": "^16.4.1", + "env-paths": "^3.0.0", + "http": "^0.0.1-security", + "os": "^0.1.2", + "path": "^0.12.7", + "url": "^0.11.4", + "ws": "^8.18.1" + }, + "bin": { + "webmcp": "build/index.js" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -36,6 +156,43 @@ ], "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -68,6 +225,549 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/child_process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", + "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/http/-/http-0.0.1-security.tgz", + "integrity": "sha512-RnDvP10Ty9FxqOtPZuxtebw1j4L/WiqNMDtuc1YMH1XQm5TgDRaR1G9u8upL6KD1bXHSp9eSXo/ED+8Q7FAr+g==" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -89,6 +789,36 @@ ], "license": "BSD-3-Clause" }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", @@ -99,16 +829,292 @@ "node": ">=18" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/os": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/os/-/os-0.1.2.tgz", + "integrity": "sha512-ZoXJkvAnljwvc56MbvhtKVWmSkzV712k42Is2mA0+0KTSRakq5XXuXpjZjgAt9ctzl51ojhQWakQQpmOvXWfjQ==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -140,12 +1146,94 @@ "node": ">= 0.8.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shadow-cljs": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/shadow-cljs/-/shadow-cljs-3.5.3.tgz", @@ -175,6 +1263,99 @@ "dev": true, "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -196,6 +1377,101 @@ "source-map": "^0.6.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -212,11 +1488,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -233,6 +1514,24 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/ui/package.json b/ui/package.json index 992c91e..863a937 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" }, diff --git a/ui/shadow-cljs.edn b/ui/shadow-cljs.edn index 68485e8..43fb897 100644 --- a/ui/shadow-cljs.edn +++ b/ui/shadow-cljs.edn @@ -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}}} diff --git a/ui/src/tbd/ui/core.cljs b/ui/src/tbd/ui/core.cljs index be996d5..8c4a114 100644 --- a/ui/src/tbd/ui/core.cljs +++ b/ui/src/tbd/ui/core.cljs @@ -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)) diff --git a/ui/src/tbd/ui/views.cljs b/ui/src/tbd/ui/views.cljs index 0bac011..5194a59 100644 --- a/ui/src/tbd/ui/views.cljs +++ b/ui/src/tbd/ui/views.cljs @@ -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
, 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])]])) diff --git a/ui/src/tbd/ui/webmcp.cljs b/ui/src/tbd/ui/webmcp.cljs new file mode 100644 index 0000000..1c0f738 --- /dev/null +++ b/ui/src/tbd/ui/webmcp.cljs @@ -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.))))) diff --git a/ui/test/tbd/ui/webmcp_test.cljs b/ui/test/tbd/ui/webmcp_test.cljs new file mode 100644 index 0000000..4ab9bc4 --- /dev/null +++ b/ui/test/tbd/ui/webmcp_test.cljs @@ -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))))))