Repository navigation
Description
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
- Configure a remote MCP server backed by AWS Bedrock AgentCore Gateway, using a Cognito User Pool and a pre-registered public PKCE client.
- Authenticate through
/mcps:- The authorization code exchange succeeds.
- The access token and refresh token are persisted.
- The token response omits
scope.
- Wait for the access token to expire—one hour with the current Cognito configuration.
- 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
- OpenCode sends the expired access token.
- The gateway returns
403 insufficient_scope. - OpenCode opens the browser for a full authorization flow.
- 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):
- Reads
tokens.scope, which isundefined. - Computes
unionScopefrom the stored scope and the challenge scope. - Evaluates
isStrictScopeSuperset(unionScope, tokens?.scope)astrue: the missing stored scope is treated as an empty set. - 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
scopepreserves the originally requested scope. - An explicitly returned
scopetakes 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; onlyscopeis 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
opencode-agent commentedon Oct 23, 2026
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:
access_token,expires_inandrefresh_token, but noscope;403witherror="insufficient_scope".Steps:
opencode mcp auth gw. This works.opencode mcp list.Result: OpenCode sends the expired token once, gets the 403 and never tries a
refresh_tokengrant. The server ends up asneeds authentication; in the interactive flow, this is where the browser opens again. When the only change is that the token response includesscope, the same 403 is followed straight away by a refresh and a successful reconnect. That supports your point that the missingscopeis 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)
Run it:
bash repro.shyiaany commentedon Oct 23, 2026
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?
waytoolongnamae commentedon Oct 24, 2026
@yiaany Hi! Yes your description of the scope sounds right.
yiaany commentedon Oct 24, 2026
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.
yiaany commentedon Nov 3, 2026
quick bump on it