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:
2026-03-10 17:11:45 +00:00
parent 2de89ad718
commit 8861e15019
4 changed files with 392 additions and 0 deletions

View 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();
});
});
});

View 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();
});
});
});

View 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)));
}
},
);
}

View 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)));
}
},
);
}