@guuey/mcp-apps-host 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/react.tsx ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * React entry point (`@guuey/mcp-apps-host/react`).
3
+ *
4
+ * `<GuueyView>` is the one React-coupled surface — the root subpath stays
5
+ * React-free (narrowing, rehydration, the relay, `attachViewHost` itself),
6
+ * so server-side consumers never import React at all. The component is a
7
+ * CONVENIENCE composition of the framework-agnostic primitive: iframe
8
+ * creation + the sandbox invariant + the handshake + lifecycle. A host
9
+ * that needs a different composition (a transcript renderer, a non-React
10
+ * surface) uses `attachViewHost` directly.
11
+ *
12
+ * ## Sandbox posture (invariant, not preference)
13
+ *
14
+ * `sandbox="allow-scripts"` WITHOUT `allow-same-origin`: a `srcdoc` frame
15
+ * inherits its embedder's origin, so granting both would run
16
+ * agent-generated HTML AS the embedding page — reach into its DOM and its
17
+ * signed-in session, an XSS by construction. Dropping `allow-same-origin`
18
+ * puts the document in an opaque origin instead. Extra flags ride ON TOP
19
+ * via {@link GuueyViewProps.dangerouslyAddSandboxFlags} — named the way it
20
+ * is because every flag it adds widens what agent-generated HTML can do.
21
+ * `allow="clipboard-write"` is delegated by default: generated views own
22
+ * copy buttons, and without the delegation every one of them silently
23
+ * no-ops inside the opaque origin.
24
+ *
25
+ * ## Who paints which state
26
+ *
27
+ * While `"negotiating"`, the component shows a small non-blocking status
28
+ * line — never a bare blank frame (guuey#186 audit). On `"connected"` the
29
+ * view owns its pixels (and its own failures) and the line disappears. On
30
+ * `"no-handshake"` the meaning is channel-aware: a `"ggui"` shell always
31
+ * negotiates, so silence is a boot failure and is labeled as one; an
32
+ * `"inline"` card is arbitrary tenant HTML with no handshake obligation,
33
+ * so the status line simply retires and the document stands as rendered.
34
+ */
35
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
36
+ import { attachViewHost, viewDocumentHtml, type AttachViewHostConfig } from "./view-host.js";
37
+ import { attachSandboxPageDelivery } from "./sandbox-page.js";
38
+ import type { ViewHostPhase } from "./view-host-protocol.js";
39
+ import type { ResolvedViewMount } from "./card-mount.js";
40
+
41
+ export { attachViewHost, viewDocumentHtml } from "./view-host.js";
42
+ export type { AttachViewHostConfig, ViewFrameLike, ViewHostEvents } from "./view-host.js";
43
+ export {
44
+ attachSandboxPageDelivery,
45
+ isSandboxProxyReady,
46
+ SANDBOX_PROXY_READY_METHOD,
47
+ SANDBOX_RESOURCE_READY_METHOD,
48
+ type SandboxPageDeliveryConfig,
49
+ } from "./sandbox-page.js";
50
+ export type { ViewHostPhase } from "./view-host-protocol.js";
51
+ export type { ResolvedViewMount, ViewMount, ViewMountChannel } from "./card-mount.js";
52
+
53
+ /** Accessible name for a mounted view when the caller has nothing better. */
54
+ const DEFAULT_TITLE = "Generated view";
55
+
56
+ export interface GuueyViewProps
57
+ extends Pick<
58
+ AttachViewHostConfig,
59
+ "hostCapabilities" | "hostInfo" | "hostContext" | "onCallTool" | "negotiationTimeoutMs"
60
+ > {
61
+ /** The resolved card to mount (see `toolResultViewMount`/`resolveViewMount`). */
62
+ mount: ResolvedViewMount;
63
+ /**
64
+ * Opt into the TWO-ORIGIN mount: instead of `srcdoc`, the frame loads
65
+ * this host-served sandbox page (guuey's `/mcp-app-sandbox` pattern — the
66
+ * caller builds the full URL, channel/app query included) and the
67
+ * document is delivered over the page's relay protocol
68
+ * (`attachSandboxPageDelivery`). Why: a `srcdoc` frame INHERITS the
69
+ * embedder's CSP, so its egress confinement is whatever the page happens
70
+ * to carry; the sandbox page is served WITH the per-request CSP that
71
+ * confines the mount — and the untrusted document lands in the page's
72
+ * own inner opaque frame, never in this one. In this mode the frame's
73
+ * `sandbox` gains `allow-same-origin` — REQUIRED and safe: the frame
74
+ * holds the cross-origin RELAY PAGE (which must run as its real origin
75
+ * for its CSP + referrer checks to mean anything), never agent HTML.
76
+ * The page must be a genuinely different origin; a same-origin URL is
77
+ * refused with a labeled state, never mounted.
78
+ */
79
+ sandboxPageUrl?: string;
80
+ /**
81
+ * Sandbox flags appended to the safe default (`allow-scripts`). Every
82
+ * entry widens what agent-generated HTML may do — `allow-same-origin`
83
+ * in particular hands it the embedder's origin. Prefer leaving unset.
84
+ * In `sandboxPageUrl` mode these forward to the INNER frame via the
85
+ * page's relay (which strips `allow-same-origin` regardless).
86
+ */
87
+ dangerouslyAddSandboxFlags?: string[];
88
+ /** Permissions-Policy delegation for the frame. Default `clipboard-write`. */
89
+ allow?: string;
90
+ /** Accessible frame title. Default "Generated view". */
91
+ title?: string;
92
+ className?: string;
93
+ style?: CSSProperties;
94
+ /** Observe the negotiation phase (the same states the default UI labels). */
95
+ onPhaseChange?: (phase: ViewHostPhase) => void;
96
+ /**
97
+ * Replace the default status/failure line for a phase. Return `null` for
98
+ * "render nothing". The default: a quiet "Negotiating with view…" line
99
+ * while `"negotiating"`; a labeled failure for `"no-handshake"` on the
100
+ * `"ggui"` channel; nothing once `"connected"` (the view owns its
101
+ * pixels) and nothing for a silent `"inline"` card.
102
+ */
103
+ renderStatus?: (phase: ViewHostPhase) => ReactNode;
104
+ }
105
+
106
+ const statusLineStyle: CSSProperties = {
107
+ position: "absolute",
108
+ insetInlineStart: 8,
109
+ insetBlockEnd: 8,
110
+ margin: 0,
111
+ padding: "2px 8px",
112
+ fontSize: 12,
113
+ lineHeight: "18px",
114
+ opacity: 0.65,
115
+ pointerEvents: "none",
116
+ };
117
+
118
+ function defaultStatus(phase: ViewHostPhase, channel: ResolvedViewMount["channel"]): ReactNode {
119
+ if (phase === "negotiating") {
120
+ return <p style={statusLineStyle}>Negotiating with view…</p>;
121
+ }
122
+ if (phase === "no-handshake" && channel === "ggui") {
123
+ // A ggui shell negotiates unconditionally before painting, so silence
124
+ // here is a boot failure with no other author — label it (role=alert
125
+ // so it is announced, not just drawn).
126
+ return (
127
+ <p role="alert" style={{ ...statusLineStyle, opacity: 1, pointerEvents: "auto" }}>
128
+ This view did not start — it never negotiated with the host.
129
+ </p>
130
+ );
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Mount a resolved view and play the MCP Apps Host for it. See the module
137
+ * docblock for the sandbox and state contracts.
138
+ */
139
+ export function GuueyView(props: GuueyViewProps): ReactNode {
140
+ const {
141
+ mount,
142
+ sandboxPageUrl,
143
+ dangerouslyAddSandboxFlags,
144
+ allow,
145
+ title,
146
+ className,
147
+ style,
148
+ onPhaseChange,
149
+ renderStatus,
150
+ ...hostConfig
151
+ } = props;
152
+ const frameRef = useRef<HTMLIFrameElement>(null);
153
+ const [phase, setPhase] = useState<ViewHostPhase>("negotiating");
154
+ const html = viewDocumentHtml(mount.resource);
155
+
156
+ // Vet the sandbox page once per URL. Same-origin is REFUSED (the widget's
157
+ // ResourceMount precedent, generalized): the whole point of the page is
158
+ // being a different origin — same-origin would hand the relay page (and
159
+ // through `allow-same-origin`, everything it can reach) the embedder's
160
+ // own origin.
161
+ const sandboxPage: URL | "refused" | undefined = useMemo(() => {
162
+ if (sandboxPageUrl === undefined) return undefined;
163
+ let url: URL;
164
+ try {
165
+ url = new URL(sandboxPageUrl);
166
+ } catch {
167
+ return "refused";
168
+ }
169
+ if (typeof window !== "undefined" && url.origin === window.location.origin) return "refused";
170
+ return url;
171
+ }, [sandboxPageUrl]);
172
+ const page = sandboxPage instanceof URL ? sandboxPage : undefined;
173
+
174
+ // The attachment is keyed to the mounted DOCUMENT, not to every render's
175
+ // fresh callback identities — host config rides a ref so the effect's
176
+ // dependency list is honestly just the document identity.
177
+ const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags });
178
+ latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags };
179
+
180
+ useEffect(() => {
181
+ // Keyed to the same identity the frame is (the resource uri): a new
182
+ // document boots fresh, and the previous negotiation's phase must not
183
+ // paper over it.
184
+ setPhase("negotiating");
185
+ const frame = frameRef.current;
186
+ if (frame === null || html === undefined) return;
187
+ if (sandboxPageUrl !== undefined && page === undefined) return; // refused config — nothing mounts
188
+ const resourceUri = mount.resource.uri;
189
+ const detachHost = attachViewHost(frame, {
190
+ ...latest.current.hostConfig,
191
+ resourceUri,
192
+ onPhaseChange: (next) => {
193
+ setPhase(next);
194
+ latest.current.onPhaseChange?.(next);
195
+ },
196
+ });
197
+ if (page === undefined) return detachHost;
198
+ // Two-origin mode: the page announces readiness, the document is
199
+ // delivered over its relay (re-delivered on a reload's re-announce),
200
+ // and the view-host handshake crosses the same relay transparently.
201
+ const flags = latest.current.dangerouslyAddSandboxFlags;
202
+ const detachDelivery = attachSandboxPageDelivery(frame, {
203
+ pageOrigin: page.origin,
204
+ html,
205
+ ...(flags !== undefined && flags.length > 0
206
+ ? { sandbox: ["allow-scripts", ...flags].join(" ") }
207
+ : {}),
208
+ });
209
+ return () => {
210
+ detachDelivery();
211
+ detachHost();
212
+ };
213
+ }, [mount.resource.uri, html, sandboxPageUrl, page]);
214
+
215
+ if (html === undefined) {
216
+ // A resolved mount with no document is producer-side breakage; an
217
+ // empty frame would be a lie. Label it, in the same voice as the
218
+ // no-handshake state.
219
+ return (
220
+ <div className={className} style={{ position: "relative", ...style }}>
221
+ <p role="alert" style={{ ...statusLineStyle, opacity: 1, pointerEvents: "auto" }}>
222
+ This view could not be displayed — its resource carries no document.
223
+ </p>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ if (sandboxPageUrl !== undefined && page === undefined) {
229
+ // A malformed or SAME-ORIGIN sandbox page is a configuration state, not
230
+ // a property of the card — refused, labeled, never mounted.
231
+ return (
232
+ <div className={className} style={{ position: "relative", ...style }}>
233
+ <p role="alert" style={{ ...statusLineStyle, opacity: 1, pointerEvents: "auto" }}>
234
+ Interactive view unavailable — the sandbox page is not usable from this origin.
235
+ </p>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ return (
241
+ <div className={className} style={{ position: "relative", ...style }}>
242
+ <iframe
243
+ ref={frameRef}
244
+ // Remount on a new resource (or mount mode) rather than reusing the
245
+ // frame: a view runtime boots once from the document it was handed,
246
+ // so swapping `srcDoc` in place would leave the old boot running
247
+ // against new markup.
248
+ key={`${page?.href ?? "srcdoc"}::${mount.resource.uri}`}
249
+ {...(page !== undefined ? { src: page.href } : { srcDoc: html })}
250
+ title={title ?? DEFAULT_TITLE}
251
+ // srcdoc mode: the INVARIANT — agent HTML in an opaque origin, extra
252
+ // flags only widen knowingly. Page mode: the frame holds the
253
+ // cross-origin RELAY PAGE, which must run as its real origin
254
+ // (`allow-same-origin`) for its CSP/referrer machinery to exist at
255
+ // all; the agent HTML lands in the page's own inner opaque frame,
256
+ // and the caller's extra flags travel to THAT frame via the relay.
257
+ sandbox={
258
+ page !== undefined
259
+ ? "allow-scripts allow-same-origin allow-forms"
260
+ : ["allow-scripts", ...(dangerouslyAddSandboxFlags ?? [])].join(" ")
261
+ }
262
+ allow={allow ?? "clipboard-write"}
263
+ style={{ display: "block", width: "100%", height: "100%", border: 0 }}
264
+ />
265
+ {renderStatus !== undefined ? renderStatus(phase) : defaultStatus(phase, mount.channel)}
266
+ </div>
267
+ );
268
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Sandbox-PAGE document delivery — the client half of the two-origin mount
3
+ * (guuey#135 wave-3c, from the #186/#135 dogfood's finding 2).
4
+ *
5
+ * ## Why a second mount mode exists
6
+ *
7
+ * The default `<GuueyView>` mount is a `srcdoc` frame: zero configuration,
8
+ * opaque origin, correct sandbox posture — but a `srcdoc` document INHERITS
9
+ * the embedding page's Content-Security-Policy, so the strongest egress
10
+ * confinement it can have is whatever the embedder's page happens to carry.
11
+ * Guuey's production surfaces confine harder: the untrusted document mounts
12
+ * inside a HOST-SERVED sandbox page on a second origin, whose per-request
13
+ * CSP names exactly the egress that mount is entitled to (see the platform's
14
+ * `/mcp-app-sandbox` route — per-channel `connect-src`, per-app
15
+ * `frame-ancestors`). This module speaks that page's delivery protocol so
16
+ * any kit consumer can opt into the same confinement.
17
+ *
18
+ * ## The protocol (co-owned in-repo; two notifications)
19
+ *
20
+ * The page is the reference sandbox relay (adapted from the MCP ext-apps
21
+ * `basic-host` example, vendored at
22
+ * `create-agentic-app/templates-src/base/web/sandbox-proxy.ts` and served by
23
+ * the platform's landing route). Its wire is exactly two JSON-RPC
24
+ * notifications:
25
+ *
26
+ * 1. page → host: `ui/notifications/sandbox-proxy-ready` — the relay booted
27
+ * and is listening;
28
+ * 2. host → page: `ui/notifications/sandbox-resource-ready` with
29
+ * `params.html` (+ optional `params.sandbox` tokens for the INNER frame —
30
+ * the page strips `allow-same-origin` from any value regardless).
31
+ *
32
+ * Every other message crosses the page transparently in both directions,
33
+ * which is why `attachViewHost` works unchanged on top of this delivery: the
34
+ * view's `ui/initialize` arrives relayed with `event.source` still the OUTER
35
+ * frame's window, and the host's answers relay inward.
36
+ *
37
+ * ## Identity + targeting
38
+ *
39
+ * Inbound messages are matched by `event.source === frame.contentWindow` —
40
+ * the package's standing identity invariant (`view-host.ts`). Outbound
41
+ * delivery targets `config.pageOrigin` EXPLICITLY (never `'*'`): unlike a
42
+ * srcdoc view, the sandbox page has a real origin, and the document being
43
+ * delivered is agent-generated content the caller confined on purpose — if
44
+ * the frame somehow navigated elsewhere, the browser drops the message
45
+ * instead of handing the document to the wrong receiver.
46
+ */
47
+ import type { ViewFrameLike, ViewHostEvents } from "./view-host.js";
48
+
49
+ export const SANDBOX_PROXY_READY_METHOD = "ui/notifications/sandbox-proxy-ready";
50
+ export const SANDBOX_RESOURCE_READY_METHOD = "ui/notifications/sandbox-resource-ready";
51
+
52
+ /** Structural check for the page's ready notification. */
53
+ export function isSandboxProxyReady(data: unknown): boolean {
54
+ return (
55
+ typeof data === "object" &&
56
+ data !== null &&
57
+ !Array.isArray(data) &&
58
+ (data as { method?: unknown }).method === SANDBOX_PROXY_READY_METHOD
59
+ );
60
+ }
61
+
62
+ export interface SandboxPageDeliveryConfig {
63
+ /**
64
+ * The sandbox page's origin — the ONLY target the document is posted to.
65
+ * Derive it from the page URL the frame was given (`new URL(url).origin`).
66
+ */
67
+ pageOrigin: string;
68
+ /** The document to deliver (the view's `viewDocumentHtml`). */
69
+ html: string;
70
+ /**
71
+ * Inner-frame sandbox tokens forwarded as `params.sandbox`. The page's
72
+ * `safeSandbox` strips `allow-same-origin` and guarantees `allow-scripts`
73
+ * whatever is sent — this only ever WIDENS within the page's own bounds.
74
+ */
75
+ sandbox?: string;
76
+ /** Message-event source, injectable for tests. Default: `window`. */
77
+ events?: ViewHostEvents;
78
+ }
79
+
80
+ /**
81
+ * Deliver a view document to a mounted sandbox page, re-delivering on every
82
+ * `sandbox-proxy-ready` (a reloaded page announces again and must be
83
+ * re-seeded). Returns a detach function.
84
+ */
85
+ export function attachSandboxPageDelivery(
86
+ frame: ViewFrameLike,
87
+ config: SandboxPageDeliveryConfig,
88
+ ): () => void {
89
+ const deliver = (): void => {
90
+ frame.contentWindow?.postMessage(
91
+ {
92
+ jsonrpc: "2.0",
93
+ method: SANDBOX_RESOURCE_READY_METHOD,
94
+ params: {
95
+ html: config.html,
96
+ ...(config.sandbox !== undefined ? { sandbox: config.sandbox } : {}),
97
+ },
98
+ },
99
+ config.pageOrigin,
100
+ );
101
+ };
102
+
103
+ const onMessage = (event: { data: unknown; source: unknown }): void => {
104
+ if (frame.contentWindow === null || event.source !== frame.contentWindow) return;
105
+ if (isSandboxProxyReady(event.data)) deliver();
106
+ };
107
+
108
+ const { events } = config;
109
+ if (events !== undefined) {
110
+ events.addEventListener("message", onMessage);
111
+ return () => events.removeEventListener("message", onMessage);
112
+ }
113
+ const domListener = (event: MessageEvent): void => onMessage(event);
114
+ window.addEventListener("message", domListener);
115
+ return () => window.removeEventListener("message", domListener);
116
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The Host half of the MCP Apps (SEP-1865) App handshake, as a PURE state
3
+ * machine — message in, `{state, effects}` out, zero DOM (guuey#186 Gap 1).
4
+ *
5
+ * ## Why the view hangs without this
6
+ *
7
+ * A spec-following App (ggui's iframe-runtime included) opens with
8
+ * `ui/initialize` posted to its parent and BLOCKS — the handshake runs
9
+ * before the view reads any seeded boot data, so a frame whose parent
10
+ * never answers stays blank forever. `toolResultViewMount` prepares the
11
+ * document; THIS module answers the document. The two halves together are
12
+ * the Host role the package is named for.
13
+ *
14
+ * ## Why a pure machine
15
+ *
16
+ * Only a real browser runs the postMessage/sandbox physics, but the
17
+ * publish gate (guuey-sdks mirror CI) is Node-only — the exact blind spot
18
+ * that let the missing-host gap ship (mount-material assertions run in
19
+ * Node; nothing ran the negotiation). So the protocol logic lives here,
20
+ * exhaustively unit-tested against scripted message sequences, and the DOM
21
+ * glue in `view-host.ts` stays thin enough to carry no decisions. Even the
22
+ * negotiation timeout is a machine INPUT ({@link viewHostElapsed}), not a
23
+ * timer here.
24
+ *
25
+ * ## Spec posture
26
+ *
27
+ * Types and method names come from `@modelcontextprotocol/ext-apps` — the
28
+ * SEP-1865 surface itself, someone else's frozen contract. Zero ggui
29
+ * imports, deliberately (guuey#123): this host answers ANY spec-following
30
+ * view; everything ggui-specific stays in `ggui-render.ts` behind ggui's
31
+ * own published protocol package.
32
+ */
33
+ import {
34
+ INITIALIZE_METHOD,
35
+ LATEST_PROTOCOL_VERSION,
36
+ RESOURCE_TEARDOWN_METHOD,
37
+ type McpUiHostCapabilities,
38
+ type McpUiHostContext,
39
+ type McpUiInitializeResult,
40
+ } from "@modelcontextprotocol/ext-apps";
41
+ import type { McpToolStructuredContent } from "./action.js";
42
+
43
+ /**
44
+ * The host identity in the initialize result — structurally the spec's
45
+ * `Implementation` (which the ext-apps root does not re-export), narrowed
46
+ * to the two fields a host must supply.
47
+ */
48
+ export interface ViewHostInfo {
49
+ name: string;
50
+ version: string;
51
+ }
52
+
53
+ /** JSON-RPC `method not found` — the spec's code, not an invented one. */
54
+ const METHOD_NOT_SUPPORTED = -32601;
55
+
56
+ /** The standard MCP method a view uses to reach host-proxied tools. */
57
+ export const TOOLS_CALL_METHOD = "tools/call";
58
+
59
+ /** A JSON-RPC id as the wire allows it. */
60
+ export type ViewRequestId = number | string;
61
+
62
+ /** The messages this host posts INTO the view frame. */
63
+ export interface ViewHostOutbound {
64
+ jsonrpc: "2.0";
65
+ id?: ViewRequestId;
66
+ method?: string;
67
+ params?: { [key: string]: unknown };
68
+ result?: { [key: string]: unknown };
69
+ error?: { code: number; message: string };
70
+ }
71
+
72
+ /**
73
+ * Where the negotiation stands, from the host's side of the boundary.
74
+ *
75
+ * - `"negotiating"` — attached; no `ui/initialize` seen yet. A plain-HTML
76
+ * inline card may stay here forever, legitimately: the handshake is how
77
+ * a spec App boots, not an obligation on arbitrary tenant HTML.
78
+ * - `"connected"` — `ui/initialize` was answered. The view owns its own
79
+ * pixels (and its own failures) from here on.
80
+ * - `"no-handshake"` — the caller declared the negotiation window over
81
+ * ({@link viewHostElapsed}) before any `ui/initialize` arrived. What
82
+ * that MEANS depends on the mount channel and is the renderer's call:
83
+ * a `"ggui"` shell always handshakes, so this phase is a boot failure
84
+ * there; an `"inline"` card may simply not be an App.
85
+ *
86
+ * A renderer binds to this — the failure mode must be a labeled state,
87
+ * never a blank page (guuey#186 audit).
88
+ */
89
+ export type ViewHostPhase = "negotiating" | "connected" | "no-handshake";
90
+
91
+ /** The machine's whole state. Immutable — every transition returns a new one. */
92
+ export interface ViewHostState {
93
+ phase: ViewHostPhase;
94
+ /** `ui/notifications/initialized` seen (the App's post-handshake ack). */
95
+ initializedSeen: boolean;
96
+ }
97
+
98
+ export function initialViewHostState(): ViewHostState {
99
+ return { phase: "negotiating", initializedSeen: false };
100
+ }
101
+
102
+ /**
103
+ * What the glue must DO after a transition. Effects are data so the machine
104
+ * stays synchronous and Node-testable; the glue performs them.
105
+ */
106
+ export type ViewHostEffect =
107
+ | { kind: "respond"; message: ViewHostOutbound }
108
+ | {
109
+ /**
110
+ * A `tools/call` the config accepted for relaying. The glue runs the
111
+ * (async) relay hook and posts {@link toolCallResponse} with the
112
+ * result. Only ever emitted when {@link ViewHostBehavior.toolRelay}
113
+ * is true — with no relay wired, the machine refuses the call
114
+ * in-band instead (an honest `method_not_supported`).
115
+ */
116
+ kind: "relay-tool-call";
117
+ id: ViewRequestId;
118
+ name: string;
119
+ arguments?: McpToolStructuredContent;
120
+ };
121
+
122
+ /**
123
+ * The host identity/behavior the machine answers with. Everything here is
124
+ * explicit config — the machine assumes nothing about the embedder.
125
+ */
126
+ export interface ViewHostBehavior {
127
+ hostInfo: ViewHostInfo;
128
+ /**
129
+ * The capabilities to advertise. Empty is a correct, honest default for
130
+ * views that ride their own live channel (ggui views do — they boot from
131
+ * their seeded envelope and talk to their pod directly, so the host's
132
+ * whole job is unblocking the handshake). Advertise ONLY what the
133
+ * embedder actually implements: a capability the host does not honor
134
+ * makes the view attribute later failures to the wrong layer.
135
+ */
136
+ hostCapabilities: McpUiHostCapabilities;
137
+ /** The context handed to the view in the initialize result. */
138
+ hostContext: McpUiHostContext;
139
+ /** Whether a `tools/call` relay hook is wired (see `view-host.ts`). */
140
+ toolRelay: boolean;
141
+ }
142
+
143
+ /** The result of feeding one inbound frame (or the timeout) to the machine. */
144
+ export interface ViewHostTransition {
145
+ state: ViewHostState;
146
+ effects: ViewHostEffect[];
147
+ }
148
+
149
+ interface InboundEnvelope {
150
+ id?: ViewRequestId;
151
+ method: string;
152
+ params?: { [key: string]: unknown };
153
+ }
154
+
155
+ /** Plain-object narrowing, same idiom as `action.ts`'s `isJsonObjectLike`. */
156
+ function isPlainObject(value: unknown): value is { [key: string]: unknown } {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
159
+
160
+ /**
161
+ * Narrow untrusted postMessage data to a JSON-RPC envelope this host could
162
+ * answer. Anything else — other windows' chatter, the view's own non-RPC
163
+ * messages — is silently not ours (`undefined`), NOT an error: a shared
164
+ * `message` listener hears the whole page.
165
+ */
166
+ function asInboundEnvelope(data: unknown): InboundEnvelope | undefined {
167
+ if (!isPlainObject(data)) return undefined;
168
+ if (data["jsonrpc"] !== "2.0") return undefined;
169
+ const method = data["method"];
170
+ if (typeof method !== "string") return undefined;
171
+ const id = data["id"];
172
+ const params = data["params"];
173
+ return {
174
+ ...(typeof id === "number" || typeof id === "string" ? { id } : {}),
175
+ method,
176
+ ...(isPlainObject(params) ? { params } : {}),
177
+ };
178
+ }
179
+
180
+ /** The spec-canonical answer to `ui/initialize`. Exported for the glue/tests. */
181
+ export function initializeResult(
182
+ behavior: ViewHostBehavior,
183
+ requestedProtocolVersion: unknown,
184
+ ): McpUiInitializeResult {
185
+ // Echo the version the view asked for when it names one — the view is
186
+ // the side with a fixed runtime; the host has no version-specific
187
+ // behavior to defend. Absent/malformed, answer with the spec's latest.
188
+ const protocolVersion =
189
+ typeof requestedProtocolVersion === "string" && requestedProtocolVersion.length > 0
190
+ ? requestedProtocolVersion
191
+ : LATEST_PROTOCOL_VERSION;
192
+ return {
193
+ protocolVersion,
194
+ hostInfo: behavior.hostInfo,
195
+ hostCapabilities: behavior.hostCapabilities,
196
+ hostContext: behavior.hostContext,
197
+ };
198
+ }
199
+
200
+ /** Build the in-band response for a relayed `tools/call`'s settled result. */
201
+ export function toolCallResponse(
202
+ id: ViewRequestId,
203
+ result: { [key: string]: unknown },
204
+ ): ViewHostOutbound {
205
+ return { jsonrpc: "2.0", id, result };
206
+ }
207
+
208
+ /**
209
+ * The spec-mannered farewell a detaching host posts (`ui/resource-teardown`).
210
+ * Sent WITHOUT an id — a host that is tearing the frame down cannot await a
211
+ * response, and an id-less JSON-RPC message is a notification the view may
212
+ * use for cleanup or ignore.
213
+ */
214
+ export function teardownMessage(): ViewHostOutbound {
215
+ return { jsonrpc: "2.0", method: RESOURCE_TEARDOWN_METHOD, params: {} };
216
+ }
217
+
218
+ /**
219
+ * Feed one inbound postMessage payload to the machine.
220
+ *
221
+ * The contract, exactly:
222
+ * - non-RPC data → ignored (not ours);
223
+ * - notifications (no id) → consumed silently, JSON-RPC-correctly; the
224
+ * `ui/notifications/initialized` ack is remembered on the state;
225
+ * - `ui/initialize` → answered spec-canonically; phase → `"connected"`
226
+ * (also from `"no-handshake"` — a late handshake still gets answered:
227
+ * the timeout labels a state, it does not close the door);
228
+ * - `tools/call` with a relay wired → `relay-tool-call` effect;
229
+ * - every other REQUEST → `method_not_supported`, honestly.
230
+ */
231
+ export function viewHostReceive(
232
+ state: ViewHostState,
233
+ behavior: ViewHostBehavior,
234
+ data: unknown,
235
+ ): ViewHostTransition {
236
+ const req = asInboundEnvelope(data);
237
+ if (req === undefined) return { state, effects: [] };
238
+
239
+ if (req.id === undefined) {
240
+ // A notification. Track the one the handshake defines; consume the rest.
241
+ if (req.method === "ui/notifications/initialized" && !state.initializedSeen) {
242
+ return { state: { ...state, initializedSeen: true }, effects: [] };
243
+ }
244
+ return { state, effects: [] };
245
+ }
246
+
247
+ if (req.method === INITIALIZE_METHOD) {
248
+ const result = initializeResult(behavior, req.params?.["protocolVersion"]);
249
+ return {
250
+ state: { ...state, phase: "connected" },
251
+ effects: [{ kind: "respond", message: { jsonrpc: "2.0", id: req.id, result } }],
252
+ };
253
+ }
254
+
255
+ if (req.method === TOOLS_CALL_METHOD && behavior.toolRelay) {
256
+ const name = req.params?.["name"];
257
+ if (typeof name === "string") {
258
+ const args = req.params?.["arguments"];
259
+ return {
260
+ state,
261
+ effects: [
262
+ {
263
+ kind: "relay-tool-call",
264
+ id: req.id,
265
+ name,
266
+ ...(isPlainObject(args) ? { arguments: args } : {}),
267
+ },
268
+ ],
269
+ };
270
+ }
271
+ // fall through: a nameless tools/call is not a call we can relay.
272
+ }
273
+
274
+ return {
275
+ state,
276
+ effects: [
277
+ {
278
+ kind: "respond",
279
+ message: {
280
+ jsonrpc: "2.0",
281
+ id: req.id,
282
+ error: {
283
+ code: METHOD_NOT_SUPPORTED,
284
+ message: `method_not_supported: ${req.method} — this host answers ${INITIALIZE_METHOD}${behavior.toolRelay ? ` and ${TOOLS_CALL_METHOD}` : ""} only`,
285
+ },
286
+ },
287
+ },
288
+ ],
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Declare the negotiation window over. Meaningful only while
294
+ * `"negotiating"`: a connected view stays connected, and an already-lapsed
295
+ * one stays lapsed. The caller owns the clock — this machine has none.
296
+ */
297
+ export function viewHostElapsed(state: ViewHostState): ViewHostState {
298
+ return state.phase === "negotiating" ? { ...state, phase: "no-handshake" } : state;
299
+ }