-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload #2347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
felixweinberger
merged 1 commit into
fweinberger/auth-1-sep2468-client
from
fweinberger/auth-4-sep2468-server
Jun 24, 2026
+1,189
−73
Merged
feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload #2347
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': minor | ||
| --- | ||
|
|
||
| SEP-2350 scope step-up: on `403 insufficient_scope`, `StreamableHTTPClientTransport` now re-authorizes with the **union** of the previously-requested and challenged scopes (`computeScopeUnion`), bypassing the refresh-token branch when the union is a strict superset of the current token's granted scope (`isStrictScopeSuperset`, `AuthOptions.forceReauthorization`). New `onInsufficientScope: 'reauthorize' | 'throw'` (default `'reauthorize'`) and `maxStepUpRetries` (default 1) on `StreamableHTTPClientTransportOptions`; `'throw'` raises the new `InsufficientScopeError`. The GET listen-stream open path now applies the same step-up handling. The previous verbatim-header retry guard is replaced by the bounded per-send counter. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| # scoped-tools — per-tool scope enforced in the tool handler | ||
|
|
||
| Demonstrates per-tool OAuth scope enforcement on a `createMcpHandler` | ||
| deployment: the HTTP gate does **bearer-verify + 401 only**, and each tool | ||
| handler checks `ctx.http?.authInfo?.scopes` for the scope it needs. The scope | ||
| decision lives next to the code it guards — the handler is the only place that | ||
| authoritatively knows which tool is executing — instead of in middleware that | ||
| would have to re-derive the operation from the request body. | ||
|
|
||
| `server.ts` runs a minimal demo Authorization Server alongside the MCP Resource | ||
| Server. `client.ts` connects with a `files:read` token, calls `list-files` | ||
| (works), then calls `write-file` → the handler returns `{ isError: true }` with | ||
| `insufficient_scope: requires files:write`. | ||
|
|
||
| The transport's automatic `403 insufficient_scope` **step-up** flow (SEP-2350 — | ||
| scope union, refresh-bypass, `maxStepUpRetries`) applies when the RS responds | ||
| `403` at the HTTP layer; that path is exercised by | ||
| `test/e2e/scenarios/client-auth.test.ts`. | ||
|
|
||
| ```bash | ||
| pnpm --filter @mcp-examples/scoped-tools server -- --http --port 3000 | ||
| pnpm --filter @mcp-examples/scoped-tools client -- --http http://127.0.0.1:3000/mcp | ||
| ``` | ||
|
|
||
| > DEMO ONLY — the bundled AS auto-approves and grants whatever scope is asked | ||
| > for. Do not deploy. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| /** | ||
| * Self-verifying per-tool scope client. | ||
| * | ||
| * Drives the same OAuth machinery as `examples/oauth/client.ts` to obtain a | ||
| * `files:read` token, then exercises the server's handler-level per-tool scope | ||
| * checks: `list-files` succeeds; `write-file` returns a tool-result | ||
| * `{ isError: true }` because the token lacks `files:write`. The transport's | ||
| * automatic `403 insufficient_scope` step-up (SEP-2350) is exercised by the | ||
| * dedicated e2e scenario (`test/e2e/scenarios/client-auth.test.ts`); this | ||
| * example demonstrates the recommended server-side pattern of enforcing scope | ||
| * inside the tool handler that needs it. | ||
| */ | ||
| import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; | ||
| import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; | ||
|
|
||
| import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; | ||
| import { InMemoryOAuthClientProvider } from '../oauth/simpleOAuthClientProvider.js'; | ||
|
|
||
| const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); | ||
| const CALLBACK_URL = 'http://127.0.0.1:8091/callback'; | ||
|
|
||
| /** Follow the demo AS's auto-consent 302 and return the `code`. */ | ||
| async function followAuthorize(authorizationUrl: URL): Promise<string> { | ||
| const res = await fetch(authorizationUrl, { redirect: 'manual' }); | ||
| const location = res.headers.get('location'); | ||
| if (!location || res.status !== 302) throw new Error(`expected 302 from /authorize, got ${res.status}`); | ||
| const code = new globalThis.URL(location).searchParams.get('code'); | ||
| if (!code) throw new Error(`authorize redirect missing ?code: ${location}`); | ||
| return code; | ||
| } | ||
|
|
||
| runClient('scoped-tools', async () => { | ||
| const captured: URL[] = []; | ||
| const clientMetadata: OAuthClientMetadata = { | ||
| client_name: 'Scoped-Tools Step-Up Client', | ||
| redirect_uris: [CALLBACK_URL], | ||
| grant_types: ['authorization_code'], | ||
| response_types: ['code'], | ||
| token_endpoint_auth_method: 'none', | ||
| scope: 'files:read' | ||
| }; | ||
| const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => { | ||
| captured.push(url); | ||
| }); | ||
|
|
||
| // ---- 1. Initial authorization for files:read ------------------------------ | ||
| const client = new Client({ name: 'scoped-tools-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); | ||
| const t1 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); | ||
| let challenged = false; | ||
| try { | ||
| await client.connect(t1); | ||
| } catch (error) { | ||
| const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; | ||
| if (!(root instanceof UnauthorizedError)) throw error; | ||
| challenged = true; | ||
| } | ||
| check.ok(challenged, 'first connect must 401'); | ||
| check.equal(captured.length, 1, 'authorize URL captured'); | ||
| check.match(captured[0]?.searchParams.get('scope') ?? '', /files:read/); | ||
| await t1.finishAuth(await followAuthorize(captured[0]!)); | ||
| check.equal(provider.tokens()?.scope, 'files:read'); | ||
|
|
||
| // ---- 2. Reconnect with files:read; list-files works ----------------------- | ||
| const t2 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); | ||
| await client.connect(t2); | ||
| const listed = await client.callTool({ name: 'list-files', arguments: {} }); | ||
| check.match(listed.content?.[0]?.type === 'text' ? listed.content[0].text : '', /listed by .* \[files:read]/); | ||
|
|
||
| // ---- 3. write-file → handler-level insufficient_scope --------------------- | ||
| // Per-tool scope is enforced inside the tool handler (ctx.http?.authInfo), | ||
| // so an under-scoped call surfaces as a tool-result `isError`, not an HTTP | ||
| // 403. The transport's automatic step-up (SEP-2350) applies only when the | ||
| // RS responds 403 at the HTTP layer. | ||
| const denied = await client.callTool({ name: 'write-file', arguments: {} }); | ||
| check.equal(denied.isError, true, 'write-file must isError under files:read-only token'); | ||
| check.match(denied.content?.[0]?.type === 'text' ? denied.content[0].text : '', /insufficient_scope: requires files:write/); | ||
| check.equal(captured.length, 1, 'no transport step-up — scope is enforced in the tool handler'); | ||
|
|
||
| await client.close(); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| { | ||
| "name": "@mcp-examples/scoped-tools", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "server": "tsx server.ts", | ||
| "client": "tsx client.ts" | ||
| }, | ||
| "dependencies": { | ||
| "@modelcontextprotocol/client": "workspace:*", | ||
| "@modelcontextprotocol/express": "workspace:*", | ||
| "@modelcontextprotocol/node": "workspace:*", | ||
| "@modelcontextprotocol/server": "workspace:*", | ||
| "zod": "catalog:runtimeShared" | ||
| }, | ||
| "devDependencies": { | ||
| "tsx": "catalog:devTools" | ||
| }, | ||
| "example": { | ||
| "transports": [ | ||
| "http" | ||
| ], | ||
| "era": "modern", | ||
| "path": "/mcp", | ||
| "//": "Per-tool scope enforcement on createMcpHandler: HTTP gate does bearer-verify + 401 only; each tool handler checks ctx.http?.authInfo?.scopes and returns isError on miss. Modern-only because authInfo plumbing through ServerContext is the feature under demonstration." | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The changeset presents SEP-2350 as purely additive, but this PR also unconditionally changes what a 403 insufficient_scope throws for transports configured without an OAuthClientProvider (minimal AuthProvider, requestInit-only headers, or no authProvider): previously SdkHttpError(ClientHttpNotImplemented), now the new InsufficientScopeError, which does not extend SdkError, so existing instanceof SdkError/SdkHttpError handlers stop matching. docs/migration.md documents this and the PR checks the breaking-change box — consider adding one sentence to the changeset (the published CHANGELOG text) so the default-mode error-class change is not a silent surprise on upgrade.
Extended reasoning...
What changed. Pre-PR, the 403 branch in
_sendwas gated onresponse.status === 403 && this._oauthProvider. A transport configured with a minimalAuthProvider,requestInit-only headers, or noauthProviderat all therefore fell through to the genericSdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: …')on a 403insufficient_scope. Post-PR the gate is justresponse.status === 403, and_stepUpAuthorize(packages/client/src/client/streamableHttp.ts, ~line 380) throws the newInsufficientScopeErrorwheneverthis._oauthProvideris absent — even in the default'reauthorize'mode, with no opt-in by the consumer. The GET listen-stream open path gains the same behavior (it previously threwSdkHttpError(ClientHttpFailedToOpenStream)).\n\nWhy this is an error-class change consumers can hit.InsufficientScopeErrorextendsOAuthClientFlowError → Error, notSdkError/SdkHttpError. A host using one of the affected configurations whose error handling doesif (error instanceof SdkHttpError)(the pattern the migration guide itself recommends) will stop matching this case after upgrading, with no code change on their side.\n\nThe documentation gap.docs/migration.mdcovers this precisely ("If you pass a non-OAuthauthProvider(or onlyrequestInitheaders), a403 insufficient_scopenow throwsInsufficientScopeErrorinstead of the previous genericSdkHttpError(ClientHttpNotImplemented)… existinginstanceof SdkErrorcatches no longer match this case"),docs/migration-SKILL.mdhas the matching throw-site row, and the PR description checks the Breaking-change box. But.changeset/sep-2350-scope-step-up.md— the text that lands in the published CHANGELOG that upgraders actually read — describes the feature as purely additive and only mentionsInsufficientScopeErrorin connection with the opt-in'throw'mode ("'throw'raises the newInsufficientScopeError"), which under-describes the implementation.\n\nStep-by-step example.\n1. A host today usesnew StreamableHTTPClientTransport(url, { requestInit: { headers: { Authorization: 'Bearer …' } } })(noOAuthClientProvider) and wraps sends incatch (e) { if (e instanceof SdkHttpError) … }.\n2. The RS responds403withWWW-Authenticate: Bearer error=\"insufficient_scope\", scope=\"files:write\".\n3. Pre-PR: the 403 branch is skipped (no_oauthProvider), the request falls through toSdkHttpError(ClientHttpNotImplemented)and the handler matches.\n4. Post-PR:_stepUpAuthorizeruns (the gate no longer requires_oauthProvider), hits theif (!this._oauthProvider)branch, and throwsInsufficientScopeError. Theinstanceof SdkHttpErrorhandler no longer matches; the host's fallback path runs instead. Reading the changeset alone gives no hint of this — it impliesInsufficientScopeErroronly appears whenonInsufficientScope: 'throw'is set.\n\nOn the counter-argument that this is editorial. It is true that the repo's required upgrade documentation (migration.md / migration-SKILL.md) covers the change, that the changeset makes no false claim, and that the new typed error is strictly more informative — which is why this is filed as a nit, not a blocker. But the changeset is the only one of those documents that ships in the package CHANGELOG that consumers see at upgrade time, and it currently scopesInsufficientScopeErrorto the opt-in'throw'mode while the default mode also throws it for a whole class of transport configurations. That is a meaningful omission for the audience the changeset serves, and the fix is a single sentence.\n\nSuggested fix. Append one sentence to.changeset/sep-2350-scope-step-up.md, e.g.: "For transports without anOAuthClientProvider(minimalAuthProvider,requestInit-only headers, or noauthProvider), a 403insufficient_scopenow throwsInsufficientScopeError(notSdkError) instead of the previous genericSdkHttpError(ClientHttpNotImplemented)— see the migration guide's Scope step-up section."