import { spawn, ChildProcess } from 'child_process'; import { join } from 'path'; import { existsSync } from 'fs'; import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCPResponse, MCPSuccessResponse, MCPErrorResponse } from '../../../domain/mcp-types'; import { testConfig } from '../setup'; /** * Utility class for testing MCP tools through the actual MCP interface */ export class MCPToolTestUtils { private static serverPath = join(process.cwd(), 'build/index.js'); private serverProcess: ChildProcess | null = null; private messageId = 1; constructor() { // Ensure build exists if (!existsSync(MCPToolTestUtils.serverPath)) { throw new Error('Server build not found. Run `npm run build` first.'); } } /** * Start the MCP server process */ async startServer(envOverrides: Record = {}): Promise { if (this.serverProcess) { throw new Error('Server is already running'); } const env = { ...process.env, ...envOverrides, // Ensure we have required environment variables GITHUB_TOKEN: process.env.GITHUB_TOKEN || 'test-token', GITHUB_OWNER: process.env.GITHUB_OWNER || 'test-owner', GITHUB_REPO: process.env.GITHUB_REPO || 'test-repo', }; this.serverProcess = spawn('node', [MCPToolTestUtils.serverPath], { env, stdio: ['pipe', 'pipe', 'pipe'] }); // Wait for server to start await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Server startup timeout')); }, 10000); this.serverProcess!.on('spawn', () => { clearTimeout(timeout); resolve(void 0); }); this.serverProcess!.on('error', (error) => { clearTimeout(timeout); reject(error); }); }); // Initialize the MCP connection await this.initializeConnection(); } /** * Stop the MCP server process */ async stopServer(): Promise { if (this.serverProcess) { this.serverProcess.kill('SIGTERM'); // Wait for process to exit await new Promise((resolve) => { const timeout = setTimeout(() => { if (this.serverProcess && !this.serverProcess.killed) { this.serverProcess.kill('SIGKILL'); } resolve(); }, 5000); this.serverProcess!.on('exit', () => { clearTimeout(timeout); resolve(); }); }); this.serverProcess = null; } } /** * Initialize MCP connection */ private async initializeConnection(): Promise { const initRequest = { jsonrpc: "2.0", id: this.messageId++, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "e2e-test", version: "1.0.0" } } }; await this.sendMessage(initRequest); } /** * Send a message to the MCP server and wait for response */ private async sendMessage(message: any): Promise { if (!this.serverProcess) { throw new Error('Server is not running'); } return new Promise((resolve, reject) => { let responseData = ''; let errorData = ''; const timeout = setTimeout(() => { reject(new Error('Message timeout')); }, 30000); const onData = (data: Buffer) => { responseData += data.toString(); // Try to parse JSON response const lines = responseData.split('\n').filter(line => line.trim()); for (const line of lines) { try { const response = JSON.parse(line); if (response.id === message.id) { clearTimeout(timeout); this.serverProcess!.stdout!.off('data', onData); this.serverProcess!.stderr!.off('data', onError); resolve(response); return; } } catch (e) { // Continue parsing other lines } } }; const onError = (data: Buffer) => { errorData += data.toString(); }; this.serverProcess!.stdout!.on('data', onData); this.serverProcess!.stderr!.on('data', onError); // Send the message this.serverProcess!.stdin!.write(JSON.stringify(message) + '\n'); }); } /** * List all available tools */ async listTools(): Promise { const request = { jsonrpc: "2.0", id: this.messageId++, method: "tools/list", params: {} }; const response = await this.sendMessage(request); if (response.error) { throw new Error(`Failed to list tools: ${response.error.message}`); } return response.result?.tools || []; } /** * Call a specific tool with arguments */ async callTool(toolName: string, args: any): Promise { const request = { jsonrpc: "2.0", id: this.messageId++, method: "tools/call", params: { name: toolName, arguments: args } }; const response = await this.sendMessage(request); if (response.error) { throw new Error(`Tool ${toolName} failed: ${response.error.message}`); } return response.result; } /** * Test if a tool exists and has the expected schema */ async validateToolExists(toolName: string): Promise { const tools = await this.listTools(); const tool = tools.find(t => t.name === toolName); if (!tool) { return false; } // Validate tool has required properties return !!(tool.name && tool.description && tool.inputSchema); } /** * Test tool with invalid arguments to verify validation */ async testToolValidation(toolName: string, invalidArgs: any): Promise<{ hasValidation: boolean; errorMessage?: string }> { try { await this.callTool(toolName, invalidArgs); return { hasValidation: false }; } catch (error) { return { hasValidation: true, errorMessage: error instanceof Error ? error.message : String(error) }; } } /** * Extract content from MCP response */ static extractContent(response: any): string { // Handle different response formats if (typeof response === 'string') { try { const parsed = JSON.parse(response); return MCPToolTestUtils.extractContent(parsed); } catch { return response; } } // Handle MCP tool response format if (response.output) { if (typeof response.output === 'string') { try { const parsed = JSON.parse(response.output); if (parsed.content) { return typeof parsed.content === 'string' ? parsed.content : JSON.stringify(parsed.content); } return response.output; } catch { return response.output; } } return JSON.stringify(response.output); } // Handle direct content if (response.content) { return typeof response.content === 'string' ? response.content : JSON.stringify(response.content); } return JSON.stringify(response); } /** * Check if we have real (not fake/test) credentials */ static hasRealCredentials(testType: 'github' | 'ai' | 'both'): boolean { const token = process.env.GITHUB_TOKEN; const owner = process.env.GITHUB_OWNER; const repo = process.env.GITHUB_REPO; const anthropicKey = process.env.ANTHROPIC_API_KEY; const openaiKey = process.env.OPENAI_API_KEY; // Check for fake test values from setup.ts const hasRealGitHub = !!( token && token !== 'test-token' && token !== '' && owner && owner !== 'test-owner' && repo && repo !== 'test-repo' ); const hasRealAI = !!( (anthropicKey && anthropicKey !== 'sk-ant-test-key' && !anthropicKey.startsWith('sk-ant-test')) || (openaiKey && openaiKey !== 'sk-test-openai-key' && !openaiKey.startsWith('sk-test')) ); switch (testType) { case 'github': return hasRealGitHub; case 'ai': return hasRealAI; case 'both': return hasRealGitHub && hasRealAI; default: return false; } } /** * Check if we should skip tests based on credentials */ static shouldSkipTest(testType: 'github' | 'ai' | 'both'): boolean { // Skip if we don't have real credentials for this test type return !MCPToolTestUtils.hasRealCredentials(testType); } /** * Create a test suite wrapper that handles server lifecycle * * Tests are always registered but will skip gracefully when credentials * are missing. Individual tests should check `if (!utils)` and return early. */ static createTestSuite(suiteName: string, testType: 'github' | 'ai' | 'both' = 'github') { return (tests: (utils: MCPToolTestUtils | undefined) => void) => { describe(suiteName, () => { let utils: MCPToolTestUtils | undefined; beforeAll(async () => { if (MCPToolTestUtils.shouldSkipTest(testType)) { console.log(`Skipping ${suiteName} - missing real credentials for ${testType} tests`); return; } utils = new MCPToolTestUtils(); await utils.startServer(); }, 30000); afterAll(async () => { if (utils) { await utils.stopServer(); } }, 10000); // Always register tests - they will skip inside via the `if (!utils)` guard tests(utils); }); }; } } /** * Helper functions for common test patterns */ export const MCPTestHelpers = { /** * Validate that a tool response has the expected structure */ validateToolResponse(response: any, expectedFields: string[] = []): void { expect(response).toBeDefined(); // Handle different response formats let actualResponse = response; // If response has output property, extract it if (response.output && typeof response.output === 'string') { try { actualResponse = JSON.parse(response.output); } catch { // If parsing fails, use the output string directly actualResponse = response.output; } } // Validate expected fields for (const field of expectedFields) { expect(actualResponse).toHaveProperty(field); } }, /** * Create test data for GitHub resources */ createTestData: { project: (overrides: any = {}) => ({ title: `Test Project ${Date.now()}`, shortDescription: "E2E test project", owner: process.env.GITHUB_OWNER || "test-owner", visibility: "private" as const, ...overrides }), milestone: (overrides: any = {}) => ({ title: `Test Milestone ${Date.now()}`, description: "E2E test milestone", dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), ...overrides }), issue: (overrides: any = {}) => ({ title: `Test Issue ${Date.now()}`, description: "E2E test issue", assignees: [], labels: [], ...overrides }), sprint: (overrides: any = {}) => ({ title: `Test Sprint ${Date.now()}`, description: "E2E test sprint", startDate: new Date().toISOString(), endDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), goals: ["Complete E2E testing"], ...overrides }) }, /** * Wait for a condition to be true */ async waitFor(condition: () => Promise, timeout = 10000, interval = 1000): Promise { const start = Date.now(); while (Date.now() - start < timeout) { if (await condition()) { return; } await new Promise(resolve => setTimeout(resolve, interval)); } throw new Error(`Condition not met within ${timeout}ms`); }, /** * Skip test if required credentials are missing * Note: Returns a boolean - caller should use console.log + return pattern */ skipIfMissingCredentials(testType: 'github' | 'ai' | 'both', testName: string): boolean { if (MCPToolTestUtils.shouldSkipTest(testType)) { console.log(`Skipping: ${testName} - missing credentials for ${testType} tests`); return true; } return false; }, /** * Check if a test should be skipped and return appropriate action */ checkCredentials(testType: 'github' | 'ai' | 'both'): { shouldSkip: boolean; reason?: string } { const shouldSkip = MCPToolTestUtils.shouldSkipTest(testType); return { shouldSkip, reason: shouldSkip ? `Missing credentials for ${testType} tests` : undefined }; } };