diff --git a/src/tools/__tests__/sage-get-context.test.ts b/src/tools/__tests__/sage-get-context.test.ts new file mode 100644 index 0000000..4d69d43 --- /dev/null +++ b/src/tools/__tests__/sage-get-context.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerGetContextTool } from '../sage-get-context.js'; +import type { RestClient } from '../../clients/rest-client.js'; +import type { RestQueryResult } from '../../types/index.js'; + +function createMockRestClient( + queryResult?: RestQueryResult, + shouldReject = false, + rejectError?: Error, +): RestClient { + return { + query: shouldReject + ? vi.fn().mockRejectedValue(rejectError ?? new Error('Connection refused')) + : vi.fn().mockResolvedValue( + queryResult ?? { + records: [ + { + ITMREF: 'PROD001', + ITMDES1: 'Widget', + PRICE: 29.99, + $uuid: 'abc-123', + $etag: 'xyz', + }, + ], + pagination: { returned: 1, hasMore: false }, + }, + ), + } as unknown as RestClient; +} + +describe('registerGetContextTool', () => { + 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_get_context tool with correct metadata', () => { + const restClient = createMockRestClient(); + + registerGetContextTool(server, restClient); + + expect(registerToolSpy).toHaveBeenCalledOnce(); + const [name, config] = registerToolSpy.mock.calls[0]; + expect(name).toBe('sage_get_context'); + expect(config).toMatchObject({ + description: expect.stringContaining('Get field names and metadata'), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }); + }); + + it('registers with entity input schema', () => { + const restClient = createMockRestClient(); + + registerGetContextTool(server, restClient); + + const [, config] = registerToolSpy.mock.calls[0]; + expect(config).toHaveProperty('inputSchema'); + }); + + describe('handler', () => { + async function callHandler( + restClient: RestClient, + args: { entity: string; representation?: string }, + ): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { + registerGetContextTool(server, restClient); + const handler = registerToolSpy.mock.calls[0][2] as (args: { + entity: string; + representation?: string; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + return handler(args); + } + + it('extracts fields from sample record and filters $ keys', async () => { + const restClient = createMockRestClient({ + records: [ + { + ITMREF: 'PROD001', + ITMDES1: 'Widget', + PRICE: 29.99, + $uuid: 'abc-123', + $etag: 'xyz', + }, + ], + pagination: { returned: 1, hasMore: false }, + }); + + const result = await callHandler(restClient, { entity: 'PRODUCT' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.entity).toBe('PRODUCT'); + expect(parsed.fields).toEqual(['ITMREF', 'ITMDES1', 'PRICE']); + expect(parsed.fields).not.toContain('$uuid'); + expect(parsed.fields).not.toContain('$etag'); + expect(parsed.sampleRecord).toEqual({ + ITMREF: 'PROD001', + ITMDES1: 'Widget', + PRICE: 29.99, + $uuid: 'abc-123', + $etag: 'xyz', + }); + }); + + it('returns null sample when no records returned', async () => { + const restClient = createMockRestClient({ + records: [], + pagination: { returned: 0, hasMore: false }, + }); + + const result = await callHandler(restClient, { entity: 'EMPTYENTITY' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.entity).toBe('EMPTYENTITY'); + expect(parsed.fields).toEqual([]); + expect(parsed.sampleRecord).toBeNull(); + }); + + it('passes entity and representation to restClient.query', async () => { + const restClient = createMockRestClient(); + + await callHandler(restClient, { entity: 'SALESORDER', representation: 'SOH' }); + + expect(restClient.query).toHaveBeenCalledWith({ + entity: 'SALESORDER', + representation: 'SOH', + count: 1, + }); + }); + + it('passes only entity when no representation given', async () => { + const restClient = createMockRestClient(); + + await callHandler(restClient, { entity: 'CUSTOMER' }); + + expect(restClient.query).toHaveBeenCalledWith({ + entity: 'CUSTOMER', + representation: undefined, + count: 1, + }); + }); + + it('handles errors with formatted error response', async () => { + const restClient = createMockRestClient( + undefined, + true, + new Error('HTTP 404: Not Found'), + ); + + const result = await callHandler(restClient, { entity: 'BADENTITY' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('HTTP 404: Not Found'); + expect(result.content[0].text).toContain('Hint:'); + }); + + it('handles timeout errors with appropriate hint', async () => { + const restClient = createMockRestClient( + undefined, + true, + new Error('Request timeout exceeded'), + ); + + const result = await callHandler(restClient, { entity: 'SLOWENTITY' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('timeout'); + }); + + it('returns valid MCP content format', async () => { + const restClient = createMockRestClient(); + + const result = await callHandler(restClient, { entity: 'PRODUCT' }); + + expect(result).toHaveProperty('content'); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content[0]).toHaveProperty('type', 'text'); + expect(result.content[0]).toHaveProperty('text'); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + }); +}); diff --git a/src/tools/__tests__/sage-list-entities.test.ts b/src/tools/__tests__/sage-list-entities.test.ts new file mode 100644 index 0000000..e23b9ca --- /dev/null +++ b/src/tools/__tests__/sage-list-entities.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerListEntitiesTool } from '../sage-list-entities.js'; +import type { RestClient } from '../../clients/rest-client.js'; + +function createMockRestClient( + entities?: string[], + shouldReject = false, + rejectError?: Error, +): RestClient { + return { + listEntities: shouldReject + ? vi.fn().mockRejectedValue(rejectError ?? new Error('Connection refused')) + : vi.fn().mockResolvedValue(entities ?? ['SALESORDER', 'CUSTOMER', 'PRODUCT']), + } as unknown as RestClient; +} + +describe('registerListEntitiesTool', () => { + 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_list_entities tool with correct metadata', () => { + const restClient = createMockRestClient(); + + registerListEntitiesTool(server, restClient); + + expect(registerToolSpy).toHaveBeenCalledOnce(); + const [name, config] = registerToolSpy.mock.calls[0]; + expect(name).toBe('sage_list_entities'); + expect(config).toMatchObject({ + description: expect.stringContaining('List available Sage X3 REST entity types'), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }); + }); + + describe('handler', () => { + async function callHandler( + restClient: RestClient, + ): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { + registerListEntitiesTool(server, restClient); + const handler = registerToolSpy.mock.calls[0][2] as () => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + return handler(); + } + + it('returns entity array on success', async () => { + const restClient = createMockRestClient(['SALESORDER', 'CUSTOMER', 'PRODUCT']); + + const result = await callHandler(restClient); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual({ + entities: ['SALESORDER', 'CUSTOMER', 'PRODUCT'], + }); + }); + + it('returns empty array when no entities available', async () => { + const restClient = createMockRestClient([]); + + const result = await callHandler(restClient); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual({ entities: [] }); + }); + + it('handles errors with formatted error response', async () => { + const restClient = createMockRestClient( + undefined, + true, + new Error('HTTP 401: Unauthorized'), + ); + + const result = await callHandler(restClient); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('HTTP 401: Unauthorized'); + expect(result.content[0].text).toContain('Hint:'); + }); + + it('handles connection errors with appropriate hint', async () => { + const restClient = createMockRestClient( + undefined, + true, + new Error('ECONNREFUSED: connect failed'), + ); + + const result = await callHandler(restClient); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('ECONNREFUSED'); + expect(result.content[0].text).toContain('Hint:'); + }); + + it('returns valid MCP content format', async () => { + const restClient = createMockRestClient(); + + const result = await callHandler(restClient); + + expect(result).toHaveProperty('content'); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content[0]).toHaveProperty('type', 'text'); + expect(result.content[0]).toHaveProperty('text'); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + }); +}); diff --git a/src/tools/sage-get-context.ts b/src/tools/sage-get-context.ts new file mode 100644 index 0000000..16f1304 --- /dev/null +++ b/src/tools/sage-get-context.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { RestClient } from '../clients/rest-client.js'; +import { formatToolError, classifyError, getErrorHint } from '../utils/errors.js'; + +export function registerGetContextTool(server: McpServer, restClient: RestClient): void { + server.registerTool( + 'sage_get_context', + { + description: + 'Get field names and metadata for a Sage X3 entity via REST. Returns available fields, their types, and sample structure.', + inputSchema: { + entity: z.string(), + representation: z.string().optional(), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (args: { entity: string; representation?: string }): Promise => { + try { + const { entity, representation } = args; + const result = await restClient.query({ entity, representation, count: 1 }); + + const sampleRecord = + result.records.length > 0 ? (result.records[0] as Record) : null; + + const fields: string[] = sampleRecord + ? Object.keys(sampleRecord).filter((key) => !key.startsWith('$')) + : []; + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ entity, fields, sampleRecord }), + }, + ], + }; + } catch (error) { + return formatToolError(error, getErrorHint(classifyError(error))); + } + }, + ); +} diff --git a/src/tools/sage-list-entities.ts b/src/tools/sage-list-entities.ts new file mode 100644 index 0000000..c9174c8 --- /dev/null +++ b/src/tools/sage-list-entities.ts @@ -0,0 +1,31 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { RestClient } from '../clients/rest-client.js'; +import { formatToolError, classifyError, getErrorHint } from '../utils/errors.js'; + +export function registerListEntitiesTool(server: McpServer, restClient: RestClient): void { + server.registerTool( + 'sage_list_entities', + { + description: + 'List available Sage X3 REST entity types (classes) on the configured endpoint. Use this to discover what data you can query.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (): Promise => { + try { + const entities = await restClient.listEntities(); + + return { + content: [{ type: 'text' as const, text: JSON.stringify({ entities }) }], + }; + } catch (error) { + return formatToolError(error, getErrorHint(classifyError(error))); + } + }, + ); +}