feat(tools): add REST discovery tools (sage_list_entities, sage_get_context)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
192
src/tools/__tests__/sage-get-context.test.ts
Normal file
192
src/tools/__tests__/sage-get-context.test.ts
Normal file
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
120
src/tools/__tests__/sage-list-entities.test.ts
Normal file
120
src/tools/__tests__/sage-list-entities.test.ts
Normal file
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user