import { describe, it, expect, vi, beforeEach } from 'vitest'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerQueryTool } from '../sage-query.js'; import type { RestClient } from '../../clients/rest-client.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; function createMockRestClient(overrides: Partial = {}): RestClient { return { query: vi.fn().mockResolvedValue({ records: [], pagination: { returned: 0, hasMore: false }, }), read: vi.fn(), listEntities: vi.fn(), healthCheck: vi.fn(), ...overrides, } as unknown as RestClient; } describe('registerQueryTool', () => { 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_query tool with correct metadata', () => { const restClient = createMockRestClient(); registerQueryTool(server, restClient); expect(registerToolSpy).toHaveBeenCalledOnce(); const [name, config] = registerToolSpy.mock.calls[0]; expect(name).toBe('sage_query'); expect(config).toMatchObject({ description: expect.stringContaining('Query Sage X3'), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }); }); it('has inputSchema with expected fields', () => { const restClient = createMockRestClient(); registerQueryTool(server, restClient); const config = registerToolSpy.mock.calls[0][1] as Record; const schema = config.inputSchema as Record; expect(schema).toHaveProperty('entity'); expect(schema).toHaveProperty('representation'); expect(schema).toHaveProperty('where'); expect(schema).toHaveProperty('orderBy'); expect(schema).toHaveProperty('count'); expect(schema).toHaveProperty('nextUrl'); expect(schema).toHaveProperty('select'); }); describe('handler', () => { async function callHandler( restClient: RestClient, args: Record = { entity: 'BPCUSTOMER' }, ): Promise { registerQueryTool(server, restClient); const handler = registerToolSpy.mock.calls[0][2] as ( args: Record, ) => Promise; return handler(args); } it('returns paginated records on success', async () => { const mockRecords = [ { BPCNUM: 'C001', BPCNAM: 'Acme Corp' }, { BPCNUM: 'C002', BPCNAM: 'Widget Inc' }, ]; const restClient = createMockRestClient({ query: vi.fn().mockResolvedValue({ records: mockRecords, pagination: { returned: 2, hasMore: true, nextUrl: 'https://x3.example.com/next' }, }), }); const result = await callHandler(restClient); expect(result.isError).toBeUndefined(); expect(result.content).toHaveLength(1); const parsed = JSON.parse((result.content[0] as { text: string }).text); expect(parsed.records).toEqual(mockRecords); expect(parsed.pagination).toEqual({ returned: 2, hasMore: true, nextUrl: 'https://x3.example.com/next', }); }); it('passes all query parameters to restClient.query', async () => { const queryMock = vi.fn().mockResolvedValue({ records: [], pagination: { returned: 0, hasMore: false }, }); const restClient = createMockRestClient({ query: queryMock }); await callHandler(restClient, { entity: 'SINVOICE', representation: 'SINV$query', where: "BPCNUM eq 'C001'", orderBy: 'ACCDAT desc', count: 50, select: 'NUM,BPCNUM', }); expect(queryMock).toHaveBeenCalledWith({ entity: 'SINVOICE', representation: 'SINV$query', where: "BPCNUM eq 'C001'", orderBy: 'ACCDAT desc', count: 50, nextUrl: undefined, select: 'NUM,BPCNUM', }); }); it('passes nextUrl for pagination continuation', async () => { const queryMock = vi.fn().mockResolvedValue({ records: [], pagination: { returned: 0, hasMore: false }, }); const restClient = createMockRestClient({ query: queryMock }); await callHandler(restClient, { entity: 'BPCUSTOMER', nextUrl: 'https://x3.example.com/api1/x3/erp/SEED/BPCUSTOMER?next=abc', }); expect(queryMock).toHaveBeenCalledWith( expect.objectContaining({ nextUrl: 'https://x3.example.com/api1/x3/erp/SEED/BPCUSTOMER?next=abc', }), ); }); it('returns empty records when no results found', async () => { const restClient = createMockRestClient({ query: vi.fn().mockResolvedValue({ records: [], pagination: { returned: 0, hasMore: false }, }), }); const result = await callHandler(restClient); const parsed = JSON.parse((result.content[0] as { text: string }).text); expect(parsed.records).toEqual([]); expect(parsed.pagination.returned).toBe(0); expect(parsed.pagination.hasMore).toBe(false); }); it('returns error with hint on authentication failure', async () => { const restClient = createMockRestClient({ query: vi.fn().mockRejectedValue(new Error('HTTP 401: Unauthorized')), }); const result = await callHandler(restClient); expect(result.isError).toBe(true); const text = (result.content[0] as { text: string }).text; expect(text).toContain('401'); expect(text).toContain('Hint:'); }); it('returns error with hint on timeout', async () => { const restClient = createMockRestClient({ query: vi.fn().mockRejectedValue(new Error('Request timeout')), }); const result = await callHandler(restClient); expect(result.isError).toBe(true); const text = (result.content[0] as { text: string }).text; expect(text).toContain('timeout'); }); it('returns error with hint on connection failure', async () => { const restClient = createMockRestClient({ query: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')), }); const result = await callHandler(restClient); expect(result.isError).toBe(true); const text = (result.content[0] as { text: string }).text; expect(text).toContain('ECONNREFUSED'); expect(text).toContain('Hint:'); }); }); });