UNPKG

@langchain/mcp-adapters

Version:
565 lines (563 loc) 23.1 kB
const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs'); const require_types = require('./types.cjs'); const require_logging = require('./logging.cjs'); const require_tools = require('./tools.cjs'); const require_connection = require('./connection.cjs'); const zod_v3 = require_rolldown_runtime.__toESM(require("zod/v3")); //#region src/client.ts const debugLog = require_logging.getDebugLog(); /** * Error class for MCP client operations */ var MCPClientError = class extends Error { constructor(message, serverName) { super(message); this.serverName = serverName; this.name = "MCPClientError"; } }; /** * Checks if the connection configuration is for a stdio transport * @param connection - The connection configuration * @returns True if the connection configuration is for a stdio transport */ function isResolvedStdioConnection(connection) { if (typeof connection !== "object" || connection === null || Array.isArray(connection)) return false; if ("transport" in connection && connection.transport === "stdio") return true; if ("type" in connection && connection.type === "stdio") return true; if ("command" in connection && typeof connection.command === "string") return true; return false; } /** * Checks if the connection configuration is for a streamable HTTP transport * @param connection - The connection configuration * @returns True if the connection configuration is for a streamable HTTP transport */ function isResolvedStreamableHTTPConnection(connection) { if (typeof connection !== "object" || connection === null || Array.isArray(connection)) return false; if ("transport" in connection && typeof connection.transport === "string" && ["http", "sse"].includes(connection.transport) || "type" in connection && typeof connection.type === "string" && ["http", "sse"].includes(connection.type)) return true; if ("url" in connection && typeof connection.url === "string") try { new URL(connection.url); return true; } catch { return false; } return false; } /** * Client for connecting to multiple MCP servers and loading LangChain-compatible tools. */ var MultiServerMCPClient = class { /** * Cached map of server names to tools */ #serverNameToTools = {}; /** * Configured MCP servers */ #mcpServers; /** * Cached map of server names to load tools options */ #loadToolsOptions = {}; /** * Connection manager */ #clientConnections; /** * Resolved client config */ #config; /** * Behavior when a server fails to connect */ #onConnectionError; /** * Set of server names that have failed to connect (when onConnectionError is "ignore") */ #failedServers = /* @__PURE__ */ new Set(); /** * Returns clone of server config for inspection purposes. * * Client does not support config modifications. */ get config() { return JSON.parse(JSON.stringify(this.#config)); } /** * Create a new MultiServerMCPClient. * * @param config - Configuration object */ constructor(config) { let parsedServerConfig; const configSchema = require_types.clientConfigSchema; if ("mcpServers" in config) parsedServerConfig = configSchema.parse(config); else { const parsedMcpServers = zod_v3.z.record(require_types.connectionSchema).parse(config); parsedServerConfig = configSchema.parse({ mcpServers: parsedMcpServers }); } if (Object.keys(parsedServerConfig.mcpServers).length === 0) throw new MCPClientError("No MCP servers provided"); for (const [serverName, serverConfig] of Object.entries(parsedServerConfig.mcpServers)) { const outputHandling = require_types._resolveAndApplyOverrideHandlingOverrides(parsedServerConfig.outputHandling, serverConfig.outputHandling); const defaultToolTimeout = parsedServerConfig.defaultToolTimeout ?? serverConfig.defaultToolTimeout; this.#loadToolsOptions[serverName] = { throwOnLoadError: parsedServerConfig.throwOnLoadError, prefixToolNameWithServerName: parsedServerConfig.prefixToolNameWithServerName, additionalToolNamePrefix: parsedServerConfig.additionalToolNamePrefix, useStandardContentBlocks: parsedServerConfig.useStandardContentBlocks, ...Object.keys(outputHandling).length > 0 ? { outputHandling } : {}, ...defaultToolTimeout ? { defaultToolTimeout } : {}, onProgress: parsedServerConfig.onProgress, beforeToolCall: parsedServerConfig.beforeToolCall, afterToolCall: parsedServerConfig.afterToolCall }; } this.#config = parsedServerConfig; this.#mcpServers = parsedServerConfig.mcpServers; this.#clientConnections = new require_connection.ConnectionManager(parsedServerConfig); this.#onConnectionError = parsedServerConfig.onConnectionError; } /** * Proactively initialize connections to all servers. This will be called automatically when * methods requiring an active connection (like {@link getTools} or {@link getClient}) are called, * but you can call it directly to ensure all connections are established before using the tools. * * When a server fails to connect, the client will throw an error if `onConnectionError` is "throw", * otherwise it will skip the server and continue with the remaining servers. * * @returns A map of server names to arrays of tools * @throws {MCPClientError} If initialization fails and `onConnectionError` is "throw" (default) */ async initializeConnections(customTransportOptions) { if (!this.#mcpServers || Object.keys(this.#mcpServers).length === 0) throw new MCPClientError("No connections to initialize"); for (const [serverName, connection] of Object.entries(this.#mcpServers)) { if ((this.#onConnectionError === "ignore" || typeof this.#onConnectionError === "function") && this.#failedServers.has(serverName)) continue; try { await this._initializeConnection(serverName, connection, customTransportOptions); this.#failedServers.delete(serverName); } catch (error) { if (this.#onConnectionError === "throw") throw error; if (typeof this.#onConnectionError === "function") { this.#onConnectionError({ serverName, error }); this.#failedServers.add(serverName); debugLog(`WARN: Failed to initialize connection to server "${serverName}": ${String(error)}`); continue; } this.#failedServers.add(serverName); debugLog(`WARN: Failed to initialize connection to server "${serverName}": ${String(error)}`); continue; } } if (this.#onConnectionError === "ignore" && Object.keys(this.#serverNameToTools).length === 0) debugLog(`WARN: No servers successfully connected. All connection attempts failed.`); return this.#serverNameToTools; } async getTools(...args) { if (args.length === 0 || args.every((arg) => typeof arg === "string")) { await this.initializeConnections(); const servers$1 = args; return servers$1.length === 0 ? this._getAllToolsAsFlatArray() : this._getToolsFromServers(servers$1); } const [servers, options] = args; await this.initializeConnections(options); return servers.length === 0 ? this._getAllToolsAsFlatArray() : this._getToolsFromServers(servers); } async setLoggingLevel(...args) { if (args.length === 1 && typeof args[0] === "string") { const level$1 = args[0]; await Promise.all(this.#clientConnections.getAllClients().map((client) => client.setLoggingLevel(level$1))); return; } const [serverName, level] = args; await this.#clientConnections.get(serverName)?.setLoggingLevel(level); } /** * Get a the MCP client for a specific server. Useful for fetching prompts or resources from that server. * * @param serverName - The name of the server * @returns The client for the server, or undefined if the server is not connected */ async getClient(serverName, options) { await this.initializeConnections(options); return this.#clientConnections.get({ serverName, headers: options?.headers, authProvider: options?.authProvider }); } async listResources(...args) { let servers; let options; if (args.length === 0 || args.every((arg) => typeof arg === "string")) { servers = args; await this.initializeConnections(); } else { [servers, options] = args; await this.initializeConnections(options); } const targetServers = servers.length > 0 ? servers : Object.keys(this.#config.mcpServers); const result = {}; for (const serverName of targetServers) { const client = await this.getClient(serverName, options); if (!client) { debugLog(`WARN: Server "${serverName}" not found or not connected`); continue; } try { const resourcesList = await client.listResources(); result[serverName] = resourcesList.resources.map((resource) => ({ uri: resource.uri, name: resource.title ?? resource.name, description: resource.description, mimeType: resource.mimeType })); debugLog(`INFO: Listed ${result[serverName].length} resources from server "${serverName}"`); } catch (error) { debugLog(`ERROR: Failed to list resources from server "${serverName}": ${error}`); result[serverName] = []; } } return result; } async listResourceTemplates(...args) { let servers; let options; if (args.length === 0 || args.every((arg) => typeof arg === "string")) { servers = args; await this.initializeConnections(); } else { [servers, options] = args; await this.initializeConnections(options); } const targetServers = servers.length > 0 ? servers : Object.keys(this.#config.mcpServers); const result = {}; for (const serverName of targetServers) { const client = await this.getClient(serverName, options); if (!client) { debugLog(`WARN: Server "${serverName}" not found or not connected`); continue; } try { const templatesList = await client.listResourceTemplates(); result[serverName] = templatesList.resourceTemplates.map((template) => ({ uriTemplate: template.uriTemplate, name: template.title ?? template.name, description: template.description, mimeType: template.mimeType })); debugLog(`INFO: Listed ${result[serverName].length} resource templates from server "${serverName}"`); } catch (error) { debugLog(`ERROR: Failed to list resource templates from server "${serverName}": ${error}`); result[serverName] = []; } } return result; } /** * Read a resource from a specific server. * * @param serverName - The name of the server to read the resource from * @param uri - The URI of the resource to read * @param options - Optional connection options for reading the resource, e.g. custom auth provider or headers. * @returns The resource contents * * @example * ```ts * const content = await client.readResource("server1", "file://path/to/resource"); * ``` */ async readResource(serverName, uri, options) { await this.initializeConnections(options); const client = await this.getClient(serverName, options); if (!client) throw new MCPClientError(`Server "${serverName}" not found or not connected`, serverName); try { debugLog(`INFO: Reading resource "${uri}" from server "${serverName}"`); const result = await client.readResource({ uri }); return result.contents.map((content) => ({ uri: content.uri, mimeType: content.mimeType, text: "text" in content ? content.text : void 0, blob: "blob" in content ? content.blob : void 0 })); } catch (error) { throw new MCPClientError(`Failed to read resource "${uri}" from server "${serverName}": ${error}`, serverName); } } /** * Close all connections. */ async close() { debugLog(`INFO: Closing all MCP connections...`); this.#serverNameToTools = {}; this.#failedServers.clear(); await this.#clientConnections.delete(); debugLog(`INFO: All MCP connections closed`); } /** * Initialize a connection to a specific server */ async _initializeConnection(serverName, connection, customTransportOptions) { if (isResolvedStdioConnection(connection)) { debugLog(`INFO: Initializing stdio connection to server "${serverName}"...`); /** * check if we already initialized this stdio connection */ if (this.#clientConnections.has(serverName)) return; await this._initializeStdioConnection(serverName, connection); } else if (isResolvedStreamableHTTPConnection(connection)) { /** * Users may want to use different connection options for tool calls or tool discovery. */ const { authProvider, headers } = customTransportOptions ?? {}; const updatedConnection = { ...connection, authProvider: authProvider ?? connection.authProvider, headers: { ...headers, ...connection.headers } }; /** * check if we already initialized this streamable HTTP connection */ const key = { serverName, headers: updatedConnection.headers, authProvider: updatedConnection.authProvider }; if (this.#clientConnections.has(key)) return; if (connection.type === "sse" || connection.transport === "sse") await this._initializeSSEConnection(serverName, updatedConnection); else await this._initializeStreamableHTTPConnection(serverName, updatedConnection); } else throw new MCPClientError(`Unsupported transport type for server "${serverName}"`, serverName); } /** * Initialize a stdio connection */ async _initializeStdioConnection(serverName, connection) { const { command, args, restart } = connection; debugLog(`DEBUG: Creating stdio transport for server "${serverName}" with command: ${command} ${args.join(" ")}`); try { const client = await this.#clientConnections.createClient("stdio", serverName, connection); const transport = this.#clientConnections.getTransport({ serverName }); if (restart?.enabled) this._setupStdioRestart(serverName, transport, connection, restart); await this._loadToolsForServer(serverName, client); } catch (error) { throw new MCPClientError(`Failed to connect to stdio server "${serverName}": ${error}`, serverName); } } /** * Set up stdio restart handling */ _setupStdioRestart(serverName, transport, connection, restart) { const originalOnClose = transport.onclose; transport.onclose = async () => { if (originalOnClose) await originalOnClose(); if (this.#clientConnections.get(serverName)) { debugLog(`INFO: Process for server "${serverName}" exited, attempting to restart...`); await this._attemptReconnect(serverName, connection, restart.maxAttempts, restart.delayMs); } }; } _getHttpErrorCode(error) { const streamableError = error; let { code } = streamableError; if (code == null) { const m = streamableError.message.match(/\(HTTP (\d\d\d)\)/); if (m && m.length > 1) code = parseInt(m[1], 10); } return code; } _createAuthenticationErrorMessage(serverName, url, transport, originalError) { return `Authentication failed for ${transport} server "${serverName}" at ${url}. Please check your credentials, authorization headers, or OAuth configuration. Original error: ${originalError}`; } _toSSEConnectionURL(url) { const urlObj = new URL(url); const pathnameParts = urlObj.pathname.split("/"); const lastPart = pathnameParts.at(-1); if (lastPart && lastPart === "mcp") pathnameParts[pathnameParts.length - 1] = "sse"; urlObj.pathname = pathnameParts.join("/"); return urlObj.toString(); } /** * Initialize a streamable HTTP connection */ async _initializeStreamableHTTPConnection(serverName, connection) { const { url, type: typeField, transport: transportField } = connection; const automaticSSEFallback = connection.automaticSSEFallback ?? true; const transportType = typeField || transportField; debugLog(`DEBUG: Creating Streamable HTTP transport for server "${serverName}" with URL: ${url}`); if (transportType === "http" || transportType == null) try { const client = await this.#clientConnections.createClient("http", serverName, connection); await this._loadToolsForServer(serverName, client); } catch (error) { const code = this._getHttpErrorCode(error); if (automaticSSEFallback && code != null && code >= 400 && code < 500) try { await this._initializeSSEConnection(serverName, connection); } catch (firstSSEError) { const sseUrl = this._toSSEConnectionURL(url); if (sseUrl !== url) try { await this._initializeSSEConnection(serverName, { ...connection, url: sseUrl }); } catch (secondSSEError) { if (code === 401) throw new MCPClientError(this._createAuthenticationErrorMessage(serverName, url, "HTTP", `${error}. Also tried SSE fallback at ${url} and ${sseUrl}, but both failed with authentication errors.`), serverName); throw new MCPClientError(`Failed to connect to streamable HTTP server "${serverName}, url: ${url}": ${error}. Additionally, tried falling back to SSE at ${url} and ${sseUrl}, but this also failed: ${secondSSEError}`, serverName); } else { if (code === 401) throw new MCPClientError(this._createAuthenticationErrorMessage(serverName, url, "HTTP", `${error}. Also tried SSE fallback at ${url}, but it failed with authentication error: ${firstSSEError}`), serverName); throw new MCPClientError(`Failed to connect to streamable HTTP server after trying to fall back to SSE: "${serverName}, url: ${url}": ${error} (SSE fallback failed with error ${firstSSEError})`, serverName); } } else { if (code === 401) throw new MCPClientError(this._createAuthenticationErrorMessage(serverName, url, "HTTP", `${error}`), serverName); throw new MCPClientError(`Failed to connect to streamable HTTP server "${serverName}, url: ${url}": ${error}`, serverName); } } } /** * Initialize an SSE connection * * Don't call this directly unless SSE transport is explicitly requested. Otherwise, * use _initializeStreamableHTTPConnection and it'll fall back to SSE if needed for * backwards compatibility. * * @param serverName - The name of the server * @param connection - The connection configuration */ async _initializeSSEConnection(serverName, connection) { const { url, headers, reconnect, authProvider } = connection; try { const client = await this.#clientConnections.createClient("sse", serverName, connection); const transport = this.#clientConnections.getTransport({ serverName, headers, authProvider }); if (reconnect?.enabled) this._setupSSEReconnect(serverName, transport, connection, reconnect); await this._loadToolsForServer(serverName, client); } catch (error) { if (error && error.name === "MCPClientError") throw error; const isAuthError = error && this._getHttpErrorCode(error) === 401; if (isAuthError) throw new MCPClientError(this._createAuthenticationErrorMessage(serverName, url, "SSE", `${error}`), serverName); throw new MCPClientError(`Failed to create SSE transport for server "${serverName}, url: ${url}": ${error}`, serverName); } } /** * Set up reconnect handling for SSE (Streamable HTTP reconnects are more complex and are handled internally by the SDK) */ _setupSSEReconnect(serverName, transport, connection, reconnect) { const originalOnClose = transport.onclose; transport.onclose = async () => { if (originalOnClose) await originalOnClose(); if (this.#clientConnections.get({ serverName, headers: connection.headers, authProvider: connection.authProvider })) { debugLog(`INFO: HTTP connection for server "${serverName}" closed, attempting to reconnect...`); await this._attemptReconnect(serverName, connection, reconnect.maxAttempts, reconnect.delayMs); } }; } /** * Load tools for a specific server */ async _loadToolsForServer(serverName, client) { try { debugLog(`DEBUG: Loading tools for server "${serverName}"...`); const tools = await require_tools.loadMcpTools(serverName, client, this.#loadToolsOptions[serverName]); this.#serverNameToTools[serverName] = tools; debugLog(`INFO: Successfully loaded ${tools.length} tools from server "${serverName}"`); } catch (error) { throw new MCPClientError(`Failed to load tools from server "${serverName}": ${error}`); } } /** * Attempt to reconnect to a server after a connection failure. * * @param serverName - The name of the server to reconnect to * @param connection - The connection configuration * @param maxAttempts - Maximum number of reconnection attempts * @param delayMs - Delay in milliseconds between reconnection attempts * @private */ async _attemptReconnect(serverName, connection, maxAttempts = 3, delayMs = 1e3) { let connected = false; let attempts = 0; if ("headers" in connection || "authProvider" in connection) { const { headers, authProvider } = connection; await this.#cleanupServerResources({ serverName, authProvider, headers }); } else await this.#cleanupServerResources({ serverName }); while (!connected && (maxAttempts === void 0 || attempts < maxAttempts)) { attempts += 1; debugLog(`INFO: Reconnection attempt ${attempts}${maxAttempts ? `/${maxAttempts}` : ""} for server "${serverName}"`); try { if (delayMs) await new Promise((resolve) => { setTimeout(resolve, delayMs); }); if (isResolvedStdioConnection(connection)) await this._initializeStdioConnection(serverName, connection); else if (isResolvedStreamableHTTPConnection(connection)) if (connection.type === "sse" || connection.transport === "sse") await this._initializeSSEConnection(serverName, connection); else await this._initializeStreamableHTTPConnection(serverName, connection); const key = "headers" in connection ? { serverName, headers: connection.headers, authProvider: connection.authProvider } : { serverName }; if (this.#clientConnections.has(key)) { connected = true; debugLog(`INFO: Successfully reconnected to server "${serverName}"`); } } catch (error) { debugLog(`ERROR: Failed to reconnect to server "${serverName}" (attempt ${attempts}): ${error}`); } } if (!connected) debugLog(`ERROR: Failed to reconnect to server "${serverName}" after ${attempts} attempts`); } /** * Clean up resources for a specific server */ async #cleanupServerResources(transportOptions) { const { serverName, authProvider, headers } = transportOptions; delete this.#serverNameToTools[serverName]; await this.#clientConnections.delete({ serverName, authProvider, headers }); } /** * Get all tools from all servers as a flat array. * * @returns A flattened array of all tools */ _getAllToolsAsFlatArray() { const allTools = []; for (const tools of Object.values(this.#serverNameToTools)) allTools.push(...tools); return allTools; } /** * Get tools from specific servers as a flat array. * * @param serverNames - Names of servers to get tools from * @returns A flattened array of tools from the specified servers */ _getToolsFromServers(serverNames) { const allTools = []; for (const serverName of serverNames) { const tools = this.#serverNameToTools[serverName]; if (tools) allTools.push(...tools); } return allTools; } }; //#endregion exports.MultiServerMCPClient = MultiServerMCPClient; //# sourceMappingURL=client.cjs.map