Skip to content

ADR-0060: MCP as Agent Authoring Surface

  • Status: Accepted (2026-04-19)
  • Date: 2026-04-18
  • Supersedes:
  • Superseded by:
  • Extends: ADR-0008 (MCP primary, HTTP secondary)
  • Depends on: ADR-0005 (Rich capability manifest), ADR-0008 (MCP primary transport), ADR-0046 (MCP KG resources), ADR-0055 (Meta-capability surface), ADR-0056 (ORM validation ergonomics), ADR-0057 (Versioned migrations), ADR-0059 (Store-agnostic validation)

Context

Today the MCP server is an invocation surface: MCP projects every @capability to tools/* and exposes five built-in resources (trails://schema, trails://kg/types, trails://stats, trails://doctor, trails://prov/latest) plus two dynamic URI patterns (trails://kg/{Type} and trails://kg/{Type}/{id}) with subscriptions on the live ones. See docs/mcp-surface.md:46-64 for the resource catalogue and docs/mcp-surface.md:170-182 for the JSON-RPC method map. The surface works; agents can call capabilities and read a handful of introspection URIs.

What the surface is not yet is a place where an agent can author and operate a Trails application end-to-end. Concretely:

  1. Framework-authoring operations are invisible to MCP. ADR-0055 introduced @meta_capability for scaffolders, schema ops, and migrations, and landed trails.meta.ping (commit 3b28512, Phase 1). The trails_meta_<name> projector is wired at python/src/trails/mcp_server.py:193-239 (list_tools extends with trails.meta.mcp_tool_schemas(); trails_meta_* names route through trails.meta.mcp_invoke). Catalogue size: one.
  2. KG instances are a coarse read-only view. trails://kg/types lists node types with counts and trails://kg/{Type}/{id} fetches one instance (python/src/trails/mcp_resources.py:643-668), but there is no typed list endpoint, no filter grammar, no explicit application/ld+json MIME, and no documented subscription cost. An agent that wants "all Requirement where status = proposed" must fall through to a handwritten capability — exactly the per-project glue the framework should absorb.
  3. The tool inputSchema is runtime-derived and silently lossy. capability_to_tool_schema at python/src/trails/mcp_server.py:99-154 maps str|int|float|bool directly and falls every other hint to "string" (mcp_server.py:144). SHACL-grade constraints declared via predicate(..., pattern=..., one_of=..., min_length=...) in python/src/trails/shapes.py:77-175 never reach the MCP schema. No snapshot test exists (docs/mcp-surface.md:278-296), so a type hint change silently alters the agent contract.
  4. Validation failures become opaque tool errors. ADR-0056 shipped FieldError(field, code, message, value, constraint), but MCP tools/call wraps any handler exception in {"isError": true, "content": [...]} at python/src/trails/mcp_server.py:200-212. A ValidationError carrying three FieldErrors flattens to a stringified message.
  5. No authentication, no scope. MCP stdio has no auth; SSE inherits whatever HTTP provides. ADR-0055's scope model (runtime / builder / dev) is enforced at dispatch, but nothing on the MCP boundary reads a bearer token or rejects a builder-scoped call from an unauthenticated client.

The feature-inventory claim that MCP is "more polished than HTTP" (docs/feature-inventory.md:113-126) is half-right: the JSON-RPC core, subscriptions, and list-changed notifications are clean (docs/mcp-surface.md:140-163); the schema contract, validation plumbing, and auth story are not (docs/mcp-surface.md:329-344).

This ADR's job is to decide what agent authoring surface means on top of the primitives ADR-0055 / ADR-0056 / ADR-0057 / ADR-0059 give us, specifically for MCP. It does not re-decide ADR-0008 (MCP-primary) nor ADR-0055's scope model; it commits to a concrete projection.

Decision

Treat the Trails MCP server as an authoring surface, not just an invocation surface. The agent must be able to (a) discover every registered capability AND every meta-capability that their scope permits, (b) read, filter, and subscribe to typed KG instances, and © receive structured, actionable errors when validation fails — all without leaving MCP.

Three bundles:

  1. Bundle 1 — Auto-project every @meta_capability to MCP, with scope gating at the MCP boundary and a discriminator on the tool descriptor so clients can separate meta from runtime.
  2. Bundle 2 — Every @node_type class becomes a typed KG resource namespace with a bounded filter grammar.
  3. Bundle 3 — Derive tool inputSchema from @shape / predicate() constraints, surface FieldErrors as structured JSON-RPC error data, and lock the schema contract behind golden-file snapshot tests.

Bundles are independent at the implementation level (Bundles 2 and 3 are unblocked today; Bundle 1 depends on ADR-0055 Phase 2) but they share one contract: nothing on the MCP side extends the MCP specification. We project, we do not invent wire format.


Bundle 1 — Meta-capability auto-projection to MCP

Every @meta_capability registers a matching MCP tool. Phase 2 of ADR-0055 is the enabling work; this ADR locks the MCP-specific concerns so the MCP projector does not re-decide them per op.

Tool naming

  • MCP tool name: trails_meta_<namespace>_<name>. Single-segment namespaces (trails.meta.ping) project as trails_meta_ping; nested ones (trails.meta.gen.shape) project as trails_meta_gen_shape. The convention is already live (python/src/trails/meta/_projector.py:18-21; mcp_tool_name in the same module is the single source of truth).
  • Dots in the canonical id become underscores in the MCP name; underscores in an op name are forbidden to keep the mapping reversible. The projector raises TrailsError at decoration time on _ in an op name. (Phase 2 lint.)

Tool descriptor shape

Each tool descriptor in the tools/list response carries a Trails-reserved extension field:

{
  "name": "trails_meta_gen_shape",
  "description": "Scaffold a @shape skeleton; returns a FileDiff.",
  "inputSchema": { ... },
  "trails": {
    "kind": "meta",
    "scope": "dev",
    "namespace": "gen",
    "op": "shape",
    "idempotent": false
  }
}

Runtime capabilities get:

{
  "name": "research.search",
  "description": "...",
  "inputSchema": { ... },
  "trails": {
    "kind": "runtime"
  }
}

The namespaced trails key is the discriminator. It is not a new MCP spec field; the MCP spec permits additional properties on tool objects, and the trails.* prefix keeps the extension self-identified. (Open question: whether MCP has since adopted a standard _meta field — see §Open questions.) Clients that do not know the extension see a normal tool; clients that do get filtering.

Scope gating and auth on the MCP boundary

The server process starts with a scope set (the union of scopes it will surface):

  • stdio: env TRAILS_MCP_SCOPES (comma list; default runtime) plus env TRAILS_MCP_TOKEN for a single bearer token (optional; if present, the client passes it in initialize.clientInfo.authorization.bearer). Mismatched token → JSON-RPC -32001 Authentication failed on initialize. stdio is single-client by construction (python/src/trails/mcp_server.py:179-186).
  • SSE: same env plus Authorization: Bearer … on GET /sse and POST /messages; per-connection narrowing via token claims (scopes: [...]). Validation reuses the HTTP auth middleware (not yet wired for SSE — see docs/mcp-surface.md:223-233).

tools/list filters by the effective scope set (process default intersected with connection claims). tools/call re-checks scope immediately before dispatch — the list filter is advisory, the call-site check is authoritative. Mirrors @meta_capability's "projector rechecks scope" rule (ADR-0055 §"Security — the error surface when scope check fails"). The broader DID/VC identity story (ADR-0011 / ADR-0030) is orthogonal; this ADR only requires the MCP boundary to have a place to put a token that the scope gate can read.

Scope-denied error shape

When scope gating rejects a tools/call invocation, the response is a JSON-RPC error (not a tool error):

{
  "jsonrpc": "2.0",
  "id": 42,
  "error": {
    "code": -32004,
    "message": "scope denied: trails_meta_gen_shape requires dev",
    "data": {
      "tool": "trails_meta_gen_shape",
      "required_scope": "dev",
      "caller_scopes": ["runtime"]
    }
  }
}

Code -32004 is chosen from the application-error range (-32000..-32099); it mirrors ADR-0055's cross-surface error contract. JSON-RPC errors — rather than {"isError": true, ...} tool errors — are used because the op did not run. This matches ADR-0055 §"Security — the error surface when scope check fails".

Argument schema derivation + tools/listChanged

Meta schemas derive from the handler signature via the same path as runtime capabilities (Bundle 3 below): mcp_tool_schemas() feeds its descriptors through capability_to_tool_schema (generalised to read MetaDescriptor alongside capability handler metadata). When a plugin (ADR-0049) registers a new @meta_capability at runtime, the server emits notifications/tools/list_changed — the primitive exists for resources (python/src/trails/mcp_resources.py:148-159) and prompts; extending it to tools is one callback each on _handlers mutation and on the meta registry.


Bundle 2 — @node_type instances as MCP resources

Every registered @node_type class becomes a typed resource namespace. The three URI patterns:

URI Returns MIME Subscriptions
trails://kg/<Type> Paginated list of {iri, label} objects application/json Watch: any create / update / delete of any instance of the type
trails://kg/<Type>/<id> Full property dict for a single instance (JSON-LD-shaped) application/ld+json Watch: create / update / delete of that one instance
trails://kg/<Type>?where=<field>=<value> Paginated filtered list (equality on one scalar field) application/json Not subscribable in v0.2.0

The first two already exist (python/src/trails/mcp_resources.py:643-668) but are undocumented in the MCP spec sense — they have no entry in resources/list, so agents have to guess URIs. This bundle commits to:

  • Listing: resources/list returns one umbrella resource per registered @node_type at trails://kg/<Type> with description ("List all Requirement instances") and uriTemplate trails://kg/<Type>{?where,limit,offset}. Per-instance URIs are not enumerated (an org with 10k instances does not want a 10k-entry resources/list); the existing dynamic resolver handles them.
  • List body: array of {iri, label} objects. Paginated via ?limit= / ?offset= (defaults limit=100, hard cap limit=1000). This is a narrowing of the current 100-hardcoded limit at mcp_resources.py:643-668.
  • Single-instance body: JSON-LD with @id, @type (the node-type IRI), and one property per declared field. Property names are the registered predicate IRIs. Makes the application/ld+json MIME correct by construction.

Filter grammar for ?where=

v0.2.0 ships the minimum-viable subset — enough for the 80% case, small enough to reject ambiguity. Exactly one filter clause per URI, exactly one operator (=), value is either a scalar literal or a bareword mapped to an xsd datatype per the node-type's registered PredicateInfo:

trails://kg/Requirement?where=status=proposed
trails://kg/Requirement?where=priority=5
trails://kg/Requirement?where=status=proposed&limit=50

Explicitly not supported in v0.2.0:

  • Comparison operators (<, >, <=, >=, !=).
  • Conjunction / disjunction (AND, OR).
  • Nested field paths (owner.email=...).
  • Free-text search.

Each of those is a deliberate deferral. The grammar is not "ORM's where() semantics shipped via URL"; it is "equality on one scalar field, because that's what an agent needs to scope a read without learning SPARQL." When an agent needs more, it calls a capability.

SPARQL injection — parameterised binding

Filter values are never interpolated. The grammar produces a parameterised query using predicate_datatype_map[field] (derived from PredicateInfo at python/src/trails/shapes.py:165-175) to pick the literal datatype, then binds via the existing typed-param path (sparql() / sparql_update() at python/src/trails/context.py:350-421, commit f6ca4c8). A value that cannot be parsed under the field's datatype returns -32602 Invalid params with data = {"field": ..., "reason": "type_mismatch", "expected_datatype": "xsd:integer"}. Characters that would terminate a string literal (", backslash, newline) are rejected at URL parse time too — belt-and-braces on top of the binder.

Read-only, subscriptions, PROV cross-reference

The MCP resource surface is read-only for KG data. There is no resources/write. Every write goes through tools/call on a @capability (or a builder-scoped meta-capability), which writes through ValidatedStore (ADR-0059 §"Surface contract"). The invariant "violations cannot land" holds end-to-end; MCP has no bypass of the validation gate.

trails://kg/<Type>/<id> already participates in the per-URI subscription pipeline (python/src/trails/mcp_resources.py:177-181 and :205-263). This bundle makes trails://kg/<Type> (umbrella) subscribable too: any mutation to an instance of <Type> emits notifications/resources/updated for the umbrella URI. Filter-URIs are not subscribable in v0.2.0 (see Open questions). The umbrella description carries an advisory subscription-cost note: "Watching the full type fires one notification per instance mutation. For types with >1000 instances, subscribe to a specific <id> where possible." Documentation gate, not runtime cap — a cap would be an ADR-0012 envelope decision.

PROV activities are not projected as per-Type resources. The existing trails://prov/latest remains the canonical entry point. A future ADR may add trails://prov?type=MetaActivity&since=... once the PROV read ergonomics mature; out of scope here.


Bundle 3 — Schema-aware tools + FieldError-shaped errors + stability tests

Schema derivation from predicate() constraints

The runtime-derived fallback path in python/src/trails/mcp_server.py:99-154 gets replaced with a richer mapper. The input schema for a capability with a @shape-typed or @node_type-typed parameter reflects every SHACL constraint for which a JSON Schema keyword exists:

predicate() arg JSON Schema keyword Notes
required=True / min_count=1 enters the required array
min_length=N minLength (string only)
max_length=N maxLength (string only)
pattern=r"..." pattern (SHACL uses XSD regex, JSON Schema uses ECMA; Trails validates with Python's re, so re.compile-able is authoritative)
one_of=[...] enum (string + numeric literals)
min_value=N minimum (integer / number)
max_value=N maximum (integer / number)
many=True / max_count=None type: "array" with scalar items

Documented gaps:

  • sh:hasValue — deferred; no const emission in v0.2.0.
  • sh:class on reference fields — target is {"type": "string", "format": "iri"} plus a trails:node_type: "<TypeName>" annotation.
  • Compound uniqueness (M11 Phase 2) — not expressible in JSON Schema; fields get a top-level description annotation listing them. Compound checks still run server-side.

FieldError on the wire

When a capability raises ValidationError carrying one or more FieldErrors (ADR-0056 §"FieldError shape"), MCP tools/call returns a JSON-RPC error rather than a tool error:

{
  "jsonrpc": "2.0",
  "id": 17,
  "error": {
    "code": -32602,
    "message": "validation failed on 2 field(s)",
    "data": {
      "fields": [
        {
          "field": "status",
          "code": "one_of",
          "message": "status must be one of ['proposed','accepted','rejected']",
          "value": "draft",
          "constraint": ["proposed","accepted","rejected"]
        },
        {
          "field": "req_id",
          "code": "pattern",
          "message": "req_id must match ^REQ-\\d+$",
          "value": "R1",
          "constraint": "^REQ-\\d+$"
        }
      ]
    }
  }
}

-32602 Invalid params is the canonical JSON-RPC code for "your args are wrong"; validation failures are the MCP-native form of "wrong args". data.fields is a list, not a dict — matching ADR-0056's flat-list choice (§Alt C there).

Other exception classes keep current behaviour: PermissionError from @policy-32002; BudgetError-32003; uncategorised handler exceptions → -32603 Internal error with the message and data.trace_id.

Golden-file schema snapshot tests

New test file: python/tests/test_mcp_schema_stability.py. For each fixture capability (initial set: meta.ping, research.search, one shaped capability with the full constraint matrix, one node-type-typed parameter), the test builds tools/list, extracts the descriptor, and snapshots name + description + inputSchema + trails.* extension to python/tests/fixtures/mcp_schemas/<capability_id>.json — one file per tool, byte-exact JSON with sorted keys. A schema change requires re-running with TRAILS_UPDATE_MCP_SNAPSHOTS=1 to rewrite the golden file — a deliberate action visible in git diff. Closes the gap at docs/mcp-surface.md:283-284. Matrix: at least one fixture per mapped JSON Schema keyword and one per trails.* extension key.


Non-goals

  • Not a reinvention of MCP. We implement the spec; we do not extend it. The trails.* tool-descriptor extension lives in the namespaced slot the spec permits for additional properties. If a later MCP revision standardises a field we already emit, we rename without breaking semantics.
  • Not multi-tenant. One server, one graph, one scope set. Hosting multiple Trails projects under one MCP process is a separate design problem (see ADR-0055 §Non-goals).
  • Not a replacement for HTTP. HTTP keeps /invoke, /admin, and POST ergonomics for humans, batch tools, and webhooks. Bundle 1's HTTP projector is owned by ADR-0055; this ADR does not re-decide the HTTP shape.
  • Not auto-generated UI. The @node_type-to-resource projection is read/filter/subscribe for agents. A human-facing auto-UI (Rails-scaffold-style) is a separate ADR at v0.3.0.
  • Not MCP Sampling / bidirectional calls. Trails stays a pure server; it does not call back into the client for LLM sampling.
  • Not SPARQL over MCP. The ?where= grammar is a deliberate minimum; agents that need SPARQL call ctx.kg.sparql(...) through a @capability.
  • Not retroactive. Resources reflect current-state data. Time-travel queries (ADR-0035 temporal KG, planned) get their own URI scheme.

Alternatives considered

Alt A — HTTP-first, MCP later

Ship the same authoring features on HTTP admin first and bring MCP to parity later. Rejected. MCP is the agent transport by design (ADR-0008); the authoring surface should live where the agent already lives. The two converge through ADR-0055's projector, not through an HTTP-primary redesign.

Alt B — Capability-only MCP

Keep MCP as tools/*-for-capabilities only; no meta projection, no @node_type-as-resource. Rejected — status quo. Boxes Trails into "function-call server" and leaves docs/mcp-surface.md:344's polish thesis unrealised.

Alt C — A bespoke wire format

Design a Trails-specific JSON-RPC (or gRPC, or custom) for the authoring surface. Rejected. The MCP ecosystem exists (Claude Desktop, Continue, MCP-Hub-style registries); swimming against the current is strictly worse. The trails.* descriptor extension is the only Trails-specific concession.

Alt D — REST-over-MCP for resources

Let the agent call a kg.list / kg.read / kg.filter capability instead of modelling KG instances as resources. Rejected — the status-quo trap. Every project ships its own glue; modelling @node_type instances as resources absorbs it once.

Alt E — Rich JSON Schema draft 2020-12

Emit $ref-linked schemas with allOf / oneOf / if/then/else for compound constraints. Rejected for v0.2.0: complexity is high and MCP client compatibility is narrow. Keep the schema linear and draft-07-shaped; richer constraints live server-side.


Rollout / phases

Phases are gated independently; Bundles 2 and 3 are unblocked today and can proceed in parallel, Bundle 1 waits on ADR-0055 Phase 2.

Phase What lands Depends on
Phase 1 (shipped) Meta-surface Phase 1 done (trails_meta_ping routed via python/src/trails/mcp_server.py:193-239). KG resources beyond the current 100-hard-limit list: not done. Schema stability tests: not done.
Phase 2 Bundle 2 (KG resources — typed umbrella + ?where= + JSON-LD MIME + umbrella subscriptions + pagination) and Bundle 3 (schema derivation from predicate() + FieldError JSON-RPC errors + golden-file snapshot tests). Parallel agent work; no hard dep between bundles. ADR-0056 (already landed), ADR-0059 Phase 1 (for the validation-error path).
Phase 3 Bundle 1 (every @meta_capability projected, generator port complete, tools/listChanged on dynamic registration). ADR-0055 Phase 2 (generator port + grant loader).
Phase 4 Auth + scope enforcement on the MCP boundary (bearer tokens on stdio and SSE, per-connection scope narrowing, scope-denial error code -32004 wired). ADR-0055 Phase 3 (grant and scope plumbing).

Each phase has one integration test in python/tests/test_mcp_*.py that exercises the new surface end-to-end against an in-memory store and a stdio transport, matching the existing test idiom (python/tests/test_mcp_server.py:100+).


Relationship to other ADRs

ADR Impact
ADR-0008 (MCP primary transport) Extends, does not replace. ADR-0008 made MCP the canonical agent transport; this ADR narrows what "agent transport" means to "agent authoring transport" — capabilities plus meta plus typed KG plus structured errors. The MCP-primary principle holds; this ADR fills in what "primary" projects.
ADR-0005 (Rich capability manifest) Honoured. The canonical capability descriptor is unchanged; the MCP projection reads more of it (the predicate() constraints that were already authoritative but unread). No new descriptor fields.
ADR-0046 (MCP KG resources) Subsumed for the live-instance case. ADR-0046 established the trails://kg/... namespace; this ADR commits to a stable filter grammar, pagination bounds, MIME correctness, and subscription-cost documentation on top of it. Where the two disagree, this ADR wins (v0.2.0+).
ADR-0055 (Meta-capability surface) Complementary. ADR-0055 owns the registry and the projector machinery; this ADR specifies the MCP-side concerns (tool name grammar, trails.* extension, scope-denial error mapping, auth gate). If ADR-0055's projector changes, the contract in this ADR is what the MCP projector must still satisfy.
ADR-0056 (ORM validation ergonomics) Reused. FieldError is the wire format for validation errors. No new validation plumbing — we surface what ADR-0056 already produces.
ADR-0057 (Versioned migrations) Downstream. Once trails.meta.migrate.* registrations exist, they auto-appear on MCP via Bundle 1 — no migration-specific MCP code. The "agent proposes a migration via MCP" story in ADR-0057 resolves through Bundle 1 plus the builder scope gate.
ADR-0059 (Store-agnostic validation) Upstream. Every write through an MCP tools/call funnels through ValidatedStore exactly like any other caller; MCP has no bypass. raw_sparql(skip_validation=True) is not projectable onto MCP in v0.2.0 (would require [validation].allow_bypass = true plus bearer-token bypass-grant, out of scope).
ADR-0009 (Provenance always on) Honoured. Every tools/call emits PROV as today; trails:MetaActivity is written for meta invocations per ADR-0055.
ADR-0049 (Plugin system) Complementary. Plugins that register @meta_capability at load time appear via tools/listChanged (Bundle 1 Phase 3).
ADR-0050 (Server environment modes) Honoured. dev-scoped tools are hidden from tools/list when config.project.env == "production", same gate as ADR-0055 §"Scope model".

Success criteria

  • tools/list returns every runtime capability and every @meta_capability the caller's scope set permits, with a machine-readable trails.kind discriminator.
  • tools/call trails_meta_gen_shape from an agent with dev scope produces the same FileDiff the CLI produces, byte-for-byte.
  • resources/read trails://kg/Requirement?where=status=proposed returns a paginated list matching Requirement.objects(ctx).where(status="proposed").
  • A capability that raises ValidationError([FieldError(...), FieldError(...)]) surfaces as JSON-RPC error -32602 with data.fields a structured list, not as a tool-level isError envelope.
  • python/tests/test_mcp_schema_stability.py guards every mapped constraint; changing a predicate() kwarg on a fixture capability fails the snapshot until the golden file is deliberately updated.
  • MCP has no validation bypass: a tools/call that tries to write a shape-violating triple fails with the same -32602 as the ORM save() would.
  • stdio MCP with no TRAILS_MCP_TOKEN configured still accepts unauthenticated runtime-scope calls (back-compat for the current developer inner loop), but any builder or dev scope tool is hidden from tools/list and rejected at tools/call with -32001.

Open questions

  1. Resource-URI grammar: ?where=field=value vs ?filter=field:value vs a separate read-with-body. v0.2.0 picks ?where= (reads like mini-SQL, matches the ORM method name). An MCP client survey may reveal a more conformant convention. Owner call before Phase 2.
  2. SPARQL injection beyond parameterised binding. Proposal: scalar-equality-only, parameterised via predicate_datatype_map, plus URL-layer rejection of literal-terminator characters. Is the double-defence excessive? Tune after security review.
  3. Tool-kind discriminator: trails.* or standardised _meta? The MCP revision Trails targets is 2024-11-05 (python/src/trails/mcp_server.py:94-96). If _meta is standardised since, migrate in Phase 2 and alias trails.* for one cycle.
  4. subscribe cost on trails://kg/<Type>. 10k instances × one notification per mutation is a worst case. v0.2.0 documents a recommended max subscriber count per type; should we also enforce a hard cap (ADR-0012-envelope-shaped) on umbrella subscriptions per session? Punted to owner.
  5. Schema snapshot file location. python/tests/fixtures/mcp_schemas/<capability_id>.json (one file per tool) vs inline (bad — diffs hard to read) vs single schemas.json (bad — merge-conflicty). Owner confirm.
  6. trails.kind on a capability that is both @capability and @meta_capability. ADR-0055's two-registry model forbids this by construction. If a plugin registers both, which kind wins? Proposal: explicit error at plugin load — track in ADR-0049.
  7. Back-pressure on notifications/resources/updated. The per-session asyncio.Queue in mcp_transport.py is unbounded. Umbrella subscriptions make this worse in the worst case. A separate ADR may decide a bounded-queue / drop-oldest policy.

Acceptance gate

This ADR is Proposed until:

  1. The open questions on grammar (§1) and discriminator (§3) receive owner sign-off.
  2. A Phase 2 prototype lands with: (a) the trails://kg/<Type> umbrella resource for one @node_type, (b) a working ?where=field=value path, © the schema-derivation mapper with at least three predicate() kwargs lifted to JSON Schema keywords, and (d) one golden-file snapshot test.

Once the prototype passes its integration test in python/tests/test_mcp_*.py, this ADR flips to Accepted and Phase 3 (Bundle 1) becomes the next gate.