-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(client): SEP-2350 scope step-up — union, retry cap, superset-gated refresh bypass #2356
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
v2-2026-07-28
from
fweinberger/auth-3-sep2350-stepup
Jun 24, 2026
Merged
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'; | ||
|
felixweinberger marked this conversation as resolved.
|
||
|
|
||
| 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,28 @@ | ||
| { | ||
| "name": "@mcp-examples/scoped-tools", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "server": "tsx server.ts", | ||
| "client": "tsx client.ts" | ||
| }, | ||
| "dependencies": { | ||
| "@mcp-examples/oauth": "workspace:*", | ||
| "@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.
Uh oh!
There was an error while loading. Please reload this page.