Skip to content

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

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:

Activity

  1. he-yufeng commented on Jun 7, 2026

    @he-yufeng

    Summary

    Fixes #2255.

    When a Streamable HTTP client receives a 403 insufficient_scope challenge, the transport calls auth() with the requested step-up scope. If the provider already has a refresh token, authInternal() currently refreshes the token first and returns AUTHORIZED.

    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

    pnpm --filter @modelcontextprotocol/client test -- auth.test.ts
    pnpm --filter @modelcontextprotocol/client typecheck
    pnpm --filter @modelcontextprotocol/client lint
    pnpm --filter @modelcontextprotocol/client build
    git diff --check

    The pre-push hook also ran:

    pnpm -r typecheck
    pnpm -r build
    pnpm sync:snippets --check && pnpm -r lint

    tsdown still prints the existing ajv / fast-uri type-export warnings during build, but the build exits successfully.

  2. slegarraga commented on Jun 7, 2026

    @slegarraga

    I prepared a small patch branch for this:

    What it changes:

    • skips refresh-token reuse when auth() is called with an explicit scope, so the step-up insufficient_scope path can redirect for user consent instead of retrying the original insufficient scope
    • adds a regression test proving the explicit step-up scope path redirects, does not call the token endpoint, and preserves the requested scope/resource in the authorization URL
    • adds a patch changeset for @modelcontextprotocol/client

    Validation:

    • corepack pnpm --filter @modelcontextprotocol/client test -- auth.test.ts
    • corepack pnpm --filter @modelcontextprotocol/client typecheck
    • corepack pnpm --filter @modelcontextprotocol/client lint
    • corepack pnpm run build:all
    • corepack pnpm run typecheck:all
    • corepack pnpm run lint:all

    I attempted to open a PR from the fork, but GitHub rejected both gh pr create and the REST create-pull-request endpoint for this account (CreatePullRequest permission / HTTP 404). The branch is available above if a maintainer wants to inspect or open/cherry-pick it.

  3. he-yufeng commented on Jun 8, 2026

    @he-yufeng

    I pushed a concrete patch from my fork for this issue:

    What it changes:

    • keeps refresh-token reuse for ordinary auth() calls
    • skips refresh-token reuse when auth() is called with an explicit scope from a step-up insufficient_scope challenge
    • redirects to authorization with the requested scope/resource so the user can consent instead of retrying the old insufficient token
    • adds a regression test and a patch changeset for @modelcontextprotocol/client

    Validation:

    • corepack pnpm --filter @modelcontextprotocol/client test -- auth.test.ts -> 11 files / 368 tests passed
    • corepack pnpm --filter @modelcontextprotocol/client typecheck -> passed
    • corepack pnpm --filter @modelcontextprotocol/client lint -> passed
    • corepack pnpm --filter @modelcontextprotocol/client build -> passed
    • pre-push hook also ran pnpm -r typecheck, pnpm -r build, and pnpm sync:snippets --check && pnpm -r lint successfully

    I tried to open the PR from this fork branch, but GitHub rejected the create-pull-request operation for this account with CreatePullRequest permission. The branch is available above if a maintainer wants to open/cherry-pick it.

  4. added
    triageQueued for automated analysis — bot will process and remove this label
    on Jul 9, 2026
  5. mcp-claude commented on Jul 9, 2026

    @mcp-claude

    already fixed on main — verified at 3834921. PR #2286 (which incorporated PR #2356, SEP-2350 scope step-up) added a superset-gated refresh bypass: authInternal() now takes a forceReauthorization flag, and the streamable HTTP transport sets it when the union of transport-tracked, token-granted, and challenged scopes strictly exceeds the current token's granted scope. refresh is skipped and the user is redirected with the widened scope.

    still reproduces on v1.x — a backport would be needed if the 1.x line is still receiving fixes.

    reopen if this is still hitting on 2.0+.

    repro.ts (main) — offline_access token + 403 insufficient_scope challenge
    import { StreamableHTTPClientTransport, type OAuthClientProvider } from '@modelcontextprotocol/client';
    import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/core-internal';
    
    class MemoryOAuthProvider implements OAuthClientProvider {
        public tokensStore: OAuthTokens | undefined = {
            access_token: 'insufficient-access-token',
            token_type: 'Bearer',
            refresh_token: 'existing-refresh-token',
            scope: 'offline_access'
        };
        public clientInfoStore: OAuthClientInformationFull | undefined = {
            client_id: 'test-client-id',
            client_secret: 'test-client-secret',
            client_id_issued_at: 1700000000,
            client_secret_expires_at: 0,
            redirect_uris: ['http://localhost:1234/callback']
        };
        public codeVerifierStore: string | undefined;
        public redirectCalledWith: URL | undefined;
        get redirectUrl() { return 'http://localhost:1234/callback'; }
        get clientMetadata() { return { redirect_uris: [this.redirectUrl] }; }
        async clientInformation() { return this.clientInfoStore; }
        async saveClientInformation(info: OAuthClientInformationFull) { this.clientInfoStore = info; }
        async tokens() { return this.tokensStore; }
        async saveTokens(tokens: OAuthTokens) { this.tokensStore = tokens; }
        async redirectToAuthorization(url: URL) { this.redirectCalledWith = url; }
        async saveCodeVerifier(v: string) { this.codeVerifierStore = v; }
        async codeVerifier() { if (!this.codeVerifierStore) throw new Error('no code verifier'); return this.codeVerifierStore; }
    }
    
    const authServer = 'https://auth.example.com';
    const resourceServer = 'https://mcp.example.com';
    let callCount = 0;
    const calls: { url: string; init?: RequestInit }[] = [];
    
    globalThis.fetch = (async (input: any, init?: RequestInit) => {
        const url = typeof input === 'string' ? input : input.toString();
        calls.push({ url, init }); callCount++;
        if (url === `${resourceServer}/mcp` && (init?.method ?? 'POST') === 'POST') {
            return new Response('Insufficient scope', {
                status: 403, statusText: 'Forbidden',
                headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="resource:tools:custom_scope", resource_metadata="${resourceServer}/.well-known/oauth-protected-resource"` }
            }) as any;
        }
        if (url.includes('oauth-protected-resource')) {
            return new Response(JSON.stringify({ resource: resourceServer, authorization_servers: [authServer] }),
                { status: 200, headers: { 'Content-Type': 'application/json' } }) as any;
        }
        if (url.includes('/.well-known/oauth-authorization-server') || url.includes('/.well-known/openid-configuration')) {
            return new Response(JSON.stringify({
                issuer: authServer,
                authorization_endpoint: `${authServer}/authorize`,
                token_endpoint: `${authServer}/token`,
                response_types_supported: ['code'],
                grant_types_supported: ['authorization_code', 'refresh_token'],
                code_challenge_methods_supported: ['S256'],
                scopes_supported: ['offline_access', 'resource:tools:custom_scope']
            }), { status: 200, headers: { 'Content-Type': 'application/json' } }) as any;
        }
        if (url === `${authServer}/token`) {
            console.log(`  [!] Token endpoint HIT: ${String(init?.body ?? '').slice(0, 200)}`);
            return new Response(JSON.stringify({
                access_token: 'refreshed-but-still-insufficient', token_type: 'Bearer',
                refresh_token: 'existing-refresh-token', scope: 'offline_access'
            }), { status: 200, headers: { 'Content-Type': 'application/json' } }) as any;
        }
        return new Response('unexpected', { status: 500 }) as any;
    }) as typeof fetch;
    
    const provider = new MemoryOAuthProvider();
    const transport = new StreamableHTTPClientTransport(new URL(`${resourceServer}/mcp`), {
        authProvider: provider, skipIssuerMetadataValidation: true
    });
    let caught: unknown;
    try {
        await transport.send({ jsonrpc: '2.0', method: 'tools/call', params: { name: 'custom_tool', arguments: {} }, id: 'test-id-1' } as any);
    } catch (e) { caught = e; }
    
    console.log(`token endpoint hit: ${calls.some(c => c.url === `${authServer}/token`)}`);
    console.log(`redirectToAuthorization called: ${provider.redirectCalledWith !== undefined}`);
    if (provider.redirectCalledWith) {
        console.log(`  scope in URL: "${provider.redirectCalledWith.searchParams.get('scope')}"`);
    }
    console.log(`error thrown: ${caught ? (caught as Error).constructor.name + ': ' + (caught as Error).message : '<none>'}`);
    run: npx tsx repro.ts

    on main (3834921):

    token endpoint hit: false
    redirectToAuthorization called: true
      scope in URL: "offline_access resource:tools:custom_scope"
    error thrown: UnauthorizedError: Unauthorized
    

    on v1.x (69749aa) — the same scenario against src/client/streamableHttp.ts:

      [!] Token endpoint HIT: grant_type=refresh_token&refresh_token=existing-refresh-token&resource=https%3A%2F%2Fmcp.example.com%2F
    token endpoint hit: true
    redirectToAuthorization called: false
    error thrown: StreamableHTTPError: Streamable HTTP error: Server returned 403 after trying upscoping
    
  6. added
    authIssues and PRs related to Authentication / OAuth
    potentially closeBot recommends closing — needs maintainer review
    and removed
    triageQueued for automated analysis — bot will process and remove this label
    on Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    authIssues and PRs related to Authentication / OAuthbugSomething isn't workingpotentially closeBot recommends closing — needs maintainer review

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions