Skip to content

Commit d62ce60

Browse files
feat(client): SEP-2350 scope step-up — union, retry cap, superset-gated refresh bypass
Adds onInsufficientScope handling: computeScopeUnion (previously-granted ∪ challenged), per-transport retry cap, and superset-gated refresh bypass when the held token already covers the challenged scope. InsufficientScopeError joins the other flow errors in authErrors.ts. Includes the scoped-tools example story (server + demo AS + client). The demo authorization server is hardened: DCR rejects any redirect_uri that is not a loopback host (127.0.0.1, localhost, [::1]) with http/https scheme; /authorize validates the request's redirect_uri exactly against the per-client registered set; both the demo AS and the MCP resource server bind to 127.0.0.1 only. Also wires the conformance fixture's fetch-level withOAuthRetry to use the exported computeScopeUnion on 403 insufficient_scope (the fixture intercepts before the transport's _stepUpAuthorize sees it). The 2026-07-28 leg's auth/scope-step-up entry stays in the expected-failures baseline — it is gated on the fixture's 2025 lifecycle (-32020 before scope escalation is observed), orthogonal to this change. Claude-Session: https://claude.ai/code/session_01XBib5gRe8AMPPJhySCz3EJ
1 parent a81ff07 commit d62ce60

19 files changed

Lines changed: 1189 additions & 73 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
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.

docs/migration-SKILL.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Three error classes now exist:
119119
| HTTP transport error (legacy era) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` |
120120
| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` |
121121
| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` |
122-
| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` |
122+
| 403 insufficient_scope after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` |
123123
| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` |
124124
| Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` |
125125
| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` |
@@ -171,7 +171,7 @@ if (error instanceof SdkHttpError) {
171171
console.log('Status text:', error.statusText); // string | undefined
172172
switch (error.code) {
173173
case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth
174-
case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping
174+
case SdkErrorCode.ClientHttpForbidden: // 403 insufficient_scope after step-up retry cap
175175
case SdkErrorCode.ClientHttpFailedToOpenStream:
176176
case SdkErrorCode.ClientHttpNotImplemented:
177177
break;
@@ -207,19 +207,22 @@ Individual OAuth error classes replaced with single `OAuthError` class and `OAut
207207
| `MethodNotAllowedError` | `OAuthError` with `OAuthErrorCode.MethodNotAllowed` |
208208
| `TooManyRequestsError` | `OAuthError` with `OAuthErrorCode.TooManyRequests` |
209209
| `InvalidClientMetadataError` | `OAuthError` with `OAuthErrorCode.InvalidClientMetadata` |
210-
| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` |
210+
| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` ¹ |
211211
| `InvalidTargetError` | `OAuthError` with `OAuthErrorCode.InvalidTarget` |
212212
| `CustomOAuthError` | `new OAuthError(customCode, message)` |
213213

214+
¹ v1 server-side OAuth error only. The new transport-layer `InsufficientScopeError` exported from `@modelcontextprotocol/client` for SEP-2350 (RFC 6750 challenge from the resource server) is a DIFFERENT class, extends `OAuthClientFlowError` not `OAuthError`, and MUST NOT be rewritten by this row.
215+
214216
Removed: `OAUTH_ERRORS` constant.
215217

216-
The OAuth client flow additionally throws dedicated classes from `@modelcontextprotocol/client` (all extend `OAuthClientFlowError`, **not** `OAuthError``auth()`'s `OAuthError` retry path will not catch them):
218+
The OAuth client flow additionally throws dedicated classes from `@modelcontextprotocol/client` (all extend `OAuthClientFlowError`, **not** `OAuthError``auth()`'s `OAuthError` retry path will not catch them). SEP-2350 adds `InsufficientScopeError` to this set; see the migration guide's [Scope step-up section](./migration.md#scope-step-up-on-403-insufficient_scope-sep-2350).
217219

218220
| Throw site | v2 class |
219221
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
220222
| `registerClient()` rejected by AS (any RFC 7591 error incl. `invalid_client_metadata`, `invalid_redirect_uri`) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) |
221223
| `exchangeAuthorization()` / `refreshAuthorization()` / `fetchToken()` / `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()` non-https token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) |
222224
| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) |
225+
| Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) |
223226

224227
Update OAuth error handling:
225228

docs/migration.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ The new `SdkErrorCode` enum contains string-valued codes for local SDK errors:
787787
| `SdkErrorCode.InvalidResult` | Response result failed local schema validation |
788788
| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed |
789789
| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication |
790-
| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping |
790+
| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 insufficient_scope after step-up re-authorization (retry cap reached) |
791791
| `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response |
792792
| `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream |
793793
| `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session |
@@ -826,7 +826,7 @@ try {
826826
console.log('Auth failed — server rejected token after re-auth');
827827
break;
828828
case SdkErrorCode.ClientHttpForbidden:
829-
console.log('Forbidden after upscoping attempt');
829+
console.log('403 insufficient_scope after step-up re-authorization (retry cap)');
830830
break;
831831
case SdkErrorCode.ClientHttpFailedToOpenStream:
832832
console.log('Failed to open SSE stream');
@@ -874,10 +874,12 @@ The following individual error classes have been removed in favor of `OAuthError
874874
| `MethodNotAllowedError` | `new OAuthError(OAuthErrorCode.MethodNotAllowed, message)` |
875875
| `TooManyRequestsError` | `new OAuthError(OAuthErrorCode.TooManyRequests, message)` |
876876
| `InvalidClientMetadataError` | `new OAuthError(OAuthErrorCode.InvalidClientMetadata, message)` |
877-
| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` |
877+
| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` ¹ |
878878
| `InvalidTargetError` | `new OAuthError(OAuthErrorCode.InvalidTarget, message)` |
879879
| `CustomOAuthError` | `new OAuthError(customCode, message)` |
880880

881+
¹ Unrelated to the new transport-layer `InsufficientScopeError` introduced for SEP-2350 — that class carries an RFC 6750 `WWW-Authenticate` challenge from the resource server and does **not** extend `OAuthError`; see [Scope step-up on `403 insufficient_scope`](#scope-step-up-on-403-insufficient_scope-sep-2350).
882+
881883
The `OAUTH_ERRORS` constant has also been removed.
882884

883885
If you need the v1 OAuth error classes and `mcpAuthRouter` during migration, `@modelcontextprotocol/server-legacy/auth` provides a frozen copy:
@@ -1542,6 +1544,22 @@ await transport.finishAuth(url.searchParams); // SDK reads `code` + `iss`
15421544

15431545
`discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not exactly match the URL it was fetched for (RFC 8414 §3.3). If you connect to a known-misconfigured AS, set `skipIssuerMetadataValidation: true` on `StreamableHTTPClientTransportOptions` / `SSEClientTransportOptions` (or on `AuthOptions` if you call `auth()` directly, or `skipIssuerValidation: true` on the low-level helper) — **this weakens the mix-up defense and should be treated as a temporary workaround.** It suppresses only the metadata-echo check; the callback-`iss` validation always runs (and degrades to a no-op only when `iss` is absent and the AS does not advertise support).
15441546

1547+
### Scope step-up on `403 insufficient_scope` (SEP-2350)
1548+
1549+
`StreamableHTTPClientTransport` now accepts `onInsufficientScope: 'reauthorize' | 'throw'` (default **`'reauthorize'`**, matching the previous unconditional behavior).
1550+
1551+
On `'reauthorize'` the transport re-authorizes with the **union** of the previously-requested scope and the challenged scope (new exported helper `computeScopeUnion`), so previously-granted permissions are not lost on step-up. When that union is a strict superset of the current token's granted scope (`isStrictScopeSuperset`), the SDK **bypasses the refresh-token branch** and forces a fresh authorization request — the refresh grant cannot widen scope (RFC 6749 §6), so refreshing would silently drop the new scope. When the token already covers the union, refresh is used as before.
1552+
1553+
On `'throw'` the transport raises `InsufficientScopeError { requiredScope, resourceMetadataUrl, errorDescription }` and does not re-authorize. Set `'throw'` for `client_credentials` / m2m clients where re-authorization cannot widen scope, and for interactive clients that need to gate the consent prompt behind UX.
1554+
1555+
If you pass a non-OAuth `authProvider` (or only `requestInit` headers), a `403 insufficient_scope` now throws `InsufficientScopeError` instead of the previous generic `SdkHttpError(ClientHttpNotImplemented)` ("Error POSTing to endpoint: …") — `InsufficientScopeError` extends `Error`, not `SdkError`, so existing `instanceof SdkError` catches no longer match this case. Catch `InsufficientScopeError` explicitly, or set `onInsufficientScope: 'throw'` to make the contract explicit.
1556+
1557+
Step-up retries are now hard-capped per send (`maxStepUpRetries`, default 1) regardless of `WWW-Authenticate` header content — the previous verbatim-header equality guard is gone. The cap is per request; cross-request "(resource, operation) already failed" tracking is host state.
1558+
1559+
`AuthOptions` gains `forceReauthorization?: boolean` for hosts driving step-up themselves.
1560+
1561+
The GET listen-stream open path now applies the same step-up handling as the POST send path.
1562+
15451563
### Conformance obligations for `OAuthClientProvider` implementers
15461564

15471565
<!-- Filled in as the SEP-2352/2350/837/2207 behavior PRs land. -->

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Add `-- --legacy` to the client command for the 2025-era handshake.
4545
| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual |
4646
| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual |
4747
| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual |
48+
| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern |
4849

4950
## HTTP hosting variants
5051

examples/scoped-tools/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# scoped-tools — per-tool scope enforced in the tool handler
2+
3+
Demonstrates per-tool OAuth scope enforcement on a `createMcpHandler`
4+
deployment: the HTTP gate does **bearer-verify + 401 only**, and each tool
5+
handler checks `ctx.http?.authInfo?.scopes` for the scope it needs. The scope
6+
decision lives next to the code it guards — the handler is the only place that
7+
authoritatively knows which tool is executing — instead of in middleware that
8+
would have to re-derive the operation from the request body.
9+
10+
`server.ts` runs a minimal demo Authorization Server alongside the MCP Resource
11+
Server. `client.ts` connects with a `files:read` token, calls `list-files`
12+
(works), then calls `write-file` → the handler returns `{ isError: true }` with
13+
`insufficient_scope: requires files:write`.
14+
15+
The transport's automatic `403 insufficient_scope` **step-up** flow (SEP-2350 —
16+
scope union, refresh-bypass, `maxStepUpRetries`) applies when the RS responds
17+
`403` at the HTTP layer; that path is exercised by
18+
`test/e2e/scenarios/client-auth.test.ts`.
19+
20+
```bash
21+
pnpm --filter @mcp-examples/scoped-tools server -- --http --port 3000
22+
pnpm --filter @mcp-examples/scoped-tools client -- --http http://127.0.0.1:3000/mcp
23+
```
24+
25+
> DEMO ONLY — the bundled AS auto-approves and grants whatever scope is asked
26+
> for. Do not deploy.

examples/scoped-tools/client.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Self-verifying per-tool scope client.
3+
*
4+
* Drives the same OAuth machinery as `examples/oauth/client.ts` to obtain a
5+
* `files:read` token, then exercises the server's handler-level per-tool scope
6+
* checks: `list-files` succeeds; `write-file` returns a tool-result
7+
* `{ isError: true }` because the token lacks `files:write`. The transport's
8+
* automatic `403 insufficient_scope` step-up (SEP-2350) is exercised by the
9+
* dedicated e2e scenario (`test/e2e/scenarios/client-auth.test.ts`); this
10+
* example demonstrates the recommended server-side pattern of enforcing scope
11+
* inside the tool handler that needs it.
12+
*/
13+
import type { OAuthClientMetadata } from '@modelcontextprotocol/client';
14+
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
15+
16+
import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js';
17+
import { InMemoryOAuthClientProvider } from '../oauth/simpleOAuthClientProvider.js';
18+
19+
const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp');
20+
const CALLBACK_URL = 'http://127.0.0.1:8091/callback';
21+
22+
/** Follow the demo AS's auto-consent 302 and return the `code`. */
23+
async function followAuthorize(authorizationUrl: URL): Promise<string> {
24+
const res = await fetch(authorizationUrl, { redirect: 'manual' });
25+
const location = res.headers.get('location');
26+
if (!location || res.status !== 302) throw new Error(`expected 302 from /authorize, got ${res.status}`);
27+
const code = new globalThis.URL(location).searchParams.get('code');
28+
if (!code) throw new Error(`authorize redirect missing ?code: ${location}`);
29+
return code;
30+
}
31+
32+
runClient('scoped-tools', async () => {
33+
const captured: URL[] = [];
34+
const clientMetadata: OAuthClientMetadata = {
35+
client_name: 'Scoped-Tools Step-Up Client',
36+
redirect_uris: [CALLBACK_URL],
37+
grant_types: ['authorization_code'],
38+
response_types: ['code'],
39+
token_endpoint_auth_method: 'none',
40+
scope: 'files:read'
41+
};
42+
const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => {
43+
captured.push(url);
44+
});
45+
46+
// ---- 1. Initial authorization for files:read ------------------------------
47+
const client = new Client({ name: 'scoped-tools-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() });
48+
const t1 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider });
49+
let challenged = false;
50+
try {
51+
await client.connect(t1);
52+
} catch (error) {
53+
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
54+
if (!(root instanceof UnauthorizedError)) throw error;
55+
challenged = true;
56+
}
57+
check.ok(challenged, 'first connect must 401');
58+
check.equal(captured.length, 1, 'authorize URL captured');
59+
check.match(captured[0]?.searchParams.get('scope') ?? '', /files:read/);
60+
await t1.finishAuth(await followAuthorize(captured[0]!));
61+
check.equal(provider.tokens()?.scope, 'files:read');
62+
63+
// ---- 2. Reconnect with files:read; list-files works -----------------------
64+
const t2 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider });
65+
await client.connect(t2);
66+
const listed = await client.callTool({ name: 'list-files', arguments: {} });
67+
check.match(listed.content?.[0]?.type === 'text' ? listed.content[0].text : '', /listed by .* \[files:read]/);
68+
69+
// ---- 3. write-file → handler-level insufficient_scope ---------------------
70+
// Per-tool scope is enforced inside the tool handler (ctx.http?.authInfo),
71+
// so an under-scoped call surfaces as a tool-result `isError`, not an HTTP
72+
// 403. The transport's automatic step-up (SEP-2350) applies only when the
73+
// RS responds 403 at the HTTP layer.
74+
const denied = await client.callTool({ name: 'write-file', arguments: {} });
75+
check.equal(denied.isError, true, 'write-file must isError under files:read-only token');
76+
check.match(denied.content?.[0]?.type === 'text' ? denied.content[0].text : '', /insufficient_scope: requires files:write/);
77+
check.equal(captured.length, 1, 'no transport step-up — scope is enforced in the tool handler');
78+
79+
await client.close();
80+
});

examples/scoped-tools/package.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@mcp-examples/scoped-tools",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"server": "tsx server.ts",
7+
"client": "tsx client.ts"
8+
},
9+
"dependencies": {
10+
"@modelcontextprotocol/client": "workspace:*",
11+
"@modelcontextprotocol/express": "workspace:*",
12+
"@modelcontextprotocol/node": "workspace:*",
13+
"@modelcontextprotocol/server": "workspace:*",
14+
"zod": "catalog:runtimeShared"
15+
},
16+
"devDependencies": {
17+
"tsx": "catalog:devTools"
18+
},
19+
"example": {
20+
"transports": [
21+
"http"
22+
],
23+
"era": "modern",
24+
"path": "/mcp",
25+
"//": "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."
26+
}
27+
}

0 commit comments

Comments
 (0)