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
5 changes: 5 additions & 0 deletions .changeset/validate-low-level-tool-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

Validate low-level `Server` tool calls against the `inputSchema` advertised by `tools/list` before dispatching them. Invalid arguments now return an `isError` tool result instead of reaching the handler.
51 changes: 12 additions & 39 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ shape: explanation

# Low-level Server

`Server` is the **protocol layer** under `McpServer`: it routes each JSON-RPC request to the handler you register for that method string, and nothing more. Rebuild the `search` tool from [Tools](../servers/tools.md) on it to see what `registerTool` adds.
`Server` is the **protocol layer** under `McpServer`: it routes each JSON-RPC request to the handler you register for that method string, and validates low-level tool calls against the schemas it advertises. Rebuild the `search` tool from [Tools](../servers/tools.md) on it to see what `registerTool` adds.

## Build the server and list your tools by hand

Expand Down Expand Up @@ -38,6 +38,8 @@ server.setRequestHandler('tools/list', async () => ({

A client's `tools/list` returns exactly the array you wrote — the SDK derived none of it.

When the server answers `tools/list`, it also remembers each tool's `inputSchema` for this connection. A later `tools/call` with arguments that do not match the advertised schema returns an `isError: true` tool result before your handler runs.

::: tip
Drop `capabilities: { tools: {} }` and `setRequestHandler('tools/list', …)` throws. `Server` never infers a capability from a handler, the way `registerTool` registers the `tools` capability for you.
:::
Expand All @@ -63,50 +65,21 @@ An in-memory `Client` connected to this server — [Test a server](../testing.md
[ { type: 'text', text: 'Travel mug\nMug rack' } ]
```

Now call it with `{ query: 42 }`. The protocol layer checks only that `arguments` is an object, so the value reaches the handler and the handler crashes:

```
ProtocolError -32603: query.toLowerCase is not a function
```

`callTool` rejected with a protocol error instead of resolving to an `isError: true` tool result — [Errors](../servers/errors.md) covers the difference.

## Validate arguments yourself

From one Zod `inputSchema` the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. Here you wrote the JSON Schema by hand, the cast went unchecked, and nothing tied the two together.

`fromJsonSchema` — exported from `@modelcontextprotocol/server` — wraps a JSON Schema object as a validator you run yourself. Registering `tools/call` again replaces the handler; this one rejects before it touches the arguments.

```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_validate"
const SearchArguments = fromJsonSchema<{ query: string }>({
type: 'object',
properties: { query: { type: 'string' } },
required: ['query']
});

server.setRequestHandler('tools/call', async request => {
if (request.params.name !== 'search') {
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
}
const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {});
if (parsed.issues) {
return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true };
}
const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase()));
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
});
```

The same `{ query: 42 }` call now comes back as an ordinary tool result the model can read and retry:
Now call it with `{ query: 42 }`. The protocol layer rejects it against the schema before the value reaches the handler:

```
{
content: [ { type: 'text', text: 'data/query must be string' } ],
content: [
{
type: 'text',
text: 'Input validation error: Invalid arguments for tool search: data/query must be string'
}
],
isError: true
}
```

Keeping the schema you advertise in `tools/list` identical to the one you validate with is still on you — `registerTool` derives both from the same object.
The handler is not called, and the model can read the tool result and retry with valid arguments.

## Serve it with the same entry points

Expand Down Expand Up @@ -155,7 +128,7 @@ You never choose once for the whole program. Start on `McpServer` and take over
## Recap

- `Server` is the protocol layer: `setRequestHandler(method, handler)` per spec method, and nothing derived on top.
- On `Server` you write the JSON Schema in `tools/list` and the argument validation in `tools/call`; `registerTool` derives both from one Zod schema.
- On `Server` you write the JSON Schema in `tools/list`, and the SDK validates later calls against it before dispatch; `registerTool` derives the schema and parses the handler arguments from one Standard Schema.
- A handler exception on `Server` reaches the client as a protocol error, not as an `isError: true` tool result.
- `serveStdio` and `createMcpHandler` accept a factory that returns a `Server` unchanged.
- `mcp.server` is the per-method escape hatch; default to `McpServer` and drop to `Server` only where you own dispatch.
8 changes: 4 additions & 4 deletions docs/advanced/schema-libraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ The SDK validates `structuredContent` against the ArkType schema before the resu

## Swap the JSON Schema validator

The server runs a JSON Schema validator in two places: a `fromJsonSchema` schema, and [elicitation](../servers/elicitation.md) form responses. Build one from the `validators/ajv` subpath, which re-exports the SDK's bundled `Ajv` and `addFormats`.
The server runs a JSON Schema validator for low-level `Server` tool calls after a `tools/list` response, for [elicitation](../servers/elicitation.md) form responses, and for `fromJsonSchema` schemas that use the supplied validator. Build one from the `validators/ajv` subpath, which re-exports the SDK's bundled `Ajv` and `addFormats`.

```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#jsonSchemaValidator_ajv"
import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
Expand All @@ -137,10 +137,10 @@ const validator = new AjvJsonSchemaValidator(ajv);
const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator });
```

`strict` now checks elicitation form responses with your `Ajv` instance.
`strict` now checks elicitation form responses with your `Ajv` instance. The high-level `McpServer` parses tool arguments through the Standard Schema supplied to `registerTool`; the `jsonSchemaValidator` option is used for tool calls when you use the low-level `Server` API.

::: warning
`jsonSchemaValidator` covers elicitation form responses only. A `fromJsonSchema` schema binds its validator at creation — pass yours as the second argument: `fromJsonSchema(document, validator)`.
`jsonSchemaValidator` on a low-level `Server` covers the raw tool schemas it has advertised and elicitation form responses. A `fromJsonSchema` schema binds its validator at creation — pass yours as the second argument: `fromJsonSchema(document, validator)`.
:::

## Pick the validator for your runtime
Expand All @@ -160,5 +160,5 @@ const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchem
- `inputSchema`, `outputSchema`, and a prompt's `argsSchema` accept any Standard Schema that exposes JSON Schema — Zod and ArkType as-is, Valibot through `@valibot/to-json-schema`.
- The raw-shape overload (`inputSchema: { name: z.string() }`) is deprecated; pass a schema object.
- `fromJsonSchema(document)` registers a JSON Schema you already have; the generic parameter types the handler's arguments.
- `jsonSchemaValidator` on the server options swaps the validator for elicitation form responses; `fromJsonSchema` takes its own as a second argument.
- `jsonSchemaValidator` on low-level `Server` options swaps the validator for raw tool schemas and elicitation form responses; `McpServer` parses registered tools through their Standard Schemas; `fromJsonSchema` takes its own validator as a second argument.
- The default validator is runtime-selected — AJV on Node.js, `@cfworker/json-schema` on workerd and browsers — and the `validators/ajv` and `validators/cf-worker` subpaths force either one.
48 changes: 9 additions & 39 deletions examples/guides/advanced/low-level-server.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
/* eslint-disable no-console, import/no-duplicates */
// Harness imports. The page's lead block (the first region) carries its own
// `Server` import so the rendered fence stands alone.
import { createMcpHandler, fromJsonSchema, McpServer } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';

Expand Down Expand Up @@ -72,55 +72,25 @@ server.setRequestHandler('tools/call', async request => {
// page's lead region stays self-contained.
// ---------------------------------------------------------------------------

const { Client, InMemoryTransport, ProtocolError } = await import('@modelcontextprotocol/client');
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');

const client = new Client({ name: 'low-level-docs-harness', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
await client.listTools();

// The handler answers a valid call exactly like the McpServer version.
const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } });
console.log(result.content);

// Nothing validated `query`, so a wrongly-typed argument reaches the handler
// and crashes it: the client sees a JSON-RPC error, not a tool result.
const crashed = await client.callTool({ name: 'search', arguments: { query: 42 } }).catch((error: unknown) => error);
if (!(crashed instanceof ProtocolError)) {
throw new Error(`low-level-server.md expected the unvalidated call to reject: ${JSON.stringify(crashed)}`);
}
console.log(`${crashed.name} ${crashed.code}: ${crashed.message}`);

// ---------------------------------------------------------------------------
// "Validate arguments yourself"
// ---------------------------------------------------------------------------

//#region lowLevel_validate
const SearchArguments = fromJsonSchema<{ query: string }>({
type: 'object',
properties: { query: { type: 'string' } },
required: ['query']
});

server.setRequestHandler('tools/call', async request => {
if (request.params.name !== 'search') {
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
}
const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {});
if (parsed.issues) {
return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true };
}
const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase()));
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
});
//#endregion lowLevel_validate

// The same wrongly-typed call now comes back as an ordinary isError result.
const rejected = await client.callTool({ name: 'search', arguments: { query: 42 } });
console.log(rejected);
if (rejected.isError !== true) {
throw new Error(`low-level-server.md expected the validated call to return isError: ${JSON.stringify(rejected)}`);
// The low-level Server now validates a declared inputSchema before dispatch,
// so a wrongly-typed argument returns a tool error without invoking the handler.
const rejectedBySchema = await client.callTool({ name: 'search', arguments: { query: 42 } });
if (rejectedBySchema.isError !== true) {
throw new Error(`low-level-server.md expected schema validation to return isError: ${JSON.stringify(rejectedBySchema)}`);
}
console.log(rejectedBySchema);

await client.close();
await server.close();
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import type * as z from 'zod/v4';

import { getCompleter, isCompletable } from './completable';
import type { ServerOptions } from './server';
import { Server } from './server';
import { disableLowLevelToolInputValidation, Server } from './server';

/**
* High-level MCP server that provides a simpler API for working with resources, tools, and prompts.
Expand Down Expand Up @@ -116,6 +116,7 @@ export class McpServer {

constructor(serverInfo: Implementation, options?: ServerOptions) {
this.server = new Server(serverInfo, options);
disableLowLevelToolInputValidation(this.server);

// Per the MCP spec, a server that declares a primitive capability MUST respond to its
// list method (potentially with an empty result) rather than "Method not found" — even
Expand Down
Loading
Loading