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:
216
src/tools/__tests__/sage-describe-entity.test.ts
Normal file
216
src/tools/__tests__/sage-describe-entity.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
158
src/tools/__tests__/sage-soap-query.test.ts
Normal file
158
src/tools/__tests__/sage-soap-query.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
155
src/tools/__tests__/sage-soap-read.test.ts
Normal file
155
src/tools/__tests__/sage-soap-read.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/tools/sage-describe-entity.ts
Normal file
102
src/tools/sage-describe-entity.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { SoapClient } from '../clients/soap-client.js';
|
||||||
|
import { formatToolError, classifyError, getErrorHint } from '../utils/errors.js';
|
||||||
|
|
||||||
|
interface FieldDefinition {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
length: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @_ prefix: fast-xml-parser attributeNamePrefix config in soap-client.ts
|
||||||
|
function parseFieldDefinitions(data: unknown): FieldDefinition[] {
|
||||||
|
if (!data || typeof data !== 'object') return [];
|
||||||
|
|
||||||
|
const fields: FieldDefinition[] = [];
|
||||||
|
const fldArray = extractFldArray(data);
|
||||||
|
|
||||||
|
for (const fld of fldArray) {
|
||||||
|
if (!fld || typeof fld !== 'object') continue;
|
||||||
|
const f = fld as Record<string, unknown>;
|
||||||
|
fields.push({
|
||||||
|
name: String(f['@_NAM'] ?? f['NAM'] ?? ''),
|
||||||
|
type: String(f['@_TYP'] ?? f['TYP'] ?? ''),
|
||||||
|
length: String(f['@_LEN'] ?? f['LEN'] ?? ''),
|
||||||
|
label: String(f['@_C_ENG'] ?? f['C_ENG'] ?? ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields.filter((f) => f.name !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFldArray(data: unknown): unknown[] {
|
||||||
|
if (!data || typeof data !== 'object') return [];
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (Array.isArray(obj['FLD'])) return obj['FLD'];
|
||||||
|
if (obj['FLD'] && typeof obj['FLD'] === 'object') return [obj['FLD']];
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const child = obj[key];
|
||||||
|
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
||||||
|
const childObj = child as Record<string, unknown>;
|
||||||
|
if (Array.isArray(childObj['FLD'])) return childObj['FLD'];
|
||||||
|
if (childObj['FLD'] && typeof childObj['FLD'] === 'object') return [childObj['FLD']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDescribeEntityTool(
|
||||||
|
server: McpServer,
|
||||||
|
soapClient: SoapClient,
|
||||||
|
): void {
|
||||||
|
server.registerTool(
|
||||||
|
'sage_describe_entity',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Get field definitions for a Sage X3 SOAP object. Returns field names, types, lengths, and English labels. Essential for understanding X3 field codes.',
|
||||||
|
inputSchema: {
|
||||||
|
publicName: z
|
||||||
|
.string()
|
||||||
|
.describe('SOAP publication name, e.g. SIH, SOH, WSBPC, WITM'),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ publicName }): Promise<CallToolResult> => {
|
||||||
|
try {
|
||||||
|
const result = await soapClient.getDescription(publicName);
|
||||||
|
|
||||||
|
if (result.status === 1) {
|
||||||
|
const fields = parseFieldDefinitions(result.data);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: JSON.stringify({ publicName, fields, fieldCount: fields.length }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = result.messages.map((m) => m.message).join('; ');
|
||||||
|
return formatToolError(
|
||||||
|
new Error(messages || 'Failed to get entity description'),
|
||||||
|
'Check the publicName. Common SOAP publications: SIH (invoices), SOH (sales orders), WSBPC (customers), WITM (items).',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const classification = classifyError(error);
|
||||||
|
return formatToolError(error, getErrorHint(classification));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/tools/sage-soap-query.ts
Normal file
61
src/tools/sage-soap-query.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import type { SoapClient } from '../clients/soap-client.js';
|
||||||
|
import { formatQueryResponse } from '../utils/response.js';
|
||||||
|
import { formatToolError, classifyError, getErrorHint } from '../utils/errors.js';
|
||||||
|
|
||||||
|
export function registerSoapQueryTool(
|
||||||
|
server: McpServer,
|
||||||
|
soapClient: SoapClient,
|
||||||
|
): void {
|
||||||
|
server.registerTool(
|
||||||
|
'sage_soap_query',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Query Sage X3 records via SOAP. Returns a list of records matching criteria. Use for bulk data retrieval via SOAP pools.',
|
||||||
|
inputSchema: {
|
||||||
|
publicName: z
|
||||||
|
.string()
|
||||||
|
.describe('SOAP publication name, e.g. SIH, SOH, WSBPC, WITM'),
|
||||||
|
listSize: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.describe('Maximum number of records to return (1-200, default 20)'),
|
||||||
|
inputXml: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional XML filter criteria for the query'),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ publicName, listSize, inputXml }) => {
|
||||||
|
try {
|
||||||
|
const result = await soapClient.query({ publicName, listSize, inputXml });
|
||||||
|
|
||||||
|
if (result.status === 1) {
|
||||||
|
const records = Array.isArray(result.data) ? result.data : result.data ? [result.data] : [];
|
||||||
|
return formatQueryResponse(records, {
|
||||||
|
returned: records.length,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = result.messages.map((m) => m.message).join('; ');
|
||||||
|
return formatToolError(
|
||||||
|
new Error(messages || 'SOAP query failed'),
|
||||||
|
'Check the publicName. Use sage_describe_entity to discover available fields.',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const classification = classifyError(error);
|
||||||
|
return formatToolError(error, getErrorHint(classification));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/tools/sage-soap-read.ts
Normal file
52
src/tools/sage-soap-read.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import type { SoapClient } from '../clients/soap-client.js';
|
||||||
|
import { formatReadResponse } from '../utils/response.js';
|
||||||
|
import { formatToolError, classifyError, getErrorHint } from '../utils/errors.js';
|
||||||
|
|
||||||
|
export function registerSoapReadTool(
|
||||||
|
server: McpServer,
|
||||||
|
soapClient: SoapClient,
|
||||||
|
): void {
|
||||||
|
server.registerTool(
|
||||||
|
'sage_soap_read',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Read a single Sage X3 record via SOAP by its key fields. Use for objects not available via REST, or when you need SOAP-specific data.',
|
||||||
|
inputSchema: {
|
||||||
|
publicName: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'SOAP publication name, e.g. SIH, SOH, WSBPC, WITM',
|
||||||
|
),
|
||||||
|
key: z
|
||||||
|
.record(z.string(), z.string())
|
||||||
|
.describe("Key field(s), e.g. {NUM: 'INV001'}"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ publicName, key }) => {
|
||||||
|
try {
|
||||||
|
const result = await soapClient.read({ publicName, key });
|
||||||
|
|
||||||
|
if (result.status === 1) {
|
||||||
|
return formatReadResponse(result.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = result.messages.map((m) => m.message).join('; ');
|
||||||
|
return formatToolError(
|
||||||
|
new Error(messages || 'SOAP read failed'),
|
||||||
|
'Check the publicName and key values. Use sage_describe_entity to discover field definitions.',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const classification = classifyError(error);
|
||||||
|
return formatToolError(error, getErrorHint(classification));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user