mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 12:15:51 -04:00
fix(mcp): refresh expired oauth tokens
This commit is contained in:
@@ -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 }> = [
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
await this.callbacks.onRedirect(authorizationUrl)
|
||||
}
|
||||
|
||||
@@ -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<string, any>()
|
||||
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<string, MCPNS.Status> | 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<McpAuth.Interface, "getForUrl" | "updateTokens"> = {
|
||||
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",
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user