@guuey/mcp-apps-host 0.4.0 → 0.6.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.
@@ -0,0 +1,303 @@
1
+ /**
2
+ * `attachViewHost` — the DOM glue around `view-host-protocol.ts`'s pure
3
+ * machine (guuey#186 Gap 1). Framework-agnostic: any embedder with an
4
+ * iframe can play the MCP Apps Host role with one call; `react.tsx` is one
5
+ * convenience composition of exactly this, and a future full transcript
6
+ * renderer composes the same primitive differently.
7
+ *
8
+ * This module is glue by DESIGN — every decision (what to answer, what to
9
+ * refuse, when the negotiation window lapses) lives in the machine, which
10
+ * the Node-only publish gate can test. What genuinely needs a browser is
11
+ * this file's five moves: listen, identity-filter, post, time, detach —
12
+ * covered by the monorepo's Playwright leg (`e2e/`), which the guuey-sdks
13
+ * mirror deliberately does not carry.
14
+ *
15
+ * ## The identity filter (security invariant)
16
+ *
17
+ * Messages are matched by `event.source === frame.contentWindow`, NEVER by
18
+ * `event.origin`: a view frame runs `sandbox="allow-scripts"` WITHOUT
19
+ * `allow-same-origin`, so its origin is opaque — every message it posts
20
+ * carries `"null"`, a value every other sandboxed frame on the page
21
+ * shares, identifying nobody. The window handle is the only identity that
22
+ * names the frame; a frame with no `contentWindow` matches nothing rather
23
+ * than everything. Responses target `'*'` for the same reason: an opaque
24
+ * origin is not addressable by name, and the handshake payload carries no
25
+ * secrets — it is the result the spec defines for any host.
26
+ *
27
+ * Seeded from ggui's console `surface-host.ts` (donated, guuey#186 audit);
28
+ * re-derived here against the pure machine + our own tests.
29
+ */
30
+ import {
31
+ initialViewHostState,
32
+ resourceReadResponse,
33
+ teardownMessage,
34
+ toolCallResponse,
35
+ viewHostElapsed,
36
+ viewHostReceive,
37
+ type ViewHostBehavior,
38
+ type ViewHostOutbound,
39
+ type ViewHostPhase,
40
+ type ViewHostState,
41
+ } from "./view-host-protocol.js";
42
+ import type { McpResourceReadResult } from "./reader.js";
43
+ import {
44
+ unavailableToolCallResult,
45
+ type McpToolCallResult,
46
+ type McpToolStructuredContent,
47
+ type UiActionRequest,
48
+ } from "./action.js";
49
+ import type { McpUiResourcePayload } from "./block-ui.js";
50
+ import type { McpUiHostCapabilities, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
51
+ import type { ViewHostInfo } from "./view-host-protocol.js";
52
+
53
+ /**
54
+ * The slice of an `HTMLIFrameElement` this host actually touches — a
55
+ * structural type so Node tests (and non-DOM hosts) can hand in a fake
56
+ * without a single cast, same injection idiom as the package's readers and
57
+ * relays. A real iframe element satisfies it as-is.
58
+ */
59
+ export interface ViewFrameLike {
60
+ readonly contentWindow: { postMessage(message: unknown, targetOrigin: string): void } | null;
61
+ readonly clientWidth: number;
62
+ readonly clientHeight: number;
63
+ }
64
+
65
+ /** The inbound side: what this host needs from `window`. */
66
+ export interface ViewHostEvents {
67
+ addEventListener(
68
+ type: "message",
69
+ listener: (event: { data: unknown; source: unknown }) => void,
70
+ ): void;
71
+ removeEventListener(
72
+ type: "message",
73
+ listener: (event: { data: unknown; source: unknown }) => void,
74
+ ): void;
75
+ }
76
+
77
+ export interface AttachViewHostConfig {
78
+ /**
79
+ * Capabilities to advertise in the initialize result. Default: `{}` —
80
+ * correct for views that ride their own live channel (ggui views do;
81
+ * they boot from their seeded envelope and talk to their pod directly),
82
+ * and the honest floor for everyone else: advertise only what the
83
+ * embedder implements. Exception: wiring {@link onCallTool} advertises
84
+ * `serverTools` automatically — a wired relay IS the implementation —
85
+ * and an explicit `hostCapabilities.serverTools` still wins.
86
+ */
87
+ hostCapabilities?: McpUiHostCapabilities;
88
+ /** Host identity for the initialize result. */
89
+ hostInfo?: ViewHostInfo;
90
+ /**
91
+ * Extra context merged over the derived defaults (locale from
92
+ * `navigator`, container dimensions from the frame when it has laid
93
+ * out — a 0×0 pre-layout reading is a lie the spec type shouldn't be
94
+ * told). Keys given here win.
95
+ */
96
+ hostContext?: McpUiHostContext;
97
+ /**
98
+ * The `tools/call` relay — a PRIVILEGE boundary, default off: with no
99
+ * hook, the machine refuses `tools/call` in-band and advertises no
100
+ * `serverTools`. Wire `createMcpUiActionRelay` (or your own) to let the
101
+ * mounted view reach tools over a transport the embedder owns. The
102
+ * request's `name`/`arguments` are VIEW-CONTROLLED wire data — the hook
103
+ * owns allowlisting and validation (`createMcpUiActionRelay` does both).
104
+ */
105
+ onCallTool?: (request: UiActionRequest) => Promise<McpToolCallResult>;
106
+ /**
107
+ * The mounted resource's `ui://` locator — the scope stamped on every
108
+ * relayed {@link UiActionRequest}. Required for the relay to fire;
109
+ * `<GuueyView>` fills it from the mount automatically.
110
+ */
111
+ resourceUri?: string;
112
+ /**
113
+ * The `resources/read` relay — a PRIVILEGE boundary like
114
+ * {@link onCallTool}, default off: with no hook, the machine refuses
115
+ * `resources/read` in-band and advertises no `serverResources`. The hook
116
+ * is structurally the SAME transport `createMcpUiResourceReader`
117
+ * assembles over ({@link CreateMcpUiResourceReaderDeps.readResource}) —
118
+ * a host with a locator reader wires the identical function here. Trust
119
+ * rules ride the reader discipline (`reader.ts`): enforcement lives
120
+ * INSIDE the transport; a miss, a deny, and a throw all answer the view
121
+ * with the one `Resource not found` error (deny == miss — no oracle).
122
+ */
123
+ onReadResource?: (uri: string) => Promise<McpResourceReadResult | undefined>;
124
+ /**
125
+ * The view reported its content size (`ui/notifications/size-changed` —
126
+ * spec notification). Whether and how to resize the frame is the
127
+ * embedder's layout decision; `<GuueyView autoResize>` is one wiring of
128
+ * exactly this callback.
129
+ */
130
+ onSizeChanged?: (size: { width?: number; height?: number }) => void;
131
+ /** Observe phase transitions (see {@link ViewHostPhase}). */
132
+ onPhaseChange?: (phase: ViewHostPhase) => void;
133
+ /**
134
+ * How long to wait for `ui/initialize` before declaring
135
+ * `"no-handshake"` (ms). `0` disables the timer. Default 8000 — a view
136
+ * runtime negotiates immediately after parse; this bound exists to turn
137
+ * "blank forever" into a labeled state, not to race slow networks.
138
+ */
139
+ negotiationTimeoutMs?: number;
140
+ /** Message-event source, injectable for tests. Default: `window`. */
141
+ events?: ViewHostEvents;
142
+ }
143
+
144
+ const DEFAULT_HOST_INFO: ViewHostInfo = { name: "guuey-view-host", version: "1" };
145
+ const DEFAULT_NEGOTIATION_TIMEOUT_MS = 8000;
146
+
147
+ /** Derived + configured context, per the {@link AttachViewHostConfig.hostContext} contract. */
148
+ function hostContextFor(frame: ViewFrameLike, config: AttachViewHostConfig): McpUiHostContext {
149
+ return {
150
+ locale: typeof navigator !== "undefined" ? navigator.language : "en-US",
151
+ ...(frame.clientWidth > 0 && frame.clientHeight > 0
152
+ ? { containerDimensions: { width: frame.clientWidth, height: frame.clientHeight } }
153
+ : {}),
154
+ ...config.hostContext,
155
+ };
156
+ }
157
+
158
+ function behaviorFor(frame: ViewFrameLike, config: AttachViewHostConfig): ViewHostBehavior {
159
+ const relayWired = config.onCallTool !== undefined && config.resourceUri !== undefined;
160
+ const readWired = config.onReadResource !== undefined;
161
+ return {
162
+ hostInfo: config.hostInfo ?? DEFAULT_HOST_INFO,
163
+ hostCapabilities: {
164
+ // A wired relay IS the implementation — advertise it; an explicit
165
+ // hostCapabilities entry still wins (the serverTools precedent).
166
+ ...(relayWired ? { serverTools: {} } : {}),
167
+ ...(readWired ? { serverResources: {} } : {}),
168
+ ...config.hostCapabilities,
169
+ },
170
+ hostContext: hostContextFor(frame, config),
171
+ toolRelay: relayWired,
172
+ resourceRelay: readWired,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Re-narrow a read hook's answer at the trust boundary — hooks are embedder
178
+ * code (possibly plain JS), and the wire entry the view receives must be a
179
+ * real `contents[]` entry: `uri` required, a string payload arm required
180
+ * (a payload-less entry is a miss — the `createMcpUiResourceReader`
181
+ * discipline, applied to the WIRE entry rather than the mountable payload).
182
+ */
183
+ function narrowReadEntry(entry: McpResourceReadResult | undefined): McpResourceReadResult | undefined {
184
+ if (entry === undefined || typeof entry.uri !== "string") return undefined;
185
+ if (typeof entry.text !== "string" && typeof entry.blob !== "string") return undefined;
186
+ return {
187
+ uri: entry.uri,
188
+ ...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}),
189
+ ...(typeof entry.text === "string" ? { text: entry.text } : {}),
190
+ ...(typeof entry.blob === "string" ? { blob: entry.blob } : {}),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Attach the Host role to a mounted view frame. Returns a detach function;
196
+ * call it before the frame unmounts — it stops listening and posts the
197
+ * spec-mannered `ui/resource-teardown` farewell through the CACHED window
198
+ * handle (post-removal, `frame.contentWindow` is already null).
199
+ */
200
+ export function attachViewHost(frame: ViewFrameLike, config: AttachViewHostConfig = {}): () => void {
201
+ const cachedWindow = frame.contentWindow;
202
+
203
+ let state: ViewHostState = initialViewHostState();
204
+
205
+ const setState = (next: ViewHostState): void => {
206
+ const phaseChanged = next.phase !== state.phase;
207
+ state = next;
208
+ if (phaseChanged) config.onPhaseChange?.(next.phase);
209
+ };
210
+
211
+ const post = (message: ViewHostOutbound): void => {
212
+ frame.contentWindow?.postMessage(message, "*");
213
+ };
214
+
215
+ const relay = (id: number | string, name: string, args?: McpToolStructuredContent): void => {
216
+ const { onCallTool, resourceUri } = config;
217
+ // The machine only emits the effect when the relay is wired (behavior
218
+ // is derived from this same config), so these are invariants, not
219
+ // runtime branches a view can steer.
220
+ if (onCallTool === undefined || resourceUri === undefined) return;
221
+ onCallTool({ resourceUri, name, ...(args === undefined ? {} : { arguments: args }) }).then(
222
+ (result) => post(toolCallResponse(id, result)),
223
+ // A relay hook that rejects (createMcpUiActionRelay never does, but
224
+ // the hook is embedder code) still owes the view an answer — the
225
+ // same in-band unavailable the relay itself uses, never a hang.
226
+ () => post(toolCallResponse(id, unavailableToolCallResult())),
227
+ );
228
+ };
229
+
230
+ const relayRead = (id: number | string, uri: string): void => {
231
+ const { onReadResource } = config;
232
+ if (onReadResource === undefined) return; // machine-guarded invariant, as with `relay`
233
+ onReadResource(uri).then(
234
+ (entry) => post(resourceReadResponse(id, narrowReadEntry(entry))),
235
+ // A throwing hook still owes the view an answer — the same not-found
236
+ // the reader discipline gives a deny (deny == miss), never a hang.
237
+ () => post(resourceReadResponse(id, undefined)),
238
+ );
239
+ };
240
+
241
+ const onMessage = (event: { data: unknown; source: unknown }): void => {
242
+ if (frame.contentWindow === null || event.source !== frame.contentWindow) return;
243
+ const { state: next, effects } = viewHostReceive(state, behaviorFor(frame, config), event.data);
244
+ setState(next);
245
+ for (const effect of effects) {
246
+ if (effect.kind === "respond") post(effect.message);
247
+ else if (effect.kind === "relay-tool-call") relay(effect.id, effect.name, effect.arguments);
248
+ else if (effect.kind === "relay-resource-read") relayRead(effect.id, effect.uri);
249
+ else {
250
+ config.onSizeChanged?.({
251
+ ...(effect.width !== undefined ? { width: effect.width } : {}),
252
+ ...(effect.height !== undefined ? { height: effect.height } : {}),
253
+ });
254
+ }
255
+ }
256
+ };
257
+
258
+ // One listener, two subscription paths: the injectable seam for Node
259
+ // tests, and `window` — whose lib.dom listener typing wants the concrete
260
+ // `MessageEvent` — for the browser default.
261
+ const subscribe = (): (() => void) => {
262
+ const { events } = config;
263
+ if (events !== undefined) {
264
+ events.addEventListener("message", onMessage);
265
+ return () => events.removeEventListener("message", onMessage);
266
+ }
267
+ const domListener = (event: MessageEvent): void => onMessage(event);
268
+ window.addEventListener("message", domListener);
269
+ return () => window.removeEventListener("message", domListener);
270
+ };
271
+ const unsubscribe = subscribe();
272
+
273
+ const timeoutMs = config.negotiationTimeoutMs ?? DEFAULT_NEGOTIATION_TIMEOUT_MS;
274
+ const timer =
275
+ timeoutMs > 0 ? setTimeout(() => setState(viewHostElapsed(state)), timeoutMs) : undefined;
276
+
277
+ return () => {
278
+ if (timer !== undefined) clearTimeout(timer);
279
+ unsubscribe();
280
+ cachedWindow?.postMessage(teardownMessage(), "*");
281
+ };
282
+ }
283
+
284
+ /**
285
+ * The document a {@link McpUiResourcePayload} mounts: `text` verbatim, or
286
+ * `blob` base64-decoded as UTF-8. `undefined` when the payload carries
287
+ * neither — nothing to put in `srcdoc`.
288
+ */
289
+ export function viewDocumentHtml(resource: McpUiResourcePayload): string | undefined {
290
+ if (typeof resource.text === "string") return resource.text;
291
+ if (typeof resource.blob === "string") {
292
+ try {
293
+ const bytes = Uint8Array.from(atob(resource.blob), (c) => c.charCodeAt(0));
294
+ return new TextDecoder().decode(bytes);
295
+ } catch {
296
+ // Malformed base64 is producer-side wire data, not an embedder bug —
297
+ // the honest answer is "no document" (the same labeled state a
298
+ // payload with neither field gets), not a render-time throw.
299
+ return undefined;
300
+ }
301
+ }
302
+ return undefined;
303
+ }