Repository navigation
Step-up authorization (403 insufficient_scope) dead-ends with SdkHttpError when a refresh_token is present #2255
Description
Activity
Summary
Fixes #2255.
When a Streamable HTTP client receives a 403
insufficient_scopechallenge, the transport callsauth()with the requested step-up scope. If the provider already has a refresh token,authInternal()currently refreshes the token first and returnsAUTHORIZED.That refresh grant cannot collect user consent for the new scope, so the retry can receive the same 403 and hit the upscoping loop guard instead of redirecting the user.
This patch keeps refresh-token reuse for normal auth calls, but skips it when
auth()was called with an explicit scope. In that case the client starts the authorization flow with the requested step-up scope.Tests
pnpm --filter @modelcontextprotocol/client test -- auth.test.ts pnpm --filter @modelcontextprotocol/client typecheck pnpm --filter @modelcontextprotocol/client lint pnpm --filter @modelcontextprotocol/client build git diff --check
The pre-push hook also ran:
pnpm -r typecheck pnpm -r build pnpm sync:snippets --check && pnpm -r lint
tsdownstill prints the existingajv/fast-uritype-export warnings during build, but the build exits successfully.I prepared a small patch branch for this:
- branch: https://github.com/slegarraga/typescript-sdk/tree/codex/skip-step-up-refresh
- commit: https://github.com/slegarraga/typescript-sdk/commit/2ec70830
- compare: main...slegarraga:typescript-sdk:codex/skip-step-up-refresh
What it changes:
- skips refresh-token reuse when
auth()is called with an explicit scope, so the step-upinsufficient_scopepath can redirect for user consent instead of retrying the original insufficient scope - adds a regression test proving the explicit step-up scope path redirects, does not call the token endpoint, and preserves the requested scope/resource in the authorization URL
- adds a patch changeset for
@modelcontextprotocol/client
Validation:
corepack pnpm --filter @modelcontextprotocol/client test -- auth.test.tscorepack pnpm --filter @modelcontextprotocol/client typecheckcorepack pnpm --filter @modelcontextprotocol/client lintcorepack pnpm run build:allcorepack pnpm run typecheck:allcorepack pnpm run lint:all
I attempted to open a PR from the fork, but GitHub rejected both
gh pr createand the REST create-pull-request endpoint for this account (CreatePullRequestpermission / HTTP 404). The branch is available above if a maintainer wants to inspect or open/cherry-pick it.Reacted by Pavlo OleksiienkoI pushed a concrete patch from my fork for this issue:
- branch: https://github.com/he-yufeng/typescript-sdk/tree/fix/step-up-scope-redirect
- commit: he-yufeng@e6c19f02
- compare: main...he-yufeng:typescript-sdk:fix/step-up-scope-redirect
What it changes:
- keeps refresh-token reuse for ordinary
auth()calls - skips refresh-token reuse when
auth()is called with an explicit scope from a step-upinsufficient_scopechallenge - redirects to authorization with the requested scope/resource so the user can consent instead of retrying the old insufficient token
- adds a regression test and a patch changeset for
@modelcontextprotocol/client
Validation:
corepack pnpm --filter @modelcontextprotocol/client test -- auth.test.ts-> 11 files / 368 tests passedcorepack pnpm --filter @modelcontextprotocol/client typecheck-> passedcorepack pnpm --filter @modelcontextprotocol/client lint-> passedcorepack pnpm --filter @modelcontextprotocol/client build-> passed- pre-push hook also ran
pnpm -r typecheck,pnpm -r build, andpnpm sync:snippets --check && pnpm -r lintsuccessfully
I tried to open the PR from this fork branch, but GitHub rejected the create-pull-request operation for this account with
CreatePullRequestpermission. The branch is available above if a maintainer wants to open/cherry-pick it.Reacted by Pavlo Oleksiienko- addedtriageQueued for automated analysis — bot will process and remove this labelQueued for automated analysis — bot will process and remove this label
on Jul 9, 2026 already fixed on main — verified at 3834921. PR #2286 (which incorporated PR #2356, SEP-2350 scope step-up) added a superset-gated refresh bypass:
authInternal()now takes aforceReauthorizationflag, and the streamable HTTP transport sets it when the union of transport-tracked, token-granted, and challenged scopes strictly exceeds the current token's granted scope. refresh is skipped and the user is redirected with the widened scope.still reproduces on v1.x — a backport would be needed if the 1.x line is still receiving fixes.
reopen if this is still hitting on 2.0+.
repro.ts (main) — offline_access token + 403 insufficient_scope challenge
import { StreamableHTTPClientTransport, type OAuthClientProvider } from '@modelcontextprotocol/client'; import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/core-internal'; class MemoryOAuthProvider implements OAuthClientProvider { public tokensStore: OAuthTokens | undefined = { access_token: 'insufficient-access-token', token_type: 'Bearer', refresh_token: 'existing-refresh-token', scope: 'offline_access' }; public clientInfoStore: OAuthClientInformationFull | undefined = { client_id: 'test-client-id', client_secret: 'test-client-secret', client_id_issued_at: 1700000000, client_secret_expires_at: 0, redirect_uris: ['http://localhost:1234/callback'] }; public codeVerifierStore: string | undefined; public redirectCalledWith: URL | undefined; get redirectUrl() { return 'http://localhost:1234/callback'; } get clientMetadata() { return { redirect_uris: [this.redirectUrl] }; } async clientInformation() { return this.clientInfoStore; } async saveClientInformation(info: OAuthClientInformationFull) { this.clientInfoStore = info; } async tokens() { return this.tokensStore; } async saveTokens(tokens: OAuthTokens) { this.tokensStore = tokens; } async redirectToAuthorization(url: URL) { this.redirectCalledWith = url; } async saveCodeVerifier(v: string) { this.codeVerifierStore = v; } async codeVerifier() { if (!this.codeVerifierStore) throw new Error('no code verifier'); return this.codeVerifierStore; } } const authServer = 'https://auth.example.com'; const resourceServer = 'https://mcp.example.com'; let callCount = 0; const calls: { url: string; init?: RequestInit }[] = []; globalThis.fetch = (async (input: any, init?: RequestInit) => { const url = typeof input === 'string' ? input : input.toString(); calls.push({ url, init }); callCount++; if (url === `${resourceServer}/mcp` && (init?.method ?? 'POST') === 'POST') { return new Response('Insufficient scope', { status: 403, statusText: 'Forbidden', headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="resource:tools:custom_scope", resource_metadata="${resourceServer}/.well-known/oauth-protected-resource"` } }) as any; } if (url.includes('oauth-protected-resource')) { return new Response(JSON.stringify({ resource: resourceServer, authorization_servers: [authServer] }), { status: 200, headers: { 'Content-Type': 'application/json' } }) as any; } if (url.includes('/.well-known/oauth-authorization-server') || url.includes('/.well-known/openid-configuration')) { return new Response(JSON.stringify({ issuer: authServer, authorization_endpoint: `${authServer}/authorize`, token_endpoint: `${authServer}/token`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], scopes_supported: ['offline_access', 'resource:tools:custom_scope'] }), { status: 200, headers: { 'Content-Type': 'application/json' } }) as any; } if (url === `${authServer}/token`) { console.log(` [!] Token endpoint HIT: ${String(init?.body ?? '').slice(0, 200)}`); return new Response(JSON.stringify({ access_token: 'refreshed-but-still-insufficient', token_type: 'Bearer', refresh_token: 'existing-refresh-token', scope: 'offline_access' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) as any; } return new Response('unexpected', { status: 500 }) as any; }) as typeof fetch; const provider = new MemoryOAuthProvider(); const transport = new StreamableHTTPClientTransport(new URL(`${resourceServer}/mcp`), { authProvider: provider, skipIssuerMetadataValidation: true }); let caught: unknown; try { await transport.send({ jsonrpc: '2.0', method: 'tools/call', params: { name: 'custom_tool', arguments: {} }, id: 'test-id-1' } as any); } catch (e) { caught = e; } console.log(`token endpoint hit: ${calls.some(c => c.url === `${authServer}/token`)}`); console.log(`redirectToAuthorization called: ${provider.redirectCalledWith !== undefined}`); if (provider.redirectCalledWith) { console.log(` scope in URL: "${provider.redirectCalledWith.searchParams.get('scope')}"`); } console.log(`error thrown: ${caught ? (caught as Error).constructor.name + ': ' + (caught as Error).message : '<none>'}`);
run:
npx tsx repro.tson main (3834921):
token endpoint hit: false redirectToAuthorization called: true scope in URL: "offline_access resource:tools:custom_scope" error thrown: UnauthorizedError: Unauthorizedon v1.x (69749aa) — the same scenario against
src/client/streamableHttp.ts:[!] Token endpoint HIT: grant_type=refresh_token&refresh_token=existing-refresh-token&resource=https%3A%2F%2Fmcp.example.com%2F token endpoint hit: true redirectToAuthorization called: false error thrown: StreamableHTTPError: Streamable HTTP error: Server returned 403 after trying upscoping- addedauthIssues and PRs related to Authentication / OAuthIssues and PRs related to Authentication / OAuthpotentially closeBot recommends closing — needs maintainer reviewBot recommends closing — needs maintainer reviewand removedtriageQueued for automated analysis — bot will process and remove this labelQueued for automated analysis — bot will process and remove this label
on Jul 9, 2026
Describe the bug
When an MCP server returns
HTTP 403withWWW-Authenticate: Bearer error="insufficient_scope"(the step-up authorization flow),
and the client holds a
refresh_token(e.g.offline_accesswas originally granted), the SDKalways throws
SdkHttpError(ClientHttpForbidden) Server returned 403 after trying upscopinginstead of redirecting the user to re-authorizewith the new scope. The user never gets a chance to consent to the required scope.
The root cause is that
authInternal()unconditionally attempts a token refresh whenrefresh_tokenis present -- butrefreshAuthorization()never sends ascopeparameter (and it shouldn't as the user may not have granted the consent for that scope yet).Per RFC 6749 §6, the AS therefore issues a new token with the original (insufficient) scope.
The retry gets an identical 403, the loop guard fires, and the SDK throws instead of falling
through to the redirect-based re-authorization flow that would actually resolve the problem.
To Reproduce
The issue can currently be reproduced with MCP Inspector and
mcp-remote.offline_access(issues refresh tokens).offline_accessas a default scope (on first authentication in 401 response), andresource:tools:custom_scopeas a step-up with 403 when usingcustom_tooltool.offline_accessscope if asked.custom_toolfrom the client.SdkHttpErrorwith codeClientHttpForbiddenand message"Server returned 403 after trying upscoping". No browser redirect ever happened.Expected behavior
The client recognizes it cannot upscope via a refresh token, and
calls
provider.redirectToAuthorization()with an authorization URL scoped to the new requiredscope -- allowing the user to consent.
Code proofs
The following code facts combine to produce the bug:
1.
authInternal()unconditionally takes the refresh path(
packages/client/src/client/auth.ts:762–804)2. The loop guard fires on the retry, permanently killing the request
(
packages/client/src/client/streamableHttp.ts:596–633)Additional context
Suggested fix -- in
authInternal(), skip the refresh path when an explicitscopeis beingrequested (which is the signal that this is a step-up call).
Related specs: