Skip to main content
Glama

mcp-github-project-manager

MCPToolTestUtils.ts11.5 kB
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<string, string> = {}): Promise<void> { 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<void> { if (this.serverProcess) { this.serverProcess.kill('SIGTERM'); // Wait for process to exit await new Promise<void>((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<void> { 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<any> { 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<any[]> { 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<any> { 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<boolean> { 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 should skip tests based on credentials */ static shouldSkipTest(testType: 'github' | 'ai' | 'both'): boolean { return testConfig.skipIfNoCredentials(testType); } /** * Create a test suite wrapper that handles server lifecycle */ static createTestSuite(suiteName: string, testType: 'github' | 'ai' | 'both' = 'github') { return (tests: (utils: MCPToolTestUtils) => void) => { describe(suiteName, () => { let utils: MCPToolTestUtils | undefined; beforeAll(async () => { if (MCPToolTestUtils.shouldSkipTest(testType)) { console.log(`⏭️ Skipping ${suiteName} - missing credentials for ${testType} tests`); return; } utils = new MCPToolTestUtils(); await utils.startServer(); }, 30000); afterAll(async () => { if (utils) { await utils.stopServer(); } }, 10000); beforeEach(() => { if (MCPToolTestUtils.shouldSkipTest(testType)) { test.skip(`Skipping test - missing credentials for ${testType} tests`, () => {}); } }); if (!MCPToolTestUtils.shouldSkipTest(testType)) { tests(utils as MCPToolTestUtils); } }); }; } } /** * 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<boolean>, timeout = 10000, interval = 1000): Promise<void> { 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 */ skipIfMissingCredentials(testType: 'github' | 'ai' | 'both', testName: string): void { if (MCPToolTestUtils.shouldSkipTest(testType)) { test.skip(`${testName} - missing credentials for ${testType} tests`, () => {}); return; } }, /** * 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 }; } };

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kunwarVivek/mcp-github-project-manager'

If you have feedback or need assistance with the MCP directory API, please join our Discord server