Skip to main content
Glama

Basecamp MCP Server

by jhliberty
cli-server.test.ts18 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn, ChildProcess } from 'child_process'; import { mockEnvironment, mockOAuthToken } from './utils.js'; // Mock child_process and other dependencies vi.mock('child_process'); vi.mock('../lib/token-storage.js', () => ({ getToken: vi.fn(), isTokenExpired: vi.fn() })); const mockSpawn = vi.mocked(spawn); describe('CLI MCP Server', () => { let mockProcess: { stdin: { write: vi.Mock; end: vi.Mock }; stdout: { on: vi.Mock; pipe: vi.Mock }; stderr: { on: vi.Mock }; on: vi.Mock; kill: vi.Mock; pid: number; }; beforeEach(() => { vi.clearAllMocks(); mockEnvironment(); // Mock child process mockProcess = { stdin: { write: vi.fn(), end: vi.fn() }, stdout: { on: vi.fn(), pipe: vi.fn() }, stderr: { on: vi.fn() }, on: vi.fn(), kill: vi.fn(), pid: 12345 }; mockSpawn.mockReturnValue(mockProcess as any); }); afterEach(() => { vi.resetAllMocks(); }); describe('Server Initialization', () => { it('should initialize the CLI server process', () => { const serverPath = 'src/index.ts'; spawn('tsx', [serverPath], { stdio: ['pipe', 'pipe', 'pipe'] }); expect(mockSpawn).toHaveBeenCalledWith('tsx', [serverPath], { stdio: ['pipe', 'pipe', 'pipe'] }); }); it('should handle server startup errors', () => { spawn('tsx', ['src/index.ts']); // Simulate setting up error handler const errorHandler = vi.fn(); mockProcess.on('error', errorHandler); // Simulate error const error = new Error('Failed to start server'); errorHandler(error); expect(mockProcess.on).toHaveBeenCalledWith('error', expect.any(Function)); }); }); describe('MCP Protocol Communication', () => { it('should respond to initialize request', async () => { const initRequest = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } } }; const expectedResponse = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'basecamp-mcp-server', version: '1.0.0' } } }; // Mock stdout response mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { callback(JSON.stringify(expectedResponse) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); // Send initialize request mockProcess.stdin.write(JSON.stringify(initRequest) + '\n'); // Wait for response await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(initRequest) + '\n'); }); it('should handle tools/list request', async () => { const toolsRequest = { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }; const expectedTools = [ 'get_projects', 'search_basecamp', 'get_todos', 'global_search', 'create_card', 'get_card_table' ]; const expectedResponse = { jsonrpc: '2.0', id: 2, result: { tools: expectedTools.map(name => ({ name, description: expect.any(String), inputSchema: expect.any(Object) })) } }; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { const response = { jsonrpc: '2.0', id: 2, result: { tools: expectedTools.map(name => ({ name, description: `${name} tool`, inputSchema: { type: 'object', properties: {} } })) } }; callback(JSON.stringify(response) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(toolsRequest) + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(toolsRequest) + '\n'); }); it('should handle tools/call request', async () => { const toolCallRequest = { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'get_projects', arguments: {} } }; const expectedResponse = { jsonrpc: '2.0', id: 3, result: { content: [{ type: 'text', text: JSON.stringify({ status: 'success', projects: [] }) }] } }; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { callback(JSON.stringify(expectedResponse) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(toolCallRequest) + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(toolCallRequest) + '\n'); }); }); describe('Error Handling', () => { it('should handle invalid JSON input', async () => { const invalidJson = 'not valid json'; mockProcess.stderr.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { callback('Invalid JSON input\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(invalidJson + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(invalidJson + '\n'); }); it('should handle unknown method calls', async () => { const unknownMethodRequest = { jsonrpc: '2.0', id: 4, method: 'unknown_method', params: {} }; const expectedErrorResponse = { jsonrpc: '2.0', id: 4, error: { code: -32601, message: 'Method not found' } }; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { callback(JSON.stringify(expectedErrorResponse) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(unknownMethodRequest) + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(unknownMethodRequest) + '\n'); }); it('should handle server crashes gracefully', () => { spawn('tsx', ['src/index.ts']); // Simulate setting up exit handler const exitHandler = vi.fn(); mockProcess.on('exit', exitHandler); // Simulate server exit exitHandler(1, null); expect(mockProcess.on).toHaveBeenCalledWith('exit', expect.any(Function)); }); }); describe('Authentication Handling', () => { it('should handle missing authentication', async () => { const toolCallRequest = { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'get_projects', arguments: {} } }; const expectedErrorResponse = { jsonrpc: '2.0', id: 5, result: { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: 'Not authenticated with Basecamp. Please run authentication first.' }) }] } }; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { callback(JSON.stringify(expectedErrorResponse) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(toolCallRequest) + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(toolCallRequest) + '\n'); }); it('should handle expired tokens', async () => { const toolCallRequest = { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'get_projects', arguments: {} } }; const expectedErrorResponse = { jsonrpc: '2.0', id: 6, result: { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: 'Authentication token has expired. Please re-authenticate.' }) }] } }; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { callback(JSON.stringify(expectedErrorResponse) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(toolCallRequest) + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(toolCallRequest) + '\n'); }); }); describe('Global Search Tool', () => { it('should handle global search requests', async () => { const searchRequest = { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'global_search', arguments: { query: 'test search' } } }; const expectedResponse = { jsonrpc: '2.0', id: 7, result: { content: [{ type: 'text', text: JSON.stringify({ status: 'success', results: { projects: [], todos: [], messages: [], documents: [] } }) }] } }; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { callback(JSON.stringify(expectedResponse) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(searchRequest) + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(searchRequest) + '\n'); }); it('should validate search query parameter', async () => { const invalidSearchRequest = { jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'global_search', arguments: {} // Missing query parameter } }; const expectedErrorResponse = { jsonrpc: '2.0', id: 8, result: { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: 'Missing required parameter: query' }) }] } }; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { callback(JSON.stringify(expectedErrorResponse) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(invalidSearchRequest) + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(invalidSearchRequest) + '\n'); }); }); describe('Process Management', () => { it('should properly terminate server process', async () => { const proc = spawn('tsx', ['src/index.ts']); proc.kill('SIGTERM'); expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); }); it('should handle SIGINT gracefully', () => { spawn('tsx', ['src/index.ts']); // Simulate setting up SIGINT handler const signalHandler = vi.fn(); mockProcess.on('SIGINT', signalHandler); // Simulate SIGINT signalHandler(); expect(mockProcess.on).toHaveBeenCalledWith('SIGINT', expect.any(Function)); }); it('should timeout on long-running requests', async () => { const timeoutRequest = { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'get_projects', arguments: {} } }; // Don't mock a response, simulating timeout spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(timeoutRequest) + '\n'); // Wait longer than expected timeout await new Promise(resolve => setTimeout(resolve, 100)); expect(mockProcess.stdin.write).toHaveBeenCalledWith(JSON.stringify(timeoutRequest) + '\n'); }); }); describe('Multiple Request Handling', () => { it('should handle multiple sequential requests', async () => { const requests = [ { jsonrpc: '2.0', id: 10, method: 'initialize', params: {} }, { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, { jsonrpc: '2.0', id: 12, method: 'tools/call', params: { name: 'get_projects', arguments: {} } } ]; let responseCount = 0; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { responseCount++; const response = { jsonrpc: '2.0', id: 9 + responseCount, result: { success: true } }; callback(JSON.stringify(response) + '\n'); }, 10 * responseCount); } }); spawn('tsx', ['src/index.ts']); for (const request of requests) { mockProcess.stdin.write(JSON.stringify(request) + '\n'); } await new Promise(resolve => setTimeout(resolve, 100)); expect(mockProcess.stdin.write).toHaveBeenCalledTimes(3); }); it('should handle concurrent requests', async () => { const concurrentRequests = Array.from({ length: 5 }, (_, i) => ({ jsonrpc: '2.0', id: 20 + i, method: 'tools/call', params: { name: 'get_projects', arguments: {} } })); mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { // Mock responses for all requests concurrentRequests.forEach((req, index) => { setTimeout(() => { const response = { jsonrpc: '2.0', id: req.id, result: { requestIndex: index } }; callback(JSON.stringify(response) + '\n'); }, Math.random() * 50); }); } }); spawn('tsx', ['src/index.ts']); // Send all requests simultaneously concurrentRequests.forEach(request => { mockProcess.stdin.write(JSON.stringify(request) + '\n'); }); await new Promise(resolve => setTimeout(resolve, 100)); expect(mockProcess.stdin.write).toHaveBeenCalledTimes(5); }); }); describe('Performance', () => { it('should respond to requests within reasonable time', async () => { const startTime = Date.now(); const request = { jsonrpc: '2.0', id: 30, method: 'tools/list', params: {} }; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { setTimeout(() => { const response = { jsonrpc: '2.0', id: 30, result: { tools: [] } }; callback(JSON.stringify(response) + '\n'); }, 10); } }); spawn('tsx', ['src/index.ts']); mockProcess.stdin.write(JSON.stringify(request) + '\n'); await new Promise(resolve => setTimeout(resolve, 50)); const endTime = Date.now(); const responseTime = endTime - startTime; // Should respond within 1 second expect(responseTime).toBeLessThan(1000); }); it('should handle high-frequency requests', async () => { const requestCount = 10; const requests = Array.from({ length: requestCount }, (_, i) => ({ jsonrpc: '2.0', id: 40 + i, method: 'tools/list', params: {} })); let responseCount = 0; mockProcess.stdout.on.mockImplementation((event, callback) => { if (event === 'data') { requests.forEach((req, index) => { setTimeout(() => { responseCount++; const response = { jsonrpc: '2.0', id: req.id, result: { tools: [] } }; callback(JSON.stringify(response) + '\n'); }, index * 5); }); } }); spawn('tsx', ['src/index.ts']); // Send requests rapidly requests.forEach(request => { mockProcess.stdin.write(JSON.stringify(request) + '\n'); }); await new Promise(resolve => setTimeout(resolve, 100)); expect(mockProcess.stdin.write).toHaveBeenCalledTimes(requestCount); }); }); });

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/jhliberty/basecamp-mcp-server'

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