ADR-0015a: MCP / WoT projection rule (amendment to ADR-0015)¶
- Status: Accepted (2026-04-19)
- Amends: ADR-0015 (WoT AgentCard alignment)
- Depends on: ADR-0005 (rich capability manifest; MCP as projection), ADR-0008 (MCP primary, HTTP secondary)
- Related: ADR-0016 (WoT discovery endpoint)
- Supersedes: —
- Superseded by: —
Context¶
ADR-0015 introduces AgentCard as a WoT-aligned serialization of Trails
capabilities, with td:actions entries mirroring the bespoke
CapabilityDescriptor. Its Open questions §MCP-coexistence asks which
surface wins when MCP tools/list and td:actions disagree on the same
capability; ADR-0016 inherits the same gap, since its
/.well-known/wot enumeration assumes a single coherent registry state.
ADR-0005 already declared the general pattern: the rich JSON-LD capability manifest is canonical; MCP is a lossy projection. That decision predates AgentCard. With ADR-0015 accepted, there are now two projections derived from the same underlying source (MCP tool schema and WoT/AgentCard TD), and ADR-0016 foreshadows a third peer surface. Without an explicit coherence rule, three classes of drift are structurally possible:
- A field present in both projections (e.g.,
description) ends up with different values because the projections are generated at different times from different cached states. - A projection is updated in isolation (e.g., a dev hand-edits the MCP tool description for a demo) and begins to claim authority over a field the canonical descriptor also owns.
- A capability is registered or updated mid-request; a client sees one projection reflecting the new state and the other reflecting the old one.
This is not a one-off: if Trails later adds an OpenAPI projection (already anticipated by ADR-0005), the same question recurs with one more dimension. The rule has to be structural, not per-projection-pair.
Decision¶
The canonical CapabilityDescriptor stored in the Rust trails-caps
registry is the single source of truth. MCP tools/list entries and
WoT td:actions entries are lowered projections generated from that
descriptor. When projections disagree about a field the descriptor
defines, the descriptor wins and the divergent projection is a bug.
More precisely:
- Canonical form is named. The
CapabilityDescriptorstruct (rust/crates/trails-caps/src/lib.rsline 77, 14 fields per ADR-0005 Update 2026-04-12) is the sole authoritative representation. Neither the AgentCard JSON-LD nor the MCPTooldict is authoritative about any field the descriptor also defines. This holds even though MCP is the primary transport (ADR-0008): primacy of transport does not imply primacy of representation. - Projections are derived, not stored. Each projection is computed
from the descriptor on demand, or cached keyed by
(descriptor.id, descriptor.version). A cache entry is valid only while its(id, version)still names the current descriptor in the registry. Any registry write that produces a new descriptor version invalidates all projection caches for that capability atomically. - Asymmetric fields are permitted and bounded. A projection MAY expose fields the other projection lacks, provided those fields are derived from the canonical descriptor or from Trails-wide configuration, not invented at projection time:
- MCP-only fields (e.g., MCP
annotationscaching hints) live in the MCP projection only. They MUST NOT leak into the AgentCard. - WoT-only fields (
forms[]transport bindings,securityDefinitions) are generated from the descriptor's transport metadata plus the Trails security configuration (biscuit/DID per ADR-0010, ADR-0011). They MUST NOT leak into the MCP projection. - Trails-specific fields (
trails:assurance,trails:costEstimate,trails:policyRequired, ACT/ECT) ride in the AgentCard because WoT tolerates namespaced extensions; they are projected away from MCP exactly as ADR-0005 already mandates. - Shared fields MUST match byte-for-byte after canonicalisation.
Where both projections carry the same logical field (
id↔td:name,description↔dct:description,idempotent↔td:idempotent,input_shape/output_shape↔td:input/td:output), the values MUST be equal once the projection-specific wrapping is stripped. Divergence is a bug — detectable bytrails doctor(see Future work) — not a negotiation. - Consistency across a single request. A request that reads more
than one projection (e.g., a mixed client that calls both
tools/listandGET /{agent}/card) MUST observe both projections as lowerings of the same descriptor version. The registry provides a snapshot read; projections within a request are pinned to that snapshot. A registration landing mid-request may be observed by the next request but will not split a single request across descriptor versions.
Stated as a one-liner: descriptor is truth; projections are views; views never outvote the descriptor and never disagree with each other about a shared field.
Implementation sketch¶
No code lands in this ADR. The intended call graph, once Phase 1 of ADR-0015 begins, is:
Client → HTTP/MCP entry point
→ Python surface (`trails/mcp_server.py`, `trails/http_adapter.py`)
→ trails_caps_py (PyO3 binding)
→ Rust trails-caps::Registry::get_descriptor(id)
└─ returns &CapabilityDescriptor (canonical; rust/crates/trails-caps/src/lib.rs:77)
↙ ↘
project_to_mcp_tool project_to_agent_card
(python/src/trails/ (python/src/trails/
mcp_server.py:: agentcard.py — new,
capability_to_tool_schema) per ADR-0015 Phase 1)
Key points wired to clauses above:
- Clause 1 (canonical named).
capability_to_tool_schemainmcp_server.py(line 67) and the forthcomingagentcard.pyemitter both take aCapabilityDescriptoras input and return a projection dict. Neither module holds mutable per-projection state. - Clause 2 (derived, cache gated by version). Both projection
emitters are pure functions of the descriptor; if memoised, the cache
key is
(descriptor.id, descriptor.version). The registry is responsible for emitting an invalidation signal on write so the cache is evicted before the new descriptor becomes observable (required to uphold Clause 5). - Clause 3 (asymmetric fields). MCP projection reads only the MCP
subset (
id,description,input_shape); WoT projection reads the full 14-field descriptor plus Trails security config. There is no back-channel between the two projection functions. - Clause 4 (shared-field equality). Any field present in both
projections is sourced from the same descriptor field via the mapping
table in ADR-0015 §Mapping. A new
trails doctorcheck (see Future work) round-trips each registered descriptor through both emitters and asserts shared-field equality. - Clause 5 (request-level snapshot). Requests spanning both
surfaces acquire a single
Registry::snapshot()read and pass the same reference down both projection paths. M0's single-threaded PyO3 surface makes this free today; M1's adapter work MUST preserve the property when async enters the picture.
Consequences¶
Positive¶
- Interop predictability. A semantic-aware client that negotiates across MCP and WoT sees a coherent capability, not a contradictory one. Clients do not need a projection-preference heuristic.
- Structural rule, not per-pair. The same rule covers any future projection (OpenAPI, A2A, ACP) without re-litigation.
- Bug detection surface. Shared-field divergence becomes a
mechanical
trails doctorcheck rather than a support ticket. - Aligns with ADR-0005 and ADR-0008. Keeps MCP as primary transport while clearly separating transport primacy from representational primacy — a distinction ADR-0008 already implies but did not state.
Negative¶
- Both projections regenerate together. There is no partial-update path where MCP gets a new description without WoT also reflecting it. Acceptable: partial updates were the source of the drift the rule is eliminating.
- Projection caches are coupled to descriptor version. A registry
write invalidates MCP and AgentCard caches even when the change is
irrelevant to one projection (e.g.,
assurancebump changes only the WoT view). Mitigation: cache key granularity per-projection remains(id, version); invalidation is cheap. - Hand-editing a projection is now explicitly a bug. Some demo/debugging workflows currently rely on tweaking a projection directly. Those must move to descriptor edits followed by regeneration.
Neutral¶
- No change to the 14-field canonical field set (ADR-0005 Update 2026-04-12).
- No change to MCP
tools/listwire shape. - No change to the AgentCard JSON-LD shape sketched in ADR-0015.
- No new runtime dependencies.
Alternatives considered¶
- MCP is canonical; AgentCard derives from MCP. Rejected.
MCP's type system is strictly weaker than
CapabilityDescriptor(no cost, assurance, policy, preconditions, side-effect graph writes, ACT/ECT). Making MCP canonical would silently erase the fields ADR-0013 and ADR-0005 exist to ship; ADR-0005 already decided against this framing. - AgentCard is canonical; MCP derives from AgentCard. Rejected.
Creates a cycle with ADR-0008 ("MCP primary") and with ADR-0015
itself, which states explicitly that AgentCard is a projection and
the Rust
CapabilityDescriptoris unchanged (§Decision bullet 4). Elevating a projection to source of truth contradicts its own defining ADR. - Each projection is standalone; divergence is permitted and negotiated per client. Rejected. Interop silently breaks: different clients see different realities of the same capability, and there is no non-arbitrary rule for which wins. This is the failure mode the amendment exists to rule out.
- Canonical = the union of both projections; descriptor is just storage. Rejected. "Union of views" has no well-defined behaviour when views disagree; it also forces a projection-shaped schema on the Rust struct, which is supposed to be the unprojected form.
Future work¶
trails doctorprojection-consistency check. Iterate the registry; for eachCapabilityDescriptor, run both projection emitters and assert the shared-field-equality contract from Clause- Flag divergences with capability id + field name. Acceptance gate for ADR-0015 Phase 1.
- OpenAPI projection. When the OpenAPI projection anticipated by
ADR-0005 lands, extend the
doctorcheck to triangulate. The rule here generalises unchanged: OpenAPI is a third projection, not a third source of truth. - Cache-invalidation signal shape. The Rust registry currently has no cross-process cache-invalidation primitive. If MCP and AgentCard emitters move to separate processes (M2+), Clause 5 needs a concrete invalidation bus; deferred to that milestone's ADR.
- Hand-edit detection. A stretch goal:
trails doctorcould checksum projection outputs at dev time and warn when a served projection doesn't round-trip through the descriptor — catches the "dev tweaked the MCP description" anti-pattern Clause 2 rules out.