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_access as a default scope (on first authentication in 401 response), and resource:tools:custom_scope as a step-up with 403 when using custom_tool tool.
- Successfully connect to that server with an MCP Client, authenticate and consent to
offline_access scope if asked.
- Call the
custom_tool from 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
SdkHttpError with code ClientHttpForbidden and 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:
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: