Skip to content

Spec registry

A named, taggable home for a project's spec set, so each transport reads one source instead of enumerating the same specs again. Both symbols are importable from the top-level package.

For the task-shaped walkthrough — where the instance lives, multiple registries, filtered views — see the recipe Declare specs once, project them to many transports.

SpecRegistry

SpecRegistry

A name → spec map with tags, stable ordering, and filtered views.

One declaration site for the operations a project exposes over more than one transport, so the transports read one source instead of each enumerating specs and drifting. It holds only the invariant part of an operation — which spec, its canonical name, its tags; per-transport configuration stays at the binding that owns it. Adapters read a registry at configuration time to build their binding tables; nothing consults one per request.

registry = SpecRegistry()
registry.register("list_orders", orders_spec, tags=("read", "public"))
registry.register("refund_order", refund_spec, tags=("write", "admin"))

public = registry.by_tag("public")   # a new registry — a snapshot

There is no global registry: a consumer holds as many of its own instances as it likes, with no shared state between them. Registries are mutable — register adds — but every derivation (by_tag, subset, merge) returns a new registry holding a snapshot of the selected entries, sharing the spec objects rather than copying them. A derived view never mutates its source, and a later register() on the source does not appear in a view derived earlier.

register

register(
    name: str,
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    *,
    tags: Iterable[str] = (),
    agent_contract: OfflineContract | None = None,
) -> None

Add a spec under name.

Parameters:

Name Type Description Default
name str

The canonical name. Must be unused in this registry — a duplicate raises rather than overwriting, so a copy-pasted declaration fails at import time instead of silently shadowing an operation.

required
spec ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any]

A ServiceSpec (mutation) or SelectorSpec (read).

required
tags Iterable[str]

Free-form labels, deduplicated into a frozen set.

()
agent_contract OfflineContract | None

What a transport with no HTTP request has to be told -- the URL captures and query params the URLconf and query string would have supplied. Every off-HTTP transport needs the identical answer, so declaring it here is declaring it once; a mount may still override it. Meaningless over HTTP, which is why it is not on the spec.

None

Raises:

Type Description
ValueError

name is already registered here.

TypeError

spec is neither a ServiceSpec nor a SelectorSpec. A PolymorphicServiceSpec is rejected — register each of its variants under its own name, since transports project one operation per variant, not a union.

get

get(name: str) -> RegisteredSpec | None

Return the entry registered under name, or None.

all

all() -> tuple[RegisteredSpec, ...]

Every entry, in registration order, so a transport's listing is stable.

mutations

mutations() -> tuple[RegisteredSpec, ...]

The ServiceSpec entries, in registration order.

queries

queries() -> tuple[RegisteredSpec, ...]

The SelectorSpec entries, in registration order.

by_tag

by_tag(*tags: str) -> SpecRegistry

A new registry holding the entries carrying any of tags.

Union, not intersection — chain calls for an intersection (reg.by_tag("read").by_tag("public")). No tags matches nothing.

subset

subset(*names: str) -> SpecRegistry

A new registry holding the named entries, in the order given.

Raises:

Type Description
KeyError

A name is not registered here — a typo is a configuration error, not a quietly smaller surface.

merge

merge(*others: SpecRegistry) -> SpecRegistry

A new registry combining this one with others, in order.

Names are unique per registry, so independent registries may reuse one; merging is the single place that reuse becomes a conflict.

Raises:

Type Description
ValueError

The same name appears in more than one input.

specs

specs() -> dict[str, ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any]]

A fresh name → spec dict — the shape adapters already accept.

Pass registry.specs() wherever a dict[str, spec] goes today. Mutating the returned dict does not affect the registry.

RegisteredSpec

RegisteredSpec dataclass

A spec under its canonical name, with free-form tags.

The value type held by SpecRegistry. It carries only the part of a "tool" that is invariant across transports — which spec, what it is called, and how it is grouped. Per-transport knobs (MCP annotations and output schemas, Pydantic-AI pagination or query-param registrations, …) stay at the binding that configures them.

Fields:

  • name — the canonical name for this operation. Unique within the registry that holds it; two independent registries are separate namespaces and may reuse a name.
  • spec — a ServiceSpec (a mutation) or a SelectorSpec (a read). The kind is deliberately not stored: it is derived by isinstance wherever it is needed, so a stored discriminator can never drift from the object it describes.
  • tags — a frozen set of free-form labels used to derive filtered views (registry.by_tag("public")). Tags carry boolean-ish facts — "read", "admin", "destructive" — that every transport can interpret in its own vocabulary. Structured, transport-specific payloads do not belong in a tag string; they belong at the binding.
  • agent_contract — an optional OfflineContract: what a transport with no HTTP request has to be told, because the URLconf and query string told an HTTP one for free. One typed slot rather than loose fields, so this stays an index rather than becoming a configuration object.

Note: "invariant across transports" is not the same test as "which transport configures it", and reading it as the latter is what put these declarations at the binding. icons and annotations are genuinely MCP's. A UrlKwarg is not: every off-HTTP transport needs the identical one, and none of them is where it should be declared.

OfflineContract

OfflineContract dataclass

The declarations an off-HTTP caller needs and an HTTP one never does.

Over HTTP this is all free. A nested route's captures reach a spec through view.kwargs because the URLconf declared them; read-shaping params reach a serializer through request.query_params because the query string carried them. A spec mounted on a view is already complete.

Off HTTP there is no route and no query string, so somebody has to say what they would have contained. That is what this is: not a missing part of the operation, but a description of the request that is not there.

UrlKwarg("project_pk") means "when there is no URL, synthesise this view kwarg from a caller-supplied argument" -- a sentence with no meaning on a transport that has a URL. Which is exactly why it does not belong on the spec itself: the spec would carry a field HTTP must ignore, and a second declaration of a fact the URLconf already owns, pointing the other way.

It belongs to the entry rather than to any one transport because every off-HTTP transport needs the identical answer. MCP and an in-process Pydantic-AI toolset synthesise the same absent request for the same operation; a project running both used to declare it twice, in two shapes, with nothing comparing them.

Deliberately not a home for bounds or strictness -- result-size caps, page ceilings, timeouts, unknown-argument policy. Those legitimately differ between a publicly exposed server and an in-process toolset, and one shared number would be a regression rather than a simplification. This carries only what cannot differ.

Sorting is absent for the same reason from the other direction: it is declared by the spec's own filter_set, which is already read by every transport, so it needs no second home.

Transports read this as a default. A mount may still override it, and one that says nothing inherits it.

field_audiences class-attribute instance-attribute

field_audiences: Mapping[str, FieldMarking] | None = None

Per-tool overrides layered over the output serializer's own markings.

Here rather than at a mount because FieldAudience already settles the question: the axis is audience, not protocol -- an MCP server and an in-process toolset want the same thing as each other, and something different from a browser. If the two are one audience, an override of what that audience sees cannot legitimately differ between them.

It also belongs at the same level as the thing it overrides. The baseline is the serializer's own FieldMarking markings, which are spec-level and read by every transport; declaring the baseline once and the override per mount is what let one spec project a different field set depending on which transport served it -- a field hidden for one agent audience left visible to the other, with nothing comparing them.