Skip to content

feat(t27f): t27f_view and t27f_graph carry their drawing as a ui:// resource on MCP Apps hosts - #28

Open
gHashTag wants to merge 3 commits into
t27mcp/visual-outputfrom
t27mcp/ui-resource
Open

gHashTag wants to merge 3 commits into
t27mcp/visual-outputfrom
t27mcp/ui-resource

Conversation

@gHashTag

@gHashTag gHashTag commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #27 (base t27mcp/visual-output). The spec is gHashTag/t27#10563, which is stacked on gHashTag/t27#10504.

Needs the owner's owner-approved-foreign label. This PR touches hand-written JS glue in visual.mjs (+3 lines) and server.mjs (about 15 lines). Every decision those lines act on comes from the spec through wasm/visual_output.wasm; the JS only reads capabilities, keeps the latest page and adds one content item.

What

On a host that renders MCP Apps, t27f_view and t27f_graph now also return their drawing as a ui:// resource, so the host shows it inline instead of a file path.

  • Spec decides (specs/t27mcp/visual_output.t27, regenerated into wasm/visual_output.wasm and wasm/visual_output_table.mjs on the Railway lab):
    • client_host(ui_extension, ui_mime): an MCP Apps host is a client whose initialize capabilities name io.modelcontextprotocol/ui with text/html;profile=mcp-app.
    • carries_ui_resource(host, svg): whether a t27f result adds the resource.
    • UI_PAGE, UI_URI_VIEW, UI_URI_GRAPH, UI_EMPTY: the page and the addresses.
  • visual.mjs: clientHost(caps), carriesUi(host) and uiPage(title, svg), each a thin call into the wasm or the table.
  • server.mjs:
    • initialize records the client's host.
    • The two tools declare _meta.ui.resourceUri in tools/list.
    • Each call keeps its latest page.
    • When the spec says so, tools/call appends {type:'resource', resource:{uri, mimeType, text}} after the text.
    • resources/list and resources/read serve ui://t27f/view and ui://t27f/graph. Before the first drawing they serve UI_EMPTY.
  • The page has no script and no fetch. It is just the SVG, with the escaped caption as its title.
  • Other hosts are unchanged. Claude Code CLI, a client without the extension, or a client with the extension but the wrong mime gets exactly the text it got before, and no SVG bytes reach the model.

Evidence (Railway lab, commit 8919c9f, wasm sha256 4976db83…25ce4)

  • Spec: 12/12 pass, 0 vacuous.
  • Mutants:
    • resource with no drawing: fails 2
    • client_host ignores the mime: fails 2
    • readability: fails 3
  • node smoke.mjs: rc 0, 33 ok, "all checks passed".
  • JSON-RPC over stdio, with the client capability {"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}}:
    • tools/call t27f_graph {fixture:1} returns content types text resource, uri ui://t27f/graph and mimeType text/html;profile=mcp-app. The page title is "T27F graph view, fixture 1: 16 nodes, 17 edges, 1860x484."
    • tools/call t27f_view {scene:3} returns a resource at ui://t27f/view: a 9650-byte page containing the <svg>.
    • resources/read ui://t27f/graph returns the same page.
    • resources/list returns ui://t27/dashboard ui://t27f/view ui://t27f/graph.
    • tools/list shows resourceUri on t27f_view, t27f_graph and t27_dashboard.
  • With no capability: t27f_graph returns text only, and resources/read ui://t27f/view returns the "No drawing yet" page.
  • With the extension but the mime text/html: text only.
  • The inline: line from feat(t27f): visual-output rule -- initialize instructions and an inline verdict on every t27f SVG #27 is unchanged in all three runs.

Known limit: resources/read serves the latest drawing per URI, so after two concurrent calls of the same tool it shows the later one. Each tools/call result embeds its own call's page, handed back through a per-call object rather than read from that map (fixed after review: lab run at 683c34f, two back-to-back t27f_graph calls embed fixture 1 and fixture 0 respectively while resources/read returns fixture 0). resources/list names the t27f resources only to an MCP Apps client.

🤖 Generated with Claude Code

gHashTag and others added 2 commits October 19, 2026 00:16
…esource on MCP Apps hosts

specs/t27mcp/visual_output.t27 (gHashTag/t27, stacked on #10504) now says when the
UI_RESOURCE channel is picked: client_host() reads the client's initialize
capabilities (the io.modelcontextprotocol/ui extension with the
text/html;profile=mcp-app mime), carries_ui_resource() says whether a t27f
result adds the resource. The wasm and table are regenerated from it on the
Railway lab.

server.mjs: the client's host is taken at initialize; t27f_view and t27f_graph
name ui://t27f/view and ui://t27f/graph in their tools/list _meta, keep their
latest drawing as a page (UI_PAGE around the SVG, no script), add it as a
resource content item when the spec says so, and serve it from resources/read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tri-claw

tri-claw Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Summary by t27-bees

This PR adds MCP Apps UI resource support for t27f_view and t27f_graph: on hosts advertising the io.modelcontextprotocol/ui extension with text/html;profile=mcp-app, the tools now return their SVG drawing as a ui:// resource (in addition to the file path), and resources/read serves the latest drawing per URI. The spec logic lives in the regenerated wasm; the JS glue reads capabilities, tracks the latest page per tool, and appends a resource content item when the spec allows.

Worth a look

  • server.mjs:198-200 — Module-level drawn Map and peerHost are shared across all requests. If this server ever handles multiple concurrent clients (e.g., HTTP transport), the latest drawing and host type would leak across sessions. The current stdio transport runs one client per process, so this is latent.
  • server.mjs:789-791 — resources/list passes true to carriesUi(peerHost, true) to decide whether to advertise the t27f URIs. The second argument means "pretend there's an SVG", so the resources appear in the list even before any drawing exists. This matches the spec's intent but means resources/read will return UI_EMPTY for those URIs until a tool is called.
  • server.mjs:695, 749 — Tool handlers assign drawn.set(uri, ui.page = uiPage(esc(cap), doc)). If esc (not shown in diff) does not properly escape HTML entities, the caption/note interpolated into the page <title> could inject markup into the served resource.
  • visual.mjs:18-20 — clientHost and carriesUi are thin wrappers around wasm exports. If the wasm's client_host or carries_ui_resource logic diverges from the spec (e.g., mime-type matching), the gating would misbehave. The mutant tests catch some of this (ignoring mime fails 2).
  • wasm/BUILD:322-325 — The wasm rebuild instructions add two new exports (client_host, carries_ui_resource). If the regeneration step is skipped or the wasm file is stale, the JS imports would call missing functions and crash at runtime.

Read: 5 files, +67 -20, the whole diff. Head 683c34f. Model: openai-compatible/nvidia/nemotron-3-ultra-550b-a55b. Commands: @t27-bees review | summary | help | pause | resume. Free for every repository.

…test one

Review on #28: t27f_graph awaits before it draws, so a second call could
replace the shared latest-drawing map between this call's handler and the
tools/call reply. The handler now hands its page back through a per-call
object (call(name, args, ui) -> ui.page); the map only serves resources/read.
carries_ui_resource gets whether there is a drawing instead of a constant 1,
and resources/list names ui://t27f/view and ui://t27f/graph only to an MCP
Apps client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gHashTag

Copy link
Copy Markdown
Owner Author

Addressed in 683c34f:

  • Embedded resource race (server.mjs tools/call): right. t27f_graph awaits before it draws, so a second call could replace the shared map between this call's handler and the reply. The handler now hands its page back through a per-call object (call(name, args, ui) -> ui.page), and the map only serves resources/read. Lab: two back-to-back t27f_graph calls embed fixture 1 and fixture 0 respectively, while resources/read returns fixture 0.
  • carriesUi hardcoded 1: it now passes whether there is a drawing to carries_ui_resource.
  • resources/list for non-MCP-Apps clients: the t27f entries are now listed only to an MCP Apps client. Without the capability the list is ui://t27/dashboard only.
  • esc not imported: it is defined in server.mjs (line 190), so no change.
  • peerHost before initialize: intentional. Until a client says otherwise it is a CLI host, the safe default (no resource is embedded), and the comment now says so.

🤖 Generated with Claude Code

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