prerequisite
JSON Schema
How a schema describes what valid JSON looks like, so a model knows what arguments a tool takes and a server can reject bad ones.
Before this
This page assumes you are comfortable with:
Why you need this
Every MCP tool publishes an inputSchema that says what arguments it accepts, and may publish an outputSchema for what it returns. The model reads the input schema to decide what to send. The server (or the SDK in front of it) checks the arguments against it before your code runs. If you can read and write a schema, you control both what the model tries and what your code can trust.
The idea
A JSON Schema is a JSON object that describes a set of JSON values. Validation asks one question of a value: is it in the set? The answer is yes or no, and when it is no, a validator also gives a list of reasons, one per broken rule, each pointing at where in the value the problem is.
A schema is built from keywords. Each keyword adds one rule. A value is valid only if it passes every rule that applies to it.
| Keyword | Rule | Example |
|---|---|---|
type |
The value's JSON kind: "string", "number", "integer", "boolean", "null", "array", "object" |
{"type": "string"} |
properties |
For an object: a schema for each named key, applied when that key is present | {"properties": {"city": {"type": "string"}}} |
required |
For an object: keys that must be present | {"required": ["city"]} |
additionalProperties |
For an object: what to do with keys not listed in properties. false rejects them. |
{"additionalProperties": false} |
enum |
The value must equal one of a fixed list | {"enum": ["metric", "imperial"]} |
minimum, maximum |
For a number: inclusive bounds | {"minimum": 1, "maximum": 7} |
items |
For an array: a schema every element must match | {"items": {"type": "string"}} |
description |
No rule at all: text for whoever reads the schema | {"description": "City name, e.g. Oslo"} |
$defs and $ref |
$defs holds named schemas; $ref says "use that schema here" |
{"$ref": "#/$defs/person"} |
A few points that trip people up:
propertiesdoes not make anything required. A schema with three properties and norequiredaccepts an empty object.- Extra keys are allowed by default. Without
"additionalProperties": false, an object with a misspelled key ("cty"instead of"city") passes, and the real key is simply missing. "integer"checks the value, not the spelling. JSON has one number type, so45.0counts as an integer (its fractional part is zero) and45.5does not.descriptionis for the reader. For a tool, the reader is the model, which makes descriptions on each property some of the most useful text in the schema.$refpaths read like file paths.#/$defs/personmeans "start at the top of this schema (#), go into$defs, then intoperson". It lets you define a shape once and use it in several places.
The dialect MCP uses
JSON Schema has gone through several versions, called dialects, which differ in small ways. A schema can name its dialect with a $schema keyword whose value is the dialect's identifier (a web address). The Base Protocol section of the 2026-07-28 MCP specification says a schema with no $schema keyword is read as the 2020-12 dialect, and every implementation must support 2020-12. Write your schemas without $schema and you are on 2020-12.
The same section adds a safety rule: a $ref that points to a web address must not be fetched automatically, because a schema from an untrusted server could otherwise make your client download anything. Keep every $ref inside the schema, pointing into $defs.
Worked example
Here is a schema for the arguments of a create_event tool. It uses most of the keywords above, including a $ref to a shared person shape.
{
"type": "object",
"description": "Arguments for create_event",
"properties": {
"title": {"type": "string", "description": "Short name shown in the calendar"},
"minutes": {"type": "integer", "minimum": 5, "maximum": 480},
"visibility": {"enum": ["public", "private"]},
"guests": {"type": "array", "items": {"$ref": "#/$defs/person"}}
},
"required": ["title", "minutes"],
"additionalProperties": false,
"$defs": {
"person": {
"type": "object",
"properties": {"name": {"type": "string"}, "email": {"type": "string"}},
"required": ["email"]
}
}
}
Five candidate argument objects, plus one that surprises people, judged by hand first:
| # | Arguments | Verdict | Reason |
|---|---|---|---|
| 1 | {"title": "Lab meeting", "minutes": 60, "guests": [{"email": "ana@example.edu"}]} |
valid | Both required keys present, 60 is an integer in 5 to 480, the guest has an email. |
| 2 | {"title": "Lab meeting"} |
invalid | minutes is required. |
| 3 | {"title": "Lab meeting", "minutes": "60"} |
invalid | "60" is a string, not an integer. |
| 4 | {"title": "Lab meeting", "minutes": 600, "visibility": "secret"} |
invalid | Two reasons: 600 is above the maximum, and "secret" is not in the enum. |
| 5 | {"title": "Lab meeting", "minutes": 30, "guests": [{"name": "Ana"}], "room": "B12"} |
invalid | Two reasons: room is not a listed property, and the first guest has no email (the rule came through $ref). |
| 6 | {"title": "Lab meeting", "minutes": 45.0} |
valid | 45.0 is a whole number, so it passes "type": "integer". |
Now check those verdicts with a real validator. This uses the jsonschema package (pip install jsonschema), with its 2020-12 validator.
from jsonschema import Draft202012Validator
schema = {
"type": "object",
"description": "Arguments for create_event",
"properties": {
"title": {"type": "string", "description": "Short name shown in the calendar"},
"minutes": {"type": "integer", "minimum": 5, "maximum": 480},
"visibility": {"enum": ["public", "private"]},
"guests": {"type": "array", "items": {"$ref": "#/$defs/person"}},
},
"required": ["title", "minutes"],
"additionalProperties": False,
"$defs": {
"person": {
"type": "object",
"properties": {"name": {"type": "string"}, "email": {"type": "string"}},
"required": ["email"],
}
},
}
candidates = [
{"title": "Lab meeting", "minutes": 60, "guests": [{"email": "ana@example.edu"}]},
{"title": "Lab meeting"},
{"title": "Lab meeting", "minutes": "60"},
{"title": "Lab meeting", "minutes": 600, "visibility": "secret"},
{"title": "Lab meeting", "minutes": 30, "guests": [{"name": "Ana"}], "room": "B12"},
{"title": "Lab meeting", "minutes": 45.0},
]
validator = Draft202012Validator(schema)
for n, args in enumerate(candidates, start=1):
errors = sorted(validator.iter_errors(args), key=lambda e: list(e.path))
print(f"{n}: {'valid' if not errors else 'invalid'}")
for e in errors:
where = "/".join(str(p) for p in e.path) or "(top)"
print(f" at {where}: {e.message}")
Run it with python check_args.py. It prints:
1: valid
2: invalid
at (top): 'minutes' is a required property
3: invalid
at minutes: '60' is not of type 'integer'
4: invalid
at minutes: 600 is greater than the maximum of 480
at visibility: 'secret' is not one of ['public', 'private']
5: invalid
at (top): Additional properties are not allowed ('room' was unexpected)
at guests/0: 'email' is a required property
6: valid
Every verdict matches the table. Notice two things in the output. Candidates 4 and 5 each produce two reasons: a validator reports every broken rule, not just the first, which is what you want to send back to a model so it can fix everything in one retry. And each reason has a location (guests/0 is "the first element of guests"), which is how a long error list stays readable.
Try the same idea interactively below. The schema is a get_forecast tool's inputSchema: a required city string, an optional units string limited to "metric" or "imperial", a required days integer from 1 to 7, an optional hourly boolean, and no other keys allowed. Edit the arguments or pick a preset.
In a server's life
Schemas sit in stage 2, design the surface: a tool's inputSchema is the contract for what the model may send, and a tight one (enums, bounds, required, no extra keys) prevents whole classes of bad calls before your code runs. They return in stage 5, test and ship, where a saved copy of every tool's schema becomes a test that fails when a change would break existing callers, and in stage 6, maintain it, where changing a schema is the most common way to break a client.
Common mistakes
- Forgetting
required. The model omits an argument your code assumes is present. Symptom: aKeyErrororNonedeep inside the tool, reported to the model as a vague failure. - Leaving
additionalPropertiesopen. The model sends"cty": "Oslo". It passes validation,cityis missing, and ifcityis optional the tool silently runs with a default. Symptom: a forecast for the wrong place with no error. - A free-text string where an enum belongs. The model sends
"celsius","metric", and"C"on different days. Symptom: your code handles one spelling and fails on the others. - Bounds only in the description. "1 to 7 days" in prose does not stop
"days": 30. Put it inminimumandmaximumtoo, so both the model and the validator see it. - A
$refto a web address. Clients following the MCP rule refuse to fetch it, and the schema fails to resolve. Symptom: your tool is rejected by some clients and works in others.
Cost
Validation time grows with the size of the value and the number of keywords that apply, so for typical tool arguments (a handful of fields) it is far below a millisecond and never the slow part of a request. The real cost of a schema is tokens: the whole inputSchema, descriptions included, is put in front of the model on every turn, so each property description is paid for repeatedly. Spend those tokens on rules the model needs (allowed values, units, ranges) and cut prose that repeats the property name. The composition keywords this page skipped (anyOf, oneOf, allOf) can make validation expensive on purpose-built hostile schemas, which is why the specification asks implementations to bound them.
Going further
- Designing tools, for how to turn these keywords into tool interfaces a model uses well.
- The composition keywords
anyOf,oneOf, andallOf, for "one of these shapes" arguments. patternandformat, for strings that must look like a date, an email, or an ID.- How your SDK builds a schema from Python type hints, covered in Building a server in Python.
Leads to
- prerequisiteAPI contracts and versioningWhat a contract between two programs is, which changes keep it and which break it, and how version numbers and deprecation windows signal the difference.
- techniqueDesigning toolsNaming, describing, and shaping a tool so a model picks it correctly and calls it with valid arguments: input and output schemas, structured content, and errors.