diff --git a/bun.lock b/bun.lock index ce00c1142dc0..363e99b69b2c 100644 --- a/bun.lock +++ b/bun.lock @@ -353,6 +353,7 @@ "@ff-labs/fff-node": "0.10.5", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@opencode-ai/pty": "0.1.13", "@opencode/ai": "workspace:*", "@opencode/codemode": "workspace:*", diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 763389848eb1..ac7a2d92e39e 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2012,6 +2012,7 @@ export type ConfigEntry = scope?: string callback_port?: number redirect_uri?: string + auth_server_metadata_url?: string } | false disabled?: boolean @@ -4667,6 +4668,7 @@ export type McpAddInput = { readonly scope?: string readonly callback_port?: number readonly redirect_uri?: string + readonly auth_server_metadata_url?: string } | false readonly disabled?: boolean diff --git a/packages/core/package.json b/packages/core/package.json index 218979b4d87c..82965f8dd303 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -111,6 +111,7 @@ "@ff-labs/fff-node": "0.10.5", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@opencode-ai/pty": "0.1.13", "@opencode/ai": "workspace:*", "@opencode/codemode": "workspace:*", diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts index a72a1a500a52..fc8616bd6e52 100644 --- a/packages/core/src/mcp/oauth.ts +++ b/packages/core/src/mcp/oauth.ts @@ -7,6 +7,7 @@ import { discoverOAuthServerInfo, extractWWWAuthenticateParams, parseErrorResponse, + resourceUrlFromServerUrl, UnauthorizedError, type FetchLike, type OAuthClientProvider, @@ -14,6 +15,7 @@ import { type StoredOAuthClientInformation, type StoredOAuthTokens, } from "@modelcontextprotocol/client" +import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core" import { Cause, Deferred, Effect } from "effect" import { ConfigMCP } from "@opencode/schema/config/mcp" import { Credential } from "../credential.js" @@ -99,6 +101,25 @@ export const loggedFetch = (fields: { readonly server: string; readonly director return request }) +// A configured authorization server document stands in for RFC 9728 discovery: the SDK reuses this +// state instead of probing the resource server, whose well-known path may not exist. +export const configuredDiscovery = async (input: { + readonly config: typeof ConfigMCP.Remote.Type + readonly fetchFn: FetchLike +}): Promise => { + const url = input.config.oauth ? input.config.oauth.auth_server_metadata_url : undefined + if (!url) return undefined + const response = await input.fetchFn(url, { headers: { accept: "application/json" } }) + if (!response.ok) throw new Error(`HTTP ${response.status} trying to load OAuth authorization server metadata`) + const body = await response.json() + const metadata = OAuthMetadataSchema.safeParse(body).data ?? OpenIdProviderDiscoveryMetadataSchema.parse(body) + return { + authorizationServerUrl: metadata.issuer, + authorizationServerMetadata: metadata, + resourceMetadata: { resource: resourceUrlFromServerUrl(input.config.url).toString() }, + } +} + export interface Store { readonly tokens: () => Promise readonly saveTokens: (tokens: StoredOAuthTokens) => Promise @@ -134,7 +155,10 @@ export const provider = (options: Options): OAuthClientProvider => { let discovery: OAuthDiscoveryState | undefined = options.discovery return { redirectUrl, - discoveryState: () => discovery, + discoveryState: async () => { + discovery ??= await configuredDiscovery({ config: options.config, fetchFn: send }) + return discovery + }, saveDiscoveryState: (state) => { discovery = state }, @@ -388,7 +412,9 @@ export const authorize = (input: { // CIMD needs the server to advertise it and accept public clients, and our published document only // lists the loopback redirect; a configured client_id always wins. const discovery = yield* Effect.tryPromise({ - try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }), + try: async () => + (await configuredDiscovery({ config: input.config, fetchFn })) ?? + discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) const cimd = diff --git a/packages/core/test/mcp-oauth.test.ts b/packages/core/test/mcp-oauth.test.ts index e5994e255055..bc169813d8ae 100644 --- a/packages/core/test/mcp-oauth.test.ts +++ b/packages/core/test/mcp-oauth.test.ts @@ -352,6 +352,38 @@ describe("MCP OAuth", () => { expect(url.pathname).toBe("/as/authorize") }) + test("uses configured authorization server metadata when the resource publishes none", async () => { + const { server: issuer } = authorizationServer({}) + const resource = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) }) + const url = `${resource.url.origin}/mcp` + const oauth = { + client_id: "client", + auth_server_metadata_url: `${issuer.url.origin}/.well-known/oauth-authorization-server`, + } + + const { url: authorization } = await Effect.runPromise(Effect.scoped(start(url, oauth))) + expect(authorization.origin).toBe(issuer.url.origin) + expect(authorization.pathname).toBe("/authorize") + expect(authorization.searchParams.get("resource")).toBe(url) + + const { server, tokenRequests } = authorizationServer({}) + const store = memoryCredentials([credential({ access: "expired", refresh: "refresh", url })]) + const oauthProvider = await connectProvider( + new ConfigMCP.Remote({ + type: "remote", + url, + oauth: { ...oauth, auth_server_metadata_url: `${server.url.origin}/.well-known/oauth-authorization-server` }, + }), + store, + ) + await auth(oauthProvider, { serverUrl: url }).finally(() => { + resource.stop(true) + issuer.stop(true) + server.stop(true) + }) + expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token") + }) + test("forwards iss from the redirect so issuer-advertising servers can complete", async () => { const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true }) const result = await Effect.runPromise( diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 4a37b3644f0f..f9c08e4bb080 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -15250,6 +15250,9 @@ }, "redirect_uri": { "type": "string" + }, + "auth_server_metadata_url": { + "type": "string" } }, "additionalProperties": false @@ -15549,6 +15552,9 @@ }, "requireAssistantAfterTool": { "type": "boolean" + }, + "supportsPromptCacheKey": { + "type": "boolean" } }, "additionalProperties": false diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index bf8c20a57c04..ffcab1c4c944 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -44,6 +44,10 @@ export class OAuthConfig extends Schema.Class("Mcp.OAuthConfig")({ scope: Schema.String.pipe(optional), callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional), redirect_uri: Schema.String.pipe(optional), + auth_server_metadata_url: Schema.String.pipe(optional).annotate({ + description: + "URL of the OAuth or OpenID Connect authorization server metadata document. Set when the MCP server does not publish protected resource metadata that names its authorization server.", + }), }) {} export class RemoteConfig extends Schema.Class("Mcp.RemoteConfig")({ diff --git a/services/www/openapi.json b/services/www/openapi.json index 4a37b3644f0f..f9c08e4bb080 100644 --- a/services/www/openapi.json +++ b/services/www/openapi.json @@ -15250,6 +15250,9 @@ }, "redirect_uri": { "type": "string" + }, + "auth_server_metadata_url": { + "type": "string" } }, "additionalProperties": false @@ -15549,6 +15552,9 @@ }, "requireAssistantAfterTool": { "type": "boolean" + }, + "supportsPromptCacheKey": { + "type": "boolean" } }, "additionalProperties": false diff --git a/services/www/public/openapi.json b/services/www/public/openapi.json index 4a37b3644f0f..f9c08e4bb080 100644 --- a/services/www/public/openapi.json +++ b/services/www/public/openapi.json @@ -15250,6 +15250,9 @@ }, "redirect_uri": { "type": "string" + }, + "auth_server_metadata_url": { + "type": "string" } }, "additionalProperties": false @@ -15549,6 +15552,9 @@ }, "requireAssistantAfterTool": { "type": "boolean" + }, + "supportsPromptCacheKey": { + "type": "boolean" } }, "additionalProperties": false diff --git a/services/www/src/docs/content/mcp-servers.mdx b/services/www/src/docs/content/mcp-servers.mdx index 6a87e1c4678a..6c0d9b13adbc 100644 --- a/services/www/src/docs/content/mcp-servers.mdx +++ b/services/www/src/docs/content/mcp-servers.mdx @@ -230,6 +230,7 @@ When a provider gives you client credentials, use V2's snake_case OAuth fields: | `scope` | Space-delimited scopes to request. | | `callback_port` | Local callback port from `1` through `65535`. An available ephemeral port is the default. | | `redirect_uri` | Pre-registered loopback URI whose path and port reach the local callback listener. | +| `auth_server_metadata_url` | URL of the authorization server's OAuth or OpenID Connect metadata document. Set it when the MCP server does not publish protected resource metadata that names its authorization server. | Remove stored OAuth credentials when you need to sign in again or switch accounts: