diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts index f51e14d39175..73f6b834dd3b 100644 --- a/packages/core/src/mcp/oauth.ts +++ b/packages/core/src/mcp/oauth.ts @@ -1,7 +1,8 @@ export * as MCPOAuth from "./oauth.js" -import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" +import { auth, extractWWWAuthenticateParams, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js" +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" import { Deferred, Effect } from "effect" import { Credential } from "@opencode-ai/schema/credential" import { ConfigMCP } from "@opencode-ai/schema/config/mcp" @@ -213,8 +214,40 @@ export const authorize = (input: { return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client }) }) + const challenge = yield* Effect.tryPromise({ + try: async () => { + const response = await fetch(input.config.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...input.config.headers, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "opencode", version: "unknown" }, + }, + }), + }) + const result = extractWWWAuthenticateParams(response) + await response.body?.cancel() + return result + }, + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + const result = yield* Effect.tryPromise({ - try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }), + try: () => + auth(oauthProvider, { + serverUrl: input.config.url, + resourceMetadataUrl: challenge.resourceMetadataUrl, + scope: oauth?.scope ?? challenge.scope, + }), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) @@ -238,7 +271,12 @@ export const authorize = (input: { Effect.flatMap((value) => Effect.tryPromise({ try: () => - auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }), + auth(oauthProvider, { + serverUrl: input.config.url, + authorizationCode: value, + resourceMetadataUrl: challenge.resourceMetadataUrl, + scope: oauth?.scope ?? challenge.scope, + }), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }), ), diff --git a/packages/core/test/mcp-oauth.test.ts b/packages/core/test/mcp-oauth.test.ts index a91564907125..8bfdf86a8f43 100644 --- a/packages/core/test/mcp-oauth.test.ts +++ b/packages/core/test/mcp-oauth.test.ts @@ -73,4 +73,60 @@ describe("MCP OAuth", () => { test("rejects an invalid redirect URL", async () => { await expect(authorize("not a URL")).rejects.toThrow("cannot be parsed as a URL") }) + + test("uses protected resource metadata announced by the MCP challenge", async () => { + let metadataRequest = "" + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/runtimes/example/invocations") + return new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/runtimes/example/invocations/.well-known/oauth-protected-resource?qualifier=dev"`, + }, + }) + if (url.pathname === "/runtimes/example/invocations/.well-known/oauth-protected-resource") { + metadataRequest = url.href + return Response.json({ + resource: `${url.origin}/runtimes/example/invocations`, + authorization_servers: [url.origin], + }) + } + if (url.pathname === "/.well-known/oauth-authorization-server") + return Response.json({ + issuer: url.origin, + authorization_endpoint: `${url.origin}/authorize`, + token_endpoint: `${url.origin}/token`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }) + return new Response(null, { status: 404 }) + }, + }) + + try { + const authorization = await Effect.runPromise( + Effect.scoped( + MCPOAuth.authorize({ + name: "aws", + config: new ConfigMCP.Remote({ + type: "remote", + url: `${server.url}runtimes/example/invocations?qualifier=dev`, + oauth: { client_id: "client" }, + }), + methodID: Integration.MethodID.make("oauth"), + }), + ), + ) + + expect(authorization.url).toStartWith(`${server.url}authorize?`) + expect(metadataRequest).toBe( + `${server.url}runtimes/example/invocations/.well-known/oauth-protected-resource?qualifier=dev`, + ) + } finally { + server.stop(true) + } + }) })