diff --git a/README.md b/README.md index 35dcfc8..87adb2b 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,6 @@ MCP-Framework gives you architecture out of the box, with automatic directory-ba - Easy-to-use base classes for tools, prompts, and resources - Out of the box authentication for SSE endpoints - -## MCP Client - -`MCPClient` is a TypeScript client library designed to connect to an MCP server using various transports (stdio, SSE, HTTP, or WebSockets). It provides a simple, unified API for sending requests and receiving responses, abstracting away the underlying transport details. - ### Purpose - Facilitate communication with an MCP server from your application. diff --git a/package-lock.json b/package-lock.json index 7caa59f..eb81497 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "node": ">=18.19.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "1.8" + "@modelcontextprotocol/sdk": "1.11" } }, "node_modules/@ampproject/remapping": { @@ -1174,9 +1174,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.8.0.tgz", - "integrity": "sha512-e06W7SwrontJDHwCawNO5SGxG+nU9AAx+jpHHZqGl/WrDBdWOpvirC+s58VpJTB5QemI4jTRcjWT4Pt3Q1NPQQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz", + "integrity": "sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==", "license": "MIT", "peer": true, "dependencies": { @@ -1186,7 +1186,7 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", - "pkce-challenge": "^4.1.0", + "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" @@ -5382,9 +5382,9 @@ } }, "node_modules/pkce-challenge": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", - "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index bba8f79..3256aed 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "protocol" ], "peerDependencies": { - "@modelcontextprotocol/sdk": "1.8" + "@modelcontextprotocol/sdk": "1.11" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/src/core/MCPClient.test.ts b/src/core/MCPClient.test.ts index 70d4494..89933ad 100644 --- a/src/core/MCPClient.test.ts +++ b/src/core/MCPClient.test.ts @@ -1,6 +1,4 @@ -import { MCPClient } from './MCPClient'; - -// Import Jest types +import { MCPClient, MCPClientConfig } from './MCPClient'; import { describe, test, expect, jest, beforeEach, afterEach, afterAll } from '@jest/globals'; import { createInterface } from 'readline/promises'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -71,6 +69,7 @@ jest.mock('readline/promises', () => { const mockInterface = { question: jest.fn().mockImplementation(() => Promise.resolve('')), close: jest.fn(), + prompt: jest.fn(), // Added prompt mock }; // Set up the mock responses @@ -199,11 +198,57 @@ describe('MCPClient', () => { }); // Verify SSEClientTransport was created with correct parameters - expect(mockSSETransport).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'http://localhost:3000/', - }) - ); + expect(mockSSETransport).toHaveBeenCalledTimes(1); + const [urlArg, optsUnknown] = mockSSETransport.mock.calls[0] as [URL, any]; + const optionsArg = optsUnknown as any; + expect(urlArg).toBeInstanceOf(URL); + expect((urlArg as URL).href).toBe('http://localhost:3000/'); + expect(optionsArg).toBeUndefined(); + }); + + test('should connect using SSE transport with custom headers', async () => { + // Clear previous mock call data to make indexing predictable + mockSSETransport.mockClear(); + + const client = new MCPClient(); + const headers = { + 'X-Test': 'foo', + Authorization: 'Bearer bar', + }; + + await client.connect({ + transport: 'sse', + url: 'http://localhost:3000/', + headers, + }); + + // Expect the transport constructor to be invoked once + expect(mockSSETransport).toHaveBeenCalledTimes(1); + + const [urlArg, optsUnknown] = mockSSETransport.mock.calls[0] as [URL, any]; + const optionsArg = optsUnknown as any; + expect(urlArg).toBeInstanceOf(URL); + expect((urlArg as URL).href).toBe('http://localhost:3000/'); + + // The options argument should include the forwarded headers in requestInit + expect(optionsArg).toBeDefined(); + expect(optionsArg.requestInit).toBeDefined(); + expect(optionsArg.requestInit.headers).toEqual(headers); + + // eventSourceInit.fetch should attach the same headers plus Accept header + if (optionsArg.eventSourceInit?.fetch) { + // simulate the custom fetch to verify headers merge + const dummyInit: RequestInit = { headers: { Existing: 'true' } }; + // We cannot actually execute fetch here; instead, verify wrapper behaviour + const wrappedFetch = optionsArg.eventSourceInit.fetch as ( + url: URL | RequestInfo, + init?: RequestInit, + ) => Promise; + const mergedInitPromise = wrappedFetch(new URL('http://dummy'), dummyInit); + // Ensure it returns a Promise (we don't await real network) + expect(mergedInitPromise).toBeTruthy(); // Ensure it's not null/undefined + expect(typeof mergedInitPromise.then).toBe('function'); // Check if it's thenable + } }); test('should connect using WebSocket transport', async () => { @@ -314,26 +359,32 @@ describe('MCPClient', () => { // 5. Chat loop tests describe('chatLoop', () => { test('should handle commands until quit', async () => { - // Initialize the mock + const mockNext = jest.fn() + .mockReturnValueOnce(Promise.resolve({ value: 'test command', done: false })) + .mockReturnValueOnce(Promise.resolve({ value: 'quit', done: false })) + .mockReturnValueOnce(Promise.resolve({ value: undefined, done: true })); + + const mockAsyncIterator = jest.fn(() => ({ + next: mockNext, + })); + const mockReadlineInstance = { - question: jest.fn() - .mockImplementationOnce(() => Promise.resolve('test command')) - .mockImplementationOnce(() => Promise.resolve('quit')), + question: jest.fn(), close: jest.fn(), + prompt: jest.fn(), + [Symbol.asyncIterator]: mockAsyncIterator, // Assign the mock function here }; mockCreateInterface.mockReturnValue(mockReadlineInstance); - - // Ensure console.log is spied on - console.log = jest.fn(); - - // Create a client and start the chat loop const client = new MCPClient(); + // Mock connect to avoid actual connection logic if not needed for chatLoop isolated test + client.connect = jest.fn<(config: MCPClientConfig) => Promise>().mockResolvedValue(undefined); await client.chatLoop(); // Verify readline was created and used expect(mockCreateInterface).toHaveBeenCalled(); - expect(mockReadlineInstance.question).toHaveBeenCalledTimes(2); + expect(mockAsyncIterator).toHaveBeenCalledTimes(1); // The async iterator factory was called once + expect(mockNext).toHaveBeenCalledTimes(2); // 'test command', 'quit'. The loop exits before {done: true} is strictly needed by for...of. expect(mockReadlineInstance.close).toHaveBeenCalled(); }); }); @@ -350,4 +401,4 @@ describe('MCPClient', () => { expect(true).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/src/core/MCPClient.ts b/src/core/MCPClient.ts index 0465a05..7e5d710 100644 --- a/src/core/MCPClient.ts +++ b/src/core/MCPClient.ts @@ -1,29 +1,30 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js"; -import readline from "readline/promises"; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import readline from 'readline/promises'; /** * Supported MCPClient configuration types. */ type MCPClientConfig = | { - transport: "stdio"; + transport: 'stdio'; serverScriptPath: string; } | { - transport: "sse"; + transport: 'sse'; url: string; headers?: Record; } | { - transport: "websocket"; + transport: 'websocket'; url: string; - headers?: Record; + // WebSocket transport in the SDK might not directly support custom headers in constructor } | { - transport: "http-stream"; + transport: 'http-stream'; url: string; headers?: Record; }; @@ -33,78 +34,82 @@ type MCPClientConfig = * - stdio (spawns a subprocess) * - SSE (connects to a remote HTTP SSE endpoint) * - WebSocket (connects to a remote WebSocket endpoint) + * - HTTP Stream (connects to a remote HTTP streaming POST endpoint) */ class MCPClient { private mcp: Client; private transport: any = null; - private tools: any[] = []; + private tools: Array<{ name: string; description: string; input_schema: any }> = []; // Typed tools array constructor() { - this.mcp = new Client({ name: "mcp-client-cli", version: "1.0.0" }); + this.mcp = new Client({ name: 'mcp-client-cli', version: '1.0.0' }); } /** * Connect to an MCP server using the specified transport configuration. - * This replaces the old connectToServer() method. */ async connect(config: MCPClientConfig) { - try { - if (config.transport === "stdio") { - // === STDIO TRANSPORT === - // Spawn a subprocess running the server script (JS or Python) - const isJs = config.serverScriptPath.endsWith(".js"); - const isPy = config.serverScriptPath.endsWith(".py"); - if (!isJs && !isPy) { - throw new Error("Server script must be a .js or .py file"); - } - const command = isPy - ? process.platform === "win32" - ? "python" - : "python3" - : process.execPath; - - this.transport = new StdioClientTransport({ - command, - args: [config.serverScriptPath], - }); - } else if (config.transport === "sse") { - // === SSE TRANSPORT === - // Connect to a remote MCP server's SSE endpoint - this.transport = new SSEClientTransport( - new URL(config.url) - ); - } else if (config.transport === "websocket") { - // === WEBSOCKET TRANSPORT === - // Connect to a remote MCP server's WebSocket endpoint - this.transport = new WebSocketClientTransport( - new URL(config.url) - ); - } else if (config.transport === "http-stream") { - // === HTTP STREAM TRANSPORT === - // Connect to a remote MCP server's HTTP streaming POST endpoint - const httpStreamTransport = new (globalThis as any).HttpStreamClientTransport(config.url); - this.transport = httpStreamTransport; - } else { - throw new Error(`Unsupported transport type: ${(config as any).transport}`); + if (config.transport === 'stdio') { + const isJs = config.serverScriptPath.endsWith('.js'); + const isPy = config.serverScriptPath.endsWith('.py'); + if (!isJs && !isPy) { + throw new Error('Server script must be a .js or .py file'); } + const command = isPy + ? process.platform === 'win32' + ? 'python' + : 'python3' + : process.execPath; - // Connect the SDK client with the selected transport - this.mcp.connect(this.transport); - - // Fetch available tools from the server - const toolsResult = await this.mcp.listTools(); - this.tools = toolsResult.tools.map((tool) => { - return { - name: tool.name, - description: tool.description, - input_schema: tool.inputSchema, - }; + this.transport = new StdioClientTransport({ + command, + args: [config.serverScriptPath], }); - // Successfully connected to server - } catch (e) { - // Log error but don't expose internal details - throw e; + } else if (config.transport === 'sse') { + this.transport = new SSEClientTransport( + new URL(config.url), + config.headers + ? { + eventSourceInit: { + fetch: (u, init) => + fetch(u, { + ...init, + headers: { + ...(init?.headers || {}), + ...config.headers, + Accept: 'text/event-stream', + }, + }), + }, + // requestInit might be used by some SDK versions for initial handshake if any, + // but primary header injection for SSE is via eventSourceInit.fetch override. + requestInit: { headers: config.headers }, + } + : undefined + ); + } else if (config.transport === 'websocket') { + // WebSocket constructor in @modelcontextprotocol/sdk typically doesn't take headers. + // Headers are usually set during the WebSocket handshake by the browser/client environment, + // or might require a custom transport if server-side node client needs them for ws library. + this.transport = new WebSocketClientTransport(new URL(config.url)); + } else if (config.transport === 'http-stream') { + this.transport = new StreamableHTTPClientTransport( + new URL(config.url), + config.headers ? { requestInit: { headers: config.headers } } : undefined + ); + } else { + throw new Error(`Unsupported transport type: ${(config as any).transport}`); } + + this.mcp.connect(this.transport); + + const toolsResult = await this.mcp.listTools(); + this.tools = toolsResult.tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? '', // Ensure description is always a string + input_schema: tool.inputSchema, + })); + console.log(`Successfully connected to server. Found ${this.tools.length} tools.`); } async callTool(toolName: string, toolArgs: any) { @@ -114,111 +119,225 @@ class MCPClient { }); } + getTools() { + return this.tools; + } + async chatLoop() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, + prompt: 'mcp> ', }); + console.log('\nMCP Client REPL. Type "help" for commands, "quit" or "exit" to exit.'); + rl.prompt(); + try { - while (true) { - const message = await rl.question("\nCommand: "); - if (message.toLowerCase() === "quit") { - break; - } + for await (const line of rl) { + const [cmd, ...rest] = line.trim().split(/\s+/); - // This is where you would implement your own command handling logic - // Process command here + switch (cmd?.toLowerCase()) { + case 'quit': + case 'exit': + rl.close(); + return; + case 'help': + console.log(` +Available commands: + help - Show this help message. + tools - List available tools from the connected server. + call [jsonArgs] - Call a tool with JSON arguments. + Example: call MyTool {"param1":"value1"} + Example: call NoArgTool + quit / exit - Exit the REPL.`); + break; + case 'tools': { + const tools = this.getTools(); + if (tools.length > 0) { + console.log('Available tools:'); + console.table(tools.map((t) => ({ Name: t.name, Description: t.description }))); + } else { + console.log('No tools available or not connected.'); + } + break; + } + case 'call': { + const [toolName, ...jsonPieces] = rest; + if (!toolName) { + console.error('Error: toolName is required. Usage: call [jsonArgs]'); + break; + } + try { + const argsString = jsonPieces.join(' '); + // Allow empty argsString for tools that take no arguments + const toolArgs = argsString ? JSON.parse(argsString) : {}; + console.log(`Calling tool "${toolName}" with args:`, toolArgs); + const result = await this.callTool(toolName, toolArgs); + console.log('Tool result:'); + console.dir(result, { depth: null, colors: true }); + } catch (err: any) { + console.error(`Error calling tool "${toolName}":`, err.message || err); + if (err instanceof SyntaxError) { + console.error( + 'Hint: Ensure your JSON arguments are correctly formatted, e.g., {"key": "value"}.' + ); + } + } + break; + } + case '': // Handle empty input from just pressing Enter + break; + default: { + if (cmd) { + // Only show unknown if cmd is not empty + console.log(`Unknown command: "${cmd}". Type "help" for available commands.`); + } + } + } + rl.prompt(); } + } catch (error) { + console.error('An unexpected error occurred in the REPL:', error); } finally { - rl.close(); + if (!rl.close) { + rl.close(); + } } } async cleanup() { + console.log('\nCleaning up and disconnecting...'); await this.mcp.close(); - } - - getTools() { - return this.tools; + console.log('Disconnected.'); } } async function main() { - // ================================ - // MCP Client CLI Argument Parsing - // ================================ - - // Extract CLI args (skip 'node' and script path) const args = process.argv.slice(2); + const argMap: Record = {}; // Allow boolean for flags like --help + const headers: Record = {}; + + function printUsageAndExit(exitCode = 1) { + console.log(` +Usage: mcp-client --transport [options] - // Simple manual argument parsing - const argMap: Record = {}; +Transports and their specific options: + --transport stdio --script + Connects to a local MCP server script via standard input/output. + + --transport sse --url + Connects to an MCP server via Server-Sent Events (SSE). + + --transport websocket --url + Connects to an MCP server via WebSockets. + + --transport http-stream --url + Connects to an MCP server via HTTP Streaming. + +Optional flags (for sse and http-stream transports): + --header + Adds an HTTP header to the request. Can be specified multiple times. + Example: --header X-Auth-Token=mysecret --header Trace=1 + +General options: + --help + Show this usage information. +`); + process.exit(exitCode); + } for (let i = 0; i < args.length; i++) { - if (args[i].startsWith("--")) { - const key = args[i].substring(2); - const value = args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : undefined; - argMap[key] = value; - if (value) i++; // Skip next since it's a value + const currentArg = args[i]; + if (currentArg === '--help') { + printUsageAndExit(0); + } else if (currentArg === '--header') { + i++; // Move to the value part of --header + const pair = args[i] ?? ''; + const [k, v] = pair.split('='); + if (!k || v === undefined) { + console.error( + 'Error: Header syntax must be key=value (e.g., --header X-Auth-Token=secret)' + ); + printUsageAndExit(); + } + headers[k] = v; + } else if (currentArg.startsWith('--')) { + const key = currentArg.substring(2); + // Check if next arg is a value or another flag + if (args[i + 1] && !args[i + 1].startsWith('--')) { + argMap[key] = args[i + 1]; + i++; // Skip next arg as it's a value + } else { + argMap[key] = true; // Treat as a boolean flag if no value follows + } + } else { + // Positional arguments not expected here, or handle them if your CLI design changes + console.error(`Error: Unexpected argument '${currentArg}'`); + printUsageAndExit(); } } - const transport = argMap["transport"]; - const script = argMap["script"]; - const url = argMap["url"]; + const transport = argMap['transport'] as string | undefined; + const script = argMap['script'] as string | undefined; + const url = argMap['url'] as string | undefined; - // Print usage instructions - function printUsageAndExit() { - // Print usage instructions for CLI mode - process.exit(1); - } - - // Validate required args - if (!transport || !["stdio", "sse", "websocket"].includes(transport)) { + if (!transport || !['stdio', 'sse', 'websocket', 'http-stream'].includes(transport)) { + console.error('Error: Missing or invalid --transport specified.'); printUsageAndExit(); } - if (transport === "stdio" && !script) { + if (transport === 'stdio' && !script) { + console.error('Error: --script is required for stdio transport.'); printUsageAndExit(); } - if ((transport === "sse" || transport === "websocket") && !url) { + if ((transport === 'sse' || transport === 'websocket' || transport === 'http-stream') && !url) { + console.error('Error: --url is required for sse, websocket, or http-stream transport.'); printUsageAndExit(); } - // Build MCPClientConfig based on args let config: MCPClientConfig; - if (transport === "stdio") { - config = { - transport: "stdio", - serverScriptPath: script!, - }; - } else if (transport === "sse") { - config = { - transport: "sse", - url: url!, - }; + const effectiveHeaders = Object.keys(headers).length > 0 ? headers : undefined; + + if (transport === 'stdio') { + config = { transport: 'stdio', serverScriptPath: script! }; + } else if (transport === 'sse') { + config = { transport: 'sse', url: url!, headers: effectiveHeaders }; + } else if (transport === 'websocket') { + // Note: WebSocket headers are typically not passed this way via constructor + config = { transport: 'websocket', url: url! }; } else { - config = { - transport: "websocket", - url: url!, - }; + // http-stream + config = { transport: 'http-stream', url: url!, headers: effectiveHeaders }; } const mcpClient = new MCPClient(); try { await mcpClient.connect(config); await mcpClient.chatLoop(); + } catch (error: any) { + console.error(`\nFatal error during MCPClient operation: ${error.message || error}`); + // console.error(error.stack); // Uncomment for more detailed stack trace } finally { await mcpClient.cleanup(); - process.exit(0); + process.exit(0); // Ensure clean exit } } -export { MCPClient }; - -if (require.main === module) { - main(); - +// Entry point if script is run directly +if ( + require.main === module || + (process.argv[1] && + (process.argv[1].endsWith('mcp-client') || + process.argv[1].endsWith('MCPClient.js') || + process.argv[1].endsWith('MCPClient.ts'))) +) { + main().catch((err) => { + // This catch is for unhandled promise rejections from main() itself, though inner try/catch should handle most. + console.error('Unhandled error in main execution:', err); + process.exit(1); + }); } + +export { MCPClient, MCPClientConfig }; diff --git a/src/index.ts b/src/index.ts index 09fb882..75a6116 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from "./core/MCPServer.js"; export * from "./core/MCPServer.js"; export * from "./core/Logger.js"; export { MCPClient } from "./core/MCPClient.js"; +export { MCPClientConfig } from "./core/MCPClient.js"; export * from "./tools/BaseTool.js"; export * from "./resources/BaseResource.js"; diff --git a/src/transports/websockets/server.ts b/src/transports/websockets/server.ts index 06836ac..6ff94f7 100644 --- a/src/transports/websockets/server.ts +++ b/src/transports/websockets/server.ts @@ -52,7 +52,6 @@ export class WebSocketServerTransport extends AbstractTransport { } this._wss!.handleUpgrade(request, socket, head, (ws: WebSocket) => { - ws.protocol = "mcp"; this._wss!.emit("connection", ws, request); }); });