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:

  • properties does not make anything required. A schema with three properties and no required accepts 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, so 45.0 counts as an integer (its fractional part is zero) and 45.5 does not.
  • description is 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.
  • $ref paths read like file paths. #/$defs/person means "start at the top of this schema (#), go into $defs, then into person". 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: a KeyError or None deep inside the tool, reported to the model as a vague failure.
  • Leaving additionalProperties open. The model sends "cty": "Oslo". It passes validation, city is missing, and if city is 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 in minimum and maximum too, so both the model and the validator see it.
  • A $ref to 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, and allOf, for "one of these shapes" arguments.
  • pattern and format, 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

Back to Building and maintaining MCP servers