feat(server): add MCP server skeleton with sage_health tool
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
17
src/index.ts
17
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
19
src/server.ts
Normal file
19
src/server.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
177
src/tools/__tests__/sage-health.test.ts
Normal file
177
src/tools/__tests__/sage-health.test.ts
Normal file
@@ -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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
src/tools/sage-health.ts
Normal file
61
src/tools/sage-health.ts
Normal file
@@ -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)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user