/** * `attachViewHost` — the DOM glue around `view-host-protocol.ts`'s pure * machine (guuey#186 Gap 1). Framework-agnostic: any embedder with an * iframe can play the MCP Apps Host role with one call; `react.tsx` is one * convenience composition of exactly this, and a future full transcript * renderer composes the same primitive differently. * * This module is glue by DESIGN — every decision (what to answer, what to * refuse, when the negotiation window lapses) lives in the machine, which * the Node-only publish gate can test. What genuinely needs a browser is * this file's five moves: listen, identity-filter, post, time, detach — * covered by the monorepo's Playwright leg (`e2e/`), which the guuey-sdks * mirror deliberately does not carry. * * ## The identity filter (security invariant) * * Messages are matched by `event.source === frame.contentWindow`, NEVER by * `event.origin`: a view frame runs `sandbox="allow-scripts"` WITHOUT * `allow-same-origin`, so its origin is opaque — every message it posts * carries `"null"`, a value every other sandboxed frame on the page * shares, identifying nobody. The window handle is the only identity that * names the frame; a frame with no `contentWindow` matches nothing rather * than everything. Responses target `'*'` for the same reason: an opaque * origin is not addressable by name, and the handshake payload carries no * secrets — it is the result the spec defines for any host. * * Seeded from ggui's console `surface-host.ts` (donated, guuey#186 audit); * re-derived here against the pure machine + our own tests. */ import type { McpAppDismissReason } from "@ggui-ai/protocol/integrations/mcp-apps"; import { type CspViolationLike, type ViewCspDiagnosis, type ViewCspOrigins, type ViewHostPhase } from "./view-host-protocol.js"; import type { McpResourceReadResult } from "./reader.js"; import { type McpToolCallResult, type UiActionRequest } from "./action.js"; import type { McpUiResourcePayload } from "./block-ui.js"; import type { McpUiHostCapabilities, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import type { ViewHostInfo } from "./view-host-protocol.js"; /** * The slice of an `HTMLIFrameElement` this host actually touches — a * structural type so Node tests (and non-DOM hosts) can hand in a fake * without a single cast, same injection idiom as the package's readers and * relays. A real iframe element satisfies it as-is. */ export interface ViewFrameLike { readonly contentWindow: { postMessage(message: unknown, targetOrigin: string): void; } | null; readonly clientWidth: number; readonly clientHeight: number; } /** The inbound side: what this host needs from `window`. */ export interface ViewHostEvents { addEventListener(type: "message", listener: (event: { data: unknown; source: unknown; }) => void): void; removeEventListener(type: "message", listener: (event: { data: unknown; source: unknown; }) => void): void; } /** * Where CSP violations are observed — the EMBEDDING document (the frame's * blocked loads report there, not inside the opaque frame). Structural like * {@link ViewHostEvents}, injectable for tests. Default: `document`. */ export interface ViewCspEvents { addEventListener(type: "securitypolicyviolation", listener: (event: CspViolationLike) => void): void; removeEventListener(type: "securitypolicyviolation", listener: (event: CspViolationLike) => void): void; } /** * What a `ui/message` sink reports back so the host can answer the view * truthfully (guuey#1706): `{ delivered: true }` once the message BECAME a * turn — sent through the host's composer gate, now or after waiting behind an * in-progress turn — or `{ delivered: false, reason }` when it could not (the * chat is unavailable; it was dropped from the queue). */ export type UserMessageDelivery = { delivered: true; } | { delivered: false; reason: string; }; /** * The longest the host holds a view's `ui/message` answer (guuey#1706). * * A spec MCP App awaiting `app.sendMessage` times out at the MCP SDK's default * request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC` = 60 s in * `@modelcontextprotocol/sdk` 1.30.0), so a message still queued behind a long * turn is answered `{}` at this cap rather than timing the view out on a message * that WILL deliver. What that trades, exactly: a prior turn LONGER than 50 s * AND the session ending while the message is still queued leaves the view * holding `{}` for a message that never sends — visible to operators only (the * sink's console trace). `{ isError: true }` at the cap would be the other lie * (the view shows "failed", then the message sends when the turn ends). The * spec's result carries only `isError`, so "queued" has no word of its own. * ggui's views — today's only producer — post `ui/message` raw and never await * the answer, so the cap has no consumer there. */ export declare const MESSAGE_ANSWER_CAP_MS = 50000; export interface AttachViewHostConfig { /** * Capabilities to advertise in the initialize result. Default: `{}` — * correct for views that ride their own live channel (ggui views do; * they boot from their seeded envelope and talk to their pod directly), * and the honest floor for everyone else: advertise only what the * embedder implements. Exception: wiring {@link onCallTool} advertises * `serverTools` automatically — a wired relay IS the implementation — * and an explicit `hostCapabilities.serverTools` still wins. */ hostCapabilities?: McpUiHostCapabilities; /** Host identity for the initialize result. */ hostInfo?: ViewHostInfo; /** * Extra context merged over the derived defaults (locale from * `navigator`, container dimensions from the frame when it has laid * out — a 0×0 pre-layout reading is a lie the spec type shouldn't be * told). Keys given here win. */ hostContext?: McpUiHostContext; /** * The `tools/call` relay — a PRIVILEGE boundary, default off: with no * hook, the machine refuses `tools/call` in-band and advertises no * `serverTools`. Wire `createMcpUiActionRelay` (or your own) to let the * mounted view reach tools over a transport the embedder owns. The * request's `name`/`arguments` are VIEW-CONTROLLED wire data — the hook * owns allowlisting and validation (`createMcpUiActionRelay` does both). */ onCallTool?: (request: UiActionRequest) => Promise; /** * Sink for `ui/update-model-context` snapshots (guuey#335): the view * pushes its context state (e.g. ggui slot values) for the model's * FUTURE turns. Wiring it makes the host ANSWER the method (empty * result, per spec) instead of `method_not_supported`, and advertises * `updateModelContext` (an explicit `hostCapabilities` entry still * wins). The machine answers BEFORE delivery — a throwing sink never * leaves the view hanging. Producers treat this channel as a COMPLEMENT * to the action path (the choice itself rides tools/call / the live * channel), so a sink that only records is honest. */ onUpdateModelContext?: (params: { [key: string]: unknown; }) => void; /** * Sink for `ui/message` (guuey#422): the view hands the host role-user * content blocks to forward into the HOST's conversation (start a * turn). ggui's #440 post-turn doorbell depends on this — without it, * a successfully-relayed post-turn gesture dead-ends in their * 'cannot relay actions' latch. Wiring it advertises `message` * (text modality). * * The answer (guuey#1706): return a Promise of {@link UserMessageDelivery} * and the host answers the view from it — `{}` delivered, `{ isError: true }` * not, `{}` if still pending at {@link MESSAGE_ANSWER_CAP_MS}. A sink that * returns nothing, synchronously OR as a Promise that resolves to nothing * (an `async` sink that delivers itself), is answered `{}`: the pre-#1706 * answer, kept for existing embedders. A sink that throws or rejects, or a * resolution the host cannot read, is answered `{ isError: true }`. The view * is answered exactly once on every path. */ onUserMessage?: (params: { [key: string]: unknown; }) => void | Promise; /** * `ui/open-link` executor (guuey#522) — receives a URL the machine's * scheme wall already passed (absolute http/https only). The kit * default SURFACES the URL to the human and opens on their own click; * a custom executor should keep that posture (never navigate * sight-unseen). `false` opts the host OUT: the capability is not * advertised and the method refuses honestly. */ onOpenLink?: ((url: string) => void) | false; /** * The mounted resource's `ui://` locator — the scope stamped on every * relayed {@link UiActionRequest}. Required for the relay to fire; * `` fills it from the mount automatically. */ resourceUri?: string; /** * The `resources/read` relay — a PRIVILEGE boundary like * {@link onCallTool}, default off: with no hook, the machine refuses * `resources/read` in-band and advertises no `serverResources`. The hook * is structurally the SAME transport `createMcpUiResourceReader` * assembles over ({@link CreateMcpUiResourceReaderDeps.readResource}) — * a host with a locator reader wires the identical function here. Trust * rules ride the reader discipline (`reader.ts`): enforcement lives * INSIDE the transport; a miss, a deny, and a throw all answer the view * with the one `Resource not found` error (deny == miss — no oracle). */ onReadResource?: (uri: string) => Promise; /** * The view reported its content size (`ui/notifications/size-changed` — * spec notification). Whether and how to resize the frame is the * embedder's layout decision; `` is one wiring of * exactly this callback. */ onSizeChanged?: (size: { width?: number; height?: number; }) => void; /** * The view asked to be dismissed — protocol 0.18.0's `ggui:dismiss` * INTENT (ggui#1109 / guuey#1423): a dismiss gesture made with focus * INSIDE the card document, which the embedding page's own key handlers * can never hear. The card forwards; the EMBEDDER decides what dismiss * means on its surface (guuey's widget routes it to the same collapse its * host-document Escape takes; a transcript card may do nothing). Ignoring * is conformant, so this is an observer, not a capability: nothing is * advertised, nothing is answered, and absent → the intent is consumed * silently exactly as before 0.18.0. `reason` is the card's stated cause * and the set is extensibly-closed: it fires for EVERY shape the protocol * guard accepts, an unknown reason included — treat one as "dismiss * requested, cause unknown", never as nothing. */ onDismiss?: (reason: McpAppDismissReason) => void; /** Observe phase transitions (see {@link ViewHostPhase}). */ onPhaseChange?: (phase: ViewHostPhase) => void; /** * The origins this view needs the embedding PAGE's CSP to allow — the * spec's per-resource declaration (`McpUiResourceCsp`). Given, the host * arms a CSP tripwire for the attachment's lifetime: a * `securitypolicyviolation` on the EMBEDDING document whose blocked URI * lands on one of these hosts is upgraded from a silent "never * negotiated" into an actionable {@link ViewCspDiagnosis} (guuey#235). * The phase itself stays `"no-handshake"` — honest about WHAT happened; * the diagnosis says WHY. Absent → tripwire not installed, zero behavior * change (the default for every existing caller). * * REACH (pinned by the browser leg, `e2e/tests/sdk/view-host.spec.ts`): * the listener lives on the embedding document, so it sees violations * the PAGE incurs on the view's origins — a runtime bundle the page * loads at page level, a live channel the page opens on the view's * behalf (the shape ggui's landing tripwire exercised). It does NOT see * a `srcdoc` view's own blocked loads: the frame inherits the page's * policy, but the browser enforces that copy in — and dispatches the * violation on — the FRAME's document, which is opaque-origin and cannot * be listened to from outside. A frame-side reporter would need host * script injected into untrusted view HTML, a trust-boundary change this * primitive deliberately does not make; the `sandboxPageUrl` mount owns * its own policy and reports nothing here by construction. */ cspOrigins?: ViewCspOrigins; /** * A CSP violation ABOUT this view was observed (see {@link cspOrigins}). * Fires at most once per attachment, as soon as the violation lands — * typically BEFORE the negotiation window lapses, since a blocked runtime * never gets to negotiate. `` folds it into the no-handshake * label; a custom renderer shows/logs it as it likes. */ onCspDiagnosis?: (diagnosis: ViewCspDiagnosis) => void; /** CSP-violation event source, injectable for tests. Default: `document`. */ cspEvents?: ViewCspEvents; /** * How long to wait for `ui/initialize` before declaring * `"no-handshake"` (ms). `0` disables the timer. Default 8000 — a view * runtime negotiates immediately after parse; this bound exists to turn * "blank forever" into a labeled state, not to race slow networks. */ negotiationTimeoutMs?: number; /** Message-event source, injectable for tests. Default: `window`. */ events?: ViewHostEvents; } export declare function attachViewHost(frame: ViewFrameLike, config?: AttachViewHostConfig): () => void; /** * The document a {@link McpUiResourcePayload} mounts: `text` verbatim, or * `blob` base64-decoded as UTF-8. `undefined` when the payload carries * neither — nothing to put in `srcdoc`. */ export declare function viewDocumentHtml(resource: McpUiResourcePayload): string | undefined; //# sourceMappingURL=view-host.d.ts.map