Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sep-2350-scope-step-up.md
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.
Comment on lines +1 to +5

Copy link
Copy Markdown

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 _send was gated on response.status === 403 && this._oauthProvider. A transport configured with a minimal AuthProvider, requestInit-only headers, or no authProvider at all therefore fell through to the generic SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: …') on a 403 insufficient_scope. Post-PR the gate is just response.status === 403, and _stepUpAuthorize (packages/client/src/client/streamableHttp.ts, ~line 380) throws the new InsufficientScopeError whenever this._oauthProvider is 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 threw SdkHttpError(ClientHttpFailedToOpenStream)).\n\nWhy this is an error-class change consumers can hit. InsufficientScopeError extends OAuthClientFlowError → Error, not SdkError/SdkHttpError. A host using one of the affected configurations whose error handling does if (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.md covers this precisely ("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) … existing instanceof SdkError catches no longer match this case"), docs/migration-SKILL.md has 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 mentions InsufficientScopeError in connection with the opt-in 'throw' mode ("'throw' raises the new InsufficientScopeError"), which under-describes the implementation.\n\nStep-by-step example.\n1. A host today uses new StreamableHTTPClientTransport(url, { requestInit: { headers: { Authorization: 'Bearer …' } } }) (no OAuthClientProvider) and wraps sends in catch (e) { if (e instanceof SdkHttpError) … }.\n2. The RS responds 403 with WWW-Authenticate: Bearer error=\"insufficient_scope\", scope=\"files:write\".\n3. Pre-PR: the 403 branch is skipped (no _oauthProvider), the request falls through to SdkHttpError(ClientHttpNotImplemented) and the handler matches.\n4. Post-PR: _stepUpAuthorize runs (the gate no longer requires _oauthProvider), hits the if (!this._oauthProvider) branch, and throws InsufficientScopeError. The instanceof SdkHttpError handler no longer matches; the host's fallback path runs instead. Reading the changeset alone gives no hint of this — it implies InsufficientScopeError only appears when onInsufficientScope: '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 scopes InsufficientScopeError to 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 an OAuthClientProvider (minimal AuthProvider, requestInit-only headers, or no authProvider), a 403 insufficient_scope now throws InsufficientScopeError (not SdkError) instead of the previous generic SdkHttpError(ClientHttpNotImplemented) — see the migration guide's Scope step-up section."

11 changes: 7 additions & 4 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Three error classes now exist:
| HTTP transport error (legacy era) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` |
| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` |
| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` |
| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` |
| 403 insufficient_scope after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` |
| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` |
| Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` |
| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` |
Expand Down Expand Up @@ -171,7 +171,7 @@ if (error instanceof SdkHttpError) {
console.log('Status text:', error.statusText); // string | undefined
switch (error.code) {
case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth
case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping
case SdkErrorCode.ClientHttpForbidden: // 403 insufficient_scope after step-up retry cap
case SdkErrorCode.ClientHttpFailedToOpenStream:
case SdkErrorCode.ClientHttpNotImplemented:
break;
Expand Down Expand Up @@ -207,19 +207,22 @@ Individual OAuth error classes replaced with single `OAuthError` class and `OAut
| `MethodNotAllowedError` | `OAuthError` with `OAuthErrorCode.MethodNotAllowed` |
| `TooManyRequestsError` | `OAuthError` with `OAuthErrorCode.TooManyRequests` |
| `InvalidClientMetadataError` | `OAuthError` with `OAuthErrorCode.InvalidClientMetadata` |
| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` |
| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` ¹ |
| `InvalidTargetError` | `OAuthError` with `OAuthErrorCode.InvalidTarget` |
| `CustomOAuthError` | `new OAuthError(customCode, message)` |

¹ 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.

Removed: `OAUTH_ERRORS` constant.

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):
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).

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

Update OAuth error handling:

Expand Down
24 changes: 21 additions & 3 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ The new `SdkErrorCode` enum contains string-valued codes for local SDK errors:
| `SdkErrorCode.InvalidResult` | Response result failed local schema validation |
| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed |
| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication |
| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping |
| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 insufficient_scope after step-up re-authorization (retry cap reached) |
| `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response |
| `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream |
| `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session |
Expand Down Expand Up @@ -826,7 +826,7 @@ try {
console.log('Auth failed — server rejected token after re-auth');
break;
case SdkErrorCode.ClientHttpForbidden:
console.log('Forbidden after upscoping attempt');
console.log('403 insufficient_scope after step-up re-authorization (retry cap)');
break;
case SdkErrorCode.ClientHttpFailedToOpenStream:
console.log('Failed to open SSE stream');
Expand Down Expand Up @@ -874,10 +874,12 @@ The following individual error classes have been removed in favor of `OAuthError
| `MethodNotAllowedError` | `new OAuthError(OAuthErrorCode.MethodNotAllowed, message)` |
| `TooManyRequestsError` | `new OAuthError(OAuthErrorCode.TooManyRequests, message)` |
| `InvalidClientMetadataError` | `new OAuthError(OAuthErrorCode.InvalidClientMetadata, message)` |
| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` |
| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` ¹ |
| `InvalidTargetError` | `new OAuthError(OAuthErrorCode.InvalidTarget, message)` |
| `CustomOAuthError` | `new OAuthError(customCode, message)` |

¹ 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).

The `OAUTH_ERRORS` constant has also been removed.

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

`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).

### Scope step-up on `403 insufficient_scope` (SEP-2350)

`StreamableHTTPClientTransport` now accepts `onInsufficientScope: 'reauthorize' | 'throw'` (default **`'reauthorize'`**, matching the previous unconditional behavior).

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.

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.

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.

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.

`AuthOptions` gains `forceReauthorization?: boolean` for hosts driving step-up themselves.

The GET listen-stream open path now applies the same step-up handling as the POST send path.

### Conformance obligations for `OAuthClientProvider` implementers

<!-- Filled in as the SEP-2352/2350/837/2207 behavior PRs land. -->
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Add `-- --legacy` to the client command for the 2025-era handshake.
| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual |
| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual |
| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual |
| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern |

## HTTP hosting variants

Expand Down
26 changes: 26 additions & 0 deletions examples/scoped-tools/README.md
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.
80 changes: 80 additions & 0 deletions examples/scoped-tools/client.ts
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();
});
27 changes: 27 additions & 0 deletions examples/scoped-tools/package.json
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."
}
}
Loading
Loading