2. A canonical capability registry, with MCP, HTTP, OpenAPI, Swagger UI, the CLI and the reference as projections
Extends ADR 1.
Context
ADR 1 added a Rust executable with one command, mcp, and a hand-written table of four
tools and a resource listing inside the MCP code. The operator asked for the same
capabilities over HTTP with an OpenAPI document and Swagger UI, for introspection from the
command line, and for generated reference documentation, with one hard invariant: a
capability is defined once, and every interface is derived from that definition, so that
no two interfaces can drift apart silently. The repository's own doctrine says the same
about generated files (project.derived-files-regenerated) and about numbers in prose.
Decision
- One model. A
Capabilityis a typed descriptor: canonical id, kind (queryorresource), title, description, canonical input and output schemas, provenance, exposure (MCP tool and/or resource, HTTP method and path, CLI path — each explicit, none inferred), stability, tags. A query carries one typed handler; a resource is read. - Two sources, one registry. Builtin executables are composed explicitly in
capability/builtin.rsthrough amacro_rules!that only fills the descriptor and wraps the typed function; no procedural macro, no linker registry, no code generation. Every object of the index becomes a resource capability. Both meet inCapabilityRegistry, built at one place (app.rs), which refuses duplicate ids, duplicate MCP names and URIs, duplicate HTTP routes, duplicate CLI paths, malformed exposures, and an executable exposure on a planned or unsupported capability, naming every party. - Schemas from types, at run time from data. Input and output schemas of executables
come from the Rust types (
schemars); the metadata contract of every declarative kind is a JSON Schema (draft 2020-12) read at run time fromshare/schemas/, validated with a JSON Schema validator;share/kinds.yamlsays how each kind is read. A repository adds kinds and schemas under.ai/repo/knowledge/; it cannot redefine a distributed one. - Projections. MCP (
mcp/surface.rs), HTTP routes with GET query binding and POST body binding (http/router.rs), OpenAPI 3.1 withoperationId= canonical id andx-majordomus-*extensions (http/openapi.rs), a Swagger UI shell that loads/openapi.jsonand embeds nothing (http/swagger.rs), thecapabilitiescommands dispatching through the registry's CLI exposure,docs/generated/openapi.jsonanddocs/generated/capabilities.mdfrom one generator, and the shell tool'sshare/allow/*.txtderived from the schemas.majordomus generate --checkcompares every committed projection with the registry and exits 10 when one is stale. - HTTP is loopback and read-only.
servebinds 127.0.0.1 unless told otherwise, has no authentication because it exposes nothing that is not already on disk to the same user, and lives as long as its stdin when that is a pipe.
Invariants
- Canonical ids are unique across both sources.
- No projection carries an entry the registry does not hold, and none lacks an entry the
registry declares for it (
tests/projections.rs). - Every schema a projection shows originates in the canonical schema of the descriptor.
- Generated output is deterministic: sorted maps, no timestamps, no absolute paths.
- Committed projections are reproducible and their staleness is detected.
- A projection cannot redefine a capability's semantics: MCP and HTTP call the same
handler (
tests/http_serve.rs, cross-protocol parity). - A planned capability may be listed but is never executable through any projection.
Alternatives rejected
- OpenAPI as the source of truth. It cannot express MCP resources, CLI paths or the declarative layer, and it would make a documentation format the owner of behaviour.
- MCP as the source of truth. Same objection, from the other side; and MCP has no notion of a route.
- Hand-maintained parallel tables for tools, routes and docs: the drift the operator named as the problem.
- Annotation-driven OpenAPI (
utoipa-style attributes on handlers) duplicating path, description and operation id beside the descriptor; and a second HTTP stack (axumwith an async runtime) for six loopback routes, where a synchronoustiny_httploop suffices. - A runtime-only registry with no committed projection: reviewable diffs of the interface would not exist.
- Procedural macros or link-time registration (
inventory,linkme): composition would stop being readable in one file. - Regex allow-lists as the contract for declarative kinds: not typed, not standard, and not extendable by a repository without teaching the code a new list.
Consequences
The registry, the id grammar, the URI scheme, the tool names, the route paths, the diagnostic codes and the two schema files become compatibility surfaces. Two executables now read the layer and the shell tool's allow-lists are generated from the schemas, so the schemas are the contract and the lists are a projection; the shell tool is not changed. The repository's "Intentionally Absent" list keeps refusing daemons and registries of workers; this registry indexes the tool's own surface and nothing else.
Testing strategy
Black-box first: the compiled binary is spawned for the CLI, MCP over pipes, HTTP over a
loopback socket, and generate --check; the registry's invariants and the propagation of a
change in one descriptor to every projection are library tests over fixture registries;
test/cases/76_capabilities_projections.sh reads one capability back through every
interface from a repository the shell tool's init wrote. CI runs cargo fmt --check,
clippy -D warnings, cargo test with doctests, cargo doc with warnings denied,
generate --check, capabilities validate and a line-coverage threshold.