Skip to content

MCPClientManager: mid-session OAuth re-authorization (insufficient_scope step-up) is never surfaced, and its callback is dropped as stale #2658

Closed

Description

@jonnyparris

What happens

An MCP server connected through MCPClientManager (via agent.addMcpServer()) is ready. A tool call then needs a scope the grant lacks, and the server answers 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="...". A 401 whose token refresh fails behaves the same way.

The SDK transport handles the challenge correctly. It runs auth(), builds a new authorization URL (granted scopes plus the challenged ones), passes it to the provider (authProvider.authUrl), and throws UnauthorizedError.

MCPClientManager only turns that into authenticating plus authUrl at connect time (connectToServer → persistAuthContinuation). Mid-session, callTool() lets the error through:

  1. The URL is never shown. The connection stays ready, auth_url is not saved, and the caller gets a plain UnauthorizedError.
  2. The callback is dropped. If the user opens the URL anyway (it is on the provider), handleCallbackRequest() finds the connection in an auth-accepted state (ready). It treats the callback as a stale popup, consumes the state and returns success without exchanging the code. The new scopes are never saved, so the retry fails the same way.

Real case: the Cloudflare API MCP server (mcp.cloudflare.com) granted with a Minimal or Read-only template. The first write tool call hits this.

Repro

A fake OAuth MCP server (protected-resource metadata, AS metadata, DCR, token endpoint) passed as transport.fetch to a real MCPClientManager in a Durable Object:

  1. registerServer + connectToServer → authenticating. Callback with a base-scope code → ready.
  2. callTool on a tool that needs workers_scripts:write → the server returns 403 insufficient_scope.
  3. Expected: something the app can show (the new authUrl), and a callback that completes the flow. Actual: UnauthorizedError: Unauthorized, connection still ready, auth_url null. A callback for the provider's new URL is ignored as stale.

Full test: https://github.com/cloudflare/agents/pull/TBD (src/tests/mcp/mid-session-reauth.test.ts).

Workarounds in the wild

  • The repo's own conformance client patches this in test code: requestReauth() in packages/agents/conformance/worker.ts sets conn.connectionState = AUTHENTICATING by hand after an unauthorized tool call.
  • Dodo (an agent app on agents) wraps callTool the same way, plus a call to the private persistAuthContinuation().

Proposed fix

In callTool() (and readResource() / getPrompt()): on an unauthorized error where the provider now holds a new authUrl, move the connection to authenticating, call persistAuthContinuation(), fire onServerStateChanged, and throw a typed MCPAuthorizationRequiredError { serverId, authUrl, clientId } (with the original error as cause, so isUnauthorized() checks still match). The existing callback path then works unchanged. PR to follow.

Activity

  1. jonnyparris commented on Oct 22, 2026

    @jonnyparris
    MemberAuthor

    beep-boop-🤖 A fix with tests is ready on a fork branch. I can't open the PR: this repo only accepts PRs from collaborators.

    Branch: https://github.com/jonnyparris/agents/tree/fix/mcp-midsession-reauth
    Compare: main...jonnyparris:agents:fix/mcp-midsession-reauth
    Commit: jonnyparris/agents@718e54d

    Summary:

    • callTool(), readResource() and getPrompt() take over only when all of these are true: the request failed as unauthorized, the provider now holds a new authUrl whose state is still redeemable, and the connection is ready, connected or authenticating. A connection that is processing a callback is left alone.
    • When they take over, they call persistAuthContinuation(), move the connection to authenticating, and throw MCPAuthorizationRequiredError { serverId, authUrl, clientId }. That error extends the SDK's UnauthorizedError and keeps its name, so existing checks still match.
    • handleCallbackRequest() needs no change: once the connection is authenticating, the existing path exchanges the code.
    • src/tests/mcp/mid-session-reauth.test.ts uses a fake OAuth MCP server through transport.fetch. It covers a 403 step-up, a 401 with a failed refresh, two calls at once, and four cases that must rethrow the original error. The workers MCP suite passes 634/634.
    • Includes a docs section and a patch changeset.

    A collaborator is welcome to open it as a PR, or to cherry-pick it.

  2. added a commit that references this issue on Nov 5, 2026
    a27ddee
  3. added a commit that references this issue on Nov 6, 2026
    d6b4441
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions