diff --git a/src/index.ts b/src/index.ts index a5a6bd7..aeb0eb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,16 @@ -// Sage X3 MCP Server entry point +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { loadConfig } from './config/index.js'; +import { createServer } from './server.js'; + +async function main(): Promise { + const config = loadConfig(); + const server = createServer(config); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Sage X3 MCP server started (stdio transport)'); +} + +main().catch((error: unknown) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..520110d --- /dev/null +++ b/src/server.ts @@ -0,0 +1,19 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SageConfig } from './types/index.js'; +import { RestClient } from './clients/rest-client.js'; +import { SoapClient } from './clients/soap-client.js'; +import { registerHealthTool } from './tools/sage-health.js'; + +export function createServer(config: SageConfig): McpServer { + const server = new McpServer( + { name: 'sage-x3-mcp', version: '1.0.0' }, + { capabilities: { logging: {} } }, + ); + + const restClient = new RestClient(config); + const soapClient = new SoapClient(config); + + registerHealthTool(server, restClient, soapClient, config); + + return server; +} diff --git a/src/tools/__tests__/sage-health.test.ts b/src/tools/__tests__/sage-health.test.ts new file mode 100644 index 0000000..f8c9996 --- /dev/null +++ b/src/tools/__tests__/sage-health.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerHealthTool } from '../sage-health.js'; +import type { RestClient } from '../../clients/rest-client.js'; +import type { SoapClient } from '../../clients/soap-client.js'; +import type { SageConfig } from '../../types/index.js'; + +const mockConfig: SageConfig = { + url: 'https://x3.example.com:8124', + user: 'admin', + password: 'secret123', + endpoint: 'SEED', + poolAlias: 'SEED', + language: 'ENG', + rejectUnauthorized: true, +}; + +function createMockRestClient( + healthResult?: { status: string; latencyMs: number }, + shouldReject = false, +): RestClient { + return { + healthCheck: shouldReject + ? vi.fn().mockRejectedValue(new Error('REST connection refused')) + : vi.fn().mockResolvedValue(healthResult ?? { status: 'connected', latencyMs: 42 }), + } as unknown as RestClient; +} + +function createMockSoapClient( + healthResult?: { status: string; latencyMs: number }, + shouldReject = false, +): SoapClient { + return { + healthCheck: shouldReject + ? vi.fn().mockRejectedValue(new Error('SOAP connection refused')) + : vi.fn().mockResolvedValue(healthResult ?? { status: 'connected', latencyMs: 55 }), + } as unknown as SoapClient; +} + +describe('registerHealthTool', () => { + let server: McpServer; + let registerToolSpy: ReturnType; + + beforeEach(() => { + server = new McpServer({ name: 'test-server', version: '1.0.0' }); + registerToolSpy = vi.spyOn(server, 'registerTool'); + }); + + it('registers sage_health tool with correct metadata', () => { + const restClient = createMockRestClient(); + const soapClient = createMockSoapClient(); + + registerHealthTool(server, restClient, soapClient, mockConfig); + + expect(registerToolSpy).toHaveBeenCalledOnce(); + const [name, config] = registerToolSpy.mock.calls[0]; + expect(name).toBe('sage_health'); + expect(config).toMatchObject({ + description: expect.stringContaining('Check connectivity'), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }); + }); + + describe('handler', () => { + async function callHealthHandler( + restClient: RestClient, + soapClient: SoapClient, + ): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { + registerHealthTool(server, restClient, soapClient, mockConfig); + const handler = registerToolSpy.mock.calls[0][2] as () => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + return handler(); + } + + it('returns health status with rest and soap sections on success', async () => { + const restClient = createMockRestClient({ status: 'connected', latencyMs: 42 }); + const soapClient = createMockSoapClient({ status: 'connected', latencyMs: 55 }); + + const result = await callHealthHandler(restClient, soapClient); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual({ + rest: { status: 'connected', latencyMs: 42, endpoint: 'SEED' }, + soap: { status: 'connected', latencyMs: 55, poolAlias: 'SEED' }, + }); + }); + + it('handles REST failure gracefully via allSettled', async () => { + const restClient = createMockRestClient(undefined, true); + const soapClient = createMockSoapClient({ status: 'connected', latencyMs: 30 }); + + const result = await callHealthHandler(restClient, soapClient); + + expect(result.content).toHaveLength(1); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.rest).toEqual({ status: 'error', latencyMs: 0, endpoint: 'SEED' }); + expect(parsed.soap).toEqual({ status: 'connected', latencyMs: 30, poolAlias: 'SEED' }); + }); + + it('handles SOAP failure gracefully via allSettled', async () => { + const restClient = createMockRestClient({ status: 'connected', latencyMs: 25 }); + const soapClient = createMockSoapClient(undefined, true); + + const result = await callHealthHandler(restClient, soapClient); + + expect(result.content).toHaveLength(1); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.rest).toEqual({ status: 'connected', latencyMs: 25, endpoint: 'SEED' }); + expect(parsed.soap).toEqual({ status: 'error', latencyMs: 0, poolAlias: 'SEED' }); + }); + + it('handles both REST and SOAP failures', async () => { + const restClient = createMockRestClient(undefined, true); + const soapClient = createMockSoapClient(undefined, true); + + const result = await callHealthHandler(restClient, soapClient); + + expect(result.content).toHaveLength(1); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.rest).toEqual({ status: 'error', latencyMs: 0, endpoint: 'SEED' }); + expect(parsed.soap).toEqual({ status: 'error', latencyMs: 0, poolAlias: 'SEED' }); + }); + + it('returns error status string from client healthCheck', async () => { + const restClient = createMockRestClient({ status: 'error: ECONNREFUSED', latencyMs: 5 }); + const soapClient = createMockSoapClient({ status: 'error: WSDL not found', latencyMs: 10 }); + + const result = await callHealthHandler(restClient, soapClient); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.rest.status).toBe('error: ECONNREFUSED'); + expect(parsed.soap.status).toBe('error: WSDL not found'); + }); + + it('includes endpoint and poolAlias from config', async () => { + const restClient = createMockRestClient(); + const soapClient = createMockSoapClient(); + + const result = await callHealthHandler(restClient, soapClient); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.rest.endpoint).toBe('SEED'); + expect(parsed.soap.poolAlias).toBe('SEED'); + }); + + it('returns valid MCP content format', async () => { + const restClient = createMockRestClient(); + const soapClient = createMockSoapClient(); + + const result = await callHealthHandler(restClient, soapClient); + + expect(result).toHaveProperty('content'); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content[0]).toHaveProperty('type', 'text'); + expect(result.content[0]).toHaveProperty('text'); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + }); +}); + +describe('createServer', () => { + it('creates server with sage_health tool registered', async () => { + const { createServer } = await import('../../server.js'); + const server = createServer(mockConfig); + + expect(server).toBeInstanceOf(McpServer); + }); +}); diff --git a/src/tools/sage-health.ts b/src/tools/sage-health.ts new file mode 100644 index 0000000..d7765e0 --- /dev/null +++ b/src/tools/sage-health.ts @@ -0,0 +1,61 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RestClient } from '../clients/rest-client.js'; +import type { SoapClient } from '../clients/soap-client.js'; +import type { SageConfig, HealthStatus } from '../types/index.js'; + +export function registerHealthTool( + server: McpServer, + restClient: RestClient, + soapClient: SoapClient, + config: SageConfig, +): void { + server.registerTool( + 'sage_health', + { + description: + 'Check connectivity to Sage X3 REST and SOAP APIs. Returns connection status, latency, and configuration details.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (): Promise<{ content: Array<{ type: 'text'; text: string }> }> => { + try { + const [restHealth, soapHealth] = await Promise.allSettled([ + restClient.healthCheck(), + soapClient.healthCheck(), + ]); + + const result: HealthStatus = { + rest: { + ...(restHealth.status === 'fulfilled' + ? restHealth.value + : { status: 'error', latencyMs: 0 }), + endpoint: config.endpoint, + }, + soap: { + ...(soapHealth.status === 'fulfilled' + ? soapHealth.value + : { status: 'error', latencyMs: 0 }), + poolAlias: config.poolAlias, + }, + }; + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: `Health check failed: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }, + ); +}