feat(mcp): add MCP Apps (ui://) extension and read-only Project Status app - #80
Merged
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Before / After
Before. Cairnline's projection tools (
projects.health,projects.operations_brief,projects.activity) return text plusstructuredContent. 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/structuredContentas before. The extension is advertised only when an app is registered, and tagging the tools never changes their call results.How
internal/app/apps.go).RegisterAppsis reactive: with ≥1 app it registers aui://resource provider/reader that serves app HTML astext/html;profile=mcp-appand declaresio.modelcontextprotocol/uiadvertisingmimeTypes: ["text/html;profile=mcp-app"]; with no apps it declares nothing.uiAppMetatags a tool descriptor with the nested_meta.ui.resourceUrilinkage._metatagging (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.internal/app/views/). One view (src/project-status.ts) detects which of the threestructuredContentshapes it received and renders that section, keeping only light per-project state: results for the sameproject_idaccumulate into one combined view, and a result for a different project resets every section so two projects never bleed together.action_kind/action_labelhints render as inert labels (no tool calls in this batch).bun install+bun run buildbundle 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 todist/and embedded via//go:embed. Runtime andgo testneed no JS toolchain. CI installs the pinned dependency tree (bun install --frozen-lockfile), rebuilds the bundle, and fails if the committeddist/drifted from source; the minified output is byte-reproducible under the pinned Bun +bun.lock.@modelcontextprotocol/ext-apps(new App(...),app.ontoolresult,app.connect()). The App drives theui/initialize→McpUiInitializeResult→ui/notifications/initializedhandshake and delivers eachui/notifications/tool-resulttoontoolresult, whoseCallToolResultcarriesstructuredContent. The earlier__require is not definedfailure was a Bun IIFE-interop bug, not an SDK defect: bundling the SDK's mixed ESM/CJS-interop graph with--format=iifemakes Bun emit a bare__requirereference it never defines. Targeting--format=esmand loading it as an inline module script eliminates that reference. The CSP is unchanged —default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline', nounsafe-eval, no external origins — and the SDK runs under it: theAppconstructor puts zod in jitless mode, so noeval/new Functionpath is taken at runtime (zod's lonenew Functionis a feature-probe wrapped intry/catchthat 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/agent-host-integration.mdandREADME.md; the viewsREADME.mddocuments 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.internal/app/views/verify/verify.mjs, not part ofgo 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 theui/initializehandshake, delivers representative health/operations/activity results over theui/notifications/tool-resultcontract, 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 nounsafe-eval.Draft — not for merge yet.