Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Decision records

ADR 0072 — MCP Apps (SEP-1865): ingress fidelity now, ui:// egress gated on an MCP server

  • Status: Accepted — implemented (2026-08-08). The D7 evidence gate was closed by explicit direction to build the server; Steps 0–2 shipped together with the effect-mode boundary (ADR 0073) that D5’s cursor tool depends on.
  • Date: 2026-08-08
  • Evaluates: MCP Apps placement evaluation (loom/docs/requirements/2026-08-08-mcp-apps-extension-evaluation.md)
  • Builds on: the A2UI egress adapter (ADR 0069), callFunction bidirectional RPC (ADR 0070), MCP dynamic dispatch (ADR 0063), the content-store seam (ADR 0046), typed failure channels (ADR 0060), plugin packaging & 1P/3P separation (ADR 0043), the deferred-evidence-gate discipline (ADR 0065).
  • Part of: the kernel/1P/3P triage discipline (P10 — bias outward). KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged — no new syscall, no new kernel event type, no fold change. Everything below is a wire binding, a read-model, or a content-store artifact.

Context

MCP gained an extension for interactive UI: MCP Apps, extension identifier io.modelcontextprotocol/ui, specification dated 2026-01-26. A server publishes a static HTML resource under the ui:// scheme (text/html;profile=mcp-app), links it from a tool via _meta.ui.resourceUri, and a host renders it in a sandboxed iframe bridged by JSON-RPC over postMessage. Tool arguments and results reach the view as host→view notifications (ui/notifications/tool-input, -partial, -result, -cancelled); the view calls back with tools/call, resources/read, ui/open-link, ui/message, ui/request-display-mode, ui/update-model-context.

It is an official extension, not core spec, and the 2026-07-28 revision then restructured the protocol underneath it — stateless operation, initialize and Mcp-Session-Id removed, Tasks demoted from core to extension. Churn is live.

Correction (2026-08-08, during implementation). This ADR and its evaluation both describe 2026-07-28 as a release candidate, which was true of the announcement post. The specification’s versioning page now lists 2026-07-28 as Current. Two refinements follow, neither of which changes a verdict: version negotiation is per-request (_meta["io.modelcontextprotocol/protocolVersion"] plus the MCP-Protocol-Version header on Streamable HTTP) rather than handshake-only, and the last handshake-based revision is 2025-11-25. D3b is implemented against that reality.

Four properties of the extension decide the design (full derivation in the evaluation §0):

  • P-A the template is static and pre-declared — pre-declaration is the security model;
  • P-B there is no server push after ui/notifications/tool-result;
  • P-C views are stateless and lifecycle-bound to a single tool call;
  • P-D it ships code, where A2UI deliberately ships none.

Decisions

D1 — Nothing enters the kernel

Every element maps onto machinery that already shipped:

ElementExisting mechanism
static ui:// template@loom/plugin-content — contentAddress, ContentStore, pinByEventKinds (ADR 0046/0059)
template pin + drift detectionthe ADR 0063 discipline — hash, pin at plan time, verify at serve time, fail typed via an ADR 0060 channel (templateDrift)
tool input / resultexisting STEP_STARTED / STEP_COMPLETED payloads
view→agent callsthe two grains of ADR 0070 — suspend/resume gate, or delegated effect with deterministic callId
dispatch/usage indexingdomain events the fold skips (the ADR 0063 D4 precedent)
surface patch cursorreadRangeOf / EventStore.readRange (packages/event-runtime), read-model shape of @loom/live-query

KERNEL_ABI stays 1.1.0; EVENT_SCHEMA unchanged. No MCP_APP_* event family — governed event types are for kernel semantics, not wire bindings.

D2 — Reject the host role in this repo

loom does not implement an MCP Apps host: sandboxed iframe construction, CSP synthesis from _meta.ui.csp, permissions policy, the recommended double-iframe sandbox proxy, theming, display-mode negotiation, container dimensions. That is a browser-application concern in which a mistake is an XSS or exfiltration hole, and loom-the-kernel has no browser. It belongs to the embedding application or plugins/renderer-react; packaged, it is 3P in ../loom-plugins (ADR 0043).

D3 — Fix MCP ingress fidelity now, unconditionally

Two defects in plugins/mcp/src/McpClient.ts block every path below and are worth fixing on their own merits:

  • D3a — callTool (lines 111–119) filters content to type === "text", joins, and JSON.parses it, discarding structuredContent, _meta, and all non-text content blocks. The declared response type is the corroborating evidence: line 106 types the RPC result as { content: Array<{ type: string; text?: string }> } — there is no slot for structuredContent or _meta to survive in. _meta.ui is the field that links a tool to its UI resource; structuredContent is what a host forwards to a view. As written, a UI-bearing result is flattened to a string before loom can observe that a UI exists. The defect is broader than UI: any structured or image content from any MCP server is already silently lossy.
  • D3b — connect() pins protocolVersion: "2024-11-05" (line 82) and threads mcp-session-id (lines 50, 65, 127), both removed by the stateless RC. Not broken today — the new Active → Deprecated → Removed policy guarantees a twelve-month window — but it is accrued drift with a known deadline.

Neither needs its own ADR. Ingress then gains UI-resource awareness: carry the resource URI and content hash through the log with provenance so an application can render. Carry, do not render.

D4 — If and when loom serves: one static template, not generated HTML

Rejected: per-flow HTML generation. It breaks replay determinism (the UI becomes code outside the log, and two replays of one execution may render differently with nothing detecting it), defeats the extension’s own pre-declaration security model (P-A), is not auditable, and turns loom into a browser codegen system — an adjacent form of what ADR 0044 already declined.

Accepted: one generic, content-pinned surface-host template. A single static HTML app serves as the ui:// resource; its only job is to render a declarative payload delivered as structuredContent and route user actions back as tools/call.

This neutralizes P-D rather than accommodating it: MCP Apps becomes a second transport binding for a surface protocol loom already ships, not a second UI protocol to maintain. No code is generated per app, so the “agent ships no code” property that made ADR 0069 clean is retained — the code is one fixed, versioned, reviewable build artifact.

The payload is the existing A2UI catalog model, unchanged: A2UICatalog + ComponentInstance (plugins/a2ui/src/catalog.ts, messages.ts) — an adjacency-list component tree from a named catalog (loom.core.v1), bound by JSON Pointer. The template is a catalog renderer. The rendered UI is therefore a pure function of (pinned template hash, logged structuredContent), both of which are in the log.

D5 — Close P-B with one app-visibility cursor tool

Because there is no server push, a long-running execution cannot stream surface patches into a rendered view. The only seam the spec offers is repeatable view-initiated tools/call plus _meta.ui.visibility: ["app"] (which the host must honour by excluding the tool from the model’s tool list).

Expose exactly one such tool — loom.surface.since(surfaceId, fromSeq) — returning SURFACE_PATCH events after a sequence number, over readRangeOf / EventStore.readRange. No new event type, no fold change, no syscall.

This is polling, and it is strictly worse than SSE. That is the extension’s limitation, not loom’s; it is contained behind one tool, and if a future revision adds server push the same cursor becomes the push source with no change to the payload contract. Recorded here rather than engineered around.

P-C requires no work — it is the favourable direction of the mismatch. loom’s execution is the durable party and the view is disposable, so a re-rendered view re-reads the cursor from 0 or from a snapshot. This is the late-joining-client handshake A2UISurfaceBridge.snapshot() already implements.

D6 — Security posture, fixed now rather than retrofitted

  • Egress redaction is mandatory, not defaulted. Surface state shipped as structuredContent crosses the trust boundary. A2UISurfaceBridge already models this correctly (K10-R6 maskPaths, with the internal fold keeping true values so increment/append/merge stay correct). A redact list that defaults to empty is a silent leak; it is required by configuration.
  • A view-initiated tools/call is an authorization event, not a continuation. The caller is the user’s UI, not the flow. It runs under a distinct principal with its own grants and never inherits the execution’s authority — @loom/capability (CapabilityInvokeAuthorizer), execution-authz (ADR 0039), and the K2 GrantTable / EFFECT_DENIED path already model exactly this.
  • ui/update-model-context is a prompt-injection surface — untrusted view content overwriting the agent’s context for future turns, with overwrite semantics. If loom ever ingests it, the content is tainted by classification (@loom/classify) and never merged into a system prompt. Cheap now, impossible to retrofit onto an immutable log — the argument ADR 0063 D3 made about principals.
  • visibility is enforced server-side. The spec obliges hosts to reject app-originated calls to tools lacking "app"; a serving loom enforces the mirror, or the D5 cursor becomes a bypass around the model’s tool boundary.
  • The view’s result is untrusted input — validated against its declared schema before acting on anything sensitive, exactly as ADR 0070 already records for functionResponse.

D7 — The server is the prerequisite, and it is the gate

loom has no MCP server. @loom/plugin-mcp is client-only: tools/list (McpClient.ts:89) and tools/call (McpClient.ts:107) outbound, with no inbound counterpart anywhere in the repo. loom speaks A2A as a server (plugins/a2a/src/A2AServer.ts) and nothing else.

So D4/D5/D6 are deferred behind an evidence gate: a concrete consumer, per the standing second-consumer discipline (ADR 0065; and the gate ADR 0069 set for callFunction, which ADR 0070 later satisfied rather than assumed). The extension is seven months old and the RC just restructured the protocol underneath it — promoting on speculation is what the deferral discipline exists to prevent.

When the gate is met, the work lands as a 1P plugin (ADR 0043: an adapter that exists because of a specific external system is a plugin), keeping the churn blast radius at the edge — the same bet ADR 0069 made on A2UI.

D8 — “3P” is where the host BELONGS, which is not the same as saying build one (2026-08-14)

D2 sends the host role to “the embedding application or plugins/renderer-react; packaged, it is 3P in ../loom-plugins.” That answers where it would go and quietly leaves whether to put anything there unanswered. Asked directly, the answer is not yet, and not in that shape.

It implements no seam. ../loom-plugins’ own contract is that a plugin “contributes a concrete implementation of an existing Loom seam” and contributes “only to existing seams”. An MCP Apps host is an iframe sandbox plus a postMessage JSON-RPC bridge for a protocol in which loom is not a participant — a view’s tools/call returns to the third-party server, not through loom. Packaged as loom-plugin-*, the “loom” would be branding rather than architecture, and it would be the second entry in that repo needing an asterisk about defineConfig (the first, loom-plugin-linucb, at least uses the policy seam).

The framing that DOES fit a seam is narrower and better: a widget renderer registered into RendererRegistry (@loom/blueprint-runtime), making a third-party UI a widget inside a loom blueprint. In that shape the plugin’s job is exactly the part only loom can do:

  • verify the pin before mounting — MCP_UI_OBSERVED now carries (resourceUri, contentHash) in the durable log, so a replay cannot silently render different markup than the original run;
  • label the frame with that provenance;
  • route a view-initiated tools/call back through mcpInvoke$ under a distinct principal, which is D6/S2’s requirement in enforceable form;
  • taint whatever the view sends back (ui/message, ui/update-model-context) per ADR 0085, so it cannot reach an instruction position. That option did not exist when this ADR was written.

Every item there is provenance and authority. None is browser security.

What such a plugin must NOT do is reimplement the sandbox — CSP synthesis from _meta.ui.csp, permissions policy, the double-iframe proxy. D2’s original reasoning stands, and a second argument decides it: a solo-maintained 3P package that lags a churning spec is worse than nothing, because it is installed by people who believe it sandboxes correctly. An unmaintained store adapter fails loudly; an unmaintained sandbox fails silently, in the attacker’s favour. Delegate that to a host library from the MCP UI ecosystem.

The trigger, and why not now. Direction B’s economics are unchanged (§1): the third-party server owns the UI and the interactions, so loom is largely a passthrough and the value is the pin plus the authority boundary. Start it when a real application needs to display a third-party MCP app — as app code first, because it is genuinely a React component — and promote it to a 3P package when a second application needs it. That is the second-consumer discipline (ADR 0083) applied at the 3P tier: a lower bar than 1P, but not zero, and it is the difference between a package that exists because someone needed it twice and one that exists because 3P looked like the tidy place to put it.

Consequences

  • Steps 0–1 (D3) proceed immediately and are independently valuable: they fix live lossy-flattening of structured MCP tool results, whether or not any UI work follows.
  • The kernel is untouched on every path — zero syscalls, zero event types, zero fold changes. The MCP Apps surface is a wire binding plus a read-model plus a content-store artifact, which is the whole point of the P10 verdict.
  • @loom/plugin-a2ui’s catalog becomes load-bearing twice. The component/catalog vocabulary shipped for A2UI is reused verbatim as the MCP Apps payload, so the two bindings share one declarative surface model rather than diverging. A future third binding inherits it.
  • The cost is honestly attributed. The stated request (“support MCP Apps”) and the actual work (“be an MCP server”) differ by roughly a factor of five and rest on different justifications. The UI extension is genuinely cheap — one static template, one cursor tool, and a payload contract already shipped. The server it must attach to is not, and that decision is made on its own merits rather than smuggled in behind a UI feature.
  • If the project declines to own an MCP server, the correct outcome is Steps 0–1 and stop. A UI extension with no server to attach it to is not a deliverable, and building the UI half first inverts the dependency for no gain.

Implementation record (2026-08-08)

Shipped as specified, with three findings worth recording because two of them changed the code and one changed the shape of D5.

A fourth ingress defect, found while implementing D3a. callTool also ignored isError. MCP reports tool failures in band — a successful JSON-RPC response carrying isError: true — so the old client returned a failure’s text as if it were a value, and every MCP tool error became a plausible-looking success. callTool now throws; callToolResult exposes the flag for callers that branch.

D5 needed ADR 0073 to be correct rather than conventional. The cursor tool is declared mode: "ambient", and that declaration is what lets a serving surface route it — without it, createMcpServer would need a hardcoded list of “tools that must not be journaled”, which is the hand-wiring the declaration exists to remove. The journaled/ambient boundary also became the server’s app-call rule: an app-originated call to a journaled tool is denied by default (authorizeAppJournaledCall), which is D6’s “distinct principal” requirement in its enforceable form.

One pin, one hashing implementation. The first cut hashed the template with a bare fnv1a64 on the egress side while ingress hashed with a text:-tagged scheme — so a pin captured at plan time did not equal what a reader recomputed, and the pin silently stopped detecting drift. Caught by a round-trip test, fixed by having surfaceHostPin delegate to the ingress side’s hashResourceBody. This is ADR 0063 D1’s warning arriving on schedule: “two canonicalizations that mostly agree is how registry-driven repair becomes unreliable.”

Audience has no in-band signal. The extension puts visibility enforcement on the host, which is the only party that knows whether a tools/call came from the model or a View. A server therefore cannot derive audience from the request alone, so audienceOf is deployment-supplied (a distinct path, port, or credential) and defaults to "model" — absent an explicit boundary, app-only tools stay unreachable rather than silently open. An audience-invisible tool is reported as unknown rather than forbidden, since a “you may not call that” reply confirms the tool exists.

Delivered: McpServer + servedEffect in @loom/plugin-mcp; @loom/plugin-mcp-app with the static template, its pin, the payload builder, and the cursor tool. Spec capabilities mcp-server, mcp-apps-ui-egress, effect-mode-boundary (L2). Workspace green — 70 build tasks, 123 test tasks.

Follow-up (2026-08-08) — completing D5, and decision provenance

The question “does a long-suspended resume need staleness checking?” exposed that D5 was only half-built, and the honest answer to the question itself is no new kernel support.

What the kernel already refuses. resume() is stricter than it looks: K11 resolves the flow code (current, pinned-by-hash, migrated, or loud refusal), K11.5 resumes with the recorded migrated _input, K12.2 rejects a forged approval, K8.2 keeps cancel terminal. Stale code, input shape, and authority are covered. Nothing was added.

What it structurally cannot know. The execution knows the sequence at which it suspended. It does not know the sequence the human was looking at. Across a gate held open for days while the surface keeps advancing those are different numbers, and only the second says whether the decision was informed. That is a provenance gap, not a kernel gap.

Resolution — evidence, not policy. The View echoes seenSequence (the cursor it had folded) on every action; isStale(action, currentSequence, tolerance?) is the predicate. Whether a given lag invalidates a decision is domain-specific, so the framework deliberately supplies the number and stops. The natural way to act on it is an ADR 0060 typed channel (staleDecision), making staleness a recorded, replay-substituted, branchable outcome rather than an exception. Absent provenance counts as stale: an action with no seenSequence is indistinguishable from one made against arbitrarily old data, and defaulting unknown to “fresh” would make the check vacuous for exactly the clients that need it most.

Two defects in the original Step 2, now fixed.

  1. The template never polled. It rendered once from ui/notifications/tool-result and never called loom.surface.since at all. The cursor tool was built and tested server-side and D5’s claim was true of the server — but the client half was missing, so the gap D5 exists to close was not actually closed end to end. The View now polls its own cursor and stops on teardown.
  2. The template discarded sequence. adopt() read components/dataModel/surfaceId and dropped the rest, so the provenance the payload already carried was thrown away.

A correctness fix to the cursor’s contract. The first cut folded from fromSequence and left the View to merge. That is wrong: a tail fold produces a partial state that only looks like a state — a nested patch yields {a:{b:1}}, which a shallow merge splices over the whole of a, and a remove op is indistinguishable from a key the tail simply never touched. The cursor now returns either { changed: false } or a complete model. The cheap tail read is kept purely for change detection, so the common no-change poll stays cheap while every payload that does come back is authoritative.

Follow-up (2026-08-14) — D3 was true of the client and false of the log

Re-examining this ADR against a fresh question (“what would it take to consume interactive UI from a third-party server?”) turned up that D3’s ingress half was unreachable from a flow.

plugins/mcp/src/ui.ts shipped complete and fully tested — uiMetaOf, uiFromResult, toUiResource, readUiResource with its contentHash pin. It had zero production callers. All three step seams dispatched through callTool (mcpInvoke$.ts:176, mcpTool$.ts:12, McpClient.ts:268), the convenience projection that resolves to structuredContent-or-text and drops _meta — where _meta.ui.resourceUri lives — along with every non-text content block. So a UI-bearing third-party result was flattened before the log could record that a UI existed, and uiFromResult, the function whose entire job is spotting one, was called only from a test.

D3’s sentence “carry the resource URI and content hash through the log with provenance” was therefore true of McpClient and false of the log. MCP_DISPATCHED carried no UI fields, and mapTool computed tool.ui and discarded it.

Why it passed review. ingress.test.ts exercises McpClient directly — a level no flow traverses. That is the vacuous-check shape this repo has now hit repeatedly: a gate tested at a level nothing on the live path reaches. The negative test that would have caught it (dispatch a UI-bearing result through mcpInvoke$ and assert the UI is observable) did not exist, and now does — plugins/mcp/src/uiDispatch.test.ts, written against the effect, never the client.

What changed. Three things, all still inside the D1 envelope — KERNEL_ABI 1.1.0 and EVENT_SCHEMA both untouched:

  • resultFidelity: "value" | "full" on all three seams. Default stays "value": a step’s output shape is a durable contract — what STEP_COMPLETED records, what downstream steps destructure, what a replay substitutes — so widening it by default would silently change every existing consumer’s payload.
  • MCP_UI_OBSERVED, a userspace domain event (fold skips it, per the ADR 0063 D4 precedent), carrying { toolName, resourceUri, contentHash?, pin }. A separate event rather than a field on MCP_DISPATCHED, because that one is emitted before the call so a failed attempt stays indexed for proactive repair, and UI provenance is only knowable after it — folding them would have meant moving the emit and silently dropping failures from the repair index.
  • projectToolResult / throwIfToolError, extracted from callTool so the fidelity path and the convenience path share one projection and one in-band-isError guard. We had told callers to prefer callToolResult for anything beyond text and given them no guard to go with it, which is how the isError defect this ADR already fixed once would have come back.

The pin has four explicit states — embedded (inlined, free), resolved (resolveUi fetched it), not-requested, unresolvable — because an absent contentHash is otherwise ambiguous between not asked for and asked for and unavailable, and a pin is a correctness device. Resolution is opt-in since it costs a resources/read per dispatch; an inlined resource is hashed for free and needs no flag. A resource that cannot be fetched is recorded, never fatal — the tool call already succeeded and its side effect already happened.

D2 re-examined and upheld. The host role stays out of this repo, unchanged and for the original reason. A headless postMessage-bridge package was reconsidered on the A2UI precedent (A2UISurfaceBridge is 1P on exactly the “message state machine, not a DOM concern” argument) and rejected on two counts: no second unrelated consumer exists (ADR 0083 — renderer-terminal cannot render HTML, and renderer-react and “the embedding app” are one consumer), and the split is not clean, since ui/open-link, ui/request-display-mode and the permissions block only mean anything in a browser. A headless bridge would ship the safe half under a name implying coverage of the dangerous half.

One correction to D6. S3 disposes of ui/update-model-context as content “tainted by classification (@loom/classify)”. That mechanism does not exist in that direction: classify’s DataClass is secret/sensitive/internal, a confidentiality axis governing what may leave, plus externalized() for where a value is stored. There is no integrity/taint axis governing what may enter a prompt. The disposition is currently unimplementable as written. The gap is real but not MCP-Apps-shaped — it is identical for any third-party MCP result, any A2A peer response, and any fetched document — so it is tracked separately rather than folded in here.

Amendment (2026-10-31) — the cursor tool is loom_surface_since

D5’s tool name loom.surface.since is renamed loom_surface_since (SURFACE_CURSOR_TOOL). Since McpClient.listTools() hides any tool whose name is not ^[A-Za-z0-9_-]{1,64}$ unless the caller passes { anyToolName: true }, the dotted name was a tool loom’s own client hid by default. A clean rename with no alias (publishing both names would keep the hidden one alive); the View template derives the name from the constant. The decision (one app-visibility cursor over readRange) and the text above are otherwise unchanged and keep the original name as historical record. Breaking for any host or client that called the dotted name.

Notes on what was rejected and why it mattered

P-D (“it ships code”) looked like the fatal objection — it is the exact property whose absence made the A2UI adapter clean — and proved the cheapest to neutralize: one static catalog-renderer template converts a code-shipping protocol into a data-shipping one (D4).

The objection that survives is P-B, and it survives as polling (D5). That is a real, permanent cost of the current extension revision, recorded rather than designed around, and the reason this ADR gates rather than accepts.

Source: docs/adr/0072-mcp-apps-ui-extension-placement.md in the loom repository.