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();
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/tools/sage-get-context.ts
Normal file
49
src/tools/sage-get-context.ts
Normal file
@@ -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<CallToolResult> => {
|
||||
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<string, unknown>) : 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)));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
31
src/tools/sage-list-entities.ts
Normal file
31
src/tools/sage-list-entities.ts
Normal file
@@ -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<CallToolResult> => {
|
||||
try {
|
||||
const entities = await restClient.listEntities();
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ entities }) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return formatToolError(error, getErrorHint(classifyError(error)));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user