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