/** * The Host half of the MCP Apps (SEP-1865) App handshake, as a PURE state * machine — message in, `{state, effects}` out, zero DOM (guuey#186 Gap 1). * * ## Why the view hangs without this * * A spec-following App (ggui's iframe-runtime included) opens with * `ui/initialize` posted to its parent and BLOCKS — the handshake runs * before the view reads any seeded boot data, so a frame whose parent * never answers stays blank forever. `toolResultViewMount` prepares the * document; THIS module answers the document. The two halves together are * the Host role the package is named for. * * ## Why a pure machine * * Only a real browser runs the postMessage/sandbox physics, but the * publish gate (guuey-sdks mirror CI) is Node-only — the exact blind spot * that let the missing-host gap ship (mount-material assertions run in * Node; nothing ran the negotiation). So the protocol logic lives here, * exhaustively unit-tested against scripted message sequences, and the DOM * glue in `view-host.ts` stays thin enough to carry no decisions. Even the * negotiation timeout is a machine INPUT ({@link viewHostElapsed}), not a * timer here. * * ## Spec posture * * Types and method names come from `@modelcontextprotocol/ext-apps` — the * SEP-1865 surface itself, someone else's frozen contract. Zero ggui * imports, deliberately (guuey#123): this host answers ANY spec-following * view. (The ggui vendor arm that used to live beside it in * `ggui-render.ts` retired 2026-08-16 — guuey#209; the module shipped one * minor as deprecated re-exports and was deleted at 0.8.0.) */ import { type McpUiHostCapabilities, type McpUiHostContext, type McpUiInitializeResult, type McpUiResourceCsp } from "@modelcontextprotocol/ext-apps"; import { type McpAppDismissReason } from "@ggui-ai/protocol/integrations/mcp-apps"; import type { McpToolStructuredContent } from "./action.js"; import type { McpResourceReadResult } from "./reader.js"; /** * The host identity in the initialize result — structurally the spec's * `Implementation` (which the ext-apps root does not re-export), narrowed * to the two fields a host must supply. */ export interface ViewHostInfo { name: string; version: string; } /** The standard MCP method a view uses to reach host-proxied tools. */ export declare const TOOLS_CALL_METHOD = "tools/call"; /** * The spec method a view uses to push a model-context snapshot at its host * (`McpUiUpdateModelContextRequest`). A local constant — the ext-apps root * exports the TYPE but no method constant for it (guuey#335). Producers * (ggui context-observer) fire it on slot changes as a COMPLEMENT to the * action path; the choice itself always rides tools/call or the live * channel ("Actions drive turns; context observes state"). */ export declare const UPDATE_MODEL_CONTEXT_METHOD = "ui/update-model-context"; /** * The spec method a view uses to hand the HOST a user-channel message * (`McpUiMessageRequest` — role "user" + content blocks). ggui's #440 * post-turn doorbell rides this: when a gesture needs an agent turn and * no consume long-poll is live, the runtime sends the model directive * here and the host starts the turn (guuey#422). Local constant, same * mirror rationale as the siblings. */ export declare const MESSAGE_METHOD_LOCAL = "ui/message"; /** * The standard MCP method a view uses to read host-proxied resources — * `ReadResourceRequest` in the spec's App→Host request union * (`@modelcontextprotocol/ext-apps` `AppRequest`). A local constant, same * as {@link TOOLS_CALL_METHOD}: the string is MCP-core vocabulary the * ext-apps root does not re-export, and this package deliberately carries * no `@modelcontextprotocol/sdk` dependency. */ export declare const RESOURCES_READ_METHOD = "resources/read"; /** * `ui/open-link` (guuey#522) — a view asks the HOST to open a URL in a * new browsing context. The machine owns the SECURITY wall: only * absolute http/https URLs pass (the #515-family policy, platform- * narrowed — mailto and every other scheme refuse in-band with -32602); * a valid ask answers `{}` FIRST and surfaces an `open-link` effect. The * kit's default executor never navigates sight-unseen: it SHOWS the URL * to the human and opens only on their own click (a real gesture — the * browser's activation gate and ours agree by construction). */ export declare const OPEN_LINK_METHOD_LOCAL = "ui/open-link"; /** A JSON-RPC id as the wire allows it. */ export type ViewRequestId = number | string; /** The messages this host posts INTO the view frame. */ export interface ViewHostOutbound { jsonrpc: "2.0"; id?: ViewRequestId; method?: string; params?: { [key: string]: unknown; }; result?: { [key: string]: unknown; }; error?: { code: number; message: string; }; } /** * Where the negotiation stands, from the host's side of the boundary. * * - `"negotiating"` — attached; no `ui/initialize` seen yet. A plain-HTML * inline card may stay here forever, legitimately: the handshake is how * a spec App boots, not an obligation on arbitrary tenant HTML. * - `"connected"` — `ui/initialize` was answered. The view owns its own * pixels (and its own failures) from here on. * - `"no-handshake"` — the caller declared the negotiation window over * ({@link viewHostElapsed}) before any `ui/initialize` arrived. What * that MEANS depends on the mount channel and is the renderer's call: * a `"ggui"` shell always handshakes, so this phase is a boot failure * there; an `"inline"` card may simply not be an App. * * A renderer binds to this — the failure mode must be a labeled state, * never a blank page (guuey#186 audit). */ export type ViewHostPhase = "negotiating" | "connected" | "no-handshake"; /** The machine's whole state. Immutable — every transition returns a new one. */ export interface ViewHostState { phase: ViewHostPhase; /** `ui/notifications/initialized` seen (the App's post-handshake ack). */ initializedSeen: boolean; } export declare function initialViewHostState(): ViewHostState; /** * What the glue must DO after a transition. Effects are data so the machine * stays synchronous and Node-testable; the glue performs them. */ export type ViewHostEffect = { kind: "respond"; message: ViewHostOutbound; } | { /** * A `tools/call` the config accepted for relaying. The glue runs the * (async) relay hook and posts {@link toolCallResponse} with the * result. Only ever emitted when {@link ViewHostBehavior.toolRelay} * is true — with no relay wired, the machine refuses the call * in-band instead (an honest `method_not_supported`). */ kind: "relay-tool-call"; id: ViewRequestId; name: string; arguments?: McpToolStructuredContent; } | { /** * A `resources/read` the config accepted for relaying (spec surface: * `ReadResourceRequest` rides the App→Host union, and the matching * advertisement is `hostCapabilities.serverResources`). The glue runs * the read hook and posts {@link resourceReadResponse}. Only emitted * when {@link ViewHostBehavior.resourceRelay} is true — unwired, the * machine refuses in-band like every other unsupported request. */ kind: "relay-resource-read"; id: ViewRequestId; uri: string; } | { /** * A `ui/update-model-context` snapshot to hand the embedder * (guuey#335). The machine already ANSWERED the view (success — the * spec result is empty), so this effect is pure delivery: a throwing * sink can never leave the view hanging. Only emitted when * {@link ViewHostBehavior.modelContextSink} is true — unwired, the * machine refuses in-band like every other unsupported request. */ kind: "model-context-update"; params: { [key: string]: unknown; }; } | { /** * A `ui/message` the view handed the host (guuey#422): role-user * content blocks the host forwards into ITS conversation loop (the * composer's send gate — a real turn starts). ggui's #440 post-turn * doorbell rides this. Under `messageAnswer: "on-accept"` (the default) * the machine already ANSWERED the view (`{}` — accepted) and delivery is * this effect; under `"on-delivery"` (guuey#1706) this effect is the ONLY * output and the host owes the view its answer — `{}` delivered, `{ * isError: true }` not — keyed by `id`. Only emitted when * {@link ViewHostBehavior.messageSink} is true. */ kind: "user-message"; id: ViewRequestId; params: { [key: string]: unknown; }; } | { /** * The view asked to open a URL (`ui/open-link`, guuey#522) and the * machine's scheme wall passed it (absolute http/https only). Only * ever emitted when {@link ViewHostBehavior.linkOpener} is true — * the response was already sent; the executor SURFACES the URL to * the human and opens on their own gesture, never sight-unseen. */ kind: "open-link"; url: string; } | { /** * The view reported its content size (`ui/notifications/size-changed` * — spec notification, App → Host). At least one of the two fields is * a finite number; a notification carrying neither is consumed * silently instead. The glue forwards this to the embedder * ({@link AttachViewHostConfig.onSizeChanged} in `view-host.ts`) — * whether/how to resize the frame is the embedder's layout decision, * never the machine's. */ kind: "size-changed"; width?: number; height?: number; } | { /** * The view asked to be dismissed (`ggui:dismiss`, protocol 0.18.0 — * ggui#1109 / guuey#1423): the user made a dismiss gesture with focus * INSIDE the card document, where the embedding page's own key * handlers cannot hear it, and the card forwarded it. An INTENT, never * a command — the embedder decides what dismiss means on its surface * (close a takeover, return focus, collapse to a chip, nothing); * ignoring it is conformant, so the glue only ever OBSERVES it * ({@link AttachViewHostConfig.onDismiss} in `view-host.ts`). * * `reason` is the card's stated cause, forwarded verbatim. The set is * EXTENSIBLY-closed on the wire: `MCP_APP_DISMISS_REASONS` documents * the reasons this release NAMES, and `isMcpAppDismissMessage` admits * any non-empty string on purpose — a reason a later card names that * this host does not know is still a dismiss request, of unknown * cause, never a message to drop (the N−1 failure the openness exists * to prevent). So: NO reason whitelist here, no `reason === "escape"` * branch; the effect fires for every shape the protocol guard accepts. */ kind: "dismiss"; reason: McpAppDismissReason; }; /** * The host identity/behavior the machine answers with. Everything here is * explicit config — the machine assumes nothing about the embedder. */ export interface ViewHostBehavior { hostInfo: ViewHostInfo; /** * The capabilities to advertise. Empty is a correct, honest default for * views that ride their own live channel (ggui views do — they boot from * their seeded envelope and talk to their pod directly, so the host's * whole job is unblocking the handshake). Advertise ONLY what the * embedder actually implements: a capability the host does not honor * makes the view attribute later failures to the wrong layer. */ hostCapabilities: McpUiHostCapabilities; /** The context handed to the view in the initialize result. */ hostContext: McpUiHostContext; /** Whether a `tools/call` relay hook is wired (see `view-host.ts`). */ toolRelay: boolean; /** Whether a model-context sink is wired (see `view-host.ts`, guuey#335). */ modelContextSink: boolean; /** Whether a ui/message sink is wired (see `view-host.ts`, guuey#422). */ messageSink: boolean; /** * WHO answers a `ui/message`, and when (guuey#1706). Default `"on-accept"`: * the machine answers `{}` BEFORE delivery and emits `user-message` — the * pre-#1706 contract, kept for direct embedders of {@link viewHostReceive}. * `"on-delivery"`: the machine emits `user-message` ONLY and the HOST answers * from the sink's delivery outcome (`{}` delivered · `{ isError: true }` not), * so a view learns whether its message actually became a turn. This * package's own `attachViewHost` sets `"on-delivery"`. */ messageAnswer?: "on-accept" | "on-delivery"; /** * `ui/open-link` wired (guuey#522). Unwired, the method refuses with * the honest -32601 — the host chose today's no-links posture. */ linkOpener: boolean; /** Whether a `resources/read` relay hook is wired (see `view-host.ts`). */ resourceRelay: boolean; } /** The result of feeding one inbound frame (or the timeout) to the machine. */ export interface ViewHostTransition { state: ViewHostState; effects: ViewHostEffect[]; } /** The spec-canonical answer to `ui/initialize`. Exported for the glue/tests. */ export declare function initializeResult(behavior: ViewHostBehavior, requestedProtocolVersion: unknown): McpUiInitializeResult; /** Build the in-band response for a relayed `tools/call`'s settled result. */ export declare function toolCallResponse(id: ViewRequestId, result: { [key: string]: unknown; }): ViewHostOutbound; /** * Build the in-band response for a relayed `resources/read`. An entry * becomes the spec's `ReadResourceResult` (`contents: [entry]`); `undefined` * — a miss, a deny, or a relay failure alike — becomes the one * `Resource not found` error (deny == miss, {@link RESOURCE_NOT_FOUND}). */ export declare function resourceReadResponse(id: ViewRequestId, entry: McpResourceReadResult | undefined): ViewHostOutbound; /** * The spec-mannered farewell a detaching host posts (`ui/resource-teardown`). * Sent WITHOUT an id — a host that is tearing the frame down cannot await a * response, and an id-less JSON-RPC message is a notification the view may * use for cleanup or ignore. */ export declare function teardownMessage(): ViewHostOutbound; /** * Feed one inbound postMessage payload to the machine. * * The contract, exactly: * - non-RPC data → ignored (not ours) — with ONE typed exception: protocol * 0.18.0's `ggui:dismiss` intent (`isMcpAppDismissMessage`) → a `dismiss` * effect, state untouched (guuey#1423); * - notifications (no id) → consumed silently, JSON-RPC-correctly; the * `ui/notifications/initialized` ack is remembered on the state; * - `ui/initialize` → answered spec-canonically; phase → `"connected"` * (also from `"no-handshake"` — a late handshake still gets answered: * the timeout labels a state, it does not close the door); * - `tools/call` with a relay wired → `relay-tool-call` effect; * - every other REQUEST → `method_not_supported`, honestly. */ export declare function viewHostReceive(state: ViewHostState, behavior: ViewHostBehavior, data: unknown): ViewHostTransition; /** * Declare the negotiation window over. Meaningful only while * `"negotiating"`: a connected view stays connected, and an already-lapsed * one stays lapsed. The caller owns the clock — this machine has none. */ export declare function viewHostElapsed(state: ViewHostState): ViewHostState; /** * The origins a view needs the EMBEDDER's page to allow — the spec's own * per-resource CSP declaration (`McpUiResourceCsp`: `connectDomains` → * `connect-src`, `resourceDomains` → `script-src`/`style-src`/…, * `frameDomains` → `frame-src`). This is the honest filter for the CSP * tripwire: a `securitypolicyviolation` whose `blockedURI` lands on one of * these hosts is a violation ABOUT the view, not about anything else the * page loads. Empty/absent → nothing to match, tripwire inert. */ export type ViewCspOrigins = McpUiResourceCsp; /** * What the embedder can act on when its own CSP blocked the view: the * URI the browser refused, the directive that refused it, and the entry * that would allow it (the blocked URI's origin — the smallest allowance * that fixes exactly this). Rides beside `"no-handshake"` — the phase * stays honest ("it never negotiated"); this is WHY. */ export interface ViewCspDiagnosis { blockedUri: string; violatedDirective: string; /** The origin to add under `violatedDirective` — e.g. `https://assets.mcp.example`. */ suggestedEntry: string; /** Operator-facing sentence, ready to label. */ message: string; } /** * The slice of a `SecurityPolicyViolationEvent` the tripwire reads — * structural, so Node tests hand in plain objects (lib.dom's class is not * constructible outside a browser). */ export interface CspViolationLike { blockedURI: string; /** e.g. `script-src-elem`, `connect-src`. */ violatedDirective: string; /** The directive as the policy spelled it (`script-src` may govern `script-src-elem`). */ effectiveDirective?: string; } /** * Pure: is this violation ABOUT the view (its blocked URI lands on a * declared origin), and if so, what should the embedder add? * * `blockedURI` is a full URL for network/script blocks; the browser sends * bare tokens (`eval`, `inline`, `data`) for policy-class blocks — those * carry no host and never match a declared origin, which is right: a * `script-src eval` report is not the view's (see guuey#236 for the zod * probe that produces exactly one such report at boot). */ export declare function diagnoseCspViolation(violation: CspViolationLike, origins: ViewCspOrigins | undefined): ViewCspDiagnosis | undefined; //# sourceMappingURL=view-host-protocol.d.ts.map