diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index cf170b95fc07..b485bad06ee5 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -29,6 +29,14 @@ export const OAuth = Schema.Struct({ redirectUri: Schema.optional(Schema.String).annotate({ description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", }), + authorizationEndpoint: Schema.optional(Schema.String).annotate({ + description: + "OAuth authorization endpoint URL. Required for servers that do not support RFC 8414 metadata discovery (e.g. Google Workspace MCP servers).", + }), + tokenEndpoint: Schema.optional(Schema.String).annotate({ + description: + "OAuth token endpoint URL. Required for servers that do not support RFC 8414 metadata discovery (e.g. Google Workspace MCP servers).", + }), }).annotate({ identifier: "McpOAuthConfig" }) export type OAuth = Schema.Schema.Type diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 992825dd63fd..8c26c58b9a14 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -3,7 +3,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import { UnauthorizedError, auth as sdkAuth } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, ToolSchema, @@ -319,6 +319,8 @@ export const layer = Layer.effect( clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, redirectUri: oauthConfig?.redirectUri, + authorizationEndpoint: oauthConfig?.authorizationEndpoint, + tokenEndpoint: oauthConfig?.tokenEndpoint, }, { onRedirect: async (url) => { @@ -785,6 +787,8 @@ export const layer = Layer.effect( clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, redirectUri: oauthConfig?.redirectUri, + authorizationEndpoint: oauthConfig?.authorizationEndpoint, + tokenEndpoint: oauthConfig?.tokenEndpoint, }, { onRedirect: async (url) => { @@ -812,6 +816,42 @@ export const layer = Layer.effect( } return Effect.die(error) }), + Effect.flatMap((result) => { + if (result.authorizationUrl || !oauthConfig?.authorizationEndpoint || !oauthConfig?.tokenEndpoint) { + return Effect.succeed(result) + } + + const existingTokens = Effect.promise(() => authProvider.tokens()) + return existingTokens.pipe( + Effect.flatMap((tokens) => { + if (tokens) return Effect.succeed(result) + + log.info("server connected without auth challenge but oauth config has explicit endpoints, triggering OAuth", { mcpName }) + + if ("client" in result && result.client) { + Effect.runPromise(Effect.tryPromise(() => result.client!.close()).pipe(Effect.ignore)) + } + + return Effect.tryPromise({ + try: () => + sdkAuth(authProvider, { + serverUrl: url.toString(), + scope: oauthConfig.scope, + }), + catch: (error) => error, + }).pipe( + Effect.map(() => { + if (capturedUrl) { + pendingOAuthTransports.set(mcpName, transport) + return { authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult + } + return { authorizationUrl: "", oauthState } satisfies AuthResult + }), + Effect.catch(() => Effect.succeed({ authorizationUrl: "", oauthState } satisfies AuthResult)), + ) + }), + ) + }), ) }) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 45dcff50f0f1..7e8badbdb69e 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -1,4 +1,4 @@ -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" +import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js" import type { OAuthClientMetadata, OAuthTokens, @@ -19,6 +19,8 @@ export interface McpOAuthConfig { clientSecret?: string scope?: string redirectUri?: string + authorizationEndpoint?: string + tokenEndpoint?: string } export interface McpOAuthCallbacks { @@ -26,6 +28,8 @@ export interface McpOAuthCallbacks { } export class McpOAuthProvider implements OAuthClientProvider { + private cachedDiscoveryState?: OAuthDiscoveryState + constructor( private mcpName: string, private serverUrl: string, @@ -190,6 +194,31 @@ export class McpOAuthProvider implements OAuthClientProvider { break } } + + async discoveryState(): Promise { + if (this.cachedDiscoveryState) return this.cachedDiscoveryState + + // When the server does not support RFC 8414 metadata discovery (e.g. + // Google Workspace MCP servers), the SDK's automatic discovery will fail. + // If the config provides explicit authorization/token endpoints, build a + // synthetic discovery state so the SDK skips its failing discovery and + // uses the configured endpoints directly. + if (!this.config.authorizationEndpoint || !this.config.tokenEndpoint) return undefined + + return { + authorizationServerUrl: new URL(this.config.authorizationEndpoint).origin, + authorizationServerMetadata: { + issuer: new URL(this.config.authorizationEndpoint).origin, + authorization_endpoint: this.config.authorizationEndpoint, + token_endpoint: this.config.tokenEndpoint, + response_types_supported: ["code"], + }, + } + } + + async saveDiscoveryState(state: OAuthDiscoveryState): Promise { + this.cachedDiscoveryState = state + } } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 3cf67742156d..c4c193185383 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -101,6 +101,7 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ // Mock UnauthorizedError in the auth module so instanceof checks work void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ UnauthorizedError: MockUnauthorizedError, + auth: async () => "AUTHORIZED" as const, })) beforeEach(() => { @@ -132,30 +133,73 @@ test("first connect to OAuth server shows needs_auth instead of failed", async ( ) }, }) +}) + +test("discoveryState() returns metadata from config when explicit endpoints are provided", async () => { + const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") + const { McpAuth } = await import("../../src/mcp/auth") + + await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, fn: async () => { - const result = await Effect.runPromise( - MCP.Service.use((mcp) => - mcp.add("test-oauth", { - type: "remote", - url: "https://example.com/mcp", - }), - ).pipe(Effect.provide(MCP.defaultLayer)), + const auth = await Effect.runPromise( + Effect.gen(function* () { + return yield* McpAuth.Service + }).pipe(Effect.provide(McpAuth.defaultLayer)), + ) + const provider = new McpOAuthProvider( + "test-discovery", + "https://example.com/mcp", + { + clientId: "test-client", + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint: "https://oauth2.googleapis.com/token", + }, + { onRedirect: async () => {} }, + auth, ) - const serverStatus = result.status as Record + const state = await provider.discoveryState() + expect(state).toBeDefined() + expect(state!.authorizationServerUrl).toBe("https://accounts.google.com") + expect(state!.authorizationServerMetadata?.authorization_endpoint).toBe( + "https://accounts.google.com/o/oauth2/v2/auth", + ) + expect(state!.authorizationServerMetadata?.token_endpoint).toBe("https://oauth2.googleapis.com/token") + }, + }) +}) + +test("discoveryState() returns undefined when no explicit endpoints configured", async () => { + const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") + const { McpAuth } = await import("../../src/mcp/auth") + + await using tmp = await tmpdir() - // The server should be detected as needing auth, NOT as failed. - // Before the fix, provider.state() would throw a plain Error - // ("No OAuth state saved for MCP server: test-oauth") which was - // not caught as UnauthorizedError, causing status to be "failed". - expect(serverStatus["test-oauth"]).toBeDefined() - expect(serverStatus["test-oauth"].status).toBe("needs_auth") + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const auth = await Effect.runPromise( + Effect.gen(function* () { + return yield* McpAuth.Service + }).pipe(Effect.provide(McpAuth.defaultLayer)), + ) + const provider = new McpOAuthProvider( + "test-no-discovery", + "https://example.com/mcp", + { clientId: "test-client" }, + { onRedirect: async () => {} }, + auth, + ) + + const state = await provider.discoveryState() + expect(state).toBeUndefined() }, }) }) +}) test("state() generates a new state when none is saved", async () => { const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")