Skip to content

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

@pavlo-to

Description

@pavlo-to

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:

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions