Skip to content
Merged
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
28 changes: 9 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,16 @@ const catalog = {

### Step 4: Define a tool

`inputSchema` should be a Zod raw shape (or a Zod object schema), the same form `McpServer.registerTool` takes. A plain JSON Schema object still works, but clients see an open object schema and arguments reach the handler unvalidated.

```ts
import { z } from "zod";

const quoteTool = {
name: "price",
description: "Return a fake price",
inputSchema: {
type: "object",
properties: { symbol: { type: "string" } },
required: ["symbol"],
symbol: z.string(),
},
handler: async ({ symbol }: { symbol: string }) => ({
content: [{ type: "text", text: `${symbol}: 123.45` }],
Expand Down Expand Up @@ -252,11 +254,7 @@ const adminTool = {
name: "delete_user",
description: "Delete a user account",
inputSchema: {
type: "object",
properties: {
userId: { type: "string", description: "User ID to delete" },
},
required: ["userId"],
userId: z.string().describe("User ID to delete"),
},
handler: async ({ userId }: { userId: string }) => ({
content: [{ type: "text", text: `User ${userId} deleted` }],
Expand All @@ -267,11 +265,7 @@ const userTool = {
name: "get_profile",
description: "Get user profile information",
inputSchema: {
type: "object",
properties: {
userId: { type: "string", description: "User ID" },
},
required: ["userId"],
userId: z.string().describe("User ID"),
},
handler: async ({ userId }: { userId: string }) => ({
content: [{ type: "text", text: `Profile for ${userId}: {...}` }],
Expand Down Expand Up @@ -847,9 +841,7 @@ const moduleLoaders = {
name: "echo",
description: "Echo back provided text",
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
text: z.string(),
},
handler: async ({ text }: { text: string }) => ({
content: [{ type: "text", text }],
Expand Down Expand Up @@ -935,9 +927,7 @@ const moduleLoaders = {
name: "echo",
description: "Echo using a backing service",
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
text: z.string(),
},
handler: async ({ text }: { text: string }) => {
const result = await ctx.apiClients.echoService.send(text);
Expand Down
10 changes: 3 additions & 7 deletions examples/custom-endpoints-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,7 @@ async function runStandardServerExample() {
name: "greet",
description: "Greet a user",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
name: z.string(),
},
handler: async (args: any) => ({
content: [
Expand Down Expand Up @@ -426,7 +422,7 @@ async function runPermissionBasedServerExample() {
{
name: "admin-action",
description: "Perform admin action",
inputSchema: { type: "object", properties: {} },
inputSchema: {},
handler: async () => ({
content: [{ type: "text", text: "Admin action completed" }],
}),
Expand All @@ -440,7 +436,7 @@ async function runPermissionBasedServerExample() {
{
name: "user-action",
description: "Perform user action",
inputSchema: { type: "object", properties: {} },
inputSchema: {},
handler: async () => ({
content: [{ type: "text", text: "User action completed" }],
}),
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@fastify/cors": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.32.1",
"fastify": "^5.12.5",
"zod": "^3.22.4"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/connect": "^3.4.38",
Expand Down
6 changes: 5 additions & 1 deletion src/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Central orchestration layer. Manages toolset lifecycle, tool registration, and s
4. **Namespacing applied BEFORE collision check** — `mapAndValidate()` generates safe names first, then checks for conflicts.
5. **Toolset added to activeToolsets AFTER registration** — All tools must register successfully before toolset is marked active. Partial failures leave the toolset inactive but orphaned tools remain registered (MCP limitation).
6. **`enableToolsets()` batches notifications** — Calls `enableToolset(name, skipNotification=true)` per toolset, sends one `tools/list_changed` notification at the end.
7. **JSON Schema `inputSchema` uses `registerTool` with a passthrough object** — SDK >= 1.31 rejects plain JSON Schema in `server.tool()`. Zod raw shapes still go through `server.tool()`. Clients see an open object schema for JSON Schema tools, not the declared one.
7. **JSON Schema `inputSchema` uses `registerTool` with a passthrough object** — The SDK rejects plain JSON Schema as a tool schema. Zod raw shapes and schemas are passed to `registerTool` as is. Clients see an open object schema for JSON Schema tools, not the declared one.

## Enable Toolset Flow

Expand All @@ -31,6 +31,10 @@ enableToolset(name)
→ notifyToolsChanged() — unless skipNotification
```

## SDK registration

Tools are registered with `McpServer.registerTool` (the 5-arg `server.tool()` overloads are deprecated). Before 1.28 the SDK silently treated a JSON Schema `inputSchema` as annotations, so handlers received the request `extra` instead of arguments; newer SDKs throw. `isZodInputSchema()` routes Zod shapes straight through and everything else to the passthrough object.

## Anti-patterns

- Bypassing ToolRegistry for tool registration (causes collision issues)
Expand Down
79 changes: 29 additions & 50 deletions src/core/DynamicToolManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js";
import type {
ExposurePolicy,
McpToolDefinition,
Expand Down Expand Up @@ -250,42 +251,25 @@ export class DynamicToolManager {
const hasAnnotations =
tool.annotations && Object.keys(tool.annotations).length > 0;

if (!isZodRawShape(tool.inputSchema)) {
// SDK >= 1.31 rejects plain JSON Schema objects in server.tool(); older
// versions silently treated them as annotations and dropped the args.
// Register with a passthrough object so arguments reach the handler.
this.server.registerTool(
tool.name,
{
description: tool.description,
inputSchema: z.object({}).passthrough(),
...(hasAnnotations ? { annotations: tool.annotations } : {}),
},
async (args: Record<string, unknown>) => {
return await tool.handler(args);
}
);
} else if (hasAnnotations && tool.annotations) {
this.server.tool(
tool.name,
tool.description,
tool.inputSchema as Parameters<typeof this.server.tool>[2],
tool.annotations,
async (args: Record<string, unknown>) => {
return await tool.handler(args);
}
);
} else {
// Legacy 4-parameter call when no annotations
this.server.tool(
tool.name,
tool.description,
tool.inputSchema as Parameters<typeof this.server.tool>[2],
async (args: Record<string, unknown>) => {
return await tool.handler(args);
}
);
}
// SDK >= 1.28 rejects plain JSON Schema inputSchema; older versions silently
// treated it as annotations and dropped the args. Non-Zod schemas register
// with a passthrough object so arguments still reach the handler.
const inputSchema = isZodInputSchema(tool.inputSchema)
? (tool.inputSchema as ZodRawShapeCompat)
: z.object({}).passthrough();

this.server.registerTool(
tool.name,
{
title: tool.annotations?.title,
description: tool.description,
inputSchema,
annotations: hasAnnotations ? tool.annotations : undefined,
},
async (args: Record<string, unknown>) => {
return await tool.handler(args);
}
);
this.toolRegistry.addForToolset(toolsetKey, tool.name);
}

Expand Down Expand Up @@ -410,20 +394,15 @@ export class DynamicToolManager {
}

/**
* Mirrors the SDK's raw-shape check: an empty object, or an object with at
* least one Zod schema value (e.g. `{ symbol: z.string() }`).
* True when the schema is a Zod schema instance (v3 or v4) or a raw shape whose
* values are all Zod schemas. An empty object counts as an empty raw shape.
*
* @param schema - The tool's inputSchema
*/
function isZodRawShape(schema: unknown): boolean {
function isZodInputSchema(schema: unknown): boolean {
const isZodType = (v: unknown) =>
typeof v === "object" && v !== null && ("_def" in v || "_zod" in v);
if (typeof schema !== "object" || schema === null) return false;
const values = Object.values(schema);
return values.length === 0 || values.some(isZodTypeLike);
}

function isZodTypeLike(value: unknown): boolean {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { parse?: unknown }).parse === "function" &&
typeof (value as { safeParse?: unknown }).safeParse === "function"
);
if (isZodType(schema)) return true;
return Object.values(schema).every(isZodType);
}
52 changes: 31 additions & 21 deletions src/meta/registerMetaTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ export function registerMetaTools(

// Dynamic-mode only tools: enable/disable toolsets at runtime
if (mode === "DYNAMIC") {
// Register with ToolRegistry for collision detection before server.tool()
// Register with ToolRegistry for collision detection before server.registerTool()
toolRegistry.addForToolset(META_TOOLSET_KEY, "enable_toolset");
server.tool(
server.registerTool(
"enable_toolset",
"Enable a toolset by name",
{ name: z.string().describe("Toolset name") },
{ destructiveHint: true, idempotentHint: true },
{
description: "Enable a toolset by name",
inputSchema: { name: z.string().describe("Toolset name") },
annotations: { destructiveHint: true, idempotentHint: true },
},
async (args: { name: string }) => {
const result = await manager.enableToolset(args.name);
return {
Expand All @@ -55,11 +57,13 @@ export function registerMetaTools(
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
server.registerTool(
"disable_toolset",
"Disable a toolset by name (state only)",
{ name: z.string().describe("Toolset name") },
{ destructiveHint: true, idempotentHint: true },
{
description: "Disable a toolset by name (state only)",
inputSchema: { name: z.string().describe("Toolset name") },
annotations: { destructiveHint: true, idempotentHint: true },
},
async (args: { name: string }) => {
const result = await manager.disableToolset(args.name);
return {
Expand All @@ -69,11 +73,13 @@ export function registerMetaTools(
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
server.registerTool(
"list_toolsets",
"List available toolsets with active status and definitions",
{},
{ readOnlyHint: true, idempotentHint: true },
{
description: "List available toolsets with active status and definitions",
inputSchema: {},
annotations: { readOnlyHint: true, idempotentHint: true },
},
async () => {
const available = manager.getAvailableToolsets();
const byToolset = manager.getStatus().toolsetToTools;
Expand Down Expand Up @@ -102,11 +108,13 @@ export function registerMetaTools(
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
server.registerTool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
{ name: z.string().describe("Toolset name") },
{ readOnlyHint: true, idempotentHint: true },
{
description: "Describe a toolset with definition, active status and tools",
inputSchema: { name: z.string().describe("Toolset name") },
annotations: { readOnlyHint: true, idempotentHint: true },
},
async (args: { name: string }) => {
const def = manager.getToolsetDefinition(args.name);
const byToolset = manager.getStatus().toolsetToTools;
Expand Down Expand Up @@ -140,11 +148,13 @@ export function registerMetaTools(

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
server.registerTool(
"list_tools",
"List currently registered tool names (best effort)",
{},
{ readOnlyHint: true, idempotentHint: true },
{
description: "List currently registered tool names (best effort)",
inputSchema: {},
annotations: { readOnlyHint: true, idempotentHint: true },
},
async () => {
const status = manager.getStatus();
const payload = {
Expand Down
1 change: 1 addition & 0 deletions src/types/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Also: `src/errors/ToolingError.ts` (18 LOC) — thin error class wrapping Toolin
2. **ToolingErrorCode values are exhaustive** — All error codes defined here; do not add codes elsewhere
3. **PermissionConfig requires source field** — Must specify `"headers"` or `"config"`
4. **SessionContextConfig.allowedKeys is security-critical** — Always whitelist allowed keys; omitting allows arbitrary config injection
5. **`McpToolDefinition.inputSchema` should be Zod** — A Zod raw shape or Zod object schema. `McpServer.registerTool` does not accept JSON Schema, so `DynamicToolManager` registers JSON Schema tools with an open passthrough object (args unvalidated, properties not advertised)

## Anti-patterns

Expand Down
7 changes: 6 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
export type McpToolDefinition = {
name: string;
description: string;
/**
* Zod raw shape (e.g. `{ symbol: z.string() }`) or Zod object schema, as accepted
* by `McpServer.registerTool`. A plain JSON Schema object is registered as an
* open passthrough object: clients don't see its properties and args are not validated.
*/
inputSchema: Record<string, any>;
handler: (args: any) => Promise<any> | any;
/**
* Optional annotations providing hints about tool behavior.
* All annotations are hints and not guaranteed.
* - title: Optional display title for the tool
* - title: Optional display title for the tool (also sent as the tool's top-level `title`)
* - readOnlyHint: Tool does not modify its environment
* - destructiveHint: Tool may perform destructive updates
* - idempotentHint: Repeated calls with same arguments have no additional effect
Expand Down
5 changes: 1 addition & 4 deletions tests/createMcpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ const catalog = {
function makeFakeServer() {
const calls: string[] = [];
const server = {
tool: (name: string) => {
calls.push(name);
},
registerTool: (name: string) => {
calls.push(name);
},
Expand Down Expand Up @@ -213,7 +210,7 @@ describe("createMcpServer", () => {
{
name: "test_tool",
description: "A test tool",
inputSchema: { type: "object", properties: {} },
inputSchema: {},
handler: async () => ({
content: [{ type: "text", text: "test" }],
}),
Expand Down
4 changes: 2 additions & 2 deletions tests/createPermissionBasedMcpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("createPermissionBasedMcpServer", () => {
{
name: "tool-a",
description: "Test tool",
inputSchema: { type: "object", properties: {} },
inputSchema: {},
handler: async () => ({ content: [] }),
},
],
Expand Down Expand Up @@ -400,7 +400,7 @@ describe("createPermissionBasedMcpServer", () => {
{
name: "module-tool",
description: "Tool from module",
inputSchema: { type: "object", properties: {} },
inputSchema: {},
handler: async () => ({ content: [] }),
},
],
Expand Down
Loading
Loading