MCP Apps (SEP-1865 interactive app rendering via gatewayd)
August 31, 2026 ยท View on GitHub
MCP Apps render server-provided ui:// HTML inline for a successful model-originated tools/call. The gateway fetches the resource out of band, spools the render payload, appends an opaque marker to the ordinary result, and the dashboard replaces that marker with an isolated iframe. mcp_gateway.backend.Backend._maybe_intercept_ui_result only reaches this path for stdio servers behind a gateway stub. Raw HTTP/SSE and streamable-HTTP server entries have no stub, remain unmodified by mcp_gateway.rewriter, and do not provide this Apps host path.
The producer advertises the MCP UI extension only while mcp_gateway.backend._mcp_apps_enabled permits it. An explicit KIROCREW_MCP_APPS disable or stored mcp_gateway.apps_enabled = false disables rendering even for a stubbed server; mcp_gateway.enabled only changes backend sharing, not Apps eligibility. The dashboard consumer still handles a valid marker independently of the producer gate. test_mcp_apps_e2e.test_flag_off_is_a_noop pins that the disabled producer path neither injects the extension nor creates a spool record.
Backend._maybe_intercept_ui_result still filters model-facing tools/list responses while the Apps producer gate is off; the server's app-only visibility declaration remains an authorization boundary rather than a rendering preference. Its exemption for a gateway-internal stub runs first, so an app authorization listing still sees app-only tools. That exemption is keyed on backend.INTERNAL_STUB_PREFIXES rather than on __app_call__ alone: a tool-surface probe (__tool_surface__, see mcp-gateway-backend-replacement.md) asks the server for its own declaration and must not receive a narrowed one. test_mcp_gateway_apps_spool.test_listing_filtered_even_when_feature_disabled and test_app_call_listing_unfiltered_when_feature_disabled pin the ordering.
Render handoff
Marker and spool record
mcp_gateway.apps.append_marker appends [kirocrew-mcp-app:<id>] to the first text item of the result, or adds a text item when none exists. mcp_apps_render.MARKER_RE accepts only a lowercase hexadecimal UUID-shaped id, so marker text cannot select an arbitrary path. mcp_apps_render.handle_tool_result strips every recognized marker from transcript text whether or not its record can render, keeping the control token out of the transcript and model-visible result.
mcp_gateway.apps.write_spool writes the schema owned by mcp_apps_render.SPOOL_SCHEMA_VERSION to the configured spool directory. mcp_apps_render.load_spool rejects malformed ids, path escapes, non-files, oversized or expired records, non-object JSON, and an unknown schema before returning data. This makes the spool record an unambiguous capability-bearing wire contract rather than best-effort JSON that a stale reader could misinterpret. test_mcp_apps_render.test_load_spool_rejects_wrong_or_missing_schema and test_mcp_apps_render.test_load_spool_rejects_and_reaps_expired pin the fail-closed reader behavior.
A record carries the producing server, canonical session key, exact producing backend pool digest, app HTML, resource CSP and permissions, callback capability, structured result, original tool arguments, and original result content. write_spool drops input keys outside that schema, so render-only or future caller fields cannot become silently persisted authority.
write_spool creates the directory and record with owner-only protection before content is published. The record filename and callback capability authorize a live app path, so publishing bytes before protection would expose both during the write window. test_mcp_gateway_apps_spool.TestWriteSpool.test_lockdown_precedes_content and test_failed_lockdown_publishes_no_record pin that no unprotected final record is observable.
The writer sweeps stale records opportunistically and load_spool enforces expiry on every read. The reader also removes the rendered sidecar with an expired record, so an app callback cannot survive merely because the background sweep has not run.
Rendering and delivery
mcp_gateway.backend.Backend._maybe_intercept_ui_result accepts a ui:// URI from result metadata or a prior tools/list declaration. It rejects every other scheme, ignores failed tool results, and falls back to the original unmodified result when resource reading, MIME validation, decoding, or spooling fails. test_mcp_gateway_apps_spool.test_resources_read_timeout_delivers_original, test_wrong_mimetype_delivers_original, and test_is_error_result_never_intercepts_either_form pin the failure-safe path.
Backend._read_ui_resource asks the same producing backend for resources/read; Backend._parse_ui_contents accepts only the MCP Apps HTML MIME profile and either UTF-8 text or validated base64 data. The gateway never dereferences an arbitrary network or filesystem URI supplied by the tool result.
mcp_apps_render._claim_render checks the canonical producing-session key before atomically creating the rendered sidecar. The order is load-bearing: a marker replayed into another session cannot consume the legitimate session's one render. The record remains until expiry after a successful claim because the already-rendered iframe still needs its callback capability. test_mcp_apps_render.test_wrong_slot_replay_does_not_burn_the_render_claim and test_handle_tool_result_replayed_marker_is_inert pin those replay properties.
handle_tool_result sends mcp_app_render only through state.broadcast_ws_owners when that owner-only channel exists. It redacts every string key and value in the structured content, tool input, and result content before crossing into server-authored HTML. The callback capability therefore never enters marker text or a guest WebSocket, and credentials or exfiltration URLs in app-bound data are scrubbed. test_mcp_apps_render.test_render_uses_owner_only_channel_not_generic and test_handle_tool_result_redacts_credentials_in_leaves enforce those boundaries.
dashboard.chat_runner._run_chat invokes handle_tool_result for EVENT_TOOL_RESULT, passes the frontend slot separately from effective_session_key, and broadcasts the claimed payload. website/src/hooks/useWebSocket.ts stores that live event by tool-call id; website/src/pages/chat/ToolCallLine.tsx consumes it into McpAppFrame. This preserves canonical-session binding while associating the surface with its originating call. test_mcp_apps_render.test_handle_tool_result_renders_in_bound_session and UseWebSocketCoverage.test.tsx pin the handoff.
The frontend keeps render payloads only in live state and never persists app tabs, because a restored tab has no valid live payload or callback capability. website/src/store/chatSlice.ts and mcpAppPanelTab.test.ts enforce this lifecycle boundary.
Iframe policy and AppBridge
website/src/components/McpAppFrame.tsx renders a srcdoc iframe with allow-scripts allow-forms and without allow-same-origin. The missing same-origin permission forces the document to a null origin, preventing access to the dashboard DOM, cookies, storage, and same-origin APIs. The iframe can only use the host's postMessage bridge for privileged actions. McpAppFrame.test.tsx pins the missing allow-same-origin attribute.
website/src/lib/mcpAppSrcdoc.ts emits the per-app CSP meta tag and trusted navigation guard before every untrusted HTML byte, preserving only a leading doctype first. A srcdoc meta CSP starts enforcement where it appears and cannot provide every header CSP directive, so the null-origin sandbox remains the isolation boundary and the CSP limits the app's external capabilities. mcpAppSrcdoc.test.ts pins the injection order and strict default policy.
buildMcpAppCsp uses default-src 'none', disables connection and nested frames unless the app declares valid domains, keeps form submission disabled, and limits script, style, image, font, media, and base sources to the policy defaults plus their corresponding declared domains. sanitizeCspDomain accepts only an HTTPS DNS origin, optional single wildcard subdomain, and optional port; it strips whitespace and rejects paths, non-HTTPS schemes, CSP separators, quoting, and embedded whitespace. This prevents a resource author from turning metadata into a second CSP directive. mcpAppSrcdoc.test.ts pins both domain sanitization and directive composition.
buildAllowAttribute grants only supported declared permissions. It may expose geolocation and clipboard write, but it does not advertise camera or microphone because those features are unusable in the opaque sandbox. The app cannot request broader iframe permissions through its HTML or CSP metadata.
McpAppFrame authenticates every inbound bridge message by the iframe contentWindow, answers ui/initialize, supplies the originating tool input and result after ui/notifications/initialized, handles size and display-mode negotiation, allows ui/open-link only for absolute HTTPS URLs with noopener,noreferrer, and logs app notifications with a server/tool label. It forwards only tools/call to the dashboard relay. Unsupported requests receive JSON-RPC method-not-found and unsupported notifications are ignored. A navigation-start signal and subsequent iframe load retire the bridge before a replacement document can use the host-held callback capability. McpAppFrame.test.tsx covers the supported bridge messages, unsupported requests, HTTPS link gate, and navigation retirement.
App-originated tool calls
The iframe posts spool_id, callback capability, requested tool name, arguments, and its session key to POST /api/mcp-apps/call. dashboard.handlers.mcp_apps.api_mcp_apps_call accepts only an object body with a tool name and object arguments, requires the authenticated dashboard owner rather than a client-supplied identity header, denies restricted sessions, and checks that the spool record belongs to the request session before it dials gatewayd. This prevents a leaked record id, app token, guest session, or another session from becoming a callback authority. test_mcp_apps_call_endpoint.test_relay_rejects_non_owner_and_app_tokens, test_relay_rejects_restricted_session, and test_relay_rejects_session_mismatch enforce the dashboard gates.
The dashboard is a relay, not the authorization authority. It sends one app-call frame over the uid-gated gateway socket and returns redacted backend results or errors to the iframe. It does not forward server names, pool identifiers, caller identity, malformed arguments, unrelated bridge methods, or a raw gateway response type. Unknown or expired records are rejected locally, and gateway unavailability, timeout, and unexpected frames have distinct HTTP failures as covered by test_mcp_apps_call_endpoint relay tests.
mcp_gateway.app_call.handle_app_call re-reads the spool record, requires a constant-time match of the owner-only callback capability, and resolves the backend only by the stored pool digest plus matching server name. A marker id alone therefore authorizes nothing, and a valid record cannot be redirected by a same-named backend in another pool partition. test_mcp_apps_call_endpoint.test_app_call_requires_callback_secret and test_app_call_rejects_record_without_digest pin those denials.
For each callback, _tools_by_name obtains a fresh caller-scoped tools/list; it is deliberately uncached because both tool visibility and input schema are authorization inputs that a server can revoke. visibility_allows is shared with model-side listing: absent visibility allows both model and app, an explicit app-only declaration hides the tool from the model but permits the app, a model-only declaration denies the app, and malformed declared visibility denies both audiences. test_app_call_allows_tool_with_no_declared_visibility, test_app_call_still_denies_model_only_tool, and test_app_call_tools_list_is_fetched_fresh_per_call pin the shipped semantics.
The gateway validates iframe-controlled arguments against that fresh tool schema before forwarding. It then applies the mcp governance decision to the canonical @server/tool reference with the producing backend agent name. Governance evaluation errors deny, while an ungoverned standalone host follows the same permit behavior as model-originated MCP calls. The forwarded request uses an ephemeral stub and CallerContext(session_type="mcp-app"), allowing backend routing and SEL auditing to distinguish app authority from model authority. test_app_call_rejects_schema_violating_arguments, test_app_call_denied_by_governance_mcp_scope, and test_app_call_governance_evaluation_error_fails_closed cover the critical gates.
Backend._maybe_intercept_ui_result never re-intercepts an __app_call__ result, even when the called tool declares ui://; it returns the backend result to the requesting app verbatim instead of minting a nested spool record and replacing the callback result with a marker. test_mcp_gateway_apps_spool.test_app_call_forward_never_reintercepted pins this boundary.
mcp_gateway.app_call._roundtrip cancels in-flight work before detaching its ephemeral stub on timeout or cancellation. Detachment only removes routing state, so cancellation is necessary to reduce duplicate mutation risk when an iframe retries after a failed callback. test_mcp_apps_call_endpoint.test_app_call_cancellation_is_audited covers the cancellation audit path.
Files and tests
src/kiro_crew/mcp_gateway/apps.pyowns spool writing, URI extraction, marker insertion, stale-record cleanup, and the shared model/app visibility parser.src/kiro_crew/mcp_gateway/backend.pyinjects UI capability for eligible stdio backends, captures declarations, fetchesui://resources, and delivers the marked result.src/kiro_crew/mcp_apps_render.pyvalidates and claims spool records, redacts app-bound payloads, strips markers, and broadcasts owner-scoped render events.src/kiro_crew/dashboard/handlers/mcp_apps.pyowns the authenticated HTTP relay and dashboard-side session gates.src/kiro_crew/mcp_gateway/app_call.pyowns capability, backend, visibility, schema, governance, forwarding, cancellation, and SEL decisions for callbacks.website/src/lib/mcpAppSrcdoc.tsandwebsite/src/components/McpAppFrame.tsxown the CSP, sandbox, permissions, and AppBridge.test/test_mcp_apps_e2e.py,test/test_mcp_gateway_apps_spool.py,test/test_mcp_apps_render.py,test/test_mcp_apps_call_endpoint.py,website/src/test/mcpAppSrcdoc.test.ts, andwebsite/src/test/McpAppFrame.test.tsxcover the backend and browser-facing contracts.