mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-09-30 10:03:17 +00:00
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||
|
|
import { ToolRegistry, defineTool } from './registry.js';
|
||
|
|
import { generateOpenAPIPaths } from './openapi.js';
|
||
|
|
import type { OASPathsResult } from './openapi.js';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Fixtures
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const lookupWorkOrder = defineTool({
|
||
|
|
name: 'lookup-work-order',
|
||
|
|
description: 'Look up a work order by ID.',
|
||
|
|
tier: 'ops',
|
||
|
|
requiredScope: 'ops:read',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
required: ['workOrderId'],
|
||
|
|
properties: {
|
||
|
|
workOrderId: { type: 'string', description: 'The work order ID.' },
|
||
|
|
},
|
||
|
|
additionalProperties: false,
|
||
|
|
},
|
||
|
|
handler: async (_input, _ctx) => ({ id: 'WO-001', status: 'open' }),
|
||
|
|
});
|
||
|
|
|
||
|
|
const searchVendors = defineTool({
|
||
|
|
name: 'search-vendors',
|
||
|
|
description: 'Search vendors in QuickBooks Online.',
|
||
|
|
tier: 'finance',
|
||
|
|
requiredScope: 'finance:read',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
required: ['query'],
|
||
|
|
properties: {
|
||
|
|
query: { type: 'string', description: 'Vendor name or keyword.' },
|
||
|
|
limit: { type: 'integer', default: 10 },
|
||
|
|
},
|
||
|
|
additionalProperties: false,
|
||
|
|
},
|
||
|
|
handler: async (_input, _ctx) => ({ vendors: [] }),
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function buildRegistry(...tools: ReturnType<typeof defineTool>[]): ToolRegistry {
|
||
|
|
const reg = new ToolRegistry();
|
||
|
|
for (const t of tools) reg.register(t);
|
||
|
|
return reg;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('generateOpenAPIPaths', () => {
|
||
|
|
let result: OASPathsResult;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
const registry = buildRegistry(lookupWorkOrder, searchVendors);
|
||
|
|
result = generateOpenAPIPaths(registry);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Basic structure
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('produces a paths object', () => {
|
||
|
|
expect(result).toHaveProperty('paths');
|
||
|
|
expect(typeof result.paths).toBe('object');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('produces one path per registered tool', () => {
|
||
|
|
expect(Object.keys(result.paths)).toHaveLength(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('generates the correct path key for each tool', () => {
|
||
|
|
expect(result.paths).toHaveProperty('/tools/lookup-work-order');
|
||
|
|
expect(result.paths).toHaveProperty('/tools/search-vendors');
|
||
|
|
});
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// POST operation structure
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('wraps each tool in a POST operation', () => {
|
||
|
|
const pathItem = result.paths['/tools/lookup-work-order']!;
|
||
|
|
expect(pathItem).toHaveProperty('post');
|
||
|
|
expect(pathItem).not.toHaveProperty('get');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('sets operationId to the tool name', () => {
|
||
|
|
expect(result.paths['/tools/lookup-work-order']!.post.operationId).toBe(
|
||
|
|
'lookup-work-order',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('sets summary to the tool description', () => {
|
||
|
|
expect(result.paths['/tools/lookup-work-order']!.post.summary).toBe(
|
||
|
|
'Look up a work order by ID.',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Tags and security
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('tags an ops tool with ["ops"]', () => {
|
||
|
|
expect(result.paths['/tools/lookup-work-order']!.post.tags).toEqual(['ops']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('tags a finance tool with ["finance"]', () => {
|
||
|
|
expect(result.paths['/tools/search-vendors']!.post.tags).toEqual([
|
||
|
|
'finance',
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('adds bearerAuth security requirement to every operation', () => {
|
||
|
|
const op = result.paths['/tools/lookup-work-order']!.post;
|
||
|
|
expect(op.security).toEqual([{ bearerAuth: [] }]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('sets x-required-scope extension from the tool definition', () => {
|
||
|
|
expect(
|
||
|
|
result.paths['/tools/lookup-work-order']!.post['x-required-scope'],
|
||
|
|
).toBe('ops:read');
|
||
|
|
expect(
|
||
|
|
result.paths['/tools/search-vendors']!.post['x-required-scope'],
|
||
|
|
).toBe('finance:read');
|
||
|
|
});
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Request body
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('marks requestBody as required', () => {
|
||
|
|
const op = result.paths['/tools/lookup-work-order']!.post;
|
||
|
|
expect(op.requestBody.required).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uses application/json for the requestBody media type', () => {
|
||
|
|
const op = result.paths['/tools/lookup-work-order']!.post;
|
||
|
|
expect(op.requestBody.content).toHaveProperty('application/json');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('round-trips the tool inputSchema verbatim into the requestBody', () => {
|
||
|
|
const op = result.paths['/tools/lookup-work-order']!.post;
|
||
|
|
expect(op.requestBody.content['application/json'].schema).toEqual(
|
||
|
|
lookupWorkOrder.inputSchema,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('round-trips the finance tool inputSchema verbatim', () => {
|
||
|
|
const op = result.paths['/tools/search-vendors']!.post;
|
||
|
|
expect(op.requestBody.content['application/json'].schema).toEqual(
|
||
|
|
searchVendors.inputSchema,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Responses
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('includes a 200 response', () => {
|
||
|
|
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
|
||
|
|
expect(responses).toHaveProperty('200');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('200 response has application/json content', () => {
|
||
|
|
const r200 =
|
||
|
|
result.paths['/tools/lookup-work-order']!.post.responses['200']!;
|
||
|
|
expect(r200.content).toHaveProperty('application/json');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('includes a 403 response for scope errors', () => {
|
||
|
|
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
|
||
|
|
expect(responses).toHaveProperty('403');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('403 response schema has requiredScope property', () => {
|
||
|
|
const r403 =
|
||
|
|
result.paths['/tools/lookup-work-order']!.post.responses['403']!;
|
||
|
|
const schema = r403.content!['application/json'].schema as {
|
||
|
|
properties: Record<string, unknown>;
|
||
|
|
};
|
||
|
|
expect(schema.properties).toHaveProperty('requiredScope');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('includes a 401 response for missing/invalid token', () => {
|
||
|
|
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
|
||
|
|
expect(responses).toHaveProperty('401');
|
||
|
|
});
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Components / security schemes
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('includes a bearerAuth security scheme in components', () => {
|
||
|
|
expect(result.components.securitySchemes).toHaveProperty('bearerAuth');
|
||
|
|
expect(result.components.securitySchemes.bearerAuth.type).toBe('http');
|
||
|
|
expect(result.components.securitySchemes.bearerAuth.scheme).toBe('bearer');
|
||
|
|
expect(result.components.securitySchemes.bearerAuth.bearerFormat).toBe(
|
||
|
|
'JWT',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Empty registry
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('produces an empty paths object for an empty registry', () => {
|
||
|
|
const empty = generateOpenAPIPaths(new ToolRegistry());
|
||
|
|
expect(Object.keys(empty.paths)).toHaveLength(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Round-trip: registry → paths → verify all tools represented
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('round-trip: every registered tool appears exactly once in paths', () => {
|
||
|
|
const tools = [
|
||
|
|
defineTool({
|
||
|
|
name: 'create-task',
|
||
|
|
description: 'Create a task.',
|
||
|
|
tier: 'ops',
|
||
|
|
requiredScope: 'ops:tasks',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
required: ['title'],
|
||
|
|
properties: { title: { type: 'string' } },
|
||
|
|
},
|
||
|
|
handler: async () => ({ id: 'T-1' }),
|
||
|
|
}),
|
||
|
|
defineTool({
|
||
|
|
name: 'lookup-payment-by-vendor',
|
||
|
|
description: 'Look up payments by vendor.',
|
||
|
|
tier: 'finance',
|
||
|
|
requiredScope: 'finance:read',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
required: ['vendorName'],
|
||
|
|
properties: { vendorName: { type: 'string' } },
|
||
|
|
},
|
||
|
|
handler: async () => ({ payments: [] }),
|
||
|
|
}),
|
||
|
|
];
|
||
|
|
const reg = buildRegistry(...tools);
|
||
|
|
const out = generateOpenAPIPaths(reg);
|
||
|
|
const paths = Object.keys(out.paths);
|
||
|
|
expect(paths).toContain('/tools/create-task');
|
||
|
|
expect(paths).toContain('/tools/lookup-payment-by-vendor');
|
||
|
|
expect(paths).toHaveLength(2);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// ToolRegistry — defineTool and registry unit tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('ToolRegistry', () => {
|
||
|
|
it('registers and lists a tool', () => {
|
||
|
|
const reg = new ToolRegistry();
|
||
|
|
reg.register(lookupWorkOrder);
|
||
|
|
expect(reg.list()).toHaveLength(1);
|
||
|
|
expect(reg.list()[0]!.name).toBe('lookup-work-order');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('get() returns a registered tool by name', () => {
|
||
|
|
const reg = new ToolRegistry();
|
||
|
|
reg.register(lookupWorkOrder);
|
||
|
|
const found = reg.get('lookup-work-order');
|
||
|
|
expect(found).toBeDefined();
|
||
|
|
expect(found!.name).toBe('lookup-work-order');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('get() returns undefined for unknown tool', () => {
|
||
|
|
const reg = new ToolRegistry();
|
||
|
|
expect(reg.get('nonexistent')).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws on duplicate tool name', () => {
|
||
|
|
const reg = new ToolRegistry();
|
||
|
|
reg.register(lookupWorkOrder);
|
||
|
|
expect(() => reg.register(lookupWorkOrder)).toThrow(
|
||
|
|
/duplicate tool name/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('register() is chainable', () => {
|
||
|
|
const reg = new ToolRegistry();
|
||
|
|
reg.register(lookupWorkOrder).register(searchVendors);
|
||
|
|
expect(reg.size).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('size reflects the number of registered tools', () => {
|
||
|
|
const reg = new ToolRegistry();
|
||
|
|
expect(reg.size).toBe(0);
|
||
|
|
reg.register(lookupWorkOrder);
|
||
|
|
expect(reg.size).toBe(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('defineTool', () => {
|
||
|
|
it('returns the definition unchanged', () => {
|
||
|
|
const def = defineTool(lookupWorkOrder);
|
||
|
|
expect(def).toBe(lookupWorkOrder);
|
||
|
|
});
|
||
|
|
});
|