Skip to content

MCP OAuth: missing scope in token response causes repeated browser re-authentication #58319

Open

Description

@waytoolongnamae

Description

MCP OAuth: missing scope in token response causes repeated browser re-authentication

Summary

When an OAuth provider omits the optional scope field from its token response, OpenCode skips the stored refresh token after the access token expires and opens the browser for a full authorization flow.

This occurs with AWS Cognito + Bedrock AgentCore Gateway, causing re-authentication approximately every hour despite a valid refresh token with a 30-day lifetime.

Environment

Component Value
OpenCode 2.1.3
OS Linux 7.0.0-38-generic (x64)
Shell /usr/bin/zsh
Terminal TERM=xterm-256color, COLORTERM=truecolor
Install/channel latest
Active plugins @tarquinen/opencode-dcp@latest
MCP server AWS Bedrock AgentCore Gateway
OAuth provider AWS Cognito User Pool
OAuth client Pre-registered public client with PKCE

Steps to reproduce

  1. Configure a remote MCP server backed by AWS Bedrock AgentCore Gateway, using a Cognito User Pool and a pre-registered public PKCE client.
  2. Authenticate through /mcps:
    • The authorization code exchange succeeds.
    • The access token and refresh token are persisted.
    • The token response omits scope.
  3. Wait for the access token to expire—one hour with the current Cognito configuration.
  4. Trigger a connection to the MCP server.

Expected behavior

OpenCode exchanges the stored refresh token for a new access token and reconnects without browser interaction.

Actual behavior

  1. OpenCode sends the expired access token.
  2. The gateway returns 403 insufficient_scope.
  3. OpenCode opens the browser for a full authorization flow.
  4. No refresh token exchange is observed in the logs.

This repeats approximately every hour, even though the refresh token remains valid.

Suspected root cause

The behavior appears to result from missing scope metadata interacting with the newer MCP SDK’s step-up authorization logic.

1. Requested scope is not retained when the token response omits it

In packages/core/src/mcp/oauth.ts, toCredential() (around line 248) stores scope from input.scope, which comes from the token response.

Cognito omits this field, so the persisted credential has no scope metadata, even though scopes were requested during authorization.

2. Missing scope triggers forced re-authorization in the MCP SDK

In @modelcontextprotocol/client v2.0.0, streamableHttp.ts, _stepUpAuthorizeInner() (around line 416):

  1. Reads tokens.scope, which is undefined.
  2. Computes unionScope from the stored scope and the challenge scope.
  3. Evaluates isStrictScopeSuperset(unionScope, tokens?.scope) as true: the missing stored scope is treated as an empty set.
  4. Sets forceReauthorization = true.

In auth.ts, authInternal() (around line 1305), the refresh-token branch requires !forceReauthorization. Consequently, the refresh-token exchange is skipped and browser authorization is triggered.

Regression context

This began after the SDK migration in PR #48937, shipped in OpenCode v2.0.4+:

OpenCode version MCP SDK Relevant behavior
2.0.3 and earlier @modelcontextprotocol/sdk v1.29.0 Attempted a refresh-token exchange on 403 insufficient_scope without this step-up scope check
2.0.4+ @modelcontextprotocol/client v2.0.0 Can force re-authorization when stored scope metadata is missing

Suggested fix

In toCredential(), persist the scope from the original authorization request when the token response omits scope. Continue to use the response’s scope when explicitly provided.

This follows RFC 6749 §5.1: when scope is omitted from the token response, the granted scope is identical to the requested scope.

Proactively refreshing access tokens before expiry could also help avoid this failure path, but preserving scope metadata would address the underlying ambiguity during scope checks.

Suggested regression coverage

  • A token response without scope preserves the originally requested scope.
  • An explicitly returned scope takes precedence.
  • An expired access token with a valid refresh token and sufficient scope triggers a refresh without browser interaction.
  • A challenge requesting genuinely additional scopes still triggers step-up authorization.

Additional observations

  • Other MCP servers in the same OpenCode instance successfully perform refresh-token exchanges.
  • The affected credentials correctly retain the access token, refresh token, issuer, and client_id; only scope is missing.
  • The issue appears specific to providers whose token responses omit scope.

Plugins

No response

OpenCode version

No response

Steps to reproduce

No response

Screenshot

No response

Operating System

No response

Terminal

No response

Activity

  1. opencode-agent commented on Oct 23, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed analysis. I could reproduce this on 2.1.3, and it still happens on the latest dev (33726e8).

    I used a local mock OAuth/MCP server that behaves as you describe:

    • the token response has access_token, expires_in and refresh_token, but no scope;
    • a request with an expired token gets 403 with error="insufficient_scope".

    Steps:

    1. Log in with opencode mcp auth gw. This works.
    2. Let the access token expire.
    3. Reconnect with opencode mcp list.

    Result: OpenCode sends the expired token once, gets the 403 and never tries a refresh_token grant. The server ends up as needs authentication; in the interactive flow, this is where the browser opens again. When the only change is that the token response includes scope, the same 403 is followed straight away by a refresh and a successful reconnect. That supports your point that the missing scope is the trigger.

    The script below reproduces both cases (it includes the mock server it starts). Closely related: #49773 (refresh MCP OAuth tokens before they expire).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #58319. Usage: ./repro.sh            (opencode from PATH)
    #                         OC_DEV=/path/to/repo ./repro.sh   (run from source)
    # WITH_SCOPE=1 makes the mock token endpoint return `scope` (control case).
    set -u
    D=$(cd "$(dirname "$0")" && pwd)
    PORT=${PORT:-8765}
    if [ -n "${OC_DEV:-}" ]; then
      # BUN_OPTIONS so the background service the CLI spawns also gets the flags
      export BUN_OPTIONS="--conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js"
      oc() { bun "$OC_DEV/packages/cli/src/index.ts" "$@"; }
    else oc() { opencode "$@"; }; fi
    export HOME=$(mktemp -d /tmp/opencode/home.XXXX)
    P=$(mktemp -d /tmp/opencode/proj.XXXX); cd "$P"
    cat > opencode.json <<J
    { "mcp": { "servers": { "gw": { "type": "remote", "url": "http://127.0.0.1:$PORT/mcp", "oauth": { "client_id": "pubclient", "scope": "mcp:tools" } } } } }
    J
    LOG=$P/server.log
    PORT=$PORT bun "$D/server.ts" > "$LOG" 2>&1 & SRV=$!
    sleep 1
    # 1. Log in; play the browser by following the printed authorize URL.
    (sleep 25 | oc mcp auth gw > auth.out 2>&1) &
    for i in $(seq 1 40); do url=$(grep -o "http://127.0.0.1:$PORT/authorize[^ ]*" auth.out 2>/dev/null | head -1); [ -n "$url" ] && break; sleep 0.5; done
    curl -sL "$url" > /dev/null; sleep 3
    echo "--- after login:"; cat "$LOG"
    # 2. Restart the background service so the connection is re-established, wait for token expiry.
    sv=$(grep -o '"pid":[0-9]*' $HOME/.local/state/opencode/service*.json | cut -d: -f2); kill $sv 2>/dev/null
    sleep 7
    # 3. Reconnect with the expired access token (refresh token still valid).
    : > "$LOG.mark"; L0=$(wc -l < "$LOG")
    oc mcp list 2>&1 | tail -3; sleep 1; oc mcp list 2>&1 | tail -3; sleep 2
    echo "--- after token expiry (server log):"; tail -n +$((L0+1)) "$LOG"
    grep -q "grant_type= refresh_token" <(tail -n +$((L0+1)) "$LOG") && echo "RESULT: refresh_token grant used" || echo "RESULT: BUG - no refresh_token grant; refresh token ignored"
    sv=$(grep -o '"pid":[0-9]*' $HOME/.local/state/opencode/service*.json 2>/dev/null | cut -d: -f2); kill $sv $SRV 2>/dev/null

    Run it: bash repro.sh

  2. yiaany commented on Oct 23, 2026

    @yiaany

    Hi! I’d like to work on this if no one has picked it up yet.

    I’ll preserve the requested scope when the token response omits it, while keeping an explicitly returned scope authoritative. I’ll also add regression coverage for token refresh without browser re-authentication and for genuine scope expansion still triggering step-up authorization.

    Does that scope sound right?

  3. waytoolongnamae commented on Oct 24, 2026

    @waytoolongnamae
    Author

    @yiaany Hi! Yes your description of the scope sounds right.

  4. yiaany commented on Oct 24, 2026

    @yiaany

    Opened #58699 with the fix. It preserves the scope from the actual authorization request when the token response omits it, and keeps that scope across refreshes. Explicitly returned scopes still take precedence. Regression tests cover repeated refreshes after expiry without browser re-authentication, as well as genuine scope expansion still requiring authorization. The MCP tests and local lint/type checks pass. I haven't tested against a live Cognito deployment, so confirmation with your setup would be helpful.

  5. yiaany commented on Nov 3, 2026

    @yiaany

    5. @yiaany Hi! Yes your description of the scope sounds right.

    quick bump on it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions