Two config files that look the same but behave differently was the last MCP gotcha. This one is worse — two schema formats that both register without error, but only one of them works.
The MCP SDK’s mcpServer.tool() method accepts a schema object as its third argument. That object defines the parameters your tool expects. You can pass it as a plain JSON Schema object or as Zod validators. The SDK doesn’t complain either way. But only Zod actually wires up the input parsing at runtime.
The Problem
You define an MCP tool with a plain JavaScript object describing the parameter types:
mcpServer.tool(
"my_tool",
"Does something useful.",
{
domain: {
type: "string",
description: "Domain to scan (e.g. example.com)"
}
},
async ({ domain }) => {
console.log(domain); // undefined
// ... your tool logic
}
);
The server starts. The tool appears in your MCP client’s tool list. Claude even calls it with the right arguments in the request payload. But inside your handler, domain is undefined.
No error is thrown. No warning is logged. The tool just doesn’t receive its inputs.
Why This Happens
The MCP SDK uses Zod internally for schema validation and input parsing. When you pass a plain JSON Schema object, the SDK stores it as metadata (it appears in the tool listing), but it doesn’t create a Zod parser from it. At runtime, the incoming tool call payload is validated against… nothing. The destructured parameters arrive empty.
This is a type-level mismatch that JavaScript can’t catch. The SDK’s TypeScript types allow either format in the signature, but the runtime code path only processes Zod schemas.
The Fix
Replace plain JSON Schema objects with Zod validators:
import { z } from "zod";
mcpServer.tool(
"my_tool",
"Does something useful.",
{ domain: z.string().describe("Domain to scan (e.g. example.com)") },
async ({ domain }) => {
console.log(domain); // "example.com" ✓
// ... your tool logic
}
);
That’s the whole fix. One import, one syntax change per parameter.
If you have multiple parameters:
mcpServer.tool(
"search",
"Search for items.",
{
query: z.string().describe("Search query"),
limit: z.number().optional().describe("Max results (default 10)"),
},
async ({ query, limit }) => {
// Both are properly parsed
}
);
Install Zod
If you don’t already have it:
npm install zod
Then add the import at the top of your server file:
import { z } from "zod";
How to Tell If You’re Affected
If your MCP tool:
- Registers successfully (appears in
/mcpstatus or your client’s tool list) - Gets called by the LLM with the correct arguments (visible in request logs)
- Receives
undefinedfor all parameters inside the handler
You’re using plain objects. Switch to Zod.
The Pattern Going Forward
Every MCP tool parameter should use Zod. The .describe() method on Zod types serves double duty — it provides the parameter description for the tool listing and enables runtime validation:
z.string().describe("Human-readable description")
z.number().optional().describe("Optional numeric param")
z.enum(["a", "b", "c"]).describe("One of the allowed values")
z.boolean().default(false).describe("Flag with default")
This isn’t a preference — it’s a requirement for the SDK to work correctly. The plain object syntax should probably throw an error, but it doesn’t. Now you know.