From 73e59412b487b1f304a72792f6abcc80dc66794f Mon Sep 17 00:00:00 2001 From: repi Date: Tue, 10 Mar 2026 17:14:47 +0000 Subject: [PATCH] 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 --- .../__tests__/sage-describe-entity.test.ts | 216 ++++++++++++++++++ src/tools/__tests__/sage-soap-query.test.ts | 158 +++++++++++++ src/tools/__tests__/sage-soap-read.test.ts | 155 +++++++++++++ src/tools/sage-describe-entity.ts | 102 +++++++++ src/tools/sage-soap-query.ts | 61 +++++ src/tools/sage-soap-read.ts | 52 +++++ 6 files changed, 744 insertions(+) create mode 100644 src/tools/__tests__/sage-describe-entity.test.ts create mode 100644 src/tools/__tests__/sage-soap-query.test.ts create mode 100644 src/tools/__tests__/sage-soap-read.test.ts create mode 100644 src/tools/sage-describe-entity.ts create mode 100644 src/tools/sage-soap-query.ts create mode 100644 src/tools/sage-soap-read.ts diff --git a/src/tools/__tests__/sage-describe-entity.test.ts b/src/tools/__tests__/sage-describe-entity.test.ts new file mode 100644 index 0000000..dcf0728 --- /dev/null +++ b/src/tools/__tests__/sage-describe-entity.test.ts @@ -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 { + 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; + + 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'); + }); + }); +}); diff --git a/src/tools/__tests__/sage-soap-query.test.ts b/src/tools/__tests__/sage-soap-query.test.ts new file mode 100644 index 0000000..f865470 --- /dev/null +++ b/src/tools/__tests__/sage-soap-query.test.ts @@ -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 { + 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; + + 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'); + }); + }); +}); diff --git a/src/tools/__tests__/sage-soap-read.test.ts b/src/tools/__tests__/sage-soap-read.test.ts new file mode 100644 index 0000000..6f06388 --- /dev/null +++ b/src/tools/__tests__/sage-soap-read.test.ts @@ -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 { + 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; + + 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 }, + ): 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 }, + ) => 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'); + }); + }); +}); diff --git a/src/tools/sage-describe-entity.ts b/src/tools/sage-describe-entity.ts new file mode 100644 index 0000000..72b122b --- /dev/null +++ b/src/tools/sage-describe-entity.ts @@ -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; + 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; + + 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; + 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 => { + 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)); + } + }, + ); +} diff --git a/src/tools/sage-soap-query.ts b/src/tools/sage-soap-query.ts new file mode 100644 index 0000000..50d5e89 --- /dev/null +++ b/src/tools/sage-soap-query.ts @@ -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)); + } + }, + ); +} diff --git a/src/tools/sage-soap-read.ts b/src/tools/sage-soap-read.ts new file mode 100644 index 0000000..a1b9820 --- /dev/null +++ b/src/tools/sage-soap-read.ts @@ -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)); + } + }, + ); +}