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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Releases before 0.7.0 are described in [GitHub Releases](https://github.com/code
- `defineEndpoint` / `definePermissionAwareEndpoint` validation failures (`VALIDATION_ERROR`, HTTP 400) now carry Zod 4 issue objects in `error.details`: issue codes and messages differ from Zod 3 (for example `invalid_type` messages). Clients that match on the old text must be updated.
- Calling a tool the server no longer lists (for example a disabled toolset's tool) is now answered with a JSON-RPC error ("Tool ... not found") instead of a result with `isError: true`. This is SDK v2 behaviour.
- `FastifyTransport` and `PermissionAwareFastifyTransport` use `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. Session handling, the per-client `mcp-client-id` model and the HTTP endpoints are unchanged: each client still gets its own server, transport and enabled/disabled toolset state.
- A tool whose `inputSchema` is a plain JSON Schema object (`type: "object"`) is now registered with the SDK's `fromJsonSchema()`: clients see its real properties and arguments are validated against it before your handler runs. Until now such a tool registered as an open object, so its parameters were not advertised and any arguments reached the handler. Invalid arguments now get an error result and the handler is not called.
- **Migration:** if a handler relied on receiving arguments its schema forbids (a missing `required` property, a wrong type, or extra properties under `additionalProperties: false`), loosen the schema or fix the caller.
- A JSON Schema that cannot be compiled (unknown type name, unresolved `$ref`, unsupported `$schema` dialect) now fails the toolset's enable with an `E_VALIDATION` error that names the tool. So does an `inputSchema` that is neither a Zod 4 shape nor a JSON Schema object (the root `type` must be the string `"object"`, so `type: ["object", "null"]` is refused; this includes a shape that mixes Zod and non-Zod values), which used to register as an open object.
- Each client's `DynamicToolManager` compiles JSON Schemas with its own validator, so compiled schemas are not shared between clients and are freed with the client. The root `$id` of a tool's schema is not used or advertised (the validator caches by root `$id`, which would give two tools with the same `$id` the first one's validator); reference the root with `#`, not its `$id` URL.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const catalog = {

### Step 4: Define a tool

`inputSchema` should be a Zod 4 raw shape (or a Zod 4 object schema); toolception wraps a raw shape with `z.object()` before it calls `McpServer.registerTool`. A Zod 3 schema fails to enable its toolset with an `E_VALIDATION` error. A plain JSON Schema object still works, but clients see an open object schema and arguments reach the handler unvalidated.
`inputSchema` should be a Zod 4 raw shape (or a Zod 4 object schema); toolception wraps a raw shape with `z.object()` before it calls `McpServer.registerTool`. A Zod 3 schema fails to enable its toolset with an `E_VALIDATION` error. A plain JSON Schema object (`type: "object"`) also works: toolception registers it with the SDK's `fromJsonSchema()`, so clients see its properties and arguments are validated against it before your handler runs. A JSON Schema that does not compile (unknown type name, unresolved `$ref`, unsupported `$schema` dialect) fails the toolset's enable with an `E_VALIDATION` error that names the tool.

```ts
import { z } from "zod";
Expand Down
5 changes: 3 additions & 2 deletions src/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ 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. A partial failure removes the tools already registered, so nothing is left callable and the toolset can be enabled again.
6. **`enableToolsets()` batches notifications** — Calls `enableToolset(name, skipNotification=true)` per toolset, sends one `tools/list_changed` notification at the end. The SDK also sends its own `tools/list_changed` for every `registerTool()` and `remove()` call on a connected server, so a disable of N tools sends N+1.
7. **`inputSchema` goes through `toRegistrableInputSchema()`** — A Zod 4 schema instance is used as is; a raw shape of Zod 4 values is wrapped with toolception's own `z.object()` (so the SDK never wraps it with a second copy of Zod); a Zod 3 schema throws `ToolingError` `E_VALIDATION` (SDK v2 cannot read it) and the enable fails; anything else is treated as plain JSON Schema and registers as `z.looseObject({})`, so clients see an open object schema, not the declared one, and arguments are not validated.
7. **`inputSchema` goes through `toRegistrableInputSchema()`** — A Zod 4 schema instance is used as is; a raw shape of Zod 4 values is wrapped with toolception's own `z.object()` (so the SDK never wraps it with a second copy of Zod); a Zod 3 schema throws `ToolingError` `E_VALIDATION` (SDK v2 cannot read it) and the enable fails; a JSON Schema object with root `type: "object"` goes through the SDK's `fromJsonSchema()`, so clients see the declared schema and arguments are validated before the handler runs; a missing or non-object schema registers as `z.looseObject({})`; anything else (a root `type` that is not the string `"object"`, which includes a shape mixing Zod and non-Zod values; a JSON Schema that fails to compile) throws `ToolingError` `E_VALIDATION` at registration, so the enable fails. The root `$id` is dropped before compiling (the validator caches by `$id`, so two tools sharing one would share the first one's validator).
8. **Failed tool registration at startup makes the orchestrator not ready** — `enableToolsets()` reports failures in its result and never throws. A failure that carries a `code` (set when enabling threw, normally while registering the toolset's tools, e.g. a tool name collision) becomes `initError`, so `isReady()` is `false` and `ensureReady()` throws. Refusals made before any registration (invalid name, already enabled, exposure policy) carry no code and are ignored. Module loader errors are also ignored: `ModuleResolver` logs them and continues.
9. **JSON Schema validators are per manager** — Each `DynamicToolManager` (one per client) owns its own `AjvJsonSchemaValidator`. Ajv keeps every compiled schema for the validator's lifetime, so the SDK's process-wide default validator would hold all clients' schemas until exit; a per-manager one is freed with the client and shares nothing. Ajv's cache is never evicted when a toolset is disabled, so a client that toggles a loader-backed toolset, or one whose schema has a root `$id` (copied on each enable), many times grows its own cache until the session ends; that growth is bounded by the client's lifetime. Do not switch to `fromJsonSchema(schema)` without a validator argument.

## Enable Toolset Flow

Expand All @@ -34,7 +35,7 @@ enableToolset(name)

## SDK registration

Tools are registered with `McpServer.registerTool` (`server.tool()` no longer exists in SDK v2). The SDK does not accept a plain JSON Schema object as `inputSchema`, so `toRegistrableInputSchema()` maps every tool schema to a Zod 4 schema first. Calling a tool that is not (or no longer) registered makes the SDK answer with a JSON-RPC error, not an `isError` result.
Tools are registered with `McpServer.registerTool` (`server.tool()` no longer exists in SDK v2). The SDK does not accept a plain JSON Schema object as `inputSchema`, so `toRegistrableInputSchema()` maps every tool schema to a Zod 4 schema, or wraps a JSON Schema with `fromJsonSchema()`, first. Calling a tool that is not (or no longer) registered makes the SDK answer with a JSON-RPC error, not an `isError` result.

## Anti-patterns

Expand Down
81 changes: 69 additions & 12 deletions src/core/DynamicToolManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import type { McpServer, RegisteredTool } from "@modelcontextprotocol/server";
import {
fromJsonSchema,
type JsonSchemaType,
type McpServer,
type RegisteredTool,
type StandardSchemaWithJSON,
type jsonSchemaValidator,
} from "@modelcontextprotocol/server";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/server/validators/ajv";
import { z } from "zod";
import type {
ExposurePolicy,
Expand All @@ -22,6 +30,13 @@ export class DynamicToolManager {
private readonly activeToolsets = new Set<string>();
/** SDK handles of each toolset's registered tools, used to unregister them. */
private readonly toolHandles = new Map<string, readonly RegisteredTool[]>();
/**
* Compiles the JSON Schemas of this manager's tools. It is deliberately per
* manager (so per client): Ajv keeps every compiled schema for the validator's
* lifetime, so the SDK's process-wide default validator would hold all clients'
* schemas until exit. Here they are freed with the client, and nothing is shared.
*/
private readonly schemaValidator = new AjvJsonSchemaValidator();

constructor(options: DynamicToolManagerOptions) {
this.server = options.server;
Expand Down Expand Up @@ -247,7 +262,11 @@ export class DynamicToolManager {
const hasAnnotations =
tool.annotations && Object.keys(tool.annotations).length > 0;

const inputSchema = toRegistrableInputSchema(tool.name, tool.inputSchema);
const inputSchema = toRegistrableInputSchema(
tool.name,
tool.inputSchema,
this.schemaValidator
);

const handle = this.server.registerTool(
tool.name,
Expand Down Expand Up @@ -389,7 +408,10 @@ export class DynamicToolManager {
}

/** The schema type handed to `registerTool`; arguments arrive as a plain record. */
type InputSchema = z.ZodType<Record<string, unknown>>;
type InputSchema = StandardSchemaWithJSON<
Record<string, unknown>,
Record<string, unknown>
>;

/**
* Detects a Zod 4 schema by its `_zod` internals.
Expand Down Expand Up @@ -417,26 +439,38 @@ function isZod3Type(value: unknown): boolean {
}

/**
* Turns a tool's `inputSchema` into the Zod 4 object schema that
* `McpServer.registerTool` expects.
* Turns a tool's `inputSchema` into the schema that `McpServer.registerTool`
* expects.
*
* - A Zod 4 schema instance is used as is.
* - A raw shape of Zod 4 schemas (an empty object counts as an empty shape) is
* wrapped with toolception's own `z.object()`, so the SDK never wraps it with
* a second copy of Zod.
* - Anything else is treated as plain JSON Schema and registers as an open
* object, so arguments still reach the handler.
* - A plain JSON Schema object (`type: "object"`) goes through the SDK's
* `fromJsonSchema()`: clients see its real properties and arguments are
* validated against it before the handler runs.
* The root `$id` is dropped before compiling (so it is not advertised either): the
* validator caches compiled schemas by `$id`, which would give two tools with the
* same `$id` the first tool's validator. Reference the root with `#`, not its URL.
* - A missing or non-object schema registers as an open object, so arguments still
* reach the handler.
*
* @param toolName - The tool name, used in the error message
* @param toolName - The tool name, used in error messages
* @param schema - The tool's inputSchema
* @param validator - Compiles JSON Schema tools; scoped to one client by the caller
* @returns A schema to pass to `registerTool`
* @throws ToolingError `E_VALIDATION` when the schema or a shape value is a Zod 3 schema
* @throws ToolingError `E_VALIDATION` when the schema or a shape value is a Zod 3 schema,
* when the schema is neither a Zod 4 shape nor a JSON Schema object, or when the JSON
* Schema cannot be compiled (bad keyword, unresolved `$ref`, unsupported dialect)
*/
function toRegistrableInputSchema(
toolName: string,
schema: unknown
schema: unknown,
validator: jsonSchemaValidator
): InputSchema {
if (typeof schema !== "object" || schema === null) return z.looseObject({});
if (typeof schema !== "object" || schema === null) {
return z.looseObject({}) as InputSchema;
}
const values = isZod4Type(schema) || isZod3Type(schema)
? [schema]
: Object.values(schema);
Expand All @@ -452,5 +486,28 @@ function toRegistrableInputSchema(
if (values.every(isZod4Type)) {
return z.object(schema as Record<string, z.ZodType>) as InputSchema;
}
return z.looseObject({});
if ((schema as { type?: unknown }).type !== "object") {
throw new ToolingError(
`Tool '${toolName}' has an inputSchema that is neither a Zod 4 shape nor a JSON Schema object (type: "object").`,
"E_VALIDATION"
);
}
// Copy only when there is a root `$id` to drop: Ajv caches by object identity,
// so a schema without one is passed as is and a re-enable reuses its compiled
// form. A copy is a new cache key, bounded by the client's lifetime.
let compilable = schema as JsonSchemaType;
if (typeof compilable.$id === "string") {
compilable = { ...compilable };
delete compilable.$id;
}
try {
return fromJsonSchema<Record<string, unknown>>(compilable, validator);
} catch (error) {
throw new ToolingError(
`Tool '${toolName}' has an invalid JSON Schema inputSchema: ${
error instanceof Error ? error.message : String(error)
}`,
"E_VALIDATION"
);
}
}
2 changes: 1 addition & 1 deletion src/types/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +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 4** — A Zod 4 raw shape or Zod 4 object schema. A Zod 3 schema fails the toolset's enable with `E_VALIDATION`. `McpServer.registerTool` does not accept JSON Schema, so `DynamicToolManager` registers JSON Schema tools with an open object (`z.looseObject({})`: args unvalidated, properties not advertised)
5. **`McpToolDefinition.inputSchema` should be Zod 4** — A Zod 4 raw shape or Zod 4 object schema. A Zod 3 schema fails the toolset's enable with `E_VALIDATION`. A plain JSON Schema object (`type: "object"`) is registered through the SDK's `fromJsonSchema()`, so it is advertised and its arguments are validated. A schema that does not compile, or has no root `type: "object"`, fails the enable with `E_VALIDATION`

## Anti-patterns

Expand Down
6 changes: 4 additions & 2 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ export type McpToolDefinition = {
/**
* Zod 4 raw shape (e.g. `{ symbol: z.string() }`) or Zod 4 object schema. A raw
* shape is wrapped with `z.object()` before it reaches `McpServer.registerTool`;
* a Zod 3 schema is refused (`E_VALIDATION`). A plain JSON Schema object is
* registered as an open object: clients don't see its properties and args are not validated.
* a Zod 3 schema is refused (`E_VALIDATION`). A plain JSON Schema object
* (`type: "object"`) is registered with the SDK's `fromJsonSchema()`: clients see its
* properties and arguments are validated before the handler runs. A JSON Schema that
* cannot be compiled, or any other shape, fails the toolset's enable (`E_VALIDATION`).
*/
inputSchema: Record<string, any>;
handler: (args: any) => Promise<any> | any;
Expand Down
Loading