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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-03-10 17:33:53 +00:00
parent 0af3af3ff2
commit 8fc6d7cbc0
3 changed files with 491 additions and 0 deletions

16
.env.example Normal file
View File

@@ -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

104
README.md Normal file
View File

@@ -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/
```

View File

@@ -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');
});
});