import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { ElicitationCompleteNotificationSchema, type CallToolRequest, type CallToolResult, type ReadResourceResult, type UrlElicitationRequiredError, } from "@modelcontextprotocol/sdk/types.js"; import type { McpTool, McpResource, ServerDefinition, ServerStreamResultPatchNotification, Transport, } from "./types.ts"; import { serverStreamResultPatchNotificationSchema } from "./types.ts"; import { resolveNpxBinary } from "./npx-resolver.ts"; import { logger } from "./logger.ts"; import { McpOAuthProvider } from "./mcp-oauth-provider.ts"; import { extractOAuthConfig, supportsOAuth } from "./mcp-auth-flow.ts"; import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts"; import { handleUrlElicitation, registerElicitationHandler, type ServerElicitationConfig, } from "./elicitation-handler.ts"; import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts"; interface ServerConnection { client: Client; transport: Transport; definition: ServerDefinition; tools: McpTool[]; resources: McpResource[]; lastUsedAt: number; inFlight: number; status: "connected" | "closed" | "needs-auth"; } type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void; export class McpServerManager { private connections = new Map(); private connectPromises = new Map>(); private uiStreamListeners = new Map(); private samplingConfig: ServerSamplingConfig | undefined; private elicitationConfig: ServerElicitationConfig | undefined; private acceptedUrlElicitations = new Map>(); /** Called after an auto-recovery attempt: args are (serverName, ok, errorMsg). * Fired from `callTool()` when a session-expired error triggered close+reconnect. * Use to surface notifications ("session auto-recovered" / "recovery failed"). */ onSessionRecover?: (serverName: string, ok: boolean, errorMsg: string) => void; setSamplingConfig(config: ServerSamplingConfig | undefined): void { this.samplingConfig = config; } setElicitationConfig(config: ServerElicitationConfig | undefined): void { this.elicitationConfig = config; } async connect(name: string, definition: ServerDefinition): Promise { // Dedupe concurrent connection attempts if (this.connectPromises.has(name)) { return this.connectPromises.get(name)!; } // Reuse existing connection if healthy const existing = this.connections.get(name); if (existing?.status === "connected") { existing.lastUsedAt = Date.now(); return existing; } const promise = this.createConnection(name, definition); this.connectPromises.set(name, promise); try { const connection = await promise; this.connections.set(name, connection); return connection; } finally { this.connectPromises.delete(name); } } private async createConnection( name: string, definition: ServerDefinition ): Promise { const client = this.createClient(name); let transport: Transport; if (definition.command) { let command = definition.command; let args = definition.args ?? []; if (command === "npx" || command === "npm") { const resolved = await resolveNpxBinary(command, args); if (resolved) { command = resolved.isJs ? "node" : resolved.binPath; args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs; logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`); } } transport = new StdioClientTransport({ command, args, env: resolveEnv(definition.env), cwd: resolveConfigPath(definition.cwd), stderr: definition.debug ? "inherit" : "ignore", }); } else if (definition.url) { // HTTP transport with fallback transport = await this.createHttpTransport(definition, name); } else { throw new Error(`Server ${name} has no command or url`); } try { await client.connect(transport); this.attachAdapterNotificationHandlers(name, client); // Discover tools and resources const [tools, resources] = await Promise.all([ this.fetchAllTools(client), this.fetchAllResources(client), ]); return { client, transport, definition, tools, resources, lastUsedAt: Date.now(), inFlight: 0, status: "connected", }; } catch (error) { // Check for UnauthorizedError - server requires OAuth if (error instanceof UnauthorizedError && supportsOAuth(definition)) { // Clean up both client and transport before reporting needs-auth. await client.close().catch(() => {}); await transport.close().catch(() => {}); return { client, transport, definition, tools: [], resources: [], lastUsedAt: Date.now(), inFlight: 0, status: "needs-auth", }; } // Clean up both client and transport on any error await client.close().catch(() => {}); await transport.close().catch(() => {}); throw error; } } private buildClientCapabilities() { return { ...(this.samplingConfig ? { sampling: {} } : {}), ...(this.elicitationConfig ? { elicitation: { form: {}, ...(this.elicitationConfig.allowUrl ? { url: {} } : {}), }, } : {}), }; } private createClient(serverName: string): Client { const capabilities = this.buildClientCapabilities(); const client = new Client( { name: `pi-mcp-${serverName}`, version: "1.0.0" }, Object.keys(capabilities).length > 0 ? { capabilities } : undefined, ); if (this.samplingConfig) { registerSamplingHandler(client, { ...this.samplingConfig, serverName }); } if (this.elicitationConfig) { registerElicitationHandler(client, { ...this.elicitationConfig, serverName, onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId), }); if (this.elicitationConfig.allowUrl) { client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => { const accepted = this.acceptedUrlElicitations.get(serverName); if (!accepted?.delete(notification.params.elicitationId)) return; this.elicitationConfig?.ui.notify( `MCP browser interaction for ${serverName} completed. You can retry the tool now.`, "info", ); }); } } return client; } async handleUrlElicitationRequired( serverName: string, error: UrlElicitationRequiredError, ): Promise<"accept" | "decline" | "cancel"> { if (!this.elicitationConfig?.allowUrl) return "cancel"; for (const params of error.elicitations) { const result = await handleUrlElicitation({ ...this.elicitationConfig, serverName, onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId), }, params); if (result.action !== "accept") return result.action; } return "accept"; } private rememberUrlElicitation(serverName: string, elicitationId: string): void { let accepted = this.acceptedUrlElicitations.get(serverName); if (!accepted) { accepted = new Set(); this.acceptedUrlElicitations.set(serverName, accepted); } accepted.add(elicitationId); } private async createHttpTransport( definition: ServerDefinition, serverName: string ): Promise { const url = new URL(definition.url!); // Build headers first (including any bearer token) const headers = resolveHeaders(definition.headers) ?? {}; // For bearer auth, add the token to headers BEFORE creating requestInit if (definition.auth === "bearer") { const token = resolveBearerToken(definition); if (token) { headers["Authorization"] = `Bearer ${token}`; } } // Create request init with headers (Authorization now included for bearer auth) const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; // For OAuth servers, create an auth provider let authProvider: McpOAuthProvider | undefined; if (supportsOAuth(definition)) { const oauthConfig = extractOAuthConfig(definition); authProvider = new McpOAuthProvider( serverName, definition.url!, oauthConfig, { onRedirect: async (_authUrl) => { // URL is captured by startAuth, no need to log }, } ); } // Try StreamableHTTP first (modern MCP servers) const streamableTransport = new StreamableHTTPClientTransport(url, { requestInit, authProvider, }); try { // Create a test client to verify the transport works const testClient = new Client({ name: "pi-mcp-probe", version: "2.1.2" }); await testClient.connect(streamableTransport); await testClient.close().catch(() => {}); // Close probe transport before creating fresh one await streamableTransport.close().catch(() => {}); // StreamableHTTP works - create fresh transport for actual use return new StreamableHTTPClientTransport(url, { requestInit, authProvider }); } catch (error) { // StreamableHTTP failed, close and try SSE fallback await streamableTransport.close().catch(() => {}); // If this was an UnauthorizedError, don't try SSE - the server needs auth if (error instanceof UnauthorizedError) { throw error; } // SSE is the legacy transport return new SSEClientTransport(url, { requestInit, authProvider }); } } private async fetchAllTools(client: Client): Promise { const allTools: McpTool[] = []; let cursor: string | undefined; do { const result = await client.listTools(cursor ? { cursor } : undefined); allTools.push(...(result.tools ?? [])); cursor = result.nextCursor; } while (cursor); return allTools; } private async fetchAllResources(client: Client): Promise { try { const allResources: McpResource[] = []; let cursor: string | undefined; do { const result = await client.listResources(cursor ? { cursor } : undefined); allResources.push(...(result.resources ?? [])); cursor = result.nextCursor; } while (cursor); return allResources; } catch { // Server may not support resources return []; } } private attachAdapterNotificationHandlers(serverName: string, client: Client): void { client.setNotificationHandler(serverStreamResultPatchNotificationSchema, (notification) => { const listener = this.uiStreamListeners.get(notification.params.streamToken); if (!listener) return; listener(serverName, notification.params); }); } registerUiStreamListener(streamToken: string, listener: UiStreamListener): void { this.uiStreamListeners.set(streamToken, listener); } removeUiStreamListener(streamToken: string): void { this.uiStreamListeners.delete(streamToken); } async readResource(name: string, uri: string): Promise { const connection = this.connections.get(name); if (!connection || connection.status !== "connected") { throw new Error(`Server "${name}" is not connected`); } try { this.touch(name); this.incrementInFlight(name); return await connection.client.readResource({ uri }); } finally { this.decrementInFlight(name); this.touch(name); } } /** * Call a tool with automatic session-recovery. * * Some MCP servers (notably UE5) return a session id in the `Mcp-Session-Id` * response header on `initialize`, then immediately forget it. The next * `tools/call` fails with "Unknown session id ... client should reinitialize". * * This wrapper catches that specific error pattern, closes + reconnects the * server (which triggers a fresh `initialize` and new session id), and * retries the call once. Other errors are propagated unchanged. * * Note: this is a per-call recovery. The MCP SDK's transport caches the * session id internally; our fix works because `close()` + `connect()` * creates a brand new transport + client, so the SDK starts fresh. */ /** Call the SDK client's callTool with arguments in the CORRECT positions. * * SDK signature is `callTool(params, resultSchema?, options?)`. We pass * `undefined` for resultSchema (→ SDK default `CallToolResultSchema`) and * forward only `signal` as the options. The previous inline cast treated * the method as `(params, options?)` and passed our options object as the * 2nd argument — landing it in the resultSchema slot. The SDK then * validated every response via `safeParse({_bookkeep, signal}, result)`, * and since a plain object has no `.safeParse`, every call crashed with * `v3Schema.safeParse is not a function` — breaking ALL tool calls (and, * via the same result-validation path in `request()`, tool listings too, * so only the adapter's own meta-tools stayed visible). */ private async sdkCallTool( client: Client, params: CallToolRequest["params"], options?: { signal?: AbortSignal }, ): Promise { return (client.callTool as ( p: CallToolRequest["params"], resultSchema: unknown, o?: { signal?: AbortSignal }, ) => Promise)( params, undefined, // → SDK default CallToolResultSchema options?.signal ? { signal: options.signal } : undefined, ); } async callTool( name: string, params: CallToolRequest["params"], options?: { signal?: AbortSignal; _bookkeep?: boolean }, ): Promise { const doCall = async (): Promise => { const connection = this.connections.get(name); if (!connection || connection.status !== "connected") { throw new Error(`Server "${name}" is not connected`); } // Skip bookkeeping if the caller already did it (legacy path). const bookkeeping = options?._bookkeep !== false; if (bookkeeping) { this.touch(name); this.incrementInFlight(name); } try { return this.sdkCallTool(connection.client, params, options); } finally { if (bookkeeping) { this.decrementInFlight(name); this.touch(name); } } }; try { return await doCall(); } catch (firstError) { if (!this.isSessionExpiredError(firstError)) throw firstError; // Session expired — reconnect and retry once. Caller can listen via // the optional onSessionRecover callback to show a notification. const connection = this.connections.get(name); if (!connection) throw firstError; const errMsg = firstError instanceof Error ? firstError.message : String(firstError); try { await this.close(name); } catch { // ignore close errors during recovery } await this.connect(name, connection.definition); try { const result = await this.sdkCallTool(connection.client, params, options); this.onSessionRecover?.(name, true, errMsg); return result; } catch (retryErr) { this.onSessionRecover?.(name, false, errMsg); throw retryErr; } } } /** Detect the UE5-style "session expired" error pattern. */ private isSessionExpiredError(error: unknown): boolean { if (!error) return false; const msg = error instanceof Error ? error.message : typeof error === "object" && error !== null && "message" in error ? String((error as { message: unknown }).message) : String(error); return /unknown session id|client should reinitialize|session.*expired|session.*invalid/i.test( msg, ); } async close(name: string): Promise { const connection = this.connections.get(name); if (!connection) return; // Delete from map BEFORE async cleanup to prevent a race where a // concurrent connect() creates a new connection that our deferred // delete() would then remove, orphaning the new server process. connection.status = "closed"; this.connections.delete(name); this.acceptedUrlElicitations.delete(name); await connection.client.close().catch(() => {}); await connection.transport.close().catch(() => {}); } async closeAll(): Promise { const names = [...this.connections.keys()]; await Promise.all(names.map(name => this.close(name))); } getConnection(name: string): ServerConnection | undefined { return this.connections.get(name); } getAllConnections(): Map { return new Map(this.connections); } touch(name: string): void { const connection = this.connections.get(name); if (connection) { connection.lastUsedAt = Date.now(); } } incrementInFlight(name: string): void { const connection = this.connections.get(name); if (connection) { connection.inFlight = (connection.inFlight ?? 0) + 1; } } decrementInFlight(name: string): void { const connection = this.connections.get(name); if (connection && connection.inFlight) { connection.inFlight--; } } isIdle(name: string, timeoutMs: number): boolean { const connection = this.connections.get(name); if (!connection || connection.status !== "connected") return false; if (connection.inFlight > 0) return false; return (Date.now() - connection.lastUsedAt) > timeoutMs; } } /** * Resolve environment variables with interpolation. */ function resolveEnv(env?: Record): Record { // Copy process.env, filtering out undefined values const resolved: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { resolved[key] = value; } } if (!env) return resolved; const overrides = interpolateEnvRecord(env); return overrides ? { ...resolved, ...overrides } : resolved; } /** * Resolve headers with environment variable interpolation. */ function resolveHeaders(headers?: Record): Record | undefined { return interpolateEnvRecord(headers); }