Skip to content

JSON Schema

View-free JSON Schema generation. These helpers turn a ServiceSpec / SelectorSpec (or a bare DRF serializer / dataclass) into a JSON Schema dict, with no view, request, or drf-spectacular dependency — what an alternate transport (a Pydantic-AI toolset, the MCP server) builds tool definitions from. Distinct from the OpenAPI adapter, which produces DRF serializer classes for DRF's own OpenAPI generators.

serializer_to_json_schema

serializer_to_json_schema

serializer_to_json_schema(
    serializer: type | None,
    *,
    partial: bool = False,
    registry: JsonSchemaRegistry = DEFAULT_JSON_SCHEMA_REGISTRY,
    max_depth: int | None = None,
) -> dict[str, Any]

Build a JSON Schema object for an input serializer / dataclass / None.

Accepts a DRF Serializer subclass, a bare @dataclass type (the convention drf-services services use for data), or None (the operation takes no input). Always returns an object — {"type": "object"} is the convention for "no declared fields", so an alternate transport can still describe the tool.

partial=True drops the required list — mirroring spec.partial, where the validator accepts omitted fields, so advertising them as required would make schema-strict consumers reject calls the service accepts.

registry supplies consumer rules for custom field / Python types — see JsonSchemaRegistry.

max_depth bounds how many serializer levels are described, truncating deeper ones to {"type": "object"}; None, the default, describes them all, which is what every caller got before the option existed. The root is level 1, so max_depth=1 publishes the top-level fields and truncates every nested serializer. It is a size knob and nothing more: a self-referential serializer is truncated after a fixed number of appearances whatever this says, because the alternative is a RecursionError raised while a transport declares its tools. Whichever of the two is tighter wins, so this still yields exactly the levels it names. A dataclass input has no nested-serializer walk, so the bound does not reach that branch.

Truncation is flat and self-contained — never $defs / $ref, which most MCP clients reject outright.

output_to_json_schema

output_to_json_schema

output_to_json_schema(
    output_serializer: type | None,
    *,
    kind: SelectorKind | None = None,
    paginate: bool = False,
    projection: AudienceProjection | None = None,
    handle_description: str | None = None,
    registry: JsonSchemaRegistry = DEFAULT_JSON_SCHEMA_REGISTRY,
    max_depth: int | None = None,
) -> dict[str, Any] | None

Build a JSON Schema for an output serializer, or None when undeclared.

Returns None when there is no output_serializer — callers shouldn't fabricate a misleading shape. kind / paginate make the schema match what dispatch actually returns:

  • kind=None / RETRIEVE — the bare item schema.
  • kind=LIST, paginate=False{type: array, items: <item>}.
  • kind=LIST, paginate=True — the pagination envelope {items, page, totalPages, hasNext}.

projection applies the serializer's field markings, mirroring what project_payload does to the payload — hidden fields dropped, choices re-declared in their display values, and a formatted field re-declared as the type its ValueFormatter produces. It lands on the item, wherever the item sits for this kind — the array wrapper and the pagination envelope are this function's own shapes and belong to no serializer, so a projection walking them would look for markings that cannot exist and silently annotate nothing.

handle_description is passed through to annotate_output_schema as the fallback wording for an unlabelled handle. It defaults to nothing: what a reader should do with an identifier depends on the reader, and the transport is what knows.

registry supplies consumer rules for custom field / Python types — see JsonSchemaRegistry.

max_depth bounds how many serializer levels the item describes, truncating deeper ones to {"type": "object"}; None, the default, describes them all. The item is level 1, and the array wrapper and the pagination envelope are this function's own shapes, so they cost no level. Independently of this bound, a serializer that nests itself is truncated after a fixed number of appearances rather than recursing until the process dies; where the two disagree the tighter wins, so this still yields exactly the levels it names. Truncation is flat and self-contained — never $defs / $ref, which most MCP clients reject outright.

filterset_to_json_schema

filterset_to_json_schema

filterset_to_json_schema(
    filter_set_class: Any,
    *,
    registry: JsonSchemaRegistry = DEFAULT_JSON_SCHEMA_REGISTRY,
) -> dict[str, dict[str, Any]]

Map a django-filter FilterSet class to JSON Schema properties.

Returns a dict shaped like the "properties" key of a JSON Schema object, ready to merge into a spec's input schema — which is what spec_to_json_schema does for a SelectorSpec carrying a filter_set. Every filter is optional: a filter narrows the queryset but is never required to call the operation, so no name is added to a required array.

registry.filters rules are tried first, so a consumer can map a custom filter type or override a built-in; common filter classes get accurate mappings otherwise, and anything unrecognised falls back to {} (JSON Schema "any value") rather than breaking generation. Requires the [filter] extra (django-filter), raising a clear ImportError when it is absent — only ever when a filter_set is actually introspected.

What a filter publishes about itself

A filter carries more than a type. Its label becomes title, its help_text becomes description, and a ChoiceFilter's labels ride along with their constants as {"const": ..., "title": ...} — the same shape the serializer path has always produced, so one project's constants are described one way whichever side of a spec they arrive on. Labels that only restate their value are dropped.

Where the argument's own name does not give the lookup away, it is stated:

class ArticleFilter(django_filters.FilterSet):
    min_views = django_filters.NumberFilter(field_name="views", lookup_expr="gte")

publishes min_views as {"type": "number", "description": "Matchesviewswith thegtelookup."}. A filter whose name, field and lookup already agree — name matching name for equality — says nothing extra, and your own help_text always wins over the derived wording.

spec_to_json_schema

spec_to_json_schema

spec_to_json_schema(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    *,
    phase: Literal["input", "output"] = "input",
    registry: JsonSchemaRegistry = DEFAULT_JSON_SCHEMA_REGISTRY,
    max_depth: int | None = None,
) -> dict[str, Any] | None

Derive a JSON Schema from a spec, reading the right serializer off it.

The convenience an alternate transport (a Pydantic-AI toolset, the MCP server) calls instead of reaching into spec internals itself. registry supplies consumer rules for custom field / filter / Python types — see JsonSchemaRegistry.

phase="input" (default) returns the input-argument schema:

  • ServiceSpec → its input_serializer (spec.partial honoured).
  • SelectorSpec → an object whose properties combine the selector callable's own annotated parameters (skipping the request / user / view transport seeds) with its filter_set fields, so get_widget(user, pk) advertises pk instead of leaning on its docstring; a bare {"type": "object"} when it exposes neither. A **kwargs: Unpack[SomeExtras] parameter is expanded into one property per TypedDict key, its required keys populating required, so a URL kwarg read from extras is discoverable off-HTTP rather than a hidden KeyError. Introspecting a filter_set needs the [filter] extra.

phase="output" returns the output schema, or None when undeclared: a ServiceSpec supplies its output_selector_spec's output_serializer and kind, a SelectorSpec its own.

max_depth bounds how many serializer levels are described, truncating deeper ones to {"type": "object"}; None, the default, describes them all. It reaches the serializer-backed schemas — a ServiceSpec's input and either spec's output — and has nothing to bound on a SelectorSpec's input, which is reflected from a callable and a filter_set rather than walked. A serializer that nests itself is truncated after a fixed number of appearances regardless, because the alternative is a RecursionError raised while a transport declares its tools; where the two disagree the tighter wins, so this still yields exactly the levels it names.

metadata["json_schema"] is the one declaration this merges on top. Derivation reads serializers and callables, so there is nowhere for it to find a title for the operation or a sentence saying what the operation does; every transport was left to invent its own, from the spec name or a docstring. A consumer writes the fragment once, on the spec:

ServiceSpec(
    service=archive_project,
    input_serializer=ArchiveInput,
    metadata={
        "json_schema": {
            "input": {"title": "Archive project", "description": "Retire a project."}
        }
    },
)

It is keyed by phase, with the same two words phase= takes. One flat fragment merged into both would hang the operation's description off the output schema, which describes what comes back rather than what to send — two different sentences that only ever coincide by accident. A key that is neither "input" nor "output" raises rather than being ignored, because omitting the phase key is the mistake this shape invites and silently publishing nothing is the worst way to report it.

The fragment wins, key by key, and the merge is shallow. It is an author's explicit declaration standing against a derived value, so a derivation it could not override would leave a wrong derivation unfixable — which is the whole reason the hatch exists. Shallow means one rule: a key the fragment names is the fragment's, whole. So a fragment naming properties replaces the entire derived block rather than adding to it, which is the sharp edge and is deliberate — the alternative is a per-key policy for properties and another for required, and every answer there is wrong for somebody.

A fragment annotates a derived schema and never conjures one: where phase="output" yields None because nothing declares an output, an "output" fragment leaves it None. Otherwise metadata would become a schema-authoring channel and a fragment carrying only a description would publish as an output schema describing nothing.

The fragment is read off the spec passed in, never off a nested one: metadata does not merge or inherit, so a ServiceSpec's output schema takes the ServiceSpec's fragment even though the serializer behind it came from output_selector_spec.

Validation happens here rather than at construction. metadata is declared by consumers who may never generate a schema, and checking a reserved key on every ServiceSpec(...) would mean the kernel reads metadata contents — the one thing the field promises it does not do.

A selector's input schema reflects the selector callable's own parameters — names plus a JSON type from each annotation, skipping the request / user / view transport seeds — merged with its filter_set fields. So a lookup selector like get_widget(user, pk) advertises pk instead of a bare {"type": "object"} that leans on the docstring alone. An un-annotated parameter is still surfaced by name (untyped {}); a filter_set field wins over a callable parameter of the same name.

What an annotation publishes

The mapping from a Python annotation is structural, so a declaration a caller took the trouble to write survives into the published schema:

Annotation Schema
str / int / float / bool / None the matching JSON type
datetime / date / time / UUID / Decimal string with the format DRF's own field would use
Literal["open", "closed"], an Enum subclass {"enum": [...]} — by member value for an Enum
list[X], set[X] / frozenset[X] array (a set adds uniqueItems)
dict[str, X] object with additionalProperties
X \| None, Union[X, Y] {"anyOf": [...]}

Anything else — a domain class, a Callable, a bare Any, an annotation that could not be resolved — publishes as {}, which in JSON Schema means any value, so a caller cannot tell it from a value that genuinely is unconstrained. Register the types that matter to you rather than letting them publish as anything:

registry = DEFAULT_JSON_SCHEMA_REGISTRY.extend(
    python_types=[(Money, {"type": "string", "format": "money"})],
)

Rules are matched by type identity, and members are resolved recursively, so one rule for Money also covers list[Money] and Money | None.

What a reflected input can say about itself

A serializer field describes itself through help_text, and a filter through its own. A reflected input has neither — it is a TypedDict key or a bare parameter — so it carries the sentence in its Annotated metadata, with InputDescription:

class WidgetExtras(HttpExtras[MyUser], total=False):
    project_pk: Annotated[
        int, InputRequired, InputDescription("The project whose widgets to list.")
    ]

project_pk publishes as {"type": "integer", "description": "The project whose widgets to list."}description being the same key the serializer and filter paths already fill, so one project's inputs read the same way whichever side of a spec they arrive on. The marker composes with InputRequired in either order and is ignored by anything else reading the same Annotated.

It is refused beside NotClientInput, which drops the key from the schema entirely and so leaves the sentence no caller to reach, and refused twice on one input, because a schema publishes one description and picking a winner would be an arbitrary rule to memorise. See the off-HTTP inputs recipe.

A spec-level title and description

Derivation reads serializers, filters and callables. None of them can tell it what the operation is called or what it does, so spec_to_json_schema used to emit no title and no description at all and every transport invented its own — from the spec name, from a docstring, from a hand-written table.

The reserved metadata["json_schema"] key is where that is declared once:

ServiceSpec(
    service=archive_project,
    input_serializer=ArchiveInput,
    output_selector_spec=SelectorSpec(kind=SelectorKind.RETRIEVE, output_serializer=ProjectOut),
    metadata={
        "json_schema": {
            "input": {
                "title": "Archive project",
                "description": "Retire a project without deleting its history.",
            },
            "output": {"description": "The project as it stands after archiving."},
        }
    },
)

Three decisions worth knowing:

  • It is keyed by phase, with the same two words phase= takes. One flat fragment merged into both would hang the operation's description off the output schema, which describes what comes back rather than what to send. A key that is neither "input" nor "output" raises — forgetting the phase key is the mistake this shape invites, and publishing nothing is the worst way to report it.
  • The fragment wins, key by key, and the merge is shallow. It is an explicit declaration standing against a derived value, so a derivation it could not override would leave a wrong derivation unfixable. Shallow means one rule instead of a per-key policy: a fragment naming properties replaces the whole derived block rather than adding to it.
  • It annotates a derived schema and never conjures one. Where phase="output" yields None because nothing declares an output, an "output" fragment leaves it None — otherwise metadata would quietly become a schema-authoring channel.

The fragment is read off the spec you pass in, never off a nested one: metadata does not merge or inherit, so a ServiceSpec's output schema takes the ServiceSpec's fragment even though the serializer behind it came from output_selector_spec. Malformed declarations raise here rather than at construction, so declaring metadata costs nothing on a spec that generates no schemas.

What generation can and cannot see

Both entry points instantiate the serializer with the same baseline context dispatch renders with — {"request": None, "format": None, "view": None} — so a serializer whose get_fields reads self.context["request"] is describable, not just callable. Before, description raised KeyError on a serializer the same spec rendered perfectly.

The view and request are None and cannot be otherwise: a schema is built once, when a transport declares its tools, and there is no request at that moment to describe. So a get_fields that branches on the view or the user is reflected as the branch taken by a caller with neither. Reflection cannot report a field set that depends on who is asking, because at description time nobody is — if your field set varies by audience, declare it with field markings instead, which are resolved per render.

How deep generation goes

A nested serializer is walked into, and two things stop the walk.

A serializer that nests itself is truncated after four appearances. A category tree, a threaded comment, an org chart — the shape is ordinary, and describing it unbounded is a RecursionError raised while a transport declares its tools, before any request exists to fail. So the guard is always on and has nothing to configure. It follows the current path, not everything seen: the same address serializer under billing and under shipping sits on two different paths and is described in full on both, however often either is walked.

The allowance is four rather than one because a serializer may bound its own nesting — the countdown recipe, where each level constructs the next with one less to spend:

class NodeSerializer(serializers.Serializer):
    name = serializers.CharField()

    def __init__(self, *args, depth=3, **kwargs):
        super().__init__(*args, **kwargs)
        if depth > 0:
            self.fields["children"] = NodeSerializer(depth=depth - 1, many=True)

That declaration terminates by itself and was never at risk of recursing, but class identity cannot tell it apart from the unbounded form. Truncating at the first re-entry published one level where four are declared. Four appearances — the root plus the three levels this recipe is usually written with — is what publishes it as declared; a declaration that nests itself deeper than that is still truncated, and so is any declaration with no bound of its own.

max_depth is an opt-in ceiling on size. A schema grows roughly threefold per nesting level and both agent transports rebuild every tool's schema each time they list, so a deep declaration is paid for on every listing. It counts serializer objects — the root is level 1, and the array wrapper many=True produces costs no level:

schema = output_to_json_schema(InvoiceSerializer, kind=SelectorKind.LIST, max_depth=2)

Left unset it describes every level, which is what generation has always done.

The two bounds are read together and the tighter one wins: max_depth=2 yields two levels of a self-referential serializer, allowance or no allowance. The allowance is a floor under what generation does when nobody asked for a bound — never a quota a caller has to spend.

A truncated node is {"type": "object"} — the one thing still known to be true, and what a caller can still send: an object whose keys the schema declines to enumerate. Never $defs / $ref. Factoring the repeated sub-schema out is the obvious answer and it is refused deliberately: most MCP clients reject a tool schema containing a reference outright, so it would trade a size problem for a compatibility one. Every schema these helpers emit is flat and self-contained.

JsonSchemaRegistry

JsonSchemaRegistry dataclass

Consumer-extensible type → JSON Schema fragment rules for the helpers.

Rules are tried in order and the first match wins, before the built-in mappings — so a rule both adds support for a custom type and can override a built-in. The matched fragment is copied per use, so callers may freely mutate the returned schema.

Immutable: extend returns a new registry rather than mutating, so there is no shared global state to leak across callers or tests. Start from DEFAULT_JSON_SCHEMA_REGISTRY, the empty base every *_to_json_schema helper falls back to, layer rules on, and pass the result via the helpers' registry= argument:

registry = DEFAULT_JSON_SCHEMA_REGISTRY.extend(
    fields=[(MoneyField, {"type": "string", "format": "money"})],
)
schema = serializer_to_json_schema(MySerializer, registry=registry)

Attributes:

Name Type Description
fields tuple[_Rule, ...]

DRF Field subclasses, matched by isinstance when walking a serializer (serializer_to_json_schema, and the input side of spec_to_json_schema).

filters tuple[_Rule, ...]

django-filter Filter subclasses, matched by isinstance when walking a FilterSet (filterset_to_json_schema).

python_types tuple[_Rule, ...]

Bare Python types, matched by identity when walking a dataclass's field annotations.

extend

extend(
    *,
    fields: Sequence[_Rule] = (),
    filters: Sequence[_Rule] = (),
    python_types: Sequence[_Rule] = (),
) -> JsonSchemaRegistry

Return a new registry with the given rules prepended (they win first).

DEFAULT_JSON_SCHEMA_REGISTRY

DEFAULT_JSON_SCHEMA_REGISTRY module-attribute

DEFAULT_JSON_SCHEMA_REGISTRY: JsonSchemaRegistry = JsonSchemaRegistry()