UNPKG

@the_cfdude/productboard-mcp

Version:

Model Context Protocol server for Productboard REST API with dynamic tool loading

245 lines (244 loc) 12.2 kB
/** * Test suite for ProductboardServer class. * * Tests the core MCP server functionality including initialization, * session management, transport handling, and error scenarios. */ import { describe, it, expect, beforeEach, afterEach, jest, } from '@jest/globals'; import { ProductboardServer } from '../productboard-server.js'; import { sessionManager } from '../session-manager.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { existsSync } from 'fs'; // Mock all external dependencies jest.mock('@modelcontextprotocol/sdk/server/index.js'); jest.mock('@modelcontextprotocol/sdk/server/stdio.js'); jest.mock('../tools/index.js'); jest.mock('../tools/index-dynamic.js'); jest.mock('../documentation/documentation-provider.js'); jest.mock('../session-manager.js'); jest.mock('fs'); jest.mock('path'); jest.mock('crypto'); jest.mock('../utils/debug-logger.js'); const mockServer = jest.mocked(Server); const mockSessionManager = jest.mocked(sessionManager); const mockExistsSync = jest.mocked(existsSync); describe('ProductboardServer', () => { let productboardServer; let mockServerInstance; let mockTransport; beforeEach(() => { // Reset all mocks jest.clearAllMocks(); // Create mock server instance mockServerInstance = { onerror: null, connect: jest.fn(), }; mockServer.mockImplementation(() => mockServerInstance); // Create mock transport mockTransport = { onclose: null, onerror: null, }; // Mock StdioServerTransport const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); StdioServerTransport.mockImplementation(() => mockTransport); // Mock crypto.randomBytes const crypto = require('crypto'); crypto.randomBytes.mockReturnValue(Buffer.from('abcd1234', 'hex')); // Mock session manager mockSessionManager.createSession.mockReturnValue({ sessionId: 'pb-stdio-123456789-abcd1234', createdAt: new Date(), lastActivity: new Date(), apiInstances: new Map(), configCache: new Map(), requestCount: 0, activeRequests: new Set(), }); mockSessionManager.getActiveSessionCount.mockReturnValue(1); mockSessionManager.removeSession.mockImplementation(() => { }); // Create server instance productboardServer = new ProductboardServer(); }); afterEach(() => { jest.clearAllMocks(); }); describe('constructor', () => { it('should create server instance with correct configuration', () => { expect(mockServer).toHaveBeenCalledWith({ name: 'productboard-server', version: '0.1.0', }, { capabilities: { tools: {}, resources: {}, prompts: {}, }, }); }); it('should setup error handler for server', () => { expect(mockServerInstance.onerror).toBeDefined(); }); it('should handle server errors gracefully', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); const testError = new Error('Test server error'); // Trigger the error handler mockServerInstance.onerror(testError); expect(consoleSpy).toHaveBeenCalledWith('[MCP Error]', testError); consoleSpy.mockRestore(); }); }); describe('initialize', () => { it('should initialize with dynamic tool loading when manifest exists', async () => { mockExistsSync.mockReturnValue(true); const setupDynamicToolHandlers = require('../tools/index-dynamic.js').setupDynamicToolHandlers; const setupDocumentation = require('../documentation/documentation-provider.js').setupDocumentation; await productboardServer.initialize(); expect(setupDynamicToolHandlers).toHaveBeenCalledWith(mockServerInstance, undefined); expect(setupDocumentation).toHaveBeenCalledWith(mockServerInstance); }); it('should initialize with static tool loading when manifest does not exist', async () => { mockExistsSync.mockReturnValue(false); const setupToolHandlers = require('../tools/index.js').setupToolHandlers; const setupDocumentation = require('../documentation/documentation-provider.js').setupDocumentation; await productboardServer.initialize(); expect(setupToolHandlers).toHaveBeenCalledWith(mockServerInstance, undefined); expect(setupDocumentation).toHaveBeenCalledWith(mockServerInstance); }); it('should initialize with session context when provided', async () => { const testSession = { sessionId: 'test-session-123', createdAt: new Date(), lastActivity: new Date(), apiInstances: new Map(), configCache: new Map(), requestCount: 0, activeRequests: new Set(), }; mockExistsSync.mockReturnValue(false); const setupToolHandlers = require('../tools/index.js').setupToolHandlers; await productboardServer.initialize(testSession); expect(setupToolHandlers).toHaveBeenCalledWith(mockServerInstance, testSession); }); it('should not initialize twice', async () => { mockExistsSync.mockReturnValue(false); const setupToolHandlers = require('../tools/index.js').setupToolHandlers; await productboardServer.initialize(); await productboardServer.initialize(); // Second call expect(setupToolHandlers).toHaveBeenCalledTimes(1); }); }); describe('run', () => { beforeEach(() => { // Mock console.error to avoid output during tests jest.spyOn(console, 'error').mockImplementation(); }); afterEach(() => { console.error.mockRestore(); }); it('should create session and setup transport successfully', async () => { mockServerInstance.connect.mockResolvedValue(undefined); await productboardServer.run(); expect(mockSessionManager.createSession).toHaveBeenCalledWith('pb-stdio-123456789-abcd1234'); expect(mockServerInstance.connect).toHaveBeenCalledWith(mockTransport); expect(console.error).toHaveBeenCalledWith('ProductBoard MCP server running on stdio'); }); it('should setup transport event handlers', async () => { mockServerInstance.connect.mockResolvedValue(undefined); await productboardServer.run(); expect(mockTransport.onclose).toBeDefined(); expect(mockTransport.onerror).toBeDefined(); }); it('should handle transport close event and cleanup session', async () => { mockServerInstance.connect.mockResolvedValue(undefined); await productboardServer.run(); // Trigger transport close mockTransport.onclose(); expect(mockSessionManager.removeSession).toHaveBeenCalledWith('pb-stdio-123456789-abcd1234'); }); it('should handle transport error event', async () => { mockServerInstance.connect.mockResolvedValue(undefined); const testError = new Error('Transport error'); await productboardServer.run(); // Should not throw when transport error handler is called expect(() => mockTransport.onerror(testError)).not.toThrow(); }); it('should cleanup session when server connection fails', async () => { const connectionError = new Error('Connection failed'); mockServerInstance.connect.mockRejectedValue(connectionError); await expect(productboardServer.run()).rejects.toThrow('Connection failed'); expect(mockSessionManager.removeSession).toHaveBeenCalledWith('pb-stdio-123456789-abcd1234'); }); it('should handle connection failure when no session exists', async () => { const connectionError = new Error('Connection failed'); mockServerInstance.connect.mockRejectedValue(connectionError); mockSessionManager.createSession.mockReturnValue(null); await expect(productboardServer.run()).rejects.toThrow('Connection failed'); // removeSession should not be called if no session was created expect(mockSessionManager.removeSession).not.toHaveBeenCalled(); }); }); describe('session management integration', () => { beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(); }); afterEach(() => { console.error.mockRestore(); }); it('should generate cryptographically secure session IDs', async () => { const crypto = require('crypto'); crypto.randomBytes.mockReturnValue(Buffer.from('12345678', 'hex')); mockServerInstance.connect.mockResolvedValue(undefined); await productboardServer.run(); expect(crypto.randomBytes).toHaveBeenCalledWith(8); expect(mockSessionManager.createSession).toHaveBeenCalledWith(expect.stringMatching(/^pb-stdio-\d+-12345678$/)); }); it('should track active session count during operation', async () => { mockServerInstance.connect.mockResolvedValue(undefined); mockSessionManager.getActiveSessionCount .mockReturnValueOnce(1) // During session creation .mockReturnValueOnce(1); // During server start await productboardServer.run(); expect(mockSessionManager.getActiveSessionCount).toHaveBeenCalled(); }); }); describe('error handling', () => { it('should handle server initialization errors', async () => { const setupError = new Error('Setup failed'); const setupToolHandlers = require('../tools/index.js').setupToolHandlers; setupToolHandlers.mockImplementation(() => { throw setupError; }); mockExistsSync.mockReturnValue(false); await expect(productboardServer.initialize()).rejects.toThrow('Setup failed'); }); it('should handle async initialization errors in dynamic setup', async () => { const setupError = new Error('Dynamic setup failed'); const setupDynamicToolHandlers = require('../tools/index-dynamic.js').setupDynamicToolHandlers; setupDynamicToolHandlers.mockRejectedValue(setupError); mockExistsSync.mockReturnValue(true); await expect(productboardServer.initialize()).rejects.toThrow('Dynamic setup failed'); }); }); describe('debug logging integration', () => { it('should log server initialization events', () => { const debugLog = require('../utils/debug-logger.js').debugLog; expect(debugLog).toHaveBeenCalledWith('productboard-server', 'Initializing ProductBoard MCP Server'); expect(debugLog).toHaveBeenCalledWith('productboard-server', 'ProductBoard MCP Server initialization completed'); }); it('should log session-related events during run', async () => { const debugLog = require('../utils/debug-logger.js').debugLog; mockServerInstance.connect.mockResolvedValue(undefined); jest.spyOn(console, 'error').mockImplementation(); await productboardServer.run(); expect(debugLog).toHaveBeenCalledWith('productboard-server', 'Starting ProductBoard MCP server transport'); expect(debugLog).toHaveBeenCalledWith('productboard-server', 'Created session for STDIO connection', expect.objectContaining({ sessionId: 'pb-stdio-123456789-abcd1234', totalSessions: 1, })); console.error.mockRestore(); }); }); });