Skip to content

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:

  1. 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.
  2. 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.
  3. 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:

  1. Canonical form is named. The CapabilityDescriptor struct (rust/crates/trails-caps/src/lib.rs line 77, 14 fields per ADR-0005 Update 2026-04-12) is the sole authoritative representation. Neither the AgentCard JSON-LD nor the MCP Tool dict 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.
  2. 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.
  3. 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:
  4. MCP-only fields (e.g., MCP annotations caching hints) live in the MCP projection only. They MUST NOT leak into the AgentCard.
  5. 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.
  6. 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.
  7. Shared fields MUST match byte-for-byte after canonicalisation. Where both projections carry the same logical field (idtd:name, descriptiondct:description, idempotenttd:idempotent, input_shape / output_shapetd:input / td:output), the values MUST be equal once the projection-specific wrapping is stripped. Divergence is a bug — detectable by trails doctor (see Future work) — not a negotiation.
  8. Consistency across a single request. A request that reads more than one projection (e.g., a mixed client that calls both tools/list and GET /{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_schema in mcp_server.py (line 67) and the forthcoming agentcard.py emitter both take a CapabilityDescriptor as 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 doctor check (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 doctor check 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., assurance bump 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/list wire shape.
  • No change to the AgentCard JSON-LD shape sketched in ADR-0015.
  • No new runtime dependencies.

Alternatives considered

  1. 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.
  2. 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 CapabilityDescriptor is unchanged (§Decision bullet 4). Elevating a projection to source of truth contradicts its own defining ADR.
  3. 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.
  4. 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 doctor projection-consistency check. Iterate the registry; for each CapabilityDescriptor, 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 doctor check 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 doctor could 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.