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:
16
.env.example
Normal file
16
.env.example
Normal 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
104
README.md
Normal 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/
|
||||
```
|
||||
371
src/__tests__/integration.test.ts
Normal file
371
src/__tests__/integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user