modelcontextprotocol / modelcontextprotocol/typescript-sdk

Step-up authorization (403 insufficient_scope) dead-ends with SdkHttpError when a refresh_token is present

Open
#2,255 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

auth bug potentially close
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.

  1. Use an OAuth 2.1 Authorization Server that supports offline_access (issues refresh tokens).
  2. 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.
  3. Successfully connect to that server with an MCP Client, authenticate and consent to offline_access scope if asked.
  4. Call the custom_tool from the client.
  5. 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"
    
  6. 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:

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.