/**
* MCP Test Client
*
* A test utility for communicating with MCP servers via stdio transport.
* Provides methods for testing MCP protocol compliance and tool/prompt functionality.
*/
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
/**
* JSON-RPC 2.0 message types
*/
export interface JsonRpcRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: any;
}
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number;
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
}
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: any;
}
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
/**
* MCP Test Client for stdio communication
*/
export class MCPTestClient extends EventEmitter {
private process: ChildProcess | null = null;
private requestId = 0;
private pendingRequests = new Map<string | number, {
resolve: (value: any) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}>();
private buffer = '';
private isReady = false;
/**
* Connect to the MCP server
*/
async connect(serverPath: string, args: string[] = []): Promise<void> {
return new Promise((resolve, reject) => {
// Spawn the server process
this.process = spawn('node', [serverPath, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
ASK_ME_MCP_DEBUG: '1' // Enable debug logging for tests
}
});
if (!this.process.stdin || !this.process.stdout || !this.process.stderr) {
reject(new Error('Failed to create stdio streams'));
return;
}
// Handle process errors
this.process.on('error', (error) => {
this.emit('error', error);
reject(error);
});
this.process.on('exit', (code, signal) => {
this.emit('exit', code, signal);
this.cleanup();
});
// Setup stderr logging (MCP servers log to stderr)
this.process.stderr.on('data', (data) => {
const message = data.toString();
this.emit('serverLog', message);
// Server is ready when it logs "Server ready for connections"
if (message.includes('Server ready for connections')) {
this.isReady = true;
resolve();
}
});
// Setup stdout message handling (JSON-RPC messages)
this.process.stdout.on('data', (data) => {
this.handleIncomingData(data);
});
// Set a timeout for connection
const connectionTimeout = setTimeout(() => {
if (!this.isReady) {
reject(new Error('Server failed to start within timeout'));
}
}, 30000); // 30 second timeout
this.once('exit', () => clearTimeout(connectionTimeout));
});
}
/**
* Send a JSON-RPC request and wait for response
*/
async request(method: string, params?: any, timeout = 5000): Promise<any> {
if (!this.process || !this.process.stdin) {
throw new Error('Client not connected');
}
const id = ++this.requestId;
const message: JsonRpcRequest = {
jsonrpc: '2.0',
id,
method,
params
};
return new Promise((resolve, reject) => {
// Set up timeout
const timeoutHandle = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout: ${method}`));
}, timeout);
// Store pending request
this.pendingRequests.set(id, {
resolve: (result) => {
clearTimeout(timeoutHandle);
resolve(result);
},
reject: (error) => {
clearTimeout(timeoutHandle);
reject(error);
},
timeout: timeoutHandle
});
// Send the message
const messageStr = JSON.stringify(message) + '\n';
this.process!.stdin!.write(messageStr);
});
}
/**
* Send a JSON-RPC notification (no response expected)
*/
notify(method: string, params?: any): void {
if (!this.process || !this.process.stdin) {
throw new Error('Client not connected');
}
const message: JsonRpcNotification = {
jsonrpc: '2.0',
method,
params
};
const messageStr = JSON.stringify(message) + '\n';
this.process.stdin.write(messageStr);
}
/**
* Initialize MCP connection (handshake)
*/
async initialize(clientInfo = { name: 'test-client', version: '1.0.0' }): Promise<any> {
return this.request('initialize', {
protocolVersion: '2024-11-05',
capabilities: {
tools: {
call: true
},
prompts: {
get: true
}
},
clientInfo
});
}
/**
* List available tools
*/
async listTools(): Promise<any> {
return this.request('tools/list');
}
/**
* Call a tool
*/
async callTool(name: string, arguments_: any = {}): Promise<any> {
return this.request('tools/call', {
name,
arguments: arguments_
});
}
/**
* List available prompts
*/
async listPrompts(): Promise<any> {
return this.request('prompts/list');
}
/**
* Get a prompt
*/
async getPrompt(name: string, arguments_: any = {}): Promise<any> {
return this.request('prompts/get', {
name,
arguments: arguments_
});
}
/**
* Disconnect from the server
*/
async disconnect(): Promise<void> {
if (this.process) {
this.process.kill('SIGTERM');
// Wait for process to exit
return new Promise((resolve) => {
const timeout = setTimeout(() => {
if (this.process) {
this.process.kill('SIGKILL');
}
resolve();
}, 5000);
this.process!.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
}
}
/**
* Get server process information
*/
getProcessInfo(): { pid?: number; connected: boolean } {
return {
pid: this.process?.pid,
connected: this.process !== null && !this.process.killed
};
}
/**
* Handle incoming data from stdout
*/
private handleIncomingData(data: Buffer): void {
this.buffer += data.toString();
// Process complete JSON-RPC messages (line-delimited)
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
try {
const message: JsonRpcMessage = JSON.parse(line);
this.handleMessage(message);
} catch (error) {
this.emit('parseError', error, line);
}
}
}
}
/**
* Handle parsed JSON-RPC message
*/
private handleMessage(message: JsonRpcMessage): void {
if ('id' in message) {
// Response message
const response = message as JsonRpcResponse;
const pending = this.pendingRequests.get(response.id);
if (pending) {
this.pendingRequests.delete(response.id);
if (response.error) {
const error = new Error(response.error.message);
(error as any).code = response.error.code;
(error as any).data = response.error.data;
pending.reject(error);
} else {
pending.resolve(response.result);
}
}
} else {
// Notification message
this.emit('notification', message);
}
}
/**
* Clean up resources
*/
private cleanup(): void {
// Reject all pending requests
for (const [id, pending] of this.pendingRequests) {
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
this.process = null;
this.isReady = false;
}
}
/**
* Create and configure an MCP test client
*/
export async function createMCPTestClient(serverPath: string, port?: number): Promise<MCPTestClient> {
const client = new MCPTestClient();
const args = port ? ['--port', port.toString()] : [];
await client.connect(serverPath, args);
await client.initialize();
return client;
}