-
Notifications
You must be signed in to change notification settings - Fork 8
feat(mcp-server): curated tool registry & input validation (MCP Prompt 10) #4011
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: claude/mcp-prompt-09-identity-mapping
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<Schema extends ToolInputSchema = ToolInputSchema> { | ||||||
| name: string; | ||||||
| description: string; | ||||||
| inputSchema: Schema; | ||||||
| policy: ToolAuthPolicy; | ||||||
| handler: ( | ||||||
| input: z.infer<Schema>, | ||||||
| context: ToolExecutionContext, | ||||||
| ) => Promise<ToolResult> | ToolResult; | ||||||
| } | ||||||
|
|
||||||
| /** Tool descriptor advertised by `tools/list` (JSON Schema for the input). */ | ||||||
| export interface ToolDescriptor { | ||||||
| name: string; | ||||||
| description: string; | ||||||
| inputSchema: Record<string, unknown>; | ||||||
| } | ||||||
|
|
||||||
| // --------------------------------------------------------------------------- | ||||||
| // 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<T> = | ||||||
| { 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<Schema extends ToolInputSchema>( | ||||||
| tool: ToolDefinition<Schema>, | ||||||
| rawArgs: unknown, | ||||||
| ): ToolInputValidation<z.infer<Schema>> { | ||||||
| const result = tool.inputSchema.strict().safeParse(rawArgs ?? {}); | ||||||
| if (result.success) { | ||||||
| return { ok: true, data: result.data as z.infer<Schema> }; | ||||||
| } | ||||||
| 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<string, ToolDefinition>(); | ||||||
|
|
||||||
| /** 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<string, unknown>, | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the explicitly imported
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declining: Generated by Claude Code |
||||||
| })); | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To support converting Zod schemas to JSON Schema without relying on non-standard global monkey-patching, import
zodToJsonSchemaexplicitly.