TypeScript: How to Implement Strongly Typed MCP Tool Schemas with Zod
· TypeScript · MCP · Zod · API Design · Architecture
Learn how to bridge Model Context Protocol tool definitions with Zod schemas to achieve end-to-end type safety in agent-accessible applications.
Auf dem Laufenden bleiben
Erhalte eine kurze Nachricht, wenn ich etwas Neues veröffentliche. Deine E-Mail- oder Browserregistrierung wird nur für diese Updates gespeichert und kann jederzeit beendet werden. Ein Konto oder Trackingprofil ist nicht erforderlich.
Introduction to Model Context Protocol Tool Definitions
The Model Context Protocol (MCP) establishes a standardized way for Large Language Models to interact with external tools, data sources, and services. When building applications that expose deterministic server-side functions to dynamic LLM agents, maintaining strict contract boundaries is essential. Without robust schema validation, runtime errors multiply as models generate unexpected argument structures.
While raw JSON Schema definitions can be written by hand, doing so in a TypeScript environment duplicates effort and risks drift between the types your application code expects and the schemas exposed to the client. By leveraging Zod for runtime validation and static type inference, we can define our tool contracts in one place and derive both the JSON Schema required by the MCP protocol and the TypeScript interfaces used by our handler functions.
The Architecture of Typed Tool Handlers
A production-ready MCP tool implementation requires three distinct layers: the schema definition, the handler function, and the registry dispatcher. Zod sits at the foundation, bridging the gap between static type checking and dynamic runtime parsing.
import { z } from 'zod';
const executeQuerySchema = z.object({
query: z.string().min(1, 'Query cannot be empty'),
maxRows: z.number().int().positive().default(100),
includeMetadata: z.boolean().optional()
});
type ExecuteQueryArgs = z.infer<typeof executeQuerySchema>;By inferring the TypeScript type directly from the Zod schema, refactoring parameters becomes a compile-time safe operation. If a downstream consumer modifies the database query execution options, the compiler flags discrepancies across the entire implementation chain.
Translating Zod Schemas to MCP JSON Schemas
MCP servers require a tool description payload that includes a JSON Schema representation of the tool's input parameters. Because writing manual JSON Schema mappings is error-prone, we can use conversion utilities or leverage community libraries designed to map Zod structures into standard JSON Schema specs consumable by the protocol transport.
import { zodToJsonSchema } from 'zod-to-json-schema';
const mcpToolDefinition = {
name: 'execute_database_query',
description: 'Executes a read-only SQL query against the analytics database',
inputSchema: zodToJsonSchema(executeQuerySchema, {
target: 'jsonSchema7',
name: 'ExecuteQueryParams'
})
};Ensure that your schema conversion target aligns with the version of the JSON Schema specification expected by your MCP client implementation. Standardizing on draft-07 provides maximum compatibility across diverse agent runtimes.
Implementing the Tool Dispatcher and Validation Middleware
When a tool call arrives over the MCP transport layer, the payload contains untrusted JSON data. We must validate and parse this input through our Zod schema before passing it to the business logic handler.
type ToolHandler<T extends z.ZodTypeAny> = (
args: z.infer<T>
) => Promise<{ content: Array<{ type: string; text: string }> }>;
function createTool<T extends z.ZodTypeAny>(
schema: T,
handler: ToolHandler<T>
) {
return async (rawArgs: unknown) => {
const parseResult = await schema.safeParseAsync(rawArgs);
if (!parseResult.success) {
return {
content: [
{
type: 'text',
text: `Validation Error: ${JSON.stringify(parseResult.error.format())}`
}
],
isError: true
};
}
return handler(parseResult.data);
};
}This pattern encapsulates error handling and type coercion. If an LLM passes a numeric parameter as a string (e.g., "maxRows": "50"), Zod's coercion capabilities can handle it safely, or strict parsing will reject it with a clear, structured error message that the model can interpret and correct on its next attempt.
Handling Edge Cases and Complex Types
Real-world tools often deal with nested structures, enums, and optional arrays. When designing schemas for LLM consumption, descriptions are just as important as types.
const updateRecordSchema = z.object({
recordId: z.string().uuid('Must be a valid UUID'),
fields: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).describe(
'Key-value pairs of fields to update'
),
strategy: z.enum(['overwrite', 'merge']).default('merge')
});Using .describe() on Zod schemas allows generators to inject rich documentation directly into the resulting JSON Schema. LLMs rely heavily on field-level descriptions to understand constraints, formatting requirements, and semantic expectations.
Performance and Security Considerations
Parsing untrusted inputs at scale introduces potential performance overhead. Zod is highly optimized, but complex nested unions can impact throughput. Cache converted JSON Schemas at startup rather than recalculating them on every tool listing request.
On the security front, never trust data originating from an agent session. Strict schema validation acts as a boundary defense, preventing prototype pollution, SQL injection vectors embedded in dynamic parameters, and unexpected memory consumption caused by deeply nested payloads. Enforce maximum string lengths and array limits within your Zod definitions:
const safeSearchSchema = z.object({
searchTerm: z.string().max(256, 'Search term too long')
});Verification and Testing
Test your tool definitions by writing unit tests that simulate malformed inputs from an agent. Verify that your dispatcher catches validation failures and returns formatted error payloads rather than throwing unhandled exceptions that could crash the MCP server transport.
import { describe, it, expect } from 'vitest';
describe('execute_database_query tool', () => {
it('rejects empty queries', async () => {
const tool = createTool(executeQuerySchema, async () => ({ content: [] }));
const result = await tool({ query: '' });
expect(result.isError).toBe(true);
});
});By treating MCP tool schemas as first-class domain models, you eliminate boilerplate, ensure type safety across your codebase, and provide deterministic boundaries for LLM integrations.
