Give every MCP tool a Zod schema and two things happen: the assistant sees exactly what arguments the tool takes, and any call that does not match is rejected with an error message the agent can read and fix. On DenkOps that is one field per tool, input, and the platform handles both the schema publishing and the validation.
How do I type a tool's input?
Pass a Zod schema as the tool's input. Here is a small, realistic server: a currency converter with a fixed rate table.
import { defineMcp } from "@denkopsai/sdk/mcp";
import { z } from "zod";
const rates: Record<string, number> = { EUR: 1, USD: 1.08, GBP: 0.85 };
export default defineMcp({
name: "fx-tools",
version: "1.0.0",
tools: {
convert: {
description: "Convert an amount between EUR, USD and GBP.",
input: z.object({
amount: z.number().positive(),
from: z.enum(["EUR", "USD", "GBP"]),
to: z.enum(["EUR", "USD", "GBP"]),
}),
handler: ({ amount, from, to }) => ({
amount,
from,
to,
converted: Math.round((amount / rates[from]!) * rates[to]! * 100) / 100,
}),
},
},
});The handler only runs on arguments that passed the schema, so it can use amount, from, and to without defensive checks. If you prefer, input also accepts a plain JSON Schema object, and you can omit it entirely for tools that take no arguments.
What does the assistant actually see?
When a client calls tools/list, your Zod schema is converted to JSON Schema and published as the tool's inputSchema:
{
"name": "convert",
"description": "Convert an amount between EUR, USD and GBP.",
"inputSchema": {
"type": "object",
"properties": {
"amount": { "type": "number", "exclusiveMinimum": 0 },
"from": { "type": "string", "enum": ["EUR", "USD", "GBP"] },
"to": { "type": "string", "enum": ["EUR", "USD", "GBP"] }
},
"required": ["amount", "from", "to"]
}
}This is why schemas matter more for agents than for humans. The model reads that schema before calling the tool, so an enum is not just validation, it is documentation the assistant follows. z.enum(["EUR", "USD", "GBP"]) tells the model the three legal values up front, which prevents most bad calls before they happen.
What happens when validation fails?
Suppose the agent calls convert with { "amount": "ten", "from": "EUR", "to": "USD" }. The handler never runs. The platform returns a JSON-RPC error with code -32602 and a message like invalid arguments for convert: ..., naming what failed. The agent sees that error as the tool result, reads it, and retries with "amount": 10. In practice this loop resolves itself in one round trip, precisely because the error says what is wrong instead of failing deep inside your handler with a stack trace.
Validation errors are distinct from handler errors. If your handler throws, the client gets a tool result flagged isError with the text tool error: <message>. So the agent can tell "I called this wrong" apart from "the tool itself failed", and only the first one is fixable by retrying with different arguments.
Why not validate inside the handler?
You could, but you would be rebuilding what the schema already gives you: the published contract and the rejection would live in two places and drift apart. With input as the single source of truth, the schema the assistant reads is the schema that is enforced. Your handler stays business logic only.
Typed tools are the second step after deploying your first MCP server. Tools are also not the only surface a server offers: see resources and prompts for the rest, or the MCP hosting overview for where this all runs.