Skip to content

feat(mcp): add MCP Apps (ui://) extension and read-only Project Status app - #80

Merged
chicoxyzzy merged 10 commits into
mainfrom
feat/mcp-apps-ui-extension
Jul 9, 2026
Merged

feat(mcp): add MCP Apps (ui://) extension and read-only Project Status app#80
chicoxyzzy merged 10 commits into
mainfrom
feat/mcp-apps-ui-extension

Conversation

@chicoxyzzy

@chicoxyzzy chicoxyzzy commented Jul 9, 2026

Copy link
Copy Markdown
Member

Before / After

Before. Cairnline's projection tools (projects.health, projects.operations_brief, projects.activity) return text plus structuredContent. A host operator sees only what their client renders from that JSON.

After. Cairnline ships an optional, read-only Project Status interactive view using the MCP Apps extension (SEP-1865, io.modelcontextprotocol/ui). A host that renders MCP Apps (Claude Desktop, VS Code Copilot, Goose) can display the view — project health, the operations/attention brief, and activity buckets — for those tool results. Hosts that do not render apps (including current coding-agent CLIs) are unaffected: they receive the exact same text/structuredContent as before. The extension is advertised only when an app is registered, and tagging the tools never changes their call results.

How

  • Extension foundation (internal/app/apps.go). RegisterApps is reactive: with ≥1 app it registers a ui:// resource provider/reader that serves app HTML as text/html;profile=mcp-app and declares io.modelcontextprotocol/ui advertising mimeTypes: ["text/html;profile=mcp-app"]; with no apps it declares nothing. uiAppMeta tags a tool descriptor with the nested _meta.ui.resourceUri linkage.
  • _meta tagging (internal/app/tools.go). The three projection tool descriptors carry _meta.ui.resourceUri = ui://cairnline/project-status. This is descriptor-only — a regression test compares each tool's call result (timestamps and project id normalized) against committed pre-app golden fixtures and asserts the result body is unchanged.
  • View + build pipeline (internal/app/views/). One view (src/project-status.ts) detects which of the three structuredContent shapes it received and renders that section, keeping only light per-project state: results for the same project_id accumulate into one combined view, and a result for a different project resets every section so two projects never bleed together. action_kind/action_label hints render as inert labels (no tool calls in this batch). bun install + bun run build bundle the view plus the official SDK to a single self-contained HTML file (ESM, inlined into a <script type="module">) under a strict default-deny CSP, committed to dist/ and embedded via //go:embed. Runtime and go test need no JS toolchain. CI installs the pinned dependency tree (bun install --frozen-lockfile), rebuilds the bundle, and fails if the committed dist/ drifted from source; the minified output is byte-reproducible under the pinned Bun + bun.lock.
  • Bridge = official SDK. The view uses @modelcontextprotocol/ext-apps (new App(...), app.ontoolresult, app.connect()). The App drives the ui/initializeMcpUiInitializeResultui/notifications/initialized handshake and delivers each ui/notifications/tool-result to ontoolresult, whose CallToolResult carries structuredContent. The earlier __require is not defined failure was a Bun IIFE-interop bug, not an SDK defect: bundling the SDK's mixed ESM/CJS-interop graph with --format=iife makes Bun emit a bare __require reference it never defines. Targeting --format=esm and loading it as an inline module script eliminates that reference. The CSP is unchangeddefault-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline', no unsafe-eval, no external origins — and the SDK runs under it: the App constructor puts zod in jitless mode, so no eval/new Function path is taken at runtime (zod's lone new Function is a feature-probe wrapped in try/catch that degrades to the interpreter when CSP blocks it). This matches how the official examples build views (Vite + vite-plugin-singlefile, loaded as <script type="module">).
  • Docs. MCP Apps authoring + host-support caveat in docs/agent-host-integration.md and README.md; the views README.md documents the ESM-vs-IIFE reasoning and the final CSP.

Verification

  • go build ./... — clean.
  • go vet ./... — clean.
  • go test ./... — all packages pass, including new coverage: ui:// resource listed/read with the mcp-app profile and HTML body; tagged tool descriptor carries _meta.ui.resourceUri; extension present when an app is registered and absent when none; embedded HTML non-empty; and the golden call-result regression on all three projection tools.
  • go test -race ./... — all packages pass.
  • Headless render check (internal/app/views/verify/verify.mjs, not part of go test): renders the built HTML in a sandboxed iframe host (the SDK App is a full JSON-RPC peer, so it must run with the host as its parent, not at top level) under its real strict CSP, completes the ui/initialize handshake, delivers representative health/operations/activity results over the ui/notifications/tool-result contract, asserts the key text rendered and that a project switch does not bleed prior sections, and screenshots. Runs clean with zero console/CSP errors, confirming the SDK needs no unsafe-eval.

Draft — not for merge yet.

Add an MCP Apps (SEP-1865, io.modelcontextprotocol/ui) foundation so hosts
can render an interactive HTML view for a tool result.

- RegisterApps wires a ui:// resource provider/reader that serves app HTML
  with the text/html;profile=mcp-app mime type, and reactively declares the
  io.modelcontextprotocol/ui extension only when at least one app is
  registered.
- uiAppMeta tags a tool descriptor with _meta.ui.resourceUri.
- Add the embedded, self-contained Project Status view plus a bun build
  pipeline under internal/app/views; the view is committed to dist/ and
  embedded via go:embed, so runtime and go test need no JS toolchain.

The view implements the MCP Apps postMessage bridge directly instead of the
ext-apps SDK, which does not bundle to a self-contained browser IIFE and
needs eval that the view's default-deny CSP forbids.
Attach the Project Status MCP Apps view to the projection tools so hosts that
render apps can show project health, the operations brief, and activity.

- Tag projects.health, projects.operations_brief, and projects.activity
  descriptors with _meta.ui.resourceUri pointing at the Project Status app.
  Adding the tag is descriptor-only: a regression test compares each tool's
  call result byte-for-byte (timestamps normalized) against an untagged
  reference server and asserts the results are unchanged.
- The single stateless view detects which of the three structuredContent
  shapes it received and renders that section; action_kind/action_label hints
  render as inert labels (no tool calls in this batch).
- Document MCP Apps authoring and the host-support caveat in the README and
  the agent host integration guide.
The view accumulated tool results in unkeyed module state, so results for
two different projects merged into one view. Key accumulated sections on
project_id and reset every section when a result for a new project arrives,
so one project's health/operations/activity can never bleed into another's.

Complete the MCP Apps handshake in spec order: post
ui/notifications/initialized only after the host answers ui/initialize.

Extend the headless verify harness to answer ui/initialize, assert the
readiness notification follows the response, and assert no cross-project
bleed after switching project_id. Rebuild the embedded dist bundle.
A source edit under views/src that skipped `bun run build` would ship a
stale embedded dist/ bundle undetected. Add a CI job that installs the
pinned Bun, rebuilds the bundle, and fails via git diff --exit-code if the
committed dist/ drifted from source.

Pin the Bun version in .bun-version and package.json (packageManager /
engines) for reproducible builds. Stop git-ignoring the lockfile so it is
committed if a dependency is ever added; the build currently pulls in no
third-party packages, so Bun writes no lockfile and CI needs no network
beyond installing Bun itself.
The regression test compared each projection tool's call result against a
freshly built untagged server, which is near-tautological: it only proves
the app _meta tag does not change the result, not that the result body is
stable. Replace it with committed golden fixtures capturing the exact
text + structuredContent for a fresh project (timestamps and project id
normalized). The tool bodies predate the app tag (PR only added descriptor
_meta), so any future drift in a result body is now caught, not just the
tag. Regenerate with -update.
The initialize and ClientCapabilities comments claimed Cairnline declares no
extensions of its own; it now declares io.modelcontextprotocol/ui via
DeclareExtension when an MCP Apps view is registered. Correct both.
Replace the hand-rolled MCP Apps postMessage bridge with the official
@modelcontextprotocol/ext-apps App. The earlier "__require is not defined"
failure was a Bun IIFE-interop bug, not an SDK defect: bundling the SDK's
mixed ESM/CJS graph to an IIFE emits an undefined __require reference. Building
to ESM and loading it as an inline <script type="module"> removes that
reference and runs under the unchanged strict CSP
(default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline') with
no unsafe-eval: the App constructor puts zod in jitless mode, so no
eval/new Function path is taken at runtime.

- add @modelcontextprotocol/ext-apps@1.7.4 as a pinned dependency with a
  committed bun.lock; the minified bundle is byte-reproducible under the pinned
  Bun + frozen lockfile
- switch build.ts to --format=esm and guard against a stray second chunk
- rebuild the embedded dist bundle; view rendering, per-project keying, and
  inert action labels are unchanged
- run verify against a sandboxed iframe host, since the SDK App is a full
  JSON-RPC peer and cannot answer its own ui/initialize at top level
- install views deps in the freshness CI job; update README and the host
  integration guide
* docs: add MCP Apps contributor skill

* docs: correct MCP Apps skill for ext-apps SDK adoption

The Project Status app now uses the official @modelcontextprotocol/ext-apps
SDK under the same strict CSP. The earlier "SDK breaks under strict CSP, use a
hand-rolled bridge" guidance was wrong: the __require crash was a Bun
--format=iife interop bug, fixed by bundling ESM and loading a module script.
Update the wire-handshake description, the recipe (SDK App wiring, ESM bundle,
bun install before build), the gotchas, and the verification ladder
(frozen-lockfile install, iframe-hosted verify.mjs).
Rename verify/verify.mjs to verify/verify.ts and convert it to proper
TypeScript: Playwright Browser/Page/Frame types, typed fixture payloads,
and typed page-injected Window helpers. Behavior is unchanged — same CSP
injection, handshake, tool-result delivery, section assertions, and the
same default screenshot path (verify/project-status.png).

Pin playwright, typescript, and @types/node as devDependencies (updating
bun.lock), add a tsconfig.json that type-checks both src/ and verify/, and
add a typecheck script. The verify script now runs via bun (bun verify/verify.ts).
@chicoxyzzy
chicoxyzzy marked this pull request as ready for review July 9, 2026 18:09
@chicoxyzzy
chicoxyzzy merged commit bf7cc2a into main Jul 9, 2026
2 checks passed
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