feat(tools): add SOAP tools (sage_soap_read, sage_soap_query, sage_describe_entity)

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:14:47 +00:00
parent 8861e15019
commit 73e59412b4
6 changed files with 744 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerDescribeEntityTool } from '../sage-describe-entity.js';
import type { SoapClient } from '../../clients/soap-client.js';
import type { SoapResult } from '../../types/index.js';
function createMockSoapClient(overrides?: Partial<SoapClient>): SoapClient {
return {
read: vi.fn(),
query: vi.fn(),
getDescription: vi.fn(),
healthCheck: vi.fn(),
...overrides,
} as unknown as SoapClient;
}
describe('registerDescribeEntityTool', () => {
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_describe_entity tool with correct metadata', () => {
const soapClient = createMockSoapClient();
registerDescribeEntityTool(server, soapClient);
expect(registerToolSpy).toHaveBeenCalledOnce();
const [name, config] = registerToolSpy.mock.calls[0];
expect(name).toBe('sage_describe_entity');
expect(config).toMatchObject({
description: expect.stringContaining('Get field definitions'),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
});
});
describe('handler', () => {
async function callHandler(
soapClient: SoapClient,
args: { publicName: string },
): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> {
registerDescribeEntityTool(server, soapClient);
const handler = registerToolSpy.mock.calls[0][2] as (
args: { publicName: string },
) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>;
return handler(args);
}
it('parses field definitions with @_ prefix attributes (fast-xml-parser format)', async () => {
const soapClient = createMockSoapClient({
getDescription: vi.fn().mockResolvedValue({
status: 1,
data: {
FLD: [
{ '@_NAM': 'NUM', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Invoice no.' },
{ '@_NAM': 'BPRNUM', '@_TYP': 'Char', '@_LEN': '15', '@_C_ENG': 'Customer' },
{ '@_NAM': 'AMTATI', '@_TYP': 'Decimal', '@_LEN': '17.2', '@_C_ENG': 'Amount incl. tax' },
],
},
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.publicName).toBe('SIH');
expect(parsed.fieldCount).toBe(3);
expect(parsed.fields).toEqual([
{ name: 'NUM', type: 'Char', length: '20', label: 'Invoice no.' },
{ name: 'BPRNUM', type: 'Char', length: '15', label: 'Customer' },
{ name: 'AMTATI', type: 'Decimal', length: '17.2', label: 'Amount incl. tax' },
]);
});
it('parses field definitions with plain attribute names (JSON format)', async () => {
const soapClient = createMockSoapClient({
getDescription: vi.fn().mockResolvedValue({
status: 1,
data: {
FLD: [
{ NAM: 'SOHNUM', TYP: 'Char', LEN: '20', C_ENG: 'Order no.' },
],
},
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'SOH' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.fields).toEqual([
{ name: 'SOHNUM', type: 'Char', length: '20', label: 'Order no.' },
]);
});
it('handles nested FLD under a root element', async () => {
const soapClient = createMockSoapClient({
getDescription: vi.fn().mockResolvedValue({
status: 1,
data: {
OBJECT: {
FLD: [
{ '@_NAM': 'ITMREF', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Item' },
],
},
},
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'WITM' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.fields).toEqual([
{ name: 'ITMREF', type: 'Char', length: '20', label: 'Item' },
]);
});
it('handles single FLD object (not wrapped in array)', async () => {
const soapClient = createMockSoapClient({
getDescription: vi.fn().mockResolvedValue({
status: 1,
data: {
FLD: { '@_NAM': 'NUM', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Number' },
},
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.fieldCount).toBe(1);
expect(parsed.fields[0].name).toBe('NUM');
});
it('returns empty fields for null data', async () => {
const soapClient = createMockSoapClient({
getDescription: vi.fn().mockResolvedValue({
status: 1,
data: null,
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.fields).toEqual([]);
expect(parsed.fieldCount).toBe(0);
});
it('filters out entries with empty names', async () => {
const soapClient = createMockSoapClient({
getDescription: vi.fn().mockResolvedValue({
status: 1,
data: {
FLD: [
{ '@_NAM': 'NUM', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Number' },
{ '@_TYP': 'Char', '@_LEN': '10' },
],
},
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.fieldCount).toBe(1);
});
it('returns error on business error (status=0)', async () => {
const soapClient = createMockSoapClient({
getDescription: vi.fn().mockResolvedValue({
status: 0,
data: null,
messages: [{ type: 3, message: 'Unknown publication' }],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'INVALID' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Unknown publication');
});
it('handles transport errors with classified hints', async () => {
const soapClient = createMockSoapClient({
getDescription: vi.fn().mockRejectedValue(new Error('unauthorized')),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('unauthorized');
expect(result.content[0].text).toContain('Hint');
});
});
});

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerSoapQueryTool } from '../sage-soap-query.js';
import type { SoapClient } from '../../clients/soap-client.js';
import type { SoapResult } from '../../types/index.js';
function createMockSoapClient(overrides?: Partial<SoapClient>): SoapClient {
return {
read: vi.fn(),
query: vi.fn(),
getDescription: vi.fn(),
healthCheck: vi.fn(),
...overrides,
} as unknown as SoapClient;
}
describe('registerSoapQueryTool', () => {
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_soap_query tool with correct metadata', () => {
const soapClient = createMockSoapClient();
registerSoapQueryTool(server, soapClient);
expect(registerToolSpy).toHaveBeenCalledOnce();
const [name, config] = registerToolSpy.mock.calls[0];
expect(name).toBe('sage_soap_query');
expect(config).toMatchObject({
description: expect.stringContaining('Query Sage X3 records via SOAP'),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
});
});
describe('handler', () => {
async function callHandler(
soapClient: SoapClient,
args: { publicName: string; listSize?: number; inputXml?: string },
): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> {
registerSoapQueryTool(server, soapClient);
const handler = registerToolSpy.mock.calls[0][2] as (
args: { publicName: string; listSize?: number; inputXml?: string },
) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>;
return handler(args);
}
it('returns records on success (status=1)', async () => {
const mockRecords = [
{ NUM: 'INV001', AMOUNT: 1500 },
{ NUM: 'INV002', AMOUNT: 2000 },
];
const soapClient = createMockSoapClient({
query: vi.fn().mockResolvedValue({
status: 1,
data: mockRecords,
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
expect(result.isError).toBeUndefined();
expect(result.content).toHaveLength(1);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.records).toEqual(mockRecords);
expect(parsed.pagination).toEqual({ returned: 2, hasMore: false });
});
it('passes listSize to soapClient.query', async () => {
const queryMock = vi.fn().mockResolvedValue({
status: 1,
data: [],
messages: [],
technicalInfos: [],
} satisfies SoapResult);
const soapClient = createMockSoapClient({ query: queryMock });
await callHandler(soapClient, { publicName: 'SIH', listSize: 50 });
expect(queryMock).toHaveBeenCalledWith({
publicName: 'SIH',
listSize: 50,
inputXml: undefined,
});
});
it('returns empty records array when data is null', async () => {
const soapClient = createMockSoapClient({
query: vi.fn().mockResolvedValue({
status: 1,
data: null,
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.records).toEqual([]);
expect(parsed.pagination).toEqual({ returned: 0, hasMore: false });
});
it('wraps single object data into array', async () => {
const soapClient = createMockSoapClient({
query: vi.fn().mockResolvedValue({
status: 1,
data: { NUM: 'INV001' },
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.records).toEqual([{ NUM: 'INV001' }]);
expect(parsed.pagination.returned).toBe(1);
});
it('returns error on business error (status=0)', async () => {
const soapClient = createMockSoapClient({
query: vi.fn().mockResolvedValue({
status: 0,
data: null,
messages: [{ type: 3, message: 'Invalid publication name' }],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, { publicName: 'INVALID' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid publication name');
});
it('handles transport errors', async () => {
const soapClient = createMockSoapClient({
query: vi.fn().mockRejectedValue(new Error('timeout')),
});
const result = await callHandler(soapClient, { publicName: 'SIH' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('timeout');
});
});
});

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerSoapReadTool } from '../sage-soap-read.js';
import type { SoapClient } from '../../clients/soap-client.js';
import type { SoapResult } from '../../types/index.js';
function createMockSoapClient(overrides?: Partial<SoapClient>): SoapClient {
return {
read: vi.fn(),
query: vi.fn(),
getDescription: vi.fn(),
healthCheck: vi.fn(),
...overrides,
} as unknown as SoapClient;
}
describe('registerSoapReadTool', () => {
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_soap_read tool with correct metadata', () => {
const soapClient = createMockSoapClient();
registerSoapReadTool(server, soapClient);
expect(registerToolSpy).toHaveBeenCalledOnce();
const [name, config] = registerToolSpy.mock.calls[0];
expect(name).toBe('sage_soap_read');
expect(config).toMatchObject({
description: expect.stringContaining('Read a single Sage X3 record via SOAP'),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
});
});
describe('handler', () => {
async function callHandler(
soapClient: SoapClient,
args: { publicName: string; key: Record<string, string> },
): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> {
registerSoapReadTool(server, soapClient);
const handler = registerToolSpy.mock.calls[0][2] as (
args: { publicName: string; key: Record<string, string> },
) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>;
return handler(args);
}
it('returns record data on success (status=1)', async () => {
const mockData = { NUM: 'INV001', AMOUNT: 1500 };
const soapClient = createMockSoapClient({
read: vi.fn().mockResolvedValue({
status: 1,
data: mockData,
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, {
publicName: 'SIH',
key: { NUM: 'INV001' },
});
expect(result.isError).toBeUndefined();
expect(result.content).toHaveLength(1);
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toEqual({ record: mockData });
});
it('returns error with messages on business error (status=0)', async () => {
const soapClient = createMockSoapClient({
read: vi.fn().mockResolvedValue({
status: 0,
data: null,
messages: [
{ type: 3, message: 'Record not found' },
{ type: 3, message: 'Check key values' },
],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, {
publicName: 'SIH',
key: { NUM: 'INVALID' },
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Record not found');
expect(result.content[0].text).toContain('Check key values');
});
it('returns error with hint when status=0 and no messages', async () => {
const soapClient = createMockSoapClient({
read: vi.fn().mockResolvedValue({
status: 0,
data: null,
messages: [],
technicalInfos: [],
} satisfies SoapResult),
});
const result = await callHandler(soapClient, {
publicName: 'SIH',
key: { NUM: 'MISSING' },
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('SOAP read failed');
});
it('passes publicName and key correctly to soapClient.read', async () => {
const readMock = vi.fn().mockResolvedValue({
status: 1,
data: {},
messages: [],
technicalInfos: [],
} satisfies SoapResult);
const soapClient = createMockSoapClient({ read: readMock });
await callHandler(soapClient, {
publicName: 'SOH',
key: { SOHNUM: 'SO001', SOHREV: '0' },
});
expect(readMock).toHaveBeenCalledWith({
publicName: 'SOH',
key: { SOHNUM: 'SO001', SOHREV: '0' },
});
});
it('handles transport errors with classified hints', async () => {
const soapClient = createMockSoapClient({
read: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')),
});
const result = await callHandler(soapClient, {
publicName: 'SIH',
key: { NUM: 'INV001' },
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('ECONNREFUSED');
expect(result.content[0].text).toContain('Hint');
});
});
});