/************************************************************************************************** * openai‑mcp.js – OpenAI SDK wrapper with dynamic MCP tool orchestration * (2025‑04‑20 – streamlined edition) **************************************************************************************************/ import OriginalOpenAI from "openai"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { CallToolResultSchema, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js"; import { EventSource } from "eventsource"; /* ─────────────────────────────────────────── polyfills ────────────────────────────────────────── */ if (typeof globalThis.EventSource === "undefined") { globalThis.EventSource = EventSource; } /* ──────────────────────────────────────────── types ──────────────────────────────────────────── */ export type LogLevel = "debug" | "info" | "warn" | "error"; type ToolCall = { id: string; function: { name: string; arguments: string } }; type Message = { role: "system" | "user" | "assistant" | "tool" | "function"; content: string; tool_calls?: ToolCall[]; tool_call_id?: string; name?: string; }; /* ───────────────────────────────────── logging utility ─────────────────────────────────────────── */ const LVL = { debug: 0, info: 1, warn: 2, error: 3 } as const; type LogLevelValue = (typeof LVL)[LogLevel]; export let currentLogLevel: LogLevelValue = LVL.debug; export const setMcpLogLevel = (level: LogLevel): void => { if (LVL[level] !== undefined) { currentLogLevel = LVL[level]; log("info", `MCP log level set to ${level.toUpperCase()}`); } else { log("warn", `Invalid MCP log level: ${level}. Using current level.`); } }; export const log = (level: LogLevel | number, msg: string): void => { const lvl = typeof level === "number" ? level : LVL[level]; if (lvl < currentLogLevel) return; const tag = ["DEBUG", "INFO", "WARN", "ERROR"][lvl]; const logMsg = `[${new Date().toISOString()}] [MCP] [${tag}] ${msg}`; (lvl <= LVL.info ? console.log : lvl === LVL.warn ? console.warn : console.error)(logMsg); }; /* ───────────────────────────────── configuration interfaces ──────────────────────────────────── */ export interface MCPConfig { serverUrl?: string; serverUrls?: string[]; headers?: Record; maxToolCalls?: number; toolTimeoutSec?: number; disconnectAfterUse?: boolean; connectionTimeoutMs?: number; maxMessageGroups?: number; finalResponseSystemPrompt?: string; secondPassSystemPrompt?: string; modelName?: string; maxOutputTokens?: number; tokenRateLimit?: number; rateLimitWindowMs?: number; noWaitOnTpm?: boolean; logLevel?: LogLevel; } export interface Plugin { name: string; handle: ( params: OriginalOpenAI.Chat.ChatCompletionCreateParams, next: ( p: OriginalOpenAI.Chat.ChatCompletionCreateParams, ) => Promise, ) => Promise; } export interface OpenAIOptions { apiKey?: string; organization?: string; baseURL?: string; timeout?: number; maxRetries?: number; defaultQuery?: Record; defaultHeaders?: Record; dangerouslyAllowBrowser?: boolean; plugins?: string[] | Plugin[] | string | null; pluginConfig?: Record; mcp?: MCPConfig; mcpLogLevel?: LogLevel; } /* ─────────────────────────────────── provider routing ────────────────────────────────────────── */ interface Provider { name: string; regex: RegExp; baseURL: string; keyEnv: string; } const PROVIDERS: Provider[] = [ { name: "openai", regex: /^(gpt|text-|davinci|curie|babbage|ada|dall-e)/i, baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", }, { name: "anthropic", regex: /^claude/i, baseURL: "https://api.anthropic.com/v1", keyEnv: "ANTHROPIC_API_KEY", }, { name: "gemini", regex: /^gemini/i, baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", keyEnv: "GEMINI_API_KEY", }, ]; /* ─────────────────────────── MCP client implementation ───────────────────────────────────────── */ class MCPClient { private client!: Client; private connected = false; private transport: (SSEClientTransport & { eventSource?: EventSource }) | null = null; private tools: any[] = []; private userMessages: Message[] = []; private assistantMessages: Message[] = []; private toolResponses: Record = {}; private errorCount = 0; private cfg: { serverUrls: string[]; headers: Record; finalResponseSystemPrompt: string; modelName: string; maxOutputTokens: number; maxToolCalls: number; toolTimeoutSec: number; connectionTimeoutMs: number; maxMessageGroups: number; }; constructor(cfg: MCPConfig = {}) { log("debug", "Initializing MCP Client"); if (cfg.logLevel) setMcpLogLevel(cfg.logLevel); // Configure server URLs const urls = this.parseServerUrls(cfg); // Configure system prompts const finalResponsePrompt = cfg.finalResponseSystemPrompt || cfg.secondPassSystemPrompt || "Provide a helpful answer based on the tool results, addressing the user's original question."; // Store configuration this.cfg = { serverUrls: urls, headers: cfg.headers || {}, finalResponseSystemPrompt: finalResponsePrompt, modelName: cfg.modelName || "gpt-4", maxOutputTokens: cfg.maxOutputTokens ?? 4096, maxToolCalls: cfg.maxToolCalls ?? 15, toolTimeoutSec: cfg.toolTimeoutSec ?? 60, connectionTimeoutMs: cfg.connectionTimeoutMs ?? 5_000, maxMessageGroups: cfg.maxMessageGroups ?? 3, }; // Initialize fresh client this.resetClient(); log("info", `MCP Client initialized with model: ${this.cfg.modelName}`); } /** * Parse and normalize server URLs from config or environment */ private parseServerUrls(cfg: MCPConfig): string[] { const raw = cfg.serverUrls || (cfg.serverUrl ? [cfg.serverUrl] : null) || process.env.MCP_SERVER_URLS || process.env.MCP_SERVER_URL || "http://0.0.0.0:3000/mcp"; let urls = Array.isArray(raw) ? raw : String(raw).split(","); urls = urls .map((u) => u.trim()) .filter(Boolean) .filter((u) => /^https?:\/\//i.test(u)); return urls.length ? urls : ["http://0.0.0.0:3000/mcp"]; } /** * Reset client to initial state - prepare for a new request */ private resetClient() { this.client = new Client({ name: "mcp-client", version: "1.0.0" }); this.connected = false; this.transport = null; this.errorCount = 0; this.tools = []; this.userMessages = []; this.assistantMessages = []; this.toolResponses = {}; } /** * Connect to MCP server - guaranteed to return a fresh connection */ async connect() { // If already connected, disconnect first to ensure clean state if (this.connected) { log("debug", "Already connected, disconnecting first to ensure clean state"); await this.disconnect(); this.resetClient(); } log("info", `Connecting to MCP servers: ${this.cfg.serverUrls.join(", ")}`); // Try each server URL until one connects for (const url of this.cfg.serverUrls) { try { log("debug", `Attempting connection to ${url}`); const transport = new SSEClientTransport(new URL(url), { requestInit: Object.keys(this.cfg.headers).length ? { headers: this.cfg.headers } : undefined, }) as SSEClientTransport & { eventSource?: EventSource }; // Connect with timeout await Promise.race([ this.client.connect(transport), new Promise((_, rej) => setTimeout( rej, this.cfg.connectionTimeoutMs, new Error("Connection timeout"), ), ), ]); // Set up error handling if (transport.eventSource) { transport.eventSource.onerror = (ev: Event) => this.handleTransportError(ev, url); } this.transport = transport; this.connected = true; // Listen for tool changes this.client.setNotificationHandler( ToolListChangedNotificationSchema, () => { log("info", "Tool list changed – refreshing"); this.updateTools(); }, ); await this.updateTools(); log("info", `Connected to MCP ${url}. Tools: ${this.tools.length}`); return; } catch (e: unknown) { log("warn", `Connect failed (${url}) – ${(e as Error)?.message || String(e)}`); } } throw new Error("MCP: all server URLs failed"); } /** * Handle transport errors */ private handleTransportError(ev: Event, url: string) { this.errorCount++; log("warn", `SSE error (${url}): ${(ev as any)?.message ?? String(ev)}. count=${this.errorCount}`); if (this.errorCount > 3) { this.tools = []; return; } // Try to recover with tool refresh setTimeout(async () => { try { await this.updateTools(); } catch (e: unknown) { log("warn", `Reconnect refresh failed: ${(e as Error)?.message || String(e)}`); } }, 1_000); } /** * Comprehensive disconnect with cleanup */ async disconnect() { if (!this.connected && !this.transport) { log("debug", "Not connected, skipping disconnect"); return true; } log("info", "Disconnecting from MCP"); const errors: string[] = []; // 1. Close the client try { await Promise.race([ (this.client as any).close?.() ?? (this.client as any).disconnect?.(), new Promise((_, rej) => setTimeout(rej, 3_000, new Error("Client close timeout"))), ]); } catch (e: unknown) { errors.push(`Client close: ${(e as Error)?.message || String(e)}`); log("warn", `Error closing client: ${(e as Error)?.message || String(e)}`); } // 2. Clean up EventSource if (this.transport?.eventSource) { try { const es = this.transport.eventSource; es.onerror = es.onmessage = es.onopen = null; es.close(); (es as any).connection?.unref?.(); // @ts-ignore this.transport.eventSource = undefined; } catch (e: unknown) { errors.push(`EventSource: ${(e as Error)?.message || String(e)}`); } } // 3. Close transport if (this.transport) { try { await Promise.race([ this.transport.close(), new Promise((_, reject) => setTimeout(reject, 3_000, new Error("Transport close timeout"))), ]); } catch (e: unknown) { errors.push(`Transport: ${(e as Error)?.message || String(e)}`); try { (this.transport as any)._abortController?.abort?.(); } catch {/* ignore */} } finally { this.transport = null; } } // 4. Clear state this.connected = false; // 5. Unref node resources try { process.stdin.unref?.(); process.stdout.unref?.(); process.stderr.unref?.(); } catch {/* ignore */} if (errors.length) { log("warn", `Disconnect completed with ${errors.length} errors`); } else { log("info", "Disconnect completed successfully"); } return errors.length === 0; } /** * Update available tools list */ async updateTools() { if (!this.connected) { log("debug", "Not connected, skipping tool update"); return (this.tools = []); } log("debug", "Updating MCP tools list"); try { const { tools = [] } = (await this.client.listTools()) || {}; this.tools = tools.map((t: any) => ({ name: t.name, description: t.description || `Use ${t.name}`, input_schema: t.inputSchema, categories: (t.categories || []).map((c: string) => c.toLowerCase()), })); log("info", `Loaded ${this.tools.length} tools`); } catch (e: unknown) { log("warn", `listTools failed: ${(e as Error)?.message || String(e)}`); } } /** * Format a tool for OpenAI */ formatTool(t: any) { let schema = typeof t.input_schema === "string" ? JSON.parse(t.input_schema) : t.input_schema || {}; if (schema?.type !== "object") schema = { type: "object", properties: {} }; if (!Object.keys(schema.properties).length) { schema.properties = { query: { type: "string", description: `Input for ${t.name}` }, }; } return { type: "function", function: { name: t.name, description: t.description, parameters: schema, }, }; } /** * Get tools in OpenAI format */ openAITools() { if (!this.tools.length) { this.updateTools().catch(() => {}); } return this.tools.map((t) => this.formatTool(t)); } /** * Build complete message history */ buildMsgs() { const out = [...this.userMessages]; for (const m of this.assistantMessages) { out.push(m); m.tool_calls?.forEach((tc: any) => { const r = this.toolResponses[tc.id]; if (r) out.push(r); }); } return out; } /** * Trim message history to manageable size */ trim(msgs: Message[]) { if (msgs.length <= 4) return msgs; const groups: Message[][] = []; const cur: Message[] = []; const flush = () => { if (cur.length) groups.push(cur.splice(0)); }; for (let i = 0; i < msgs.length; i++) { cur.push(msgs[i]); if (msgs[i].role === "assistant" && msgs[i].tool_calls?.length) { const ids = new Set(msgs[i].tool_calls?.map((t) => t.id) || []); for (let j = i + 1; j < msgs.length && msgs[j].role === "tool"; j++) { if (ids.has(msgs[j].tool_call_id!)) { cur.push(msgs[j]); i = j; } else break; } } flush(); } return [ groups[0] || [], ...groups.slice(-this.cfg.maxMessageGroups), ].flat(); } /** * Process a tool call and get results */ async processToolCall(tc: any) { let args; try { args = JSON.parse(tc.function.arguments); } catch { args = tc.function.arguments; } log("info", `Processing tool call: ${tc.function.name}`); try { const r = await this.client.callTool( { name: tc.function.name, arguments: args }, CallToolResultSchema, { timeout: this.cfg.toolTimeoutSec * 1_000 }, ); const txt = Array.isArray(r.content) ? r.content.map((c: any) => c.text).join("\n\n") : r.content || "No result"; return (this.toolResponses[tc.id] = { role: "tool", tool_call_id: tc.id, name: tc.function.name, content: typeof txt === "string" ? txt.length > 8_000 ? txt.slice(0, 8_000) + "\n\n[truncated]" : txt : "No result", }); } catch (e: unknown) { log("warn", `Tool error: ${(e as Error)?.message || String(e)}`); return (this.toolResponses[tc.id] = { role: "tool", tool_call_id: tc.id, name: tc.function.name, content: `Error: ${(e as Error)?.message || String(e)}`, }); } } // Public getters getUserMessages = () => this.userMessages; getAssistantMessages = () => this.assistantMessages; getTools = () => this.tools; isConnected = () => this.connected; getConfig = () => this.cfg; } /* ────────────────────────────────────── Utilities ─────────────────────────────────────────── */ async function* asIterable(resp: any) { if (resp && Symbol.asyncIterator in resp) { for await (const x of resp) yield x; return; } const content = resp?.choices?.[0]?.message?.content ?? resp?.choices?.[0]?.delta?.content ?? resp?.content ?? JSON.stringify(resp); yield { choices: [{ delta: { content } }] }; } /* ───────────────────────────────────────── Plugins ──────────────────────────────────────────── */ let globalApiKey: string | null = null; const providerCache = new Map(); function providerFor(model: string): OriginalOpenAI { const info = PROVIDERS.find((p) => p.regex.test(model)) || PROVIDERS[0]; log("debug", `Using provider ${info.name} for model ${model}`); if (!providerCache.has(info.name)) { log("debug", `Creating new provider instance for ${info.name}`); providerCache.set( info.name, new OriginalOpenAI({ apiKey: globalApiKey || process.env[info.keyEnv], baseURL: info.baseURL, }), ); } return providerCache.get(info.name)!; } interface InternalPlugin { name: string; handle: (params: any, next: (p: any) => Promise) => Promise; } const multiModelPlugin: InternalPlugin = { name: "multiModelPlugin", async handle(params, next) { log("debug", `MultiModel plugin handling request for model: ${params.model}`); return providerFor(params.model).chat.completions.create(params); }, }; function mcpPlugin(opts: any = {}): InternalPlugin { return { name: "mcpPlugin", async handle(params: any, next: (p: any) => Promise) { log("info", `MCP plugin handling request for model: ${params.model}`); const serverConfig = opts.serverUrls || opts.serverUrl || process.env.MCP_SERVER_URLS || process.env.MCP_SERVER_URL; if (!serverConfig) { log("debug", "No MCP server config found, skipping MCP processing"); return next(params); } const wantStream = params.stream === true; log("debug", `Request stream mode: ${wantStream}`); const originalSystemMessage = params.messages.find( (m: Message) => m.role === "system", ); // Create fresh MCP client for this request log("debug", "Creating MCP client"); const mcp = new MCPClient({ ...opts, serverUrls: opts.serverUrls, modelName: params.model, maxOutputTokens: params.max_tokens, }); // Always ensure clean connections per request try { await mcp.connect(); return await processRequest(); } catch (e: unknown) { log("warn", `MCP unavailable – ${(e as Error)?.message || String(e)}`); return next(params); } finally { await mcp.disconnect().catch(e => { log("warn", `Error during final disconnect: ${(e as Error)?.message || String(e)}`); }); } // Process the request with the connected client async function processRequest() { // Load messages and tools log("debug", `Processing ${params.messages.length} messages`); params.messages.forEach((m: Message) => { (m.role === "user" ? mcp.getUserMessages() : mcp.getAssistantMessages() ).push(m); }); await mcp.updateTools(); const tools = mcp.openAITools(); log("debug", `Available tools: ${tools.length}`); const messagesWithSystem = mcp.trim(mcp.buildMsgs()); const firstPassMessages = originalSystemMessage ? [ originalSystemMessage, ...messagesWithSystem.filter((m: Message) => m.role !== "system"), ] : messagesWithSystem; // First pass to get tool calls log("info", "Sending first pass request to model"); const first = await next({ model: params.model, stream: false, max_tokens: params.max_tokens ?? 4096, messages: firstPassMessages, ...(tools.length && { tools, tool_choice: "auto" }), }); const assistant = first.choices[0].message; const calls = assistant.tool_calls ?? []; log("info", `First pass response received, tool calls: ${calls.length}`); // If no tool calls, return direct response if (!calls.length) { log("debug", "No tool calls, returning direct response"); if (wantStream) { return asIterable(await next({ ...params, stream: true })); } return first; } // Process tool calls mcp.getAssistantMessages().push({ role: "assistant", content: assistant.content, tool_calls: calls, }); const summaries = []; log("info", `Processing ${Math.min(calls.length, mcp.getConfig().maxToolCalls)} tool calls`); for (const tc of calls.slice(0, mcp.getConfig().maxToolCalls)) { summaries.push( `### ${tc.function.name}\n${(await mcp.processToolCall(tc)).content}`, ); } // Final response with tool results const finalResponseSystemPrompt = opts.finalResponseSystemPrompt || opts.secondPassSystemPrompt || mcp.getConfig().finalResponseSystemPrompt; log("info", "Sending follow-up request with tool results"); const follow = await next({ model: params.model, stream: wantStream, max_tokens: params.max_tokens ?? 4096, messages: [ { role: "system", content: finalResponseSystemPrompt }, { role: "user", content: mcp.getUserMessages().at(-1)?.content || "", }, { role: "user", content: summaries.join("\n\n") }, ], }); // Return appropriate response format if (wantStream) { log("debug", "Returning stream response"); return asIterable(follow); } log("debug", "Processing final response"); let final = ""; for await (const ch of asIterable(follow)) { final += ch.choices?.[0]?.delta?.content || ""; } assistant.content = final; log("info", "Request completed successfully"); return { id: `chatcmpl-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1e3), model: params.model, usage: first.usage, choices: [ { index: 0, finish_reason: calls.length && params.return_tool_calls ? "tool_calls" : "stop", message: { role: "assistant", content: assistant.content, tool_calls: params.return_tool_calls ? calls : undefined, }, }, ], }; } }, }; } function compose( plugins: InternalPlugin[], base: (p: any) => Promise, ): (p: any) => Promise { return plugins.reduceRight( (next, plugin) => (params) => plugin.handle(params, next), base, ); } /** * OpenAI client with MCP support - drop-in replacement for OpenAI */ class OpenAI extends OriginalOpenAI { constructor(options: OpenAIOptions = {}) { super({ apiKey: options.apiKey, organization: options.organization, baseURL: options.baseURL, timeout: options.timeout, maxRetries: options.maxRetries, defaultQuery: options.defaultQuery, defaultHeaders: options.defaultHeaders, dangerouslyAllowBrowser: options.dangerouslyAllowBrowser, }); if (options.mcpLogLevel || (options.mcp && options.mcp.logLevel)) { setMcpLogLevel(options.mcpLogLevel || options.mcp?.logLevel || "debug"); } log("info", `Initializing OpenAI client with plugins`); globalApiKey = options.apiKey || null; const pluginConfig = { ...(options.pluginConfig || {}), mcp: { ...(options.mcp || {}), disconnectAfterUse: true, // Force disconnect after each use for clean lifecycle }, }; const activePlugins = this.#loadPlugins( options.plugins || null, pluginConfig, ); const originalCreate = this.chat.completions.create.bind( this.chat.completions, ); const handler = compose(activePlugins, originalCreate); this.chat.completions.create = handler as typeof originalCreate; log( "info", `OpenAI client initialized with ${activePlugins.length} plugins`, ); } #loadPlugins( plugins: string | string[] | Plugin[] | null, config: Record, ): InternalPlugin[] { if (Array.isArray(plugins) && plugins.length > 0 && typeof plugins[0] === "object") { // Handle Plugin objects directly const pluginObjects = plugins as Plugin[]; const uniquePluginNames = [...new Set(pluginObjects.map((p) => p.name))]; log("debug", `Loading ${uniquePluginNames.length} object plugins: ${uniquePluginNames.join(", ")}`); return pluginObjects.map((p) => ({ name: p.name, handle: async (params: any, next: any) => p.handle(params, next), })); } else if (plugins) { // Handle string plugin names const pluginNames = Array.isArray(plugins) ? [...new Set(plugins as string[])] : [plugins as string]; log("debug", `Loading ${pluginNames.length} plugins: ${pluginNames.join(", ")}`); const pluginMap = new Map(); pluginNames.forEach((name) => { if (name === "mcp" && !pluginMap.has("mcp")) { pluginMap.set("mcp", mcpPlugin(config.mcp || {})); } if (name === "multiModel" && !pluginMap.has("multiModel")) { pluginMap.set("multiModel", multiModelPlugin); } }); return [...pluginMap.values()]; } return []; } } /* ───────────────────────────────────────── exports ──────────────────────────────────────────── */ export default OpenAI; export { OpenAI }; if (typeof module !== "undefined") { module.exports = OpenAI; module.exports.OpenAI = OpenAI; module.exports.default = OpenAI; }