Skip to content

Remote MCP OAuth: resource_metadata URL from WWW-Authenticate is ignored (origin-only RFC 9728 lookup) — breaks AWS Bedrock AgentCore runtimes #44790

Closed

Description

@mustafa89

Summary

For remote MCP servers, opencode ignores the resource_metadata URL announced in the WWW-Authenticate header of the 401 challenge and only looks for RFC 9728 protected-resource metadata at the domain root (https://<host>/.well-known/oauth-protected-resource). When the server publishes its metadata at a path-shaped URL (announced in the header), the root lookup 404s and opencode falls back to treating the resource host itself as the authorization server, building the authorize URL on the wrong host. The browser then opens a dead/error page and the whole flow fails or degrades.

This breaks a whole class of real-world servers, e.g. AWS Bedrock AgentCore runtime endpoints, whose MCP URL has the resource ARN in the path:

https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/<url-encoded-arn>/invocations?qualifier=<name>

Likely also relevant to opencode2 / V2 (unverified there, but the discovery logic decides everything downstream, and existing V2 OAuth issues like #40767 suggest the same code path).

Environment

  • opencode 1.18.21, macOS (darwin/arm64)
  • Server: AWS Bedrock AgentCore runtime (streamable HTTP MCP, JWT authorizer, IdP = JumpCloud/Ory Hydra, static PKCE client — the IdP has no dynamic client registration)

Reproduction / evidence

Unauthenticated POST to the MCP endpoint — the server correctly announces where its metadata lives:

$ curl -s -D - -X POST 'https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A<ACCOUNT_ID>%3Aruntime%2F<RUNTIME_ID>/invocations?qualifier=<NAME>' \
    -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3A...%2F<RUNTIME_ID>/invocations/.well-known/oauth-protected-resource?qualifier=<NAME>"

That metadata URL resolves fine and names the real authorization server:

$ curl -s 'https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/.../invocations/.well-known/oauth-protected-resource?qualifier=<NAME>'
{"authorization_servers":["https://oauth.id.jumpcloud.com"],"resource":"https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/..."}

The domain-root lookup (the only one opencode appears to try) 404s:

$ curl -s 'https://bedrock-agentcore.us-east-1.amazonaws.com/.well-known/oauth-protected-resource'
Invalid api path            (HTTP 404)

What opencode then does (opencode mcp auth <name>) — authorize URL built on the resource host, not the IdP:

https://bedrock-agentcore.us-east-1.amazonaws.com/authorize?response_type=code&client_id=<CLIENT_ID>&code_challenge=...&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A53712%2Fcallback&state=...

That path does not exist on the resource host ("Invalid api path"), so the user lands on an error page.

Expected behavior

Per RFC 9728 §5.1 (and the MCP authorization spec, which builds on it): when the 401 WWW-Authenticate header carries resource_metadata="<url>", the client should fetch that exact URL and use its authorization_servers entry for AS metadata discovery — not derive a well-known URL from the origin, and never assume the resource host is the AS.

Asks

  1. Bug fix: honor the resource_metadata URL from the WWW-Authenticate challenge (including path + query string forms) before/instead of the origin-root lookup.
  2. Feature (escape hatch): an explicit override in the oauth config block — e.g. authServerMetadataUrl — pointing directly at the AS metadata document, for servers whose discovery is broken or unusual. (Claude Code ships exactly this field, and it is the practical workaround there.) The current schema (clientId / clientSecret / scope / callbackPort / redirectUri) offers no way to steer discovery at all.

Notes

  • The same server works in clients that follow the header (verified with mcp-remote and with Claude Code's native remote MCP support).
  • No DCR on the IdP, so the automatic dynamic-registration fallback can't save the flow either — everything hinges on discovery landing on the right AS.

Activity

  1. mhuggins7278 commented on Aug 26, 2026

    @mhuggins7278
    Contributor

    Additional evidence from OpenCode v2 beta (0.0.0-beta-18230) using private company MCP servers:

    • The MCP endpoint returns a 401 with a resource_metadata URL.
    • That protected-resource metadata resolves and advertises an authorization server whose issuer and endpoints are under a non-root path.
    • OpenCode v2 instead generates root-relative /authorize and /token URLs, both of which return 404.
    • Manually replacing the authorization URL with the advertised path reaches the identity-provider login successfully.
    • An existing bearer token successfully completes MCP initialize and returns tools, confirming the server and credentials are valid.
    • OpenCode v1 works with the same MCP servers.
  2. HugeLetters commented on Aug 29, 2026

    @HugeLetters

    I don't know much about MCP protocol but hit an issue with V2 requiring explicit scope in oauth config, while it wasn't needed in V1. My agent composed this description of the issue and it seems related to this too.

    My opencode2 version is v0.0.0-beta-18684

    LLM Text below

    Adding another symptom of the same root cause, this time on V2 — the scope field from WWW-Authenticate is also never read, not just resource_metadata.

    Setup

    We have an MCP gateway that fronts multiple services under path-prefixed URLs (e.g. https://mcp-gateway.example.com/service-a/mcp, https://mcp-gateway.example.com/service-b/mcp). Each service has its own OAuth scopes. A 401 from any of them returns:

    WWW-Authenticate: Bearer scope="service-a:read service-a:write", resource_metadata="https://mcp-gateway.example.com/service-a/.well-known/oauth-protected-resource"
    

    The gateway also serves a root PRM (https://mcp-gateway.example.com/.well-known/oauth-protected-resource) that returns 200 — but without scopes_supported:

    // Root PRM (what SDK discovery finds)
    {"resource":"https://mcp-gateway.example.com","resource_name":"MCP Gateway",
     "authorization_servers":["https://mcp-gateway.example.com"],"bearer_methods_supported":["header"]}
    
    // Per-service PRM (only reachable via the resource_metadata URL in the 401 header)
    {"resource":"https://mcp-gateway.example.com","resource_name":"service-a",
     "authorization_servers":["https://mcp-gateway.example.com"],
     "scopes_supported":["service-a:read","service-a:write"],"bearer_methods_supported":["header"]}

    The SDK's path-aware discovery (/.well-known/oauth-protected-resource/service-a/mcp) also hits the root PRM (200, no scopes), so it accepts it and never tries the per-service URL. The scopes are only discoverable through the WWW-Authenticate header — exactly the mechanism this issue is about.

    What breaks on V2

    V2's authorize (packages/core/src/mcp/oauth.ts) calls auth() directly without going through the transport:

    auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope })

    No initialize → no 401 → no WWW-Authenticate parsing. The SDK's scope resolution chain falls through entirely:

    const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
    //                  = undefined || undefined?.join(' ') || undefined

    The authorization URL to the IdP ends up with no scope parameter. The token is either rejected or issued without the needed permissions, and every tool call fails with 401/403.

    Why V1 works

    V1's startAuth creates a real StreamableHTTPClientTransport and calls client.connect(transport). The transport sends initialize, gets the 401, and the SDK's streamableHttp.ts extracts both fields:

    const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
    this._scope = scope;  // "service-a:read service-a:write"
    await auth(this._authProvider, { serverUrl, resourceMetadataUrl, scope });

    So on V1 the scopes are discovered automatically — no config scope needed.

    Workaround

    Adding scope to the oauth config works because provider.clientMetadata.scope is the last-resort fallback in the SDK. But it shouldn't be necessary — the server already advertises the scopes.

    Shared fix

    The fix for this issue (honoring resource_metadata from WWW-Authenticate) would also fix the scope problem — both fields are extracted by the same extractWWWAuthenticateParams call. Probing the server before calling auth() and passing both scope and resourceMetadataUrl would resolve both this issue and the scope-discovery problem in one change.

  3. mustafa89 commented on Sep 6, 2026

    @mustafa89
    Author

    @opencode-agent , can you please have a look.

  4. rekram1-node commented on Sep 15, 2026

    @rekram1-node
    Collaborator

    Both asks are now in v2:

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions