web: Wave 2 — spec-conformant MCP Apps host (rollup of #1565–#1572) (#1660)
* web: sandbox hardening — opaque-origin inner frame, srcdoc-only, frame-ancestors Harden the MCP Apps sandbox isolation model: - The inner (untrusted-widget) iframe is sandboxed WITHOUT `allow-same-origin` (default "allow-scripts allow-forms") and any server-supplied sandbox value has `allow-same-origin` stripped case-insensitively, so the widget runs under an opaque origin and cannot reach the proxy's DOM to bypass its own CSP via the parent realm. - Widget delivery is `srcdoc`-only (no document.write), which respects the opaque origin. - The relay accepts the inner frame's "null" origin with `event.source === inner.contentWindow` as the unforgeable identity check. - sandbox-controller.ts serves the proxy with `Content-Security-Policy: frame-ancestors http://127.0.0.1:* http://localhost:*`; fetch directives are deliberately omitted so a srcdoc iframe's inherited policy container does not override the per-app CSP. Updated the isolation-model comments and the sandbox-controller integration test asserts the frame-ancestors header (and the absence of fetch directives). Closes #1565 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: enforce per-app _meta.ui.csp in the app bridge + surface resource-read failures - createAppBridgeFactory now builds each app's `_meta.ui.csp` host-side via the sandbox-csp library (approveCspSources → buildSandboxCspPolicy) and wraps the untrusted widget HTML with wrapSandboxedHtml so the CSP <meta> is always the first <head> child before the HTML reaches the sandboxed iframe. The approved (post-filter) csp + permissions are echoed back via hostCapabilities.sandbox so the view sees what was granted. The csp is no longer sent inline through sendSandboxResourceReady — it is enforced via the wrapped meta. - A per-app copy of HOST_CAPABILITIES prevents the sandbox echo from mutating the shared constant. - New optional `onResourceError` dep on AppBridgeFactoryDeps routes resource read/post failures to the caller (also always console.error'd) instead of leaving a silent blank frame. Closes #1566 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: full hostContext delivery + live host-context-changed updates - createAppBridgeFactory now seeds the full hostContext via snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES): theme, styles (Mantine design tokens), displayMode ("inline"), availableDisplayModes, and containerDimensions — replacing the theme-only seed. The local currentTheme helper is dropped in favor of hostContext.ts. - AppRenderer gains `displayMode` and `containerRef` props and pushes live host-context changes via the SDK's sendHostContextChange (partial host-context-changed notifications), never a hand-maintained snapshot: - theme + styles via a MutationObserver on <html data-mantine-color-scheme>, - containerDimensions via a ResizeObserver on the host container (or the iframe fallback), gated on `initialized`, skipping 0x0 and value-equal repeats, plus a one-shot push on initialize once layout settles, - displayMode whenever the prop changes (Maximize/Restore), gated on init. Adds AppRenderer host-context tests (theme flip incl. resolved styles, displayMode push + init gate, container-dimensions on init and resize, observer teardown) and updates the factory + story mocks for the new hostContext shape and sendHostContextChange method. Closes #1567 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: honor ui/notifications/size-changed and ui/request-display-mode AppRenderer: - New `onSizeChange` prop forwards the view's ui/notifications/size-changed reports (via the bridge `sizechange` event). - New `onRequestDisplayMode` prop backs an `onrequestdisplaymode` bridge handler that returns the mode the host actually applied; with no handler the request is declined by returning the current host-side mode (falling back to inline). AppsScreen: - Adds a host-controlled RendererContainer (measured for containerDimensions) with an inner box sized by the view-reported height (`appHeight`), so host→view container size and view→host size-changed don't couple. - `handleSizeChange` records the reported height (ignored while maximized); `displayMode` is derived from the maximize toggle and pushed to the view; `handleRequestDisplayMode` filters against HOST_AVAILABLE_DISPLAY_MODES (declining e.g. "pip") and maps fullscreen↔inline onto the maximize state. Adds AppRenderer tests (size forwarding, request-display-mode apply/decline/ fallback) and AppsScreen tests (frame sizing on report, no-height ignore, fullscreen/restore/decline via request-display-mode). Story mocks gain sendHostContextChange/addEventListener. Closes #1568 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: honor ui/download-file with confirmation and http(s) allowlist createAppBridgeFactory now advertises the `downloadFile` host capability and attaches an `ondownloadfile` handler: - Requires a host-mediated confirmation (window.confirm) listing the requested file(s); a declined prompt or an empty payload returns isError without acting. - Inline EmbeddedResources (text or base64 blob) are written to disk via the shared downloadBlob object-URL anchor; base64 is decoded via base64ToBytes. - ResourceLinks are opened in a new tab, restricted to http(s) by the shared isHttpUrl allowlist (javascript:/data:/file:/malformed are rejected). - Labels shown in the confirmation are sanitized (control + format chars stripped, length clamped) so a server-supplied filename/URI can't inject newlines or reflow the prompt. - Partial-batch success is surfaced (isError only when nothing downloaded), with skipped items warned. Adds factory tests for the full download surface (text/blob/link, decline, empty, throw, non-http rejection, label sanitization/clamping, partial batch). Closes #1569 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: surface ui/message and app logging in panels below the running app AppRenderer: - New `onMessage` prop backs an `onmessage` bridge handler that surfaces the view's ui/message content and returns the spec-required empty result; with no handler the submission is declined (isError). - New `onLog` prop forwards MCP `notifications/message` log entries via the bridge `loggingmessage` event, honoring the advertised `logging` capability. AppsScreen: - Adds `messages`/`appLogs` state (cleared with the reported height on select/open/close/back via resetAppChannels) and `handleMessage`/`handleLog`. - Renders a "Messages from app" panel (data-testid="apps-messages", reusing ContentViewer) and a default-expanded collapsible "App logs" panel (data-testid="apps-logs", reusing LogLevelBadge) with logger names and a Clear button, both as pinned panels below the running widget. Adds AppRenderer tests (log forward + no-handler no-throw, ui/message route/empty-result/decline) and AppsScreen tests (message log render + empty result + clear-on-close, log panel expand/collapse/clear). Closes #1570 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: stage partial input — replay tool-input-partial before final tool input AppRenderer: - New `partialInputs` prop (ordered fragments), snapshotted into pendingPartials at bridge-build time (read via a ref so prop churn never rebuilds the iframe) and cleared on dispose. flushPending replays them via bridge.sendToolInputPartial BEFORE the complete tool-input, per spec. AppsScreen: - New `partialStages` state + `handleStagePartialInput` (snapshots current form values). resetAppChannels gains a `keepPartials` option so Open App preserves the staged fragments (they're consumed by the renderer) while select/close/ back clear them. - Adds a "Stage partial input" control (fielded apps only) with a staged count and "Clear staged", wired to AppRenderer's `partialInputs`. Adds AppRenderer tests (ordered replay before tool-input; none when omitted) and AppsScreen tests (stage/count/clear gated on fields; staged partials survive Open and are replayed). Closes #1571 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: app lifecycle status + machine-readable data attributes on the Apps screen AppRenderer: - New AppRendererStatus ("loading" | "ready" | "error") + onAppStatusChange callback. Fires "loading" at the start of every (re)build, "ready" on the view's initialized signal, and "error" on a factory throw/rejection. AppsScreen: - appStatus/appError state; handleAppError captures the reason locally and forwards to the parent onError. resetAppChannels clears both. - Surfaces the contract as data-testid="apps-form" carrying data-app-status and data-app-error, renders an error panel (data-testid="apps-error") in place of the blank frame, and tags the stage button data-testid="apps-stage". - AppDetailPanel's Open App button gains data-testid="open-app". App.tsx: - Wires the bridge factory's onResourceError to a red toast so a malformed/404 UI resource is no longer console-only (the renderer separately drives data-app-status, so a driver times out on never-reaching-ready and reads it). Documents the data-testid/data-* automation contract in clients/web/README.md. Adds AppRenderer status-transition tests and AppsScreen status/error-panel tests. Closes #1572 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: warn when server sandbox drops allow-scripts + revert incidental lockfile churn Addresses the claude[bot] review on PR #1650: - sandbox_proxy.html: log a console.warn when a server-supplied `sandbox` value lacks `allow-scripts` after the `allow-same-origin` strip, so a fail-closed (app-never-runs) sandbox is diagnosable instead of silent. - Restore clients/{cli,launcher,tui,web}/package-lock.json to their v2/main state — the earlier churn (@emnapi/@napi-rs optional deps, a dropped version field) was incidental `npm install` regeneration unrelated to the sandbox change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: document CSP-vs-permissions filtering asymmetry (PR #1652) Address the claude[bot] review's finding #2: add a comment explaining why `csp` is injection-filtered (approveCspSources) while `permissions` is passed through verbatim — permissions is a structured boolean object consumed only by the sandbox proxy's buildAllowAttribute() (fixed key→token mapping), so it never reaches the iframe sandbox/allow attribute as raw text. No logic change. Also merges v2/1565-sandbox-hardening (review fixes) up the stack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * test: lock in per-app HOST_CAPABILITIES isolation (PR #1652 round 2) Add a test asserting the shared HOST_CAPABILITIES.sandbox stays undefined after a sandboxready run sets hostCapabilities.sandbox — guards against a silent regression if the per-app spread copy were ever dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: gate theme observer on initialized + document seed/dep assumptions (PR #1654) Address the claude[bot] review: - Finding #1 (applied): gate the theme+styles MutationObserver on the view's `initialized` signal, matching the container/displayMode pushes — a theme flip in the construction→handshake window no longer races ui/initialize. The factory seeds the construction-time theme, and the first post-init flip carries the current value. Added tests: pre-init flip is dropped; the live flip test now completes the handshake first. - Finding #2 (applied, comment): note in snapshotHostContext that the `displayMode: "inline"` seed assumes inline-at-open (AppsScreen always opens inline; #1568's displayMode push carries transitions). - Finding #4 (applied, comment): explain why `containerRef` is a build-effect dep yet doesn't force a rebuild (sameInputs ignores it; read lazily). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: guard 0-height size reports, distinct RendererFrame, shared payload type (PR #1656) Address the claude[bot] review: - #1 (applied): handleSizeChange ignores a non-positive height — a view's ResizeObserver can transiently fire 0 pre-layout/teardown, which would collapse the frame; mirrors AppRenderer's 0×0 skip. Added a test. - #2 (applied): reinstate a distinct `RendererFrame` constant for the inner content-sized box so its role reads clearly vs. the outer RendererContainer. - #3 (declined + comment): keep carrying `appHeight` across maximize→restore — clearing it would flash the frame to full-card height until the next size-changed; the carry-over restores at the prior size. Documented. - #4 (applied): type handleSizeChange's param as AppBridgeEventMap["sizechange"] so the payload shape has a single source of truth with AppRenderer's prop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: distinguish links, cap batch size, comment truncation (PR #1657) Address the claude[bot] review: - #1 (applied): the confirmation now reads "download or open N item(s)" and resource_links are prefixed "↗" in the summary, so a link that opens in a tab is distinguishable from an embedded file that saves to disk. - #2 (applied): cap a single ui/download-file batch at MAX_DOWNLOAD_ITEMS (20); an oversized batch is rejected (isError) before the prompt, with a warn. - #3 (declined): can't check window.open's return value to detect a blocked popup — we pass `noopener`, and per spec window.open returns null even on a successful noopener open, so null can't distinguish blocked from succeeded. - #4b (applied, comment): note the start-of-label truncation is intentional so a link's scheme+host stays visible for the trust decision. Adds tests: oversized batch rejected without confirming/acting; resource-link ↗ prefix in the summary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: skip embedded resource with neither text nor blob (PR #1657 round 2) Address the round-2 observation: an untrusted EmbeddedResource carrying neither `blob` nor a string `text` previously produced `new Blob([undefined])` — a file containing the literal text "undefined", counted as success. Now such an item is skipped (returns false, like a rejected link), so isError reflects it. Added a test asserting no object URL is created and the batch reports isError. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: type-honest formatLogData, cap channels, log-panel a11y (PR #1658) Address the claude[bot] review: - #1 (applied): formatLogData coalesces JSON.stringify(undefined) → "" so the `: string` return type is honest for a data-less log. - #2 (applied): cap retained messages/logs at MAX_APP_CHANNEL_ENTRIES (500) via appendCapped — a chatty widget no longer grows the panels/DOM without bound between Clear/close; oldest entries drop. - #3 (declined): keep the Messages panel without a Clear/collapse — messages are sparser and already cleared on close/switch; keeping it simple. - #4 (applied): the App-logs toggle now has aria-controls pointing at the Collapse region (id="apps-logs-region") for assistive tech. Adds tests: no-data log renders (coalesce branch), 501 logs cap at 500 (oldest dropped), aria-controls assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: stable id key for Messages panel (PR #1658 round 2) Address the round-2 nit: give AppMessage a stable `id` (assigned via nextMessageIdRef in handleMessage) and key the Messages panel on it instead of the array index — so the appendCapped front-drop can't renumber keys once a run exceeds the cap, matching AppLogEntry's treatment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: note partialStages intentionally survives handleOpen (PR #1659) Address the claude[bot] review's observation #2: add a comment at the handleOpen call site clarifying that partialStages is intentionally NOT cleared there — AppRenderer snapshots the fragments into its own pendingPartialsRef at build time, and the staging UI only renders while not running, so the surviving state is invisible until the next reset drains it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: align error-panel docs with behavior + gate data-app-error on running (PR #1660) Address the claude[bot] review: - #1 (applied, docs): the error panel renders BELOW the (blank) frame, not in place of it — the renderer stays mounted so a factory error still shows the reason alongside the iframe (gating the frame off on error would drop the auto-launched iframe an integration test relies on). Reworded the README row and the test comment to say "below the frame"; added a code comment. - #2 (applied): gate data-app-error on `running` (like data-app-status) so the two attributes can't desync if a future path sets an error without running. - #3 (no change): resource-read failures staying at data-app-status="loading" (toast only) is intentional and documented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6722438428
commit
0ddb79203a
1 changed files with 2618 additions and 120 deletions
|
|
@ -4,14 +4,28 @@ import {
|
|||
getToolUiResourceUri,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type {
|
||||
McpUiDisplayMode,
|
||||
McpUiHostCapabilities,
|
||||
McpUiResourceMeta,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import type {
|
||||
EmbeddedResource,
|
||||
Implementation,
|
||||
ReadResourceResult,
|
||||
ResourceLink,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
approveCspSources,
|
||||
buildSandboxCspPolicy,
|
||||
wrapSandboxedHtml,
|
||||
} from "../../../lib/sandbox-csp";
|
||||
import {
|
||||
downloadBlob,
|
||||
fileNameFromUri,
|
||||
isHttpUrl,
|
||||
} from "../../../lib/downloadFile";
|
||||
import { snapshotHostContext } from "./hostContext";
|
||||
import type { BridgeFactory } from "./AppRenderer";
|
||||
|
||||
/**
|
||||
|
|
@ -27,52 +41,39 @@ export const HOST_INFO: Implementation = {
|
|||
* Capabilities the inspector host offers a running MCP App. Constructed WITH an
|
||||
* MCP client (see {@link createAppBridgeFactory}), so the bridge auto-forwards
|
||||
* tools/resources/prompts to the view; we only declare the host-side features
|
||||
* we actually back: external links (handled below), tool/resource list-change
|
||||
* forwarding, and logging passthrough.
|
||||
* we actually back: external links and file downloads (both handled below),
|
||||
* tool/resource list-change forwarding, and logging passthrough.
|
||||
*/
|
||||
export const HOST_CAPABILITIES: McpUiHostCapabilities = {
|
||||
openLinks: {},
|
||||
downloadFile: {},
|
||||
serverTools: { listChanged: true },
|
||||
serverResources: { listChanged: true },
|
||||
logging: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Display modes the inspector host supports, advertised in the handshake
|
||||
* hostContext (`availableDisplayModes`). AppsScreen renders an app either
|
||||
* inline within its layout card or maximized to fill the screen, so only those
|
||||
* two are offered.
|
||||
*/
|
||||
export const HOST_AVAILABLE_DISPLAY_MODES: readonly McpUiDisplayMode[] = [
|
||||
"inline",
|
||||
"fullscreen",
|
||||
];
|
||||
|
||||
export interface AppBridgeFactoryDeps {
|
||||
/** The connected SDK client to back the bridge, or null when disconnected. */
|
||||
getClient: () => Client | null;
|
||||
/** Reads a UI resource (resources/read) and returns the SDK result. */
|
||||
readResource: (uri: string) => Promise<ReadResourceResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host theme from the DOM at bridge-build time. Mantine writes the
|
||||
* resolved color scheme to `<html data-mantine-color-scheme>`. Reading it here
|
||||
* (rather than capturing React state in the factory deps) keeps the factory's
|
||||
* identity stable across theme toggles — the AppRenderer treats a new factory
|
||||
* identity as "rebuild the bridge", which would reload a running app's iframe on
|
||||
* every theme flip. The theme is read once per bridge build (the value at open
|
||||
* time); pushing live theme updates to an already-open app would need an
|
||||
* AppBridge.setHostContext follow-up.
|
||||
*
|
||||
* The attribute is only ever `"light"` or `"dark"` — Mantine resolves
|
||||
* `defaultColorScheme="auto"` to the system value before paint and never writes
|
||||
* `"auto"` here, so no `auto` branch is needed. The matchMedia fallback only
|
||||
* covers the attribute being absent (e.g. a hydration race).
|
||||
*/
|
||||
function currentTheme(): "light" | "dark" {
|
||||
if (typeof document !== "undefined") {
|
||||
const attr = document.documentElement.getAttribute(
|
||||
"data-mantine-color-scheme",
|
||||
);
|
||||
if (attr === "dark" || attr === "light") return attr;
|
||||
}
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
) {
|
||||
return "dark";
|
||||
}
|
||||
return "light";
|
||||
/**
|
||||
* Called when reading or posting the UI resource fails after the sandbox
|
||||
* proxy is ready. Without this the user is left staring at a blank-but-live
|
||||
* frame; the error is also always console.error'd.
|
||||
*/
|
||||
onResourceError?: (err: Error) => void;
|
||||
}
|
||||
|
||||
/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */
|
||||
|
|
@ -92,6 +93,86 @@ function extractHtmlAndMeta(result: ReadResourceResult): {
|
|||
throw new Error("UI resource has no text (HTML) content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64-encoded blob resource into bytes for download. Allocates the
|
||||
* backing store explicitly so the return type is `Uint8Array<ArrayBuffer>`
|
||||
* (Blob accepts `ArrayBufferView<ArrayBuffer>`, not the wider
|
||||
* `ArrayBufferLike`).
|
||||
*/
|
||||
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on items honored from a single `ui/download-file` request. One
|
||||
* user approval must not fan out into an unbounded number of saves / new tabs.
|
||||
*/
|
||||
const MAX_DOWNLOAD_ITEMS = 20;
|
||||
|
||||
/**
|
||||
* Strip control characters and clamp length so a server-supplied filename or
|
||||
* URI cannot forge additional lines in the confirmation prompt or push the
|
||||
* real summary off-screen.
|
||||
*/
|
||||
function sanitizeDownloadLabel(label: string): string {
|
||||
// Cc = control chars (newlines, escape, etc.); Cf = format chars (bidi
|
||||
// overrides, zero-width joiners, BOM) — both can spoof or reflow the prompt.
|
||||
const cleaned = label.replace(/[\p{Cc}\p{Cf}]+/gu, " ").trim();
|
||||
// Keep the START of an over-long label: for a link that preserves the
|
||||
// scheme+host, which is what the user needs to make a trust decision.
|
||||
return cleaned.length > 80 ? cleaned.slice(0, 77) + "..." : cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for a download item, shown in the confirmation prompt.
|
||||
* `forPrompt` marks a resource_link with a leading "↗" so the user can tell a
|
||||
* link that will *open in a tab* apart from an embedded file that will *save to
|
||||
* disk* — the two item kinds share this "download" confirmation.
|
||||
*/
|
||||
function describeDownloadItem(
|
||||
item: EmbeddedResource | ResourceLink,
|
||||
forPrompt = false,
|
||||
): string {
|
||||
if (item.type === "resource_link") {
|
||||
return forPrompt ? `↗ ${item.uri}` : item.uri;
|
||||
}
|
||||
return fileNameFromUri(item.resource.uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download for a single MCP resource item. Inline
|
||||
* {@link EmbeddedResource}s (text or base64 blob) are written via
|
||||
* {@link downloadBlob}. A {@link ResourceLink} is *opened* in a new tab —
|
||||
* the inspector does not fetch the URL to disk itself, since the link may
|
||||
* require auth or content negotiation the browser can supply but we cannot.
|
||||
* Returns false when the item carries nothing downloadable or its URI is
|
||||
* rejected by the http(s)-only allowlist.
|
||||
*/
|
||||
function downloadResourceItem(item: EmbeddedResource | ResourceLink): boolean {
|
||||
if (item.type === "resource_link") {
|
||||
const parsed = isHttpUrl(item.uri);
|
||||
if (!parsed) return false;
|
||||
window.open(parsed.href, "_blank", "noopener,noreferrer");
|
||||
return true;
|
||||
}
|
||||
const resource = item.resource;
|
||||
// The types forbid it, but the payload is untrusted: a resource with neither
|
||||
// `blob` nor a string `text` has nothing to save. Skip it (like a rejected
|
||||
// link) rather than writing a file containing the literal text "undefined".
|
||||
if (!("blob" in resource) && typeof resource.text !== "string") return false;
|
||||
const blob =
|
||||
"blob" in resource
|
||||
? new Blob([base64ToBytes(resource.blob)], {
|
||||
type: resource.mimeType ?? "application/octet-stream",
|
||||
})
|
||||
: new Blob([resource.text], { type: resource.mimeType ?? "text/plain" });
|
||||
downloadBlob(fileNameFromUri(resource.uri), blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link BridgeFactory} the AppRenderer uses to bring a sandbox
|
||||
* iframe to life. For each mounted iframe + tool it:
|
||||
|
|
@ -99,9 +180,13 @@ function extractHtmlAndMeta(result: ReadResourceResult): {
|
|||
* 1. constructs a host-side {@link AppBridge} over the SDK client (so the view
|
||||
* can call tools/resources/prompts directly),
|
||||
* 2. on the sandbox proxy's `sandboxready` signal, reads the tool's UI
|
||||
* resource and pushes its HTML + sandbox/permissions into the inner iframe,
|
||||
* resource and pushes its HTML + sandbox/permissions/CSP into the inner
|
||||
* iframe, echoing the applied sandbox config back via hostCapabilities,
|
||||
* 3. handles `openLinks` by opening http(s) URLs in a new tab,
|
||||
* 4. connects a {@link PostMessageTransport} to the iframe and returns the
|
||||
* 4. handles `downloadFile` by confirming with the user, then writing each
|
||||
* embedded resource to disk via an object-URL anchor (resource links are
|
||||
* opened in a new tab),
|
||||
* 5. connects a {@link PostMessageTransport} to the iframe and returns the
|
||||
* live bridge.
|
||||
*
|
||||
* Host-initiated tool input/result are pushed separately through the renderer's
|
||||
|
|
@ -123,15 +208,21 @@ export function createAppBridgeFactory(
|
|||
throw new Error("Cannot render MCP App: sandbox iframe has no window.");
|
||||
}
|
||||
|
||||
const bridge = new AppBridge(client, HOST_INFO, HOST_CAPABILITIES, {
|
||||
hostContext: { theme: currentTheme() },
|
||||
// Per-app copy so the approved-sandbox echo (set on sandboxready below)
|
||||
// never mutates the shared HOST_CAPABILITIES constant — each app may
|
||||
// declare its own csp/permissions.
|
||||
const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES };
|
||||
const bridge = new AppBridge(client, HOST_INFO, hostCapabilities, {
|
||||
hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES),
|
||||
});
|
||||
|
||||
// The double-iframe proxy posts `sandboxready` once it can receive content.
|
||||
// Read the tool's UI resource and hand its HTML (plus any sandbox/permission
|
||||
// hints from the resource _meta) to the inner sandboxed iframe. Swallow
|
||||
// failures: a read error leaves an empty (but live) app frame rather than
|
||||
// tearing down the bridge mid-handshake.
|
||||
// hints from the resource _meta) to the inner sandboxed iframe. A failure
|
||||
// here is the case a developer most needs surfaced (their app's resource is
|
||||
// erroring or malformed) — log it and report it via deps.onResourceError so
|
||||
// the host can show something better than a blank frame. The bridge stays
|
||||
// live so a retry path remains possible.
|
||||
bridge.addEventListener("sandboxready", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
|
|
@ -139,13 +230,42 @@ export function createAppBridgeFactory(
|
|||
if (!uri) return;
|
||||
const result = await deps.readResource(uri);
|
||||
const { html, meta } = extractHtmlAndMeta(result);
|
||||
await bridge.sendSandboxResourceReady({
|
||||
html,
|
||||
// Build the per-app CSP host-side: filter the requested sources to
|
||||
// ones the host accepts, render the policy string, and wrap the
|
||||
// app's HTML in a fixed shell whose first <head> child is the CSP
|
||||
// <meta>. The proxy assigns that document to srcdoc verbatim — it
|
||||
// never parses the untrusted bytes — so the policy is guaranteed to
|
||||
// apply before any app content loads. The approved (post-filter) csp
|
||||
// is what we echo back via hostCapabilities.sandbox so the view sees
|
||||
// what was granted, not what it asked for. Set before
|
||||
// sendSandboxResourceReady: the view only sends ui/initialize once it
|
||||
// has the HTML, so the bridge reflects this in the initialize result.
|
||||
const approvedCsp = approveCspSources(meta?.csp);
|
||||
// NOTE on the CSP-vs-permissions asymmetry: `csp` is injection-filtered
|
||||
// by approveCspSources because its sources are interpolated into the
|
||||
// CSP <meta> content string. `permissions` is NOT filtered here — it is
|
||||
// a structured object (camera/microphone/geolocation/clipboardWrite
|
||||
// booleans), and its only consumer is the sandbox proxy's
|
||||
// buildAllowAttribute(), which maps each known key to a fixed
|
||||
// Permissions-Policy token and ignores anything else. Untrusted values
|
||||
// therefore can't reach the iframe `sandbox`/`allow` attribute as raw
|
||||
// text (that layer, and the allow-same-origin strip, is owned by the
|
||||
// sandbox-hardening work in #1565), so no source-style allowlist applies.
|
||||
hostCapabilities.sandbox = {
|
||||
permissions: meta?.permissions,
|
||||
csp: approvedCsp,
|
||||
};
|
||||
await bridge.sendSandboxResourceReady({
|
||||
html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)),
|
||||
permissions: meta?.permissions,
|
||||
csp: meta?.csp,
|
||||
});
|
||||
} catch {
|
||||
/* read/post failed (or bridge closed) — leave the frame empty */
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
console.error(
|
||||
"[mcp-app] failed to load UI resource into sandbox:",
|
||||
error,
|
||||
);
|
||||
deps.onResourceError?.(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
@ -158,6 +278,54 @@ export function createAppBridgeFactory(
|
|||
return { isError: true };
|
||||
};
|
||||
|
||||
// The view asks the host to save MCP resource contents to disk (sandboxed
|
||||
// iframes can't download directly). Confirm with the user first — the spec
|
||||
// requires a host-mediated confirmation — then write each item out. A
|
||||
// declined prompt, an empty/oversized payload, or a thrown error all
|
||||
// return isError.
|
||||
bridge.ondownloadfile = async ({ contents }) => {
|
||||
if (!Array.isArray(contents) || contents.length === 0) {
|
||||
return { isError: true };
|
||||
}
|
||||
// Sanity cap: one approval must not fan out into an unbounded number of
|
||||
// downloads / new tabs. A buggy or hostile app requesting hundreds of
|
||||
// items is rejected outright rather than acted on.
|
||||
if (contents.length > MAX_DOWNLOAD_ITEMS) {
|
||||
console.warn(
|
||||
`[mcp-app] refusing download batch of ${contents.length} items (max ${MAX_DOWNLOAD_ITEMS})`,
|
||||
);
|
||||
return { isError: true };
|
||||
}
|
||||
const summary = contents
|
||||
.map((item) => sanitizeDownloadLabel(describeDownloadItem(item, true)))
|
||||
.join("\n");
|
||||
const approved = window.confirm(
|
||||
`This MCP App wants to download or open ${contents.length} item(s):\n\n${summary}`,
|
||||
);
|
||||
if (!approved) return { isError: true };
|
||||
let succeeded = 0;
|
||||
const skipped: string[] = [];
|
||||
for (const item of contents) {
|
||||
try {
|
||||
if (downloadResourceItem(item)) {
|
||||
succeeded++;
|
||||
} else {
|
||||
skipped.push(describeDownloadItem(item));
|
||||
}
|
||||
} catch (err) {
|
||||
skipped.push(describeDownloadItem(item));
|
||||
console.error("[mcp-app] download item failed:", err);
|
||||
}
|
||||
}
|
||||
if (skipped.length > 0) {
|
||||
console.warn(
|
||||
`[mcp-app] ${skipped.length} of ${contents.length} download item(s) skipped:`,
|
||||
skipped,
|
||||
);
|
||||
}
|
||||
return { isError: succeeded === 0 };
|
||||
};
|
||||
|
||||
const transport = new PostMessageTransport(targetWindow, targetWindow);
|
||||
await bridge.connect(transport);
|
||||
return bridge;
|
||||
|
|
|
|||
Loading…
Reference in a new issue