From 42edb37def950d1ea306d0dccda2587ada95efc3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 00:33:49 -0500 Subject: [PATCH 1/3] feat(core): configure MCP OAuth authorization server metadata Add oauth.auth_server_metadata_url for remote MCP servers that do not publish protected resource metadata naming their authorization server. When set, the document is loaded directly and seeds discovery state for both interactive authorization and connect-time token refresh, so the SDK does not fall back to treating the resource host as the issuer. Closes #44790 --- bun.lock | 1 + .../client/src/promise/generated/types.ts | 2 ++ packages/core/package.json | 1 + packages/core/src/mcp/oauth.ts | 30 +++++++++++++++-- packages/core/test/mcp-oauth.test.ts | 32 +++++++++++++++++++ packages/protocol/openapi.json | 3 ++ packages/schema/src/mcp.ts | 4 +++ services/www/src/docs/content/mcp-servers.mdx | 1 + 8 files changed, 72 insertions(+), 2 deletions(-) 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 d94b23b9ba4b..a20f276b98ec 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2014,6 +2014,7 @@ export type ConfigEntry = scope?: string callback_port?: number redirect_uri?: string + auth_server_metadata_url?: string } | false disabled?: boolean @@ -4669,6 +4670,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 40dd9de4b3f9..e77d1a4fe75e 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 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/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: From 9d1262e1a924ba6d9470d29e0dc5e8a4208ad9ed Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 01:27:40 -0500 Subject: [PATCH 2/3] chore(www): regenerate OpenAPI documents --- services/www/openapi.json | 3 +++ services/www/public/openapi.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/services/www/openapi.json b/services/www/openapi.json index 40dd9de4b3f9..e77d1a4fe75e 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 diff --git a/services/www/public/openapi.json b/services/www/public/openapi.json index 40dd9de4b3f9..e77d1a4fe75e 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 From c7aee560c7077451efa43f05f8a5cc8bc77af855 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 13:50:36 -0500 Subject: [PATCH 3/3] chore: regenerate OpenAPI documents after merging v2 --- packages/protocol/openapi.json | 3 +++ services/www/openapi.json | 3 +++ services/www/public/openapi.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 10f45872785e..f9c08e4bb080 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -15552,6 +15552,9 @@ }, "requireAssistantAfterTool": { "type": "boolean" + }, + "supportsPromptCacheKey": { + "type": "boolean" } }, "additionalProperties": false diff --git a/services/www/openapi.json b/services/www/openapi.json index 10f45872785e..f9c08e4bb080 100644 --- a/services/www/openapi.json +++ b/services/www/openapi.json @@ -15552,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 10f45872785e..f9c08e4bb080 100644 --- a/services/www/public/openapi.json +++ b/services/www/public/openapi.json @@ -15552,6 +15552,9 @@ }, "requireAssistantAfterTool": { "type": "boolean" + }, + "supportsPromptCacheKey": { + "type": "boolean" } }, "additionalProperties": false