@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
245 lines (244 loc) • 12.2 kB
JavaScript
/**
* 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();
});
});
});