From 8fc6d7cbc0f51cda292d36d25b8201fd5aff8336 Mon Sep 17 00:00:00 2001 From: repi Date: Tue, 10 Mar 2026 17:33:53 +0000 Subject: [PATCH] test(integration): add integration test suite, .env.example, and README Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus --- .env.example | 16 ++ README.md | 104 +++++++++ src/__tests__/integration.test.ts | 371 ++++++++++++++++++++++++++++++ 3 files changed, 491 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 src/__tests__/integration.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ccfbf6a --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Sage X3 Connection (REQUIRED) +SAGE_X3_URL=http://your-x3-server:8124 +SAGE_X3_USER=your_webservice_user +SAGE_X3_PASSWORD=your_password +SAGE_X3_ENDPOINT=X3V12 + +# Sage X3 SOAP Settings (OPTIONAL) +SAGE_X3_POOL_ALIAS=SEED +SAGE_X3_LANGUAGE=ENG + +# MCP Transport (OPTIONAL) +MCP_TRANSPORT=stdio +MCP_HTTP_PORT=3000 + +# TLS (OPTIONAL — set to false for self-signed certificates) +SAGE_X3_REJECT_UNAUTHORIZED=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..f28ccfd --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# sage-mcp-server + +MCP server providing read-only access to Sage X3 ERP data through both REST and SOAP APIs. Exposes 9 tools that let LLMs query business objects, read records, search data, and inspect entity schemas — all without modifying any X3 data. + +## Quick Start + +```bash +npm install +cp .env.example .env # edit with your X3 credentials +npm run build +npm start +``` + +## Tools + +| Tool | Description | +|------|-------------| +| `sage_health` | Check X3 REST and SOAP API connectivity | +| `sage_query` | Query records via REST with pagination, filtering, and sorting | +| `sage_read` | Read a single record by primary key via REST | +| `sage_search` | Search records with flexible text matching across fields | +| `sage_list_entities` | Discover available REST entity types on the endpoint | +| `sage_get_context` | Get entity field names and sample structure | +| `sage_soap_read` | Read a single record via SOAP by key fields | +| `sage_soap_query` | Query records via SOAP with list size control | +| `sage_describe_entity` | Get field definitions with types, lengths, and labels | + +All tools are annotated as `readOnlyHint: true` — they never modify X3 data. + +## Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `SAGE_X3_URL` | Yes | — | Base URL of X3 server (e.g. `http://x3-server:8124`) | +| `SAGE_X3_USER` | Yes | — | Web service user with API access | +| `SAGE_X3_PASSWORD` | Yes | — | Password for the web service user | +| `SAGE_X3_ENDPOINT` | Yes | — | X3 endpoint folder (e.g. `X3V12`, `SEED`) | +| `SAGE_X3_POOL_ALIAS` | No | `SEED` | SOAP connection pool alias | +| `SAGE_X3_LANGUAGE` | No | `ENG` | Language code for SOAP responses | +| `SAGE_X3_REJECT_UNAUTHORIZED` | No | `true` | Set to `false` for self-signed TLS certificates | +| `MCP_TRANSPORT` | No | `stdio` | Transport mode: `stdio` or `http` | +| `MCP_HTTP_PORT` | No | `3000` | Port for HTTP transport mode | + +## Usage Examples + +### Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "sage-x3": { + "command": "node", + "args": ["/path/to/sage-mcp-server/dist/index.js"], + "env": { + "SAGE_X3_URL": "http://your-x3-server:8124", + "SAGE_X3_USER": "your_user", + "SAGE_X3_PASSWORD": "your_password", + "SAGE_X3_ENDPOINT": "X3V12" + } + } + } +} +``` + +### Opencode + +Add to `opencode.json`: + +```json +{ + "mcp": { + "sage-x3": { + "type": "local", + "command": ["node", "/path/to/sage-mcp-server/dist/index.js"], + "env": { + "SAGE_X3_URL": "http://your-x3-server:8124", + "SAGE_X3_USER": "your_user", + "SAGE_X3_PASSWORD": "your_password", + "SAGE_X3_ENDPOINT": "X3V12" + } + } + } +} +``` + +### HTTP Transport + +```bash +MCP_TRANSPORT=http MCP_HTTP_PORT=3000 npm start +``` + +The server listens on `POST /mcp` for Streamable HTTP MCP connections. + +## Development + +```bash +npm run dev # run with tsx (hot reload) +npm test # run all tests +npm run test:watch # watch mode +npm run typecheck # type check without emitting +npm run build # compile TypeScript to dist/ +``` diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts new file mode 100644 index 0000000..3f259a6 --- /dev/null +++ b/src/__tests__/integration.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { SageConfig } from '../types/index.js'; + +const restMethods = { + query: vi.fn(), + read: vi.fn(), + listEntities: vi.fn(), + healthCheck: vi.fn(), +}; + +const soapMethods = { + read: vi.fn(), + query: vi.fn(), + getDescription: vi.fn(), + healthCheck: vi.fn(), +}; + +vi.mock('../clients/rest-client.js', () => { + const RestClient = vi.fn(); + RestClient.prototype = restMethods; + return { RestClient }; +}); + +vi.mock('../clients/soap-client.js', () => { + const SoapClient = vi.fn(); + SoapClient.prototype = soapMethods; + return { SoapClient }; +}); + +const testConfig: SageConfig = { + url: 'https://x3.example.com:8124', + user: 'admin', + password: 'secret', + endpoint: 'SEED', + poolAlias: 'SEED', + language: 'ENG', + rejectUnauthorized: true, +}; + +const EXPECTED_TOOL_NAMES = [ + 'sage_health', + 'sage_query', + 'sage_read', + 'sage_search', + 'sage_list_entities', + 'sage_get_context', + 'sage_soap_read', + 'sage_soap_query', + 'sage_describe_entity', +] as const; + +let client: Client; + +beforeAll(async () => { + const { createServer } = await import('../server.js'); + const server = createServer(testConfig); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + client = new Client({ name: 'integration-test', version: '1.0.0' }); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); +}); + +afterAll(async () => { + await client.close(); +}); + +beforeEach(() => { + restMethods.query.mockReset().mockResolvedValue({ + records: [{ BPCNUM: 'C001', BPCNAM: 'Acme Corp' }], + pagination: { returned: 1, hasMore: false }, + }); + restMethods.read.mockReset().mockResolvedValue({ + record: { BPCNUM: 'C001', BPCNAM: 'Acme Corp', BPCSTA: 'Active' }, + }); + restMethods.listEntities.mockReset().mockResolvedValue(['BPCUSTOMER', 'SINVOICE', 'SORDER']); + restMethods.healthCheck.mockReset().mockResolvedValue({ status: 'connected', latencyMs: 42 }); + + soapMethods.read.mockReset().mockResolvedValue({ + status: 1, + data: { NUM: 'INV001', AMOUNT: 1500 }, + messages: [], + technicalInfos: [], + }); + soapMethods.query.mockReset().mockResolvedValue({ + status: 1, + data: [{ NUM: 'INV001' }, { NUM: 'INV002' }], + messages: [], + technicalInfos: [], + }); + soapMethods.getDescription.mockReset().mockResolvedValue({ + status: 1, + data: { + FLD: [ + { '@_NAM': 'NUM', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Invoice number' }, + { '@_NAM': 'AMOUNT', '@_TYP': 'Decimal', '@_LEN': '14', '@_C_ENG': 'Amount' }, + ], + }, + messages: [], + technicalInfos: [], + }); + soapMethods.healthCheck.mockReset().mockResolvedValue({ status: 'connected', latencyMs: 55 }); +}); + +describe('tools/list', () => { + it('returns exactly 9 tools', async () => { + const { tools } = await client.listTools(); + expect(tools).toHaveLength(9); + }); + + it('returns all expected tool names', async () => { + const { tools } = await client.listTools(); + const names = tools.map((t: Tool) => t.name).sort(); + expect(names).toEqual([...EXPECTED_TOOL_NAMES].sort()); + }); + + it('every tool has readOnlyHint: true', async () => { + const { tools } = await client.listTools(); + for (const tool of tools) { + expect(tool.annotations?.readOnlyHint).toBe(true); + } + }); + + it('every tool has a non-empty description', async () => { + const { tools } = await client.listTools(); + for (const tool of tools) { + expect(tool.description).toBeTruthy(); + expect(tool.description!.length).toBeGreaterThan(10); + } + }); +}); + +describe('tools/call', () => { + describe('sage_health', () => { + it('returns health status JSON with rest and soap sections', async () => { + const result = await client.callTool({ name: 'sage_health', arguments: {} }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.rest).toMatchObject({ status: 'connected', endpoint: 'SEED' }); + expect(parsed.soap).toMatchObject({ status: 'connected', poolAlias: 'SEED' }); + }); + }); + + describe('sage_query', () => { + it('returns paginated records', async () => { + const result = await client.callTool({ + name: 'sage_query', + arguments: { entity: 'BPCUSTOMER' }, + }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.records).toEqual([{ BPCNUM: 'C001', BPCNAM: 'Acme Corp' }]); + expect(parsed.pagination).toEqual({ returned: 1, hasMore: false }); + }); + + it('passes all parameters through to REST client', async () => { + await client.callTool({ + name: 'sage_query', + arguments: { + entity: 'SINVOICE', + where: "BPCNUM eq 'C001'", + orderBy: 'ACCDAT desc', + count: 50, + select: 'NUM,BPCNUM', + }, + }); + + expect(restMethods.query).toHaveBeenCalledWith( + expect.objectContaining({ + entity: 'SINVOICE', + where: "BPCNUM eq 'C001'", + orderBy: 'ACCDAT desc', + count: 50, + select: 'NUM,BPCNUM', + }), + ); + }); + }); + + describe('sage_read', () => { + it('returns single record by key', async () => { + const result = await client.callTool({ + name: 'sage_read', + arguments: { entity: 'BPCUSTOMER', key: 'C001' }, + }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.record).toEqual({ BPCNUM: 'C001', BPCNAM: 'Acme Corp', BPCSTA: 'Active' }); + }); + }); + + describe('sage_search', () => { + it('returns search results', async () => { + const result = await client.callTool({ + name: 'sage_search', + arguments: { entity: 'BPCUSTOMER', searchTerm: 'Acme' }, + }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.records).toBeDefined(); + expect(parsed.pagination).toBeDefined(); + }); + + it('builds where clause and calls REST query', async () => { + await client.callTool({ + name: 'sage_search', + arguments: { entity: 'BPCUSTOMER', searchTerm: 'Acme' }, + }); + + expect(restMethods.query).toHaveBeenCalledWith( + expect.objectContaining({ + entity: 'BPCUSTOMER', + where: expect.stringContaining('Acme'), + }), + ); + }); + }); + + describe('sage_list_entities', () => { + it('returns entity list', async () => { + const result = await client.callTool({ name: 'sage_list_entities', arguments: {} }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.entities).toEqual(['BPCUSTOMER', 'SINVOICE', 'SORDER']); + }); + }); + + describe('sage_get_context', () => { + it('returns field names from sample record', async () => { + restMethods.query.mockResolvedValue({ + records: [{ BPCNUM: 'C001', BPCNAM: 'Acme', $url: 'http://...' }], + pagination: { returned: 1, hasMore: false }, + }); + + const result = await client.callTool({ + name: 'sage_get_context', + arguments: { entity: 'BPCUSTOMER' }, + }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.entity).toBe('BPCUSTOMER'); + expect(parsed.fields).toContain('BPCNUM'); + expect(parsed.fields).toContain('BPCNAM'); + expect(parsed.fields).not.toContain('$url'); + }); + }); + + describe('sage_soap_read', () => { + it('returns SOAP record on success', async () => { + const result = await client.callTool({ + name: 'sage_soap_read', + arguments: { publicName: 'SIH', key: { NUM: 'INV001' } }, + }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.record).toEqual({ NUM: 'INV001', AMOUNT: 1500 }); + }); + }); + + describe('sage_soap_query', () => { + it('returns SOAP query results', async () => { + const result = await client.callTool({ + name: 'sage_soap_query', + arguments: { publicName: 'SIH' }, + }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.records).toEqual([{ NUM: 'INV001' }, { NUM: 'INV002' }]); + expect(parsed.pagination).toBeDefined(); + }); + }); + + describe('sage_describe_entity', () => { + it('returns field definitions with labels', async () => { + const result = await client.callTool({ + name: 'sage_describe_entity', + arguments: { publicName: 'SIH' }, + }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + + expect(parsed.publicName).toBe('SIH'); + expect(parsed.fields).toEqual([ + { name: 'NUM', type: 'Char', length: '20', label: 'Invoice number' }, + { name: 'AMOUNT', type: 'Decimal', length: '14', label: 'Amount' }, + ]); + expect(parsed.fieldCount).toBe(2); + }); + }); +}); + +describe('error propagation', () => { + it('REST client error → tool returns isError with hint', async () => { + restMethods.query.mockRejectedValue(new Error('HTTP 401: Unauthorized')); + + const result = await client.callTool({ + name: 'sage_query', + arguments: { entity: 'BPCUSTOMER' }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toContain('401'); + expect(text).toContain('Hint'); + }); + + it('SOAP client error → tool returns isError with hint', async () => { + soapMethods.read.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await client.callTool({ + name: 'sage_soap_read', + arguments: { publicName: 'SIH', key: { NUM: 'INV001' } }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toContain('ECONNREFUSED'); + expect(text).toContain('Hint'); + }); + + it('connection error includes actionable hint', async () => { + restMethods.read.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await client.callTool({ + name: 'sage_read', + arguments: { entity: 'BPCUSTOMER', key: 'C001' }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toContain('Hint'); + expect(text).toContain('connect'); + }); +}); + +describe('input validation', () => { + it('sage_query returns validation error when missing required entity', async () => { + const result = await client.callTool({ name: 'sage_query', arguments: {} }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toContain('entity'); + expect(text).toContain('invalid'); + }); + + it('sage_read returns validation error when missing required parameters', async () => { + const result = await client.callTool({ name: 'sage_read', arguments: {} }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toContain('entity'); + }); + + it('sage_soap_read returns validation error when missing required parameters', async () => { + const result = await client.callTool({ name: 'sage_soap_read', arguments: {} }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toContain('publicName'); + }); +});