@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.
- package/README.md +18 -0
- package/dist/action.d.ts +6 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +11 -6
- 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 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/react.d.ts +109 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +184 -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 +211 -0
- package/dist/view-host-protocol.d.ts.map +1 -0
- package/dist/view-host-protocol.js +230 -0
- package/dist/view-host.d.ts +142 -0
- package/dist/view-host.d.ts.map +1 -0
- package/dist/view-host.js +184 -0
- package/package.json +19 -2
- package/src/action.ts +11 -6
- package/src/card-mount.ts +27 -0
- package/src/index.ts +35 -0
- package/src/react.tsx +314 -0
- package/src/sandbox-page.ts +116 -0
- package/src/view-host-protocol.ts +399 -0
- package/src/view-host.ts +303 -0
|
@@ -0,0 +1,184 @@
|
|
|
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 { initialViewHostState, resourceReadResponse, teardownMessage, toolCallResponse, viewHostElapsed, viewHostReceive, } from "./view-host-protocol.js";
|
|
31
|
+
import { unavailableToolCallResult, } from "./action.js";
|
|
32
|
+
const DEFAULT_HOST_INFO = { name: "guuey-view-host", version: "1" };
|
|
33
|
+
const DEFAULT_NEGOTIATION_TIMEOUT_MS = 8000;
|
|
34
|
+
/** Derived + configured context, per the {@link AttachViewHostConfig.hostContext} contract. */
|
|
35
|
+
function hostContextFor(frame, config) {
|
|
36
|
+
return {
|
|
37
|
+
locale: typeof navigator !== "undefined" ? navigator.language : "en-US",
|
|
38
|
+
...(frame.clientWidth > 0 && frame.clientHeight > 0
|
|
39
|
+
? { containerDimensions: { width: frame.clientWidth, height: frame.clientHeight } }
|
|
40
|
+
: {}),
|
|
41
|
+
...config.hostContext,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function behaviorFor(frame, config) {
|
|
45
|
+
const relayWired = config.onCallTool !== undefined && config.resourceUri !== undefined;
|
|
46
|
+
const readWired = config.onReadResource !== undefined;
|
|
47
|
+
return {
|
|
48
|
+
hostInfo: config.hostInfo ?? DEFAULT_HOST_INFO,
|
|
49
|
+
hostCapabilities: {
|
|
50
|
+
// A wired relay IS the implementation — advertise it; an explicit
|
|
51
|
+
// hostCapabilities entry still wins (the serverTools precedent).
|
|
52
|
+
...(relayWired ? { serverTools: {} } : {}),
|
|
53
|
+
...(readWired ? { serverResources: {} } : {}),
|
|
54
|
+
...config.hostCapabilities,
|
|
55
|
+
},
|
|
56
|
+
hostContext: hostContextFor(frame, config),
|
|
57
|
+
toolRelay: relayWired,
|
|
58
|
+
resourceRelay: readWired,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Re-narrow a read hook's answer at the trust boundary — hooks are embedder
|
|
63
|
+
* code (possibly plain JS), and the wire entry the view receives must be a
|
|
64
|
+
* real `contents[]` entry: `uri` required, a string payload arm required
|
|
65
|
+
* (a payload-less entry is a miss — the `createMcpUiResourceReader`
|
|
66
|
+
* discipline, applied to the WIRE entry rather than the mountable payload).
|
|
67
|
+
*/
|
|
68
|
+
function narrowReadEntry(entry) {
|
|
69
|
+
if (entry === undefined || typeof entry.uri !== "string")
|
|
70
|
+
return undefined;
|
|
71
|
+
if (typeof entry.text !== "string" && typeof entry.blob !== "string")
|
|
72
|
+
return undefined;
|
|
73
|
+
return {
|
|
74
|
+
uri: entry.uri,
|
|
75
|
+
...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}),
|
|
76
|
+
...(typeof entry.text === "string" ? { text: entry.text } : {}),
|
|
77
|
+
...(typeof entry.blob === "string" ? { blob: entry.blob } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Attach the Host role to a mounted view frame. Returns a detach function;
|
|
82
|
+
* call it before the frame unmounts — it stops listening and posts the
|
|
83
|
+
* spec-mannered `ui/resource-teardown` farewell through the CACHED window
|
|
84
|
+
* handle (post-removal, `frame.contentWindow` is already null).
|
|
85
|
+
*/
|
|
86
|
+
export function attachViewHost(frame, config = {}) {
|
|
87
|
+
const cachedWindow = frame.contentWindow;
|
|
88
|
+
let state = initialViewHostState();
|
|
89
|
+
const setState = (next) => {
|
|
90
|
+
const phaseChanged = next.phase !== state.phase;
|
|
91
|
+
state = next;
|
|
92
|
+
if (phaseChanged)
|
|
93
|
+
config.onPhaseChange?.(next.phase);
|
|
94
|
+
};
|
|
95
|
+
const post = (message) => {
|
|
96
|
+
frame.contentWindow?.postMessage(message, "*");
|
|
97
|
+
};
|
|
98
|
+
const relay = (id, name, args) => {
|
|
99
|
+
const { onCallTool, resourceUri } = config;
|
|
100
|
+
// The machine only emits the effect when the relay is wired (behavior
|
|
101
|
+
// is derived from this same config), so these are invariants, not
|
|
102
|
+
// runtime branches a view can steer.
|
|
103
|
+
if (onCallTool === undefined || resourceUri === undefined)
|
|
104
|
+
return;
|
|
105
|
+
onCallTool({ resourceUri, name, ...(args === undefined ? {} : { arguments: args }) }).then((result) => post(toolCallResponse(id, result)),
|
|
106
|
+
// A relay hook that rejects (createMcpUiActionRelay never does, but
|
|
107
|
+
// the hook is embedder code) still owes the view an answer — the
|
|
108
|
+
// same in-band unavailable the relay itself uses, never a hang.
|
|
109
|
+
() => post(toolCallResponse(id, unavailableToolCallResult())));
|
|
110
|
+
};
|
|
111
|
+
const relayRead = (id, uri) => {
|
|
112
|
+
const { onReadResource } = config;
|
|
113
|
+
if (onReadResource === undefined)
|
|
114
|
+
return; // machine-guarded invariant, as with `relay`
|
|
115
|
+
onReadResource(uri).then((entry) => post(resourceReadResponse(id, narrowReadEntry(entry))),
|
|
116
|
+
// A throwing hook still owes the view an answer — the same not-found
|
|
117
|
+
// the reader discipline gives a deny (deny == miss), never a hang.
|
|
118
|
+
() => post(resourceReadResponse(id, undefined)));
|
|
119
|
+
};
|
|
120
|
+
const onMessage = (event) => {
|
|
121
|
+
if (frame.contentWindow === null || event.source !== frame.contentWindow)
|
|
122
|
+
return;
|
|
123
|
+
const { state: next, effects } = viewHostReceive(state, behaviorFor(frame, config), event.data);
|
|
124
|
+
setState(next);
|
|
125
|
+
for (const effect of effects) {
|
|
126
|
+
if (effect.kind === "respond")
|
|
127
|
+
post(effect.message);
|
|
128
|
+
else if (effect.kind === "relay-tool-call")
|
|
129
|
+
relay(effect.id, effect.name, effect.arguments);
|
|
130
|
+
else if (effect.kind === "relay-resource-read")
|
|
131
|
+
relayRead(effect.id, effect.uri);
|
|
132
|
+
else {
|
|
133
|
+
config.onSizeChanged?.({
|
|
134
|
+
...(effect.width !== undefined ? { width: effect.width } : {}),
|
|
135
|
+
...(effect.height !== undefined ? { height: effect.height } : {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
// One listener, two subscription paths: the injectable seam for Node
|
|
141
|
+
// tests, and `window` — whose lib.dom listener typing wants the concrete
|
|
142
|
+
// `MessageEvent` — for the browser default.
|
|
143
|
+
const subscribe = () => {
|
|
144
|
+
const { events } = config;
|
|
145
|
+
if (events !== undefined) {
|
|
146
|
+
events.addEventListener("message", onMessage);
|
|
147
|
+
return () => events.removeEventListener("message", onMessage);
|
|
148
|
+
}
|
|
149
|
+
const domListener = (event) => onMessage(event);
|
|
150
|
+
window.addEventListener("message", domListener);
|
|
151
|
+
return () => window.removeEventListener("message", domListener);
|
|
152
|
+
};
|
|
153
|
+
const unsubscribe = subscribe();
|
|
154
|
+
const timeoutMs = config.negotiationTimeoutMs ?? DEFAULT_NEGOTIATION_TIMEOUT_MS;
|
|
155
|
+
const timer = timeoutMs > 0 ? setTimeout(() => setState(viewHostElapsed(state)), timeoutMs) : undefined;
|
|
156
|
+
return () => {
|
|
157
|
+
if (timer !== undefined)
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
unsubscribe();
|
|
160
|
+
cachedWindow?.postMessage(teardownMessage(), "*");
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The document a {@link McpUiResourcePayload} mounts: `text` verbatim, or
|
|
165
|
+
* `blob` base64-decoded as UTF-8. `undefined` when the payload carries
|
|
166
|
+
* neither — nothing to put in `srcdoc`.
|
|
167
|
+
*/
|
|
168
|
+
export function viewDocumentHtml(resource) {
|
|
169
|
+
if (typeof resource.text === "string")
|
|
170
|
+
return resource.text;
|
|
171
|
+
if (typeof resource.blob === "string") {
|
|
172
|
+
try {
|
|
173
|
+
const bytes = Uint8Array.from(atob(resource.blob), (c) => c.charCodeAt(0));
|
|
174
|
+
return new TextDecoder().decode(bytes);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Malformed base64 is producer-side wire data, not an embedder bug —
|
|
178
|
+
// the honest answer is "no document" (the same labeled state a
|
|
179
|
+
// payload with neither field gets), not a render-time throw.
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guuey/mcp-apps-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The MCP Apps (SEP-1865) Host role for guuey's chat surfaces — view-mount narrowing across UI channels, ui:// locator rehydration by resources/read, and the sandbox-trust channel contract. Vendor-neutral: any spec-following MCP App mounts through it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,14 +24,31 @@
|
|
|
24
24
|
"types": "./dist/narrowing.d.ts",
|
|
25
25
|
"import": "./dist/narrowing.js",
|
|
26
26
|
"default": "./dist/narrowing.js"
|
|
27
|
+
},
|
|
28
|
+
"./react": {
|
|
29
|
+
"react-native": "./src/react.tsx",
|
|
30
|
+
"types": "./dist/react.d.ts",
|
|
31
|
+
"import": "./dist/react.js",
|
|
32
|
+
"default": "./dist/react.js"
|
|
27
33
|
}
|
|
28
34
|
},
|
|
29
35
|
"dependencies": {
|
|
30
36
|
"@ggui-ai/protocol": "0.9.0",
|
|
31
|
-
"@
|
|
37
|
+
"@modelcontextprotocol/ext-apps": "1.7.5",
|
|
38
|
+
"@silverprotocol/core": "0.5.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"react": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
32
47
|
},
|
|
33
48
|
"devDependencies": {
|
|
34
49
|
"@types/node": "^24.0.0",
|
|
50
|
+
"@types/react": "^19.0.0",
|
|
51
|
+
"react": "^19.0.0",
|
|
35
52
|
"typescript": "^5.0.0",
|
|
36
53
|
"vitest": "^3.0.0"
|
|
37
54
|
},
|
package/src/action.ts
CHANGED
|
@@ -58,7 +58,12 @@ export const UI_ACTION_TOOLS: ReadonlySet<string> = new Set([
|
|
|
58
58
|
export const UI_ACTION_UNAVAILABLE_TEXT =
|
|
59
59
|
"This action isn't available right now.";
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The in-band `isError` result for an action that cannot be performed —
|
|
63
|
+
* the relay's own refusals use it, and `attachViewHost` posts it when an
|
|
64
|
+
* embedder-supplied relay hook rejects (the view is always answered).
|
|
65
|
+
*/
|
|
66
|
+
export function unavailableToolCallResult(): McpToolCallResult {
|
|
62
67
|
return {
|
|
63
68
|
content: [{ type: "text", text: UI_ACTION_UNAVAILABLE_TEXT }],
|
|
64
69
|
isError: true,
|
|
@@ -152,15 +157,15 @@ export function createMcpUiActionRelay(
|
|
|
152
157
|
deps: CreateMcpUiActionRelayDeps,
|
|
153
158
|
): (request: UiActionRequest) => Promise<McpToolCallResult> {
|
|
154
159
|
return async (request) => {
|
|
155
|
-
if (!UI_ACTION_TOOLS.has(request.name)) return
|
|
156
|
-
if (!request.resourceUri.startsWith("ui://")) return
|
|
160
|
+
if (!UI_ACTION_TOOLS.has(request.name)) return unavailableToolCallResult();
|
|
161
|
+
if (!request.resourceUri.startsWith("ui://")) return unavailableToolCallResult();
|
|
157
162
|
let raw: unknown;
|
|
158
163
|
try {
|
|
159
164
|
raw = await deps.callTool(request.resourceUri, request.name, request.arguments);
|
|
160
165
|
} catch {
|
|
161
|
-
return
|
|
166
|
+
return unavailableToolCallResult(); // transport failure == unavailable, in-band
|
|
162
167
|
}
|
|
163
|
-
if (raw === undefined) return
|
|
164
|
-
return asToolCallResult(raw) ??
|
|
168
|
+
if (raw === undefined) return unavailableToolCallResult();
|
|
169
|
+
return asToolCallResult(raw) ?? unavailableToolCallResult();
|
|
165
170
|
};
|
|
166
171
|
}
|
package/src/card-mount.ts
CHANGED
|
@@ -143,6 +143,33 @@ export function snapshotViewMount(cardSnapshot: JsonValue): ViewMount | undefine
|
|
|
143
143
|
return undefined;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The resolved-only convenience over the mount union (guuey#186 G6): every
|
|
148
|
+
* consumer that renders was writing the same two-call walk — narrow the
|
|
149
|
+
* union, then feed the `"locator"` arm to a reader. This collapses it:
|
|
150
|
+
*
|
|
151
|
+
* - already-resolved mounts pass through untouched (no reader round-trip);
|
|
152
|
+
* - a `"locator"` arm resolves via the reader — or the honest `undefined`
|
|
153
|
+
* (placeholder) when no reader is wired: never a stale mount;
|
|
154
|
+
* - a reader that answers with ANOTHER locator is treated as a miss. The
|
|
155
|
+
* {@link UiResourceReader} contract says a read yields mount material or
|
|
156
|
+
* nothing (guuey#127); a locator answer would loop, so the honest
|
|
157
|
+
* reading is "could not resolve", not recursion.
|
|
158
|
+
*
|
|
159
|
+
* Takes `ViewMount | undefined` so it chains directly off
|
|
160
|
+
* `toolResultViewMount`/`snapshotViewMount` without a narrowing dance at
|
|
161
|
+
* the call site.
|
|
162
|
+
*/
|
|
163
|
+
export async function resolveViewMount(
|
|
164
|
+
mount: ViewMount | undefined,
|
|
165
|
+
reader?: UiResourceReader,
|
|
166
|
+
): Promise<ResolvedViewMount | undefined> {
|
|
167
|
+
if (mount === undefined || mount.channel !== "locator") return mount;
|
|
168
|
+
if (reader === undefined) return undefined;
|
|
169
|
+
const read = await reader(mount.resourceUri);
|
|
170
|
+
return read === undefined || read.channel === "locator" ? undefined : read;
|
|
171
|
+
}
|
|
172
|
+
|
|
146
173
|
/**
|
|
147
174
|
* The blocks to scan inside a card snapshot: the stored `AgArtifact`'s `parts`
|
|
148
175
|
* when present, then the snapshot root itself — exactly `snapshotUiResource`'s own
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
type GguiShellHtmlOptions,
|
|
29
29
|
} from "./ggui-render.js";
|
|
30
30
|
export {
|
|
31
|
+
resolveViewMount,
|
|
31
32
|
snapshotViewMount,
|
|
32
33
|
toolResultViewMount,
|
|
33
34
|
type LocatorViewMount,
|
|
@@ -45,6 +46,7 @@ export {
|
|
|
45
46
|
export {
|
|
46
47
|
asToolCallResult,
|
|
47
48
|
createMcpUiActionRelay,
|
|
49
|
+
unavailableToolCallResult,
|
|
48
50
|
UI_ACTION_TOOLS,
|
|
49
51
|
UI_ACTION_UNAVAILABLE_TEXT,
|
|
50
52
|
type CreateMcpUiActionRelayDeps,
|
|
@@ -53,3 +55,36 @@ export {
|
|
|
53
55
|
type McpToolStructuredContent,
|
|
54
56
|
type UiActionRequest,
|
|
55
57
|
} from "./action.js";
|
|
58
|
+
export {
|
|
59
|
+
initializeResult,
|
|
60
|
+
initialViewHostState,
|
|
61
|
+
resourceReadResponse,
|
|
62
|
+
RESOURCES_READ_METHOD,
|
|
63
|
+
teardownMessage,
|
|
64
|
+
toolCallResponse,
|
|
65
|
+
TOOLS_CALL_METHOD,
|
|
66
|
+
viewHostElapsed,
|
|
67
|
+
viewHostReceive,
|
|
68
|
+
type ViewHostBehavior,
|
|
69
|
+
type ViewHostEffect,
|
|
70
|
+
type ViewHostOutbound,
|
|
71
|
+
type ViewHostPhase,
|
|
72
|
+
type ViewHostInfo,
|
|
73
|
+
type ViewHostState,
|
|
74
|
+
type ViewHostTransition,
|
|
75
|
+
type ViewRequestId,
|
|
76
|
+
} from "./view-host-protocol.js";
|
|
77
|
+
export {
|
|
78
|
+
attachViewHost,
|
|
79
|
+
viewDocumentHtml,
|
|
80
|
+
type AttachViewHostConfig,
|
|
81
|
+
type ViewFrameLike,
|
|
82
|
+
type ViewHostEvents,
|
|
83
|
+
} from "./view-host.js";
|
|
84
|
+
export {
|
|
85
|
+
attachSandboxPageDelivery,
|
|
86
|
+
isSandboxProxyReady,
|
|
87
|
+
SANDBOX_PROXY_READY_METHOD,
|
|
88
|
+
SANDBOX_RESOURCE_READY_METHOD,
|
|
89
|
+
type SandboxPageDeliveryConfig,
|
|
90
|
+
} from "./sandbox-page.js";
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
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"
|
|
60
|
+
| "hostInfo"
|
|
61
|
+
| "hostContext"
|
|
62
|
+
| "onCallTool"
|
|
63
|
+
| "onReadResource"
|
|
64
|
+
| "onSizeChanged"
|
|
65
|
+
| "negotiationTimeoutMs"
|
|
66
|
+
> {
|
|
67
|
+
/** The resolved card to mount (see `toolResultViewMount`/`resolveViewMount`). */
|
|
68
|
+
mount: ResolvedViewMount;
|
|
69
|
+
/**
|
|
70
|
+
* Opt into the TWO-ORIGIN mount: instead of `srcdoc`, the frame loads
|
|
71
|
+
* this host-served sandbox page (guuey's `/mcp-app-sandbox` pattern — the
|
|
72
|
+
* caller builds the full URL, channel/app query included) and the
|
|
73
|
+
* document is delivered over the page's relay protocol
|
|
74
|
+
* (`attachSandboxPageDelivery`). Why: a `srcdoc` frame INHERITS the
|
|
75
|
+
* embedder's CSP, so its egress confinement is whatever the page happens
|
|
76
|
+
* to carry; the sandbox page is served WITH the per-request CSP that
|
|
77
|
+
* confines the mount — and the untrusted document lands in the page's
|
|
78
|
+
* own inner opaque frame, never in this one. In this mode the frame's
|
|
79
|
+
* `sandbox` gains `allow-same-origin` — REQUIRED and safe: the frame
|
|
80
|
+
* holds the cross-origin RELAY PAGE (which must run as its real origin
|
|
81
|
+
* for its CSP + referrer checks to mean anything), never agent HTML.
|
|
82
|
+
* The page must be a genuinely different origin; a same-origin URL is
|
|
83
|
+
* refused with a labeled state, never mounted.
|
|
84
|
+
*
|
|
85
|
+
* `null` (as opposed to absent) means the two-origin mount is REQUIRED
|
|
86
|
+
* by the embedder's posture but no page is configured — the mount is
|
|
87
|
+
* refused with the same labeled state, and srcdoc is NEVER fallen back
|
|
88
|
+
* to (falling back would silently trade the caller's egress confinement
|
|
89
|
+
* for the page's CSP; the widget/Studio convergence posture).
|
|
90
|
+
*/
|
|
91
|
+
sandboxPageUrl?: string | null;
|
|
92
|
+
/**
|
|
93
|
+
* Apply the view's own size reports (`ui/notifications/size-changed` —
|
|
94
|
+
* spec surface) to the frame: a reported HEIGHT becomes the frame's
|
|
95
|
+
* height; width stays the container's (a transcript column owns its
|
|
96
|
+
* width). Default OFF — the primitive changes nothing for existing
|
|
97
|
+
* hosts; a caller's {@link AttachViewHostConfig.onSizeChanged} observer
|
|
98
|
+
* fires either way.
|
|
99
|
+
*/
|
|
100
|
+
autoResize?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Sandbox flags appended to the safe default (`allow-scripts`). Every
|
|
103
|
+
* entry widens what agent-generated HTML may do — `allow-same-origin`
|
|
104
|
+
* in particular hands it the embedder's origin. Prefer leaving unset.
|
|
105
|
+
* In `sandboxPageUrl` mode these forward to the INNER frame via the
|
|
106
|
+
* page's relay (which strips `allow-same-origin` regardless).
|
|
107
|
+
*/
|
|
108
|
+
dangerouslyAddSandboxFlags?: string[];
|
|
109
|
+
/** Permissions-Policy delegation for the frame. Default `clipboard-write`. */
|
|
110
|
+
allow?: string;
|
|
111
|
+
/** Accessible frame title. Default "Generated view". */
|
|
112
|
+
title?: string;
|
|
113
|
+
className?: string;
|
|
114
|
+
style?: CSSProperties;
|
|
115
|
+
/** Observe the negotiation phase (the same states the default UI labels). */
|
|
116
|
+
onPhaseChange?: (phase: ViewHostPhase) => void;
|
|
117
|
+
/**
|
|
118
|
+
* Replace the default status/failure line for a phase. Return `null` for
|
|
119
|
+
* "render nothing". The default: a quiet "Negotiating with view…" line
|
|
120
|
+
* while `"negotiating"`; a labeled failure for `"no-handshake"` on the
|
|
121
|
+
* `"ggui"` channel; nothing once `"connected"` (the view owns its
|
|
122
|
+
* pixels) and nothing for a silent `"inline"` card.
|
|
123
|
+
*/
|
|
124
|
+
renderStatus?: (phase: ViewHostPhase) => ReactNode;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const statusLineStyle: CSSProperties = {
|
|
128
|
+
position: "absolute",
|
|
129
|
+
insetInlineStart: 8,
|
|
130
|
+
insetBlockEnd: 8,
|
|
131
|
+
margin: 0,
|
|
132
|
+
padding: "2px 8px",
|
|
133
|
+
fontSize: 12,
|
|
134
|
+
lineHeight: "18px",
|
|
135
|
+
opacity: 0.65,
|
|
136
|
+
pointerEvents: "none",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function defaultStatus(phase: ViewHostPhase, channel: ResolvedViewMount["channel"]): ReactNode {
|
|
140
|
+
if (phase === "negotiating") {
|
|
141
|
+
return <p style={statusLineStyle}>Negotiating with view…</p>;
|
|
142
|
+
}
|
|
143
|
+
if (phase === "no-handshake" && channel === "ggui") {
|
|
144
|
+
// A ggui shell negotiates unconditionally before painting, so silence
|
|
145
|
+
// here is a boot failure with no other author — label it (role=alert
|
|
146
|
+
// so it is announced, not just drawn).
|
|
147
|
+
return (
|
|
148
|
+
<p role="alert" style={{ ...statusLineStyle, opacity: 1, pointerEvents: "auto" }}>
|
|
149
|
+
This view did not start — it never negotiated with the host.
|
|
150
|
+
</p>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Mount a resolved view and play the MCP Apps Host for it. See the module
|
|
158
|
+
* docblock for the sandbox and state contracts.
|
|
159
|
+
*/
|
|
160
|
+
export function GuueyView(props: GuueyViewProps): ReactNode {
|
|
161
|
+
const {
|
|
162
|
+
mount,
|
|
163
|
+
sandboxPageUrl,
|
|
164
|
+
autoResize,
|
|
165
|
+
dangerouslyAddSandboxFlags,
|
|
166
|
+
allow,
|
|
167
|
+
title,
|
|
168
|
+
className,
|
|
169
|
+
style,
|
|
170
|
+
onPhaseChange,
|
|
171
|
+
renderStatus,
|
|
172
|
+
...hostConfig
|
|
173
|
+
} = props;
|
|
174
|
+
const frameRef = useRef<HTMLIFrameElement>(null);
|
|
175
|
+
const [phase, setPhase] = useState<ViewHostPhase>("negotiating");
|
|
176
|
+
// The view's own size report, applied only under `autoResize`.
|
|
177
|
+
const [reportedHeight, setReportedHeight] = useState<number | undefined>(undefined);
|
|
178
|
+
const html = viewDocumentHtml(mount.resource);
|
|
179
|
+
|
|
180
|
+
// Vet the sandbox page once per URL. Same-origin is REFUSED (the widget's
|
|
181
|
+
// ResourceMount precedent, generalized): the whole point of the page is
|
|
182
|
+
// being a different origin — same-origin would hand the relay page (and
|
|
183
|
+
// through `allow-same-origin`, everything it can reach) the embedder's
|
|
184
|
+
// own origin. `null` — page mode required but unconfigured — refuses the
|
|
185
|
+
// same way: srcdoc is never a silent fallback for a confinement posture.
|
|
186
|
+
const sandboxPage: URL | "refused" | undefined = useMemo(() => {
|
|
187
|
+
if (sandboxPageUrl === undefined) return undefined;
|
|
188
|
+
if (sandboxPageUrl === null) return "refused";
|
|
189
|
+
let url: URL;
|
|
190
|
+
try {
|
|
191
|
+
url = new URL(sandboxPageUrl);
|
|
192
|
+
} catch {
|
|
193
|
+
return "refused";
|
|
194
|
+
}
|
|
195
|
+
if (typeof window !== "undefined" && url.origin === window.location.origin) return "refused";
|
|
196
|
+
return url;
|
|
197
|
+
}, [sandboxPageUrl]);
|
|
198
|
+
const page = sandboxPage instanceof URL ? sandboxPage : undefined;
|
|
199
|
+
|
|
200
|
+
// The attachment is keyed to the mounted DOCUMENT, not to every render's
|
|
201
|
+
// fresh callback identities — host config rides a ref so the effect's
|
|
202
|
+
// dependency list is honestly just the document identity.
|
|
203
|
+
const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize });
|
|
204
|
+
latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize };
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
// Keyed to the same identity the frame is (the resource uri): a new
|
|
208
|
+
// document boots fresh, and the previous negotiation's phase must not
|
|
209
|
+
// paper over it — nor must the previous document's reported size.
|
|
210
|
+
setPhase("negotiating");
|
|
211
|
+
setReportedHeight(undefined);
|
|
212
|
+
const frame = frameRef.current;
|
|
213
|
+
if (frame === null || html === undefined) return;
|
|
214
|
+
if (sandboxPageUrl !== undefined && page === undefined) return; // refused config — nothing mounts
|
|
215
|
+
const resourceUri = mount.resource.uri;
|
|
216
|
+
const detachHost = attachViewHost(frame, {
|
|
217
|
+
...latest.current.hostConfig,
|
|
218
|
+
resourceUri,
|
|
219
|
+
onPhaseChange: (next) => {
|
|
220
|
+
setPhase(next);
|
|
221
|
+
latest.current.onPhaseChange?.(next);
|
|
222
|
+
},
|
|
223
|
+
onSizeChanged: (size) => {
|
|
224
|
+
if (latest.current.autoResize === true && size.height !== undefined) {
|
|
225
|
+
setReportedHeight(size.height);
|
|
226
|
+
}
|
|
227
|
+
latest.current.hostConfig.onSizeChanged?.(size);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
if (page === undefined) return detachHost;
|
|
231
|
+
// Two-origin mode: the page announces readiness, the document is
|
|
232
|
+
// delivered over its relay (re-delivered on a reload's re-announce),
|
|
233
|
+
// and the view-host handshake crosses the same relay transparently.
|
|
234
|
+
const flags = latest.current.dangerouslyAddSandboxFlags;
|
|
235
|
+
const detachDelivery = attachSandboxPageDelivery(frame, {
|
|
236
|
+
pageOrigin: page.origin,
|
|
237
|
+
html,
|
|
238
|
+
...(flags !== undefined && flags.length > 0
|
|
239
|
+
? { sandbox: ["allow-scripts", ...flags].join(" ") }
|
|
240
|
+
: {}),
|
|
241
|
+
});
|
|
242
|
+
return () => {
|
|
243
|
+
detachDelivery();
|
|
244
|
+
detachHost();
|
|
245
|
+
};
|
|
246
|
+
}, [mount.resource.uri, html, sandboxPageUrl, page]);
|
|
247
|
+
|
|
248
|
+
if (html === undefined) {
|
|
249
|
+
// A resolved mount with no document is producer-side breakage; an
|
|
250
|
+
// empty frame would be a lie. Label it, in the same voice as the
|
|
251
|
+
// no-handshake state.
|
|
252
|
+
return (
|
|
253
|
+
<div className={className} style={{ position: "relative", ...style }}>
|
|
254
|
+
<p role="alert" style={{ ...statusLineStyle, opacity: 1, pointerEvents: "auto" }}>
|
|
255
|
+
This view could not be displayed — its resource carries no document.
|
|
256
|
+
</p>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (sandboxPageUrl !== undefined && page === undefined) {
|
|
262
|
+
// A missing (null), malformed, or SAME-ORIGIN sandbox page is a
|
|
263
|
+
// configuration state, not a property of the card — refused, labeled,
|
|
264
|
+
// never mounted, and never silently downgraded to srcdoc. The copy
|
|
265
|
+
// names the configuration cause (an operator can act on it) without
|
|
266
|
+
// ever printing the offending URL.
|
|
267
|
+
return (
|
|
268
|
+
<div className={className} style={{ position: "relative", ...style }}>
|
|
269
|
+
<p role="alert" style={{ ...statusLineStyle, opacity: 1, pointerEvents: "auto" }}>
|
|
270
|
+
{sandboxPageUrl === null
|
|
271
|
+
? "Interactive view unavailable — no sandbox page is configured."
|
|
272
|
+
: "Interactive view unavailable — the sandbox page is not usable from this origin."}
|
|
273
|
+
</p>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<div className={className} style={{ position: "relative", ...style }}>
|
|
280
|
+
<iframe
|
|
281
|
+
ref={frameRef}
|
|
282
|
+
// Remount on a new resource (or mount mode) rather than reusing the
|
|
283
|
+
// frame: a view runtime boots once from the document it was handed,
|
|
284
|
+
// so swapping `srcDoc` in place would leave the old boot running
|
|
285
|
+
// against new markup.
|
|
286
|
+
key={`${page?.href ?? "srcdoc"}::${mount.resource.uri}`}
|
|
287
|
+
{...(page !== undefined ? { src: page.href } : { srcDoc: html })}
|
|
288
|
+
title={title ?? DEFAULT_TITLE}
|
|
289
|
+
// srcdoc mode: the INVARIANT — agent HTML in an opaque origin, extra
|
|
290
|
+
// flags only widen knowingly. Page mode: the frame holds the
|
|
291
|
+
// cross-origin RELAY PAGE, which must run as its real origin
|
|
292
|
+
// (`allow-same-origin`) for its CSP/referrer machinery to exist at
|
|
293
|
+
// all; the agent HTML lands in the page's own inner opaque frame,
|
|
294
|
+
// and the caller's extra flags travel to THAT frame via the relay.
|
|
295
|
+
sandbox={
|
|
296
|
+
page !== undefined
|
|
297
|
+
? "allow-scripts allow-same-origin allow-forms"
|
|
298
|
+
: ["allow-scripts", ...(dangerouslyAddSandboxFlags ?? [])].join(" ")
|
|
299
|
+
}
|
|
300
|
+
allow={allow ?? "clipboard-write"}
|
|
301
|
+
// Under `autoResize`, the view's own height report wins over the
|
|
302
|
+
// fill-the-container default (width stays the container's — a
|
|
303
|
+
// transcript column owns its width).
|
|
304
|
+
style={{
|
|
305
|
+
display: "block",
|
|
306
|
+
width: "100%",
|
|
307
|
+
height: autoResize === true && reportedHeight !== undefined ? reportedHeight : "100%",
|
|
308
|
+
border: 0,
|
|
309
|
+
}}
|
|
310
|
+
/>
|
|
311
|
+
{renderStatus !== undefined ? renderStatus(phase) : defaultStatus(phase, mount.channel)}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|