JSON Schema¶
A transport that hands an operation to a client describes it first: an MCP
server lists each tool with an input and an output schema, an agent toolset
gives the model the same, a form builder picks its widgets from one. The
kernel emits that description from the spec's own declarations -
Parameters for what
goes in, Output for what comes
out - so no adapter describes itself.
That is the point of emitting it here. Every adapter already reads its
library's declaration into Parameters and Output, and the schema is read from
those alone, so there is one dialect whichever library declared the spec.
A pydantic model's own model_json_schema() is not used: it speaks a
different dialect - references, titles on every field, a decimal as an
anyOf - and a transport would hand its clients whichever one the project
happened to install.
The functions¶
spec_input_schema(spec, *, unknown_arguments=REJECT)describes everythingspec.parameters()declares: the arguments dispatch checks.spec_output_schema(spec)describes whatpresentreturns for the spec, orNonewhen the spec declares no output.parameters_schema(parameters, *, unknown_arguments=REJECT)andoutput_schema(output)are the same over a bare declaration, for a transport describing something that is not a whole spec.
Each schema is a plain dict, built anew on every call, so a transport can add
its own keys - a $schema, an annotation of its own - without reaching the
declaration or another transport's copy.
An example¶
The notes list and the author write from the earlier pages, described:
from django_service_specs import UnknownArguments, spec_input_schema, spec_output_schema
from docs.examples.declaring import list_notes_spec
from docs.examples.relations import create_author_spec
# What a transport advertises for the notes list: its arguments, and its rows.
notes_input = spec_input_schema(list_notes_spec)
notes_output = spec_output_schema(list_notes_spec)
# The author write, as a transport that refuses an undeclared argument...
author_input = spec_input_schema(create_author_spec)
# ...and as one that drops it, which must not claim to refuse it.
lenient_author_input = spec_input_schema(
create_author_spec, unknown_arguments=UnknownArguments.IGNORE
)
author_output = spec_output_schema(create_author_spec)
The notes list takes a search term with help text and an ordering with two choices and a default, and refuses any other argument:
{
"type": "object",
"properties": {
"search": {"type": "string", "description": "Part of the title, in any case."},
"ordering": {"type": "string", "enum": ["title", "-title"], "default": "title"}
},
"additionalProperties": false
}
It returns a list, so its output is an array of rows. The rows' fields carry
markings - id is a handle, title the label - which the schema does not
state; see what is not emitted.
{
"type": "array",
"items": {
"type": "object",
"properties": {"id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["id", "title"]
}
}
The author write takes rows, and each row is described in full and closed as
the top is. A book's price is a decimal, its status has choices and a
default, and published_on and pk may be null:
{
"type": "object",
"properties": {
"name": {"type": "string"},
"books": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"price": {"type": "string", "format": "decimal"},
"status": {"type": "string", "enum": ["draft", "published"], "default": "draft"},
"published_on": {"type": ["string", "null"], "format": "date", "default": null},
"pk": {"type": ["integer", "null"], "default": null}
},
"required": ["title", "price"],
"additionalProperties": false
}
}
},
"required": ["name"],
"additionalProperties": false
}
Its output selector re-reads the author once the write has committed, so the
output may be null (see what a spec returns), and the
book's status states each choice's display as a title:
{
"type": ["object", "null"],
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"books": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"title": {"type": "string"},
"price": {"type": "string", "format": "decimal"},
"status": {
"type": "string",
"oneOf": [
{"const": "draft", "title": "Draft"},
{"const": "published", "title": "Published"}
]
}
},
"required": ["id", "title", "price", "status"]
}
}
},
"required": ["id", "name", "books"]
}
Precise: never false, and not incomplete where the declaration knows¶
A schema is a claim a client acts on. A model that reads "this may not be null" never sends null; a form builder that reads an enum offers nothing else. So the rule is that the schema never states something the kernel would contradict, and states everything the declaration knows.
- Nullable is stated for every node, objects and arrays included, as a
type list:
["string", "null"]. Parameters carriesnullablewhichever library declared the field, so the schema does not depend on it. An array's element states its own, fromitems_nullable, onitems: the array and its elements may each be null or not, independently. - An enum lists every accepted value. An enum claims to be the whole set,
so leaving a value out is a falsehood rather than an omission. A nullable
parameter with choices therefore lists
nullamong them, because the shape check accepts a null before it reads the choices. An array's choices constrain each element, so they sit onitems, and gainnullthere by the element's nullability alone, never by the array's. requiredlists what the shape check refuses when absent: a required parameter with no default. A required parameter that declares a default is not refused when omitted, because the Validator supplies the default, so it is not listed.- A value JSON cannot carry is left out whole. A default of
Decimal("9.99")ordate.today, or a choice set holding one, would break the encoding every transport performs on the schema, so it is not stated rather than restated in a form the declaration did not choose. Leaving out only the unencodable choices would claim the others are refused.
What stays incomplete is only what the declaration does not carry. There are
no bounds (maxLength, minimum) because Parameters has none yet; the
Validator enforces them, and adding them to Parameters would reach the schema
too.
The decimal convention¶
A decimal is {"type": "string", "format": "decimal"}. The shape check also
accepts a JSON number for one, because every decimal validator does, and the
schema does not advertise that: a string is the form every validator reads
without a binary float's rounding, so it is the one a client is told to send.
The closed set follows the policy¶
Dispatch closes the argument set under UnknownArguments.REJECT, the
default, and the schema says so with "additionalProperties": false, at
exactly the levels the shape check closes: the top, every object parameter
that declares its fields, and every row of an array of rows. A declared
object with no fields at all is closed too, since it refuses every key.
Under UnknownArguments.IGNORE an undeclared key is accepted and dropped, so
a schema saying false would tell the client a call that runs is invalid.
The schema leaves the keyword out at every level instead, as
lenient_author_input in the example does: the same description as
author_input with every "additionalProperties": false gone. Pass the
policy the transport dispatches with.
Flat and self-contained¶
Every schema is inline. There is no $defs and no $ref, however deeply the
declaration nests: most MCP clients refuse a tool schema holding a reference,
and none of the family's transports resolve one.
A declaration that refers to itself - a category tree, a threaded comment -
cannot be inlined without end, so an adapter bounds it where it reads the
declaration: past the bound, a nested object is declared with no fields.
The schema of that node is {"type": "object"}. It is the one thing still
known to be true, it is what a caller can still send, and it constrains
nothing a valid call could fail. An object parameter declared without fields
is described the same way for the same reason.
What a spec returns¶
spec_output_schema
describes what dispatch presents, which depends on the kind of spec as well as
on its presenter:
| Spec | Schema |
|---|---|
| No presenter, on the spec or its output selector | None |
A LIST selector spec |
{"type": "array", "items": <item>} |
A RETRIEVE selector spec |
the item |
A RETRIEVE selector spec with allow_none=True |
the item, with "null" in its type |
A service spec whose output selector is a LIST |
{"type": "array", "items": <item>} |
A service spec whose output selector is a RETRIEVE |
the item, with "null" in its type |
| A service spec with no output selector | the item |
A RETRIEVE selector spec without allow_none answers a missing row as
not-found, which a transport reports in its own terms and never presents, so
its item is not nullable. A service spec's RETRIEVE output selector is
nullable whatever its allow_none says: once the service has run, a re-read
that finds nothing is None rather than not-found, because not-found would
tell the caller a committed write did not happen. And a service spec that
presents what the service returned is described as the item: whether a
service can return None is not something its declaration says.
What is deliberately not emitted¶
- No
titleon input. A Parameter has no label. On output, a field'slabelbecomes itstitle, and adapters set a label only where the author wrote one, so a title is never the field's name restated. - No
additionalPropertieson output. Output is not a set a caller can get wrong, and a transport may add keys of its own to what it sends. - No
defaultordescriptionon output. A default says what happens when a caller omits an argument, which means nothing for a value that came back. - No marking. A field marked as a handle, a label or hidden is described like any other. Showing a field to an agent audience, or leaving it out, is a projection over the schema and the payload, and the kernel declares markings without applying any.
- No
$schemakey. A transport whose wire wants one adds it.