@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.
@@ -0,0 +1,241 @@
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
+ teardownMessage,
33
+ toolCallResponse,
34
+ viewHostElapsed,
35
+ viewHostReceive,
36
+ type ViewHostBehavior,
37
+ type ViewHostOutbound,
38
+ type ViewHostPhase,
39
+ type ViewHostState,
40
+ } from "./view-host-protocol.js";
41
+ import {
42
+ unavailableToolCallResult,
43
+ type McpToolCallResult,
44
+ type McpToolStructuredContent,
45
+ type UiActionRequest,
46
+ } from "./action.js";
47
+ import type { McpUiResourcePayload } from "./block-ui.js";
48
+ import type { McpUiHostCapabilities, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
49
+ import type { ViewHostInfo } from "./view-host-protocol.js";
50
+
51
+ /**
52
+ * The slice of an `HTMLIFrameElement` this host actually touches — a
53
+ * structural type so Node tests (and non-DOM hosts) can hand in a fake
54
+ * without a single cast, same injection idiom as the package's readers and
55
+ * relays. A real iframe element satisfies it as-is.
56
+ */
57
+ export interface ViewFrameLike {
58
+ readonly contentWindow: { postMessage(message: unknown, targetOrigin: string): void } | null;
59
+ readonly clientWidth: number;
60
+ readonly clientHeight: number;
61
+ }
62
+
63
+ /** The inbound side: what this host needs from `window`. */
64
+ export interface ViewHostEvents {
65
+ addEventListener(
66
+ type: "message",
67
+ listener: (event: { data: unknown; source: unknown }) => void,
68
+ ): void;
69
+ removeEventListener(
70
+ type: "message",
71
+ listener: (event: { data: unknown; source: unknown }) => void,
72
+ ): void;
73
+ }
74
+
75
+ export interface AttachViewHostConfig {
76
+ /**
77
+ * Capabilities to advertise in the initialize result. Default: `{}` —
78
+ * correct for views that ride their own live channel (ggui views do;
79
+ * they boot from their seeded envelope and talk to their pod directly),
80
+ * and the honest floor for everyone else: advertise only what the
81
+ * embedder implements. Exception: wiring {@link onCallTool} advertises
82
+ * `serverTools` automatically — a wired relay IS the implementation —
83
+ * and an explicit `hostCapabilities.serverTools` still wins.
84
+ */
85
+ hostCapabilities?: McpUiHostCapabilities;
86
+ /** Host identity for the initialize result. */
87
+ hostInfo?: ViewHostInfo;
88
+ /**
89
+ * Extra context merged over the derived defaults (locale from
90
+ * `navigator`, container dimensions from the frame when it has laid
91
+ * out — a 0×0 pre-layout reading is a lie the spec type shouldn't be
92
+ * told). Keys given here win.
93
+ */
94
+ hostContext?: McpUiHostContext;
95
+ /**
96
+ * The `tools/call` relay — a PRIVILEGE boundary, default off: with no
97
+ * hook, the machine refuses `tools/call` in-band and advertises no
98
+ * `serverTools`. Wire `createMcpUiActionRelay` (or your own) to let the
99
+ * mounted view reach tools over a transport the embedder owns. The
100
+ * request's `name`/`arguments` are VIEW-CONTROLLED wire data — the hook
101
+ * owns allowlisting and validation (`createMcpUiActionRelay` does both).
102
+ */
103
+ onCallTool?: (request: UiActionRequest) => Promise<McpToolCallResult>;
104
+ /**
105
+ * The mounted resource's `ui://` locator — the scope stamped on every
106
+ * relayed {@link UiActionRequest}. Required for the relay to fire;
107
+ * `<GuueyView>` fills it from the mount automatically.
108
+ */
109
+ resourceUri?: string;
110
+ /** Observe phase transitions (see {@link ViewHostPhase}). */
111
+ onPhaseChange?: (phase: ViewHostPhase) => void;
112
+ /**
113
+ * How long to wait for `ui/initialize` before declaring
114
+ * `"no-handshake"` (ms). `0` disables the timer. Default 8000 — a view
115
+ * runtime negotiates immediately after parse; this bound exists to turn
116
+ * "blank forever" into a labeled state, not to race slow networks.
117
+ */
118
+ negotiationTimeoutMs?: number;
119
+ /** Message-event source, injectable for tests. Default: `window`. */
120
+ events?: ViewHostEvents;
121
+ }
122
+
123
+ const DEFAULT_HOST_INFO: ViewHostInfo = { name: "guuey-view-host", version: "1" };
124
+ const DEFAULT_NEGOTIATION_TIMEOUT_MS = 8000;
125
+
126
+ /** Derived + configured context, per the {@link AttachViewHostConfig.hostContext} contract. */
127
+ function hostContextFor(frame: ViewFrameLike, config: AttachViewHostConfig): McpUiHostContext {
128
+ return {
129
+ locale: typeof navigator !== "undefined" ? navigator.language : "en-US",
130
+ ...(frame.clientWidth > 0 && frame.clientHeight > 0
131
+ ? { containerDimensions: { width: frame.clientWidth, height: frame.clientHeight } }
132
+ : {}),
133
+ ...config.hostContext,
134
+ };
135
+ }
136
+
137
+ function behaviorFor(frame: ViewFrameLike, config: AttachViewHostConfig): ViewHostBehavior {
138
+ const relayWired = config.onCallTool !== undefined && config.resourceUri !== undefined;
139
+ return {
140
+ hostInfo: config.hostInfo ?? DEFAULT_HOST_INFO,
141
+ hostCapabilities: {
142
+ ...(relayWired ? { serverTools: {} } : {}),
143
+ ...config.hostCapabilities,
144
+ },
145
+ hostContext: hostContextFor(frame, config),
146
+ toolRelay: relayWired,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Attach the Host role to a mounted view frame. Returns a detach function;
152
+ * call it before the frame unmounts — it stops listening and posts the
153
+ * spec-mannered `ui/resource-teardown` farewell through the CACHED window
154
+ * handle (post-removal, `frame.contentWindow` is already null).
155
+ */
156
+ export function attachViewHost(frame: ViewFrameLike, config: AttachViewHostConfig = {}): () => void {
157
+ const cachedWindow = frame.contentWindow;
158
+
159
+ let state: ViewHostState = initialViewHostState();
160
+
161
+ const setState = (next: ViewHostState): void => {
162
+ const phaseChanged = next.phase !== state.phase;
163
+ state = next;
164
+ if (phaseChanged) config.onPhaseChange?.(next.phase);
165
+ };
166
+
167
+ const post = (message: ViewHostOutbound): void => {
168
+ frame.contentWindow?.postMessage(message, "*");
169
+ };
170
+
171
+ const relay = (id: number | string, name: string, args?: McpToolStructuredContent): void => {
172
+ const { onCallTool, resourceUri } = config;
173
+ // The machine only emits the effect when the relay is wired (behavior
174
+ // is derived from this same config), so these are invariants, not
175
+ // runtime branches a view can steer.
176
+ if (onCallTool === undefined || resourceUri === undefined) return;
177
+ onCallTool({ resourceUri, name, ...(args === undefined ? {} : { arguments: args }) }).then(
178
+ (result) => post(toolCallResponse(id, result)),
179
+ // A relay hook that rejects (createMcpUiActionRelay never does, but
180
+ // the hook is embedder code) still owes the view an answer — the
181
+ // same in-band unavailable the relay itself uses, never a hang.
182
+ () => post(toolCallResponse(id, unavailableToolCallResult())),
183
+ );
184
+ };
185
+
186
+ const onMessage = (event: { data: unknown; source: unknown }): void => {
187
+ if (frame.contentWindow === null || event.source !== frame.contentWindow) return;
188
+ const { state: next, effects } = viewHostReceive(state, behaviorFor(frame, config), event.data);
189
+ setState(next);
190
+ for (const effect of effects) {
191
+ if (effect.kind === "respond") post(effect.message);
192
+ else relay(effect.id, effect.name, effect.arguments);
193
+ }
194
+ };
195
+
196
+ // One listener, two subscription paths: the injectable seam for Node
197
+ // tests, and `window` — whose lib.dom listener typing wants the concrete
198
+ // `MessageEvent` — for the browser default.
199
+ const subscribe = (): (() => void) => {
200
+ const { events } = config;
201
+ if (events !== undefined) {
202
+ events.addEventListener("message", onMessage);
203
+ return () => events.removeEventListener("message", onMessage);
204
+ }
205
+ const domListener = (event: MessageEvent): void => onMessage(event);
206
+ window.addEventListener("message", domListener);
207
+ return () => window.removeEventListener("message", domListener);
208
+ };
209
+ const unsubscribe = subscribe();
210
+
211
+ const timeoutMs = config.negotiationTimeoutMs ?? DEFAULT_NEGOTIATION_TIMEOUT_MS;
212
+ const timer =
213
+ timeoutMs > 0 ? setTimeout(() => setState(viewHostElapsed(state)), timeoutMs) : undefined;
214
+
215
+ return () => {
216
+ if (timer !== undefined) clearTimeout(timer);
217
+ unsubscribe();
218
+ cachedWindow?.postMessage(teardownMessage(), "*");
219
+ };
220
+ }
221
+
222
+ /**
223
+ * The document a {@link McpUiResourcePayload} mounts: `text` verbatim, or
224
+ * `blob` base64-decoded as UTF-8. `undefined` when the payload carries
225
+ * neither — nothing to put in `srcdoc`.
226
+ */
227
+ export function viewDocumentHtml(resource: McpUiResourcePayload): string | undefined {
228
+ if (typeof resource.text === "string") return resource.text;
229
+ if (typeof resource.blob === "string") {
230
+ try {
231
+ const bytes = Uint8Array.from(atob(resource.blob), (c) => c.charCodeAt(0));
232
+ return new TextDecoder().decode(bytes);
233
+ } catch {
234
+ // Malformed base64 is producer-side wire data, not an embedder bug —
235
+ // the honest answer is "no document" (the same labeled state a
236
+ // payload with neither field gets), not a render-time throw.
237
+ return undefined;
238
+ }
239
+ }
240
+ return undefined;
241
+ }