feat(mcp): upgrade client SDK to v2 (#39247)

Co-authored-by: Matt Carey <mcarey@cloudflare.com>
This commit is contained in:
Aiden Cline
2026-07-27 23:19:33 -05:00
committed by GitHub
parent 9c8060d96d
commit 921b1c6a34
28 changed files with 271 additions and 1028 deletions
+4
View File
@@ -11,6 +11,7 @@ export const Tokens = Schema.Struct({
refreshToken: Schema.mutableKey(Schema.optional(Schema.String)),
expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
scope: Schema.mutableKey(Schema.optional(Schema.String)),
issuer: Schema.mutableKey(Schema.optional(Schema.String)),
})
export type Tokens = Schema.Schema.Type<typeof Tokens>
@@ -19,6 +20,9 @@ export const ClientInfo = Schema.Struct({
clientSecret: Schema.mutableKey(Schema.optional(Schema.String)),
clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)),
clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
redirectUris: Schema.mutableKey(Schema.optional(Schema.Array(Schema.String))),
issuer: Schema.mutableKey(Schema.optional(Schema.String)),
configPreRegistered: Schema.mutableKey(Schema.optional(Schema.Boolean)),
})
export type ClientInfo = Schema.Schema.Type<typeof ClientInfo>
+11 -71
View File
@@ -1,40 +1,8 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import {
CallToolResultSchema,
ListToolsResultSchema,
ToolSchema,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js"
import { Client, type Tool as MCPToolDef } from "@modelcontextprotocol/client"
import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai"
import { Effect } from "effect"
const DEFAULT_TIMEOUT = 30_000
const MAX_LIST_PAGES = 1_000
const TolerantListToolsResultSchema = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
export async function paginate<T, R extends { nextCursor?: string }>(
list: (cursor?: string) => Promise<R>,
items: (result: R) => T[],
) {
const result: T[] = []
const cursors = new Set<string>()
let cursor: string | undefined
for (let page = 0; page < MAX_LIST_PAGES; page++) {
const page = await list(cursor)
result.push(...items(page))
if (page.nextCursor === undefined) return result
if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`)
cursors.add(page.nextCursor)
cursor = page.nextCursor
}
throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`)
}
export function defs(client: Client, timeout?: number) {
return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void))
}
@@ -56,7 +24,6 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
name: mcpTool.name,
arguments: (args || {}) as Record<string, unknown>,
},
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: options.abortSignal,
@@ -118,53 +85,26 @@ export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")
export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name)
export function prompts(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.prompts) return Promise.resolve([])
return paginate(
(cursor) => client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout }),
(result) => result.prompts,
)
export async function prompts(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.prompts) return []
return (await client.listPrompts(undefined, { timeout })).prompts
}
export function resources(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
return paginate(
(cursor) => client.listResources(cursor === undefined ? undefined : { cursor }, { timeout }),
(result) => result.resources,
)
export async function resources(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return []
return (await client.listResources(undefined, { timeout })).resources
}
export function resourceTemplates(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
return paginate(
(cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }),
(result) => result.resourceTemplates,
)
export async function resourceTemplates(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return []
return (await client.listResourceTemplates(undefined, { timeout })).resourceTemplates
}
function listTools(client: Client, timeout: number) {
return Effect.tryPromise({
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
try {
return await client.listTools(params, { timeout })
} catch (error) {
if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error
return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout })
}
},
(result) => result.tools,
),
try: async () => (await client.listTools(undefined, { timeout })).tools,
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
}
function isOutputSchemaValidationError(error: Error) {
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
error.message,
)
}
export * as McpCatalog from "./catalog"
+51 -24
View File
@@ -3,18 +3,18 @@ import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client, type ClientOptions } 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 {
ListRootsRequestSchema,
Client,
type ClientOptions,
StreamableHTTPClientTransport,
SSEClientTransport,
UnauthorizedError,
RegistrationRejectedError,
SdkHttpError,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
} from "@modelcontextprotocol/client"
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"
import { Config } from "@/config/config"
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -36,7 +36,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { McpBrowser } from "./browser"
const DEFAULT_TIMEOUT = 30_000
const CLIENT_OPTIONS = {
export const CLIENT_OPTIONS = {
capabilities: {
// https://github.com/anomalyco/opencode/issues/11948
// sampling: {},
@@ -47,6 +47,8 @@ const CLIENT_OPTIONS = {
// https://github.com/anomalyco/opencode/issues/28567
// tasks: {},
},
versionNegotiation: { mode: "auto" },
listMaxPages: 1_000,
} satisfies ClientOptions
export const Resource = Schema.Struct({
@@ -70,13 +72,19 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP
name: Schema.String,
}) {}
type MCPClient = Client
type MCPClient = Client & { onToolsChanged?: (error: Error | null) => void }
function createClient(directory: string) {
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS)
client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
const client: MCPClient = new Client(
{ name: "opencode", version: InstallationVersion },
{
...CLIENT_OPTIONS,
listChanged: {
tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) },
},
},
)
client.setRequestHandler("roots/list", async () => ({ roots: [{ uri: pathToFileURL(directory).href }] }))
return client
}
@@ -190,7 +198,11 @@ export interface Interface {
mcpName: string,
onAuthorization?: (authorizationUrl: string) => void,
) => Effect.Effect<Status, NotFoundError>
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status, NotFoundError>
readonly finishAuth: (
mcpName: string,
authorizationCode: string,
iss?: string,
) => Effect.Effect<Status, NotFoundError>
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
@@ -291,11 +303,18 @@ const layer = Layer.effect(
Effect.map((client) => ({ client, transportName: name })),
Effect.catch((error) => {
const lastError = error instanceof Error ? error : new Error(String(error))
const registrationRejected =
error instanceof RegistrationRejectedError ||
lastError.message.includes("registration") ||
lastError.message.includes("client_id")
const isAuthError =
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
error instanceof UnauthorizedError ||
registrationRejected ||
(authProvider && error instanceof SdkHttpError && error.status === 401) ||
(authProvider && lastError.message.includes("OAuth"))
if (isAuthError) {
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
if (registrationRejected) {
lastStatus = {
status: "needs_client_registration" as const,
error: "Server does not support dynamic client registration. Please provide clientId in config.",
@@ -454,12 +473,16 @@ const layer = Layer.effect(
)
}
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) =>
client.setNotificationHandler("notifications/message", (notification) =>
bridge.promise(serverLog(name, notification.params)),
)
if (!client.getServerCapabilities()?.tools) return
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
client.onToolsChanged = async (error) => {
if (error) {
await bridge.promise(Effect.logWarning("failed to refresh MCP tools", { server: name, error: error.message }))
return
}
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
const listed = await bridge.promise(McpCatalog.defs(client, timeout))
@@ -468,7 +491,7 @@ const layer = Layer.effect(
s.defs[name] = listed
await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
})
}
}
function serverLog(name: string, params: LoggingMessageNotification["params"]) {
@@ -904,7 +927,7 @@ const layer = Layer.effect(
}),
)
const code = yield* Effect.promise(() => callbackPromise)
const callback = yield* Effect.promise(() => callbackPromise)
const storedState = yield* auth.getOAuthState(mcpName)
if (storedState !== result.oauthState) {
@@ -912,16 +935,20 @@ const layer = Layer.effect(
throw new Error("OAuth state mismatch - potential CSRF attack")
}
yield* auth.clearOAuthState(mcpName)
return yield* finishAuth(mcpName, code)
return yield* finishAuth(mcpName, callback.code, callback.iss)
})
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
const finishAuth = Effect.fn("MCP.finishAuth")(function* (
mcpName: string,
authorizationCode: string,
iss?: string,
) {
yield* requireMcpConfig(mcpName)
const pending = pendingOAuthTransports.get(mcpName)
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const error = yield* Effect.tryPromise({
try: () => pending.transport.finishAuth(authorizationCode),
try: () => pending.transport.finishAuth(authorizationCode, iss),
catch: (error) => error,
}).pipe(
Effect.match({
+9 -3
View File
@@ -9,8 +9,13 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1"
let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH
export interface AuthorizationCallback {
code: string
iss?: string
}
interface PendingAuth {
resolve: (code: string) => void
resolve: (callback: AuthorizationCallback) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
@@ -49,6 +54,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
}
const code = url.searchParams.get("code")
const iss = url.searchParams.get("iss") ?? undefined
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
@@ -95,7 +101,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.resolve(code)
pending.resolve({ code, iss })
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(OauthCallbackPage.success({ provider: "MCP" }))
@@ -130,7 +136,7 @@ export async function ensureRunning(redirectUri?: string): Promise<void> {
})
}
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
export function waitForCallback(oauthState: string, mcpName?: string): Promise<AuthorizationCallback> {
if (mcpName) mcpNameToState.set(mcpName, oauthState)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
+63 -22
View File
@@ -1,10 +1,9 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type {
OAuthClientProvider,
OAuthClientMetadata,
OAuthTokens,
OAuthClientInformation,
OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js"
StoredOAuthTokens,
StoredOAuthClientInformation,
} from "@modelcontextprotocol/client"
import { Effect } from "effect"
import { McpAuth } from "./auth"
@@ -23,6 +22,14 @@ export interface McpOAuthCallbacks {
onRedirect: (url: URL) => void | Promise<void>
}
function registrationMetadata(info: StoredOAuthClientInformation) {
return {
clientIdIssuedAt: "client_id_issued_at" in info ? info.client_id_issued_at : undefined,
clientSecretExpiresAt: "client_secret_expires_at" in info ? info.client_secret_expires_at : undefined,
redirectUris: "redirect_uris" in info ? info.redirect_uris : undefined,
}
}
export class McpOAuthProvider implements OAuthClientProvider {
constructor(
protected mcpName: string,
@@ -52,18 +59,21 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
}
async clientInformation(): Promise<OAuthClientInformation | undefined> {
async clientInformation(): Promise<StoredOAuthClientInformation | undefined> {
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (this.config.clientId) {
const issuer = entry?.clientInfo?.clientId === this.config.clientId ? entry.clientInfo.issuer : undefined
return {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
...(issuer !== undefined ? { issuer } : {}),
}
}
// Check stored client info (from dynamic registration)
// Use getForUrl to validate credentials are for the current server URL
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (entry?.clientInfo) {
if (entry.clientInfo.configPreRegistered) return undefined
// Check if client secret has expired
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
return undefined
@@ -71,6 +81,14 @@ export class McpOAuthProvider implements OAuthClientProvider {
return {
client_id: entry.clientInfo.clientId,
client_secret: entry.clientInfo.clientSecret,
...(entry.clientInfo.clientIdIssuedAt !== undefined
? { client_id_issued_at: entry.clientInfo.clientIdIssuedAt }
: {}),
...(entry.clientInfo.clientSecretExpiresAt !== undefined
? { client_secret_expires_at: entry.clientInfo.clientSecretExpiresAt }
: {}),
redirect_uris: entry.clientInfo.redirectUris ? [...entry.clientInfo.redirectUris] : [this.redirectUrl],
...(entry.clientInfo.issuer !== undefined ? { issuer: entry.clientInfo.issuer } : {}),
}
}
@@ -78,22 +96,36 @@ export class McpOAuthProvider implements OAuthClientProvider {
return undefined
}
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
async saveClientInformation(info: StoredOAuthClientInformation): Promise<void> {
if (this.config.clientId && info.client_id === this.config.clientId) {
await Effect.runPromise(
this.auth.updateClientInfo(
this.mcpName,
{ clientId: info.client_id, issuer: info.issuer, configPreRegistered: true },
this.serverUrl,
),
)
return
}
const metadata = registrationMetadata(info)
await Effect.runPromise(
this.auth.updateClientInfo(
this.mcpName,
{
clientId: info.client_id,
clientSecret: info.client_secret,
clientIdIssuedAt: info.client_id_issued_at,
clientSecretExpiresAt: info.client_secret_expires_at,
clientIdIssuedAt: metadata.clientIdIssuedAt,
clientSecretExpiresAt: metadata.clientSecretExpiresAt,
redirectUris: metadata.redirectUris ? [...metadata.redirectUris] : [this.redirectUrl],
issuer: info.issuer,
},
this.serverUrl,
),
)
}
async tokens(): Promise<OAuthTokens | undefined> {
async tokens(): Promise<StoredOAuthTokens | undefined> {
// Use getForUrl to validate tokens are for the current server URL
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (!entry?.tokens) return undefined
@@ -106,18 +138,20 @@ export class McpOAuthProvider implements OAuthClientProvider {
? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
: undefined,
scope: entry.tokens.scope,
issuer: entry.tokens.issuer,
}
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
async saveTokens(tokens: StoredOAuthTokens): Promise<void> {
await Effect.runPromise(
this.auth.updateTokens(
this.mcpName,
{
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
expiresAt: tokens.expires_in !== undefined ? Date.now() / 1000 + tokens.expires_in : undefined,
scope: tokens.scope,
issuer: tokens.issuer,
},
this.serverUrl,
),
@@ -181,10 +215,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
export class McpOAuthPendingProvider extends McpOAuthProvider {
private pendingClientInfo?: OAuthClientInformationFull
private pendingTokens?: OAuthTokens
private pendingClientInfo?: StoredOAuthClientInformation
private pendingTokens?: StoredOAuthTokens
override async clientInformation(): Promise<OAuthClientInformation | undefined> {
override async clientInformation(): Promise<StoredOAuthClientInformation | undefined> {
if (!this.config.clientId) return this.pendingClientInfo
return {
client_id: this.config.clientId,
@@ -192,15 +226,15 @@ export class McpOAuthPendingProvider extends McpOAuthProvider {
}
}
override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
override async saveClientInformation(info: StoredOAuthClientInformation): Promise<void> {
this.pendingClientInfo = info
}
override async tokens(): Promise<OAuthTokens | undefined> {
override async tokens(): Promise<StoredOAuthTokens | undefined> {
return this.pendingTokens
}
override async saveTokens(tokens: OAuthTokens): Promise<void> {
override async saveTokens(tokens: StoredOAuthTokens): Promise<void> {
this.pendingTokens = tokens
}
@@ -211,6 +245,7 @@ export class McpOAuthPendingProvider extends McpOAuthProvider {
async commit(): Promise<void> {
if (!this.pendingTokens) return
const pendingMetadata = this.pendingClientInfo ? registrationMetadata(this.pendingClientInfo) : undefined
await Effect.runPromise(
this.auth.set(
this.mcpName,
@@ -218,16 +253,22 @@ export class McpOAuthPendingProvider extends McpOAuthProvider {
tokens: {
accessToken: this.pendingTokens.access_token,
refreshToken: this.pendingTokens.refresh_token,
expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
expiresAt:
this.pendingTokens.expires_in !== undefined
? Date.now() / 1000 + this.pendingTokens.expires_in
: undefined,
scope: this.pendingTokens.scope,
issuer: this.pendingTokens.issuer,
},
clientInfo:
this.pendingClientInfo && !this.config.clientId
? {
clientId: this.pendingClientInfo.client_id,
clientSecret: this.pendingClientInfo.client_secret,
clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
clientIdIssuedAt: pendingMetadata?.clientIdIssuedAt,
clientSecretExpiresAt: pendingMetadata?.clientSecretExpiresAt,
redirectUris: pendingMetadata?.redirectUris ? [...pendingMetadata.redirectUris] : [this.redirectUrl],
issuer: this.pendingClientInfo.issuer,
}
: undefined,
},