diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 36e0a445586..af970dc1455 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -8,6 +8,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ 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 { createFetchWithInit } from "@modelcontextprotocol/sdk/shared/transport.js" import { type LoggingMessageNotification, LoggingMessageNotificationSchema, @@ -212,7 +213,7 @@ export const layer = Layer.effect( let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { - authProvider = new McpOAuthProvider( + const provider = new McpOAuthProvider( key, mcp.url, { @@ -227,6 +228,10 @@ export const layer = Layer.effect( }, auth, ) + authProvider = provider + yield* Effect.tryPromise(() => + provider.refreshTokensIfExpired(mcp.headers ? createFetchWithInit(fetch, { headers: mcp.headers }) : undefined), + ).pipe(Effect.ignore) } const transports: Array<{ name: string; transport: TransportWithAuth }> = [ diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index aa29777f544..da7f02ea674 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -1,15 +1,18 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" +import { discoverOAuthServerInfo, refreshAuthorization, selectResourceURL } from "@modelcontextprotocol/sdk/client/auth.js" import type { OAuthClientMetadata, OAuthTokens, OAuthClientInformation, OAuthClientInformationFull, } from "@modelcontextprotocol/sdk/shared/auth.js" +import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js" import { Effect } from "effect" import { McpAuth } from "./auth" const OAUTH_CALLBACK_PORT = 19876 const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback" +const TOKEN_REFRESH_SKEW_SECONDS = 60 export interface McpOAuthConfig { clientId?: string @@ -125,6 +128,28 @@ export class McpOAuthProvider implements OAuthClientProvider { ) } + async refreshTokensIfExpired(fetchFn?: FetchLike): Promise { + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) + if (!entry?.tokens?.refreshToken) return false + if (!entry.tokens.expiresAt) return false + if (entry.tokens.expiresAt > Date.now() / 1000 + TOKEN_REFRESH_SKEW_SECONDS) return false + + const clientInformation = await this.clientInformation() + if (!clientInformation) return false + + const info = await discoverOAuthServerInfo(this.serverUrl, { fetchFn }) + await this.saveTokens( + await refreshAuthorization(info.authorizationServerUrl, { + metadata: info.authorizationServerMetadata, + clientInformation, + refreshToken: entry.tokens.refreshToken, + resource: await selectResourceURL(this.serverUrl, this, info.resourceMetadata), + fetchFn, + }), + ) + return true + } + async redirectToAuthorization(authorizationUrl: URL): Promise { await this.callbacks.onRedirect(authorizationUrl) } diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 763f6a3197e..b9d046b1459 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -3,6 +3,7 @@ import { expect, mock, beforeEach } from "bun:test" import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" +import type { McpAuth } from "../../src/mcp/auth" import { testEffect } from "../lib/effect" import { TestInstance } from "../fixture/fixture" @@ -52,6 +53,7 @@ let clientCreateCount = 0 let transportCloseCount = 0 // Captures the opts passed to each MockStdioTransport, keyed by lastCreatedClientName const stdioOptsByName = new Map() +let refreshAuthorizationCalls = 0 function getOrCreateClientState(name?: string): MockClientState { const key = name ?? "default" @@ -141,6 +143,18 @@ void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ super("Unauthorized") } }, + discoverOAuthServerInfo: async () => ({ authorizationServerUrl: "https://auth.example.com" }), + selectResourceURL: async () => undefined, + refreshAuthorization: async () => { + refreshAuthorizationCalls++ + return { + access_token: "new-access-token", + token_type: "Bearer", + refresh_token: "new-refresh-token", + expires_in: 3600, + scope: "read", + } + }, })) // Mock Client that delegates to per-name MockClientState @@ -238,11 +252,13 @@ beforeEach(() => { connectError = "Mock transport cannot connect" clientCreateCount = 0 transportCloseCount = 0 + refreshAuthorizationCalls = 0 }) // Import after mocks const { MCP } = await import("../../src/mcp/index") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") +const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") const it = testEffect(MCP.defaultLayer) @@ -251,6 +267,45 @@ function statusName(status: Record | MCPNS.Status, server: return status[server]?.status } +it.live("McpOAuthProvider refreshes expired stored tokens", () => + Effect.gen(function* () { + const entry: McpAuth.Entry = { + serverUrl: "https://mcp.example.com/mcp", + clientInfo: { clientId: "client-id" }, + tokens: { + accessToken: "old-access-token", + refreshToken: "old-refresh-token", + expiresAt: Date.now() / 1000 - 1, + }, + } + const auth: Pick = { + getForUrl: () => Effect.succeed(entry), + updateTokens: (_mcpName: string, tokens: McpAuth.Tokens, serverUrl?: string) => + Effect.sync(() => { + entry.tokens = tokens + entry.serverUrl = serverUrl ?? entry.serverUrl + }), + } + + const refreshed = yield* Effect.promise(() => + new McpOAuthProvider( + "remote-server", + "https://mcp.example.com/mcp", + {}, + { onRedirect: async () => {} }, + auth as McpAuth.Interface, + ).refreshTokensIfExpired(), + ) + + expect(refreshed).toBe(true) + expect(refreshAuthorizationCalls).toBe(1) + if (!entry.tokens) throw new Error("tokens were not saved") + expect(entry.tokens.accessToken).toBe("new-access-token") + expect(entry.tokens.refreshToken).toBe("new-refresh-token") + expect(entry.tokens.expiresAt).toBeGreaterThan(Date.now() / 1000) + }), +) + it.instance( "local mcp cwd resolves relative paths against instance directory", () =>