UNPKG

@agentforce/adk

Version:

AgentForce Agent Development Kit - A powerful framework for building AI agents and servers

437 lines (386 loc) 17.9 kB
import type { MCPClient, MCPServerConfig, MCPTool, MCPResource, MCPPrompt } from "../types"; import { Client as DefaultClient } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport as DefaultStdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { SSEClientTransport as DefaultSSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport as DefaultStreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; /** * Node.js implementation of MCP client using the official Anthropic SDK */ export class MCPNodeClient implements MCPClient { public readonly name: string; public readonly config: MCPServerConfig; private isConnectedState: boolean = false; private client?: any; private transport?: any; // Allow injecting dependencies for easier testing constructor( config: MCPServerConfig, private ClientImpl: typeof DefaultClient = DefaultClient, private StdioClientTransportImpl: typeof DefaultStdioClientTransport = DefaultStdioClientTransport, private SSEClientTransportImpl: typeof DefaultSSEClientTransport = DefaultSSEClientTransport, private StreamableHTTPClientTransportImpl: typeof DefaultStreamableHTTPClientTransport = DefaultStreamableHTTPClientTransport, ) { this.name = config.name; this.config = config; } get isConnected(): boolean { return this.isConnectedState; } /** * Connect to the MCP server using the official Anthropic SDK */ async connect(): Promise<void> { try { // Note: No logger available here during connection, using console.log console.log(`[MCP] Connecting to ${this.name} server...`); // Initialize the MCP client this.client = new this.ClientImpl({ name: "agentforce-adk", version: "0.11.0", }, { capabilities: { tools: {}, resources: {}, prompts: {}, }, }); // Determine transport type based on config if (this.isHttpTransport()) { // HTTP/SSE transport for remote servers await this.connectHttp(); } else { // Stdio transport for local command-based servers await this.connectStdio(); } this.isConnectedState = true; console.log(`[MCP] Connected to ${this.name} server`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to connect to MCP server ${this.name}: ${errorMessage}`); } } /** * Connect using HTTP/SSE transport for remote servers */ private async connectHttp(): Promise<void> { // Get server URL from config.url or config.env.MCP_SERVER_URL const serverUrl = this.config.url || this.config.env?.MCP_SERVER_URL; if (!serverUrl) { throw new Error(`HTTP transport requires url property or MCP_SERVER_URL in env config for ${this.name}`); } // Get transport type from config.type or config.env.MCP_TRANSPORT_TYPE, default to sse const transportType = this.config.type === "http" ? "http-stream" : this.config.env?.MCP_TRANSPORT_TYPE || "sse"; if (transportType === "http-stream" || transportType === "http") { // Use StreamableHTTPClientTransport for HTTP streaming this.transport = new this.StreamableHTTPClientTransportImpl( new URL(serverUrl), { requestInit: { headers: { Authorization: this.config.env?.AUTHORIZATION || "", ...this.getCustomHeaders(), }, }, }, ); } else { // Use SSE transport (default) this.transport = new this.SSEClientTransportImpl( new URL(serverUrl) ); } await this.client!.connect(this.transport); } /** * Connect using stdio transport for local command-based servers */ private async connectStdio(): Promise<void> { if (!this.config.command) { throw new Error(`Local transport requires command property for ${this.name}`); } // Create stdio transport - it will handle process spawning internally const env: Record<string, string> = {}; // Add process environment variables for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { env[key] = value; } } // Add config environment variables if (this.config.env) { for (const [key, value] of Object.entries(this.config.env)) { if (value) { env[key] = value; } } } this.transport = new this.StdioClientTransportImpl({ command: this.config.command, args: this.config.args || [], env, }); // Connect with timeout const timeout = this.config.timeout || 10000; const connectPromise = this.client!.connect(this.transport); const timeoutPromise = new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error("Connection timeout")), timeout); timer.unref(); // Don't keep the event loop alive }); await Promise.race([connectPromise, timeoutPromise]); } /** * Check if this config uses HTTP transport */ private isHttpTransport(): boolean { return this.config.type === "sse" || this.config.type === "http" || !!this.config.url || !!this.config.env?.MCP_SERVER_URL; } /** * Get custom headers from config */ private getCustomHeaders(): Record<string, string> { const headers: Record<string, string> = {}; // Extract custom headers from env (prefixed with MCP_HEADER_) if (this.config.env) { for (const [key, value] of Object.entries(this.config.env)) { if (key.startsWith("MCP_HEADER_") && value) { const headerName = key.replace("MCP_HEADER_", "").replace(/_/g, "-"); headers[headerName] = value; } } } return headers; } /** * Disconnect from the MCP server */ async disconnect(logger?: any): Promise<void> { try { if (this.client) { if (logger) { logger.debug(`[MCP] Closing client connection for ${this.name}`); } await this.client.close(); this.client = undefined; } if (this.transport) { if (logger) { logger.debug(`[MCP] Closing transport for ${this.name}`); } // Add timeout to transport close to prevent hanging const closePromise = this.transport.close?.(); if (closePromise) { const timeoutPromise = new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error("Transport close timeout")), 3000); timer.unref(); // Don't keep the event loop alive }); try { await Promise.race([closePromise, timeoutPromise]); if (logger) { logger.debug(`[MCP] Transport closed successfully for ${this.name}`); } } catch (error) { // Log timeout but don't throw - we still want to mark as disconnected if (logger) { logger.debug(`[MCP] Transport close timeout for ${this.name}: ${error}`); } else { console.warn(`[MCP] Transport close timeout for ${this.name}: ${error}`); } // Force close the transport if it has a process if (this.transport && typeof this.transport.close === "function") { try { // Try to access the underlying process and kill it const process = (this.transport as any).process || (this.transport as any)._process; if (process && typeof process.kill === "function") { if (logger) { logger.debug(`[MCP] Force killing transport process for ${this.name}`); } else { console.warn(`[MCP] Force killing transport process for ${this.name}`); } process.kill("SIGTERM"); // Give it a moment then force kill, but don't keep the event loop alive const killTimer = setTimeout(() => { if (!process.killed) { process.kill("SIGKILL"); if (logger) { logger.debug(`[MCP] Force killed transport process with SIGKILL for ${this.name}`); } } }, 1000); killTimer.unref(); // Don't keep the event loop alive } } catch (killError) { if (logger) { logger.debug(`[MCP] Failed to force kill process for ${this.name}: ${killError}`); } else { console.warn(`[MCP] Failed to force kill process for ${this.name}: ${killError}`); } } } } } this.transport = undefined; } this.isConnectedState = false; if (logger) { logger.debug(`[MCP] Disconnected from ${this.name} server`); } else { console.log(`[MCP] Disconnected from ${this.name} server`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (logger) { logger.error(`[MCP] Error disconnecting from ${this.name}: ${errorMessage}`); } else { console.error(`[MCP] Error disconnecting from ${this.name}: ${errorMessage}`); } } } /** * List available tools from the MCP server */ async listTools(): Promise<MCPTool[]> { if (!this.isConnectedState || !this.client) { throw new Error(`MCP client ${this.name} is not connected`); } try { const response = await this.client.listTools(); return response.tools.map((tool: any) => ({ name: tool.name, description: tool.description || "", inputSchema: tool.inputSchema as { type: "object"; properties: Record<string, any>; required?: string[]; }, })); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to list tools from MCP server ${this.name}: ${errorMessage}`); } } /** * Call a tool on the MCP server */ async callTool(name: string, arguments_: Record<string, any>): Promise<any> { if (!this.isConnectedState || !this.client) { throw new Error(`MCP client ${this.name} is not connected`); } try { console.log(`[MCP] Calling tool ${name} on server ${this.name}`); console.log("[MCP] Arguments:", arguments_); const response = await this.client.callTool({ name, arguments: arguments_, }); return response; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to call tool ${name} on MCP server ${this.name}: ${errorMessage}`); } } /** * List available resources from the MCP server */ async listResources(): Promise<MCPResource[]> { if (!this.isConnectedState || !this.client) { throw new Error(`MCP client ${this.name} is not connected`); } try { const response = await this.client.listResources(); return response.resources.map((resource: any) => ({ uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType, })); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to list resources from MCP server ${this.name}: ${errorMessage}`); } } /** * Read a resource from the MCP server */ async readResource(uri: string): Promise<{ contents: Array<{ type: string; text?: string; data?: string }> }> { if (!this.isConnectedState || !this.client) { throw new Error(`MCP client ${this.name} is not connected`); } try { console.log(`[MCP] Reading resource ${uri} from server ${this.name}`); const response = await this.client.readResource({ uri }); return { contents: response.contents.map((content: any) => ({ type: String(content.type), text: content.type === "text" ? (content as any).text : undefined, data: content.type === "blob" ? (content as any).data : undefined, })), }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read resource ${uri} from MCP server ${this.name}: ${errorMessage}`); } } /** * List available prompts from the MCP server */ async listPrompts(): Promise<MCPPrompt[]> { if (!this.isConnectedState || !this.client) { throw new Error(`MCP client ${this.name} is not connected`); } try { const response = await this.client.listPrompts(); return response.prompts.map((prompt: any) => ({ name: prompt.name, description: prompt.description || "", arguments: prompt.arguments ? { type: "object" as const, properties: prompt.arguments.reduce((acc: Record<string, any>, arg: any) => { acc[arg.name] = { type: "string", description: arg.description, }; return acc; }, {} as Record<string, any>), required: prompt.arguments.filter((arg: any) => arg.required).map((arg: any) => arg.name), } : undefined, })); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to list prompts from MCP server ${this.name}: ${errorMessage}`); } } /** * Get a prompt from the MCP server */ async getPrompt(name: string, arguments_?: Record<string, any>): Promise<{ description?: string; messages: Array<{ role: string; content: { type: string; text: string } }> }> { if (!this.isConnectedState || !this.client) { throw new Error(`MCP client ${this.name} is not connected`); } try { console.log(`[MCP] Getting prompt ${name} from server ${this.name}`); if (arguments_) { console.log("[MCP] Arguments:", arguments_); } const response = await this.client.getPrompt({ name, arguments: arguments_, }); return { description: response.description, messages: response.messages.map((message: any) => ({ role: message.role, content: { type: "text", text: Array.isArray(message.content) ? message.content.map((c: any) => (c as any).text || "").join("") : (message.content as any)?.text || "", }, })), }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to get prompt ${name} from MCP server ${this.name}: ${errorMessage}`); } } }