UNPKG

signalk-mcp-server

Version:
547 lines 25.1 kB
/* eslint-disable @typescript-eslint/unbound-method */ /** * SignalKMCPServer Unit Tests * * Comprehensive tests for the MCP server functionality including: * - Server initialization and configuration * - Tool handler setup and execution * - SignalK client integration * - Error handling and edge cases */ import { describe, test, expect, jest, beforeEach, afterEach, } from '@jest/globals'; // Mock import.meta.url for Jest jest.mock('url', () => ({ fileURLToPath: jest.fn(() => '/mocked/path/src/signalk-mcp-server.ts'), })); // Mock the path utils to avoid import.meta issues jest.mock('./utils/path-utils.js', () => ({ getCurrentDirname: jest.fn(() => '/mocked/path/src'), })); // Mock path module jest.mock('path', () => ({ dirname: jest.fn((p) => p.replace(/\/[^/]+$/, '')), join: jest.fn((...args) => args.join('/')), })); // Mock fs/promises jest.mock('fs/promises', () => ({ readFile: jest.fn(() => Promise.resolve(JSON.stringify({ test: 'data' }))), })); import { SignalKMCPServer } from './signalk-mcp-server'; import { SignalKClient } from './signalk-client'; // Mock the SignalK client jest.mock('./signalk-client', () => ({ SignalKClient: jest.fn().mockImplementation(() => ({ on: jest.fn(), connect: jest.fn().mockImplementation(() => Promise.resolve()), // Returns Promise<void> getVesselState: jest.fn(), getAISTargets: jest.fn(), getActiveAlarms: jest.fn(), listAvailablePaths: jest.fn(), getPathValue: jest.fn(), getConnectionStatus: jest.fn(), disconnect: jest.fn(), buildWebSocketUrl: jest.fn(), buildHttpUrl: jest.fn(), buildRestApiUrl: jest.fn(), handleDelta: jest.fn(), setupEventHandlers: jest.fn(), setSignalKConfig: jest.fn(), client: jest.fn(), })), })); const MockedSignalKClient = jest.mocked(SignalKClient); // Mock the MCP SDK components const mockServer = { setRequestHandler: jest.fn(), connect: jest.fn(), }; const mockStdioTransport = {}; jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ Server: jest.fn().mockImplementation(() => mockServer), })); jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ StdioServerTransport: jest.fn().mockImplementation(() => mockStdioTransport), })); jest.mock('@modelcontextprotocol/sdk/types.js', () => ({ CallToolRequestSchema: 'CallToolRequestSchema', ListToolsRequestSchema: 'ListToolsRequestSchema', ErrorCode: { MethodNotFound: -32601, InternalError: -32603, }, McpError: jest.fn().mockImplementation((...args) => { const [code, message] = args; const error = new Error(message); error.code = code; return error; }), })); describe('SignalKMCPServer', () => { let mockSignalKClient; let consoleErrorSpy; beforeEach(() => { jest.clearAllMocks(); // Reset the mock to always return the same instance mockSignalKClient = { on: jest.fn(), connect: jest.fn().mockImplementation(() => Promise.resolve()), getVesselState: jest.fn(), getAISTargets: jest.fn(), getActiveAlarms: jest.fn(), listAvailablePaths: jest.fn(), getPathValue: jest.fn(), getConnectionStatus: jest.fn(), disconnect: jest.fn(), buildWebSocketUrl: jest.fn(), buildHttpUrl: jest.fn(), buildRestApiUrl: jest.fn(), handleDelta: jest.fn(), setupEventHandlers: jest.fn(), setSignalKConfig: jest.fn(), client: jest.fn(), }; // Make the constructor always return our specific mock instance MockedSignalKClient.mockImplementation(() => mockSignalKClient); // Spy on console.error consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); }); afterEach(() => { consoleErrorSpy.mockRestore(); }); describe('Constructor', () => { test('should initialize with default options', () => { new SignalKMCPServer(); expect(MockedSignalKClient).toHaveBeenCalledWith(); expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(4); // 2 for tools + 2 for resources const onCalls = mockSignalKClient.on.mock.calls; const errorCall = onCalls.find((call) => call[0] === 'error'); expect(errorCall).toBeDefined(); expect(typeof errorCall?.[1]).toBe('function'); }); test('should initialize with custom options', () => { const options = { serverName: 'custom-server', serverVersion: '2.0.0', signalkClient: mockSignalKClient, }; const server = new SignalKMCPServer(options); expect(MockedSignalKClient).not.toHaveBeenCalled(); expect(server.signalkClientInstance).toBe(mockSignalKClient); }); test('should use environment variables when no options provided', () => { const originalEnv = process.env; process.env = { ...originalEnv, SERVER_NAME: 'env-server', SERVER_VERSION: '3.0.0', }; const server = new SignalKMCPServer(); expect(server).toBeDefined(); process.env = originalEnv; }); test('should set up error handler for SignalK client', () => { new SignalKMCPServer(); const errorOnCalls = mockSignalKClient.on.mock.calls; const errorHandlerCall = errorOnCalls.find((call) => call[0] === 'error'); expect(errorHandlerCall).toBeDefined(); expect(typeof errorHandlerCall?.[1]).toBe('function'); // Test the error handler const errorHandler = mockSignalKClient.on.mock.calls[0][1]; const testError = new Error('Test error'); errorHandler(testError); expect(consoleErrorSpy).toHaveBeenCalledWith('SignalK connection error:', 'Test error'); }); test('should attempt SignalK connection during initialization', () => { mockSignalKClient.connect.mockResolvedValue(undefined); new SignalKMCPServer(); expect(mockSignalKClient.connect).toHaveBeenCalledTimes(1); }); }); describe('connectToSignalK', () => { test('should connect successfully and log success', async () => { mockSignalKClient.connect.mockResolvedValue(undefined); const server = new SignalKMCPServer(); // Clear the mock calls from constructor mockSignalKClient.connect.mockClear(); await server.connectToSignalK(); expect(mockSignalKClient.connect).toHaveBeenCalledTimes(1); expect(consoleErrorSpy).toHaveBeenCalledWith('SignalK client connected successfully'); }); test('should handle connection failure gracefully', async () => { const connectionError = new Error('Connection failed'); mockSignalKClient.connect.mockRejectedValue(connectionError); const server = new SignalKMCPServer(); await server.connectToSignalK(); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to connect to SignalK:', 'Connection failed'); }); test('should handle connection failure with non-Error object', async () => { mockSignalKClient.connect.mockRejectedValue('String error'); const server = new SignalKMCPServer(); await server.connectToSignalK(); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to connect to SignalK:', 'String error'); }); }); describe('setupToolHandlers', () => { test('should register list tools handler', () => { new SignalKMCPServer(); const setHandlerCalls = mockServer.setRequestHandler.mock.calls; const listToolsCall = setHandlerCalls.find((call) => call[0] === 'ListToolsRequestSchema'); expect(listToolsCall).toBeDefined(); expect(typeof listToolsCall?.[1]).toBe('function'); }); test('should register call tool handler', () => { new SignalKMCPServer(); const setHandlerCalls = mockServer.setRequestHandler.mock.calls; const callToolCall = setHandlerCalls.find((call) => call[0] === 'CallToolRequestSchema'); expect(callToolCall).toBeDefined(); expect(typeof callToolCall?.[1]).toBe('function'); }); test('should return correct tools list', async () => { new SignalKMCPServer({ executionMode: 'tools' }); const listToolsHandler = mockServer.setRequestHandler.mock .calls[0][1]; const result = await listToolsHandler(); expect(result.tools).toHaveLength(7); expect(result.tools.map((tool) => tool.name)).toEqual([ 'get_vessel_state', 'get_ais_targets', 'get_active_alarms', 'list_available_paths', 'get_path_value', 'get_connection_status', 'get_initial_context', ]); // Verify get_ais_targets has pagination parameters const aisTargetsTool = result.tools.find((tool) => tool.name === 'get_ais_targets'); expect(aisTargetsTool.inputSchema.properties).toHaveProperty('page'); expect(aisTargetsTool.inputSchema.properties).toHaveProperty('pageSize'); expect(aisTargetsTool.inputSchema.properties.page.type).toBe('number'); expect(aisTargetsTool.inputSchema.properties.pageSize.type).toBe('number'); expect(aisTargetsTool.inputSchema.properties.pageSize.maximum).toBe(50); }); }); describe('Tool execution', () => { let callToolHandler; beforeEach(() => { new SignalKMCPServer({ executionMode: 'tools' }); callToolHandler = mockServer.setRequestHandler.mock .calls[1][1]; }); test('should execute get_vessel_state tool', async () => { const mockData = { connected: true, context: 'vessels.self', data: { position: 'test' }, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.getVesselState.mockResolvedValue(mockData); const request = { params: { name: 'get_vessel_state', arguments: {} }, }; const result = await callToolHandler(request); expect(mockSignalKClient.getVesselState).toHaveBeenCalledTimes(1); expect(result.content[0].type).toBe('text'); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('should execute get_ais_targets tool', async () => { const mockData = { connected: true, targets: [], count: 0, timestamp: '2025-06-21T10:00:00.000Z', pagination: { page: 1, pageSize: 10, totalCount: 0, totalPages: 0, hasNextPage: false, hasPreviousPage: false, }, }; mockSignalKClient.getAISTargets.mockResolvedValue(mockData); const request = { params: { name: 'get_ais_targets', arguments: {} }, }; const result = await callToolHandler(request); expect(mockSignalKClient.getAISTargets).toHaveBeenCalledWith(undefined, undefined); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('should execute get_ais_targets tool with pagination', async () => { const mockData = { connected: true, targets: [ { mmsi: '123456789', distanceMeters: 1000, lastUpdate: '2025-06-21T10:00:00.000Z' }, ], count: 1, timestamp: '2025-06-21T10:00:00.000Z', pagination: { page: 2, pageSize: 5, totalCount: 10, totalPages: 2, hasNextPage: false, hasPreviousPage: true, }, }; mockSignalKClient.getAISTargets.mockResolvedValue(mockData); const request = { params: { name: 'get_ais_targets', arguments: { page: 2, pageSize: 5 } }, }; const result = await callToolHandler(request); expect(mockSignalKClient.getAISTargets).toHaveBeenCalledWith(2, 5); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('should execute get_active_alarms tool', async () => { const mockData = { connected: true, alarms: [], count: 0, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.getActiveAlarms.mockResolvedValue(mockData); const request = { params: { name: 'get_active_alarms', arguments: {} }, }; const result = await callToolHandler(request); expect(mockSignalKClient.getActiveAlarms).toHaveBeenCalledTimes(1); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('should execute list_available_paths tool', async () => { const mockData = { connected: true, paths: ['navigation.position'], count: 1, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.listAvailablePaths.mockResolvedValue(mockData); const request = { params: { name: 'list_available_paths', arguments: {} }, }; const result = await callToolHandler(request); expect(mockSignalKClient.listAvailablePaths).toHaveBeenCalledTimes(1); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('should execute get_path_value tool', async () => { const mockData = { connected: true, path: 'navigation.position', data: { value: 'test' }, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.getPathValue.mockResolvedValue(mockData); const request = { params: { name: 'get_path_value', arguments: { path: 'navigation.position' }, }, }; const result = await callToolHandler(request); expect(mockSignalKClient.getPathValue).toHaveBeenCalledWith('navigation.position'); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('should execute get_connection_status tool', async () => { const mockData = { connected: false, hostname: 'localhost', port: 3000, url: 'localhost:3000', wsUrl: 'ws://localhost:3000', httpUrl: 'http://localhost:3000', useTLS: false, context: 'vessels.self', timestamp: '2025-06-21T10:00:00.000Z', pathCount: 0, aisTargetCount: 0, activeAlarmCount: 0, }; mockSignalKClient.getConnectionStatus.mockReturnValue(mockData); const request = { params: { name: 'get_connection_status', arguments: {} }, }; const result = await callToolHandler(request); expect(mockSignalKClient.getConnectionStatus).toHaveBeenCalledTimes(1); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('should execute get_initial_context tool', async () => { const request = { params: { name: 'get_initial_context', arguments: {} }, }; const result = await callToolHandler(request); const parsedResult = JSON.parse(result.content[0].text); expect(parsedResult).toHaveProperty('server_info'); expect(parsedResult.server_info).toHaveProperty('name'); expect(parsedResult.server_info).toHaveProperty('version'); expect(parsedResult.server_info).toHaveProperty('loaded_at'); expect(parsedResult.server_info).toHaveProperty('description'); }); test('should handle unknown tool gracefully', async () => { const request = { params: { name: 'unknown_tool', arguments: {} }, }; await expect(callToolHandler(request)).rejects.toThrow('Unknown tool: unknown_tool'); }); test('should handle tool execution errors', async () => { mockSignalKClient.getVesselState.mockImplementation(() => { throw new Error('Tool execution failed'); }); const request = { params: { name: 'get_vessel_state', arguments: {} }, }; await expect(callToolHandler(request)).rejects.toThrow('Tool execution failed: Tool execution failed'); }); }); describe('Individual tool methods', () => { let server; beforeEach(() => { server = new SignalKMCPServer({ executionMode: 'tools' }); }); test('getVesselState should return formatted response', async () => { const mockData = { connected: true, context: 'vessels.self', data: {}, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.getVesselState.mockResolvedValue(mockData); const result = await server.getVesselState(); expect(result.content[0].type).toBe('text'); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('getAISTargets should return formatted response', async () => { const mockData = { connected: true, targets: [], count: 0, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.getAISTargets.mockResolvedValue(mockData); const result = await server.getAISTargets(); expect(result.content[0].type).toBe('text'); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('getActiveAlarms should return formatted response', async () => { const mockData = { connected: true, alarms: [], count: 0, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.getActiveAlarms.mockResolvedValue(mockData); const result = await server.getActiveAlarms(); expect(result.content[0].type).toBe('text'); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('listAvailablePaths should return formatted response', async () => { const mockData = { connected: true, paths: [], count: 0, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.listAvailablePaths.mockResolvedValue(mockData); const result = await server.listAvailablePaths(); expect(result.content[0].type).toBe('text'); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('getPathValue should return formatted response', async () => { const mockData = { connected: true, path: 'test.path', data: null, timestamp: '2025-06-21T10:00:00.000Z', }; mockSignalKClient.getPathValue.mockResolvedValue(mockData); const result = await server.getPathValue('test.path'); expect(mockSignalKClient.getPathValue).toHaveBeenCalledWith('test.path'); expect(result.content[0].type).toBe('text'); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('getConnectionStatus should return formatted response', () => { const mockData = { connected: false, hostname: 'localhost', port: 3000, url: 'localhost:3000', wsUrl: 'ws://localhost:3000', httpUrl: 'http://localhost:3000', useTLS: false, context: 'vessels.self', timestamp: '2025-06-21T10:00:00.000Z', pathCount: 0, aisTargetCount: 0, activeAlarmCount: 0, }; mockSignalKClient.getConnectionStatus.mockReturnValue(mockData); const result = server.getConnectionStatus(); expect(result.content[0].type).toBe('text'); expect(JSON.parse(result.content[0].text)).toEqual(mockData); }); test('getInitialContext should return formatted response', () => { const result = server.getInitialContext(); expect(result.content[0].type).toBe('text'); const parsedResult = JSON.parse(result.content[0].text); expect(parsedResult).toHaveProperty('server_info'); expect(parsedResult.server_info).toHaveProperty('name'); expect(parsedResult.server_info).toHaveProperty('version'); expect(parsedResult.server_info).toHaveProperty('loaded_at'); expect(parsedResult.server_info).toHaveProperty('description'); }); }); describe('run', () => { test('should connect server with stdio transport', async () => { mockServer.connect.mockImplementation(() => Promise.resolve()); const server = new SignalKMCPServer(); await server.run(); expect(mockServer.connect).toHaveBeenCalledWith(mockStdioTransport); expect(consoleErrorSpy).toHaveBeenCalledWith('signalk-mcp-server v1.0.0 running on stdio'); }); test('should handle connection errors during run', async () => { const connectionError = new Error('Server connection failed'); mockServer.connect.mockRejectedValue(connectionError); const server = new SignalKMCPServer(); await expect(server.run()).rejects.toThrow('Server connection failed'); }); test('should use custom server name and version in run message', async () => { mockServer.connect.mockResolvedValue(undefined); const server = new SignalKMCPServer({ serverName: 'test-server', serverVersion: '2.0.0', }); await server.run(); expect(consoleErrorSpy).toHaveBeenCalledWith('test-server v2.0.0 running on stdio'); }); }); describe('Getters', () => { test('should expose mcpServer getter', () => { const server = new SignalKMCPServer(); expect(server.mcpServer).toBe(mockServer); }); test('should expose signalkClientInstance getter', () => { const server = new SignalKMCPServer(); expect(server.signalkClientInstance).toBe(mockSignalKClient); }); }); describe('Error scenarios', () => { test('should handle SignalK client error event', () => { new SignalKMCPServer(); const errorHandler = mockSignalKClient.on.mock.calls[0][1]; // Test with Error object const error = new Error('Test error'); errorHandler(error); expect(consoleErrorSpy).toHaveBeenCalledWith('SignalK connection error:', 'Test error'); // Test with object that has no message property const errorObj = { code: 'ECONNREFUSED' }; errorHandler(errorObj); expect(consoleErrorSpy).toHaveBeenCalledWith('SignalK connection error:', errorObj); }); test('should handle async connection errors during initialization', async () => { const connectionError = new Error('Async connection error'); mockSignalKClient.connect.mockImplementation(() => Promise.reject(connectionError)); const server = new SignalKMCPServer(); // Call the connectToSignalK method directly to test the error handling await server.connectToSignalK(); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to connect to SignalK:', 'Async connection error'); }); }); }); //# sourceMappingURL=signalk-mcp-server.spec.js.map