Skip to content

RFC: capsule-served resource interface (MCP resources)#32

Open
joshuajbouw wants to merge 7 commits into
mainfrom
rfc/resource-interface
Open

RFC: capsule-served resource interface (MCP resources)#32
joshuajbouw wants to merge 7 commits into
mainfrom
rfc/resource-interface

Conversation

@joshuajbouw

@joshuajbouw joshuajbouw commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Defines astrid-bus:resource@1.0.0 — a capsule-served bus contract exposing read-only, addressable resources to MCP clients (via astrid mcp serve) as MCP resources. Structural parallel of the tool interface.

Hardened against a scenario walk + the pinned rmcp 1.7.0 source, which caught that the first draft was specified against mechanisms the live broker doesn't implement:

  • describe is a 1→N scatter-gather keyed on source-id (the live tool.v1 fan-out: empty broadcast request, per-source response topics, wildcard drain). The earlier correlation-id-on-describe was vestigial tool.wit surface; dropped. correlation-id stays only on read.
  • read is broadcast-with-self-filter (no source-id-targeted publish exists), non-owners stay silent; fails closed as a JSON-RPC error (rmcp ReadResourceResult has no isError — verified).
  • Per-principal isolation is enforced, not assumed: the bus self-scope only covers the audit topic, so the broker MUST filter drained responses to the invoking principal, the describe cache + uri→source-id map MUST be keyed per-principal (the global tools.cache is not a safe template), and the serving capsule MUST take the principal from caller()/Verified, never the body.
  • Records carry the full MCP 2025-11-25 Resource shape (title/size/annotations/icons/_meta, multi-content variant{text,blob} array) — verified field-by-field against rmcp 1.7.0 (size: u32, Annotations, default read_resource=method_not_found → MUST override).
  • Pagination: single-page in 1.0 (cardinality is tiny; real windowing needs immutable snapshot retention the cache can't express, so it's not a broker-only flip — deferred). No cursor reaches a capsule.
  • Shapes implicit (serve a URI family without enumerating; flat URIs, no ..); URI squatting = v1 operator-trust + deterministic source-id tie-break, with a signed-registry URI authority for the untrusted ecosystem written into Future Possibilities.
  • New Limits (DoS caps) and Conformance tests sections — including compile-time rmcp-shape guards so a future rmcp bump breaks a test instead of drifting silently.

Number unassigned per the RFC process (0000-resource-interface.md).

Define astrid-bus:resource@1.0.0 — a fetchable, interceptor-style contract (describe fan-out + per-URI read) parallel to the tool interface, bridging capsule-served resources to MCP clients via astrid mcp serve. Pull-only, per-principal scoped, no secrets; sampling/logging (push) out of scope, MCP prompts deferred. Number left unassigned per the RFC process.
Audited the draft against the latest MCP schema (2025-11-25) and rmcp 1.7.0. Fixed three breaking-if-deferred defects, since WIT records are not extensible:

- read contents: scalar string -> list<resource-contents> where resource-contents is a variant { text(resource-text), blob(resource-blob) }. MCP returns an ARRAY of discriminated text/blob items (a URI may expand into sub-resources); text-vs-blob is a structural discriminant, not a mime-type heuristic.
- resource-definition: add the full Resource field set — title, size, annotations, icons, and a meta (_meta JSON) escape hatch — alongside uri/name/description/mime-type. Adds annotations/resource-role/icon/icon-theme shared types.
- describe-request/response: add cursor/next-cursor. Pagination reconciles the N-capsule fan-out by having the broker aggregate, uri-sort, and mint one opaque client cursor; v1 ships single-page behind the paginated shape.

Also: error moved to its own read-response field (was an overloaded contents string); subscribe/updated EXCLUDE-by-construction (no server->client push) negotiated via subscribe:false; templates + completion DEFER-without-precluding (reserved separate topic, additive); added Pagination and 'Shim coverage (rmcp 1.7.0)' sections; per-feature decision table reconciling every MCP resource feature.
…sub bus

The Astrid bus is pub/sub (publish + subscribe are the only host primitives); request/response is a convention on top (request topic answered on a correlation-keyed response topic, ipc::request_response for the 1:1 case). Tightened the Summary/Motivation so 'request/response' is no longer overstated as 'not pub/sub' — read is a routed 1:1 exchange, describe is a 1→N scatter-gather the broker aggregates.
…ain is requester-side

describe is one request -> many responses (shared correlation-id, responders told apart by source-id). The drain/aggregate lives once on the requester side; serving capsules implement only their handler and never drain. Window-bounded => best-effort: the aggregate is whoever answered in time; a capsule that misses the window reappears next describe (eventual, not snapshot, consistency). read is the degenerate 1:1 case (ipc::request_response).
…per-principal enforcement, fail-closed reads

A scenario walk (verified against sage-mcp discovery.rs + pinned rmcp 1.7.0 source) found the records were specified against mechanisms the live broker does not implement. Fixes:

- describe: drop correlation-id (tool.wit's is vestigial; the live fan-out publishes empty {} and keys responses on the per-source topic). describe-request is now empty (principal rides the envelope); describe-response carries only the resource list; attribution is by kernel-stamped source-id. Deterministic source-id tie-break on uri collision.
- read: there is no source-id-targeted publish, so 'routed to owner' is unimplementable — read is now broadcast-with-self-filter (non-owners stay silent). correlation-id retained on read (genuine per-request).
- per-principal is NOT free: the bus self-scope only covers the audit topic, so the broker MUST filter drained describe-responses to the invoking principal, the describe cache + uri->source-id map MUST be keyed per principal (the global tools.cache is not a safe template), and the serving capsule MUST take the principal from caller()/Verified, never the body (capsule-untrusted-input).
- reads fail closed as JSON-RPC errors (rmcp ReadResourceResult has no isError; verified): unknown-uri -32002, denied/timeout -32603.
- shapes implicit: a capsule may serve a uri family without enumerating each; URIs are flat (no .. traversal); describe is not a forced allowlist.
- pagination single-page in 1.0 (cardinality tiny; windowing needs immutable snapshot retention the cache can't express; cursor removed from capsule-facing records — never reaches a capsule).
- size option<u64> -> option<u32> (rmcp RawResource.size). New Limits section (DoS caps). New Conformance tests section (rmcp-shape guards + per-principal/dedup/fail-closed invariants). Shim coverage updated to verified rmcp 1.7.0 facts (read_resource default method_not_found -> MUST override; annotations Annotated wrapper + DateTime parse; list_changed needs an independent resource diff).
- URI squatting: v1 operator-trust + deterministic tie-break; signed-registry URI authority written into Future possibilities for the untrusted ecosystem.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant