@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/README.md +18 -0
- package/dist/action.d.ts +95 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +111 -0
- package/dist/card-mount.d.ts +18 -0
- package/dist/card-mount.d.ts.map +1 -1
- package/dist/card-mount.js +25 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/react.d.ts +94 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +158 -0
- package/dist/sandbox-page.d.ts +75 -0
- package/dist/sandbox-page.d.ts.map +1 -0
- package/dist/sandbox-page.js +40 -0
- package/dist/view-host-protocol.d.ts +167 -0
- package/dist/view-host-protocol.d.ts.map +1 -0
- package/dist/view-host-protocol.js +168 -0
- package/dist/view-host.d.ts +119 -0
- package/dist/view-host.d.ts.map +1 -0
- package/dist/view-host.js +143 -0
- package/package.json +21 -4
- package/src/action.ts +171 -0
- package/src/card-mount.ts +27 -0
- package/src/index.ts +44 -0
- package/src/react.tsx +268 -0
- package/src/sandbox-page.ts +116 -0
- package/src/view-host-protocol.ts +299 -0
- package/src/view-host.ts +241 -0
package/src/view-host.ts
ADDED
|
@@ -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
|
+
}
|