Repository navigation
Step-up authorization (403 insufficient_scope) dead-ends with SdkHttpError when a refresh_token is present #2255
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
Activity
he-yufeng commentedon Jun 7, 2026
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
The pre-push hook also ran:
tsdownstill prints the existingajv/fast-uritype-export warnings during build, but the build exits successfully.slegarraga commentedon Jun 7, 2026
I prepared a small patch branch for this:
What it changes:
auth()is called with an explicit scope, so the step-upinsufficient_scopepath can redirect for user consent instead of retrying the original insufficient scope@modelcontextprotocol/clientValidation:
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:allI 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.he-yufeng commentedon Jun 8, 2026
I pushed a concrete patch from my fork for this issue:
What it changes:
auth()callsauth()is called with an explicit scope from a step-upinsufficient_scopechallenge@modelcontextprotocol/clientValidation:
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-> passedpnpm -r typecheck,pnpm -r build, andpnpm sync:snippets --check && pnpm -r lintsuccessfullyI 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.