Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions packages/mcp-server/src/tools/__tests__/registry.test.ts
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);
}
});
});
169 changes: 169 additions & 0 deletions packages/mcp-server/src/tools/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { z } from 'zod';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To support converting Zod schemas to JSON Schema without relying on non-standard global monkey-patching, import zodToJsonSchema explicitly.

Suggested change
import { z } from 'zod';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

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>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Use the explicitly imported zodToJsonSchema function instead of the non-standard z.toJSONSchema to ensure compatibility, type safety, and avoid runtime errors.

Suggested change
inputSchema: z.toJSONSchema(tool.inputSchema) as Record<string, unknown>,
inputSchema: zodToJsonSchema(tool.inputSchema) as Record<string, unknown>,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: z.toJSONSchema() is a built-in zod v4 API (the repo pins zod@4.4.3), not global monkey-patching. Verified against the installed version — it returns a valid JSON Schema with additionalProperties: false. Pulling in the separate zod-to-json-schema package (the zod v3 approach) would add an unnecessary dependency. Keeping the built-in.


Generated by Claude Code

}));
}
}
Loading