Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/opencode/src/config/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof OAuth>

Expand Down
42 changes: 41 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)),
)
}),
)
}),
)
})

Expand Down
31 changes: 30 additions & 1 deletion packages/opencode/src/mcp/oauth-provider.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,13 +19,17 @@ export interface McpOAuthConfig {
clientSecret?: string
scope?: string
redirectUri?: string
authorizationEndpoint?: string
tokenEndpoint?: string
}

export interface McpOAuthCallbacks {
onRedirect: (url: URL) => void | Promise<void>
}

export class McpOAuthProvider implements OAuthClientProvider {
private cachedDiscoveryState?: OAuthDiscoveryState

constructor(
private mcpName: string,
private serverUrl: string,
Expand Down Expand Up @@ -190,6 +194,31 @@ export class McpOAuthProvider implements OAuthClientProvider {
break
}
}

async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
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<void> {
this.cachedDiscoveryState = state
}
}

export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
Expand Down
72 changes: 58 additions & 14 deletions packages/opencode/test/mcp/oauth-auto-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<string, { status: string; error?: string }>
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")
Expand Down
Loading