Repository navigation
feat(mobile): render MCP Apps tool interfaces in the chat transcript - #8472
Conversation
A remote tool's `_meta` no longer stops at discovery, and a call's answer is
no longer flattened before a surface can render it.
The offered tool carries `serverToolName`, the name a view calls the tool by,
and `uiResourceUri`, the view the server attached, when it attached one.
Neither is in `definition`, so the model is told nothing new. `call` answers
the whole result — the text the model reads, plus the server's content and
structured content — and `deps.onCallResult` reports each answer, because a
view renders what the text drops.
`remoteMcpApp(server, deps, { tools, context, openLink })` is the host half of
one view, over the official `AppBridge`: it answers the handshake with the
host's identity, capabilities and context, sends the call's input and result,
reports size changes, opens links, and forwards a view's `tools/call` through
the same client a model's call uses, refusing any name its own server was not
discovered to offer. Sampling, model-context updates, messages and downloads
are not advertised. The surface supplies one transport that writes text into
its sandbox and hands the text back through `receive`; it names no method.
Proof: the remote-mcp suites drive a real protocol server through the bridge —
the handshake, the context, initialization, size changes, the call's input and
result, a callback to the same server, a refused callback, a failed call, and
an opened link — plus the offered tool's view and the answer reported to the
surface.
A remote MCP tool whose server attached a `ui://` view now draws that view in the transcript, under the row the call already had. Tools without one, and calls that have not finished, keep today's rendering. The view runs in a WebView that loads one page of the app's own: a proxy that owns a single `srcdoc` iframe with `sandbox="allow-scripts"` — an opaque origin, no file access, no way to navigate anything but itself — under a content policy built only from the origins the resource declared, and default-deny when it declared none. Only JSON text for the bridge crosses into it or out of it, and only messages from that iframe are relayed. The call's answer is what the view receives: the live call's content and structured result are kept for the row that draws them (`mcpCallResultOf`), so an interface that renders the server's structured data shows it. A transcript reopened from storage has the text the model read and no structured result; the view then draws from the call's input alone. The row keeps one height from the moment it is drawn — the view's own reported height inside the transcript's clamp — so a view that loads a moment after its call does not move the list. Loading, an empty document and a failure each have their own state, and a failure offers Retry. Copy: `modelChat.mcp.app.label`, `.failed` and `.empty` are added to every catalog. Proof: the chat suites cover the view lookup, the document read, the policy, the height clamp, the arguments handed to a view, and the router that picks the card; the Android run renders the official example App with the server's time and calls its tool back through the bridge.
|
bot: Android end-to-end run of this branch against a local dev stack, with the official
|
…policy The resource's `_meta` is the server's to write, and metadata that is not the shape the specification defines is not a reason to refuse to draw the view: it is a resource that named no origins, so it may load none. Proof: a policy request whose `connectDomains` is not a list resolves to the default-deny policy rather than an error.
The card is what the transcript routes to; the view inside it has no other caller, so it is not exported and the barrel re-exports only the card.
The sheet subscribes to the hardware back button, and the suite's react-native mock answered every other native export it reads. The mock now answers this one too, so the sheet mounts in the shard it lands in.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryThe only change since the last review is a type-only annotation ( Assumptions: Files Reviewed (2 files)
Previous Review Summaries (5 snapshots, latest commit 2c77f76)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 2c77f76)Status: No Issues Found | Recommendation: Merge Executive SummaryThe only PR-authored change since the last review is the Effect 4 adaptation in Assumptions: Files Reviewed (5 files)
Previous review (commit c6fdad2)Status: No Issues Found | Recommendation: Merge Executive SummaryThe three fixes at Assumptions: Overview
Files Reviewed (5 files)
Fix these issues in Kilo Cloud Previous review (commit 8d3562e)Status: 3 Issues Found | Recommendation: Address before merge Executive SummaryThe fixes at HEAD close all four earlier findings, but the new abort signal turns a normal row unmount into a published server failure that drops the server's tools and views from the live chat. Assumptions: Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (7 files)
Fix these issues in Kilo Cloud Previous review (commit b140c5d)Status: 4 Issues Found | Recommendation: Address before merge Executive SummaryThe incremental change since Assumptions: Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (5 files)
Fix these issues in Kilo Cloud Previous review (commit b6ab7a6)Status: 4 Issues Found | Recommendation: Address before merge Executive SummaryThe mobile/harness split is sound and the sandbox around server-authored HTML is deliberate, but the policy injection can force a resource without an explicit Assumptions: Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (99 files)
Reviewed by deepseek-flash-latest · Input: 81.5K · Output: 4.5K · Cached: 393.2K Review guidance: REVIEW.md from base branch |
Four things the view's own review found: - A document without a head gets its policy after the doctype. Putting it in front of the doctype is a parse error, and the parser draws the whole document in quirks mode from there. - A retry gets a page of its own. A page that failed to load has no host object left to hand a document to, so re-reading the resource into the same webview could never recover. - The document read is bounded like a discovery and ends with the row that asked: 15 seconds, and the request is aborted when the row goes away. - The answers kept for the rows are bounded by characters as well as by count. This is the only place the app holds a server's payload after a call ends. Proof: the policy tests cover a document that starts with a doctype; the retained-answer tests cover eviction by count and by size.
|
bot: Re-ran the Android proof on the head that includes the review fixes (retry remount, bounded read, bounded retention, doctype placement). Same fixtures as the first run: the official 1.7.5 The view drew the live result ( |
…ript-views # Conflicts: # apps/mobile/src/components/todos/todo-list-model-picker.mounted.test.tsx
…for the page Three things the second review pass found: - The read no longer carries `onCallFailure`. A row that unmounts aborts its read, and that abort was classified as the server being unreachable, which published the server as failed and dropped its tools. A read that does not answer is reported by the row's own Retry. - The page's loaded flag resets on a retry, not on a re-read. A re-read for the same mounted page (a re-discovery republishes the view) would otherwise wait for a load that never comes again and leave the row blank. - An answer larger than the retention bound on its own is not kept, so no single server payload can exceed the cap the module documents. Proof: the retained-answer tests cover eviction by count, by accumulated size and by one oversized answer; the read's test asserts it stays bounded and unreported.
|
bot: Resolved the main-branch conflict and checked the merged integration.
This follow-up changes no visual layout. The Android screenshots already attached to this PR remain the visual evidence; Android was not rerun for this merge recovery. |
|
bot: Fixed the CI typecheck/lint failure introduced by the newer base's WebView 14 declarations. Its default generic is now Verification:
|



What changed
A remote MCP tool whose server attached an MCP Apps view now draws that view in the mobile chat transcript, under the row the call already had. Tools without a view, and calls that have not finished, keep today's rendering.
harness-sdk (
plugins/remote-mcp)serverToolName(the name a view calls the tool by) anduiResourceUri(the view the server attached, when it attached one). Neither is indefinition, so nothing new enters the cached prefix.callanswers the whole result: thetextthe model reads, plus the server'scontentandstructuredContent.deps.onCallResultreports each answer, because a view renders what the text drops.remoteMcpApp(server, deps, { tools, context, openLink })is the host half of one view, over the officialAppBridge(ext-apps 1.7.5, already the package the plugin reads a tool's view uri with). It answers the handshake with the host's identity, capabilities and context, sends the call's input and result, reports size changes, opens links, and forwards a view'stools/callthrough the same client a model's call uses — refusing any name its own server was not discovered to offer. Sampling, model-context updates, messages and downloads are not advertised. The surface supplies one transport that writes text into its sandbox and hands the text back throughreceive; it names no method.apps/mobile
remote-mcp-surface.ts).McpAppViewmounts a WebView that loads one page of the app's own: a proxy that owns a singlesrcdociframe withsandbox="allow-scripts"— an opaque origin, no file access, no way to navigate anything but itself — under a content policy built only from the origins the resource declared, default-deny when it declared none, with anything that would widen it (wildcards,'unsafe-eval', non-http schemes) dropped. Only JSON text for the bridge crosses into it or out of it, and only messages from that iframe are relayed.modelChat.mcp.app.label,.failedand.emptyadded to all 87 catalogs; everything else reuses existing keys (common.loading,common.retry).End-to-end evidence (Android, emulator on this VM)
Exercised on a dev build of this branch against a local dev stack, with the official
examples/basic-server-vanillajsfrom@modelcontextprotocol/ext-apps1.7.5 served over Streamable HTTP, and a throwaway OpenAI-compatible backend that answers the chat with atools/callfor whichevermcp_<server>_get-timetool the app discovered. Both endpoints reached the emulator overadb reverse, on an owned fake account.mcp_apps-example_get-timeruns on the device, the tool row renders, and the view draws the App with the server's time from the live result's structured content:Server Time: 2026-10-21T15:23:02.665Z.2026-10-21T15:23:51.601Z.Screenshots are attached in the comment below.
Static checks on this branch:
packages/harness-sdkcheck:ci(typecheck, lint, 58 suites, build, platform and package checks), mobiletypecheck, the whole mobile vitest suite (1205 files, 18059 tests), andcheck:i18n/check:plurals.Also
todo-list-model-picker.mounted.test.tsxreached a hardware back-button subscription through areact-nativemock that answered every other native export it reads. That shard is red onmainfor the same reason; the mock now answers this one too, so the suite mounts the sheet wherever the shard lands.Limits
Proxy answers
The owner's proxy gave these answers in the owner's place.
Answer: harness-sdk remote-mcp plugin (Recommended)
Confidence: 0.70 · Precedent: Engineering conventions: 'Worker direct, extract any common code to an internal library' (session 5d8113f51c48). The plugin already owns remote MCP and pins ext-apps.
Answer: Relay structuredContent for live calls (Recommended)
Confidence: 0.50 · Precedent: Speed and least work: 'do the least amount of work possible, with no drop in quality' (session 7f3470e0ba6f).
Answer: Completed parts only, inline (Recommended)
Confidence: 0.75 · Precedent: UX and design quality: 'CLS is a massive enemy' (session 00eb2a1d3e4b). A fullscreen sheet is beyond the brief.
Answer: Two new keys in all 87 catalogs (Recommended)
Confidence: 0.85 · Precedent: UX and design quality: 'the mobile app always must respect i18n' (session dcb3f9170164).
Answer: Scripted chat backend drives the tool call (Recommended)
Confidence: 0.75 · Precedent: Proof and verification: prove behavior with a real request through real clients (session 4807b1d78fc7); fixtures rule: use real backend state when a fixture cannot mimic real usage (session b1be07191a1d)
Answer: Same server's discovered tools only (Recommended)
Confidence: 0.80 · Precedent: Autonomy: make executive decisions with "is this reasonable?" as the test (session 7f3470e0ba6f). Exploit janitor lanes treat harm to users as a real concern (session 2f71af3a787e). The brief limits calls to that server's tools, with the same prompts as model calls.