diff --git a/packages/mcp-server/src/tools/__tests__/registry.test.ts b/packages/mcp-server/src/tools/__tests__/registry.test.ts new file mode 100644 index 000000000..793cb7b00 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/registry.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + DuplicateToolError, + type ToolDefinition, + ToolRegistry, + validateToolInput, +} from '../registry.js'; + +function makeTool(name: string): ToolDefinition { + return { + name, + description: `desc for ${name}`, + inputSchema: z.object({ query: z.string(), limit: z.number().int().optional() }), + policy: { requiresBusinessScope: true, dataClassification: 'business' }, + handler: input => ({ content: [{ type: 'text', text: JSON.stringify(input) }] }), + }; +} + +describe('ToolRegistry', () => { + it('registers and looks up a tool', () => { + const registry = new ToolRegistry(); + const tool = makeTool('charges_search'); + registry.register(tool); + + expect(registry.has('charges_search')).toBe(true); + expect(registry.get('charges_search')).toBe(tool); + expect(registry.get('missing')).toBeUndefined(); + }); + + it('rejects duplicate names', () => { + const registry = new ToolRegistry(); + registry.register(makeTool('dup')); + expect(() => registry.register(makeTool('dup'))).toThrow(DuplicateToolError); + }); + + it('preserves registration order in list()', () => { + const registry = new ToolRegistry(); + registry.register(makeTool('a')); + registry.register(makeTool('b')); + registry.register(makeTool('c')); + expect(registry.list().map(t => t.name)).toEqual(['a', 'b', 'c']); + }); + + it('describes tools with JSON-Schema input (unknown fields disallowed)', () => { + const registry = new ToolRegistry(); + registry.register(makeTool('charges_search')); + const [descriptor] = registry.describe(); + + expect(descriptor.name).toBe('charges_search'); + expect(descriptor.inputSchema.type).toBe('object'); + expect(descriptor.inputSchema.additionalProperties).toBe(false); + expect(descriptor.inputSchema.required).toEqual(['query']); + }); +}); + +describe('validateToolInput', () => { + const tool = makeTool('charges_search'); + + it('accepts valid input and returns typed data', () => { + const result = validateToolInput(tool, { query: 'acme', limit: 10 }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toEqual({ query: 'acme', limit: 10 }); + } + }); + + it('defaults missing args to an empty object (still validated)', () => { + const result = validateToolInput(tool, undefined); + // query is required, so an empty object fails deterministically + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('VALIDATION_ERROR'); + expect(result.error.issues.some(i => i.path === 'query')).toBe(true); + } + }); + + it('rejects unknown fields with a deterministic payload', () => { + const result = validateToolInput(tool, { query: 'x', bogus: true }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('VALIDATION_ERROR'); + expect(result.error.message).toBe('Invalid tool input'); + expect(result.error.issues.length).toBeGreaterThan(0); + } + }); + + it('reports the field path for a type mismatch', () => { + const result = validateToolInput(tool, { query: 123 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.issues.some(i => i.path === 'query')).toBe(true); + } + }); +}); diff --git a/packages/mcp-server/src/tools/registry.ts b/packages/mcp-server/src/tools/registry.ts new file mode 100644 index 000000000..7aac54c51 --- /dev/null +++ b/packages/mcp-server/src/tools/registry.ts @@ -0,0 +1,169 @@ +import { z } from 'zod'; +import type { AuthorizedReadScope, McpAuthContext } from '../auth/identity.js'; + +/** + * Curated tool registry abstraction. + * + * Every exposed capability is a {@link ToolDefinition} with a strict input + * schema, an authorization policy, and a pure handler. The registry supports + * incremental registration and lookup, and provides strict input validation + * with unknown-field rejection and a deterministic validation error payload. + * + * Authorization enforcement (the policy) and handler execution are wired in + * later steps; this file defines the contracts and the validation layer. + */ + +/** Data-sensitivity class of a tool's output (spec §9.4). */ +export type DataClassification = 'public' | 'business' | 'sensitive'; + +/** Per-tool authorization policy (spec §7.2). Evaluated before execution. */ +export interface ToolAuthPolicy { + /** Roles/scopes the caller must hold (any-of). Empty/omitted ⇒ no role gate. */ + requiredRoles?: readonly string[]; + /** Whether the tool operates within the caller's business read scope. */ + requiresBusinessScope: boolean; + /** Sensitivity of the data the tool returns. */ + dataClassification: DataClassification; +} + +/** Context passed to a tool handler at execution time. */ +export interface ToolExecutionContext { + /** The authenticated caller and their memberships. */ + auth: McpAuthContext; + /** Read scope resolved for this call (default or caller-narrowed). */ + readScope: AuthorizedReadScope; + /** Correlation id for tracing/log/upstream propagation. */ + correlationId: string; +} + +export interface ToolTextContent { + type: 'text'; + text: string; +} + +/** MCP `tools/call` result. */ +export interface ToolResult { + content: ToolTextContent[]; + isError?: boolean; + /** Optional machine-readable payload mirrored alongside the text content. */ + structuredContent?: unknown; +} + +/** A zod object schema describing a tool's input. */ +export type ToolInputSchema = z.ZodObject; + +/** A registered, curated tool. Handlers must be pure and testable. */ +export interface ToolDefinition { + name: string; + description: string; + inputSchema: Schema; + policy: ToolAuthPolicy; + handler: ( + input: z.infer, + context: ToolExecutionContext, + ) => Promise | ToolResult; +} + +/** Tool descriptor advertised by `tools/list` (JSON Schema for the input). */ +export interface ToolDescriptor { + name: string; + description: string; + inputSchema: Record; +} + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +export interface ToolValidationIssue { + /** Dot-joined path to the offending field (empty for the root object). */ + path: string; + message: string; +} + +/** Deterministic validation error payload (spec §10.2 VALIDATION_ERROR). */ +export interface ToolValidationError { + code: 'VALIDATION_ERROR'; + message: string; + issues: ToolValidationIssue[]; +} + +export type ToolInputValidation = + { ok: true; data: T } | { ok: false; error: ToolValidationError }; + +function toValidationError(error: z.ZodError): ToolValidationError { + const issues = error.issues.map(issue => ({ + path: issue.path.map(String).join('.'), + message: issue.message, + })); + return { + code: 'VALIDATION_ERROR', + message: 'Invalid tool input', + issues, + }; +} + +/** + * Validate raw tool-call arguments against a tool's schema. Unknown fields are + * rejected (`.strict()`). Returns the parsed input or a deterministic error. + */ +export function validateToolInput( + tool: ToolDefinition, + rawArgs: unknown, +): ToolInputValidation> { + const result = tool.inputSchema.strict().safeParse(rawArgs ?? {}); + if (result.success) { + return { ok: true, data: result.data as z.infer }; + } + return { ok: false, error: toValidationError(result.error) }; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** Thrown when registering a tool whose name is already taken. */ +export class DuplicateToolError extends Error { + constructor(name: string) { + super(`A tool named "${name}" is already registered`); + this.name = 'DuplicateToolError'; + } +} + +/** + * In-memory registry of curated tools. Registration order is preserved so + * `list()`/`describe()` output is stable. + */ +export class ToolRegistry { + private readonly tools = new Map(); + + /** Register a tool. Throws {@link DuplicateToolError} on a name collision. */ + register(tool: ToolDefinition): void { + if (this.tools.has(tool.name)) { + throw new DuplicateToolError(tool.name); + } + this.tools.set(tool.name, tool); + } + + has(name: string): boolean { + return this.tools.has(name); + } + + get(name: string): ToolDefinition | undefined { + return this.tools.get(name); + } + + /** All registered tools, in registration order. */ + list(): ToolDefinition[] { + return [...this.tools.values()]; + } + + /** Descriptors for `tools/list`, with input schemas rendered to JSON Schema. */ + describe(): ToolDescriptor[] { + return this.list().map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: z.toJSONSchema(tool.inputSchema) as Record, + })); + } +}