From e46f69e02fe97b4a5e38cf6583669fcbf87bd61e Mon Sep 17 00:00:00 2001 From: EthanHuo Date: Sun, 25 Oct 2026 12:39:04 +0800 Subject: [PATCH 1/2] feat: OAuth on the MCP SDK v2; any 401 challenge starts authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-written OAuth client (metadata discovery, DCR, PKCE, token exchange, refresh) with the official SDK's auth() and an mcpx OAuthClientProvider, and move from @modelcontextprotocol/sdk 1.x to @modelcontextprotocol/client 2.4. A server's need for OAuth is now decided by its challenge, not by an @add probe. The HTTP transport carries an authProvider whose onUnauthorized surfaces the 401 (or 403 insufficient_scope) challenge; the session pool joins the shared Authentication Flow with it and retries once. Mixed-auth servers such as whiteboardanimation.ai — anonymous tools beside protected ones, 405 on GET — previously froze as auth "none" and failed every protected call with reauth-required. - Credential Store v3: one OAuth grant per server in the SDK's stored shapes (client + tokens, issuer-stamped). A journaled migration converts v2 tokens; manual clients keep their secret and fixed redirect URI. - Refresh never opens the browser; a new sign-in never replays stored tokens or a client registered for another loopback port, and only replaces the grant once it succeeds. Invalidation deletes only what the server refused. - @add no longer probes with GET; a challenged tool listing records OAuth. - Manual clients (Slack) keep http://127.0.0.1:65245/callback; Slack's authed_user token response is still accepted. - ADR 0009 records the challenge as the OAuth discovery entry. Bump to 0.14.0. Why: src/oauth.ts#OAuthCallback | a stopped Bun server reports port 0, so the code exchange must reuse the redirect URI captured at listen time Why: src/mcp-client.ts#challengeAuthProvider | the transport must not run OAuth inside a request; the Runtime owns single-flight, Caller Input and registry writes --- GLOSSARY.md | 8 +- README.md | 31 +- bun.lock | 172 +--- ...008-calls-wait-for-shared-authorization.md | 2 +- ...-challenge-is-the-oauth-discovery-entry.md | 16 + package.json | 8 +- prototypes/mcp-cancellation/evidence.json | 80 +- prototypes/mcp-cancellation/findings.md | 10 + prototypes/mcp-cancellation/run.ts | 14 +- src/auth-discovery.ts | 77 -- src/discovery.ts | 34 +- src/headers.ts | 48 +- src/mcp-client.ts | 85 +- src/oauth.ts | 956 ++++++++---------- src/runtime-authentication.ts | 283 ++---- src/runtime-session-pool.ts | 104 +- src/runtime-stores.ts | 146 ++- src/skill-template.ts | 3 +- src/types.ts | 38 +- tests/auth-discovery.test.ts | 29 - tests/fixtures/notification-mcp-server.mjs | 34 +- tests/fixtures/stdio-server.mjs | 15 +- tests/headers.test.ts | 18 +- tests/oauth-challenge.test.ts | 273 +++++ tests/oauth.test.ts | 492 ++++++--- tests/runtime-authentication.test.ts | 449 ++++---- tests/runtime-cli.test.ts | 1 + tests/runtime-session-pool.test.ts | 40 +- tests/runtime-stores.test.ts | 97 +- tests/runtime.test.ts | 10 +- 30 files changed, 1851 insertions(+), 1722 deletions(-) create mode 100644 docs/adr/0009-a-challenge-is-the-oauth-discovery-entry.md delete mode 100644 src/auth-discovery.ts delete mode 100644 tests/auth-discovery.test.ts create mode 100644 tests/oauth-challenge.test.ts diff --git a/GLOSSARY.md b/GLOSSARY.md index 9daf1e3..5c2f939 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -27,11 +27,15 @@ The durable, sensitive authorization material that lets MCPX act for the user wi _Avoid_: Auth config, token registry **Credential Identity**: -The stable authorization identity shared by every server declaration and call that must use the same grant. +The stable authorization identity shared by every server declaration and call that must use the same grant. An OAuth grant belongs to one server declaration, so its Credential Identity is the server name. _Avoid_: Server key, access token, session ID +**Authorization Challenge**: +A resource server's `401` or `403 insufficient_scope` answer with its `WWW-Authenticate` parameters. It is the only evidence that a server needs OAuth, and it can arrive on any request, not only when the server is added. +_Avoid_: Auth probe, auth discovery result + **Authentication Flow**: -One shared attempt to make a Credential Identity usable: a silent token refresh when possible, otherwise browser authorization. Calls and `@refresh` join the same flow and wait for its outcome. +One shared attempt to make a Credential Identity usable: a silent token refresh when possible, otherwise browser authorization. Calls and `@refresh` join the same flow and wait for its outcome; an Authorization Challenge starts or joins it too. _Avoid_: Per-command login, token retry **Caller Input**: diff --git a/README.md b/README.md index 9743082..4c68a4c 100644 --- a/README.md +++ b/README.md @@ -51,17 +51,10 @@ mcpx @add "{ name: 'browser', url: 'http://127.0.0.1:9000/mcp' }" > Chrome. It exposes a local MCP server that lets agents control the browser > directly — navigating pages, extracting content, and interacting with web UIs. -mcpx stores server configuration in: - -```text -~/.agents/mcpx/servers.json -``` - -OAuth tokens and client secrets are stored separately in the global token cache: - -```text -~/.agents/mcpx/tokens.json -``` +mcpx keeps its state under `~/.agents/mcpx/state-v2/`: server declarations in +`registry.json`, credentials (OAuth grants, bearer values, secret headers) in +`credentials.json`, and observed tool schemas in `schema-cache.json`. The MCP +Runtime is the only writer. Project directories do not need MCP config files. A project should decide which global servers are relevant by using schema selectors or a generated skill. @@ -194,9 +187,19 @@ passed through the shell literally. When several bearer credentials are registered, mcpx advances a local round-robin cursor before each HTTP request. -When `@add` detects OAuth, mcpx tries to complete authentication immediately. +OAuth follows the MCP authorization spec through the official MCP SDK. A server +does not need to declare OAuth up front: whenever it answers a request with +`401` and a `WWW-Authenticate: Bearer resource_metadata=…` challenge, mcpx +discovers its authorization server from that challenge, authorizes, records +the server as OAuth, and retries the request. This covers servers whose tools +are partly anonymous — `@add` lists their tools without credentials, and the +first call to a protected tool starts sign-in. A `403 insufficient_scope` +challenge re-authorizes with the additional scope the same way. `@add` itself +never opens a browser; it reports `auth-required` when listing tools was +challenged. + For OAuth servers that support dynamic client registration, mcpx registers a -client automatically. +client automatically for each browser sign-in. For OAuth servers that do not support dynamic client registration, such as Slack, mcpx prompts for a manual `client_id` and `client_secret`. These providers @@ -213,7 +216,7 @@ OAuth credentials repair themselves during ordinary tool calls: - When a token is close to expiry, mcpx refreshes it silently before the call. - When the server rejects a token with 401, mcpx refreshes it and retries the - call once. + call once. Another call that already replaced the token is not undone. - When no refresh is possible (no token yet, or the refresh grant was revoked), the call opens browser authorization, prints the authorization URL to stderr, and waits up to 5 minutes for it to complete before continuing. Concurrent diff --git a/bun.lock b/bun.lock index 568188e..37b0610 100644 --- a/bun.lock +++ b/bun.lock @@ -7,15 +7,17 @@ "dependencies": { "@celados/argc": "^7.11.2", "@clack/prompts": "^1.2.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.4.0", "@valibot/to-json-schema": "^1.6.0", "valibot": "^1.3.1", "yaml": "^2.9.0", }, "devDependencies": { + "@modelcontextprotocol/server": "^2.4.0", "@types/bun": "^1.3.12", "oxfmt": "^0.46.0", "typescript": "^7.0.2", + "zod": "^4.6.7", }, }, }, @@ -32,9 +34,11 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "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" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="], + + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], @@ -168,212 +172,56 @@ "@valibot/to-json-schema": ["@valibot/to-json-schema@1.7.1", "", { "peerDependencies": { "valibot": "^1.4.0" } }, "sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A=="], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "body-parser": ["body-parser@2.3.0", "", { "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" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], - "express": ["express@5.2.1", "", { "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" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], - "finalhandler": ["finalhandler@2.1.1", "", { "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" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - - "hono": ["hono@4.13.2", "", {}, "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA=="], - - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "oxc-parser": ["oxc-parser@0.137.0", "", { "dependencies": { "@oxc-project/types": "^0.137.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.137.0", "@oxc-parser/binding-android-arm64": "0.137.0", "@oxc-parser/binding-darwin-arm64": "0.137.0", "@oxc-parser/binding-darwin-x64": "0.137.0", "@oxc-parser/binding-freebsd-x64": "0.137.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", "@oxc-parser/binding-linux-arm64-musl": "0.137.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-musl": "0.137.0", "@oxc-parser/binding-openharmony-arm64": "0.137.0", "@oxc-parser/binding-wasm32-wasi": "0.137.0", "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg=="], "oxfmt": ["oxfmt@0.46.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.46.0", "@oxfmt/binding-android-arm64": "0.46.0", "@oxfmt/binding-darwin-arm64": "0.46.0", "@oxfmt/binding-darwin-x64": "0.46.0", "@oxfmt/binding-freebsd-x64": "0.46.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.46.0", "@oxfmt/binding-linux-arm-musleabihf": "0.46.0", "@oxfmt/binding-linux-arm64-gnu": "0.46.0", "@oxfmt/binding-linux-arm64-musl": "0.46.0", "@oxfmt/binding-linux-ppc64-gnu": "0.46.0", "@oxfmt/binding-linux-riscv64-gnu": "0.46.0", "@oxfmt/binding-linux-riscv64-musl": "0.46.0", "@oxfmt/binding-linux-s390x-gnu": "0.46.0", "@oxfmt/binding-linux-x64-gnu": "0.46.0", "@oxfmt/binding-linux-x64-musl": "0.46.0", "@oxfmt/binding-openharmony-arm64": "0.46.0", "@oxfmt/binding-win32-arm64-msvc": "0.46.0", "@oxfmt/binding-win32-ia32-msvc": "0.46.0", "@oxfmt/binding-win32-x64-msvc": "0.46.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], - - "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "send": ["send@1.2.1", "", { "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" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "side-channel": ["side-channel@1.1.1", "", { "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" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], - - "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], - "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], } } diff --git a/docs/adr/0008-calls-wait-for-shared-authorization.md b/docs/adr/0008-calls-wait-for-shared-authorization.md index 5ecc0a3..8c89006 100644 --- a/docs/adr/0008-calls-wait-for-shared-authorization.md +++ b/docs/adr/0008-calls-wait-for-shared-authorization.md @@ -7,7 +7,7 @@ supersedes: 0007-authentication-is-explicit.md # Calls Wait for Shared Authorization -When a Call's Credential Identity is unusable, the MCP Runtime makes it usable instead of returning `reauth-required`: it refreshes the token silently when a refresh grant exists, and otherwise starts or joins the single shared browser Authentication Flow (ADR 0004). The Call waits for that flow, bounded by the flow's 5 minute timeout, and then proceeds. A 401 from the server replaces the credential the same way and retries the request once, because a rejected request was never executed. +When a Call's Credential Identity is unusable, the MCP Runtime makes it usable instead of returning `reauth-required`: it refreshes the token silently when a refresh grant exists, and otherwise starts or joins the single shared browser Authentication Flow (ADR 0004). The Call waits for that flow, bounded by the flow's 5 minute timeout, and then proceeds. A 401 from the server replaces the credential the same way and retries the request once, because a rejected request was never executed. [ADR 0009](0009-a-challenge-is-the-oauth-discovery-entry.md) extends this to servers added without auth: the challenge, not `@add`, decides that a server needs OAuth. [ADR 0007](0007-authentication-is-explicit.md) returned `reauth-required` for every unusable credential, which treated a non-interactive token refresh like an interactive login: routine hourly expiry failed ordinary Calls until the user ran `mcpx @refresh`. The recoverable case now recovers, and the interactive case waits rather than failing. diff --git a/docs/adr/0009-a-challenge-is-the-oauth-discovery-entry.md b/docs/adr/0009-a-challenge-is-the-oauth-discovery-entry.md new file mode 100644 index 0000000..54eeaa6 --- /dev/null +++ b/docs/adr/0009-a-challenge-is-the-oauth-discovery-entry.md @@ -0,0 +1,16 @@ +--- +type: Decision +title: A Challenge Is the OAuth Discovery Entry +status: accepted +--- + +# A Challenge Is the OAuth Discovery Entry + +Whether a server needs OAuth is decided by the resource server's challenge, at any time, not by a probe when the server is added. A `401` (or `403 insufficient_scope`) on any request — tool listing or a Call — hands its `WWW-Authenticate` challenge to the MCP Runtime, which joins the single shared Authentication Flow for the server (ADR 0004, ADR 0008), authorizes with the challenge's `resource_metadata` and `scope`, records the server as OAuth in the same commit as its first grant, and retries the request once. + +`@add` used to `GET` the server URL and freeze the result as the server's auth kind. A mixed-auth server — anonymous tools beside protected ones, such as `whiteboardanimation.ai` — answers that `GET` with `405`, so it was recorded as unauthenticated, and its spec-compliant challenge on a protected tool could only end in `reauth-required`. The probe guessed from the wrong request; the challenge is the server's own answer. + +- `@add` no longer probes. It lists tools; a challenged listing records the server as OAuth and reports `auth-required` without opening a browser. +- The transport carries an mcpx `authProvider` whose `onUnauthorized` surfaces the challenge instead of running OAuth inside the request, so the Runtime keeps single-flight, Caller Input, progress events, and registry ownership (ADR 0002). +- OAuth itself — discovery, dynamic registration, PKCE, resource indicators, `iss` validation, refresh — is the official MCP SDK's `auth()`. mcpx supplies its `OAuthClientProvider`: per-server grants in the Credential Store, a loopback callback per sign-in, and the fixed `http://127.0.0.1:65245/callback` for manually registered clients. A refresh never opens the browser; a new sign-in never replays stored tokens or a client registered for another port, and replaces the stored grant only once it succeeds. +- A bearer credential that is challenged still fails with `reauth-required`; static credentials cannot be repaired by the Runtime. diff --git a/package.json b/package.json index c487242..ade06da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcpx", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "bin": { "mcpx": "./src/main.ts" @@ -18,14 +18,16 @@ "dependencies": { "@celados/argc": "^7.11.2", "@clack/prompts": "^1.2.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.4.0", "@valibot/to-json-schema": "^1.6.0", "valibot": "^1.3.1", "yaml": "^2.9.0" }, "devDependencies": { + "@modelcontextprotocol/server": "^2.4.0", "@types/bun": "^1.3.12", "oxfmt": "^0.46.0", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "zod": "^4.6.7" } } diff --git a/prototypes/mcp-cancellation/evidence.json b/prototypes/mcp-cancellation/evidence.json index 2b23f39..4201916 100644 --- a/prototypes/mcp-cancellation/evidence.json +++ b/prototypes/mcp-cancellation/evidence.json @@ -1,9 +1,9 @@ { "question": "Can one caller-owned AbortSignal promptly cancel Client.callTool while preserving stdio and Streamable HTTP connection reuse?", "runtime": { - "bun": "1.3.14", - "sdk": "1.29.0", - "sdkLock": "@modelcontextprotocol/sdk@1.29.0" + "bun": "1.4.2", + "sdk": "2.4.0", + "sdkLock": "@modelcontextprotocol/client@2.4.0" }, "abortReason": "originating CLI socket closed", "transports": [ @@ -16,9 +16,9 @@ "status": "rejected", "elapsedMs": 16, "error": { - "name": "McpError", - "code": -32001, - "message": "MCP error -32001: Error: originating CLI socket closed" + "name": "SdkError", + "code": "REQUEST_TIMEOUT", + "message": "Error: originating CLI socket closed" } }, "reuseBeforeLate": "echo-ok", @@ -76,11 +76,11 @@ "scenario": "ignore", "settlement": { "status": "rejected", - "elapsedMs": 15, + "elapsedMs": 17, "error": { - "name": "McpError", - "code": -32001, - "message": "MCP error -32001: Error: originating CLI socket closed" + "name": "SdkError", + "code": "REQUEST_TIMEOUT", + "message": "Error: originating CLI socket closed" } }, "reuseBeforeLate": "echo-ok", @@ -131,11 +131,11 @@ "scenario": "race", "settlement": { "status": "rejected", - "elapsedMs": 15, + "elapsedMs": 17, "error": { - "name": "McpError", - "code": -32001, - "message": "MCP error -32001: Error: originating CLI socket closed" + "name": "SdkError", + "code": "REQUEST_TIMEOUT", + "message": "Error: originating CLI socket closed" } }, "reuseBeforeLate": "echo-ok", @@ -186,7 +186,7 @@ "scenario": "complete-before-cancel", "settlement": { "status": "fulfilled", - "elapsedMs": 6, + "elapsedMs": 9, "text": "complete-before-cancel" }, "reuseBeforeLate": "echo-ok", @@ -207,17 +207,6 @@ "id": 10, "scenario": "complete-before-cancel" }, - { - "event": "message", - "method": "notifications/cancelled" - }, - { - "event": "cancel", - "requestId": 10, - "reason": "Error: originating CLI socket closed", - "scenario": "complete-before-cancel", - "pending": false - }, { "event": "message", "method": "tools/call", @@ -252,7 +241,6 @@ "local-session", "local-session", "local-session", - "local-session", "local-session" ], "scenarios": [ @@ -260,11 +248,11 @@ "scenario": "acknowledge", "settlement": { "status": "rejected", - "elapsedMs": 16, + "elapsedMs": 18, "error": { - "name": "McpError", - "code": -32001, - "message": "MCP error -32001: Error: originating CLI socket closed" + "name": "SdkError", + "code": "REQUEST_TIMEOUT", + "message": "Error: originating CLI socket closed" } }, "reuseBeforeLate": "echo-ok", @@ -315,11 +303,11 @@ "scenario": "ignore", "settlement": { "status": "rejected", - "elapsedMs": 16, + "elapsedMs": 17, "error": { - "name": "McpError", - "code": -32001, - "message": "MCP error -32001: Error: originating CLI socket closed" + "name": "SdkError", + "code": "REQUEST_TIMEOUT", + "message": "Error: originating CLI socket closed" } }, "reuseBeforeLate": "echo-ok", @@ -372,11 +360,11 @@ "scenario": "race", "settlement": { "status": "rejected", - "elapsedMs": 16, + "elapsedMs": 17, "error": { - "name": "McpError", - "code": -32001, - "message": "MCP error -32001: Error: originating CLI socket closed" + "name": "SdkError", + "code": "REQUEST_TIMEOUT", + "message": "Error: originating CLI socket closed" } }, "reuseBeforeLate": "echo-ok", @@ -429,7 +417,7 @@ "scenario": "complete-before-cancel", "settlement": { "status": "fulfilled", - "elapsedMs": 6, + "elapsedMs": 8, "text": "complete-before-cancel" }, "reuseBeforeLate": "echo-ok", @@ -451,18 +439,6 @@ "id": 10, "scenario": "complete-before-cancel" }, - { - "event": "message", - "method": "notifications/cancelled" - }, - { - "event": "cancel", - "requestId": 10, - "reason": "Error: originating CLI socket closed", - "scenario": "complete-before-cancel", - "pending": false, - "originalRequestAborted": false - }, { "event": "message", "method": "tools/call", diff --git a/prototypes/mcp-cancellation/findings.md b/prototypes/mcp-cancellation/findings.md index 55770c6..1ac3b28 100644 --- a/prototypes/mcp-cancellation/findings.md +++ b/prototypes/mcp-cancellation/findings.md @@ -18,6 +18,16 @@ description: Observed SDK 1.29.0 cancellation behavior and a proposed MCPX adapt - Raw observations: `evidence.json`. - Reproduction: `bun run prototype:cancellation:evidence`. +### Re-run on SDK v2 + +`evidence.json` now records Bun `1.4.2` with `@modelcontextprotocol/client@2.4.0`. +Every verdict below holds. Two observations differ: an aborted call rejects with +`SdkError` (`REQUEST_TIMEOUT`) instead of `McpError` `-32001` — MCPX already +owns the cancellation cause rather than inferring it from the SDK error — and +`complete-before-cancel` no longer sends a stray `notifications/cancelled` for +a request that already completed. The v1 source references below are kept as +the original evidence. + The fixture deliberately controls both compliant and adversarial server outcomes. SDK implementation checks use the installed files at: diff --git a/prototypes/mcp-cancellation/run.ts b/prototypes/mcp-cancellation/run.ts index c9ac8c9..0a666c4 100644 --- a/prototypes/mcp-cancellation/run.ts +++ b/prototypes/mcp-cancellation/run.ts @@ -1,6 +1,8 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' import fs from 'node:fs/promises' import path from 'node:path' import { createInterface } from 'node:readline/promises' @@ -107,7 +109,7 @@ async function runMatrix(): Promise { const sdkPackage = await Bun.file( path.join( prototypeDir, - '../../node_modules/@modelcontextprotocol/sdk/package.json', + '../../node_modules/@modelcontextprotocol/client/package.json', ), ).json() const transports = await Promise.all([ @@ -120,7 +122,7 @@ async function runMatrix(): Promise { runtime: { bun: Bun.version, sdk: sdkPackage.version, - sdkLock: '@modelcontextprotocol/sdk@1.29.0', + sdkLock: `@modelcontextprotocol/client@${sdkPackage.version}`, }, abortReason, transports, @@ -157,7 +159,6 @@ async function runScenario( const startedAt = performance.now() const promise = fixture.client.callTool( { name: 'controlled', arguments: { scenario } }, - undefined, { signal: controller.signal, timeout: 1_000 }, ) @@ -213,7 +214,6 @@ async function settle( async function echo(client: Client): Promise { const result = await client.callTool( { name: 'echo', arguments: {} }, - undefined, { timeout: 1_000 }, ) return resultText(result) diff --git a/src/auth-discovery.ts b/src/auth-discovery.ts deleted file mode 100644 index 74a49a5..0000000 --- a/src/auth-discovery.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { AuthDiscovery } from './types' - -export async function discoverAuth( - url: URL, - headers: Record, -): Promise { - const response = await fetch(url, { method: 'GET', headers }) - - if (response.status !== 401 && response.status !== 403) { - return { kind: 'none' } - } - - const authenticate = response.headers.get('www-authenticate') - const metadataUrls = parseResourceMetadataUrls(authenticate) - if (metadataUrls.length === 0) { - return { - kind: 'unknown', - reason: `Server returned ${response.status} without OAuth resource metadata.`, - } - } - - for (const metadataUrl of metadataUrls) { - try { - const metadataResponse = await fetch(metadataUrl, { - headers: { Accept: 'application/json' }, - }) - if (!metadataResponse.ok) continue - - const metadata = (await metadataResponse.json()) as Record< - string, - unknown - > - const result: AuthDiscovery = { - kind: 'oauth', - confidence: 'confirmed', - resourceMetadataUrl: metadataUrl, - } - const authorizationServers = stringArray(metadata.authorization_servers) - if (authorizationServers) - result.authorizationServers = authorizationServers - const scopesSupported = stringArray(metadata.scopes_supported) - if (scopesSupported) result.scopesSupported = scopesSupported - return result - } catch { - continue - } - } - - const fallbackMetadataUrl = metadataUrls[metadataUrls.length - 1]! - return { - kind: 'oauth', - confidence: 'inferred', - resourceMetadataUrl: fallbackMetadataUrl, - } -} - -export function parseResourceMetadataUrl( - header: string | null, -): string | undefined { - return parseResourceMetadataUrls(header).at(-1) -} - -export function parseResourceMetadataUrls(header: string | null): string[] { - if (!header) return [] - const matches = header.matchAll(/resource_metadata="([^"]+)"/gi) - return [...matches] - .map((match) => match[1]) - .filter((url): url is string => !!url) -} - -function stringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined - const entries = value.filter( - (entry): entry is string => typeof entry === 'string', - ) - return entries.length > 0 ? entries : undefined -} diff --git a/src/discovery.ts b/src/discovery.ts index 4747e44..f636514 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -6,10 +6,8 @@ import type { ToolDefinition, } from './types' -import { discoverAuth } from './auth-discovery' import { authFromBearerValues, describeBearerAuth } from './bearer' -import { resolveProbeHeaders } from './headers' -import { listMcpTools } from './mcp-client' +import { authorizationChallenge, listMcpTools } from './mcp-client' import { assignCommandNames } from './names' export type DiscoverServerOptions = { @@ -36,20 +34,13 @@ export async function discoverServer( const url = new URL(options.url) const configuredAuth = authFromBearerValues(options.bearer) - const seedServer: ServerConfig = { + const server: ServerConfig = { transport: 'http', url: url.toString(), auth: configuredAuth ?? { kind: 'none' }, - } - if (options.headers) seedServer.headers = options.headers - - const discoveredAuth = - configuredAuth ?? (await discoverAuth(url, resolveProbeHeaders(seedServer))) - const server: ServerConfig = { - ...seedServer, - auth: discoveredAuth, discoveredAt: new Date().toISOString(), } + if (options.headers) server.headers = options.headers try { const tools = await listMcpTools(server, options.name) @@ -61,21 +52,22 @@ export async function discoverServer( } } catch (error) { const message = error instanceof Error ? error.message : String(error) - // @add never starts OAuth (ADR-0007), so name the one command that does; - // the transport error for an unauthenticated POST is empty noise here. - if (discoveredAuth.kind === 'oauth') { + // The challenge, not a probe, says the server wants OAuth (ADR 0009). + // @add never starts OAuth, so name the commands that do. + if (!configuredAuth && authorizationChallenge(error)) { + server.auth = { kind: 'oauth' } return { server, status: 'auth-required', message: - 'Authentication is required before tool schemas can be listed. Run `mcpx @refresh` to authenticate.', + 'Authentication is required before tool schemas can be listed. Run `mcpx @refresh` or call a tool to authenticate.', } } - if (discoveredAuth.kind === 'unknown') { + if (configuredAuth && authorizationChallenge(error)) { return { server, status: 'auth-required', - message: `Authentication is required before tool schemas can be listed. ${message}`, + message: `The configured bearer credential was rejected. ${message}`, } } return { server, status: 'unreachable', message } @@ -136,11 +128,7 @@ export function describeAuth(auth: AuthDiscovery): string { case 'bearer': return describeBearerAuth(auth) case 'oauth': - return `oauth ${auth.confidence}` - case 'oauth-token': - return 'oauth token' - case 'unknown': - return `unknown: ${auth.reason}` + return 'oauth' } } diff --git a/src/headers.ts b/src/headers.ts index c57eb5a..5d3a107 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -1,54 +1,18 @@ import type { HttpServerConfig } from './types' -import { resolveBearerHeader, resolveBearerHeaderForProbe } from './bearer' - -export type ResolvedHeaders = { - headers: Record - authRefreshed: boolean -} - -export function resolveProbeHeaders( - server: HttpServerConfig, -): Record { - const headers = baseHeaders(server) - - if (server.auth.kind === 'bearer') { - headers.Authorization = resolveBearerHeaderForProbe(server.auth) - } - - return headers -} +import { resolveBearerHeader } from './bearer' export async function resolveHeaders( server: HttpServerConfig, ): Promise> { - return (await resolveHeadersWithState(server)).headers -} - -export async function resolveHeadersWithState( - server: HttpServerConfig, -): Promise { - const headers = baseHeaders(server) + const headers: Record = { + Accept: 'application/json, text/event-stream', + ...(server.headers ?? {}), + } if (server.auth.kind === 'bearer') { headers.Authorization = await resolveBearerHeader(server.url, server.auth) } - return { headers, authRefreshed: false } -} - -function baseHeaders(server: HttpServerConfig): Record { - return { - Accept: 'application/json, text/event-stream', - ...(server.headers ?? {}), - } -} - -export function normalizeAuthScheme(tokenType: string): string { - const normalized = tokenType.toLowerCase() - return normalized === 'bearer' || - normalized === 'bot' || - normalized === 'user' - ? 'Bearer' - : tokenType + return headers } diff --git a/src/mcp-client.ts b/src/mcp-client.ts index 39c9061..d514a96 100644 --- a/src/mcp-client.ts +++ b/src/mcp-client.ts @@ -1,10 +1,17 @@ -import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js' +import type { AuthProvider, RequestOptions } from '@modelcontextprotocol/client' import type { Stream } from 'node:stream' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { + Client, + extractWWWAuthenticateParams, + InsufficientScopeError, + SdkHttpError, + StreamableHTTPClientTransport, + UnauthorizedError, +} from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' +import type { OAuthChallenge } from './oauth' import type { McpTool, ServerConfig, ToolAnnotations } from './types' import { resolveHeaders } from './headers' @@ -36,10 +43,13 @@ export type McpConnection = { stderr: Stream | null sessionId: () => string | undefined updateHeaders: (headers: Record) => void + /** The bearer token sent with every later request; `undefined` sends none. */ + updateToken: (token: string | undefined) => void } export type ConnectMcpClientOptions = { headers?: Record | undefined + token?: string | undefined sessionId?: string | undefined onNotification?: (notification: { method: string; params?: unknown }) => void } @@ -71,12 +81,13 @@ export async function connectMcpClient( server.transport === 'stdio' ? undefined : (options.headers ?? (await resolveHeaders(server))) + let token = options.token const transport = server.transport === 'stdio' ? new StdioClientTransport(stdioTransportParams(server)) : new StreamableHTTPClientTransport( new URL(server.url), - httpTransportOptions(httpHeaders, options), + httpTransportOptions(httpHeaders, options, () => token), ) await client.connect(transport as never) @@ -103,16 +114,77 @@ export async function connectMcpClient( for (const key of Object.keys(httpHeaders)) delete httpHeaders[key] Object.assign(httpHeaders, headers) }, + updateToken: (next) => { + token = next + }, + } +} + +/** + * Rejection of a request by the resource server's auth layer. The Runtime, not + * the transport, owns what happens next: the challenge is the discovery entry + * for OAuth at any time, not only when a server is added. + */ +export class AuthorizationChallengeError extends Error { + readonly challenge: OAuthChallenge + + constructor(challenge: OAuthChallenge) { + super( + challenge.status === 401 + ? 'Unauthorized' + : `Insufficient scope${challenge.scope ? `: required "${challenge.scope}"` : ''}`, + ) + this.name = 'AuthorizationChallengeError' + this.challenge = challenge + } +} + +export function authorizationChallenge( + error: unknown, +): OAuthChallenge | undefined { + if (error instanceof AuthorizationChallengeError) return error.challenge + if (error instanceof InsufficientScopeError) { + const challenge: OAuthChallenge = { status: 403 } + if (error.requiredScope) challenge.scope = error.requiredScope + if (error.resourceMetadataUrl) + challenge.resourceMetadataUrl = error.resourceMetadataUrl.toString() + return challenge + } + if (error instanceof UnauthorizedError) return { status: 401 } + if (error instanceof SdkHttpError && error.status === 401) + return { status: 401 } + return undefined +} + +// The transport asks for the token before every request and reports a 401 +// here instead of running the SDK's own OAuth flow inside the request. +function challengeAuthProvider(token: () => string | undefined): AuthProvider { + return { + token: async () => token(), + onUnauthorized: async ({ response }) => { + const { resourceMetadataUrl, scope } = + extractWWWAuthenticateParams(response) + const challenge: OAuthChallenge = { status: 401 } + if (resourceMetadataUrl) + challenge.resourceMetadataUrl = resourceMetadataUrl.toString() + if (scope) challenge.scope = scope + throw new AuthorizationChallengeError(challenge) + }, } } function httpTransportOptions( headers: Record | undefined, options: ConnectMcpClientOptions, + token: () => string | undefined, ): ConstructorParameters[1] { const transportOptions: NonNullable< ConstructorParameters[1] - > = {} + > = { + authProvider: challengeAuthProvider(token), + // A 403 step-up is an Authentication Flow the Runtime coordinates. + onInsufficientScope: 'throw', + } if (headers) transportOptions.requestInit = { headers } if (options.sessionId) transportOptions.sessionId = options.sessionId return transportOptions @@ -175,7 +247,6 @@ export async function callMcpTool( return withMcpClient(server, async (client) => client.callTool( { name: toolName, arguments: input }, - undefined, toolCallRequestOptions(), ), ) diff --git a/src/oauth.ts b/src/oauth.ts index d0f26cc..6adb8ce 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,11 +1,48 @@ -import { createHash, randomBytes } from 'node:crypto' +import type { + FetchLike, + OAuthClientInformationContext, + OAuthClientMetadata, + OAuthClientProvider, + OAuthDiscoveryState, + StoredOAuthClientInformation, + StoredOAuthTokens, +} from '@modelcontextprotocol/client' + +import { + auth, + computeScopeUnion, + isStrictScopeSuperset, +} from '@modelcontextprotocol/client' +import { randomBytes } from 'node:crypto' + +import type { AuthenticationProgress } from './authentication-coordinator' + +/** One server's OAuth grant in the Credential Store, in the SDK's stored shapes. */ +export type OAuthGrant = { + client?: { + information: StoredOAuthClientInformation + /** `manual` clients were entered by the user for a fixed redirect URI. */ + registration: 'dynamic' | 'manual' + redirectUri?: string + } + tokens?: { + value: StoredOAuthTokens + expiresAt?: string + } +} -import type { AuthDiscovery, OAuthServerMetadata, OAuthToken } from './types' +/** What the resource server asked for when it rejected a request. */ +export type OAuthChallenge = { + status: 401 | 403 + resourceMetadataUrl?: string + scope?: string +} -type OAuthClientRegistration = { - clientId: string - clientSecret?: string - clientSecretKey?: string +export type OAuthGrantStore = { + read: () => Promise + update: ( + change: (grant: OAuthGrant | undefined) => OAuthGrant | undefined, + ) => Promise } export type ManualOAuthClientRequest = { @@ -15,569 +52,473 @@ export type ManualOAuthClientRequest = { scopes: string[] } -export type ManualOAuthClientProvider = ( - request: ManualOAuthClientRequest, -) => Promise +export type ManualOAuthClient = { clientId: string; clientSecret: string } -type OAuthCallbackResult = { - code: string - state: string -} - -type OAuthCallbackServer = { - redirectUri: string - result: Promise - close: () => void +export type AuthorizeOAuthOptions = { + serverName: string + serverUrl: URL + grants: OAuthGrantStore + challenge?: OAuthChallenge | undefined + signal: AbortSignal + requestManualClient: ( + request: ManualOAuthClientRequest, + ) => Promise + report: (progress: AuthenticationProgress) => void + openBrowser?: (url: string) => void } -type AuthenticatedOAuth = Extract -type DiscoveredOAuth = Extract - const LOCALHOST = '127.0.0.1' const CALLBACK_PATH = '/callback' -const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // Manual OAuth clients require a pre-registered redirect URI; random ports break that contract. const MANUAL_CALLBACK_PORT = 65245 +const MANUAL_REDIRECT_URI = `http://${LOCALHOST}:${MANUAL_CALLBACK_PORT}${CALLBACK_PATH}` +const EXPIRY_MARGIN_MS = 60_000 -export async function performOAuthAuthentication( - serverName: string, - resourceUrl: URL, - auth: DiscoveredOAuth, - signal?: AbortSignal, - manualClientProvider?: ManualOAuthClientProvider, - onAuthorizationUrl?: (url: string) => void, -): Promise<{ - auth: AuthenticatedOAuth - token: OAuthToken - clientSecret?: string -}> { - const authorizationServer = auth.authorizationServers?.[0] - if (!authorizationServer) { - throw new Error( - 'OAuth authentication requires an authorization server URL.', - ) - } +export function oauthAccessToken( + grant: OAuthGrant | undefined, + now: Date = new Date(), +): string | undefined { + const tokens = grant?.tokens + if (!tokens) return undefined + if (!tokens.expiresAt) return tokens.value.access_token + const expiresAt = Date.parse(tokens.expiresAt) + // An unreadable expiry cannot prove the token is usable. + if (!Number.isFinite(expiresAt)) return undefined + return expiresAt - now.getTime() > EXPIRY_MARGIN_MS + ? tokens.value.access_token + : undefined +} - const metadata = await fetchAuthorizationServerMetadata(authorizationServer, { - signal, - }) - if ( - !metadata.registrationEndpoint && - metadata.tokenEndpointAuthMethodsSupported && - !metadata.tokenEndpointAuthMethodsSupported.includes('client_secret_post') - ) { - throw new Error( - 'OAuth server requires a client authentication method mcpx cannot use yet.', - ) +/** + * Makes a server's grant usable through the SDK's `auth()`: a silent refresh + * when the stored grant allows one, otherwise one browser authorization. Every + * credential the SDK produces is written through to the Credential Store as it + * arrives, so a rotated refresh token survives a cancelled flow. + */ +export async function authorizeOAuth( + options: AuthorizeOAuthOptions, +): Promise { + const { challenge, signal } = options + const grant = await options.grants.read() + const grantedScope = grant?.tokens?.value.scope + // A 403 step-up keeps what was granted and adds what the server asked for. + const scope = + challenge?.status === 403 + ? computeScopeUnion(grantedScope, challenge.scope) + : challenge?.scope + const widensScope = + challenge?.status === 403 && isStrictScopeSuperset(scope, grantedScope) + const base = { + serverUrl: options.serverUrl, + scope, + resourceMetadataUrl: challenge?.resourceMetadataUrl + ? new URL(challenge.resourceMetadataUrl) + : undefined, + fetchFn: oauthFetch(signal), } - const verifier = base64Url(randomBytes(32)) - const challenge = base64Url(createHash('sha256').update(verifier).digest()) - const state = base64Url(randomBytes(24)) - const manualClient = metadata.registrationEndpoint - ? undefined - : await requireManualClientProvider(manualClientProvider)({ - serverName, - redirectUri: `http://${LOCALHOST}:${MANUAL_CALLBACK_PORT}${CALLBACK_PATH}`, - issuer: metadata.issuer, - scopes: auth.scopesSupported ?? [], + + // A refresh grant cannot widen scope (RFC 6749 §6), so step-up skips it. + if (grant?.client && grant.tokens?.value.refresh_token && !widensScope) { + options.report({ + phase: 'refreshing-token', + message: 'refreshing OAuth token', + }) + try { + const provider = new McpxOAuthProvider(options, grant, 'refresh') + if ((await auth(provider, base)) === 'AUTHORIZED') return + } catch (error) { + if (signal.aborted) throw signal.reason ?? error + // A revoked grant is repaired by a fresh authorization, not by asking the + // user to remove and re-add the server. + options.report({ + phase: 'refreshing-token', + message: + error instanceof InteractionRequired + ? 'OAuth token could not be refreshed; starting browser authorization' + : `token refresh failed (${errorMessage(error)}); starting browser authorization`, }) - const callback = await waitForOAuthCallback( - state, - manualClient ? MANUAL_CALLBACK_PORT : 0, - signal, - ) + } + } + const state = randomBytes(24).toString('base64url') + const callback = await OAuthCallback.start(state, signal) try { - const client = - manualClient ?? - (await registerOAuthClient(metadata, callback.redirectUri, { signal })) - const scope = chooseOAuthScope( - auth.scopesSupported, - metadata.scopesSupported, - ) - const authorizationUrl = buildAuthorizationUrl({ - metadata, - clientId: client.clientId, - redirectUri: callback.redirectUri, - resourceUrl: resourceUrl.toString(), - scope, + const provider = new McpxOAuthProvider( + options, + grant, + 'interactive', + callback, state, - challenge, + ) + if ((await auth(provider, base)) === 'AUTHORIZED') return + const params = await callback.result + const code = params.get('code') + if (!code) throw new Error('Invalid OAuth callback.') + const iss = params.get('iss') ?? undefined + // RFC 9207: the SDK checks `iss` against the issuer the redirect targeted. + await auth(provider, { + ...base, + authorizationCode: code, + ...(iss ? { iss } : {}), }) - - // The Runtime's stderr is detached; callers must receive the URL as an event - // so a failed browser launch never turns into a silent wait. - onAuthorizationUrl?.(authorizationUrl) - openBrowser(authorizationUrl) - - const callbackResult = await callback.result - const exchangeOptions: { - metadata: OAuthServerMetadata - clientId: string - clientSecret?: string - redirectUri: string - resourceUrl: string - code: string - verifier: string - signal?: AbortSignal - } = { - metadata, - clientId: client.clientId, - redirectUri: callback.redirectUri, - resourceUrl: resourceUrl.toString(), - code: callbackResult.code, - verifier, - } - if (client.clientSecret) exchangeOptions.clientSecret = client.clientSecret - if (signal) exchangeOptions.signal = signal - const token = await exchangeAuthorizationCode(exchangeOptions) - - const tokenKey = `${serverName}:${metadata.issuer}` - const completed: { - auth: AuthenticatedOAuth - token: OAuthToken - clientSecret?: string - } = { - auth: { kind: 'oauth-token', tokenKey, confidence: 'confirmed' }, - token, - } - if (client.clientSecret) completed.clientSecret = client.clientSecret - return completed } finally { callback.close() } } -export async function fetchAuthorizationServerMetadata( - issuer: string, - options: { signal?: AbortSignal } = {}, -): Promise { - const metadataUrls = authorizationServerMetadataUrls(issuer) - const failures: string[] = [] - - for (const metadataUrl of metadataUrls) { - const response = await fetch(metadataUrl, { - headers: { Accept: 'application/json' }, - signal: options.signal, - }) - if (!response.ok) { - failures.push(`${metadataUrl} (${response.status})`) - continue - } - - return parseAuthorizationServerMetadata( - await response.json(), - metadataUrl.toString(), - issuer, - ) - } - - throw new Error( - `Failed to fetch OAuth server metadata from ${failures.join(', ')}.`, - ) -} - -function authorizationServerMetadataUrls(issuer: string): URL[] { - const issuerUrl = new URL(issuer) - const issuerPath = issuerUrl.pathname === '/' ? '' : issuerUrl.pathname - const urls = [ - // RFC 8414 inserts the well-known path between host and issuer path. - new URL( - `/.well-known/oauth-authorization-server${issuerPath}`, - issuerUrl.origin, - ), - ] - - const issuerRelativePath = `${issuerPath.replace(/\/?$/, '/')}.well-known/oauth-authorization-server` - const issuerRelativeUrl = new URL(issuerRelativePath, issuerUrl.origin) - if (!urls.some((url) => url.toString() === issuerRelativeUrl.toString())) { - urls.push(issuerRelativeUrl) +class InteractionRequired extends Error { + constructor() { + super('OAuth authorization requires the browser.') + this.name = 'InteractionRequired' } - return urls } -function parseAuthorizationServerMetadata( - payload: unknown, - metadataUrl: string, - issuer: string, -): OAuthServerMetadata { - const metadata = payload as Record - const authorizationEndpoint = stringField(metadata.authorization_endpoint) - const tokenEndpoint = stringField(metadata.token_endpoint) - const issuerValue = stringField(metadata.issuer) ?? issuer - if (!authorizationEndpoint || !tokenEndpoint) { - throw new Error( - `OAuth server metadata at ${metadataUrl} is missing required endpoints.`, - ) - } - - const result: OAuthServerMetadata = { - issuer: issuerValue, - authorizationEndpoint, - tokenEndpoint, +/** + * The SDK drives the OAuth state machine; this provider supplies storage and + * the browser hand-off. `refresh` mode may only refresh: reaching the browser + * step aborts it. `interactive` mode is a new sign-in on this flow's loopback + * callback, so it never replays the stored tokens or a dynamic client + * registered for another port. + */ +class McpxOAuthProvider implements OAuthClientProvider { + readonly #options: AuthorizeOAuthOptions + readonly #stored: OAuthGrant | undefined + readonly #mode: 'refresh' | 'interactive' + readonly #callback: OAuthCallback | undefined + readonly #state: string | undefined + #client: OAuthGrant['client'] + #tokensInvalidated = false + #verifier: string | undefined + #discovery: OAuthDiscoveryState | undefined + + constructor( + options: AuthorizeOAuthOptions, + stored: OAuthGrant | undefined, + mode: 'refresh' | 'interactive', + callback?: OAuthCallback, + state?: string, + ) { + this.#options = options + this.#stored = stored + this.#mode = mode + this.#callback = callback + this.#state = state + if (mode === 'refresh') this.#client = stored?.client } - const registrationEndpoint = stringField(metadata.registration_endpoint) - if (registrationEndpoint) result.registrationEndpoint = registrationEndpoint - const scopesSupported = stringArray(metadata.scopes_supported) - if (scopesSupported) result.scopesSupported = scopesSupported - const methods = stringArray(metadata.code_challenge_methods_supported) - if (methods) result.codeChallengeMethodsSupported = methods - const authMethods = stringArray( - metadata.token_endpoint_auth_methods_supported, - ) - if (authMethods) result.tokenEndpointAuthMethodsSupported = authMethods - return result -} -export async function registerOAuthClient( - metadata: OAuthServerMetadata, - redirectUri: string, - options: { signal?: AbortSignal } = {}, -): Promise { - if (!metadata.registrationEndpoint) { - throw new Error( - 'OAuth server does not advertise dynamic client registration.', - ) + get redirectUrl(): string { + if (this.#callback) return this.#callback.redirectUri + return this.#client?.redirectUri ?? MANUAL_REDIRECT_URI } - const response = await fetch(metadata.registrationEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ + get clientMetadata(): OAuthClientMetadata { + return { client_name: 'MCPX', - redirect_uris: [redirectUri], + redirect_uris: [this.redirectUrl], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', application_type: 'native', - }), - signal: options.signal, - }) - - if (!response.ok) { - throw new Error( - `OAuth dynamic client registration failed: ${await response.text()}`, - ) + } } - const body = (await response.json()) as Record - const clientId = stringField(body.client_id) - if (!clientId) { - throw new Error( - 'OAuth dynamic client registration response did not include client_id.', - ) + state(): string { + return this.#state ?? randomBytes(24).toString('base64url') } - return { clientId } -} - -export function chooseOAuthScope( - resourceScopes: string[] | undefined, - serverScopes: string[] | undefined, -): string { - const supported = new Set(serverScopes ?? []) - const scopes = (resourceScopes ?? []).filter((scope) => { - return supported.size === 0 || supported.has(scope) - }) - return scopes.join(' ') -} -export function shouldRefreshOAuthToken( - token: OAuthToken, - now: Date = new Date(), -): boolean { - if (!token.expiresAt) return false - const expiresAt = Date.parse(token.expiresAt) - // An unreadable expiry cannot prove the token is usable. - if (!Number.isFinite(expiresAt)) return true - return expiresAt - now.getTime() <= 60_000 -} + async clientInformation( + ctx?: OAuthClientInformationContext, + ): Promise { + if (this.#client) return this.#client.information + if (this.#mode === 'refresh') throw new InteractionRequired() + + const stored = this.#stored?.client + if ( + stored?.registration === 'manual' && + issuersMatch(stored.information.issuer, ctx?.issuer) + ) { + await this.#callback?.listen(MANUAL_CALLBACK_PORT) + this.#client = stored + return stored.information + } + const metadata = this.#discovery?.authorizationServerMetadata + // Without metadata the SDK falls back to the conventional /register path. + if (!metadata || metadata.registration_endpoint) return undefined -export async function refreshOAuthToken(options: { - issuer: string - resourceUrl: string - token: OAuthToken - clientSecret?: string - signal?: AbortSignal -}): Promise { - if (!options.token.refreshToken) { - throw new Error('OAuth token has no refresh token.') - } - if (!options.token.clientId) { - throw new Error('OAuth token has no client_id to refresh with.') - } - const metadata = await fetchAuthorizationServerMetadata(options.issuer, { - signal: options.signal, - }) - const body = new URLSearchParams({ - grant_type: 'refresh_token', - client_id: options.token.clientId, - refresh_token: options.token.refreshToken, - resource: options.resourceUrl, - }) - const clientSecret = options.clientSecret - if (options.token.clientSecretKey && !clientSecret) { - throw new Error('OAuth client secret is missing.') + this.#options.report({ + phase: 'awaiting-input', + message: 'waiting for OAuth client credentials in the terminal', + }) + const issuer = ctx?.issuer ?? metadata.issuer + const manual = await this.#options.requestManualClient({ + serverName: this.#options.serverName, + redirectUri: MANUAL_REDIRECT_URI, + issuer, + scopes: this.#discovery?.resourceMetadata?.scopes_supported ?? [], + }) + await this.#callback?.listen(MANUAL_CALLBACK_PORT) + this.#client = { + registration: 'manual', + redirectUri: MANUAL_REDIRECT_URI, + information: { + client_id: manual.clientId, + client_secret: manual.clientSecret, + issuer, + }, + } + return this.#client.information } - if (clientSecret) body.set('client_secret', clientSecret) - - const response = await fetch(metadata.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, - body, - signal: options.signal, - }) - if (!response.ok) { - throw new Error(`OAuth token refresh failed: ${await response.text()}`) + async saveClientInformation( + information: StoredOAuthClientInformation, + ): Promise { + this.#client = + this.#client?.information.client_id === information.client_id + ? { ...this.#client, information } + : { + information, + registration: 'dynamic', + redirectUri: this.redirectUrl, + } + // An interactive client is persisted with the tokens it earns, so a failed + // sign-in never replaces a working grant. A refresh only back-stamps. + if (this.#mode === 'refresh') { + await this.#options.grants.update((grant) => + grant?.client?.information.client_id === information.client_id + ? { ...grant, client: { ...grant.client, information } } + : grant, + ) + } } - const fallback: { - clientId: string - clientSecretKey?: string - refreshToken: string - } = { - clientId: options.token.clientId, - refreshToken: options.token.refreshToken, + tokens(): StoredOAuthTokens | undefined { + if (this.#mode === 'interactive' || this.#tokensInvalidated) + return undefined + return this.#stored?.tokens?.value } - if (options.token.clientSecretKey) - fallback.clientSecretKey = options.token.clientSecretKey - return parseOAuthTokenResponse(await response.json(), fallback) -} -function buildAuthorizationUrl(options: { - metadata: OAuthServerMetadata - clientId: string - redirectUri: string - resourceUrl: string - scope: string - state: string - challenge: string -}): string { - const url = new URL(options.metadata.authorizationEndpoint) - url.searchParams.set('response_type', 'code') - url.searchParams.set('client_id', options.clientId) - url.searchParams.set('redirect_uri', options.redirectUri) - url.searchParams.set('code_challenge', options.challenge) - url.searchParams.set('code_challenge_method', 'S256') - url.searchParams.set('state', options.state) - url.searchParams.set('resource', options.resourceUrl) - if (options.scope) url.searchParams.set('scope', options.scope) - return url.toString() -} - -async function exchangeAuthorizationCode(options: { - metadata: OAuthServerMetadata - clientId: string - clientSecret?: string - redirectUri: string - resourceUrl: string - code: string - verifier: string - signal?: AbortSignal -}): Promise { - const body = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: options.clientId, - redirect_uri: options.redirectUri, - code: options.code, - code_verifier: options.verifier, - resource: options.resourceUrl, - }) - if (options.clientSecret) body.set('client_secret', options.clientSecret) - - const response = await fetch(options.metadata.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, - body, - signal: options.signal, - }) + async saveTokens(tokens: StoredOAuthTokens): Promise { + const entry: NonNullable = { value: tokens } + if (tokens.expires_in !== undefined) { + entry.expiresAt = new Date( + Date.now() + tokens.expires_in * 1000, + ).toISOString() + } + const client = this.#client + await this.#options.grants.update((grant) => { + // An issuer back-stamp re-saves the same token without `expires_in`. + if ( + !entry.expiresAt && + grant?.tokens?.expiresAt && + grant.tokens.value.access_token === tokens.access_token + ) + entry.expiresAt = grant.tokens.expiresAt + const next: OAuthGrant = { tokens: entry } + const nextClient = client ?? grant?.client + if (nextClient) next.client = nextClient + return next + }) + } - if (!response.ok) { - throw new Error(`OAuth token exchange failed: ${await response.text()}`) + redirectToAuthorization(authorizationUrl: URL): void { + if (this.#mode === 'refresh') throw new InteractionRequired() + const url = authorizationUrl.toString() + // The Runtime's stderr is detached; callers must receive the URL as an event + // so a failed browser launch never turns into a silent wait. + this.#options.report({ + phase: 'awaiting-browser', + message: `authorize in your browser (waiting up to 5 minutes): ${url}`, + url, + }) + ;(this.#options.openBrowser ?? openBrowser)(url) } - const fallback: { - clientId: string - clientSecretKey?: string - } = { - clientId: options.clientId, + saveCodeVerifier(verifier: string): void { + this.#verifier = verifier } - if (options.clientSecret) - fallback.clientSecretKey = clientSecretKey(options.clientId) - return parseOAuthTokenResponse(await response.json(), fallback) -} -export function parseOAuthTokenResponse( - payload: unknown, - fallback: { - clientId: string - clientSecretKey?: string - refreshToken?: string - }, -): OAuthToken { - const body = parseSuccessfulTokenPayload(payload) - const tokenBody = selectTokenBody(body) - const accessToken = stringField(tokenBody.access_token) - const tokenType = stringField(tokenBody.token_type) ?? 'Bearer' - if (!accessToken) { - throw new Error('OAuth token response did not include access_token.') + codeVerifier(): string { + if (!this.#verifier) throw new Error('OAuth code verifier is missing.') + return this.#verifier } - const token: OAuthToken = { - accessToken, - clientId: fallback.clientId, - tokenType, + saveDiscoveryState(state: OAuthDiscoveryState): void { + this.#discovery = state } - if (fallback.clientSecretKey) token.clientSecretKey = fallback.clientSecretKey - const refreshToken = - stringField(tokenBody.refresh_token) ?? fallback.refreshToken - if (refreshToken) token.refreshToken = refreshToken - const scope = stringField(tokenBody.scope) - if (scope) token.scope = scope - const expiresIn = numberField(tokenBody.expires_in) - if (expiresIn !== undefined) { - token.expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString() + + discoveryState(): OAuthDiscoveryState | undefined { + return this.#discovery } - return token -} -function requireManualClientProvider( - provider: ManualOAuthClientProvider | undefined, -): ManualOAuthClientProvider { - if (!provider) { - throw new Error( - 'Manual OAuth client authentication requires Caller Input from mcpx @refresh.', - ) + async invalidateCredentials( + scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery', + ): Promise { + if (scope === 'all' || scope === 'verifier') this.#verifier = undefined + if (scope === 'all' || scope === 'discovery') this.#discovery = undefined + const client = scope === 'all' || scope === 'client' + const tokens = scope === 'all' || scope === 'tokens' + if (client) this.#client = undefined + if (tokens) this.#tokensInvalidated = true + // A new sign-in keeps the stored grant until it succeeds. + if (this.#mode === 'interactive' || (!client && !tokens)) return + const rejected = this.#stored + await this.#options.grants.update((grant) => { + if (!grant) return grant + const next = { ...grant } + // Delete only what the server refused: another flow may have replaced it. + if ( + tokens && + grant.tokens?.value.access_token === + rejected?.tokens?.value.access_token + ) + delete next.tokens + if ( + client && + grant.client?.information.client_id === + rejected?.client?.information.client_id + ) + delete next.client + return next + }) } - return provider } -function parseSuccessfulTokenPayload( - payload: unknown, -): Record { - const body = payload as Record - if (body.ok === false) { - throw new Error( - `OAuth token response failed: ${stringField(body.error) ?? 'unknown_error'}`, - ) - } - return body +function issuersMatch( + stored: string | undefined, + current: string | undefined, +): boolean { + if (!stored || !current) return true + return stored.replace(/\/$/, '') === current.replace(/\/$/, '') } -function selectTokenBody( - body: Record, -): Record { - const authedUser = body.authed_user - if ( - authedUser && - typeof authedUser === 'object' && - stringField((authedUser as Record).access_token) - ) { - return authedUser as Record +/** + * Binds every OAuth request to the flow's lifetime and accepts Slack's token + * response, which nests a user token under `authed_user`. + */ +function oauthFetch(signal: AbortSignal): FetchLike { + return async (url, init) => { + const response = await fetch(url, { + ...init, + signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal, + }) + if ( + !(init?.body instanceof URLSearchParams) || + !init.body.has('grant_type') + ) + return response + return unwrapAuthedUser(response) } - return body } -function clientSecretKey(clientId: string): string { - return `oauth-client:${clientId}` +async function unwrapAuthedUser(response: Response): Promise { + if (!response.headers.get('content-type')?.includes('json')) return response + const body = (await response + .clone() + .json() + .catch(() => undefined)) as Record | undefined + const user = body?.authed_user as Record | undefined + if (!body || body.access_token || typeof user?.access_token !== 'string') + return response + return Response.json(user, { + status: response.status, + statusText: response.statusText, + }) } -function waitForOAuthCallback( - expectedState: string, - port: number, - signal?: AbortSignal, -): OAuthCallbackServer { - let server: Bun.Server | undefined - let timer: ReturnType | undefined - let settled = false - let closeWithError = () => {} - const result = new Promise((finish, fail) => { - const onAbort = () => { - complete( - 'fail', - signal?.reason instanceof Error +/** The loopback redirect target for one browser authorization. */ +class OAuthCallback { + readonly result: Promise + readonly #state: string + #server: Bun.Server | undefined + // A stopped Bun server no longer reports its port; the code exchange that + // follows the callback must repeat the exact redirect URI. + #redirectUri: string | undefined + #finish: (params: URLSearchParams) => void = () => {} + #fail: (error: Error) => void = () => {} + #settled = false + + private constructor(state: string, signal: AbortSignal) { + this.#state = state + this.result = new Promise((resolve, reject) => { + this.#finish = resolve + this.#fail = reject + }) + // The browser step can fail before anyone awaits the callback. + void this.result.catch(() => {}) + const onAbort = () => + this.#complete( + signal.reason instanceof Error ? signal.reason : new Error('OAuth authentication cancelled.'), ) - } - const complete = ( - outcome: 'finish' | 'fail', - value: OAuthCallbackResult | Error, - ) => { - if (settled) return - settled = true - if (timer) clearTimeout(timer) - signal?.removeEventListener('abort', onAbort) - server?.stop() - if (outcome === 'finish') { - finish(value as OAuthCallbackResult) - return - } - fail(value) - } - closeWithError = () => - complete('fail', new Error('OAuth callback server closed.')) - - timer = setTimeout(() => { - complete('fail', new Error('OAuth authentication timed out.')) - }, CALLBACK_TIMEOUT_MS) - if (signal?.aborted) { - onAbort() - return - } - signal?.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + else signal.addEventListener('abort', onAbort, { once: true }) + void this.result + .finally(() => signal.removeEventListener('abort', onAbort)) + .catch(() => {}) + } - server = Bun.serve({ + static async start( + state: string, + signal: AbortSignal, + ): Promise { + const callback = new OAuthCallback(state, signal) + await callback.listen(0) + return callback + } + + get redirectUri(): string { + if (!this.#redirectUri) + throw new Error('OAuth callback server is not running.') + return this.#redirectUri + } + + async listen(port: number): Promise { + if (this.#server && (port === 0 || this.#server.port === port)) return + this.#server?.stop(true) + this.#server = Bun.serve({ hostname: LOCALHOST, port, routes: { - [CALLBACK_PATH]: (request) => { - const requestUrl = new URL(request.url) - const code = requestUrl.searchParams.get('code') - const state = requestUrl.searchParams.get('state') - const error = requestUrl.searchParams.get('error') - if (error) { - complete('fail', new Error(`OAuth authorization failed: ${error}`)) - return new Response(`OAuth failed: ${error}`, { status: 400 }) - } - if (!code || state !== expectedState) { - complete('fail', new Error('Invalid OAuth callback.')) - return new Response('Invalid OAuth callback.', { status: 400 }) - } - - complete('finish', { code, state }) - return new Response( - 'MCPX authentication complete. You can close this tab.', - ) - }, - }, - fetch() { - return new Response('Not found', { status: 404 }) + [CALLBACK_PATH]: (request) => this.#receive(new URL(request.url)), }, + fetch: () => new Response('Not found', { status: 404 }), }) - }) - // Registration can fail before anyone awaits the callback; keep close rejection handled. - void result.catch(() => {}) + this.#redirectUri = `http://${LOCALHOST}:${this.#server.port}${CALLBACK_PATH}` + // The Authentication Flow timeout owns this lifetime, not the socket. + this.#server.unref() + } + + close(): void { + this.#complete(new Error('OAuth callback server closed.')) + } - if (!server) { - throw new Error('Failed to start local OAuth callback server.') + #receive(url: URL): Response { + const params = url.searchParams + if (params.get('state') !== this.#state) { + this.#complete(new Error('Invalid OAuth callback.')) + return new Response('Invalid OAuth callback.', { status: 400 }) + } + const error = params.get('error') + if (error) { + // `error_description` may come from a mixed-up authorization server. + this.#complete(new Error(`OAuth authorization failed: ${error}`)) + return new Response(`OAuth failed: ${error}`, { status: 400 }) + } + if (!params.get('code')) { + this.#complete(new Error('Invalid OAuth callback.')) + return new Response('Invalid OAuth callback.', { status: 400 }) + } + this.#complete(params) + return new Response('MCPX authentication complete. You can close this tab.') } - // The timeout owns the authentication lifetime; a stopped Bun server must not - // strand the CLI if its native socket cleanup lags behind the rejected promise. - server.unref() - - return { - redirectUri: `http://${LOCALHOST}:${server.port}${CALLBACK_PATH}`, - result, - close: () => { - closeWithError() - }, + + #complete(outcome: URLSearchParams | Error): void { + if (this.#settled) return + this.#settled = true + this.#server?.stop() + if (outcome instanceof Error) this.#fail(outcome) + else this.#finish(outcome) } } @@ -589,28 +530,9 @@ function openBrowser(url: string): void { ? 'cmd' : 'xdg-open' const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url] - Bun.spawn([command, ...args], { - stdout: 'ignore', - stderr: 'ignore', - }) -} - -function base64Url(input: Buffer): string { - return input.toString('base64url') -} - -function stringField(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function numberField(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined + Bun.spawn([command, ...args], { stdout: 'ignore', stderr: 'ignore' }) } -function stringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined - const values = value.filter( - (entry): entry is string => typeof entry === 'string', - ) - return values.length > 0 ? values : undefined +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) } diff --git a/src/runtime-authentication.ts b/src/runtime-authentication.ts index 6619955..bccb12c 100644 --- a/src/runtime-authentication.ts +++ b/src/runtime-authentication.ts @@ -2,37 +2,28 @@ import type { AuthenticationFlow, AuthenticationProgress, } from './authentication-coordinator' +import type { + AuthorizeOAuthOptions, + ManualOAuthClient, + OAuthChallenge, + OAuthGrant, + OAuthGrantStore, +} from './oauth' import type { RuntimeCaller } from './runtime-caller' import type { RuntimeEvent } from './runtime-protocol' -import type { DeclaredServer, RuntimeStores } from './runtime-stores' -import type { AuthDiscovery, OAuthToken } from './types' +import type { RuntimeStores } from './runtime-stores' import { AuthenticationCoordinator } from './authentication-coordinator' -import { - performOAuthAuthentication, - refreshOAuthToken, - shouldRefreshOAuthToken, -} from './oauth' +import { authorizeOAuth, oauthAccessToken } from './oauth' import { RuntimeOperationError } from './runtime-call' -type HttpServer = Extract -type DiscoveredOAuth = Extract -type AuthenticatedOAuth = Extract - -type Authorized = { - tokenKey: string - token: OAuthToken - auth?: AuthenticatedOAuth - clientSecret?: string -} - export type AuthorizationProgress = AuthenticationProgress & { serverName: string } export type AuthorizeOptions = { - /** The server rejected the current credential, so it must be replaced. */ - rejected?: boolean + /** The server rejected a request; its challenge says what it wants. */ + challenge?: OAuthChallenge | undefined signal?: AbortSignal onProgress?: (progress: AuthorizationProgress) => void } @@ -42,21 +33,21 @@ export type AuthorizeOutcome = 'ready' | 'disconnected' export class RuntimeAuthentication { readonly #stores: RuntimeStores readonly #coordinator: AuthenticationCoordinator - readonly #refreshToken: typeof refreshOAuthToken - readonly #authenticate: typeof performOAuthAuthentication + readonly #authorizeOAuth: typeof authorizeOAuth + readonly #openBrowser: AuthorizeOAuthOptions['openBrowser'] constructor( stores: RuntimeStores, options: { coordinator?: AuthenticationCoordinator - refreshToken?: typeof refreshOAuthToken - authenticate?: typeof performOAuthAuthentication + authorizeOAuth?: typeof authorizeOAuth + openBrowser?: AuthorizeOAuthOptions['openBrowser'] } = {}, ) { this.#stores = stores this.#coordinator = options.coordinator ?? new AuthenticationCoordinator() - this.#refreshToken = options.refreshToken ?? refreshOAuthToken - this.#authenticate = options.authenticate ?? performOAuthAuthentication + this.#authorizeOAuth = options.authorizeOAuth ?? authorizeOAuth + this.#openBrowser = options.openBrowser } async close(): Promise { @@ -70,7 +61,8 @@ export class RuntimeAuthentication { /** * Makes a server's credential usable, joining the single shared flow for its * Credential Identity: a silent token refresh first, then browser - * authorization when no refresh is possible. + * authorization when no refresh is possible. A 401 challenge on a server + * declared without auth is how OAuth is discovered after `@add`. */ async authorize( serverName: string, @@ -87,50 +79,51 @@ export class RuntimeAuthentication { } if (server.transport === 'stdio') return 'ready' - const auth = server.auth - if (auth.kind === 'oauth-token') { - const observed = credentials.oauth[auth.tokenKey] - if (observed && !options.rejected && !shouldRefreshOAuthToken(observed)) - return 'ready' - return this.#join( - serverName, - `oauth:${auth.tokenKey}`, - caller, - options, - this.#tokenFlow(serverName, server, auth.tokenKey, observed), - ) + const { challenge } = options + if (server.auth.kind === 'bearer') { + // Static credentials cannot be repaired by the Runtime. + if (challenge) throw reauthRequired(serverName) + return 'ready' } - if (auth.kind === 'oauth') { - const authorizationServer = auth.authorizationServers?.[0] - if (!authorizationServer) { - throw reauthRequired( - serverName, - 'no OAuth authorization server was discovered', + if (server.auth.kind === 'none' && !challenge) return 'ready' + + const observed = credentials.oauth[serverName] + if ( + server.auth.kind === 'oauth' && + !challenge && + oauthAccessToken(observed) + ) + return 'ready' + return this.#join(serverName, `oauth:${serverName}`, caller, options, { + start: async (signal, requestInput, report) => { + const current = (await this.#stores.credentials.read()).oauth[ + serverName + ] + // A caller can arrive after a shared flow persisted but before it observed + // completion; reuse that rotation instead of authorizing again. + if ( + current?.tokens?.value.access_token !== + observed?.tokens?.value.access_token && + oauthAccessToken(current) ) - } - return this.#join( - serverName, - `oauth:${serverName}:${authorizationServer}`, - caller, - options, - { - start: (signal, requestInput, report) => - this.#authorizeInBrowser( + return + await this.#authorizeOAuth({ + serverName, + serverUrl: new URL(server.url), + grants: this.#grantStore(serverName), + challenge, + signal, + report, + openBrowser: this.#openBrowser, + requestManualClient: async (request) => + parseManualClient( serverName, - server, - auth, - signal, - requestInput, - report, + await requestInput({ type: 'oauth-client', ...request }), ), - persist: (authorized) => this.#persist(serverName, authorized), - }, - ) - } - if (auth.kind === 'unknown') throw reauthRequired(serverName, auth.reason) - // Static credentials cannot be repaired by the Runtime. - if (options.rejected) throw reauthRequired(serverName) - return 'ready' + }) + }, + persist: async () => {}, + }) } async #join( @@ -138,7 +131,7 @@ export class RuntimeAuthentication { identity: string, caller: RuntimeCaller, options: AuthorizeOptions, - flow: AuthenticationFlow, + flow: AuthenticationFlow, ): Promise { const waiting = this.#coordinator.join(identity, caller, flow, (progress) => options.onProgress?.({ serverName, ...progress }), @@ -151,130 +144,34 @@ export class RuntimeAuthentication { } } - #tokenFlow( - serverName: string, - server: HttpServer, - tokenKey: string, - observed: OAuthToken | undefined, - ): AuthenticationFlow { + /** + * Writes each credential the SDK produces as it arrives. Earning a grant also + * declares the server as OAuth, in the same commit. + */ + #grantStore(serverName: string): OAuthGrantStore { return { - start: async (signal, requestInput, report) => { - const separator = tokenKey.indexOf(':') - if (separator === -1) throw reauthRequired(serverName) - const issuer = tokenKey.slice(separator + 1) - const credentials = await this.#stores.credentials.read() - const current = credentials.oauth[tokenKey] - // A caller can arrive after a shared flow persisted but before it observed - // completion; reuse that rotation instead of refreshing it again. - if ( - current && - current.accessToken !== observed?.accessToken && - !shouldRefreshOAuthToken(current) - ) - return { tokenKey, token: current } - - if (current?.refreshToken && current.clientId) { - report({ - phase: 'refreshing-token', - message: 'refreshing OAuth token', - }) - try { - const refreshOptions: Parameters[0] = { - issuer, - resourceUrl: server.url, - token: current, - signal, + read: async () => + (await this.#stores.credentials.read()).oauth[serverName], + update: (change) => + this.#stores.updateState((state) => { + const next: OAuthGrant | undefined = change( + state.credentials.oauth[serverName], + ) + if (next) state.credentials.oauth[serverName] = next + else delete state.credentials.oauth[serverName] + const server = state.registry.servers[serverName] + if ( + next?.tokens && + server && + server.transport !== 'stdio' && + server.auth.kind === 'none' + ) + state.registry.servers[serverName] = { + ...server, + auth: { kind: 'oauth' }, } - const clientSecret = current.clientSecretKey - ? credentials.oauthClientSecrets[current.clientSecretKey] - : undefined - if (clientSecret) refreshOptions.clientSecret = clientSecret - return { tokenKey, token: await this.#refreshToken(refreshOptions) } - } catch (error) { - if (signal.aborted) throw error - // A revoked grant is repaired by a fresh authorization, not by - // asking the user to remove and re-add the server. - report({ - phase: 'refreshing-token', - message: `token refresh failed (${errorMessage(error)}); starting browser authorization`, - }) - } - } - - const auth: DiscoveredOAuth = { - kind: 'oauth', - confidence: 'confirmed', - authorizationServers: [issuer], - } - const scopes = current?.scope?.split(' ').filter(Boolean) - if (scopes?.length) auth.scopesSupported = scopes - return this.#authorizeInBrowser( - serverName, - server, - auth, - signal, - requestInput, - report, - ) - }, - persist: (authorized) => this.#persist(serverName, authorized), - } - } - - async #authorizeInBrowser( - serverName: string, - server: HttpServer, - auth: DiscoveredOAuth, - signal: AbortSignal, - requestInput: RuntimeCaller['requestInput'], - report: (progress: AuthenticationProgress) => void, - ): Promise { - const completed = await this.#authenticate( - serverName, - new URL(server.url), - auth, - signal, - async (request) => { - report({ - phase: 'awaiting-input', - message: 'waiting for OAuth client credentials in the terminal', - }) - return parseManualClient( - serverName, - await requestInput({ type: 'oauth-client', ...request }), - ) - }, - (url) => - report({ - phase: 'awaiting-browser', - message: `authorize in your browser (waiting up to 5 minutes): ${url}`, - url, }), - ) - const authorized: Authorized = { - tokenKey: completed.auth.tokenKey, - token: completed.token, - auth: completed.auth, } - if (completed.clientSecret) authorized.clientSecret = completed.clientSecret - return authorized - } - - async #persist(serverName: string, authorized: Authorized): Promise { - await this.#stores.updateState((state) => { - state.credentials.oauth[authorized.tokenKey] = authorized.token - if (authorized.clientSecret && authorized.token.clientSecretKey) { - state.credentials.oauthClientSecrets[authorized.token.clientSecretKey] = - authorized.clientSecret - } - const current = state.registry.servers[serverName] - if (authorized.auth && current && current.transport !== 'stdio') { - state.registry.servers[serverName] = { - ...current, - auth: authorized.auth, - } - } - }) } } @@ -347,11 +244,7 @@ function abortable(promise: Promise, signal?: AbortSignal): Promise { function parseManualClient( serverName: string, value: unknown, -): { - clientId: string - clientSecret: string - clientSecretKey: string -} { +): ManualOAuthClient { if (!value || typeof value !== 'object') throw new RuntimeOperationError( 'cancelled', @@ -387,11 +280,9 @@ function parseManualClient( 'OAuth client credentials were invalid.', ) } - const clientId = input.clientId.trim() return { - clientId, + clientId: input.clientId.trim(), clientSecret: input.clientSecret.trim(), - clientSecretKey: `oauth-client:${clientId}`, } } diff --git a/src/runtime-session-pool.ts b/src/runtime-session-pool.ts index 1e16cb9..01039c9 100644 --- a/src/runtime-session-pool.ts +++ b/src/runtime-session-pool.ts @@ -1,13 +1,14 @@ import { createHash } from 'node:crypto' import type { McpConnection } from './mcp-client' +import type { OAuthChallenge } from './oauth' import type { RuntimeCaller } from './runtime-caller' import type { NotificationMode } from './runtime-protocol' import type { DeclaredServer, RuntimeStores } from './runtime-stores' import type { McpTool, ServerConfig, ToolDefinition } from './types' -import { normalizeAuthScheme } from './headers' import { + authorizationChallenge, connectMcpClient, listAllMcpTools, toolCallRequestOptions, @@ -17,7 +18,7 @@ import { createNotificationBuffer, flushNotificationBuffer, } from './notifications' -import { shouldRefreshOAuthToken } from './oauth' +import { oauthAccessToken } from './oauth' import { progressEvent, reauthRequired, @@ -36,6 +37,11 @@ type ResolvedServer = { config: ServerConfig } +/** What one attempt presents to an HTTP server; `undefined` for stdio. */ +type AttemptCredential = + | { headers: Record; token: string | undefined } + | undefined + type ManagedRuntimeSession = { key: string serverNames: Set @@ -105,8 +111,13 @@ export class RuntimeSessionPool { this.#assertAccepting() const session = this.#sessionFor(input.serverName, resolved) session.queue.enqueue(call, (signal) => - this.#runAuthorized(session, input.serverName, call, signal, (headers) => - this.#callTool(session, call, input, headers, signal), + this.#runAuthorized( + session, + input.serverName, + call, + signal, + (credential) => + this.#callTool(session, call, input, credential, signal), ), ) await call.settled @@ -143,14 +154,14 @@ export class RuntimeSessionPool { serverName, call, signal, - async (headers) => { + async (credential) => { const timeout = setTimeout(() => { void call.cancel('timeout') }, toolCallRequestOptions().timeout) timeout.unref() try { return await listAllMcpTools( - (await this.#ensureConnected(session, headers)).client, + (await this.#ensureConnected(session, credential)).client, ) } finally { clearTimeout(timeout) @@ -175,29 +186,29 @@ export class RuntimeSessionPool { #authorize( serverName: string, call: RuntimeCall, - rejected = false, + challenge?: OAuthChallenge, ): ReturnType { return this.#authentication.authorize(serverName, call.caller, { - rejected, + challenge, signal: call.signal, onProgress: (progress) => call.emit(progressEvent(progress)), }) } /** - * Runs one attempt with the current credential. A 401 means the server never - * executed the request, so it is safe to replace the credential and retry - * once. + * Runs one attempt with the current credential. A 401 or 403 challenge means + * the server never executed the request, so it is safe to authorize as the + * challenge asks — even on a server added without auth — and retry once. */ async #runAuthorized( session: ManagedRuntimeSession, serverName: string, call: RuntimeCall, signal: AbortSignal, - run: (headers: Record | undefined) => Promise, + run: (credential: AttemptCredential) => Promise, ): Promise { for (let retried = false; ; retried = true) { - const headers = await this.#resolveHeaders(serverName) + const credential = await this.#resolveCredential(serverName) signal.throwIfAborted() if (session.closing) { throw new RuntimeOperationError( @@ -205,15 +216,19 @@ export class RuntimeSessionPool { 'MCP Runtime session is closing.', ) } + let challenge: OAuthChallenge | undefined try { - return await run(headers) + return await run(credential) } catch (error) { - if (!isUnauthorizedError(error)) throw error + challenge = authorizationChallenge(error) + if (!challenge) throw error session.evictCount += 1 await this.#closeSession(session, false) if (retried) throw reauthRequired(serverName) } - if ((await this.#authorize(serverName, call, true)) === 'disconnected') + if ( + (await this.#authorize(serverName, call, challenge)) === 'disconnected' + ) throw new RuntimeOperationError('cancelled', 'Runtime caller left.') signal.throwIfAborted() } @@ -223,7 +238,7 @@ export class RuntimeSessionPool { session: ManagedRuntimeSession, call: RuntimeCall, input: RuntimeCallInput, - headers: Record | undefined, + credential: AttemptCredential, signal: AbortSignal, ) { const buffer = createNotificationBuffer() @@ -243,10 +258,9 @@ export class RuntimeSessionPool { session.currentBuffer = input.notificationMode === 'discard' ? undefined : buffer try { - const connection = await this.#ensureConnected(session, headers) + const connection = await this.#ensureConnected(session, credential) const result = await connection.client.callTool( { name: input.toolName, arguments: input.input }, - undefined, { ...requestOptions, signal }, ) const notifications = await flushNotificationBuffer(buffer) @@ -342,6 +356,13 @@ export class RuntimeSessionPool { ) } + if (declared.transport !== 'stdio' && declared.auth.kind === 'oauth') { + // Each OAuth server carries its own grant, so never share its session. + return { + key: stableServerKey({ name: serverName, ...declared }), + config: { url: declared.url, auth: { kind: 'none' } }, + } + } const key = stableServerKey(declared) if (declared.transport === 'stdio') { return { @@ -353,9 +374,7 @@ export class RuntimeSessionPool { return { key, config: { url: declared.url, auth: { kind: 'none' } } } } - async #resolveHeaders( - serverName: string, - ): Promise | undefined> { + async #resolveCredential(serverName: string): Promise { const { registry, credentials } = await this.#stores.readState() const declared = registry.servers[serverName] if (!declared) @@ -369,6 +388,7 @@ export class RuntimeSessionPool { Accept: 'application/json, text/event-stream', ...(credentials.headers[serverName] ?? {}), } + let token: string | undefined if (declared.auth.kind === 'bearer') { const key = stableServerKey(declared) const cursor = this.#bearerCursors.get(key) ?? 0 @@ -386,25 +406,15 @@ export class RuntimeSessionPool { ? process.env[credential.name] : credentials.bearer[credential.key] if (!value) throw reauthRequired(serverName) - headers.Authorization = value.startsWith('Bearer ') - ? value - : `Bearer ${value}` - } else if (declared.auth.kind === 'oauth-token') { - const token = credentials.oauth[declared.auth.tokenKey] + token = value.replace(/^Bearer /, '') + } else if (declared.auth.kind === 'oauth') { + token = oauthAccessToken(credentials.oauth[serverName]) // Authorization already ran before queueing; this only catches a credential // that expired or vanished while the Call waited for its turn. - if (!token || shouldRefreshOAuthToken(token)) { - throw reauthRequired(serverName) - } - headers.Authorization = `${normalizeAuthScheme(token.tokenType)} ${token.accessToken}` - } else if ( - declared.auth.kind === 'oauth' || - declared.auth.kind === 'unknown' - ) { - throw reauthRequired(serverName) + if (!token) throw reauthRequired(serverName) } - return headers + return { headers, token } } #sessionFor( @@ -433,7 +443,7 @@ export class RuntimeSessionPool { async #ensureConnected( session: ManagedRuntimeSession, - headers?: Record, + credential?: AttemptCredential, ): Promise { if (session.closing) { throw new RuntimeOperationError( @@ -442,12 +452,16 @@ export class RuntimeSessionPool { ) } if (session.connection) { - if (headers) session.connection.updateHeaders(headers) + if (credential) { + session.connection.updateHeaders(credential.headers) + session.connection.updateToken(credential.token) + } return session.connection } if (!session.connecting) { session.connecting = this.#connect(session.config, { - headers, + headers: credential?.headers, + token: credential?.token, sessionId: session.lastSessionId, onNotification: (notification) => { if (session.currentBuffer) { @@ -520,16 +534,6 @@ function sortValue(value: unknown): unknown { ) } -function isUnauthorizedError(error: unknown): boolean { - if (error instanceof RuntimeOperationError) return false - if (error instanceof Error) { - const code = (error as { code?: unknown }).code - if (code === 401 || error.name === 'UnauthorizedError') return true - } - const message = error instanceof Error ? error.message : String(error) - return /\b401\b/.test(message) || /unauthorized/i.test(message) -} - function withCommandNames(tools: McpTool[]): ToolDefinition[] { const names = assignCommandNames(tools.map((tool) => tool.name)) return tools.map((tool) => { diff --git a/src/runtime-stores.ts b/src/runtime-stores.ts index f576747..e8bb469 100644 --- a/src/runtime-stores.ts +++ b/src/runtime-stores.ts @@ -2,9 +2,9 @@ import { randomUUID } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' +import type { OAuthGrant } from './oauth' import type { HttpServerConfig, - OAuthToken, RegistryConfig, ServerConfig, ServerRefreshStatus, @@ -55,9 +55,12 @@ export type DeclaredRegistry = { } export type CredentialState = { - version: 2 - oauth: Record - oauthClientSecrets: Record + version: 3 + /** + * OAuth grants keyed by server name, the Credential Identity of an OAuth + * server. + */ + oauth: Record bearer: Record headers: Record> stdioEnv: Record> @@ -105,20 +108,41 @@ export type RuntimeStores = { ) => Promise> } +type LegacyOAuthToken = { + accessToken: string + tokenType: string + clientId?: string + clientSecretKey?: string + refreshToken?: string + expiresAt?: string + scope?: string +} + type LegacyCredentials = { version: 1 - oauth: Record + oauth: Record oauthClientSecrets?: Record } +/** The pre-SDK credential generation: tokens keyed by `server:issuer`. */ +type CredentialStateV2 = Omit & { + version: 2 + oauth: Record + oauthClientSecrets: Record +} + +// Matches the redirect URI manual clients were registered with. +const MANUAL_REDIRECT_URI = 'http://127.0.0.1:65245/callback' + export async function openRuntimeStores(root: string): Promise { const stateDir = path.join(root, STATE_DIR) await cleanupUnpublishedMigrations(root) if (!(await exists(stateDir))) await migrateLegacyState(root, stateDir) await recoverTransaction(stateDir) + const paths = statePaths(stateDir) + await upgradeCredentials(paths) await archiveLegacyFiles(root) - const paths = statePaths(stateDir) let tail = Promise.resolve() let poison: Error | undefined const exclusive = (operation: () => Promise): Promise => { @@ -191,6 +215,8 @@ export async function openRuntimeStores(root: string): Promise { { version: 1, oauth: {} }, ) declared.servers[name] = split.registry.servers[name]! + // A re-added server starts a new grant, like a new declaration. + delete secrets.oauth[name] delete secrets.headers[name] delete secrets.stdioEnv[name] for (const key of Object.keys(secrets.bearer)) { @@ -217,7 +243,7 @@ export async function openRuntimeStores(root: string): Promise { for (const name of names) { const server = declared.servers[name] if (!server) continue - let tokenRemoved = false + const tokenRemoved = secrets.oauth[name] !== undefined delete declared.servers[name] delete cache.servers[name] delete secrets.headers[name] @@ -225,15 +251,6 @@ export async function openRuntimeStores(root: string): Promise { for (const key of Object.keys(secrets.bearer)) { if (key.startsWith(`${name}:bearer:`)) delete secrets.bearer[key] } - if ( - server.transport !== 'stdio' && - server.auth.kind === 'oauth-token' - ) { - tokenRemoved = !isOAuthTokenReferenced( - declared, - server.auth.tokenKey, - ) - } removed.push({ name, tokenRemoved }) } sweepUnreferencedOAuth(state) @@ -295,11 +312,11 @@ function splitLegacyState( legacyCredentials: LegacyCredentials, ): { registry: DeclaredRegistry - credentials: CredentialState + credentials: CredentialStateV2 schemas: SchemaCache } { const declarations: DeclaredRegistry = { version: 2, servers: {} } - const credentials: CredentialState = { + const credentials: CredentialStateV2 = { version: 2, oauth: legacyCredentials.oauth, oauthClientSecrets: legacyCredentials.oauthClientSecrets ?? {}, @@ -335,7 +352,7 @@ function splitLegacyState( function migrateAuth( name: string, server: HttpServerConfig, - credentials: CredentialState, + credentials: Pick, ): DeclaredAuth { if (server.auth.kind !== 'bearer') return server.auth @@ -458,31 +475,80 @@ async function recoverTransaction(stateDir: string): Promise { await fs.rm(paths.transaction) } -function isOAuthTokenReferenced( - registry: DeclaredRegistry, - tokenKey: string, -): boolean { - return Object.values(registry.servers).some( - (server) => - server.transport !== 'stdio' && - server.auth.kind === 'oauth-token' && - server.auth.tokenKey === tokenKey, - ) +function sweepUnreferencedOAuth(state: RuntimeState): void { + for (const name of Object.keys(state.credentials.oauth)) { + const server = state.registry.servers[name] + // An unauthenticated server may be earning its first grant from a 401. + if (server && server.transport !== 'stdio' && server.auth.kind !== 'bearer') + continue + delete state.credentials.oauth[name] + } } -function sweepUnreferencedOAuth(state: RuntimeState): void { - for (const [tokenKey, token] of Object.entries(state.credentials.oauth)) { - if (isOAuthTokenReferenced(state.registry, tokenKey)) continue - delete state.credentials.oauth[tokenKey] - if ( - token.clientSecretKey && - !Object.values(state.credentials.oauth).some( - (candidate) => candidate.clientSecretKey === token.clientSecretKey, - ) - ) { - delete state.credentials.oauthClientSecrets[token.clientSecretKey] +/** + * Moves pre-SDK OAuth tokens into per-server grants in the SDK's shapes. The + * journaled commit makes the registry and credential rewrite one generation. + */ +async function upgradeCredentials(paths: RuntimeStatePaths): Promise { + const state = await readRuntimeState(paths) + const legacy = state.credentials as unknown as + | CredentialStateV2 + | CredentialState + if (legacy.version === 3) return + + const oauth: CredentialState['oauth'] = {} + for (const [name, server] of Object.entries(state.registry.servers)) { + if (server.transport === 'stdio') continue + const auth = server.auth as { kind: string; tokenKey?: string } + if (auth.kind === 'oauth-token') { + server.auth = { kind: 'oauth' } + const token = auth.tokenKey ? legacy.oauth[auth.tokenKey] : undefined + if (token) oauth[name] = grantFromLegacyToken(token, legacy) + } else if (auth.kind === 'oauth') { + server.auth = { kind: 'oauth' } + } else if (auth.kind === 'unknown') { + // The next 401 challenge decides; a stale guess must not block Calls. + server.auth = { kind: 'none' } } } + state.credentials = { + version: 3, + oauth, + bearer: legacy.bearer, + headers: legacy.headers, + stdioEnv: legacy.stdioEnv ?? {}, + } + await commitRuntimeState(paths, state) +} + +function grantFromLegacyToken( + token: LegacyOAuthToken, + legacy: CredentialStateV2, +): OAuthGrant { + const grant: OAuthGrant = { + tokens: { + value: { access_token: token.accessToken, token_type: token.tokenType }, + }, + } + if (token.refreshToken) grant.tokens!.value.refresh_token = token.refreshToken + if (token.scope) grant.tokens!.value.scope = token.scope + if (token.expiresAt) grant.tokens!.expiresAt = token.expiresAt + if (token.clientId) { + const secret = token.clientSecretKey + ? legacy.oauthClientSecrets[token.clientSecretKey] + : undefined + grant.client = secret + ? { + registration: 'manual', + redirectUri: MANUAL_REDIRECT_URI, + information: { client_id: token.clientId, client_secret: secret }, + } + : { + registration: 'dynamic', + information: { client_id: token.clientId }, + } + } + return grant } export const __test = { diff --git a/src/skill-template.ts b/src/skill-template.ts index ac95769..cec8dbc 100644 --- a/src/skill-template.ts +++ b/src/skill-template.ts @@ -267,7 +267,8 @@ ${sections.join('\n')} ## Authenticate An ordinary \`mcpx .\` call refreshes expired OAuth tokens itself. -When browser authorization is needed, the call prints the authorization URL to +When browser authorization is needed — including the first call to a protected +tool on a server that lists its tools anonymously — the call prints the authorization URL to stderr and waits up to 5 minutes; share that URL with the user and let the call finish. If a call still returns \`reauth-required\` (for example, a provider that needs a manual OAuth client and no terminal is available), run: diff --git a/src/types.ts b/src/types.ts index 85c247a..ad875b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,51 +1,19 @@ +// The 401 challenge, not this declaration, decides whether a server needs OAuth; +// `none` is promoted to `oauth` once a Call is challenged and authorized. export type AuthDiscovery = | { kind: 'none' } - | { - kind: 'oauth' - confidence: 'confirmed' | 'inferred' - resourceMetadataUrl?: string - authorizationServers?: string[] - scopesSupported?: string[] - } + | { kind: 'oauth' } | { kind: 'bearer' credentials: BearerCredential[] strategy: 'round-robin' confidence: 'configured' } - | { kind: 'oauth-token'; tokenKey: string; confidence: 'confirmed' } - | { kind: 'unknown'; reason: string } export type BearerCredential = | { kind: 'env'; name: string } | { kind: 'literal'; value: string } -export type OAuthServerMetadata = { - issuer: string - authorizationEndpoint: string - tokenEndpoint: string - registrationEndpoint?: string - scopesSupported?: string[] - codeChallengeMethodsSupported?: string[] - tokenEndpointAuthMethodsSupported?: string[] -} - -export type OAuthToken = { - accessToken: string - tokenType: string - clientId?: string - clientSecretKey?: string - refreshToken?: string - expiresAt?: string - scope?: string -} - -export type TokenCache = { - version: 1 - oauth: Record - oauthClientSecrets?: Record -} - export type ToolDefinition = { name: string commandName: string diff --git a/tests/auth-discovery.test.ts b/tests/auth-discovery.test.ts deleted file mode 100644 index d81276e..0000000 --- a/tests/auth-discovery.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { - parseResourceMetadataUrl, - parseResourceMetadataUrls, -} from '../src/auth-discovery' - -describe('auth discovery', () => { - it('extracts OAuth protected resource metadata URLs', () => { - expect( - parseResourceMetadataUrl( - 'Bearer resource_metadata="https://mcp.posthog.com/.well-known/oauth-protected-resource/mcp"', - ), - ).toBe('https://mcp.posthog.com/.well-known/oauth-protected-resource/mcp') - }) - - it('uses the last OAuth protected resource metadata URL when servers send duplicates', () => { - const header = - 'Bearer realm="OAuth", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource", error="invalid_token", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp"' - - expect(parseResourceMetadataUrls(header)).toEqual([ - 'https://mcp.sentry.dev/.well-known/oauth-protected-resource', - 'https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp', - ]) - expect(parseResourceMetadataUrl(header)).toBe( - 'https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp', - ) - }) -}) diff --git a/tests/fixtures/notification-mcp-server.mjs b/tests/fixtures/notification-mcp-server.mjs index 11b93ac..0b74bf1 100644 --- a/tests/fixtures/notification-mcp-server.mjs +++ b/tests/fixtures/notification-mcp-server.mjs @@ -1,5 +1,5 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { McpServer } from '@modelcontextprotocol/server' +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio' import { z } from 'zod' const server = new McpServer({ @@ -12,16 +12,16 @@ server.registerTool( { title: 'Progress Stream', description: 'Emit a configurable number of progress notifications', - inputSchema: { + inputSchema: z.object({ count: z.number().int().min(0).max(20).default(4), - }, + }), }, - async (args, extra) => { + async (args, ctx) => { for (let index = 1; index <= args.count; index += 1) { - await extra.sendNotification({ + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { - progressToken: extra.requestId, + progressToken: ctx.mcpReq.id, progress: index, total: args.count, message: `step ${index}`, @@ -38,10 +38,10 @@ server.registerTool( { title: 'Notify Tools Changed', description: 'Emit notifications/tools/list_changed during the call', - inputSchema: {}, + inputSchema: z.object({}), }, - async (_args, extra) => { - await extra.sendNotification({ method: 'notifications/tools/list_changed' }) + async (_args, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/tools/list_changed' }) return result('notify-tools-changed') }, ) @@ -51,10 +51,10 @@ server.registerTool( { title: 'Verbatim Notify', description: 'Emit an unknown notification method for passthrough checks', - inputSchema: {}, + inputSchema: z.object({}), }, - async (_args, extra) => { - await extra.sendNotification({ + async (_args, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/custom/event', params: { source: 'notification-fixture', ok: true }, }) @@ -67,12 +67,12 @@ server.registerTool( { title: 'Flood Notify', description: 'Emit enough notifications to trigger daemon truncation', - inputSchema: {}, + inputSchema: z.object({}), }, - async (_args, extra) => { + async (_args, ctx) => { const payload = 'x'.repeat(1_024) for (let index = 0; index < 120; index += 1) { - await extra.sendNotification({ + await ctx.mcpReq.notify({ method: 'notifications/custom/flood', params: { index, payload }, }) @@ -86,7 +86,7 @@ server.registerTool( { title: 'Idle Tools Changed', description: 'Emit tools/list_changed after the current call has completed', - inputSchema: {}, + inputSchema: z.object({}), }, async () => { setTimeout(() => { diff --git a/tests/fixtures/stdio-server.mjs b/tests/fixtures/stdio-server.mjs index e99ff18..d742995 100644 --- a/tests/fixtures/stdio-server.mjs +++ b/tests/fixtures/stdio-server.mjs @@ -1,5 +1,6 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { McpServer } from '@modelcontextprotocol/server' +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio' +import { z } from 'zod' const server = new McpServer({ name: 'mcpx-stdio-fixture', version: '1.0.0' }) let counter = 0 @@ -9,7 +10,7 @@ server.registerTool( { title: 'Echo', description: 'Echo a fixed response', - inputSchema: {}, + inputSchema: z.object({}), }, async () => ({ content: [{ type: 'text', text: 'ok' }], @@ -21,7 +22,7 @@ server.registerTool( { title: 'PID', description: 'Return the fixture process id', - inputSchema: {}, + inputSchema: z.object({}), }, async () => ({ content: [{ type: 'text', text: String(process.pid) }], @@ -33,7 +34,7 @@ server.registerTool( { title: 'CWD', description: 'Return the fixture process working directory', - inputSchema: {}, + inputSchema: z.object({}), }, async () => ({ content: [{ type: 'text', text: process.cwd() }], @@ -45,7 +46,7 @@ server.registerTool( { title: 'Increment', description: 'Increment in-process state', - inputSchema: {}, + inputSchema: z.object({}), }, async () => { counter += 1 @@ -60,7 +61,7 @@ server.registerTool( { title: 'Slow', description: 'Return after a short delay', - inputSchema: {}, + inputSchema: z.object({}), }, async () => { await new Promise((resolve) => setTimeout(resolve, 100)) diff --git a/tests/headers.test.ts b/tests/headers.test.ts index 0ff1adc..c8c188c 100644 --- a/tests/headers.test.ts +++ b/tests/headers.test.ts @@ -3,21 +3,9 @@ import { describe, expect, it } from 'bun:test' import type { HttpServerConfig } from '../src/types' import { authFromBearerValues } from '../src/bearer' -import { normalizeAuthScheme, resolveProbeHeaders } from '../src/headers' +import { resolveHeaders } from '../src/headers' describe('headers', () => { - it('canonicalizes bearer token auth scheme', () => { - expect(normalizeAuthScheme('bearer')).toBe('Bearer') - expect(normalizeAuthScheme('Bearer')).toBe('Bearer') - expect(normalizeAuthScheme('BEARER')).toBe('Bearer') - expect(normalizeAuthScheme('user')).toBe('Bearer') - expect(normalizeAuthScheme('bot')).toBe('Bearer') - }) - - it('keeps non-bearer token auth schemes unchanged', () => { - expect(normalizeAuthScheme('DPoP')).toBe('DPoP') - }) - it('parses literal and referenced bearer credentials', () => { expect( authFromBearerValues([ @@ -37,13 +25,13 @@ describe('headers', () => { }) }) - it('fails fast when a bearer env reference is missing', () => { + it('fails fast when a bearer env reference is missing', async () => { const previous = process.env.MISSING_MCPX_TEST_TOKEN delete process.env.MISSING_MCPX_TEST_TOKEN const server = bearerServer(['env:MISSING_MCPX_TEST_TOKEN']) try { - expect(() => resolveProbeHeaders(server)).toThrow( + await expect(resolveHeaders(server)).rejects.toThrow( /MISSING_MCPX_TEST_TOKEN/, ) } finally { diff --git a/tests/oauth-challenge.test.ts b/tests/oauth-challenge.test.ts new file mode 100644 index 0000000..5e124d8 --- /dev/null +++ b/tests/oauth-challenge.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import type { RuntimeFrame } from '../src/runtime-protocol' + +import { discoverServer } from '../src/discovery' +import { authorizationChallenge, connectMcpClient } from '../src/mcp-client' +import { McpRuntime } from '../src/runtime' +import { RuntimeAuthentication } from '../src/runtime-authentication' +import { createInMemoryRuntimeCaller } from '../src/runtime-caller' +import { openRuntimeStores } from '../src/runtime-stores' + +// A mixed-auth server like whiteboardanimation.ai: `quote` is anonymous, +// `status` answers 401 with a resource_metadata challenge until authorized. +describe('OAuth discovered from a call-time challenge', () => { + const roots: string[] = [] + const fixtures: Bun.Server[] = [] + + afterEach(async () => { + for (const fixture of fixtures.splice(0)) fixture.stop(true) + await Promise.all( + roots.splice(0).map((root) => fs.rm(root, { recursive: true })), + ) + }) + + it('surfaces the 401 challenge from the transport', async () => { + const fixture = serveMixedAuthServer() + const connection = await connectMcpClient({ + url: fixture.url, + auth: { kind: 'none' }, + }) + try { + const error = await connection.client + .callTool({ name: 'status', arguments: {} }) + .catch((caught: unknown) => caught) + + expect(authorizationChallenge(error)).toEqual({ + status: 401, + resourceMetadataUrl: fixture.resourceMetadataUrl, + scope: 'mcp:tools', + }) + } finally { + await connection.close() + } + }) + + it('surfaces a 403 insufficient_scope challenge from the transport', async () => { + const fixture = serveMixedAuthServer({ insufficientScope: true }) + const connection = await connectMcpClient( + { url: fixture.url, auth: { kind: 'none' } }, + { token: 'narrow-token' }, + ) + try { + const error = await connection.client + .callTool({ name: 'status', arguments: {} }) + .catch((caught: unknown) => caught) + + expect(authorizationChallenge(error)).toMatchObject({ + status: 403, + scope: 'mcp:tools mcp:admin', + }) + } finally { + await connection.close() + } + }) + + it('adds a mixed-auth server without auth and an OAuth-only one as OAuth', async () => { + const mixed = serveMixedAuthServer() + const protectedOnly = serveMixedAuthServer({ protectAll: true }) + + const added = await discoverServer({ name: 'wb', url: mixed.url }) + const challenged = await discoverServer({ + name: 'locked', + url: protectedOnly.url, + }) + + expect(added).toMatchObject({ + status: 'ready', + server: { auth: { kind: 'none' } }, + }) + expect(challenged).toMatchObject({ + status: 'auth-required', + server: { auth: { kind: 'oauth' } }, + }) + }) + + it('authorizes in the browser when a Call is challenged, then retries it', async () => { + const fixture = serveMixedAuthServer() + const root = await fs.mkdtemp(path.join(tmpdir(), 'mcpx-challenge-')) + roots.push(root) + const stores = await openRuntimeStores(root) + await stores.upsertServer('wb', { + url: fixture.url, + auth: { kind: 'none' }, + }) + const browserUrls: string[] = [] + const runtime = new McpRuntime(stores, { + authentication: new RuntimeAuthentication(stores, { + openBrowser: (url) => { + browserUrls.push(url) + void fetch(url).catch(() => {}) + }, + }), + }) + + const quote = createInMemoryRuntimeCaller('quote') + await runtime.handle(callIntent('quote', 'quote'), quote) + const status = createInMemoryRuntimeCaller('status') + await runtime.handle(callIntent('status', 'status'), status) + const again = createInMemoryRuntimeCaller('again') + await runtime.handle(callIntent('again', 'status'), again) + + expect(resultText(quote)).toBe('quote-ok') + expect(resultText(status)).toBe('status-ok') + expect(resultText(again)).toBe('status-ok') + // Only the challenged Call opened the browser; the next one reused the grant. + expect(browserUrls).toHaveLength(1) + expect(progress(status)).toEqual([ + expect.stringContaining('wb: authorize in your browser'), + ]) + expect(progress(again)).toEqual([]) + const state = await stores.readState() + expect(state.registry.servers.wb).toMatchObject({ auth: { kind: 'oauth' } }) + expect(state.credentials.oauth.wb?.tokens?.value.access_token).toBe( + 'granted-token', + ) + await runtime.handle( + { requestId: 'stop', op: 'stop' }, + createInMemoryRuntimeCaller('stop'), + ) + }) + + function serveMixedAuthServer( + options: { protectAll?: boolean; insufficientScope?: boolean } = {}, + ) { + let origin = '' + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(request): Promise { + const url = new URL(request.url) + switch (url.pathname) { + case '/.well-known/oauth-protected-resource/mcp': + return Response.json({ + resource: `${origin}/mcp`, + authorization_servers: [origin], + scopes_supported: ['mcp:tools'], + }) + case '/.well-known/oauth-authorization-server': + return Response.json({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }) + case '/register': { + const body = (await request.json()) as Record + return Response.json( + { ...body, client_id: 'wb-client' }, + { status: 201 }, + ) + } + case '/authorize': { + const redirect = new URL(url.searchParams.get('redirect_uri')!) + redirect.searchParams.set('code', 'wb-code') + redirect.searchParams.set('state', url.searchParams.get('state')!) + return Response.redirect(redirect.toString(), 302) + } + case '/token': + return Response.json({ + access_token: 'granted-token', + refresh_token: 'granted-refresh', + token_type: 'Bearer', + expires_in: 3600, + }) + case '/mcp': + return mcp(request) + } + return new Response('not found', { status: 404 }) + }, + }) + fixtures.push(server) + origin = `http://127.0.0.1:${server.port}` + const resourceMetadataUrl = `${origin}/.well-known/oauth-protected-resource/mcp` + + const challenge = () => + new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + headers: { + 'content-type': 'application/json', + 'www-authenticate': `Bearer scope="mcp:tools", resource_metadata="${resourceMetadataUrl}"`, + }, + }) + + async function mcp(request: Request): Promise { + if (request.method !== 'POST') return new Response(null, { status: 405 }) + const authorized = + request.headers.get('authorization') === 'Bearer granted-token' + if (options.protectAll && !authorized) return challenge() + const message = (await request.json()) as { + id?: number + method: string + params?: Record + } + switch (message.method) { + case 'initialize': + return rpc(message.id, { + protocolVersion: message.params?.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'mixed-auth', version: '1.0.0' }, + }) + case 'tools/list': + return rpc(message.id, { + tools: [ + { name: 'quote', inputSchema: { type: 'object' } }, + { name: 'status', inputSchema: { type: 'object' } }, + ], + }) + case 'tools/call': { + const name = message.params?.name + if (name === 'status' && options.insufficientScope) + return new Response(null, { + status: 403, + headers: { + 'www-authenticate': `Bearer error="insufficient_scope", scope="mcp:tools mcp:admin", resource_metadata="${resourceMetadataUrl}"`, + }, + }) + if (name === 'status' && !authorized) return challenge() + return rpc(message.id, { + content: [{ type: 'text', text: `${name}-ok` }], + }) + } + } + return new Response(null, { status: 202 }) + } + + return { url: `${origin}/mcp`, resourceMetadataUrl } + } +}) + +function rpc(id: unknown, result: unknown): Response { + return Response.json({ jsonrpc: '2.0', id, result }) +} + +function callIntent(requestId: string, toolName: string) { + return { + requestId, + op: 'call' as const, + serverName: 'wb', + toolName, + input: {}, + } +} + +function resultText(caller: { frames: RuntimeFrame[] }): string | undefined { + const frame = caller.frames.find((item) => item.kind !== 'event') + if (frame?.kind !== 'result') return JSON.stringify(frame) + const result = (frame.result as { result: { content: { text: string }[] } }) + .result + return result.content[0]?.text +} + +function progress(caller: { frames: RuntimeFrame[] }): string[] { + return caller.frames.flatMap((frame) => + frame.kind === 'event' && frame.event.type === 'progress' + ? [frame.event.message ?? ''] + : [], + ) +} diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 3560ee2..9681560 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -1,148 +1,382 @@ -import { describe, expect, it } from 'bun:test' +import { afterEach, describe, expect, it } from 'bun:test' -import { - chooseOAuthScope, - fetchAuthorizationServerMetadata, - parseOAuthTokenResponse, - shouldRefreshOAuthToken, +import type { AuthenticationProgress } from '../src/authentication-coordinator' +import type { + AuthorizeOAuthOptions, + ManualOAuthClientRequest, + OAuthGrant, } from '../src/oauth' +import { authorizeOAuth, oauthAccessToken } from '../src/oauth' + describe('oauth', () => { - it('uses protected-resource scopes that are supported by the authorization server', () => { - expect( - chooseOAuthScope( - ['openid', 'profile', 'alert:read'], - ['openid', 'alert:read'], - ), - ).toBe('openid alert:read') + const fixtures: Bun.Server[] = [] + + afterEach(() => { + for (const fixture of fixtures.splice(0)) fixture.stop(true) }) - it('discovers authorization server metadata for path-based issuers', async () => { - const requestedUrls: string[] = [] - const originalFetch = globalThis.fetch - globalThis.fetch = (async (input: string | URL | Request) => { - const url = input.toString() - requestedUrls.push(url) - if ( - url !== - 'https://supabase.example/.well-known/oauth-authorization-server/auth/v1' - ) { - return new Response('not found', { status: 404 }) - } - return Response.json({ - issuer: 'https://supabase.example/auth/v1', - authorization_endpoint: - 'https://supabase.example/auth/v1/oauth/authorize', - token_endpoint: 'https://supabase.example/auth/v1/oauth/token', - }) - }) as typeof fetch - - try { - const metadata = await fetchAuthorizationServerMetadata( - 'https://supabase.example/auth/v1', - ) - - expect(requestedUrls).toEqual([ - 'https://supabase.example/.well-known/oauth-authorization-server/auth/v1', - ]) - expect(metadata.issuer).toBe('https://supabase.example/auth/v1') - expect(metadata.authorizationEndpoint).toBe( - 'https://supabase.example/auth/v1/oauth/authorize', - ) - } finally { - globalThis.fetch = originalFetch - } + it('treats a token expiring within a minute as unusable', () => { + const now = new Date('2026-04-27T08:00:00.000Z') + const grant = (expiresAt?: string): OAuthGrant => ({ + tokens: { + value: { access_token: 'access', token_type: 'Bearer' }, + ...(expiresAt ? { expiresAt } : {}), + }, + }) + + expect(oauthAccessToken(grant('2026-04-27T08:00:59.000Z'), now)).toBe( + undefined, + ) + expect(oauthAccessToken(grant('2026-04-27T08:02:00.000Z'), now)).toBe( + 'access', + ) + expect(oauthAccessToken(grant(), now)).toBe('access') + expect(oauthAccessToken(grant('not a date'), now)).toBe(undefined) }) - it('falls back to issuer-relative metadata used by some hosted OAuth providers', async () => { - const requestedUrls: string[] = [] - const originalFetch = globalThis.fetch - globalThis.fetch = (async (input: string | URL | Request) => { - const url = input.toString() - requestedUrls.push(url) - if ( - url !== - 'https://supabase.example/auth/v1/.well-known/oauth-authorization-server' - ) { - return new Response('not found', { status: 404 }) - } - return Response.json({ - issuer: 'https://supabase.example/auth/v1', - authorization_endpoint: - 'https://supabase.example/auth/v1/oauth/authorize', - token_endpoint: 'https://supabase.example/auth/v1/oauth/token', - }) - }) as typeof fetch - - try { - const metadata = await fetchAuthorizationServerMetadata( - 'https://supabase.example/auth/v1', - ) - - expect(requestedUrls).toEqual([ - 'https://supabase.example/.well-known/oauth-authorization-server/auth/v1', - 'https://supabase.example/auth/v1/.well-known/oauth-authorization-server', - ]) - expect(metadata.tokenEndpoint).toBe( - 'https://supabase.example/auth/v1/oauth/token', - ) - } finally { - globalThis.fetch = originalFetch + it('registers a client and completes browser authorization for the challenged resource', async () => { + const fixture = serveAuthorizationServer() + const flow = flowOptions(fixture, { + challenge: { + status: 401, + resourceMetadataUrl: `${fixture.origin}/.well-known/oauth-protected-resource/mcp`, + }, + }) + + await authorizeOAuth(flow.options) + + expect(fixture.registrations).toHaveLength(1) + const redirectUri = (fixture.registrations[0]?.redirect_uris as string[])[0] + expect(redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/) + const authorize = new URL(flow.browserUrls[0]!) + expect(authorize.searchParams.get('resource')).toBe(`${fixture.origin}/mcp`) + expect(authorize.searchParams.get('scope')).toBe('mcp:tools') + expect(fixture.tokenRequests[0]?.get('grant_type')).toBe( + 'authorization_code', + ) + expect(flow.grant?.client).toMatchObject({ + registration: 'dynamic', + redirectUri, + information: { client_id: 'dynamic-client', issuer: fixture.origin }, + }) + expect(flow.grant?.tokens?.value).toMatchObject({ + access_token: 'access-1', + refresh_token: 'refresh-1', + issuer: fixture.origin, + }) + expect(oauthAccessToken(flow.grant)).toBe('access-1') + expect(flow.progress.map((progress) => progress.phase)).toEqual([ + 'awaiting-browser', + ]) + expect(flow.progress[0]?.url).toBe(flow.browserUrls[0]) + }) + + it('refreshes a stored grant without opening the browser', async () => { + const fixture = serveAuthorizationServer() + const flow = flowOptions(fixture, { grant: storedGrant(fixture.origin) }) + + await authorizeOAuth(flow.options) + + expect(flow.browserUrls).toEqual([]) + expect(fixture.registrations).toEqual([]) + expect(fixture.tokenRequests[0]?.get('grant_type')).toBe('refresh_token') + expect(fixture.tokenRequests[0]?.get('refresh_token')).toBe( + 'stored-refresh', + ) + expect(flow.grant?.tokens?.value.access_token).toBe('access-1') + expect(flow.grant?.client?.information.client_id).toBe('stored-client') + expect(flow.progress.map((progress) => progress.message)).toEqual([ + 'refreshing OAuth token', + ]) + }) + + it('falls back to browser authorization when the refresh grant is revoked', async () => { + const fixture = serveAuthorizationServer({ rejectRefresh: true }) + const flow = flowOptions(fixture, { grant: storedGrant(fixture.origin) }) + + await authorizeOAuth(flow.options) + + expect(flow.browserUrls).toHaveLength(1) + expect(flow.grant?.tokens?.value.access_token).toBe('access-1') + expect(flow.grant?.client?.information.client_id).toBe('dynamic-client') + expect(flow.progress.map((progress) => progress.message)).toContain( + 'OAuth token could not be refreshed; starting browser authorization', + ) + }) + + it('keeps tokens another flow saved while this refresh was rejected', async () => { + const fixture = serveAuthorizationServer({ rejectRefresh: true }) + const flow = flowOptions(fixture, { grant: storedGrant(fixture.origin) }) + const replacement: OAuthGrant = { + ...storedGrant(fixture.origin), + tokens: { + value: { access_token: 'other-flow', token_type: 'Bearer' }, + }, + } + fixture.onRefresh = () => { + flow.grant = replacement + } + const seen: (string | undefined)[] = [] + const update = flow.options.grants.update + flow.options.grants.update = async (change) => { + await update(change) + seen.push(flow.grant?.tokens?.value.access_token) } + flow.options.openBrowser = () => { + throw new Error('stop before the browser') + } + + await expect(authorizeOAuth(flow.options)).rejects.toThrow( + 'stop before the browser', + ) + + expect(seen[0]).toBe('other-flow') }) - it('refreshes oauth tokens one minute before expiry', () => { - const now = new Date('2026-04-27T08:00:00.000Z') + it('asks the caller for a manual client when the server has no registration endpoint', async () => { + const fixture = serveAuthorizationServer({ registration: false }) + const requests: ManualOAuthClientRequest[] = [] + const flow = flowOptions(fixture, { + requestManualClient: async (request) => { + requests.push(request) + return { clientId: 'manual-client', clientSecret: 'manual-secret' } + }, + }) - expect( - shouldRefreshOAuthToken( - { - accessToken: 'access', - tokenType: 'Bearer', - expiresAt: '2026-04-27T08:00:59.000Z', - }, - now, - ), - ).toBe(true) - expect( - shouldRefreshOAuthToken( - { - accessToken: 'access', - tokenType: 'Bearer', - expiresAt: '2026-04-27T08:01:01.000Z', - }, - now, - ), - ).toBe(false) + await authorizeOAuth(flow.options) + + expect(requests).toEqual([ + { + serverName: 'fixture', + redirectUri: 'http://127.0.0.1:65245/callback', + issuer: fixture.origin, + scopes: ['mcp:tools'], + }, + ]) + const authorize = new URL(flow.browserUrls[0]!) + expect(authorize.searchParams.get('redirect_uri')).toBe( + 'http://127.0.0.1:65245/callback', + ) + expect(fixture.tokenRequests[0]?.get('client_secret')).toBe('manual-secret') + expect(flow.grant?.client).toMatchObject({ + registration: 'manual', + redirectUri: 'http://127.0.0.1:65245/callback', + information: { + client_id: 'manual-client', + client_secret: 'manual-secret', + }, + }) + expect(flow.progress.map((progress) => progress.phase)).toEqual([ + 'awaiting-input', + 'awaiting-browser', + ]) }) - it('uses Slack authed_user token payloads', () => { - expect( - parseOAuthTokenResponse( - { - ok: true, - access_token: 'xoxb-bot', - token_type: 'bot', - authed_user: { - access_token: 'xoxp-user', - refresh_token: 'refresh-user', - token_type: 'user', - scope: 'search:read.users', - expires_in: 3600, + it('reuses a stored manual client for a new browser authorization', async () => { + const fixture = serveAuthorizationServer({ registration: false }) + const flow = flowOptions(fixture, { + grant: { + client: { + registration: 'manual', + redirectUri: 'http://127.0.0.1:65245/callback', + information: { + client_id: 'manual-client', + client_secret: 'manual-secret', }, }, - { - clientId: 'client-id', - clientSecretKey: 'oauth-client:client-id', - }, - ), - ).toMatchObject({ - accessToken: 'xoxp-user', - clientId: 'client-id', - clientSecretKey: 'oauth-client:client-id', - refreshToken: 'refresh-user', - tokenType: 'user', - scope: 'search:read.users', + }, + requestManualClient: async () => { + throw new Error('must not prompt again') + }, + }) + + await authorizeOAuth(flow.options) + + expect(fixture.tokenRequests[0]?.get('client_id')).toBe('manual-client') + expect(flow.grant?.tokens?.value.access_token).toBe('access-1') + }) + + it('accepts a Slack token response that nests the user token', async () => { + const fixture = serveAuthorizationServer({ slackTokens: true }) + const flow = flowOptions(fixture) + + await authorizeOAuth(flow.options) + + expect(flow.grant?.tokens?.value).toMatchObject({ + access_token: 'xoxp-user', + token_type: 'user', }) }) + + it('refuses an authorization response from another issuer', async () => { + const fixture = serveAuthorizationServer({ callbackIssuer: 'https://evil' }) + const flow = flowOptions(fixture) + + await expect(authorizeOAuth(flow.options)).rejects.toThrow(/issuer/i) + expect(fixture.tokenRequests).toEqual([]) + expect(flow.grant).toBeUndefined() + }) + + type FixtureOptions = { + registration?: boolean + rejectRefresh?: boolean + slackTokens?: boolean + callbackIssuer?: string + } + + function serveAuthorizationServer(options: FixtureOptions = {}) { + const state = { + origin: '', + registrations: [] as Record[], + tokenRequests: [] as URLSearchParams[], + onRefresh: undefined as (() => void) | undefined, + authorizedRedirect: '', + } + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(request): Promise { + const url = new URL(request.url) + const origin = state.origin + switch (url.pathname) { + case '/.well-known/oauth-protected-resource/mcp': + return Response.json({ + resource: `${origin}/mcp`, + authorization_servers: [origin], + scopes_supported: ['mcp:tools'], + }) + case '/.well-known/oauth-authorization-server': + return Response.json({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: + options.registration === false + ? ['client_secret_post'] + : ['none', 'client_secret_post'], + authorization_response_iss_parameter_supported: true, + ...(options.registration === false + ? {} + : { registration_endpoint: `${origin}/register` }), + }) + case '/register': { + const body = (await request.json()) as Record + state.registrations.push(body) + return Response.json( + { ...body, client_id: 'dynamic-client' }, + { status: 201 }, + ) + } + case '/authorize': { + state.authorizedRedirect = url.searchParams.get('redirect_uri')! + const redirect = new URL(state.authorizedRedirect) + redirect.searchParams.set('code', 'code-1') + redirect.searchParams.set('state', url.searchParams.get('state')!) + redirect.searchParams.set('iss', options.callbackIssuer ?? origin) + return Response.redirect(redirect.toString(), 302) + } + case '/token': { + const body = new URLSearchParams(await request.text()) + state.tokenRequests.push(body) + if ( + body.get('grant_type') === 'authorization_code' && + body.get('redirect_uri') !== state.authorizedRedirect + ) + return Response.json( + { error: 'invalid_grant', error_description: 'redirect_uri' }, + { status: 400 }, + ) + if (body.get('grant_type') === 'refresh_token') { + state.onRefresh?.() + if (options.rejectRefresh) + return Response.json( + { error: 'invalid_grant' }, + { status: 400 }, + ) + } + if (options.slackTokens) + return Response.json({ + ok: true, + authed_user: { + access_token: 'xoxp-user', + token_type: 'user', + }, + }) + return Response.json({ + access_token: 'access-1', + refresh_token: 'refresh-1', + token_type: 'Bearer', + expires_in: 3600, + }) + } + } + return new Response('not found', { status: 404 }) + }, + }) + fixtures.push(server) + state.origin = `http://127.0.0.1:${server.port}` + return state + } + + function flowOptions( + fixture: { origin: string }, + overrides: { + grant?: OAuthGrant + challenge?: AuthorizeOAuthOptions['challenge'] + requestManualClient?: AuthorizeOAuthOptions['requestManualClient'] + } = {}, + ) { + const flow = { + grant: overrides.grant, + browserUrls: [] as string[], + progress: [] as AuthenticationProgress[], + options: undefined as unknown as AuthorizeOAuthOptions, + } + flow.options = { + serverName: 'fixture', + serverUrl: new URL(`${fixture.origin}/mcp`), + grants: { + read: async () => flow.grant, + update: async (change) => { + flow.grant = change(flow.grant) + }, + }, + challenge: overrides.challenge, + signal: new AbortController().signal, + report: (progress) => flow.progress.push(progress), + requestManualClient: + overrides.requestManualClient ?? + (async () => { + throw new Error('unexpected manual client request') + }), + // The "browser" follows the authorization redirect to the loopback callback. + openBrowser: (url) => { + flow.browserUrls.push(url) + void fetch(url).catch(() => {}) + }, + } + return flow + } }) + +function storedGrant(issuer: string): OAuthGrant { + return { + client: { + registration: 'dynamic', + redirectUri: 'http://127.0.0.1:1/callback', + information: { client_id: 'stored-client', issuer }, + }, + tokens: { + value: { + access_token: 'stored-access', + refresh_token: 'stored-refresh', + token_type: 'Bearer', + issuer, + }, + expiresAt: '2000-01-01T00:00:00.000Z', + }, + } +} diff --git a/tests/runtime-authentication.test.ts b/tests/runtime-authentication.test.ts index b59d250..02a0f88 100644 --- a/tests/runtime-authentication.test.ts +++ b/tests/runtime-authentication.test.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os' import path from 'node:path' import type { McpConnection } from '../src/mcp-client' +import type { AuthorizeOAuthOptions, OAuthGrant } from '../src/oauth' import type { RuntimeFrame } from '../src/runtime-protocol' -import type { RuntimeStores } from '../src/runtime-stores' +import type { DeclaredServer, RuntimeStores } from '../src/runtime-stores' +import { AuthorizationChallengeError } from '../src/mcp-client' import { McpRuntime } from '../src/runtime' import { RuntimeAuthentication } from '../src/runtime-authentication' import { createInMemoryRuntimeCaller } from '../src/runtime-caller' @@ -36,6 +38,7 @@ describe('Runtime authentication', () => { issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, + response_types_supported: ['code'], }) } if (request.url.endsWith('/token')) { @@ -53,21 +56,9 @@ describe('Runtime authentication', () => { }) servers.push(fixture) issuer = `http://127.0.0.1:${fixture.port}` - const tokenKey = `fixture:${issuer}` const stores = await createStores( - { - url: `${issuer}/mcp`, - auth: { kind: 'oauth-token', tokenKey, confidence: 'confirmed' }, - }, - { - [tokenKey]: { - accessToken: 'expired', - refreshToken: 'refresh-1', - clientId: 'fixture-client', - tokenType: 'bearer', - expiresAt: '2000-01-01T00:00:00.000Z', - }, - }, + { fixture: { url: `${issuer}/mcp`, auth: { kind: 'oauth' } } }, + { fixture: expiredGrant() }, ) const runtime = new McpRuntime(stores) const callers = Array.from({ length: 5 }, (_, index) => @@ -88,33 +79,27 @@ describe('Runtime authentication', () => { ) expect(refreshRequests).toBe(1) - expect((await stores.credentials.read()).oauth[tokenKey]?.accessToken).toBe( - 'rotated-access', - ) + expect( + (await stores.credentials.read()).oauth.fixture?.tokens?.value + .access_token, + ).toBe('rotated-access') expect(callers.every((caller) => terminalFrames(caller).length === 1)).toBe( true, ) }) it('keeps an interactive flow for remaining waiters and aborts after the final disconnect', async () => { - const stores = await createStores({ - url: 'http://127.0.0.1:1/mcp', - auth: { - kind: 'oauth', - confidence: 'confirmed', - authorizationServers: ['http://127.0.0.1:1'], - }, - }) + const stores = await createStores({ fixture: oauthServer() }) let signal: AbortSignal | undefined let callbackOpen = false let starts = 0 const authentication = new RuntimeAuthentication(stores, { - authenticate: async (_name, _url, _auth, flowSignal) => { + authorizeOAuth: async ({ signal: flowSignal }) => { starts += 1 signal = flowSignal callbackOpen = true return new Promise((_resolve, reject) => { - flowSignal?.addEventListener( + flowSignal.addEventListener( 'abort', () => { callbackOpen = false @@ -162,49 +147,17 @@ describe('Runtime authentication', () => { it('continues past a failed server and attributes the failure to it', async () => { const stores = await createStores( { - url: 'http://127.0.0.1:1/broken/mcp', - auth: { - kind: 'oauth-token', - tokenKey: 'broken:http://127.0.0.1:1', - confidence: 'confirmed', - }, - }, - { - 'broken:http://127.0.0.1:1': { - accessToken: 'broken-access', - refreshToken: 'broken-refresh', - clientId: 'broken-client', - tokenType: 'bearer', - expiresAt: '2000-01-01T00:00:00.000Z', - }, + broken: oauthServer('http://127.0.0.1:1/broken/mcp'), + working: oauthServer('http://127.0.0.1:1/working/mcp'), }, - 'broken', + { broken: expiredGrant(), working: expiredGrant() }, ) - await stores.upsertServer('working', { - url: 'http://127.0.0.1:1/working/mcp', - auth: { - kind: 'oauth-token', - tokenKey: 'working:http://127.0.0.1:1', - confidence: 'confirmed', - }, - }) - await stores.updateState((state) => { - state.credentials.oauth['working:http://127.0.0.1:1'] = { - accessToken: 'working-access', - refreshToken: 'working-refresh', - clientId: 'working-client', - tokenType: 'bearer', - expiresAt: '2000-01-01T00:00:00.000Z', - } - }) const authentication = new RuntimeAuthentication(stores, { - refreshToken: async ({ resourceUrl }) => { - if (resourceUrl.includes('/broken/')) - throw new Error('OAuth token refresh failed: invalid_grant') - return freshToken('rotated-access', 'working-client') - }, - authenticate: async () => { - throw new Error('browser authorization failed') + authorizeOAuth: async ({ serverName, grants, report }) => { + report({ phase: 'refreshing-token', message: 'refreshing OAuth token' }) + if (serverName === 'broken') + throw new Error('browser authorization failed') + await grants.update(() => freshGrant('rotated-access')) }, }) const runtime = runtimeWith(stores, authentication, async () => [ @@ -236,9 +189,6 @@ describe('Runtime authentication', () => { ]) const messages = progressMessages(caller) expect(messages).toContain('broken: refreshing OAuth token') - expect(messages).toContain( - 'broken: token refresh failed (OAuth token refresh failed: invalid_grant); starting browser authorization', - ) expect(messages).toContain('working: ok (1 tool)') const state = await stores.readState() expect(state.schemas.servers.broken?.refreshStatus).toMatchObject({ @@ -250,29 +200,29 @@ describe('Runtime authentication', () => { }) it('silently refreshes an expiring token before an ordinary Call', async () => { - const stores = await createStores(oauthTokenServer(), { - [TOKEN_KEY]: expiredToken(), - }) + const stores = await createStores( + { fixture: oauthServer() }, + { fixture: expiredGrant() }, + ) let refreshes = 0 - const authorizations: (string | undefined)[] = [] const authentication = new RuntimeAuthentication(stores, { - refreshToken: async () => { + authorizeOAuth: async ({ grants, report, challenge }) => { + expect(challenge).toBeUndefined() refreshes += 1 - return freshToken('rotated-access') - }, - authenticate: async () => { - throw new Error('must not open a browser') + report({ phase: 'refreshing-token', message: 'refreshing OAuth token' }) + await grants.update(() => freshGrant('rotated-access')) }, }) + const tokens: (string | undefined)[] = [] const runtime = runtimeWith(stores, authentication, undefined, { - onConnect: (headers) => authorizations.push(headers?.Authorization), + onConnect: (token) => tokens.push(token), }) const caller = createInMemoryRuntimeCaller('call') await runtime.handle(callIntent('call'), caller) expect(refreshes).toBe(1) - expect(authorizations).toEqual(['Bearer rotated-access']) + expect(tokens).toEqual(['rotated-access']) expect(terminalFrames(caller)[0]).toMatchObject({ kind: 'result' }) expect(progressMessages(caller)).toEqual([ 'fixture: refreshing OAuth token', @@ -280,22 +230,23 @@ describe('Runtime authentication', () => { }) it('replaces a rejected credential and retries the Call once', async () => { - const stores = await createStores(oauthTokenServer(), { - [TOKEN_KEY]: freshToken('revoked-access'), - }) - let refreshes = 0 + const stores = await createStores( + { fixture: oauthServer() }, + { fixture: freshGrant('revoked-access') }, + ) + const challenges: unknown[] = [] const authentication = new RuntimeAuthentication(stores, { - refreshToken: async () => { - refreshes += 1 - return freshToken('rotated-access') + authorizeOAuth: async ({ grants, challenge }) => { + challenges.push(challenge) + await grants.update(() => freshGrant('rotated-access')) }, }) const attempts: (string | undefined)[] = [] const runtime = runtimeWith(stores, authentication, undefined, { - onConnect: (headers) => attempts.push(headers?.Authorization), - callTool: async (headers) => { - if (headers?.Authorization === 'Bearer revoked-access') - throw Object.assign(new Error('Unauthorized'), { code: 401 }) + onConnect: (token) => attempts.push(token), + callTool: async (token) => { + if (token === 'revoked-access') + throw new AuthorizationChallengeError({ status: 401 }) return 'ok' }, }) @@ -303,8 +254,8 @@ describe('Runtime authentication', () => { await runtime.handle(callIntent('call'), caller) - expect(refreshes).toBe(1) - expect(attempts).toEqual(['Bearer revoked-access', 'Bearer rotated-access']) + expect(challenges).toEqual([{ status: 401 }]) + expect(attempts).toEqual(['revoked-access', 'rotated-access']) expect(terminalFrames(caller)).toEqual([ { requestId: 'call', @@ -314,35 +265,109 @@ describe('Runtime authentication', () => { ]) }) - it('holds a Call until browser authorization completes and reports the URL', async () => { + it('discovers OAuth from a 401 challenge on a server declared without auth', async () => { + const stores = await createStores({ + fixture: { url: 'http://127.0.0.1:1/mcp', auth: { kind: 'none' } }, + }) + const challenge = { + status: 401 as const, + resourceMetadataUrl: + 'http://127.0.0.1:1/.well-known/oauth-protected-resource/mcp', + scope: 'mcp:tools', + } + const seen: AuthorizeOAuthOptions['challenge'][] = [] + const authentication = new RuntimeAuthentication(stores, { + authorizeOAuth: async (options) => { + seen.push(options.challenge) + options.report({ + phase: 'awaiting-browser', + message: 'authorize in your browser: http://127.0.0.1:1/authorize', + url: 'http://127.0.0.1:1/authorize', + }) + await options.grants.update(() => freshGrant('browser-access')) + }, + }) + const attempts: (string | undefined)[] = [] + const runtime = runtimeWith(stores, authentication, undefined, { + onConnect: (token) => attempts.push(token), + callTool: async (token) => { + if (!token) throw new AuthorizationChallengeError(challenge) + return 'status' + }, + }) + const caller = createInMemoryRuntimeCaller('call') + + await runtime.handle(callIntent('call'), caller) + + expect(seen).toEqual([challenge]) + expect(attempts).toEqual([undefined, 'browser-access']) + expect(terminalFrames(caller)[0]).toMatchObject({ + kind: 'result', + result: { result: 'status' }, + }) + expect(progressMessages(caller)).toEqual([ + 'fixture: authorize in your browser: http://127.0.0.1:1/authorize', + ]) + expect((await stores.registry.read()).servers.fixture).toMatchObject({ + auth: { kind: 'oauth' }, + }) + }) + + it('reports reauth-required when a bearer credential is challenged', async () => { const stores = await createStores({ - url: 'http://127.0.0.1:1/mcp', - auth: { - kind: 'oauth', - confidence: 'confirmed', - authorizationServers: ['http://127.0.0.1:1'], + fixture: { + url: 'http://127.0.0.1:1/mcp', + auth: { + kind: 'bearer', + strategy: 'round-robin', + confidence: 'configured', + credentials: [{ kind: 'stored', key: 'fixture:bearer:0' }], + }, }, }) + await stores.updateState((state) => { + state.credentials.bearer['fixture:bearer:0'] = 'static' + }) + const authentication = new RuntimeAuthentication(stores, { + authorizeOAuth: async () => { + throw new Error('must not start OAuth') + }, + }) + const runtime = runtimeWith(stores, authentication, undefined, { + callTool: async () => { + throw new AuthorizationChallengeError({ status: 401 }) + }, + }) + const caller = createInMemoryRuntimeCaller('call') + + await runtime.handle(callIntent('call'), caller) + + expect(terminalFrames(caller)[0]).toMatchObject({ + kind: 'error', + error: { code: 'reauth-required' }, + }) + }) + + it('holds a Call until browser authorization completes and reports the URL', async () => { + const stores = await createStores({ fixture: oauthServer() }) let approve = () => {} const authentication = new RuntimeAuthentication(stores, { - authenticate: async (_name, _url, _auth, _signal, _manual, onUrl) => { - onUrl?.('http://127.0.0.1:1/authorize?state=fixture') + authorizeOAuth: async ({ grants, report }) => { + report({ + phase: 'awaiting-browser', + message: + 'authorize in your browser (waiting up to 5 minutes): http://127.0.0.1:1/authorize?state=fixture', + url: 'http://127.0.0.1:1/authorize?state=fixture', + }) await new Promise((resolve) => { approve = resolve }) - return { - auth: { - kind: 'oauth-token', - tokenKey: TOKEN_KEY, - confidence: 'confirmed', - }, - token: freshToken('browser-access'), - } + await grants.update(() => freshGrant('browser-access')) }, }) - const authorizations: (string | undefined)[] = [] + const tokens: (string | undefined)[] = [] const runtime = runtimeWith(stores, authentication, undefined, { - onConnect: (headers) => authorizations.push(headers?.Authorization), + onConnect: (token) => tokens.push(token), }) const first = createInMemoryRuntimeCaller('first') const late = createInMemoryRuntimeCaller('late') @@ -362,29 +387,14 @@ describe('Runtime authentication', () => { expect(progressMessages(late)).toEqual([expected]) expect(terminalFrames(first)[0]).toMatchObject({ kind: 'result' }) expect(terminalFrames(late)[0]).toMatchObject({ kind: 'result' }) - expect(authorizations).toEqual(['Bearer browser-access']) - expect((await stores.readState()).registry.servers.fixture).toMatchObject({ - auth: { kind: 'oauth-token', tokenKey: TOKEN_KEY }, - }) + expect(tokens).toEqual(['browser-access']) }) it('fails fast when manual OAuth client input has no terminal', async () => { - const stores = await createStores({ - url: 'http://127.0.0.1:1/mcp', - auth: { - kind: 'oauth', - confidence: 'confirmed', - authorizationServers: ['http://127.0.0.1:1'], - }, - }) + const stores = await createStores({ fixture: oauthServer() }) const authentication = new RuntimeAuthentication(stores, { - authenticate: async (_name, _url, _auth, _signal, manualClient) => { - await manualClient?.({ - serverName: 'fixture', - redirectUri: 'http://127.0.0.1:65245/callback', - issuer: 'http://127.0.0.1:1', - scopes: [], - }) + authorizeOAuth: async ({ requestManualClient }) => { + await requestManualClient(manualRequest()) throw new Error('unreachable') }, }) @@ -412,48 +422,25 @@ describe('Runtime authentication', () => { ]) }) - it('requests manual OAuth client input from the CLI caller and persists it in Runtime state', async () => { - const stores = await createStores({ - url: 'http://127.0.0.1:1/mcp', - auth: { - kind: 'oauth', - confidence: 'confirmed', - authorizationServers: ['http://127.0.0.1:1'], - }, - }) - let inputRequests = 0 + it('requests manual OAuth client input from the CLI caller', async () => { + const stores = await createStores({ fixture: oauthServer() }) + const requests: unknown[] = [] const authentication = new RuntimeAuthentication(stores, { - authenticate: async (_name, _url, _auth, _signal, manualClient) => { - const client = await manualClient?.({ - serverName: 'fixture', - redirectUri: 'http://127.0.0.1:65245/callback', - issuer: 'http://127.0.0.1:1', - scopes: ['scope:read'], + authorizeOAuth: async ({ requestManualClient, grants }) => { + const client = await requestManualClient(manualRequest()) + expect(client).toEqual({ + clientId: 'local-client', + clientSecret: 'local-secret', }) - if (!client) throw new Error('Missing manual client.') - return { - auth: { - kind: 'oauth-token', - tokenKey: 'fixture:issuer', - confidence: 'confirmed', - }, - token: { - accessToken: 'local-access', - tokenType: 'bearer', - clientId: client.clientId, - clientSecretKey: client.clientSecretKey, - }, - clientSecret: client.clientSecret, - } + await grants.update(() => freshGrant('local-access')) }, }) const runtime = new McpRuntime(stores, { authentication }) const caller = { ...createInMemoryRuntimeCaller('manual'), - requestInput: async (request: { type: string }) => { - inputRequests += 1 - expect(request.type).toBe('oauth-client') - return { clientId: 'local-client', clientSecret: 'local-secret' } + requestInput: async (request: unknown) => { + requests.push(request) + return { clientId: ' local-client ', clientSecret: 'local-secret' } }, } @@ -462,51 +449,17 @@ describe('Runtime authentication', () => { caller, ) - expect(inputRequests).toBe(1) - const state = await stores.readState() - expect(state.credentials.oauth['fixture:issuer']?.clientId).toBe( - 'local-client', - ) - expect( - state.credentials.oauthClientSecrets['oauth-client:local-client'], - ).toBe('local-secret') - expect(state.registry.servers.fixture).toMatchObject({ - auth: { kind: 'oauth-token' }, - }) + expect(requests).toEqual([{ type: 'oauth-client', ...manualRequest() }]) }) it('moves manual OAuth input to a surviving waiter after the first caller disconnects', async () => { - const stores = await createStores({ - url: 'http://127.0.0.1:1/mcp', - auth: { - kind: 'oauth', - confidence: 'confirmed', - authorizationServers: ['http://127.0.0.1:1'], - }, - }) + const stores = await createStores({ fixture: oauthServer() }) let firstPrompted = false let secondPrompted = false const authentication = new RuntimeAuthentication(stores, { - authenticate: async (_name, _url, _auth, _signal, manualClient) => { - const client = await manualClient?.({ - serverName: 'fixture', - redirectUri: 'http://127.0.0.1:65245/callback', - issuer: 'http://127.0.0.1:1', - scopes: [], - }) - if (!client) throw new Error('Missing manual client.') - return { - auth: { - kind: 'oauth-token', - tokenKey: 'fixture:issuer', - confidence: 'confirmed', - }, - token: { - accessToken: 'local-access', - tokenType: 'bearer', - clientId: client.clientId, - }, - } + authorizeOAuth: async ({ requestManualClient, grants }) => { + await requestManualClient(manualRequest()) + await grants.update(() => freshGrant('local-access')) }, }) const firstBase = createInMemoryRuntimeCaller('first-input') @@ -538,50 +491,64 @@ describe('Runtime authentication', () => { }) async function createStores( - server: Record, - oauth: Record = {}, - name = 'fixture', + declared: Record, + oauth: Record = {}, ) { const root = await fs.mkdtemp(path.join(tmpdir(), 'mcpx-runtime-auth-')) roots.push(root) - await fs.writeFile( - path.join(root, 'servers.json'), - JSON.stringify({ version: 1, servers: { [name]: server } }), - ) - await fs.writeFile( - path.join(root, 'tokens.json'), - JSON.stringify({ version: 1, oauth, oauthClientSecrets: {} }), - ) - return openRuntimeStores(root) + const stores = await openRuntimeStores(root) + await stores.updateState((state) => { + Object.assign(state.registry.servers, declared) + Object.assign(state.credentials.oauth, oauth) + }) + return stores } }) -const TOKEN_KEY = 'fixture:http://127.0.0.1:1' +function oauthServer(url = 'http://127.0.0.1:1/mcp'): DeclaredServer { + return { url, auth: { kind: 'oauth' } } +} -function oauthTokenServer() { +function expiredGrant(): OAuthGrant { return { - url: 'http://127.0.0.1:1/mcp', - auth: { kind: 'oauth-token', tokenKey: TOKEN_KEY, confidence: 'confirmed' }, + client: { + registration: 'dynamic', + information: { client_id: 'fixture-client' }, + }, + tokens: { + value: { + access_token: 'expired-access', + refresh_token: 'refresh-1', + token_type: 'bearer', + }, + expiresAt: '2000-01-01T00:00:00.000Z', + }, } } -function expiredToken() { +function freshGrant(accessToken: string): OAuthGrant { return { - accessToken: 'expired-access', - refreshToken: 'refresh-1', - clientId: 'fixture-client', - tokenType: 'bearer', - expiresAt: '2000-01-01T00:00:00.000Z', + client: { + registration: 'dynamic', + information: { client_id: 'fixture-client' }, + }, + tokens: { + value: { + access_token: accessToken, + refresh_token: 'refresh-2', + token_type: 'bearer', + }, + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }, } } -function freshToken(accessToken: string, clientId = 'fixture-client') { +function manualRequest() { return { - accessToken, - refreshToken: 'refresh-2', - clientId, - tokenType: 'bearer', - expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + serverName: 'fixture', + redirectUri: 'http://127.0.0.1:65245/callback', + issuer: 'http://127.0.0.1:1', + scopes: [], } } @@ -600,19 +567,18 @@ function runtimeWith( authentication: RuntimeAuthentication, listTools: () => Promise = async () => [], options: { - onConnect?: (headers: Record | undefined) => void - callTool?: (headers: Record | undefined) => Promise + onConnect?: (token: string | undefined) => void + callTool?: (token: string | undefined) => Promise } = {}, ): McpRuntime { const sessions = new RuntimeSessionPool(stores, { authentication, connect: async (_server, connectOptions) => { - const headers = connectOptions?.headers - options.onConnect?.(headers) + let token = connectOptions?.token + options.onConnect?.(token) const connection: McpConnection = { client: { - callTool: async () => - (options.callTool ?? (async () => 'ok'))(headers), + callTool: async () => (options.callTool ?? (async () => 'ok'))(token), listTools: async () => ({ tools: await listTools() }), } as unknown as McpConnection['client'], close: async () => {}, @@ -620,6 +586,9 @@ function runtimeWith( stderr: null, sessionId: () => undefined, updateHeaders: () => {}, + updateToken: (next) => { + token = next + }, } return connection }, diff --git a/tests/runtime-cli.test.ts b/tests/runtime-cli.test.ts index 156ba33..e0b603f 100644 --- a/tests/runtime-cli.test.ts +++ b/tests/runtime-cli.test.ts @@ -269,6 +269,7 @@ function startHttpFixture( issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, + response_types_supported: ['code'], }) } if (requestUrl.pathname === '/token') { diff --git a/tests/runtime-session-pool.test.ts b/tests/runtime-session-pool.test.ts index 0f68d33..d1d314d 100644 --- a/tests/runtime-session-pool.test.ts +++ b/tests/runtime-session-pool.test.ts @@ -6,6 +6,8 @@ import path from 'node:path' import type { McpConnection } from '../src/mcp-client' import type { ServerConfig } from '../src/types' +import { AuthorizationChallengeError } from '../src/mcp-client' +import { RuntimeAuthentication } from '../src/runtime-authentication' import { RuntimeCall } from '../src/runtime-call' import { createInMemoryRuntimeCaller } from '../src/runtime-caller' import { RuntimeSessionPool } from '../src/runtime-session-pool' @@ -46,12 +48,14 @@ describe('Runtime session pool', () => { }) let connectedServer: ServerConfig | undefined let connectedHeaders: Record | undefined + let connectedToken: string | undefined let receivedSignal: AbortSignal | undefined const pool = new RuntimeSessionPool(stores, { connect: async (server, options) => { connectedServer = server connectedHeaders = options?.headers - return fakeConnection(async (_params, _schema, requestOptions) => { + connectedToken = options?.token + return fakeConnection(async (_params, requestOptions) => { receivedSignal = requestOptions?.signal return 'ok' }) @@ -72,9 +76,9 @@ describe('Runtime session pool', () => { }) expect(connectedHeaders).toEqual({ Accept: 'application/json, text/event-stream', - Authorization: 'Bearer bearer-secret', 'x-api-key': 'header-secret', }) + expect(connectedToken).toBe('bearer-secret') expect(receivedSignal).toBe(call.signal) expect(JSON.stringify(caller.frames)).not.toContain('secret') }) @@ -90,7 +94,7 @@ describe('Runtime session pool', () => { const pool = new RuntimeSessionPool(stores, { connect: async () => { connectCount += 1 - return fakeConnection(async (params, _schema, options) => { + return fakeConnection(async (params, options) => { upstreamCalls.push(params.name) if (params.name === 'hold') { return new Promise((_resolve, reject) => { @@ -140,17 +144,24 @@ describe('Runtime session pool', () => { }) }) - it('maps 401 to reauth-required without starting authentication', async () => { + it('authorizes from a 401 challenge and reports reauth-required when it fails', async () => { const stores = await createStores({ url: 'http://127.0.0.1:1/mcp', auth: { kind: 'none' }, }) let closed = 0 + const challenges: unknown[] = [] const pool = new RuntimeSessionPool(stores, { + authentication: new RuntimeAuthentication(stores, { + authorizeOAuth: async ({ challenge }) => { + challenges.push(challenge) + throw new Error('no OAuth metadata') + }, + }), connect: async () => fakeConnection( async () => { - throw new Error('HTTP 401 Unauthorized') + throw new AuthorizationChallengeError({ status: 401 }) }, () => closed++, ), @@ -169,10 +180,12 @@ describe('Runtime session pool', () => { kind: 'error', error: { code: 'reauth-required', - message: 'Credentials for fixture must be refreshed.', + message: + 'Credentials for fixture must be refreshed: no OAuth metadata', }, }, ]) + expect(challenges).toEqual([{ status: 401 }]) expect(closed).toBe(1) }) @@ -197,15 +210,15 @@ describe('Runtime session pool', () => { const observed: string[] = [] const pool = new RuntimeSessionPool(stores, { connect: async (_server, options) => { - authorization = options?.headers?.Authorization ?? '' + authorization = options?.token ?? '' return { ...fakeConnection(async (params) => { observed.push(`${params.name}:${authorization}`) if (params.name === 'first') await firstBlocked return 'ok' }), - updateHeaders: (headers) => { - authorization = headers.Authorization ?? '' + updateToken: (token) => { + authorization = token ?? '' }, } }, @@ -228,10 +241,10 @@ describe('Runtime session pool', () => { }, ) await Bun.sleep(5) - expect(observed).toEqual(['first:Bearer token-a']) + expect(observed).toEqual(['first:token-a']) releaseFirst() await Promise.all([first, second]) - expect(observed).toEqual(['first:Bearer token-a', 'second:Bearer token-b']) + expect(observed).toEqual(['first:token-a', 'second:token-b']) }) it('owns the timeout cause instead of inferring it from the SDK error', async () => { @@ -245,7 +258,7 @@ describe('Runtime session pool', () => { const pool = new RuntimeSessionPool(stores, { connect: async () => fakeConnection( - async (_params, _schema, options) => + async (_params, options) => new Promise((_resolve, reject) => { options?.signal?.addEventListener( 'abort', @@ -361,7 +374,7 @@ describe('Runtime session pool', () => { const pool = new RuntimeSessionPool(stores, { connect: async () => fakeConnection( - async (_params, _schema, options) => + async (_params, options) => new Promise((_resolve, reject) => { options?.signal?.addEventListener( 'abort', @@ -702,6 +715,7 @@ function fakeConnection( stderr: null, sessionId: () => 'fixture-session', updateHeaders: () => {}, + updateToken: () => {}, } } diff --git a/tests/runtime-stores.test.ts b/tests/runtime-stores.test.ts index 38055d7..9563e9e 100644 --- a/tests/runtime-stores.test.ts +++ b/tests/runtime-stores.test.ts @@ -46,6 +46,18 @@ describe('Runtime state stores', () => { command: 'fixture-command', env: { FIXTURE_SECRET: 'stdio-secret' }, }, + slack: { + url: 'https://slack.example/mcp', + auth: { + kind: 'oauth-token', + tokenKey: 'slack:https://slack.example', + confidence: 'confirmed', + }, + }, + guessed: { + url: 'https://guessed.example/mcp', + auth: { kind: 'unknown', reason: '401 without metadata' }, + }, }, }), ) @@ -54,12 +66,17 @@ describe('Runtime state stores', () => { JSON.stringify({ version: 1, oauth: { - 'fixture:issuer': { + 'slack:https://slack.example': { accessToken: 'oauth-secret', - tokenType: 'bearer', + tokenType: 'user', + clientId: 'slack-client', + clientSecretKey: 'oauth-client:slack-client', + refreshToken: 'refresh-secret', + expiresAt: '2026-08-03T00:00:00.000Z', }, + 'orphan:issuer': { accessToken: 'orphan', tokenType: 'bearer' }, }, - oauthClientSecrets: { fixture: 'client-secret' }, + oauthClientSecrets: { 'oauth-client:slack-client': 'client-secret' }, }), ) @@ -81,17 +98,32 @@ describe('Runtime state stores', () => { }, }, stdio: { transport: 'stdio', command: 'fixture-command' }, + slack: { url: 'https://slack.example/mcp', auth: { kind: 'oauth' } }, + guessed: { url: 'https://guessed.example/mcp', auth: { kind: 'none' } }, }, }) expect(await stores.credentials.read()).toEqual({ - version: 2, + version: 3, oauth: { - 'fixture:issuer': { - accessToken: 'oauth-secret', - tokenType: 'bearer', + slack: { + client: { + registration: 'manual', + redirectUri: 'http://127.0.0.1:65245/callback', + information: { + client_id: 'slack-client', + client_secret: 'client-secret', + }, + }, + tokens: { + value: { + access_token: 'oauth-secret', + token_type: 'user', + refresh_token: 'refresh-secret', + }, + expiresAt: '2026-08-03T00:00:00.000Z', + }, }, }, - oauthClientSecrets: { fixture: 'client-secret' }, bearer: { 'fixture:bearer:0': 'bearer-secret' }, headers: { fixture: { 'x-api-key': 'header-secret' } }, stdioEnv: { stdio: { FIXTURE_SECRET: 'stdio-secret' } }, @@ -142,43 +174,32 @@ describe('Runtime state stores', () => { ) }) - it('retains shared OAuth material until its final declaration is removed', async () => { + it('drops an OAuth grant with its declaration and on re-add', async () => { const root = await fs.mkdtemp(path.join(tmpdir(), 'mcpx-runtime-stores-')) roots.push(root) const stores = await openRuntimeStores(root) + const grant = { + tokens: { value: { access_token: 'secret', token_type: 'Bearer' } }, + } await stores.updateState((state) => { - state.registry.servers.first = { - url: 'http://127.0.0.1:1/mcp', - auth: { - kind: 'oauth-token', - tokenKey: 'shared', - confidence: 'confirmed', - }, - } - state.registry.servers.second = { - url: 'http://127.0.0.1:1/mcp', - auth: { - kind: 'oauth-token', - tokenKey: 'shared', - confidence: 'confirmed', - }, - } - state.credentials.oauth.shared = { - accessToken: 'shared-secret', - tokenType: 'bearer', - clientId: 'client', - clientSecretKey: 'client-secret-key', + for (const name of ['first', 'second']) { + state.registry.servers[name] = { + url: 'http://127.0.0.1:1/mcp', + auth: { kind: 'oauth' }, + } + state.credentials.oauth[name] = grant } - state.credentials.oauthClientSecrets['client-secret-key'] = - 'client-secret' }) - await stores.removeServers(['first']) - expect((await stores.credentials.read()).oauth.shared).toBeDefined() - await stores.removeServers(['second']) - const credentials = await stores.credentials.read() - expect(credentials.oauth.shared).toBeUndefined() - expect(credentials.oauthClientSecrets['client-secret-key']).toBeUndefined() + expect(await stores.removeServers(['first'])).toEqual([ + { name: 'first', tokenRemoved: true }, + ]) + expect((await stores.credentials.read()).oauth).toEqual({ second: grant }) + await stores.upsertServer('second', { + url: 'http://127.0.0.1:1/mcp', + auth: { kind: 'oauth' }, + }) + expect((await stores.credentials.read()).oauth).toEqual({}) }) it('reopens the published state without reviving a legacy registry', async () => { diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 82b7fbf..9bab83b 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -48,11 +48,11 @@ describe('MCP Runtime', () => { confidence: 'confirmed', }, }) - let authorizationServer: string | undefined + let serverUrl: string | undefined const runtime = new McpRuntime(stores, { authentication: new RuntimeAuthentication(stores, { - authenticate: async (_name, _url, auth) => { - authorizationServer = auth.authorizationServers?.[0] + authorizeOAuth: async (options) => { + serverUrl = options.serverUrl.toString() throw new Error('browser unavailable') }, }), @@ -81,8 +81,8 @@ describe('MCP Runtime', () => { }, }, ]) - // A missing token falls back to browser authorization at the token issuer. - expect(authorizationServer).toBe('http://127.0.0.1:1') + // A migrated declaration without a token still authorizes its server. + expect(serverUrl).toBe('http://127.0.0.1:1/mcp') }) async function storesFor(server: Record) { From 5ce3d0e45de0a7337af9ef345f327582ffc3a6b5 Mon Sep 17 00:00:00 2001 From: EthanHuo Date: Sun, 25 Oct 2026 12:44:10 +0800 Subject: [PATCH 2/2] refactor: drop Slack-specific OAuth handling Slack is reached through its own CLI, not an mcpx server, so the authed_user token-response shim goes. Manually registered OAuth clients stay generic. --- README.md | 5 ++--- src/index.md | 2 +- src/oauth.ts | 31 +++---------------------------- src/router.ts | 2 +- tests/oauth.test.ts | 21 --------------------- tests/runtime-stores.test.ts | 20 ++++++++++---------- 6 files changed, 17 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 4c68a4c..2b61bed 100644 --- a/README.md +++ b/README.md @@ -201,8 +201,7 @@ challenged. For OAuth servers that support dynamic client registration, mcpx registers a client automatically for each browser sign-in. -For OAuth servers that do not support dynamic client registration, such as -Slack, mcpx prompts for a manual `client_id` and `client_secret`. These providers +For OAuth servers that do not support dynamic client registration, mcpx prompts for a manual `client_id` and `client_secret`. These providers usually require a preconfigured redirect URL. mcpx uses: ```text @@ -274,7 +273,7 @@ For temporary agent guidance without writing `.agents/skills`, print a one-serve skill to stdout: ```bash -mcpx @skill-install "{ show: 'slack' }" +mcpx @skill-install "{ show: 'sentry' }" ``` ## Skill diff --git a/src/index.md b/src/index.md index 508fa7d..5d53d45 100644 --- a/src/index.md +++ b/src/index.md @@ -173,7 +173,7 @@ For temporary agent guidance without writing `.agents/skills`, print a single-server skill to stdout: ```bash -mcpx @skill-install "{ show: 'slack' }" +mcpx @skill-install "{ show: 'sentry' }" ``` Use this when the user asks for a one-off service action during the current diff --git a/src/oauth.ts b/src/oauth.ts index 6adb8ce..1a1b55c 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -391,38 +391,13 @@ function issuersMatch( return stored.replace(/\/$/, '') === current.replace(/\/$/, '') } -/** - * Binds every OAuth request to the flow's lifetime and accepts Slack's token - * response, which nests a user token under `authed_user`. - */ +/** Binds every OAuth request to the flow's lifetime. */ function oauthFetch(signal: AbortSignal): FetchLike { - return async (url, init) => { - const response = await fetch(url, { + return (url, init) => + fetch(url, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal, }) - if ( - !(init?.body instanceof URLSearchParams) || - !init.body.has('grant_type') - ) - return response - return unwrapAuthedUser(response) - } -} - -async function unwrapAuthedUser(response: Response): Promise { - if (!response.headers.get('content-type')?.includes('json')) return response - const body = (await response - .clone() - .json() - .catch(() => undefined)) as Record | undefined - const user = body?.authed_user as Record | undefined - if (!body || body.access_token || typeof user?.access_token !== 'string') - return response - return Response.json(user, { - status: response.status, - statusText: response.statusText, - }) } /** The loopback redirect target for one browser authorization. */ diff --git a/src/router.ts b/src/router.ts index dac113d..61ca63e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -198,7 +198,7 @@ function buildRouter(registry: RegistryView): Router { examples: [ 'mcpx @skill-install', `mcpx @skill-install "{ servers: 'posthog,sentry' }"`, - `mcpx @skill-install "{ show: 'slack' }"`, + `mcpx @skill-install "{ show: 'sentry' }"`, ], }) .input(skillInput), diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 9681560..7af29e7 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -197,18 +197,6 @@ describe('oauth', () => { expect(flow.grant?.tokens?.value.access_token).toBe('access-1') }) - it('accepts a Slack token response that nests the user token', async () => { - const fixture = serveAuthorizationServer({ slackTokens: true }) - const flow = flowOptions(fixture) - - await authorizeOAuth(flow.options) - - expect(flow.grant?.tokens?.value).toMatchObject({ - access_token: 'xoxp-user', - token_type: 'user', - }) - }) - it('refuses an authorization response from another issuer', async () => { const fixture = serveAuthorizationServer({ callbackIssuer: 'https://evil' }) const flow = flowOptions(fixture) @@ -221,7 +209,6 @@ describe('oauth', () => { type FixtureOptions = { registration?: boolean rejectRefresh?: boolean - slackTokens?: boolean callbackIssuer?: string } @@ -297,14 +284,6 @@ describe('oauth', () => { { status: 400 }, ) } - if (options.slackTokens) - return Response.json({ - ok: true, - authed_user: { - access_token: 'xoxp-user', - token_type: 'user', - }, - }) return Response.json({ access_token: 'access-1', refresh_token: 'refresh-1', diff --git a/tests/runtime-stores.test.ts b/tests/runtime-stores.test.ts index 9563e9e..f481d61 100644 --- a/tests/runtime-stores.test.ts +++ b/tests/runtime-stores.test.ts @@ -46,11 +46,11 @@ describe('Runtime state stores', () => { command: 'fixture-command', env: { FIXTURE_SECRET: 'stdio-secret' }, }, - slack: { - url: 'https://slack.example/mcp', + manual: { + url: 'https://manual.example/mcp', auth: { kind: 'oauth-token', - tokenKey: 'slack:https://slack.example', + tokenKey: 'manual:https://manual.example', confidence: 'confirmed', }, }, @@ -66,17 +66,17 @@ describe('Runtime state stores', () => { JSON.stringify({ version: 1, oauth: { - 'slack:https://slack.example': { + 'manual:https://manual.example': { accessToken: 'oauth-secret', tokenType: 'user', - clientId: 'slack-client', - clientSecretKey: 'oauth-client:slack-client', + clientId: 'manual-client', + clientSecretKey: 'oauth-client:manual-client', refreshToken: 'refresh-secret', expiresAt: '2026-08-03T00:00:00.000Z', }, 'orphan:issuer': { accessToken: 'orphan', tokenType: 'bearer' }, }, - oauthClientSecrets: { 'oauth-client:slack-client': 'client-secret' }, + oauthClientSecrets: { 'oauth-client:manual-client': 'client-secret' }, }), ) @@ -98,19 +98,19 @@ describe('Runtime state stores', () => { }, }, stdio: { transport: 'stdio', command: 'fixture-command' }, - slack: { url: 'https://slack.example/mcp', auth: { kind: 'oauth' } }, + manual: { url: 'https://manual.example/mcp', auth: { kind: 'oauth' } }, guessed: { url: 'https://guessed.example/mcp', auth: { kind: 'none' } }, }, }) expect(await stores.credentials.read()).toEqual({ version: 3, oauth: { - slack: { + manual: { client: { registration: 'manual', redirectUri: 'http://127.0.0.1:65245/callback', information: { - client_id: 'slack-client', + client_id: 'manual-client', client_secret: 'client-secret', }, },