@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,143 @@
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, 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
+ return {
47
+ hostInfo: config.hostInfo ?? DEFAULT_HOST_INFO,
48
+ hostCapabilities: {
49
+ ...(relayWired ? { serverTools: {} } : {}),
50
+ ...config.hostCapabilities,
51
+ },
52
+ hostContext: hostContextFor(frame, config),
53
+ toolRelay: relayWired,
54
+ };
55
+ }
56
+ /**
57
+ * Attach the Host role to a mounted view frame. Returns a detach function;
58
+ * call it before the frame unmounts — it stops listening and posts the
59
+ * spec-mannered `ui/resource-teardown` farewell through the CACHED window
60
+ * handle (post-removal, `frame.contentWindow` is already null).
61
+ */
62
+ export function attachViewHost(frame, config = {}) {
63
+ const cachedWindow = frame.contentWindow;
64
+ let state = initialViewHostState();
65
+ const setState = (next) => {
66
+ const phaseChanged = next.phase !== state.phase;
67
+ state = next;
68
+ if (phaseChanged)
69
+ config.onPhaseChange?.(next.phase);
70
+ };
71
+ const post = (message) => {
72
+ frame.contentWindow?.postMessage(message, "*");
73
+ };
74
+ const relay = (id, name, args) => {
75
+ const { onCallTool, resourceUri } = config;
76
+ // The machine only emits the effect when the relay is wired (behavior
77
+ // is derived from this same config), so these are invariants, not
78
+ // runtime branches a view can steer.
79
+ if (onCallTool === undefined || resourceUri === undefined)
80
+ return;
81
+ onCallTool({ resourceUri, name, ...(args === undefined ? {} : { arguments: args }) }).then((result) => post(toolCallResponse(id, result)),
82
+ // A relay hook that rejects (createMcpUiActionRelay never does, but
83
+ // the hook is embedder code) still owes the view an answer — the
84
+ // same in-band unavailable the relay itself uses, never a hang.
85
+ () => post(toolCallResponse(id, unavailableToolCallResult())));
86
+ };
87
+ const onMessage = (event) => {
88
+ if (frame.contentWindow === null || event.source !== frame.contentWindow)
89
+ return;
90
+ const { state: next, effects } = viewHostReceive(state, behaviorFor(frame, config), event.data);
91
+ setState(next);
92
+ for (const effect of effects) {
93
+ if (effect.kind === "respond")
94
+ post(effect.message);
95
+ else
96
+ relay(effect.id, effect.name, effect.arguments);
97
+ }
98
+ };
99
+ // One listener, two subscription paths: the injectable seam for Node
100
+ // tests, and `window` — whose lib.dom listener typing wants the concrete
101
+ // `MessageEvent` — for the browser default.
102
+ const subscribe = () => {
103
+ const { events } = config;
104
+ if (events !== undefined) {
105
+ events.addEventListener("message", onMessage);
106
+ return () => events.removeEventListener("message", onMessage);
107
+ }
108
+ const domListener = (event) => onMessage(event);
109
+ window.addEventListener("message", domListener);
110
+ return () => window.removeEventListener("message", domListener);
111
+ };
112
+ const unsubscribe = subscribe();
113
+ const timeoutMs = config.negotiationTimeoutMs ?? DEFAULT_NEGOTIATION_TIMEOUT_MS;
114
+ const timer = timeoutMs > 0 ? setTimeout(() => setState(viewHostElapsed(state)), timeoutMs) : undefined;
115
+ return () => {
116
+ if (timer !== undefined)
117
+ clearTimeout(timer);
118
+ unsubscribe();
119
+ cachedWindow?.postMessage(teardownMessage(), "*");
120
+ };
121
+ }
122
+ /**
123
+ * The document a {@link McpUiResourcePayload} mounts: `text` verbatim, or
124
+ * `blob` base64-decoded as UTF-8. `undefined` when the payload carries
125
+ * neither — nothing to put in `srcdoc`.
126
+ */
127
+ export function viewDocumentHtml(resource) {
128
+ if (typeof resource.text === "string")
129
+ return resource.text;
130
+ if (typeof resource.blob === "string") {
131
+ try {
132
+ const bytes = Uint8Array.from(atob(resource.blob), (c) => c.charCodeAt(0));
133
+ return new TextDecoder().decode(bytes);
134
+ }
135
+ catch {
136
+ // Malformed base64 is producer-side wire data, not an embedder bug —
137
+ // the honest answer is "no document" (the same labeled state a
138
+ // payload with neither field gets), not a render-time throw.
139
+ return undefined;
140
+ }
141
+ }
142
+ return undefined;
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/mcp-apps-host",
3
- "version": "0.3.1",
3
+ "version": "0.5.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
- "@ggui-ai/protocol": "0.6.3",
36
+ "@ggui-ai/protocol": "0.9.0",
37
+ "@modelcontextprotocol/ext-apps": "1.7.5",
31
38
  "@silverprotocol/core": "0.4.1"
32
39
  },
40
+ "peerDependencies": {
41
+ "react": ">=18"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "react": {
45
+ "optional": true
46
+ }
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
  },
@@ -52,10 +69,10 @@
52
69
  "url": "git+https://github.com/withguuey/guuey-sdks.git",
53
70
  "directory": "packages/mcp-apps-host"
54
71
  },
55
- "homepage": "https://guuey.com",
56
72
  "bugs": {
57
- "url": "https://github.com/loqu-co/guuey/issues"
73
+ "url": "https://github.com/withguuey/guuey-sdks/issues"
58
74
  },
75
+ "homepage": "https://guuey.com",
59
76
  "scripts": {
60
77
  "build": "tsc -p tsconfig.build.json",
61
78
  "dev": "tsc --watch",
package/src/action.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The Host role's ACTION side (guuey#158) — the `tools/call` sibling of
3
+ * `reader.ts`. A mounted card's sandbox posts a runtime action to the host
4
+ * (SEP-1865 / ggui's relay-host contract); the host relays it over an
5
+ * AUTHENTICATED transport it owns, and hands the result back in-band. Two
6
+ * invariants mirror the reader:
7
+ *
8
+ * - the relay NEVER throws into the sandbox bridge: allowlist miss,
9
+ * transport failure, and un-narrowable answers all collapse to an
10
+ * in-band `isError` result the card can display;
11
+ * - runtime re-narrowing, not trust: transports are host-supplied and the
12
+ * upstream answer is wire data — every content arm is re-checked before
13
+ * it crosses into the sandbox.
14
+ */
15
+
16
+ /** The wire arms a relay hands back to the sandbox — re-narrowed, never trusted. */
17
+ export type McpToolCallContent =
18
+ | { type: "text"; text: string }
19
+ | { type: "image"; data: string; mimeType: string }
20
+ | {
21
+ type: "resource";
22
+ resource: { uri: string; mimeType?: string } & (
23
+ | { text: string }
24
+ | { blob: string }
25
+ );
26
+ };
27
+
28
+ /**
29
+ * `structuredContent` is protocol-open by design (the MCP spec types it as
30
+ * an arbitrary JSON object) — the index signature is the honest wire type,
31
+ * not an erasure of a known shape.
32
+ */
33
+ export type McpToolStructuredContent = {
34
+ [key: string]: unknown;
35
+ };
36
+
37
+ /**
38
+ * The SEP-1865 CallToolResult surface a host hands back to the sandbox.
39
+ * A `type` alias, deliberately: the MCP SDK's own result types carry Zod
40
+ * passthrough index signatures, and only type aliases (never interfaces)
41
+ * get the implicit index signature that makes this assignable to them.
42
+ */
43
+ export type McpToolCallResult = {
44
+ content: McpToolCallContent[];
45
+ isError?: boolean;
46
+ structuredContent?: McpToolStructuredContent;
47
+ };
48
+
49
+ /**
50
+ * The runtime-action tools a card sandbox may relay — the client-side twin
51
+ * of the server allowlist (defense in depth: the proxy enforces it again).
52
+ */
53
+ export const UI_ACTION_TOOLS: ReadonlySet<string> = new Set([
54
+ "ggui_runtime_submit_action",
55
+ ]);
56
+
57
+ /** The in-band answer for anything the relay cannot (or will not) do. */
58
+ export const UI_ACTION_UNAVAILABLE_TEXT =
59
+ "This action isn't available right now.";
60
+
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 {
67
+ return {
68
+ content: [{ type: "text", text: UI_ACTION_UNAVAILABLE_TEXT }],
69
+ isError: true,
70
+ };
71
+ }
72
+
73
+ function isJsonObjectLike(value: unknown): value is McpToolStructuredContent {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ }
76
+
77
+ function asContentArm(value: unknown): McpToolCallContent | undefined {
78
+ if (!isJsonObjectLike(value)) return undefined;
79
+ const type = value["type"];
80
+ if (type === "text" && typeof value["text"] === "string") {
81
+ return { type: "text", text: value["text"] };
82
+ }
83
+ if (
84
+ type === "image" &&
85
+ typeof value["data"] === "string" &&
86
+ typeof value["mimeType"] === "string"
87
+ ) {
88
+ return { type: "image", data: value["data"], mimeType: value["mimeType"] };
89
+ }
90
+ if (type === "resource" && isJsonObjectLike(value["resource"])) {
91
+ const res = value["resource"];
92
+ if (typeof res["uri"] !== "string") return undefined;
93
+ const mimeType =
94
+ typeof res["mimeType"] === "string" ? { mimeType: res["mimeType"] } : {};
95
+ if (typeof res["text"] === "string") {
96
+ return { type: "resource", resource: { uri: res["uri"], ...mimeType, text: res["text"] } };
97
+ }
98
+ if (typeof res["blob"] === "string") {
99
+ return { type: "resource", resource: { uri: res["uri"], ...mimeType, blob: res["blob"] } };
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * Narrow an untrusted `tools/call` answer to the arms the sandbox may see.
107
+ * Unknown content arms are DROPPED (never forwarded opaque); a value that
108
+ * is not result-shaped at all is `undefined` (the relay answers in-band).
109
+ */
110
+ export function asToolCallResult(value: unknown): McpToolCallResult | undefined {
111
+ if (!isJsonObjectLike(value)) return undefined;
112
+ const rawContent = value["content"];
113
+ if (!Array.isArray(rawContent)) return undefined;
114
+ const content: McpToolCallContent[] = [];
115
+ for (const entry of rawContent) {
116
+ const arm = asContentArm(entry);
117
+ if (arm) content.push(arm);
118
+ }
119
+ return {
120
+ content,
121
+ ...(value["isError"] === true ? { isError: true } : {}),
122
+ ...(isJsonObjectLike(value["structuredContent"])
123
+ ? { structuredContent: value["structuredContent"] }
124
+ : {}),
125
+ };
126
+ }
127
+
128
+ /** The host-supplied transport {@link createMcpUiActionRelay} assembles over. */
129
+ export interface CreateMcpUiActionRelayDeps {
130
+ /**
131
+ * One `tools/call` bound to the mounted card's locator `uri` over the
132
+ * host's authenticated channel. Returns the raw result (narrowed here),
133
+ * or `undefined` when the upstream denied/lost the session. Throwing is
134
+ * treated as unavailable.
135
+ */
136
+ callTool: (
137
+ uri: string,
138
+ name: string,
139
+ args: McpToolStructuredContent | undefined,
140
+ ) => Promise<unknown>;
141
+ }
142
+
143
+ /** The request shape a mounted card's `onCallTool` bridge produces. */
144
+ export interface UiActionRequest {
145
+ /** The mounted card's persisted `ui://` locator — the action's scope. */
146
+ resourceUri: string;
147
+ name: string;
148
+ arguments?: McpToolStructuredContent;
149
+ }
150
+
151
+ /**
152
+ * Assemble the sandbox-facing action relay from a host transport. The
153
+ * returned function is shaped for an `onCallTool` bridge: it always
154
+ * resolves (never rejects), answering in-band.
155
+ */
156
+ export function createMcpUiActionRelay(
157
+ deps: CreateMcpUiActionRelayDeps,
158
+ ): (request: UiActionRequest) => Promise<McpToolCallResult> {
159
+ return async (request) => {
160
+ if (!UI_ACTION_TOOLS.has(request.name)) return unavailableToolCallResult();
161
+ if (!request.resourceUri.startsWith("ui://")) return unavailableToolCallResult();
162
+ let raw: unknown;
163
+ try {
164
+ raw = await deps.callTool(request.resourceUri, request.name, request.arguments);
165
+ } catch {
166
+ return unavailableToolCallResult(); // transport failure == unavailable, in-band
167
+ }
168
+ if (raw === undefined) return unavailableToolCallResult();
169
+ return asToolCallResult(raw) ?? unavailableToolCallResult();
170
+ };
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,
@@ -42,3 +43,46 @@ export {
42
43
  type CreateMcpUiResourceReaderDeps,
43
44
  type McpResourceReadResult,
44
45
  } from "./reader.js";
46
+ export {
47
+ asToolCallResult,
48
+ createMcpUiActionRelay,
49
+ unavailableToolCallResult,
50
+ UI_ACTION_TOOLS,
51
+ UI_ACTION_UNAVAILABLE_TEXT,
52
+ type CreateMcpUiActionRelayDeps,
53
+ type McpToolCallContent,
54
+ type McpToolCallResult,
55
+ type McpToolStructuredContent,
56
+ type UiActionRequest,
57
+ } from "./action.js";
58
+ export {
59
+ initializeResult,
60
+ initialViewHostState,
61
+ teardownMessage,
62
+ toolCallResponse,
63
+ TOOLS_CALL_METHOD,
64
+ viewHostElapsed,
65
+ viewHostReceive,
66
+ type ViewHostBehavior,
67
+ type ViewHostEffect,
68
+ type ViewHostOutbound,
69
+ type ViewHostPhase,
70
+ type ViewHostInfo,
71
+ type ViewHostState,
72
+ type ViewHostTransition,
73
+ type ViewRequestId,
74
+ } from "./view-host-protocol.js";
75
+ export {
76
+ attachViewHost,
77
+ viewDocumentHtml,
78
+ type AttachViewHostConfig,
79
+ type ViewFrameLike,
80
+ type ViewHostEvents,
81
+ } from "./view-host.js";
82
+ export {
83
+ attachSandboxPageDelivery,
84
+ isSandboxProxyReady,
85
+ SANDBOX_PROXY_READY_METHOD,
86
+ SANDBOX_RESOURCE_READY_METHOD,
87
+ type SandboxPageDeliveryConfig,
88
+ } from "./sandbox-page.js";