inspector/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts
Cliff Hall a82a70f4f5
refactor(web): sharpen the src/lib vs src/utils rule and move misfiled modules (#1784)
* refactor(web): sharpen src/lib vs src/utils rule and move misfiled modules (#1776)

Codify the empirical rule the web client already followed loosely — utils =
pure functions that compute; lib = things that instantiate, adapt, or touch
the environment — in AGENTS.md and clients/web/README.md, and move the modules
that sat on the wrong side of it.

Moves (source + co-located test):
- lib/sandbox-csp -> utils (pure CSP-string construction; type-only import)
- utils/browserTabVisibility -> lib (registers visibilitychange listeners)
- utils/oauthResume -> lib (reads/writes sessionStorage)
- utils/clearServerOAuthState -> lib (drives live InspectorClient / OAuthStorage)

Borderline audits (kept in place): pendingReauth (pure type declarations),
deepLink / correlateTransportErrors / mcpNetworkHeaders / oauthUx / oauthFlow /
oauthNetworkPhase (pure transforms / type-only core imports) stay in utils;
downloadFile stays whole in lib (dominant side is DOM side effects).

Also relocate the lone src/test/lib/downloadFile.test.ts straggler beside its
source at src/lib/downloadFile.test.ts, and document the test-placement rule
(side-by-side by default; src/test/ only for core/ tests, the integration
project, and shared scaffolding).

Pure move — no behavior change; no build/coverage config keys off the boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* refactor(web): address review — fix stale paths, move customHeaders, one-way arrows (#1776)

Responds to the @claude review on PR #1784:

1. Stale path references (fixed): update the four in-repo pointers left at
   pre-move paths — specification/v2_auth_mid_session.md (oauthResume,
   browserTabVisibility → lib/), and the two load-bearing sandbox security
   comments (sandbox_proxy.html, sandbox-controller.ts → src/utils/sandbox-csp).

2. lib/types/customHeaders (moved): it's pure (types + pure transforms), the
   rule's clearest counterexample. Moved lib/types/customHeaders.{ts,test.ts} →
   utils/ (no external importers; only its own test) and removed the
   undocumented lib/types/ subdir. Documented that utils/ is the home for pure
   shared domain types + their constructors — no separate types/ bucket.

3. One-way arrows (item 3): declare OAuthResumeAuthKind in utils/pendingReauth
   and re-export it from lib/oauthResume, so the only cross-directory arrow now
   points lib → utils. Codified that invariant in AGENTS.md.

Wording nit: AGENTS.md + README now state diagnostic logging
(console.warn/error) doesn't count as a side effect for the utils rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs: address review round 2 — stale customHeaders refs, rule wording (#1776)

Doc-only follow-up to the round-2 @claude review on PR #1784 (no code changed):

1. Stale paths (same class as round-1 item 1): the customHeaders move left
   three references to the deleted lib/types/customHeaders.ts path in
   specification/ (v2_ux_interfaces_phase_0_4_inventory.md,
   v2_ux_interfaces_plan.md ×2) — updated to utils/customHeaders.ts.

2. customHeaders anchor: note in AGENTS.md that its shape is staged for
   ServerSettingsForm (per v2_ux_interfaces_plan.md), so its zero-importer
   state reads as intentional rather than rot; kept as the no-types/-bucket
   illustration and led the anchor list with load-bearing modules.

3. Type-only-import clause: split the conflated clause — a type-only import
   (pendingReauth) and re-exporting core's pure functions/constants
   (oauthUx/oauthFlow) are distinct reasons to stay in utils; what makes a
   module lib is wrapping core's stateful runtime. Fixed in both AGENTS.md
   and the web README.

Cosmetic: re-padded the v2_auth_mid_session.md table row to column width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs: address review round 3 — scope the "no types/ bucket" claim (#1776)

Doc-only follow-up to the round-3 @claude review on PR #1784 (no code changed):

1. "no separate types/ bucket" overreached: clients/web/src/types/ does exist
   (navigation.ts + react-syntax-highlighter .d.ts ambient stubs wired via
   tsconfig.app.json paths). Scoped the claim in both AGENTS.md and the web
   README to "no types/ sub-bucket INSIDE lib/utils", and noted src/types/ is
   for ambient .d.ts stubs, not domain types.

2. README example list drifted from AGENTS.md's round-2 load-bearing anchors —
   realigned it (inspectorTabs/mcpNetworkHeaders, dropped the zero-importer
   customHeaders from the headline list).

3. Clarified v2_ux_interfaces_plan.md:185 so the v2 path reads as the copy's
   destination, not as the v1.5 source location.

Follow-up filed as #1785 (board Todo): src/types/navigation.ts InspectorTab is
dead and duplicates utils/inspectorTabs.ts — kept out of this pure-move PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs: address review round 4 — reconcile src/types/ claim with #1785 (#1776)

Doc-only follow-up to the round-4 @claude review on PR #1784 (no code changed):

The round-3 scope fix overshot: it asserted src/types/ holds "not domain types
at all", but the dead navigation.ts InspectorTab is a plain-.ts domain type
still living there, and specification/v2_ux_interfaces_plan.md:181-182 actively
prescribes placing InspectorTab there — so AGENTS.md and that spec doc gave
opposite guidance about the same directory.

- AGENTS.md + web README: softened to "not the place for new domain types"
  and added a parenthetical that the one lingering domain type (dead
  navigation.ts) is tracked for removal in #1785; until then a pure domain
  type belongs in utils/, not src/types/.
- v2_ux_interfaces_plan.md:181: marked the tab-enum placement guidance as
  superseded by #1785 (live type is InspectorTabId in utils/inspectorTabs.ts).

Self-heals on merge of #1785.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs: address review round 5 — fix garbled #1785 clause, restructure utils rule (#1776)

Doc-only follow-up to the round-5 @claude review on PR #1784 (no code changed):

1. Grammar (item 1): the round-4 #1785 clause read "superseding [#1785] will
   remove it" — a participle with no subject. Reworded to "the dead
   navigation.ts InspectorTab, which #1785 will remove".

2. Readability (item 2): the src/utils/ bullet had accreted into a single
   ~1,400-char blob across five review rounds. Promoted the carve-outs to
   scannable sub-bullets (Domain types / Diagnostic logging / Importing from
   core) and lifted the src/types/ note out to its own line, since it's a
   statement about a sibling directory, not about utils.

AGENTS.md only; the README's parallel sentence was already clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs+config: address review round 6 — correct the coverage-glob claim (#1776)

Responds to the round-6 @claude review on PR #1784:

1. "no build/coverage glob" was factually wrong (should-fix): the web coverage
   include in clients/web/vite.config.ts DOES enumerate src/lib/** and
   src/utils/**. The conclusion (a move between them is coverage-neutral)
   survives, but the reason was wrong. Reworded both AGENTS.md and the web
   README to say nothing *enforces* the boundary — no path alias, and the
   include lists both dirs so a move is coverage-neutral — plus the important
   whitelist caveat: a module outside components/lib/utils/server falls out of
   the ≥90 gate entirely.

2. Removed a pre-existing duplicate glob in vite.config.ts (src/lib/**/*.{ts,tsx}
   appeared twice, lines 161 and 163). Inert (vitest de-dupes) but it sat three
   lines from the config the doc now describes.

Nits: gave the README bullet the same round-5 sub-bullet restructure as
AGENTS.md, and made its #1785 reference a real markdown link (bare #N doesn't
autolink in rendered repo markdown).

npm run ci green (config change re-verified through coverage + smokes + Storybook).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 22:24:30 -04:00

343 lines
14 KiB
TypeScript

import {
AppBridge,
PostMessageTransport,
getToolUiResourceUri,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type {
McpUiDisplayMode,
McpUiHostCapabilities,
McpUiResourceMeta,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Client } from "@modelcontextprotocol/client";
import type {
EmbeddedResource,
Implementation,
ReadResourceResult,
ResourceLink,
} from "@modelcontextprotocol/client";
import {
approveCspSources,
buildSandboxCspPolicy,
wrapSandboxedHtml,
} from "../../../utils/sandbox-csp";
import {
downloadBlob,
fileNameFromUri,
isHttpUrl,
} from "../../../lib/downloadFile";
import { snapshotHostContext } from "./hostContext";
import type { BridgeFactory } from "./AppRenderer";
/**
* Host identity advertised to MCP Apps during the bridge handshake. Static —
* the value is informational (shown by some apps), not a protocol version.
*/
export const HOST_INFO: Implementation = {
name: "MCP Inspector",
version: "2.0.0",
};
/**
* 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 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>;
/**
* 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). */
function extractHtmlAndMeta(result: ReadResourceResult): {
html: string;
meta: McpUiResourceMeta | undefined;
} {
for (const content of result.contents) {
const text = (content as { text?: unknown }).text;
if (typeof text === "string") {
return {
html: text,
meta: content._meta as McpUiResourceMeta | undefined,
};
}
}
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:
*
* 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/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. 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
* imperative handle (see `AppRenderer`), gated on the view's `initialized`
* event. The factory throws when no client is connected; AppRenderer routes
* that to its `onError` so the user sees a clear failure instead of a blank
* frame.
*/
export function createAppBridgeFactory(
deps: AppBridgeFactoryDeps,
): BridgeFactory {
return async (iframe, tool) => {
const client = deps.getClient();
if (!client) {
throw new Error("Cannot render MCP App: no connected MCP client.");
}
const targetWindow = iframe.contentWindow;
if (!targetWindow) {
throw new Error("Cannot render MCP App: sandbox iframe has no window.");
}
// 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 };
// ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both
// are runtime-compatible with v2's. Cast at this single construction
// boundary. TODO: drop when ext-apps#702 ships a v2 peer release.
const bridge = new AppBridge(
client as unknown as ConstructorParameters<typeof AppBridge>[0],
HOST_INFO as unknown as ConstructorParameters<typeof AppBridge>[1],
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. 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 {
const uri = getToolUiResourceUri(
tool as Parameters<typeof getToolUiResourceUri>[0],
);
if (!uri) return;
const result = await deps.readResource(uri);
const { html, meta } = extractHtmlAndMeta(result);
// 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,
});
} 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);
}
})();
});
bridge.onopenlink = async ({ url }) => {
if (/^https?:\/\//i.test(url)) {
window.open(url, "_blank", "noopener,noreferrer");
return { isError: false };
}
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;
};
}