From 61edc9de8e12a035f0109dedfff02bec8e0caccf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 14:05:32 +0000 Subject: [PATCH 1/5] feat: useMcp hook + a "bring your own MCP server" demo panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo could only drive tools defined inside itself. Add a second panel where the visitor pastes any remote MCP endpoint and the agent picks up that server's tools instead — including servers behind OAuth, which is where a browser client actually earns its keep (dynamic client registration means there is no client ID to hand out in advance). The hook owns the parts a host would otherwise rewrite: - one server per hook, connect / disconnect / status / catalog; - auth by static header or OAuth 2.1 + DCR; - the redirect round-trip — it persists what it needs before leaving, then on the way back finishes the code exchange, strips ?code= from the address bar (single-use, and it lands in history) and reconnects, all behind `completingAuthorization`; - `authorize()` navigates only from a real user gesture, never on its own, so an authorization demanded mid-run can't discard the session. The core's ./mcp subpath is loaded with a dynamic import and typed structurally in mcp-types.ts: apps that never connect keep @modelcontextprotocol/sdk out of their bundle (Vite splits it into its own chunk), and a core without the OAuth half degrades to a clear message instead of a crash. Tests cover the two pure pieces this package owns — the connect-outcome to UI-status mapping, and the URL cleanup after a redirect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur --- CLAUDE.md | 6 +- README.md | 38 + demo/README.md | 21 +- demo/package-lock.json | 1208 +++++++++++++++++++++++++++-- demo/package.json | 1 + demo/src/App.tsx | 108 ++- demo/src/app.css | 167 ++++ demo/src/components/McpPanel.tsx | 194 +++++ package-lock.json | 1220 +++++++++++++++++++++++++++++- package.json | 1 + src/hooks/use-mcp.ts | 331 ++++++++ src/index.ts | 3 + src/mcp-types.ts | 71 ++ tests/mcp.test.ts | 60 ++ 14 files changed, 3344 insertions(+), 85 deletions(-) create mode 100644 demo/src/components/McpPanel.tsx create mode 100644 src/hooks/use-mcp.ts create mode 100644 src/mcp-types.ts create mode 100644 tests/mcp.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index a0f6f84..25f332a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,10 +29,14 @@ The repo also contains a **Vite demo** (`demo/`) that is auto-deployed to - `src/state.ts` — the pure `agentStateReducer` + `createInitialAgentState`. - `src/types.ts` — `AgentUiState`, `ChatMessage`, `StepView`, `ToolCallView`, … -- `src/hooks/` — `use-agent` (the hook), `use-credentials` (vault), `use-webllm-model`. +- `src/hooks/` — `use-agent` (the hook), `use-credentials` (vault), + `use-webllm-model`, `use-mcp` (remote MCP + OAuth round-trip). +- `src/mcp-types.ts` — structural types for the core's optional `./mcp` subpath, + which `use-mcp` loads with a dynamic import (never a static one). - `src/context.tsx` — `AgentProvider` + `useAgentContext`. - `src/components/` — `AgentChat`, `MessageList`, `Composer`, `PlanView`, `StepList`, `ModelLoadBar`, `UsageBadge`, `ApiKeyForm`, `icons`, `format`. +- `demo/src/components/McpPanel.tsx` — the demo's "bring your own MCP" panel. - `src/styles.css` — optional theme (light + dark). - `src/index.ts` — public surface (+ curated re-exports from the core). - `tests/state.test.ts` — `node --test` over the reducer (imports from `dist`). diff --git a/README.md b/README.md index 110063e..cd61cc4 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,44 @@ function CloudAgent() { The [`demo/`](demo/) app does exactly this — see [`demo/src/providers.ts`](demo/src/providers.ts). +## The `useMcp` hook — connect a remote MCP server + +Let the user name their own MCP server at runtime and hand its tools to the +agent. Auth is either a static header or the full OAuth 2.1 flow with **dynamic +client registration** — no client ID to configure, and the access token is +refreshed for you when it expires mid-run. + +```tsx +const mcp = useMcp({ clientName: 'my-app' }) + +await mcp.connect({ url, oauth: true }) // or { url, headers: { Authorization } } + +// The server wants a sign-in: send the user off from a real click. +{mcp.status === 'needs-authorization' && } + +// Then just merge the tools into any agent config. +const config = { model, tools: { ...myTools, ...mcp.tools } } +``` + +`useMcp` returns `{ status, tools, catalog, error, authorizationUrl, +oauthSupported, completingAuthorization, connect, disconnect, authorize, +forgetAuthorization }`. `status` is +`idle | connecting | connected | needs-authorization | error`. + +The OAuth round-trip navigates away from your app, so the hook persists what it +needs and, on the way back, finishes the code exchange, strips `?code=…` from +the address bar and reconnects before rendering — `completingAuthorization` +covers that window. + +Two notes: + +- The connector lives in the core's optional `@dudko.dev/agent-web/mcp` subpath + and is loaded with a **dynamic import**, so apps that never call `connect` + don't pay for `@modelcontextprotocol/sdk`. Install it alongside the core when + you do use MCP. +- `oauthSupported` is `false` on cores older than the one that introduced + `BrowserOAuthProvider`; header auth still works there. + ## Components All components are optional and styled by `styles.css` (class-prefixed `awr-`, diff --git a/demo/README.md b/demo/README.md index b204522..da525cd 100644 --- a/demo/README.md +++ b/demo/README.md @@ -1,9 +1,24 @@ # agent-web-react — demo A Vite + React app showcasing [`@dudko.dev/agent-web-react`](../): an in-browser -LLM agent that edits a sticky-notes board through tools. Pick a cloud model -(bring your own key, stored encrypted) or load a local WebGPU model — everything -runs in the browser. +LLM agent driving tools. Pick a cloud model (bring your own key, stored +encrypted) or load a local WebGPU model — everything runs in the browser. + +Two panels: + +- **Sticky notes** — the agent edits a board through locally defined tools. +- **Your MCP server** — paste any remote MCP endpoint and the agent picks up + *its* tools. Auth is none, a bearer token, or **OAuth 2.1 + dynamic client + registration**: the app registers itself with your authorization server, runs + PKCE, keeps the tokens encrypted in IndexedDB and refreshes them on expiry. + The server must send CORS headers for this origin (including + `Access-Control-Expose-Headers: WWW-Authenticate, mcp-session-id`) — the + browser talks to it directly, nothing is proxied. + +> The OAuth panel needs a core that exports `BrowserOAuthProvider`. The repo +> pins `@dudko.dev/agent-web` as a devDependency for the aliased build; bump it +> once a core with MCP OAuth is published, or the panel reports that OAuth is +> unavailable and only header auth works. **Live:** https://dudko-dev.github.io/agent-web-react/ diff --git a/demo/package-lock.json b/demo/package-lock.json index 5a81841..0e5527e 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -14,6 +14,7 @@ "@browser-ai/web-llm": "^2.0.0", "@dudko.dev/agent-web": "^0.0.6", "@mlc-ai/web-llm": "^0.2.79", + "@modelcontextprotocol/sdk": "^1.30.0", "ai": "^6.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -931,6 +932,18 @@ "node": ">=18" } }, + "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/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -990,6 +1003,46 @@ "loglevel": "^1.9.1" } }, + "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/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -1098,9 +1151,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1115,9 +1165,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1132,9 +1179,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1149,9 +1193,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1166,9 +1207,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1183,9 +1221,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1200,9 +1235,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1217,9 +1249,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1234,9 +1263,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1251,9 +1277,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1268,9 +1291,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1285,9 +1305,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1302,9 +1319,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1503,6 +1517,19 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "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/ai": { "version": "6.0.218", "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.218.tgz", @@ -1521,6 +1548,39 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "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/baseline-browser-mapping": { "version": "2.10.41", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", @@ -1534,6 +1594,43 @@ "node": ">=6.0.0" } }, + "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/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -1568,6 +1665,44 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "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/caniuse-lite": { "version": "1.0.30001800", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", @@ -1589,6 +1724,28 @@ ], "license": "CC-BY-4.0" }, + "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/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1596,6 +1753,55 @@ "dev": true, "license": "MIT" }, + "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/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1607,7 +1813,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1621,6 +1826,35 @@ } } }, + "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/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/electron-to-chromium": { "version": "1.5.384", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", @@ -1628,6 +1862,45 @@ "dev": true, "license": "ISC" }, + "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/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/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1680,6 +1953,33 @@ "node": ">=6" } }, + "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.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", @@ -1689,6 +1989,90 @@ "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.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "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.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1707,6 +2091,45 @@ } } }, + "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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1722,6 +2145,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "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/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1732,38 +2164,213 @@ "node": ">=6.9.0" } }, - "node_modules/idb": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", - "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" + "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/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "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", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/json-schema": { - "version": "0.4.0", + "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.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", + "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "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/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, + "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.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "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/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1800,11 +2407,69 @@ "yallist": "^3.0.2" } }, + "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==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1826,6 +2491,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -1836,6 +2510,76 @@ "node": ">=18" } }, + "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/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-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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1856,6 +2600,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -1885,6 +2638,63 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "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.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1916,6 +2726,15 @@ "node": ">=0.10.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/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1961,6 +2780,28 @@ "fsevents": "~2.3.2" } }, + "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.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1977,6 +2818,150 @@ "semver": "bin/semver.js" } }, + "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/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-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1987,6 +2972,15 @@ "node": ">=0.10.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/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2004,6 +2998,46 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2018,6 +3052,15 @@ "node": ">=14.17" } }, + "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/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2049,6 +3092,15 @@ "browserslist": ">= 4.21.0" } }, + "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/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -2124,6 +3176,27 @@ } } }, + "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/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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2139,6 +3212,15 @@ "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/demo/package.json b/demo/package.json index 55481ce..567db92 100644 --- a/demo/package.json +++ b/demo/package.json @@ -16,6 +16,7 @@ "@browser-ai/web-llm": "^2.0.0", "@dudko.dev/agent-web": "^0.0.6", "@mlc-ai/web-llm": "^0.2.79", + "@modelcontextprotocol/sdk": "^1.30.0", "ai": "^6.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 1aa7dd5..e0848bf 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -1,7 +1,14 @@ import { useEffect, useMemo, useState } from 'react' import type { BrowserAgentConfig, ModelInput } from '@dudko.dev/agent-web' -import { AgentChat, useAgent, useCredentials, useWebLLMModel } from '@dudko.dev/agent-web-react' +import { + AgentChat, + useAgent, + useCredentials, + useMcp, + useWebLLMModel, +} from '@dudko.dev/agent-web-react' import type { LanguageModel } from 'ai' +import { McpPanel } from './components/McpPanel' import { NotesBoard } from './components/NotesBoard' import { Settings } from './components/Settings' import { isLocal, MODELS } from './models' @@ -13,6 +20,24 @@ Add, update, remove and list notes to satisfy the user's request. Keep each note short (a few words). When asked for a list or a plan, create one note per item. Use colors meaningfully — e.g. red for urgent, green for done.` +const MCP_SYSTEM_PROMPT = `You drive a remote MCP server the user connected themselves. +You have no prior knowledge of what its tools do beyond their names and descriptions — +read them, pick the ones that fit, and call them. Never invent a tool or a parameter. +If the tools cannot satisfy the request, say so plainly instead of guessing.` + +type View = 'notes' | 'mcp' + +/** + * Which panel to open on load. An OAuth round-trip comes back to the bare page + * URL (a redirect_uri may not carry a fragment), so `?code=…` is what tells us + * the visitor was in the middle of connecting their MCP server. + */ +const initialView = (): View => { + const params = new URLSearchParams(window.location.search) + if (params.has('code') || params.has('error')) return 'mcp' + return window.location.hash.replace(/^#\/?/, '') === 'mcp' ? 'mcp' : 'notes' +} + export const App = () => { const [modelId, setModelId] = useState('google-flash') const model = MODELS.find((m) => m.id === modelId) ?? MODELS[0] @@ -74,6 +99,32 @@ export const App = () => { deps: [modelId, resolvedModel], }) + // ── Panel 2: the same agent, but driving whatever MCP server the visitor + // connects. Its own agent instance so the two chats keep separate histories. + const [view, setView] = useState(initialView) + const mcp = useMcp({ clientName: 'agent-web-demo' }) + + useEffect(() => { + const next = view === 'mcp' ? '#/mcp' : '' + if (window.location.hash !== next) { + window.history.replaceState(null, '', `${window.location.pathname}${next}`) + } + }, [view]) + + const mcpConfig = useMemo( + () => ({ + model: resolvedModel as ModelInput, + credentials: credentials.store, + tools: mcp.tools ?? {}, + systemPrompt: MCP_SYSTEM_PROMPT, + maxIterations: 6, + logLevel: 'debug', + }), + [resolvedModel, credentials.store, mcp.tools], + ) + + const mcpAgent = useAgent(mcpConfig, { deps: [modelId, resolvedModel, mcp.tools] }) + return (
@@ -100,6 +151,25 @@ export const App = () => {
+ +
{ onSelect={setModelId} credentials={credentials} webllm={webllm} - onKeyChange={agent.reload} + onKeyChange={() => { + agent.reload() + mcpAgent.reload() + }} />
- + {view === 'notes' ? ( + + ) : mcp.status === 'connected' ? ( + + ) : ( +
+ Connect a server on the right, and its tools become this agent’s toolbox. +
+ )}
- + {view === 'notes' ? ( + + ) : ( + + )}
diff --git a/demo/src/app.css b/demo/src/app.css index 1bf2e8a..794c821 100644 --- a/demo/src/app.css +++ b/demo/src/app.css @@ -273,3 +273,170 @@ a { text-align: center; padding-bottom: 4px; } + +/* ── View tabs ────────────────────────────────────────────────────────────── */ +.app__tabs { + display: flex; + gap: 6px; + margin-bottom: 12px; +} +.app__tab { + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel); + color: var(--muted); + font: inherit; + font-weight: 500; + cursor: pointer; +} +.app__tab.is-active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.app__empty { + height: 100%; + display: grid; + place-items: center; + padding: 24px; + text-align: center; + color: var(--muted); + background: var(--panel); + border: 1px dashed var(--border); + border-radius: 12px; + font-size: 14px; +} + +/* ── MCP panel ────────────────────────────────────────────────────────────── */ +.mcp { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + max-height: 100%; + overflow: auto; +} +.mcp__form { + display: flex; + flex-direction: column; + gap: 10px; +} +.mcp__input { + padding: 9px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--fg); + font: inherit; + width: 100%; + box-sizing: border-box; +} +.mcp__modes { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.mcp__mode { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 13px; + cursor: pointer; +} +.mcp__mode.is-active { + border-color: var(--accent); + color: var(--accent); +} +.mcp__actions { + display: flex; + gap: 8px; + align-items: center; +} +.mcp__btn-ghost { + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--muted); + font: inherit; + cursor: pointer; +} +.mcp__status { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 13px; + color: var(--muted); +} +.mcp__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--border); +} +.mcp__status--connected .mcp__dot { + background: #16a34a; +} +.mcp__status--connecting .mcp__dot { + background: #f59e0b; +} +.mcp__status--needs-authorization .mcp__dot { + background: #6366f1; +} +.mcp__status--error .mcp__dot { + background: #dc2626; +} +.mcp__auth { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + font-size: 13px; +} +.mcp__auth p { + margin: 0; + color: var(--muted); +} +.mcp__tools { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.mcp__tool { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; +} +.mcp__tool code { + font-size: 12px; + color: var(--accent); +} +.mcp__tool span { + color: var(--muted); +} +.mcp__hint { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--muted); +} +.mcp__hint code { + font-size: 11px; +} diff --git a/demo/src/components/McpPanel.tsx b/demo/src/components/McpPanel.tsx new file mode 100644 index 0000000..c1d526e --- /dev/null +++ b/demo/src/components/McpPanel.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from 'react' +import type { UseMcpReturn } from '@dudko.dev/agent-web-react' + +export type McpAuthMode = 'none' | 'bearer' | 'oauth' + +export interface McpPanelProps { + mcp: UseMcpReturn +} + +const STORAGE_KEY = 'agent-web-demo:mcp-form' + +interface StoredForm { + url: string + mode: McpAuthMode +} + +// The OAuth flow reloads the page, so the form has to survive it. The bearer +// token deliberately does NOT: it is a credential, and this is a demo — it +// lives in component state for the length of the session only. +const readForm = (): StoredForm => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) return JSON.parse(raw) as StoredForm + } catch { + /* private mode */ + } + return { url: '', mode: 'none' } +} + +const STATUS_TEXT: Record = { + idle: 'Not connected', + connecting: 'Connecting…', + connected: 'Connected', + 'needs-authorization': 'Authorization required', + error: 'Failed', +} + +/** + * Connect the demo to any remote MCP server the visitor names: no auth, a + * bearer token, or the full OAuth 2.1 + dynamic-registration flow. + */ +export const McpPanel = ({ mcp }: McpPanelProps) => { + const [form, setForm] = useState(readForm) + const [token, setToken] = useState('') + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(form)) + } catch { + /* private mode */ + } + }, [form]) + + const busy = mcp.status === 'connecting' || mcp.completingAuthorization + const canConnect = form.url.trim().length > 0 && !busy + + const submit = (e: React.FormEvent) => { + e.preventDefault() + if (!canConnect) return + void mcp.connect({ + url: form.url.trim(), + name: 'mcp', + headers: + form.mode === 'bearer' && token.trim() + ? { Authorization: `Bearer ${token.trim()}` } + : undefined, + oauth: form.mode === 'oauth', + }) + } + + return ( +
+
+ + +
+ {( + [ + ['none', 'No auth'], + ['bearer', 'Bearer token'], + ['oauth', 'OAuth + DCR'], + ] as [McpAuthMode, string][] + ).map(([value, label]) => ( + + ))} +
+ + {form.mode === 'bearer' && ( + + )} + + {form.mode === 'oauth' && ( +

+ No client ID needed: the app registers itself with your server’s authorization server + (RFC 7591), runs PKCE, and refreshes the access token on its own when it expires. Tokens + are stored encrypted in IndexedDB. +

+ )} + +
+ {mcp.status === 'connected' ? ( + + ) : ( + + )} + {form.mode === 'oauth' && ( + + )} +
+
+ +

+

+ + {mcp.status === 'needs-authorization' && ( +
+

This server wants you to sign in before it hands over its tools.

+ +
+ )} + + {mcp.error &&

{mcp.error}

} + + {mcp.catalog.length > 0 && ( +
    + {mcp.catalog.map((t) => ( +
  • + {t.name} + {t.description && {t.description}} +
  • + ))} +
+ )} + +

+ Your browser talks to the server directly, so it must send CORS headers for this origin — + including Access-Control-Expose-Headers: WWW-Authenticate, mcp-session-id, or + the OAuth challenge can’t be read. Nothing is proxied through us; no server sees your token. +

+
+ ) +} diff --git a/package-lock.json b/package-lock.json index 84eed1b..f7fc044 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "license": "MIT", "devDependencies": { "@dudko.dev/agent-web": "^0.0.7", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.9.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", @@ -614,6 +615,19 @@ "node": ">=18" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -653,6 +667,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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==", + "dev": true, + "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/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -1067,6 +1122,20 @@ "node": ">= 20" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1099,6 +1168,41 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1106,6 +1210,45 @@ "dev": true, "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1122,6 +1265,16 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1132,6 +1285,37 @@ "node": ">=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==", + "dev": true, + "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==", + "dev": true, + "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/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1175,6 +1359,83 @@ "node": "^14.18.0 || >=16.10.0" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1200,6 +1461,81 @@ } } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1242,6 +1578,36 @@ "@esbuild/win32-x64": "0.27.7" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", @@ -1252,6 +1618,94 @@ "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==", + "dev": true, + "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.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1270,6 +1724,28 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "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/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1282,6 +1758,26 @@ "rollup": "^4.34.8" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1297,17 +1793,204 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/idb": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", - "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "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==", "dev": true, - "license": "ISC" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", + "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true, "license": "MIT", "engines": { @@ -1321,6 +2004,20 @@ "dev": true, "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1361,6 +2058,70 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -1393,6 +2154,16 @@ "thenify-all": "^1.0.0" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1403,6 +2174,73 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1440,6 +2278,16 @@ "node": ">= 6" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -1511,6 +2359,67 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1548,6 +2457,16 @@ "url": "https://paulmillr.com/funding/" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1603,6 +2522,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1610,6 +2553,159 @@ "dev": true, "license": "MIT" }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "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==", + "dev": true, + "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==", + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1620,6 +2716,16 @@ "node": ">= 12" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -1690,6 +2796,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -1760,6 +2876,39 @@ } } }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1788,6 +2937,49 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -1797,6 +2989,16 @@ "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==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 5c42856..8428459 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ }, "devDependencies": { "@dudko.dev/agent-web": "^0.0.7", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.9.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", diff --git a/src/hooks/use-mcp.ts b/src/hooks/use-mcp.ts new file mode 100644 index 0000000..267a793 --- /dev/null +++ b/src/hooks/use-mcp.ts @@ -0,0 +1,331 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { AgentToolSet } from '@dudko.dev/agent-web' +import type { + BrowserOAuthProvider, + ConnectedMcp, + McpCatalogEntry, + McpModule, + McpServerResult, +} from '../mcp-types.js' + +export type McpStatus = 'idle' | 'connecting' | 'connected' | 'needs-authorization' | 'error' + +export interface McpConnectOptions { + /** The server's StreamableHTTP endpoint. */ + url: string + /** Prefix for the discovered tool names ("__"). Default 'mcp'. */ + name?: string + /** Static headers, e.g. `{ Authorization: 'Bearer …' }`. Ignored when `oauth` is set. */ + headers?: Record + /** + * Authenticate with OAuth 2.1 + dynamic client registration. Requires a core + * that ships the OAuth provider; check `oauthSupported` before offering it. + */ + oauth?: boolean | { redirectUrl?: string; scope?: string; clientName?: string } +} + +export interface UseMcpOptions { + /** Client name reported to the MCP server during initialize. */ + clientName?: string + /** Called with connector log lines — handy while debugging a server. */ + onLog?: (level: string, message: string) => void +} + +export interface UseMcpReturn { + status: McpStatus + /** Merge into `BrowserAgentConfig.tools` once `status === 'connected'`. */ + tools?: AgentToolSet + catalog: McpCatalogEntry[] + error?: string + /** Where to send the user when `status === 'needs-authorization'`. */ + authorizationUrl?: string + /** + * Whether the installed core exposes the OAuth API. `undefined` until the + * optional subpath has been loaded once (first `connect`, or a callback). + */ + oauthSupported?: boolean + /** True while an OAuth redirect is being finished on mount. */ + completingAuthorization: boolean + connect: (options: McpConnectOptions) => Promise + disconnect: () => Promise + /** Navigate to the authorization server. Call it from a user gesture. */ + authorize: () => void + /** Drop the stored tokens and dynamic registration for the current server. */ + forgetAuthorization: () => Promise +} + +const OAUTH_UNSUPPORTED = + 'The installed @dudko.dev/agent-web has no MCP OAuth support. Upgrade the core to a version exporting BrowserOAuthProvider, or use a static Authorization header.' + +const STORAGE_KEY = 'agent-web-react:mcp:pending' + +interface PendingConnect { + url: string + name?: string + redirectUrl?: string + scope?: string + clientName?: string +} + +let modulePromise: Promise | undefined + +/** + * Load the core's optional `./mcp` subpath. Kept dynamic so apps that never + * touch MCP don't pull `@modelcontextprotocol/sdk` into their bundle, and so a + * core without the OAuth half degrades to a clear message instead of a crash. + */ +const loadMcp = (): Promise => { + modulePromise ??= import('@dudko.dev/agent-web/mcp').then((m) => m as unknown as McpModule) + return modulePromise +} + +/** Map a single server's connect outcome onto the hook's status. Pure. */ +export const describeMcpResult = ( + result: McpServerResult | undefined, +): { status: McpStatus; error?: string } => { + if (!result) return { status: 'error', error: 'The connector returned no result for the server' } + if (result.connected) return { status: 'connected' } + if (result.needsAuthorization) return { status: 'needs-authorization' } + return { status: 'error', error: result.error ?? 'Could not connect to the MCP server' } +} + +/** + * Remove the OAuth response parameters from a URL, leaving the rest intact. + * The code is single-use and lands in history, referrers and screenshots — + * clear it as soon as it has been exchanged. + */ +export const stripOAuthParams = (href: string): string => { + const url = new URL(href) + for (const key of ['code', 'state', 'error', 'error_description', 'iss']) { + url.searchParams.delete(key) + } + return url.toString() +} + +const looksLikeCallback = (): boolean => { + if (typeof globalThis.location === 'undefined') return false + const params = new URLSearchParams(globalThis.location.search) + return params.has('code') || params.has('error') +} + +const readPending = (): PendingConnect | undefined => { + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as PendingConnect) : undefined + } catch { + return undefined + } +} + +const writePending = (value: PendingConnect | undefined): void => { + try { + if (value) globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(value)) + else globalThis.localStorage?.removeItem(STORAGE_KEY) + } catch { + // Private mode / storage disabled: the OAuth round-trip simply won't + // resume automatically, which the UI already handles. + } +} + +// A redirect_uri must not carry a fragment (RFC 6749 §3.1.2), and it has to +// match the registered value byte-for-byte, so query and hash are dropped. +const defaultRedirectUrl = (): string => + typeof globalThis.location === 'undefined' + ? '' + : `${globalThis.location.origin}${globalThis.location.pathname}` + +/** + * Connect the browser agent to a remote MCP server the user names at runtime — + * static header, or full OAuth 2.1 with dynamic client registration. + * + * The OAuth round-trip leaves the page, so the hook persists what it needs to + * resume: on mount it detects `?code=…`, finishes the exchange, cleans the URL + * and reconnects, all before the app renders anything MCP-related. + * + * ```tsx + * const mcp = useMcp() + * // await mcp.connect({ url, oauth: true }) + * // mcp.status === 'needs-authorization' && + * // + * ``` + */ +export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { + const [status, setStatus] = useState('idle') + const [tools, setTools] = useState(undefined) + const [catalog, setCatalog] = useState([]) + const [error, setError] = useState(undefined) + const [authorizationUrl, setAuthorizationUrl] = useState(undefined) + const [oauthSupported, setOauthSupported] = useState(undefined) + const [completingAuthorization, setCompletingAuthorization] = useState(looksLikeCallback) + + const connectionRef = useRef(undefined) + const providerRef = useRef(undefined) + // Options land in a ref so `connect` keeps a stable identity: it is a natural + // dependency of effects in host components. + const optionsRef = useRef(options) + optionsRef.current = options + + const closeConnection = useCallback(async () => { + const open = connectionRef.current + connectionRef.current = undefined + if (open) await open.close().catch(() => {}) + }, []) + + const connect = useCallback( + async (opts: McpConnectOptions) => { + await closeConnection() + setStatus('connecting') + setError(undefined) + setAuthorizationUrl(undefined) + setTools(undefined) + setCatalog([]) + + try { + const mod = await loadMcp() + setOauthSupported(typeof mod.BrowserOAuthProvider === 'function') + const name = opts.name ?? 'mcp' + let authProvider: BrowserOAuthProvider | undefined + + if (opts.oauth) { + if (!mod.BrowserOAuthProvider) throw new Error(OAUTH_UNSUPPORTED) + const oauth = typeof opts.oauth === 'object' ? opts.oauth : {} + const redirectUrl = oauth.redirectUrl ?? defaultRedirectUrl() + authProvider = new mod.BrowserOAuthProvider({ + serverUrl: opts.url, + redirectUrl, + clientName: oauth.clientName ?? optionsRef.current.clientName, + scope: oauth.scope, + }) + providerRef.current = authProvider + // Remember enough to rebuild this provider after the redirect: the + // page will be reloaded from scratch when the user comes back. + writePending({ + url: opts.url, + name, + redirectUrl, + scope: oauth.scope, + clientName: oauth.clientName ?? optionsRef.current.clientName, + }) + } else { + providerRef.current = undefined + } + + const connection = await mod.connectMcpHttp( + { + [name]: { + url: opts.url, + headers: opts.oauth ? undefined : opts.headers, + authProvider, + }, + }, + { clientName: optionsRef.current.clientName, onLog: optionsRef.current.onLog }, + ) + + const outcome = describeMcpResult(connection.results[0]) + if (outcome.status !== 'connected') { + await connection.close().catch(() => {}) + if (outcome.status === 'needs-authorization') { + const url = providerRef.current?.authorizationUrl + setAuthorizationUrl(url ? String(url) : undefined) + setStatus('needs-authorization') + return + } + throw new Error(outcome.error) + } + + connectionRef.current = connection + setTools(connection.tools) + setCatalog(connection.catalog) + setStatus('connected') + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setStatus('error') + } + }, + [closeConnection], + ) + + const disconnect = useCallback(async () => { + await closeConnection() + setTools(undefined) + setCatalog([]) + setAuthorizationUrl(undefined) + setError(undefined) + setStatus('idle') + }, [closeConnection]) + + const authorize = useCallback(() => { + if (authorizationUrl && typeof globalThis.location !== 'undefined') { + globalThis.location.href = authorizationUrl + } + }, [authorizationUrl]) + + const forgetAuthorization = useCallback(async () => { + writePending(undefined) + await providerRef.current?.reset().catch(() => {}) + providerRef.current = undefined + await disconnect() + }, [disconnect]) + + // Resume an OAuth round-trip: the authorization server has just sent the user + // back with ?code=… and this component is mounting for the first time. + useEffect(() => { + if (!looksLikeCallback()) { + setCompletingAuthorization(false) + return + } + let cancelled = false + void (async () => { + const pending = readPending() + try { + const mod = await loadMcp() + setOauthSupported(typeof mod.BrowserOAuthProvider === 'function') + const callback = mod.readOAuthCallback?.() + if (!callback || !pending || !mod.BrowserOAuthProvider || !mod.finishMcpOAuth) return + const provider = new mod.BrowserOAuthProvider({ + serverUrl: pending.url, + redirectUrl: pending.redirectUrl ?? defaultRedirectUrl(), + clientName: pending.clientName, + scope: pending.scope, + }) + providerRef.current = provider + await mod.finishMcpOAuth(provider, callback) + if (cancelled) return + await connect({ url: pending.url, name: pending.name, oauth: true }) + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)) + setStatus('error') + } + } finally { + writePending(undefined) + // Drop the one-time code from the address bar whatever happened, so a + // reload can't replay a spent (or failed) authorization. + if (typeof globalThis.history !== 'undefined') { + globalThis.history.replaceState(null, '', stripOAuthParams(globalThis.location.href)) + } + if (!cancelled) setCompletingAuthorization(false) + } + })() + return () => { + cancelled = true + } + // Runs once: `connect` is stable and the callback exists only on first load. + }, [connect]) + + useEffect(() => () => void closeConnection(), [closeConnection]) + + return { + status, + tools, + catalog, + error, + authorizationUrl, + oauthSupported, + completingAuthorization, + connect, + disconnect, + authorize, + forgetAuthorization, + } +} diff --git a/src/index.ts b/src/index.ts index 5e7f771..db2c59d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,9 @@ export { useAgent } from './hooks/use-agent.js' export type { UseAgentOptions, UseAgentReturn } from './hooks/use-agent.js' export { useCredentials } from './hooks/use-credentials.js' export type { UseCredentialsReturn } from './hooks/use-credentials.js' +export { useMcp, describeMcpResult, stripOAuthParams } from './hooks/use-mcp.js' +export type { McpConnectOptions, McpStatus, UseMcpOptions, UseMcpReturn } from './hooks/use-mcp.js' +export type { ConnectedMcp, McpCatalogEntry, McpServerResult, McpModule } from './mcp-types.js' export { useWebLLMModel } from './hooks/use-webllm-model.js' export type { UseWebLLMModelReturn, diff --git a/src/mcp-types.ts b/src/mcp-types.ts new file mode 100644 index 0000000..64dd0a7 --- /dev/null +++ b/src/mcp-types.ts @@ -0,0 +1,71 @@ +import type { AgentToolSet } from '@dudko.dev/agent-web' + +/** + * Structural types for the core's OPTIONAL `@dudko.dev/agent-web/mcp` subpath. + * + * The subpath is loaded with a dynamic import and typed here rather than + * imported, for two reasons: it drags in `@modelcontextprotocol/sdk` (an + * optional peer of the core, which apps without MCP must not have to install), + * and the OAuth half only exists in newer cores while this package supports + * `@dudko.dev/agent-web >= 0.0.6`. `useMcp` feature-detects at runtime and + * reports `oauthSupported: false` on an older core instead of crashing. + */ + +export interface McpCatalogEntry { + name: string + description: string + server: string +} + +export interface McpServerResult { + name: string + connected: boolean + error?: string + needsAuthorization?: boolean +} + +export interface ConnectedMcp { + tools: AgentToolSet + catalog: McpCatalogEntry[] + results: McpServerResult[] + refreshServer?: (name: string) => Promise + close: () => Promise +} + +export interface McpHttpServerConfig { + url: string + headers?: Record + authProvider?: unknown +} + +export interface McpOAuthCallback { + code?: string + state?: string + error?: string + errorDescription?: string +} + +export interface BrowserOAuthProvider { + serverUrl: string + authorizationUrl?: URL + isAuthorized(): Promise + reset(): Promise +} + +export interface BrowserOAuthProviderOptions { + serverUrl: string + redirectUrl: string + clientName?: string + scope?: string +} + +/** The shape of the subpath module, as far as this package uses it. */ +export interface McpModule { + connectMcpHttp: ( + servers: Record, + opts?: { clientName?: string; onLog?: (level: string, message: string) => void }, + ) => Promise + BrowserOAuthProvider?: new (opts: BrowserOAuthProviderOptions) => BrowserOAuthProvider + readOAuthCallback?: (input?: string | URL) => McpOAuthCallback | undefined + finishMcpOAuth?: (provider: BrowserOAuthProvider, callback: McpOAuthCallback) => Promise +} diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts new file mode 100644 index 0000000..690fc9a --- /dev/null +++ b/tests/mcp.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { describeMcpResult, stripOAuthParams } from '../dist/index.js' + +// The connect/OAuth machinery lives in the core and is covered there; what this +// package owns is the mapping from a connect outcome to UI state, and the URL +// hygiene around the redirect. Both are pure, so they are tested without React. + +test('describeMcpResult: a connected server yields the connected status', () => { + assert.deepEqual(describeMcpResult({ name: 'mcp', connected: true }), { status: 'connected' }) +}) + +test('describeMcpResult: needsAuthorization outranks the accompanying error text', () => { + assert.deepEqual( + describeMcpResult({ + name: 'mcp', + connected: false, + needsAuthorization: true, + error: 'Unauthorized', + }), + { status: 'needs-authorization' }, + ) +}) + +test('describeMcpResult: a plain failure carries the server error through', () => { + assert.deepEqual(describeMcpResult({ name: 'mcp', connected: false, error: 'DNS exploded' }), { + status: 'error', + error: 'DNS exploded', + }) +}) + +test('describeMcpResult: a failure with no message still gets a readable one', () => { + const { status, error } = describeMcpResult({ name: 'mcp', connected: false }) + assert.equal(status, 'error') + assert.match(error ?? '', /Could not connect/) +}) + +test('describeMcpResult: a missing result is an error, not a crash', () => { + const { status, error } = describeMcpResult(undefined) + assert.equal(status, 'error') + assert.match(error ?? '', /no result/) +}) + +test('stripOAuthParams: drops the single-use response params, keeps everything else', () => { + assert.equal( + stripOAuthParams('https://app.test/demo/?code=abc&state=xyz&iss=as&view=mcp#/panel'), + 'https://app.test/demo/?view=mcp#/panel', + ) +}) + +test('stripOAuthParams: drops an error response too', () => { + assert.equal( + stripOAuthParams('https://app.test/?error=access_denied&error_description=nope'), + 'https://app.test/', + ) +}) + +test('stripOAuthParams: leaves a clean URL untouched', () => { + assert.equal(stripOAuthParams('https://app.test/demo/'), 'https://app.test/demo/') +}) From 36d2671bb4d4525c721aa53e8fa2a38f5645dc6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 14:47:48 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix(mcp):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?capture=20the=20OAuth=20callback=20before=20the=20first=20await?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline bug was deterministic, not a race: useMcp read the callback after `await loadMcp()`, and React flushes the rest of the app's passive effects in that gap. The demo's own view effect rewrote location without the query string, so by the time the hook looked, `?code=` was gone — status stayed idle, no error, and pressing Connect restarted the same loop forever. The callback is now read and claimed synchronously, before any await, and the URL is cleaned as soon as the code is in hand rather than after the reconnect. The claim is module-level and keyed by the code, because StrictMode mounts every effect twice in development: both passes used to reach the token endpoint, and since the core's state check is single-use, the second reported "state mismatch (possible CSRF)" on a perfectly good authorization. Also from the review: - a hung handshake left completingAuthorization true forever, which disabled Connect permanently with no error and no way out but a reload that replayed a spent code. The flag is cleared before the reconnect, which owns the status from there. - the callback params were stripped even when the callback belonged to another sign-in flow on the same page. Only ours is touched now. - "Forget authorization" was a no-op after a reload (no live provider), so tokens the user believed revoked stayed in the vault and the next Connect silently reused them. The provider is rebuilt from a persisted record. - connect() had no epoch guard: unmounting or reconnecting mid-handshake orphaned an open MCP session. It now closes anything that arrives late. - a mid-session authorization failure left the panel reporting "connected" while every tool call failed; tool calls are wrapped so an UnauthorizedError flips the UI back to needs-authorization. - a rejected dynamic import was cached forever, killing MCP for the tab. - the resume path failed silently in three distinct ways. - oauthSupported was only resolved inside connect, so the panel offered OAuth on cores that cannot do it; checkOAuthSupport() resolves it when the user picks the mode, and the panel says so. - fragment-carried callbacks are detected and stripped, matching the core. - the demo stops the other panel's agent on a view switch, so one local WebGPU engine never runs two generations at once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur --- demo/src/App.tsx | 14 +- demo/src/components/McpPanel.tsx | 32 +++- src/hooks/use-mcp.ts | 285 +++++++++++++++++++++++++------ src/index.ts | 8 +- tests/mcp.test.ts | 55 +++++- 5 files changed, 331 insertions(+), 63 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index e0848bf..39952db 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -107,7 +107,10 @@ export const App = () => { useEffect(() => { const next = view === 'mcp' ? '#/mcp' : '' if (window.location.hash !== next) { - window.history.replaceState(null, '', `${window.location.pathname}${next}`) + // Keep the query string: useMcp reads (and clears) the OAuth callback + // params from it, and this effect runs while that is still in flight. + const { pathname, search } = window.location + window.history.replaceState(null, '', `${pathname}${search}${next}`) } }, [view]) @@ -125,6 +128,15 @@ export const App = () => { const mcpAgent = useAgent(mcpConfig, { deps: [modelId, resolvedModel, mcp.tools] }) + // One model, two agents: stop whichever panel the user just left, so a local + // WebGPU engine never has two generations running against it at once. + const stopNotes = agent.stop + const stopMcp = mcpAgent.stop + useEffect(() => { + if (view === 'mcp') stopNotes() + else stopMcp() + }, [view, stopNotes, stopMcp]) + return (
diff --git a/demo/src/components/McpPanel.tsx b/demo/src/components/McpPanel.tsx index c1d526e..c5b7dad 100644 --- a/demo/src/components/McpPanel.tsx +++ b/demo/src/components/McpPanel.tsx @@ -51,8 +51,18 @@ export const McpPanel = ({ mcp }: McpPanelProps) => { } }, [form]) + // Resolve OAuth support the moment the user reaches for it, not after they + // have filled the form and pressed Connect. It loads the core's optional + // subpath, so it stays lazy. + const check = mcp.checkOAuthSupport + useEffect(() => { + if (form.mode === 'oauth') void check() + }, [form.mode, check]) + + const oauthUnavailable = form.mode === 'oauth' && mcp.oauthSupported === false + const busy = mcp.status === 'connecting' || mcp.completingAuthorization - const canConnect = form.url.trim().length > 0 && !busy + const canConnect = form.url.trim().length > 0 && !busy && !oauthUnavailable const submit = (e: React.FormEvent) => { e.preventDefault() @@ -121,13 +131,19 @@ export const McpPanel = ({ mcp }: McpPanelProps) => { )} - {form.mode === 'oauth' && ( -

- No client ID needed: the app registers itself with your server’s authorization server - (RFC 7591), runs PKCE, and refreshes the access token on its own when it expires. Tokens - are stored encrypted in IndexedDB. -

- )} + {form.mode === 'oauth' && + (oauthUnavailable ? ( +

+ This build of @dudko.dev/agent-web has no MCP OAuth support — upgrade the + core, or use a bearer token here. +

+ ) : ( +

+ No client ID needed: the app registers itself with your server’s authorization server + (RFC 7591), runs PKCE, and refreshes the access token on its own when it expires. + Tokens are stored encrypted in IndexedDB. +

+ ))}
{mcp.status === 'connected' ? ( diff --git a/src/hooks/use-mcp.ts b/src/hooks/use-mcp.ts index 267a793..754c5ac 100644 --- a/src/hooks/use-mcp.ts +++ b/src/hooks/use-mcp.ts @@ -5,6 +5,7 @@ import type { ConnectedMcp, McpCatalogEntry, McpModule, + McpOAuthCallback, McpServerResult, } from '../mcp-types.js' @@ -41,7 +42,8 @@ export interface UseMcpReturn { authorizationUrl?: string /** * Whether the installed core exposes the OAuth API. `undefined` until the - * optional subpath has been loaded once (first `connect`, or a callback). + * optional subpath has been loaded once — call `checkOAuthSupport()` to + * resolve it before offering OAuth in your UI. */ oauthSupported?: boolean /** True while an OAuth redirect is being finished on mount. */ @@ -52,17 +54,20 @@ export interface UseMcpReturn { authorize: () => void /** Drop the stored tokens and dynamic registration for the current server. */ forgetAuthorization: () => Promise + /** Load the optional subpath and report whether it can do OAuth. */ + checkOAuthSupport: () => Promise } const OAUTH_UNSUPPORTED = 'The installed @dudko.dev/agent-web has no MCP OAuth support. Upgrade the core to a version exporting BrowserOAuthProvider, or use a static Authorization header.' -const STORAGE_KEY = 'agent-web-react:mcp:pending' +const PENDING_KEY = 'agent-web-react:mcp:pending' +const OAUTH_KEY = 'agent-web-react:mcp:oauth' -interface PendingConnect { +interface OAuthRecord { url: string name?: string - redirectUrl?: string + redirectUrl: string scope?: string clientName?: string } @@ -75,7 +80,14 @@ let modulePromise: Promise | undefined * core without the OAuth half degrades to a clear message instead of a crash. */ const loadMcp = (): Promise => { - modulePromise ??= import('@dudko.dev/agent-web/mcp').then((m) => m as unknown as McpModule) + modulePromise ??= import('@dudko.dev/agent-web/mcp') + .then((m) => m as unknown as McpModule) + .catch((err: unknown) => { + // A rejected promise is neither null nor undefined, so ??= would cache + // the failure forever — one flaky chunk fetch would kill MCP for the tab. + modulePromise = undefined + throw err + }) return modulePromise } @@ -89,38 +101,94 @@ export const describeMcpResult = ( return { status: 'error', error: result.error ?? 'Could not connect to the MCP server' } } +const OAUTH_PARAMS = ['code', 'state', 'error', 'error_description', 'iss'] + +/** + * Read the authorization-code parameters out of a URL **synchronously**. + * + * The core exports an equivalent, but it lives behind a dynamic import: by the + * time that import resolves, React has flushed the rest of the app's effects, + * and any one of them may have rewritten `location` (the demo's own view + * router did exactly that). The callback has to be captured before the first + * await, so this parser is duplicated here on purpose. + * + * Reads the query string and, for hash-routed apps, the fragment. Returns + * undefined when neither a `code` nor an `error` is present. + */ +export const readCallbackParams = (href?: string): McpOAuthCallback | undefined => { + const target = + href ?? (typeof globalThis.location !== 'undefined' ? globalThis.location.href : undefined) + if (!target) return undefined + let url: URL + try { + url = new URL(target) + } catch { + return undefined + } + const params = new URLSearchParams(url.search) + const hash = url.hash.startsWith('#') ? url.hash.slice(1) : url.hash + const q = hash.indexOf('?') + if (q >= 0) { + for (const [k, v] of new URLSearchParams(hash.slice(q + 1))) { + if (!params.has(k)) params.set(k, v) + } + } + const code = params.get('code') ?? undefined + const error = params.get('error') ?? undefined + if (!code && !error) return undefined + return { + code, + state: params.get('state') ?? undefined, + error, + errorDescription: params.get('error_description') ?? undefined, + } +} + /** - * Remove the OAuth response parameters from a URL, leaving the rest intact. - * The code is single-use and lands in history, referrers and screenshots — - * clear it as soon as it has been exchanged. + * Remove the OAuth response parameters from a URL, leaving the rest intact — + * query string and fragment alike. The code is single-use and lands in history, + * referrers and screenshots, so it goes as soon as it has been read. */ export const stripOAuthParams = (href: string): string => { const url = new URL(href) - for (const key of ['code', 'state', 'error', 'error_description', 'iss']) { - url.searchParams.delete(key) + for (const key of OAUTH_PARAMS) url.searchParams.delete(key) + const hash = url.hash.startsWith('#') ? url.hash.slice(1) : url.hash + const q = hash.indexOf('?') + if (q >= 0) { + const params = new URLSearchParams(hash.slice(q + 1)) + for (const key of OAUTH_PARAMS) params.delete(key) + const rest = params.toString() + url.hash = rest ? `${hash.slice(0, q)}?${rest}` : hash.slice(0, q) } return url.toString() } -const looksLikeCallback = (): boolean => { - if (typeof globalThis.location === 'undefined') return false - const params = new URLSearchParams(globalThis.location.search) - return params.has('code') || params.has('error') +// An authorization code is single-use, and React StrictMode mounts every effect +// twice in development. Claiming happens synchronously, before any await, so +// the second pass cannot race the first into the token endpoint (where it would +// lose the state check and report a CSRF failure on a perfectly good flow). +const claimedCallbacks = new Set() + +export const claimOAuthCallback = (callback: McpOAuthCallback): boolean => { + const key = `${callback.code ?? ''}|${callback.state ?? ''}|${callback.error ?? ''}` + if (claimedCallbacks.has(key)) return false + claimedCallbacks.add(key) + return true } -const readPending = (): PendingConnect | undefined => { +const readRecord = (key: string): T | undefined => { try { - const raw = globalThis.localStorage?.getItem(STORAGE_KEY) - return raw ? (JSON.parse(raw) as PendingConnect) : undefined + const raw = globalThis.localStorage?.getItem(key) + return raw ? (JSON.parse(raw) as T) : undefined } catch { return undefined } } -const writePending = (value: PendingConnect | undefined): void => { +const writeRecord = (key: string, value: unknown): void => { try { - if (value) globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(value)) - else globalThis.localStorage?.removeItem(STORAGE_KEY) + if (value) globalThis.localStorage?.setItem(key, JSON.stringify(value)) + else globalThis.localStorage?.removeItem(key) } catch { // Private mode / storage disabled: the OAuth round-trip simply won't // resume automatically, which the UI already handles. @@ -134,6 +202,41 @@ const defaultRedirectUrl = (): string => ? '' : `${globalThis.location.origin}${globalThis.location.pathname}` +// The MCP SDK throws this when a server demands authorization we don't have. +// Matching on the name (not the message) keeps a tool that merely mentions +// "401" in its output from flipping the UI into an auth prompt. +const isUnauthorized = (err: unknown): boolean => + err instanceof Error && err.name === 'UnauthorizedError' + +/** + * Wrap each tool so a mid-session authorization failure is visible. Without + * this the panel keeps reporting "connected" while every call fails: the core + * refreshes silently on a 401, but once the refresh token is gone (revoked, + * expired) it can only ask for a new authorization. + */ +const watchAuthorization = (tools: AgentToolSet, onUnauthorized: () => void): AgentToolSet => { + const entries = Object.entries(tools as Record).map(([name, value]) => { + const tool = value as { execute?: (args: unknown, options: unknown) => Promise } + if (typeof tool.execute !== 'function') return [name, value] + const execute = tool.execute.bind(tool) + return [ + name, + { + ...(value as object), + execute: async (args: unknown, options: unknown) => { + try { + return await execute(args, options) + } catch (err) { + if (isUnauthorized(err)) onUnauthorized() + throw err + } + }, + }, + ] + }) + return Object.fromEntries(entries) as AgentToolSet +} + /** * Connect the browser agent to a remote MCP server the user names at runtime — * static header, or full OAuth 2.1 with dynamic client registration. @@ -156,10 +259,16 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { const [error, setError] = useState(undefined) const [authorizationUrl, setAuthorizationUrl] = useState(undefined) const [oauthSupported, setOauthSupported] = useState(undefined) - const [completingAuthorization, setCompletingAuthorization] = useState(looksLikeCallback) + const [completingAuthorization, setCompletingAuthorization] = useState( + () => readCallbackParams() !== undefined, + ) const connectionRef = useRef(undefined) const providerRef = useRef(undefined) + // Bumped by every connect / disconnect / unmount. A handshake that finishes + // after its epoch has passed closes itself instead of writing state or + // leaking an open MCP session onto a dead component. + const epochRef = useRef(0) // Options land in a ref so `connect` keeps a stable identity: it is a natural // dependency of effects in host components. const optionsRef = useRef(options) @@ -171,9 +280,24 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { if (open) await open.close().catch(() => {}) }, []) + const checkOAuthSupport = useCallback(async () => { + try { + const mod = await loadMcp() + const supported = typeof mod.BrowserOAuthProvider === 'function' + setOauthSupported(supported) + return supported + } catch { + setOauthSupported(false) + return false + } + }, []) + const connect = useCallback( async (opts: McpConnectOptions) => { + const epoch = ++epochRef.current + const current = () => epochRef.current === epoch await closeConnection() + if (!current()) return setStatus('connecting') setError(undefined) setAuthorizationUrl(undefined) @@ -189,23 +313,24 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { if (opts.oauth) { if (!mod.BrowserOAuthProvider) throw new Error(OAUTH_UNSUPPORTED) const oauth = typeof opts.oauth === 'object' ? opts.oauth : {} - const redirectUrl = oauth.redirectUrl ?? defaultRedirectUrl() - authProvider = new mod.BrowserOAuthProvider({ - serverUrl: opts.url, - redirectUrl, - clientName: oauth.clientName ?? optionsRef.current.clientName, - scope: oauth.scope, - }) - providerRef.current = authProvider - // Remember enough to rebuild this provider after the redirect: the - // page will be reloaded from scratch when the user comes back. - writePending({ + const record: OAuthRecord = { url: opts.url, name, - redirectUrl, + redirectUrl: oauth.redirectUrl ?? defaultRedirectUrl(), scope: oauth.scope, clientName: oauth.clientName ?? optionsRef.current.clientName, + } + authProvider = new mod.BrowserOAuthProvider({ + serverUrl: record.url, + redirectUrl: record.redirectUrl, + clientName: record.clientName, + scope: record.scope, }) + providerRef.current = authProvider + // `pending` resumes the redirect; `oauth` outlives it so tokens stay + // addressable for forgetAuthorization() after a reload. + writeRecord(PENDING_KEY, record) + writeRecord(OAUTH_KEY, record) } else { providerRef.current = undefined } @@ -221,11 +346,17 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { { clientName: optionsRef.current.clientName, onLog: optionsRef.current.onLog }, ) + if (!current()) { + // Unmounted, or another connect started while we were shaking hands. + await connection.close().catch(() => {}) + return + } + const outcome = describeMcpResult(connection.results[0]) if (outcome.status !== 'connected') { await connection.close().catch(() => {}) if (outcome.status === 'needs-authorization') { - const url = providerRef.current?.authorizationUrl + const url = authProvider?.authorizationUrl setAuthorizationUrl(url ? String(url) : undefined) setStatus('needs-authorization') return @@ -234,10 +365,18 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { } connectionRef.current = connection - setTools(connection.tools) + setTools( + watchAuthorization(connection.tools, () => { + if (!current()) return + const url = providerRef.current?.authorizationUrl + if (url) setAuthorizationUrl(String(url)) + setStatus('needs-authorization') + }), + ) setCatalog(connection.catalog) setStatus('connected') } catch (err) { + if (!current()) return setError(err instanceof Error ? err.message : String(err)) setStatus('error') } @@ -246,6 +385,7 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { ) const disconnect = useCallback(async () => { + epochRef.current++ await closeConnection() setTools(undefined) setCatalog([]) @@ -261,8 +401,25 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { }, [authorizationUrl]) const forgetAuthorization = useCallback(async () => { - writePending(undefined) - await providerRef.current?.reset().catch(() => {}) + const record = readRecord(OAUTH_KEY) + writeRecord(PENDING_KEY, undefined) + writeRecord(OAUTH_KEY, undefined) + // After a reload there is no live provider, but the tokens are still in the + // vault — rebuild one for the same server so "forget" actually forgets + // rather than only resetting the UI. + let provider = providerRef.current + if (!provider && record) { + const mod = await loadMcp().catch(() => undefined) + if (mod?.BrowserOAuthProvider) { + provider = new mod.BrowserOAuthProvider({ + serverUrl: record.url, + redirectUrl: record.redirectUrl, + clientName: record.clientName, + scope: record.scope, + }) + } + } + await provider?.reset().catch(() => {}) providerRef.current = undefined await disconnect() }, [disconnect]) @@ -270,18 +427,37 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { // Resume an OAuth round-trip: the authorization server has just sent the user // back with ?code=… and this component is mounting for the first time. useEffect(() => { - if (!looksLikeCallback()) { + // Everything up to the first await runs synchronously, on purpose: React + // flushes the remaining passive effects (which may rewrite location) before + // an awaited continuation resumes, and StrictMode runs this effect twice. + const callback = readCallbackParams() + if (!callback) { + setCompletingAuthorization(false) + return + } + const pending = readRecord(PENDING_KEY) + if (!pending) { + // Not our callback — another sign-in flow on this page may still need + // those parameters, so leave the URL exactly as we found it. + setCompletingAuthorization(false) + return + } + if (!claimOAuthCallback(callback)) { setCompletingAuthorization(false) return } + // Ours, and claimed: the code is in hand, so clear it from the address bar + // now rather than after the reconnect. + if (typeof globalThis.history !== 'undefined') { + globalThis.history.replaceState(null, '', stripOAuthParams(globalThis.location.href)) + } + let cancelled = false void (async () => { - const pending = readPending() try { const mod = await loadMcp() setOauthSupported(typeof mod.BrowserOAuthProvider === 'function') - const callback = mod.readOAuthCallback?.() - if (!callback || !pending || !mod.BrowserOAuthProvider || !mod.finishMcpOAuth) return + if (!mod.BrowserOAuthProvider || !mod.finishMcpOAuth) throw new Error(OAUTH_UNSUPPORTED) const provider = new mod.BrowserOAuthProvider({ serverUrl: pending.url, redirectUrl: pending.redirectUrl ?? defaultRedirectUrl(), @@ -290,20 +466,18 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { }) providerRef.current = provider await mod.finishMcpOAuth(provider, callback) + writeRecord(PENDING_KEY, undefined) if (cancelled) return + // Hand back to `connect`, which owns the status from here — a slow or + // hung handshake must not leave the UI stuck on "finishing". + setCompletingAuthorization(false) await connect({ url: pending.url, name: pending.name, oauth: true }) } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)) - setStatus('error') - } + writeRecord(PENDING_KEY, undefined) + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + setStatus('error') } finally { - writePending(undefined) - // Drop the one-time code from the address bar whatever happened, so a - // reload can't replay a spent (or failed) authorization. - if (typeof globalThis.history !== 'undefined') { - globalThis.history.replaceState(null, '', stripOAuthParams(globalThis.location.href)) - } if (!cancelled) setCompletingAuthorization(false) } })() @@ -313,7 +487,13 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { // Runs once: `connect` is stable and the callback exists only on first load. }, [connect]) - useEffect(() => () => void closeConnection(), [closeConnection]) + useEffect( + () => () => { + epochRef.current++ + void closeConnection() + }, + [closeConnection], + ) return { status, @@ -327,5 +507,6 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { disconnect, authorize, forgetAuthorization, + checkOAuthSupport, } } diff --git a/src/index.ts b/src/index.ts index db2c59d..43236d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,13 @@ export { useAgent } from './hooks/use-agent.js' export type { UseAgentOptions, UseAgentReturn } from './hooks/use-agent.js' export { useCredentials } from './hooks/use-credentials.js' export type { UseCredentialsReturn } from './hooks/use-credentials.js' -export { useMcp, describeMcpResult, stripOAuthParams } from './hooks/use-mcp.js' +export { + useMcp, + claimOAuthCallback, + describeMcpResult, + readCallbackParams, + stripOAuthParams, +} from './hooks/use-mcp.js' export type { McpConnectOptions, McpStatus, UseMcpOptions, UseMcpReturn } from './hooks/use-mcp.js' export type { ConnectedMcp, McpCatalogEntry, McpServerResult, McpModule } from './mcp-types.js' export { useWebLLMModel } from './hooks/use-webllm-model.js' diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 690fc9a..41e26ec 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { describeMcpResult, stripOAuthParams } from '../dist/index.js' +import { + claimOAuthCallback, + describeMcpResult, + readCallbackParams, + stripOAuthParams, +} from '../dist/index.js' // The connect/OAuth machinery lives in the core and is covered there; what this // package owns is the mapping from a connect outcome to UI state, and the URL @@ -58,3 +63,51 @@ test('stripOAuthParams: drops an error response too', () => { test('stripOAuthParams: leaves a clean URL untouched', () => { assert.equal(stripOAuthParams('https://app.test/demo/'), 'https://app.test/demo/') }) + +// The callback has to be read synchronously, before the hook's first await: +// React flushes the rest of the app's effects in between, and one of them may +// rewrite location (the demo's own view router did exactly that). + +test('readCallbackParams: reads a query-string callback', () => { + assert.deepEqual(readCallbackParams('https://app.test/demo/?code=abc&state=xyz'), { + code: 'abc', + state: 'xyz', + error: undefined, + errorDescription: undefined, + }) +}) + +test('readCallbackParams: reads a callback carried in a hash route', () => { + assert.deepEqual(readCallbackParams('https://app.test/#/cb?code=abc&state=xyz'), { + code: 'abc', + state: 'xyz', + error: undefined, + errorDescription: undefined, + }) +}) + +test('readCallbackParams: reads a denial, and ignores a URL carrying neither', () => { + const denied = readCallbackParams('https://app.test/?error=access_denied&error_description=nope') + assert.equal(denied?.error, 'access_denied') + assert.equal(denied?.errorDescription, 'nope') + assert.equal(readCallbackParams('https://app.test/demo/?view=mcp'), undefined) + assert.equal(readCallbackParams('not a url'), undefined) +}) + +test('stripOAuthParams: clears a callback carried in the fragment too', () => { + assert.equal( + stripOAuthParams('https://app.test/#/cb?code=abc&state=xyz&tab=tools'), + 'https://app.test/#/cb?tab=tools', + ) + assert.equal(stripOAuthParams('https://app.test/#/cb?code=abc'), 'https://app.test/#/cb') +}) + +test('claimOAuthCallback: a code can only be claimed once', () => { + const callback = { code: 'single-use', state: 'st' } + // StrictMode mounts every effect twice in development; the second pass must + // not race the first into the token endpoint, where it would lose the + // single-use state check and report a CSRF failure on a good flow. + assert.equal(claimOAuthCallback(callback), true) + assert.equal(claimOAuthCallback({ ...callback }), false) + assert.equal(claimOAuthCallback({ code: 'another', state: 'st' }), true) +}) From e97ce24b31ad415cb28dcabb1b2033e814297b4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:07:11 +0000 Subject: [PATCH 3/5] chore(release): 0.0.4, on a core that speaks AI SDK v7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo pinned @dudko.dev/agent-web 0.0.6 and ai v6 while the core has moved to ai v7. Pointing it at the published 0.0.11 surfaced two things at once: the demo's own AI SDK stack was a major behind (so its provider factories built models the core cannot accept), and its copy of the core resolved a different @ai-sdk/provider-utils than the one the aliased library dist resolves — two nominally distinct `Schema` symbols, which tsc rejects. Aligning the demo on ai ^7 with the v4 provider majors fixes both: one provider-utils across both trees, and models the core actually accepts. The peer floor moves to >=0.0.11 for the same reason — that is the first core whose own peer ranges resolve against AI SDK v7, and this package re-exports its types. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur --- README.md | 6 +- demo/README.md | 7 +- demo/package-lock.json | 159 +++++++++++++++++++++-------------------- demo/package.json | 14 ++-- package-lock.json | 110 +++++++++++++++------------- package.json | 8 +-- 6 files changed, 160 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index cd61cc4..1db78ac 100644 --- a/README.md +++ b/README.md @@ -287,8 +287,10 @@ Two notes: and is loaded with a **dynamic import**, so apps that never call `connect` don't pay for `@modelcontextprotocol/sdk`. Install it alongside the core when you do use MCP. -- `oauthSupported` is `false` on cores older than the one that introduced - `BrowserOAuthProvider`; header auth still works there. +- `oauthSupported` is `false` on cores older than `@dudko.dev/agent-web@0.0.9`, + which introduced `BrowserOAuthProvider`; header auth still works there. The + peer floor is `>=0.0.11` — that is the first core whose own peer ranges + resolve against AI SDK v7. ## Components diff --git a/demo/README.md b/demo/README.md index da525cd..fd58b40 100644 --- a/demo/README.md +++ b/demo/README.md @@ -15,10 +15,9 @@ Two panels: `Access-Control-Expose-Headers: WWW-Authenticate, mcp-session-id`) — the browser talks to it directly, nothing is proxied. -> The OAuth panel needs a core that exports `BrowserOAuthProvider`. The repo -> pins `@dudko.dev/agent-web` as a devDependency for the aliased build; bump it -> once a core with MCP OAuth is published, or the panel reports that OAuth is -> unavailable and only header auth works. +> The OAuth panel needs a core that exports `BrowserOAuthProvider` +> (`@dudko.dev/agent-web` ≥ 0.0.9). On an older core the panel says so and +> disables Connect for that mode; header auth is unaffected. **Live:** https://dudko-dev.github.io/agent-web-react/ diff --git a/demo/package-lock.json b/demo/package-lock.json index 0e5527e..4201581 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -8,14 +8,14 @@ "name": "agent-web-react-demo", "version": "0.0.0", "dependencies": { - "@ai-sdk/anthropic": "^3.0.0", - "@ai-sdk/google": "^3.0.0", - "@ai-sdk/openai": "^3.0.0", - "@browser-ai/web-llm": "^2.0.0", - "@dudko.dev/agent-web": "^0.0.6", - "@mlc-ai/web-llm": "^0.2.79", + "@ai-sdk/anthropic": "^4.0.39", + "@ai-sdk/google": "^4.0.44", + "@ai-sdk/openai": "^4.0.42", + "@browser-ai/web-llm": "^3.0.2", + "@dudko.dev/agent-web": "^0.0.11", + "@mlc-ai/web-llm": "^0.2.84", "@modelcontextprotocol/sdk": "^1.30.0", - "ai": "^6.0.0", + "ai": "^7.0.66", "react": "^19.0.0", "react-dom": "^19.0.0", "zod": "^4.4.3" @@ -29,94 +29,96 @@ } }, "node_modules/@ai-sdk/anthropic": { - "version": "3.0.92", - "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.92.tgz", - "integrity": "sha512-dFrf4xhx2yM686KHFm76Nn7nBekjkjiw1btqOyR26/kXz58QMguhNsjyvMqPktD8AW/wwTAq0fDRVyDvF2gH1w==", + "version": "4.0.39", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.39.tgz", + "integrity": "sha512-JAMGtYeEuaBzqbsPO4fkho6vQyNoVhsHASM4o59wmJRU6Vh7prjOp490Kmc7YQTY+ioU1/xYzXvWOtxZBup0Xw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.13", - "@ai-sdk/provider-utils": "4.0.35" + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.142", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.142.tgz", - "integrity": "sha512-Y1iwdxdebYXpoK5y/4CrcCfJGeFwEJWlEx+pMWIg/ZVWGi9KA+JXM7YBkhxcshpq/jAXx8YyQeFMyZr0TGfXZQ==", + "version": "4.0.52", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.52.tgz", + "integrity": "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.13", - "@ai-sdk/provider-utils": "4.0.35", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@ai-sdk/google": { - "version": "3.0.88", - "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.88.tgz", - "integrity": "sha512-CN3PHCz5pa2sBowwZG4sNqE+7YfHWZT6+5KU12YMWuBssZ03s143Jr2jThkN5Fgemy8Kyg+ub2XbpHGhtIZ2yQ==", + "version": "4.0.44", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-4.0.44.tgz", + "integrity": "sha512-bmRTDg06jQD+eX8nf214pET9+Oe8O1+lUIRGbWsGXj9IN2UJkpl1O1x7cvtiboyTtKSLvSRdVtItUfSl8sQ2GA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.13", - "@ai-sdk/provider-utils": "4.0.35" + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@ai-sdk/openai": { - "version": "3.0.80", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.80.tgz", - "integrity": "sha512-u3EfYbBG4YS/U2eOGH0yv8lPRwDj25X3sTluUKMYEwOLTZzWYv0IPtrpO7tPEra0QU4oq5Gpg49/FGFSrzE4vA==", + "version": "4.0.42", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.42.tgz", + "integrity": "sha512-ZxDca6jJalYuXrIGVrw6dnkpz1Io9AWy+/b/wVWIbjigHCbd+zWLpPi8NnK0OFU+U3YCpP+KWfUvEnG5pFhltA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.13", - "@ai-sdk/provider-utils": "4.0.35" + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@ai-sdk/provider": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.13.tgz", - "integrity": "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/@ai-sdk/provider-utils": { - "version": "4.0.35", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.35.tgz", - "integrity": "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg==", + "version": "5.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.13", + "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.8" + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" @@ -405,19 +407,19 @@ } }, "node_modules/@browser-ai/web-llm": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@browser-ai/web-llm/-/web-llm-2.1.7.tgz", - "integrity": "sha512-e+3g+5PGbfDpkC1l43W8pKGEa8n7wto2igLi+yO71jZIAb4u0jwV40DH174km2L1ib3Z8pRVh41bu7mYV/uFfA==", - "license": "Apache License", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@browser-ai/web-llm/-/web-llm-3.0.2.tgz", + "integrity": "sha512-pCjC9OGi7u1fOU0rof7TWVvjhMjR0oH1LAQkhB74eASWOFFkL2/iJ0HV3v3Q5OJ7qEyBgx/StmJQ+vLTXS2bSA==", + "license": "Apache-2.0", "peerDependencies": { "@mlc-ai/web-llm": "^0.2.79", - "ai": "^6.0.0" + "ai": "^7.0.0" } }, "node_modules/@dudko.dev/agent-web": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@dudko.dev/agent-web/-/agent-web-0.0.6.tgz", - "integrity": "sha512-Ls3oNiTW0ZFDNpe0JQypoN/F0daxKgHhbeSVKbx71u5DchqXa6mI/0vmSn9Eld99gXNSPLLTt+d88dfMnQQYdQ==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@dudko.dev/agent-web/-/agent-web-0.0.11.tgz", + "integrity": "sha512-XETUYARI3osFSiaOueBQD0WWZedazRpeGdU5FV3RfqxsS2SivvQuqwC+PDepT5aqeOamLvDhwpaeQj1xeOhZaw==", "funding": [ { "type": "individual", @@ -438,7 +440,7 @@ ], "license": "MIT", "dependencies": { - "ai": "^6.0.218", + "ai": "^7.0.66", "idb": "^8.0.0", "zod": "^4.4.3" }, @@ -446,16 +448,16 @@ "node": ">=18" }, "peerDependencies": { - "@ai-sdk/anthropic": "^3.0.0", - "@ai-sdk/deepseek": "^2.0.0", - "@ai-sdk/google": "^3.0.0", - "@ai-sdk/openai": "^3.0.0", - "@ai-sdk/openai-compatible": "^2.0.0", - "@ai-sdk/xai": "^3.0.0", - "@browser-ai/core": "^2.0.0", - "@browser-ai/web-llm": "^2.0.0", + "@ai-sdk/anthropic": "^4.0.0", + "@ai-sdk/deepseek": "^3.0.0", + "@ai-sdk/google": "^4.0.0", + "@ai-sdk/openai": "^4.0.0", + "@ai-sdk/openai-compatible": "^3.0.0", + "@ai-sdk/xai": "^4.0.0", + "@browser-ai/core": "^3.0.0", + "@browser-ai/web-llm": "^3.0.0", "@mlc-ai/web-llm": ">=0.2.70", - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.30.0" }, "peerDependenciesMeta": { "@ai-sdk/anthropic": { @@ -1043,15 +1045,6 @@ } } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1517,6 +1510,12 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1531,18 +1530,17 @@ } }, "node_modules/ai": { - "version": "6.0.218", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.218.tgz", - "integrity": "sha512-HsyCUNaaYgX/b/kGOoYfKkqfT1HvpUKKDb8YkN1FKeCNZjKdqXLGY+cKBpYGIRAvsPuOHskxLxZ46cK1dTBWQQ==", + "version": "7.0.66", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.66.tgz", + "integrity": "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "3.0.142", - "@ai-sdk/provider": "3.0.13", - "@ai-sdk/provider-utils": "4.0.35", - "@opentelemetry/api": "^1.9.0" + "@ai-sdk/gateway": "4.0.52", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" @@ -3052,6 +3050,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/demo/package.json b/demo/package.json index 567db92..217c167 100644 --- a/demo/package.json +++ b/demo/package.json @@ -10,14 +10,14 @@ "preview": "vite preview" }, "dependencies": { - "@ai-sdk/anthropic": "^3.0.0", - "@ai-sdk/google": "^3.0.0", - "@ai-sdk/openai": "^3.0.0", - "@browser-ai/web-llm": "^2.0.0", - "@dudko.dev/agent-web": "^0.0.6", - "@mlc-ai/web-llm": "^0.2.79", + "@ai-sdk/anthropic": "^4.0.39", + "@ai-sdk/google": "^4.0.44", + "@ai-sdk/openai": "^4.0.42", + "@browser-ai/web-llm": "^3.0.2", + "@dudko.dev/agent-web": "^0.0.11", + "@mlc-ai/web-llm": "^0.2.84", "@modelcontextprotocol/sdk": "^1.30.0", - "ai": "^6.0.0", + "ai": "^7.0.66", "react": "^19.0.0", "react-dom": "^19.0.0", "zod": "^4.4.3" diff --git a/package-lock.json b/package-lock.json index f7fc044..b757d61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dudko.dev/agent-web-react", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dudko.dev/agent-web-react", - "version": "0.0.3", + "version": "0.0.4", "funding": [ { "type": "individual", @@ -27,7 +27,7 @@ ], "license": "MIT", "devDependencies": { - "@dudko.dev/agent-web": "^0.0.7", + "@dudko.dev/agent-web": "^0.0.11", "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.9.0", "@types/react": "^19.2.18", @@ -42,64 +42,66 @@ "node": ">=18" }, "peerDependencies": { - "@dudko.dev/agent-web": ">=0.0.6", + "@dudko.dev/agent-web": ">=0.0.11", "react": ">=18", "react-dom": ">=18" } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.142", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.142.tgz", - "integrity": "sha512-Y1iwdxdebYXpoK5y/4CrcCfJGeFwEJWlEx+pMWIg/ZVWGi9KA+JXM7YBkhxcshpq/jAXx8YyQeFMyZr0TGfXZQ==", + "version": "4.0.52", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.52.tgz", + "integrity": "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.13", - "@ai-sdk/provider-utils": "4.0.35", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@ai-sdk/provider": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.13.tgz", - "integrity": "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/@ai-sdk/provider-utils": { - "version": "4.0.35", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.35.tgz", - "integrity": "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg==", + "version": "5.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.13", + "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.8" + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@dudko.dev/agent-web": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@dudko.dev/agent-web/-/agent-web-0.0.7.tgz", - "integrity": "sha512-iMtkVPpmC1A2sl/LRrY1u8viXS7WDr09TNNrvfPZsezmxa4lvapwChlKFzBa3cxRq4ioP8K2BBChKkNk4u22Rw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@dudko.dev/agent-web/-/agent-web-0.0.11.tgz", + "integrity": "sha512-XETUYARI3osFSiaOueBQD0WWZedazRpeGdU5FV3RfqxsS2SivvQuqwC+PDepT5aqeOamLvDhwpaeQj1xeOhZaw==", "dev": true, "funding": [ { @@ -121,7 +123,7 @@ ], "license": "MIT", "dependencies": { - "ai": "^6.0.218", + "ai": "^7.0.66", "idb": "^8.0.0", "zod": "^4.4.3" }, @@ -129,16 +131,16 @@ "node": ">=18" }, "peerDependencies": { - "@ai-sdk/anthropic": "^3.0.0", - "@ai-sdk/deepseek": "^2.0.0", - "@ai-sdk/google": "^3.0.0", - "@ai-sdk/openai": "^3.0.0", - "@ai-sdk/openai-compatible": "^2.0.0", - "@ai-sdk/xai": "^3.0.0", - "@browser-ai/core": "^2.0.0", - "@browser-ai/web-llm": "^2.0.0", + "@ai-sdk/anthropic": "^4.0.0", + "@ai-sdk/deepseek": "^3.0.0", + "@ai-sdk/google": "^4.0.0", + "@ai-sdk/openai": "^4.0.0", + "@ai-sdk/openai-compatible": "^3.0.0", + "@ai-sdk/xai": "^4.0.0", + "@browser-ai/core": "^3.0.0", + "@browser-ai/web-llm": "^3.0.0", "@mlc-ai/web-llm": ">=0.2.70", - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.30.0" }, "peerDependenciesMeta": { "@ai-sdk/anthropic": { @@ -708,16 +710,6 @@ } } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -1122,6 +1114,13 @@ "node": ">= 20" } }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1150,19 +1149,18 @@ } }, "node_modules/ai": { - "version": "6.0.218", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.218.tgz", - "integrity": "sha512-HsyCUNaaYgX/b/kGOoYfKkqfT1HvpUKKDb8YkN1FKeCNZjKdqXLGY+cKBpYGIRAvsPuOHskxLxZ46cK1dTBWQQ==", + "version": "7.0.66", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.66.tgz", + "integrity": "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "3.0.142", - "@ai-sdk/provider": "3.0.13", - "@ai-sdk/provider-utils": "4.0.35", - "@opentelemetry/api": "^1.9.0" + "@ai-sdk/gateway": "4.0.52", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" @@ -2930,6 +2928,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 8428459..5b624c5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@dudko.dev/agent-web-react", - "version": "0.0.3", - "description": "React bindings for @dudko.dev/agent-web: a headless useAgent hook, an AgentProvider context, and optional pre-styled components (chat panel, plan/step view, BYOK key form, WebLLM load bar) that connect the in-browser LLM agent to any React site. UI you can drop in — or a headless reducer you can build your own around.", + "version": "0.0.4", + "description": "React bindings for @dudko.dev/agent-web: a headless useAgent hook, an AgentProvider context, and optional pre-styled components (chat panel, plan/step view, BYOK key form, WebLLM load bar) that connect the in-browser LLM agent to any React site. UI you can drop in \u2014 or a headless reducer you can build your own around.", "type": "module", "sideEffects": [ "**/*.css" @@ -104,12 +104,12 @@ "node": ">=18" }, "peerDependencies": { - "@dudko.dev/agent-web": ">=0.0.6", + "@dudko.dev/agent-web": ">=0.0.11", "react": ">=18", "react-dom": ">=18" }, "devDependencies": { - "@dudko.dev/agent-web": "^0.0.7", + "@dudko.dev/agent-web": "^0.0.11", "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.9.0", "@types/react": "^19.2.18", From e9313ec27052bf1105af31e4fe7b51f5b0c5dbba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:24:02 +0000 Subject: [PATCH 4/5] fix(mcp): make the mid-session authorization prompt actually fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the check for a lapsed authorization was dead code: the MCP SDK's UnauthorizedError never assigns `this.name`, so it reads as "Error" and `err.name === 'UnauthorizedError'` never matched. The panel kept reporting "connected" while every tool call failed — exactly the symptom the wrapper existed to prevent. Matching the message instead is not an option: the connector rethrows a failing tool's own text, so a tool that says "unauthorized" would flip the UI into an auth prompt. The core now re-exports the class, so this checks identity against the same module instance the connector uses, with a constructor-name fallback for an older core. Peer floor moves to >=0.0.12 accordingly. Two more from the same review: - StrictMode's simulated cleanup aborted the post-OAuth reconnect. The second setup could not take over (the code was already claimed and stripped from the URL), so the flow finished the authorization and then sat at "not connected" until the user pressed Connect again. The reconnect no longer bails on that flag — connect() is epoch-guarded and a setState after a real unmount is a no-op. - The `pending` record outlived its flow, so an unrelated ?code= on the same page could be claimed as ours. It now carries a timestamp, is ignored after ten minutes, and is cleared on a successful connect. Also: the tool wrapper no longer forces a promise, so the AI SDK can still detect a streaming tool by what execute() returns synchronously. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur --- README.md | 2 +- package.json | 4 +- src/hooks/use-mcp.ts | 100 +++++++++++++++++++++++++++++-------------- src/index.ts | 9 +++- src/mcp-types.ts | 5 +++ tests/mcp.test.ts | 27 ++++++++++++ 6 files changed, 112 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 1db78ac..4043af6 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ const config = { model, tools: { ...myTools, ...mcp.tools } } `useMcp` returns `{ status, tools, catalog, error, authorizationUrl, oauthSupported, completingAuthorization, connect, disconnect, authorize, -forgetAuthorization }`. `status` is +forgetAuthorization, checkOAuthSupport }`. `status` is `idle | connecting | connected | needs-authorization | error`. The OAuth round-trip navigates away from your app, so the hook persists what it diff --git a/package.json b/package.json index 5b624c5..f92da78 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@dudko.dev/agent-web-react", "version": "0.0.4", - "description": "React bindings for @dudko.dev/agent-web: a headless useAgent hook, an AgentProvider context, and optional pre-styled components (chat panel, plan/step view, BYOK key form, WebLLM load bar) that connect the in-browser LLM agent to any React site. UI you can drop in \u2014 or a headless reducer you can build your own around.", + "description": "React bindings for @dudko.dev/agent-web: a headless useAgent hook, an AgentProvider context, and optional pre-styled components (chat panel, plan/step view, BYOK key form, WebLLM load bar) that connect the in-browser LLM agent to any React site. UI you can drop in — or a headless reducer you can build your own around.", "type": "module", "sideEffects": [ "**/*.css" @@ -104,7 +104,7 @@ "node": ">=18" }, "peerDependencies": { - "@dudko.dev/agent-web": ">=0.0.11", + "@dudko.dev/agent-web": ">=0.0.12", "react": ">=18", "react-dom": ">=18" }, diff --git a/src/hooks/use-mcp.ts b/src/hooks/use-mcp.ts index 754c5ac..af79cfa 100644 --- a/src/hooks/use-mcp.ts +++ b/src/hooks/use-mcp.ts @@ -70,8 +70,15 @@ interface OAuthRecord { redirectUrl: string scope?: string clientName?: string + /** When the redirect was started, so a stale record can't claim a callback. */ + startedAt?: number } +// A round-trip to an authorization server and back takes seconds. Anything +// older than this is not the flow that produced the ?code= we are looking at — +// most likely another sign-in on the same page — so we leave it alone. +const PENDING_TTL_MS = 10 * 60 * 1000 + let modulePromise: Promise | undefined /** @@ -202,11 +209,21 @@ const defaultRedirectUrl = (): string => ? '' : `${globalThis.location.origin}${globalThis.location.pathname}` -// The MCP SDK throws this when a server demands authorization we don't have. -// Matching on the name (not the message) keeps a tool that merely mentions -// "401" in its output from flipping the UI into an auth prompt. -const isUnauthorized = (err: unknown): boolean => - err instanceof Error && err.name === 'UnauthorizedError' +/** + * Did this error come from the server refusing our authorization? + * + * Identity, not strings: the SDK's `UnauthorizedError` never assigns + * `this.name` (so it reads as "Error"), and its message would also match a + * tool whose own error text mentions "unauthorized" — the connector throws a + * failing tool's text verbatim. The class is re-exported by the core's `./mcp` + * subpath for exactly this; `constructor.name` is the fallback for a core too + * old to export it. + */ +export const isUnauthorizedError = (err: unknown, mod?: McpModule): boolean => { + if (!(err instanceof Error)) return false + if (mod?.UnauthorizedError && err instanceof mod.UnauthorizedError) return true + return err.constructor?.name === 'UnauthorizedError' +} /** * Wrap each tool so a mid-session authorization failure is visible. Without @@ -214,25 +231,35 @@ const isUnauthorized = (err: unknown): boolean => * refreshes silently on a 401, but once the refresh token is gone (revoked, * expired) it can only ask for a new authorization. */ -const watchAuthorization = (tools: AgentToolSet, onUnauthorized: () => void): AgentToolSet => { +const watchAuthorization = ( + tools: AgentToolSet, + mod: McpModule, + onUnauthorized: () => void, +): AgentToolSet => { const entries = Object.entries(tools as Record).map(([name, value]) => { - const tool = value as { execute?: (args: unknown, options: unknown) => Promise } + const tool = value as { execute?: (args: unknown, options: unknown) => unknown } if (typeof tool.execute !== 'function') return [name, value] const execute = tool.execute.bind(tool) - return [ - name, - { - ...(value as object), - execute: async (args: unknown, options: unknown) => { - try { - return await execute(args, options) - } catch (err) { - if (isUnauthorized(err)) onUnauthorized() + const watched = (args: unknown, options: unknown): unknown => { + // Deliberately not an async function: the AI SDK inspects what `execute` + // returns SYNCHRONOUSLY to decide whether a tool streams (isAsyncIterable), + // so wrapping everything in a promise would collapse a streaming tool into + // a single opaque result. + try { + const out = execute(args, options) + if (out && typeof (out as Promise).then === 'function') { + return (out as Promise).then(undefined, (err: unknown) => { + if (isUnauthorizedError(err, mod)) onUnauthorized() throw err - } - }, - }, - ] + }) + } + return out + } catch (err) { + if (isUnauthorizedError(err, mod)) onUnauthorized() + throw err + } + } + return [name, { ...(value as object), execute: watched }] }) return Object.fromEntries(entries) as AgentToolSet } @@ -319,6 +346,7 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { redirectUrl: oauth.redirectUrl ?? defaultRedirectUrl(), scope: oauth.scope, clientName: oauth.clientName ?? optionsRef.current.clientName, + startedAt: Date.now(), } authProvider = new mod.BrowserOAuthProvider({ serverUrl: record.url, @@ -365,8 +393,11 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { } connectionRef.current = connection + // The round-trip is over: a stale record would later make an unrelated + // ?code= on this page look like ours. + writeRecord(PENDING_KEY, undefined) setTools( - watchAuthorization(connection.tools, () => { + watchAuthorization(connection.tools, mod, () => { if (!current()) return const url = providerRef.current?.authorizationUrl if (url) setAuthorizationUrl(String(url)) @@ -436,9 +467,15 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { return } const pending = readRecord(PENDING_KEY) - if (!pending) { - // Not our callback — another sign-in flow on this page may still need - // those parameters, so leave the URL exactly as we found it. + // A record only proves that WE started a flow, not that THIS callback is + // its answer — so an expired one is treated as somebody else's. Either way + // the URL is left exactly as we found it: another sign-in flow on this page + // may still need those parameters. + const fresh = + pending !== undefined && + (pending.startedAt === undefined || Date.now() - pending.startedAt < PENDING_TTL_MS) + if (!pending || !fresh) { + if (pending) writeRecord(PENDING_KEY, undefined) setCompletingAuthorization(false) return } @@ -452,7 +489,6 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { globalThis.history.replaceState(null, '', stripOAuthParams(globalThis.location.href)) } - let cancelled = false void (async () => { try { const mod = await loadMcp() @@ -467,23 +503,25 @@ export const useMcp = (options: UseMcpOptions = {}): UseMcpReturn => { providerRef.current = provider await mod.finishMcpOAuth(provider, callback) writeRecord(PENDING_KEY, undefined) - if (cancelled) return + // NOT gated on `cancelled`. StrictMode's simulated cleanup sets it in + // development, and the second setup cannot take over — the code has + // already been claimed and stripped from the URL — so bailing here + // would finish the authorization and then never connect. `connect` is + // epoch-guarded and a setState after a real unmount is a no-op, so + // letting it run is safe either way. + // // Hand back to `connect`, which owns the status from here — a slow or // hung handshake must not leave the UI stuck on "finishing". setCompletingAuthorization(false) await connect({ url: pending.url, name: pending.name, oauth: true }) } catch (err) { writeRecord(PENDING_KEY, undefined) - if (cancelled) return setError(err instanceof Error ? err.message : String(err)) setStatus('error') } finally { - if (!cancelled) setCompletingAuthorization(false) + setCompletingAuthorization(false) } })() - return () => { - cancelled = true - } // Runs once: `connect` is stable and the callback exists only on first load. }, [connect]) diff --git a/src/index.ts b/src/index.ts index 43236d4..5b0339e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,18 @@ export { useMcp, claimOAuthCallback, describeMcpResult, + isUnauthorizedError, readCallbackParams, stripOAuthParams, } from './hooks/use-mcp.js' export type { McpConnectOptions, McpStatus, UseMcpOptions, UseMcpReturn } from './hooks/use-mcp.js' -export type { ConnectedMcp, McpCatalogEntry, McpServerResult, McpModule } from './mcp-types.js' +export type { + ConnectedMcp, + McpCatalogEntry, + McpOAuthCallback, + McpServerResult, + McpModule, +} from './mcp-types.js' export { useWebLLMModel } from './hooks/use-webllm-model.js' export type { UseWebLLMModelReturn, diff --git a/src/mcp-types.ts b/src/mcp-types.ts index 64dd0a7..64c5bf8 100644 --- a/src/mcp-types.ts +++ b/src/mcp-types.ts @@ -68,4 +68,9 @@ export interface McpModule { BrowserOAuthProvider?: new (opts: BrowserOAuthProviderOptions) => BrowserOAuthProvider readOAuthCallback?: (input?: string | URL) => McpOAuthCallback | undefined finishMcpOAuth?: (provider: BrowserOAuthProvider, callback: McpOAuthCallback) => Promise + /** + * The SDK's error class, re-exported by the core so an authorization failure + * can be recognised by identity. Absent on cores older than 0.0.12. + */ + UnauthorizedError?: new (message?: string) => Error } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 41e26ec..76a8730 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -3,6 +3,7 @@ import test from 'node:test' import { claimOAuthCallback, describeMcpResult, + isUnauthorizedError, readCallbackParams, stripOAuthParams, } from '../dist/index.js' @@ -111,3 +112,29 @@ test('claimOAuthCallback: a code can only be claimed once', () => { assert.equal(claimOAuthCallback({ ...callback }), false) assert.equal(claimOAuthCallback({ code: 'another', state: 'st' }), true) }) + +// The SDK's UnauthorizedError never assigns `this.name`, so it reads as +// "Error" — a name check silently never fires, which is how the mid-session +// re-authorization prompt shipped as dead code. + +test('isUnauthorizedError: matches the core-exported class by identity', () => { + class UnauthorizedError extends Error {} + const mod = { UnauthorizedError } as never + assert.equal(isUnauthorizedError(new UnauthorizedError('Unauthorized'), mod), true) + assert.equal(isUnauthorizedError(new Error('Unauthorized'), mod), false) +}) + +test('isUnauthorizedError: falls back to the constructor name on an older core', () => { + class UnauthorizedError extends Error {} + const err = new UnauthorizedError() + assert.equal(err.name, 'Error', 'guards the assumption behind the fallback') + assert.equal(isUnauthorizedError(err), true) +}) + +test('isUnauthorizedError: a tool whose own error mentions "unauthorized" is not auth', () => { + // The connector throws a failing tool's text verbatim, so message matching + // would flip the UI into an auth prompt for an ordinary tool failure. + assert.equal(isUnauthorizedError(new Error('unauthorized: quota exceeded')), false) + assert.equal(isUnauthorizedError('not an error'), false) + assert.equal(isUnauthorizedError(undefined), false) +}) From 7ca4bf6ec9214a4b318d261870f3d0dd4d3f37cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:25:45 +0000 Subject: [PATCH 5/5] chore(deps): core 0.0.12, the version this package's peer floor requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.0.12 is the core that re-exports UnauthorizedError, which is what the mid-session authorization check now matches against by identity. Both trees were still on 0.0.11 — i.e. the repo violated its own declared peer range, and the demo build would have taken the constructor-name fallback instead of the real class. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur --- demo/package-lock.json | 8 ++++---- demo/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/demo/package-lock.json b/demo/package-lock.json index 4201581..dd598f7 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -12,7 +12,7 @@ "@ai-sdk/google": "^4.0.44", "@ai-sdk/openai": "^4.0.42", "@browser-ai/web-llm": "^3.0.2", - "@dudko.dev/agent-web": "^0.0.11", + "@dudko.dev/agent-web": "^0.0.12", "@mlc-ai/web-llm": "^0.2.84", "@modelcontextprotocol/sdk": "^1.30.0", "ai": "^7.0.66", @@ -417,9 +417,9 @@ } }, "node_modules/@dudko.dev/agent-web": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@dudko.dev/agent-web/-/agent-web-0.0.11.tgz", - "integrity": "sha512-XETUYARI3osFSiaOueBQD0WWZedazRpeGdU5FV3RfqxsS2SivvQuqwC+PDepT5aqeOamLvDhwpaeQj1xeOhZaw==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@dudko.dev/agent-web/-/agent-web-0.0.12.tgz", + "integrity": "sha512-fD8jfemVjSxGdC4y3cftpGKCtIstN66jXcCWb1/mPiMxPCqK52H9BPGooKtAT11hNm2LHYmiJvtlfSNfLb95JA==", "funding": [ { "type": "individual", diff --git a/demo/package.json b/demo/package.json index 217c167..33e5aba 100644 --- a/demo/package.json +++ b/demo/package.json @@ -14,7 +14,7 @@ "@ai-sdk/google": "^4.0.44", "@ai-sdk/openai": "^4.0.42", "@browser-ai/web-llm": "^3.0.2", - "@dudko.dev/agent-web": "^0.0.11", + "@dudko.dev/agent-web": "^0.0.12", "@mlc-ai/web-llm": "^0.2.84", "@modelcontextprotocol/sdk": "^1.30.0", "ai": "^7.0.66", diff --git a/package-lock.json b/package-lock.json index b757d61..8117fdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ ], "license": "MIT", "devDependencies": { - "@dudko.dev/agent-web": "^0.0.11", + "@dudko.dev/agent-web": "^0.0.12", "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.9.0", "@types/react": "^19.2.18", @@ -42,7 +42,7 @@ "node": ">=18" }, "peerDependencies": { - "@dudko.dev/agent-web": ">=0.0.11", + "@dudko.dev/agent-web": ">=0.0.12", "react": ">=18", "react-dom": ">=18" } @@ -99,9 +99,9 @@ } }, "node_modules/@dudko.dev/agent-web": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@dudko.dev/agent-web/-/agent-web-0.0.11.tgz", - "integrity": "sha512-XETUYARI3osFSiaOueBQD0WWZedazRpeGdU5FV3RfqxsS2SivvQuqwC+PDepT5aqeOamLvDhwpaeQj1xeOhZaw==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@dudko.dev/agent-web/-/agent-web-0.0.12.tgz", + "integrity": "sha512-fD8jfemVjSxGdC4y3cftpGKCtIstN66jXcCWb1/mPiMxPCqK52H9BPGooKtAT11hNm2LHYmiJvtlfSNfLb95JA==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index f92da78..85a3f9d 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "react-dom": ">=18" }, "devDependencies": { - "@dudko.dev/agent-web": "^0.0.11", + "@dudko.dev/agent-web": "^0.0.12", "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.9.0", "@types/react": "^19.2.18",