feat(tools): add REST query tools (sage_query, sage_read, sage_search)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
199
src/tools/__tests__/sage-query.test.ts
Normal file
199
src/tools/__tests__/sage-query.test.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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> = {}): 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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
const schema = config.inputSchema as Record<string, unknown>;
|
||||||
|
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<string, unknown> = { entity: 'BPCUSTOMER' },
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
registerQueryTool(server, restClient);
|
||||||
|
const handler = registerToolSpy.mock.calls[0][2] as (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
) => Promise<CallToolResult>;
|
||||||
|
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:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
160
src/tools/__tests__/sage-read.test.ts
Normal file
160
src/tools/__tests__/sage-read.test.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { registerReadTool } from '../sage-read.js';
|
||||||
|
import type { RestClient } from '../../clients/rest-client.js';
|
||||||
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
function createMockRestClient(overrides: Partial<RestClient> = {}): RestClient {
|
||||||
|
return {
|
||||||
|
query: vi.fn(),
|
||||||
|
read: vi.fn().mockResolvedValue({ record: {} }),
|
||||||
|
listEntities: vi.fn(),
|
||||||
|
healthCheck: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as RestClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerReadTool', () => {
|
||||||
|
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_read tool with correct metadata', () => {
|
||||||
|
const restClient = createMockRestClient();
|
||||||
|
registerReadTool(server, restClient);
|
||||||
|
|
||||||
|
expect(registerToolSpy).toHaveBeenCalledOnce();
|
||||||
|
const [name, config] = registerToolSpy.mock.calls[0];
|
||||||
|
expect(name).toBe('sage_read');
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
description: expect.stringContaining('Read a single Sage X3 record'),
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has inputSchema with entity, key, and representation fields', () => {
|
||||||
|
const restClient = createMockRestClient();
|
||||||
|
registerReadTool(server, restClient);
|
||||||
|
|
||||||
|
const config = registerToolSpy.mock.calls[0][1] as Record<string, unknown>;
|
||||||
|
const schema = config.inputSchema as Record<string, unknown>;
|
||||||
|
expect(schema).toHaveProperty('entity');
|
||||||
|
expect(schema).toHaveProperty('key');
|
||||||
|
expect(schema).toHaveProperty('representation');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handler', () => {
|
||||||
|
async function callHandler(
|
||||||
|
restClient: RestClient,
|
||||||
|
args: Record<string, unknown> = { entity: 'BPCUSTOMER', key: 'C001' },
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
registerReadTool(server, restClient);
|
||||||
|
const handler = registerToolSpy.mock.calls[0][2] as (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
) => Promise<CallToolResult>;
|
||||||
|
return handler(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns full record on success', async () => {
|
||||||
|
const mockRecord = {
|
||||||
|
BPCNUM: 'C001',
|
||||||
|
BPCNAM: 'Acme Corp',
|
||||||
|
BPCADD: '123 Main St',
|
||||||
|
CRY: 'US',
|
||||||
|
};
|
||||||
|
const restClient = createMockRestClient({
|
||||||
|
read: vi.fn().mockResolvedValue({ record: mockRecord }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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.record).toEqual(mockRecord);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes entity, key, and representation to restClient.read', async () => {
|
||||||
|
const readMock = vi.fn().mockResolvedValue({ record: {} });
|
||||||
|
const restClient = createMockRestClient({ read: readMock });
|
||||||
|
|
||||||
|
await callHandler(restClient, {
|
||||||
|
entity: 'SINVOICE',
|
||||||
|
key: 'INV001',
|
||||||
|
representation: 'SINV$details',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readMock).toHaveBeenCalledWith('SINVOICE', 'INV001', 'SINV$details');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes undefined representation when not provided', async () => {
|
||||||
|
const readMock = vi.fn().mockResolvedValue({ record: {} });
|
||||||
|
const restClient = createMockRestClient({ read: readMock });
|
||||||
|
|
||||||
|
await callHandler(restClient, { entity: 'BPCUSTOMER', key: 'C001' });
|
||||||
|
|
||||||
|
expect(readMock).toHaveBeenCalledWith('BPCUSTOMER', 'C001', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error with not_found hint on 404', async () => {
|
||||||
|
const restClient = createMockRestClient({
|
||||||
|
read: vi.fn().mockRejectedValue(new Error('HTTP 404: Not Found')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callHandler(restClient);
|
||||||
|
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
const text = (result.content[0] as { text: string }).text;
|
||||||
|
expect(text).toContain('404');
|
||||||
|
expect(text).toContain('Hint:');
|
||||||
|
expect(text).toContain('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error with auth hint on 401', async () => {
|
||||||
|
const restClient = createMockRestClient({
|
||||||
|
read: 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('handles key with special characters', async () => {
|
||||||
|
const readMock = vi.fn().mockResolvedValue({ record: { NUM: 'INV/2024/001' } });
|
||||||
|
const restClient = createMockRestClient({ read: readMock });
|
||||||
|
|
||||||
|
const result = await callHandler(restClient, {
|
||||||
|
entity: 'SINVOICE',
|
||||||
|
key: 'INV/2024/001',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readMock).toHaveBeenCalledWith('SINVOICE', 'INV/2024/001', undefined);
|
||||||
|
expect(result.isError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns minified JSON response', async () => {
|
||||||
|
const restClient = createMockRestClient({
|
||||||
|
read: vi.fn().mockResolvedValue({ record: { A: 1 } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callHandler(restClient);
|
||||||
|
|
||||||
|
const text = (result.content[0] as { text: string }).text;
|
||||||
|
expect(text).not.toContain('\n');
|
||||||
|
expect(text).toBe('{"record":{"A":1}}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
211
src/tools/__tests__/sage-search.test.ts
Normal file
211
src/tools/__tests__/sage-search.test.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { registerSearchTool, buildWhereClause } from '../sage-search.js';
|
||||||
|
import type { RestClient } from '../../clients/rest-client.js';
|
||||||
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
function createMockRestClient(overrides: Partial<RestClient> = {}): 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('buildWhereClause', () => {
|
||||||
|
it('builds clause with default fields based on entity name', () => {
|
||||||
|
const result = buildWhereClause('BPCUSTOMER', 'acme');
|
||||||
|
expect(result).toBe("(contains(BPCUSTOMERNUM,'acme') or contains(BPCUSTOMERNAM,'acme'))");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds clause with custom searchFields', () => {
|
||||||
|
const result = buildWhereClause('BPCUSTOMER', 'acme', ['BPCNAM', 'BPCNUM']);
|
||||||
|
expect(result).toBe("(contains(BPCNAM,'acme') or contains(BPCNUM,'acme'))");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds clause with single custom field (no parens wrapping)', () => {
|
||||||
|
const result = buildWhereClause('BPCUSTOMER', 'acme', ['BPCNAM']);
|
||||||
|
expect(result).toBe("contains(BPCNAM,'acme')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default fields when searchFields is empty array', () => {
|
||||||
|
const result = buildWhereClause('SINVOICE', 'INV001', []);
|
||||||
|
expect(result).toBe("(contains(SINVOICENUM,'INV001') or contains(SINVOICENAM,'INV001'))");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles search term with spaces', () => {
|
||||||
|
const result = buildWhereClause('BPCUSTOMER', 'Acme Corp', ['BPCNAM']);
|
||||||
|
expect(result).toBe("contains(BPCNAM,'Acme Corp')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds clause with three fields', () => {
|
||||||
|
const result = buildWhereClause('ITMMASTER', 'widget', ['ITMREF', 'ITMDES', 'ITMDES2']);
|
||||||
|
expect(result).toBe(
|
||||||
|
"(contains(ITMREF,'widget') or contains(ITMDES,'widget') or contains(ITMDES2,'widget'))",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerSearchTool', () => {
|
||||||
|
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_search tool with correct metadata', () => {
|
||||||
|
const restClient = createMockRestClient();
|
||||||
|
registerSearchTool(server, restClient);
|
||||||
|
|
||||||
|
expect(registerToolSpy).toHaveBeenCalledOnce();
|
||||||
|
const [name, config] = registerToolSpy.mock.calls[0];
|
||||||
|
expect(name).toBe('sage_search');
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
description: expect.stringContaining('Search Sage X3'),
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has inputSchema with expected fields', () => {
|
||||||
|
const restClient = createMockRestClient();
|
||||||
|
registerSearchTool(server, restClient);
|
||||||
|
|
||||||
|
const config = registerToolSpy.mock.calls[0][1] as Record<string, unknown>;
|
||||||
|
const schema = config.inputSchema as Record<string, unknown>;
|
||||||
|
expect(schema).toHaveProperty('entity');
|
||||||
|
expect(schema).toHaveProperty('searchTerm');
|
||||||
|
expect(schema).toHaveProperty('searchFields');
|
||||||
|
expect(schema).toHaveProperty('count');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handler', () => {
|
||||||
|
async function callHandler(
|
||||||
|
restClient: RestClient,
|
||||||
|
args: Record<string, unknown> = { entity: 'BPCUSTOMER', searchTerm: 'acme' },
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
registerSearchTool(server, restClient);
|
||||||
|
const handler = registerToolSpy.mock.calls[0][2] as (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
) => Promise<CallToolResult>;
|
||||||
|
return handler(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('calls restClient.query with built where clause using default fields', async () => {
|
||||||
|
const queryMock = vi.fn().mockResolvedValue({
|
||||||
|
records: [],
|
||||||
|
pagination: { returned: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
const restClient = createMockRestClient({ query: queryMock });
|
||||||
|
|
||||||
|
await callHandler(restClient, { entity: 'BPCUSTOMER', searchTerm: 'acme' });
|
||||||
|
|
||||||
|
expect(queryMock).toHaveBeenCalledWith({
|
||||||
|
entity: 'BPCUSTOMER',
|
||||||
|
where: "(contains(BPCUSTOMERNUM,'acme') or contains(BPCUSTOMERNAM,'acme'))",
|
||||||
|
count: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls restClient.query with custom searchFields', async () => {
|
||||||
|
const queryMock = vi.fn().mockResolvedValue({
|
||||||
|
records: [],
|
||||||
|
pagination: { returned: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
const restClient = createMockRestClient({ query: queryMock });
|
||||||
|
|
||||||
|
await callHandler(restClient, {
|
||||||
|
entity: 'BPCUSTOMER',
|
||||||
|
searchTerm: 'acme',
|
||||||
|
searchFields: ['BPCNAM', 'BPCNUM'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryMock).toHaveBeenCalledWith({
|
||||||
|
entity: 'BPCUSTOMER',
|
||||||
|
where: "(contains(BPCNAM,'acme') or contains(BPCNUM,'acme'))",
|
||||||
|
count: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes count to restClient.query', async () => {
|
||||||
|
const queryMock = vi.fn().mockResolvedValue({
|
||||||
|
records: [],
|
||||||
|
pagination: { returned: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
const restClient = createMockRestClient({ query: queryMock });
|
||||||
|
|
||||||
|
await callHandler(restClient, {
|
||||||
|
entity: 'BPCUSTOMER',
|
||||||
|
searchTerm: 'acme',
|
||||||
|
count: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ count: 50 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns matching records on success', async () => {
|
||||||
|
const mockRecords = [{ BPCNUM: 'C001', BPCNAM: 'Acme Corp' }];
|
||||||
|
const restClient = createMockRestClient({
|
||||||
|
query: vi.fn().mockResolvedValue({
|
||||||
|
records: mockRecords,
|
||||||
|
pagination: { returned: 1, hasMore: false },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callHandler(restClient);
|
||||||
|
|
||||||
|
expect(result.isError).toBeUndefined();
|
||||||
|
const parsed = JSON.parse((result.content[0] as { text: string }).text);
|
||||||
|
expect(parsed.records).toEqual(mockRecords);
|
||||||
|
expect(parsed.pagination.returned).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty results when no matches found', async () => {
|
||||||
|
const restClient = createMockRestClient();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error with hint on 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 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/tools/sage-query.ts
Normal file
46
src/tools/sage-query.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import type { RestClient } from '../clients/rest-client.js';
|
||||||
|
import { formatQueryResponse } from '../utils/response.js';
|
||||||
|
import { formatToolError, classifyError, getErrorHint } from '../utils/errors.js';
|
||||||
|
|
||||||
|
export function registerQueryTool(server: McpServer, restClient: RestClient): void {
|
||||||
|
server.registerTool(
|
||||||
|
'sage_query',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Query Sage X3 business objects via REST. Returns paginated records. Use entity names like BPCUSTOMER, SINVOICE, SORDER, PORDER, ITMMASTER, STOCK.',
|
||||||
|
inputSchema: {
|
||||||
|
entity: z.string(),
|
||||||
|
representation: z.string().optional(),
|
||||||
|
where: z.string().optional(),
|
||||||
|
orderBy: z.string().optional(),
|
||||||
|
count: z.number().min(1).max(200).optional(),
|
||||||
|
nextUrl: z.string().optional(),
|
||||||
|
select: z.string().optional(),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
try {
|
||||||
|
const result = await restClient.query({
|
||||||
|
entity: args.entity,
|
||||||
|
representation: args.representation,
|
||||||
|
where: args.where,
|
||||||
|
orderBy: args.orderBy,
|
||||||
|
count: args.count,
|
||||||
|
nextUrl: args.nextUrl,
|
||||||
|
select: args.select,
|
||||||
|
});
|
||||||
|
return formatQueryResponse(result.records, result.pagination);
|
||||||
|
} catch (error) {
|
||||||
|
return formatToolError(error, getErrorHint(classifyError(error)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/tools/sage-read.ts
Normal file
34
src/tools/sage-read.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import type { RestClient } from '../clients/rest-client.js';
|
||||||
|
import { formatReadResponse } from '../utils/response.js';
|
||||||
|
import { formatToolError, classifyError, getErrorHint } from '../utils/errors.js';
|
||||||
|
|
||||||
|
export function registerReadTool(server: McpServer, restClient: RestClient): void {
|
||||||
|
server.registerTool(
|
||||||
|
'sage_read',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Read a single Sage X3 record by its primary key. Returns full record details. Example: entity=SINVOICE, key=INV001.',
|
||||||
|
inputSchema: {
|
||||||
|
entity: z.string(),
|
||||||
|
key: z.string(),
|
||||||
|
representation: z.string().optional(),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
try {
|
||||||
|
const result = await restClient.read(args.entity, args.key, args.representation);
|
||||||
|
return formatReadResponse(result.record);
|
||||||
|
} catch (error) {
|
||||||
|
return formatToolError(error, getErrorHint(classifyError(error)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/tools/sage-search.ts
Normal file
53
src/tools/sage-search.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import type { RestClient } from '../clients/rest-client.js';
|
||||||
|
import { formatQueryResponse } from '../utils/response.js';
|
||||||
|
import { formatToolError, classifyError, getErrorHint } from '../utils/errors.js';
|
||||||
|
|
||||||
|
function buildWhereClause(entity: string, searchTerm: string, searchFields?: string[]): string {
|
||||||
|
const fields = searchFields && searchFields.length > 0
|
||||||
|
? searchFields
|
||||||
|
: [`${entity}NUM`, `${entity}NAM`];
|
||||||
|
|
||||||
|
const conditions = fields.map((field) => `contains(${field},'${searchTerm}')`);
|
||||||
|
return conditions.length === 1
|
||||||
|
? conditions[0]
|
||||||
|
: `(${conditions.join(' or ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSearchTool(server: McpServer, restClient: RestClient): void {
|
||||||
|
server.registerTool(
|
||||||
|
'sage_search',
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'Search Sage X3 records with flexible text matching. Builds SData where clauses from a search term across common fields.',
|
||||||
|
inputSchema: {
|
||||||
|
entity: z.string(),
|
||||||
|
searchTerm: z.string(),
|
||||||
|
searchFields: z.array(z.string()).optional(),
|
||||||
|
count: z.number().min(1).max(200).optional(),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
try {
|
||||||
|
const where = buildWhereClause(args.entity, args.searchTerm, args.searchFields);
|
||||||
|
const result = await restClient.query({
|
||||||
|
entity: args.entity,
|
||||||
|
where,
|
||||||
|
count: args.count,
|
||||||
|
});
|
||||||
|
return formatQueryResponse(result.records, result.pagination);
|
||||||
|
} catch (error) {
|
||||||
|
return formatToolError(error, getErrorHint(classifyError(error)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildWhereClause };
|
||||||
Reference in New Issue
Block a user