modelcontextprotocol / modelcontextprotocol/typescript-sdk
Step-up authorization (403 insufficient_scope) dead-ends with SdkHttpError when a refresh_token is present
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
Describe the bug
When an MCP server returns HTTP 403 with WWW-Authenticate: Bearer error="insufficient_scope"
(the step-up authorization flow),
and the client holds a refresh_token (e.g. offline_access was originally granted), the SDK
always throws SdkHttpError(ClientHttpForbidden) Server returned 403 after trying upscoping instead of redirecting the user to re-authorize
with 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 when
refresh_token is present -- but refreshAuthorization() never sends a scope parameter (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.
- Use an OAuth 2.1 Authorization Server that supports
offline_access(issues refresh tokens). - Start a simple MCP Server that requests
offline_accessas a default scope (on first authentication in 401 response), andresource:tools:custom_scopeas a step-up with 403 when usingcustom_tooltool. - Successfully connect to that server with an MCP Client, authenticate and consent to
offline_accessscope if asked. - Call the
custom_toolfrom the client. - The server responds with:
HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", scope="resource:tools:custom_scope", resource_metadata="https://example.com/.wource/mcp", error_description="Tool 'custom-tool' requires additional scope" - Observe the client throws
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 required
scope -- 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)
const tokens = await provider.tokens();
if (tokens?.refresh_token) { // no check: is this a step-up request?
const newTokens = await refreshAuthorization(authorization
metadata, clientInformation,
refreshToken: tokens.refresh_token,
resource, addClientAuthentication, fetchFn
});
await provider.saveTokens(newTokens);
return 'AUTHORIZED'; // returns AUTHORIZED with an insufficient-scope token
}
// This code is never reached when refresh succeeds:
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
scope: resolvedScope, // the correct upscoped
...
});
await provider.redirectToAuthorization(authorizationUrl);
return 'REDIRECT';
2. The loop guard fires on the retry, permanently killing the request
(packages/client/src/client/streamableHttp.ts:596–633)
// First 403 — upscoping attempted:
this._lastUpscopingHeader = wwwAuthHeader; // line 620: guard
const result = await auth(...); // line 621: returns 'AUTHORIZED' (wrong scope)
return this._send(message, options, ...);
// Second 403 — identical header, because token scope is still
if (this._lastUpscopingHeader === wwwAuthHeader) { // line 603: FIRES
throw new SdkHttpError(ClientHttpForbidden,
'Server returned 403 after trying upscoping'); // thrown — flow ends here
}
Additional context
Suggested fix -- in authInternal(), skip the refresh path when an explicit scope is being
requested (which is the signal that this is a step-up call).
Related specs:
- MCP Authorization — Step-up flow: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow
- RFC 6749 §6 — Refreshing an Access Token: https://www.rfc-ed6
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/client/src/client/auth.ts at authInternal() and compare the explicit scope path with refreshAuthorization(). Then trace the retry and loop guard in packages/client/src/client/streamableHttp.ts; the fix is complete when a 403 insufficient_scope request with a refresh token redirects via provider.redirectToAuthorization() for the required scope instead of ending in SdkHttpError.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 62/100