How to Write MCP Tool Input Schemas
Each MCP tool declares its parameters in an inputSchema, which is a JSON Schema object. The model reads that schema to decide what to send, and the server checks every call against it before your code runs. You don't write the JSON by hand: the Python SDK builds it from type hints and Field(...), and the TypeScript SDK builds it from Zod schemas. Give each parameter an exact type, an enum for fixed choices, a default when there's a sensible one, and a description that says what to pass.
What the schema does
The model doesn't see your function. It sees the tool's name, its description and the inputSchema that the server returns from tools/list. The spec requires that schema to be a valid JSON Schema object, and it reads as JSON Schema 2020-12 unless the schema names another dialect. The spec also says servers MUST validate all tool inputs.
When the arguments fail, the SDK returns a tool result with isError: true and the validation message, instead of a protocol error. The model can read that message and retry with a corrected value. The spec puts input validation errors in this category on purpose (SEP-1303).
This is what the TypeScript server's create_task tool looks like in tools/list, trimmed to the priority parameter:
"priority": {
"default": "normal",
"description": "How urgent the task is. Use normal unless the user says otherwise",
"type": "string",
"enum": [
"low",
"normal",
"high"
]
}
The enum tells the model the only valid values. The default means the model can leave the parameter out. The description is the text the model reads to decide what to put there.
How your types become schema
Both SDKs generate standard JSON Schema keywords from these types. The table shows what each one produces for the example server below.
Python (MCPServer) | TypeScript (Zod) | Schema the model sees |
|---|---|---|
str with Field(min_length=1, max_length=200) | z.string().min(1).max(200) | "type": "string", minLength, maxLength |
Literal["low", "normal", "high"] | z.enum(["low", "normal", "high"]) | "enum": ["low", "normal", "high"] |
int with Field(ge=1, le=100) | z.number().int().min(1).max(100) | "type": "integer", minimum, maximum |
date | z.iso.date() | "type": "string", "format": "date" |
= "normal" (any type with a default) | .default("normal") | "default": "normal" |
Optional type with = None (for example Reminder) | reminderInput.optional() | Not in required. Python adds an anyOf with a null branch |
Field(description="...") | .describe("...") | "description" |
Pydantic BaseModel class | Nested z.object({...}) | Python puts it in $defs and refers to it with $ref. TypeScript inlines it |
A parameter with no default and no None or .optional() is required. The required list in the schema shows which ones those are. In the example, title is the only required parameter of create_task.
Use the description to say what a value means, not what type it is. The type is already in the schema.
A worked example: a task list
The example is a task list with two tools. create_task uses most of the table above: a length-limited string, a fixed choice, a date, and an optional nested object. It also enforces a rule that a schema can't express: a reminder needs a due date. list_tasks has an optional filter and a number with bounds. Both servers do the same thing, so you can compare the two languages.
- Python
- TypeScript
import json
import logging
import os
from datetime import date
from pathlib import Path
from typing import Annotated, Literal
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import BaseModel, Field
# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("tasks")
TASKS_FILE = Path(os.environ.get("TASKS_FILE", Path.home() / ".tasks-mcp.json"))
mcp = MCPServer("tasks")
class Reminder(BaseModel):
"""When to send a reminder before a task is due."""
minutes_before: int = Field(
ge=0,
le=10080,
description="Minutes before the due date to send the reminder, up to one week",
)
channel: Literal["email", "push"] = Field(description="How to send the reminder")
def load_tasks() -> list[dict]:
if not TASKS_FILE.exists():
return []
return json.loads(TASKS_FILE.read_text())
def save_tasks(tasks: list[dict]) -> None:
TASKS_FILE.write_text(json.dumps(tasks, indent=2))
@mcp.tool()
def create_task(
title: Annotated[
str,
Field(
min_length=1,
max_length=200,
description="What needs doing, in one short sentence",
),
],
priority: Annotated[
Literal["low", "normal", "high"],
Field(
description="How urgent the task is. Use normal unless the user says otherwise"
),
] = "normal",
due_date: Annotated[
date | None,
Field(description="Due date as YYYY-MM-DD. Leave empty if the user gave no date"),
] = None,
reminder: Annotated[
Reminder | None,
Field(description="Optional reminder. Only valid together with due_date"),
] = None,
) -> str:
"""Create a task in the task list.
Use this when the user asks to add, create or remember a task.
"""
if reminder is not None and due_date is None:
raise ToolError(
"A reminder needs a due_date. Ask the user for a date, or leave out reminder."
)
tasks = load_tasks()
task = {
"id": len(tasks) + 1,
"title": title,
"priority": priority,
"due_date": due_date.isoformat() if due_date else None,
"reminder": reminder.model_dump() if reminder else None,
}
tasks.append(task)
save_tasks(tasks)
logger.info("Created task %s", task["id"])
return f"Created task {task['id']}: {title} (priority {priority})"
@mcp.tool()
def list_tasks(
priority: Annotated[
Literal["low", "normal", "high"] | None,
Field(description="Only show tasks with this priority. Leave empty for all tasks"),
] = None,
limit: Annotated[
int,
Field(ge=1, le=100, description="Maximum number of tasks to return"),
] = 20,
) -> str:
"""List tasks in the task list, newest first."""
tasks = [t for t in load_tasks() if priority is None or t["priority"] == priority]
if not tasks:
return "No tasks match."
lines = []
for t in reversed(tasks[-limit:]):
due = f", due {t['due_date']}" if t["due_date"] else ""
lines.append(f"- [{t['id']}] {t['title']} ({t['priority']}{due})")
return "\n".join(lines)
if __name__ == "__main__":
mcp.run(transport="stdio")
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const TASKS_FILE = process.env.TASKS_FILE ?? join(homedir(), ".tasks-mcp.json");
interface Reminder {
minutes_before: number;
channel: "email" | "push";
}
interface Task {
id: number;
title: string;
priority: "low" | "normal" | "high";
due_date: string | null;
reminder: Reminder | null;
}
const reminderInput = z
.object({
minutes_before: z
.number()
.int()
.min(0)
.max(10080)
.describe("Minutes before the due date to send the reminder, up to one week"),
channel: z.enum(["email", "push"]).describe("How to send the reminder"),
})
.describe("When to send a reminder before a task is due.");
const createTaskInput = z.object({
title: z
.string()
.min(1)
.max(200)
.describe("What needs doing, in one short sentence"),
priority: z
.enum(["low", "normal", "high"])
.default("normal")
.describe("How urgent the task is. Use normal unless the user says otherwise"),
due_date: z
.iso.date()
.optional()
.describe("Due date as YYYY-MM-DD. Leave empty if the user gave no date"),
reminder: reminderInput
.optional()
.describe("Optional reminder. Only valid together with due_date"),
});
const listTasksInput = z.object({
priority: z
.enum(["low", "normal", "high"])
.optional()
.describe("Only show tasks with this priority. Leave empty for all tasks"),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(20)
.describe("Maximum number of tasks to return"),
});
function loadTasks(): Task[] {
if (!existsSync(TASKS_FILE)) return [];
return JSON.parse(readFileSync(TASKS_FILE, "utf8")) as Task[];
}
function saveTasks(tasks: Task[]): void {
writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
}
function text(value: string, isError = false) {
return { content: [{ type: "text" as const, text: value }], isError };
}
// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "tasks", version: "1.0.0" });
server.registerTool(
"create_task",
{
description:
"Create a task in the task list. Use this when the user asks to add, create or remember a task.",
inputSchema: createTaskInput,
},
async ({ title, priority, due_date, reminder }) => {
if (reminder !== undefined && due_date === undefined) {
return text(
"A reminder needs a due_date. Ask the user for a date, or leave out reminder.",
true,
);
}
const tasks = loadTasks();
const task: Task = {
id: tasks.length + 1,
title,
priority,
due_date: due_date ?? null,
reminder: reminder ?? null,
};
tasks.push(task);
saveTasks(tasks);
console.error(`Created task ${task.id}`);
return text(`Created task ${task.id}: ${title} (priority ${priority})`);
},
);
server.registerTool(
"list_tasks",
{
description: "List tasks in the task list, newest first.",
inputSchema: listTasksInput,
},
async ({ priority, limit }) => {
const tasks = loadTasks().filter(
(t) => priority === undefined || t.priority === priority,
);
if (tasks.length === 0) return text("No tasks match.");
const lines = tasks
.slice(-limit)
.reverse()
.map((t) => {
const due = t.due_date ? `, due ${t.due_date}` : "";
return `- [${t.id}] ${t.title} (${t.priority}${due})`;
});
return text(lines.join("\n"));
},
);
return server;
}
// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("Tasks server error:", error),
});
The Python server uses Annotated[..., Field(...)] for each parameter. The TypeScript server passes a Zod object to registerTool, and each .describe() becomes a parameter description.
Check the schema the model will see
Ask the server for its tools. Run this from the folder that holds the server. Then run it again with --protocol-era legacy to get the same schema through the older 2025-era handshake:
- Python
- TypeScript
npx @modelcontextprotocol/inspector --cli uv run tasks.py --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector --cli node build/index.js --protocol-era modern --method tools/list
The Python output for priority matches what the model receives:
"priority": {
"default": "normal",
"description": "How urgent the task is. Use normal unless the user says otherwise",
"enum": [
"low",
"normal",
"high"
],
"title": "Priority",
"type": "string"
},
The title key comes from Pydantic and does no harm. Pass --strict to the same command to check the schema for portability problems. The Inspector exits with code 6 if it finds an error-level problem. Both servers in this guide exit 0.
See what a bad call returns
Send a value outside the enum:
npx @modelcontextprotocol/inspector --cli uv run tasks.py --protocol-era modern --method tools/call --tool-name create_task --tool-args-json '{"title":"x","priority":"urgent"}'
The Python server returns this as the tool result:
{
"content": [
{
"type": "text",
"text": "Error executing tool create_task: 1 validation error for create_taskArguments\npriority\n Input should be 'low', 'normal' or 'high' [type=literal_error, input_value='urgent', input_type=str]\n For further information visit https://errors.pydantic.dev/2.14/v/literal_error"
}
],
"isError": true
}
The TypeScript server returns a different wording with the same effect:
{
"content": [
{
"type": "text",
"text": "Input validation error: Invalid arguments for tool create_task: priority: Invalid option: expected one of \"low\"|\"normal\"|\"high\""
}
],
"isError": true
}
The Inspector exits with code 5 whenever a tool returns isError: true, so a script can catch the failure. The model gets the message in the tool result and can try again with normal. For how to word messages so the model recovers, see tool errors.
Rules the schema can't express
A schema checks shape: types, ranges, lengths and allowed values. It can't check a rule that depends on other parameters or on the state of the world. The SEP that made validation errors visible to the model gives the example of a departure date that must be in the future, which JSON Schema can't express (SEP-1303).
The reminder rule in create_task is the same kind of check. Its schema is valid on its own, so the code has to enforce it. Both servers return a tool error when a reminder comes without a due date. The TypeScript server returns the message as written. The Python SDK adds an Error executing tool create_task: prefix to the same text.
{
"content": [
{
"type": "text",
"text": "A reminder needs a due_date. Ask the user for a date, or leave out reminder."
}
],
"isError": true
}
Write the message so the model knows what to change. "Ask the user for a date" tells it to do that. A bare "invalid input" doesn't.
Common misconceptions
The docstring describes the parameters. In Python, the Args: section of a docstring stays in the tool's description. It doesn't go into the parameter schemas. Use Field(description=...) on each parameter. The bad version below has an Args: section and its parameters have no description in the schema.
Listing the options in the description is enough. A str parameter with "one of low, normal, high" in the text still accepts urgent. The bad version below returns ok for it. Use Literal or z.enum so the schema and the code agree.
The schema is all the validation you need. The spec says servers MUST validate all tool inputs. Schema validation covers types and ranges. Business rules like the reminder check still belong in your code.
The low-level Server checks arguments too. It doesn't. In the Python SDK, the low-level Server advertises the schema but never applies it, so your handler gets whatever the client sent. MCPServer, which this guide uses, validates arguments before the tool runs. If you use the low-level class, validate the arguments yourself (Python SDK v2 what's new).
Bad and good side by side
These are the smallest versions of the same tool that still show the problem. Both accept any priority and any due date. Both were run through the Inspector to produce the results below.
- Python
- TypeScript
from mcp.server import MCPServer
mcp = MCPServer("bad")
@mcp.tool()
def create_task(title: str, priority: str = "normal", due: str | None = None, reminder: dict | None = None) -> str:
"""Create a task.
Args:
title: Short title of the task.
priority: One of low, normal, high.
due: Due date, YYYY-MM-DD.
"""
return "ok"
mcp.run(transport="stdio")
With priority set to urgent, this server returns "text": "ok" and "isError": false. Its schema for priority is only {"default": "normal", "title": "Priority", "type": "string"}.
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
function createServer() {
const server = new McpServer({ name: "bad", version: "1.0.0" });
server.registerTool(
"create_task",
{
description: "Create a task.",
inputSchema: z.object({
title: z.string(),
priority: z.string(),
due_date: z.string(),
}),
},
async () => ({ content: [{ type: "text", text: "ok" }] }),
);
return server;
}
serveStdio(createServer);
With priority set to urgent and due_date set to next-friday, this server returns "text": "ok". Its schema for priority is only {"type": "string"}.
The good version has an enum for priority, a date format for due_date, and a description on every parameter. Its server rejects bad values before any code runs.
Going further: nesting and composition
Nested objects work in both SDKs. The reminder parameter in the example is one. The Python SDK moves the nested schema into $defs and refers to it with $ref. The TypeScript SDK writes it inline. Both are valid JSON Schema.
The spec allows anyOf, oneOf, allOf and $ref in an input schema (SEP-2106). That proposal also says older clients may not handle keywords beyond type, properties and required. Python already emits anyOf for optional parameters, so check the schema with the legacy era as well as the modern one, as the test script does.
Common problems
Input validation error: Invalid arguments for tool create_task: priority: Invalid option (TypeScript). The model sent a value that isn't in the enum. Check the enum list in tools/list and make the description say which values to use.
Input should be a valid date or datetime, invalid character in year (Python). The due_date value isn't a date. The schema asks for YYYY-MM-DD, so the model needs to convert a phrase like "next Friday" to a date before calling the tool.
Too small: expected string to have >=1 characters (TypeScript). The model sent an empty title. The min(1) check is doing its job. If the model sends empty titles often, make the title description clearer about what to write.
FAQ
Do I write the JSON Schema by hand?
No. The Python SDK builds it from type hints and Field(...), and the TypeScript SDK builds it from a Zod object. You can read the result in tools/list, as shown above.
Does the server check arguments before my function runs?
Yes, for MCPServer in Python and McpServer in TypeScript. A value that fails the schema returns a tool result with isError set to true, and your function does not run. The low-level Python Server does not check arguments.
Should I use an enum or list the options in the description?
Use an enum. The model sees the allowed values in the schema, and the server rejects anything else. A description alone can be ignored, and the server won't catch the mistake.
Which JSON Schema version does an MCP tool use?
The spec says 2020-12 unless the schema declares another $schema. The TypeScript output in this guide includes "$schema": "https://json-schema.org/draft/2020-12/schema".
- Tool names and descriptionsWrite names and descriptions that lead the model to the right tool.
- Tool errorsReturn error messages the model can act on.
- Tool outputsReturn text, structured content and output schemas.
- Build an MCP serverBuild and connect a complete server in Python or TypeScript.
- Test with MCP InspectorUse the Inspector web interface and CLI to check your tools.