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 |
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
|
|
TypeError
|
|
all ¶
Every entry, in registration order, so a transport's listing is stable.
mutations ¶
The ServiceSpec entries, in registration order.
by_tag ¶
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 ¶
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 ¶
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 ¶
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— aServiceSpec(a mutation) or aSelectorSpec(a read). The kind is deliberately not stored: it is derived byisinstancewherever 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 optionalOfflineContract: 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
¶
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.