@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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox-PAGE document delivery — the client half of the two-origin mount
|
|
3
|
+
* (guuey#135 wave-3c, from the #186/#135 dogfood's finding 2).
|
|
4
|
+
*
|
|
5
|
+
* ## Why a second mount mode exists
|
|
6
|
+
*
|
|
7
|
+
* The default `<GuueyView>` mount is a `srcdoc` frame: zero configuration,
|
|
8
|
+
* opaque origin, correct sandbox posture — but a `srcdoc` document INHERITS
|
|
9
|
+
* the embedding page's Content-Security-Policy, so the strongest egress
|
|
10
|
+
* confinement it can have is whatever the embedder's page happens to carry.
|
|
11
|
+
* Guuey's production surfaces confine harder: the untrusted document mounts
|
|
12
|
+
* inside a HOST-SERVED sandbox page on a second origin, whose per-request
|
|
13
|
+
* CSP names exactly the egress that mount is entitled to (see the platform's
|
|
14
|
+
* `/mcp-app-sandbox` route — per-channel `connect-src`, per-app
|
|
15
|
+
* `frame-ancestors`). This module speaks that page's delivery protocol so
|
|
16
|
+
* any kit consumer can opt into the same confinement.
|
|
17
|
+
*
|
|
18
|
+
* ## The protocol (co-owned in-repo; two notifications)
|
|
19
|
+
*
|
|
20
|
+
* The page is the reference sandbox relay (adapted from the MCP ext-apps
|
|
21
|
+
* `basic-host` example, vendored at
|
|
22
|
+
* `create-agentic-app/templates-src/base/web/sandbox-proxy.ts` and served by
|
|
23
|
+
* the platform's landing route). Its wire is exactly two JSON-RPC
|
|
24
|
+
* notifications:
|
|
25
|
+
*
|
|
26
|
+
* 1. page → host: `ui/notifications/sandbox-proxy-ready` — the relay booted
|
|
27
|
+
* and is listening;
|
|
28
|
+
* 2. host → page: `ui/notifications/sandbox-resource-ready` with
|
|
29
|
+
* `params.html` (+ optional `params.sandbox` tokens for the INNER frame —
|
|
30
|
+
* the page strips `allow-same-origin` from any value regardless).
|
|
31
|
+
*
|
|
32
|
+
* Every other message crosses the page transparently in both directions,
|
|
33
|
+
* which is why `attachViewHost` works unchanged on top of this delivery: the
|
|
34
|
+
* view's `ui/initialize` arrives relayed with `event.source` still the OUTER
|
|
35
|
+
* frame's window, and the host's answers relay inward.
|
|
36
|
+
*
|
|
37
|
+
* ## Identity + targeting
|
|
38
|
+
*
|
|
39
|
+
* Inbound messages are matched by `event.source === frame.contentWindow` —
|
|
40
|
+
* the package's standing identity invariant (`view-host.ts`). Outbound
|
|
41
|
+
* delivery targets `config.pageOrigin` EXPLICITLY (never `'*'`): unlike a
|
|
42
|
+
* srcdoc view, the sandbox page has a real origin, and the document being
|
|
43
|
+
* delivered is agent-generated content the caller confined on purpose — if
|
|
44
|
+
* the frame somehow navigated elsewhere, the browser drops the message
|
|
45
|
+
* instead of handing the document to the wrong receiver.
|
|
46
|
+
*/
|
|
47
|
+
import type { ViewFrameLike, ViewHostEvents } from "./view-host.js";
|
|
48
|
+
|
|
49
|
+
export const SANDBOX_PROXY_READY_METHOD = "ui/notifications/sandbox-proxy-ready";
|
|
50
|
+
export const SANDBOX_RESOURCE_READY_METHOD = "ui/notifications/sandbox-resource-ready";
|
|
51
|
+
|
|
52
|
+
/** Structural check for the page's ready notification. */
|
|
53
|
+
export function isSandboxProxyReady(data: unknown): boolean {
|
|
54
|
+
return (
|
|
55
|
+
typeof data === "object" &&
|
|
56
|
+
data !== null &&
|
|
57
|
+
!Array.isArray(data) &&
|
|
58
|
+
(data as { method?: unknown }).method === SANDBOX_PROXY_READY_METHOD
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SandboxPageDeliveryConfig {
|
|
63
|
+
/**
|
|
64
|
+
* The sandbox page's origin — the ONLY target the document is posted to.
|
|
65
|
+
* Derive it from the page URL the frame was given (`new URL(url).origin`).
|
|
66
|
+
*/
|
|
67
|
+
pageOrigin: string;
|
|
68
|
+
/** The document to deliver (the view's `viewDocumentHtml`). */
|
|
69
|
+
html: string;
|
|
70
|
+
/**
|
|
71
|
+
* Inner-frame sandbox tokens forwarded as `params.sandbox`. The page's
|
|
72
|
+
* `safeSandbox` strips `allow-same-origin` and guarantees `allow-scripts`
|
|
73
|
+
* whatever is sent — this only ever WIDENS within the page's own bounds.
|
|
74
|
+
*/
|
|
75
|
+
sandbox?: string;
|
|
76
|
+
/** Message-event source, injectable for tests. Default: `window`. */
|
|
77
|
+
events?: ViewHostEvents;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Deliver a view document to a mounted sandbox page, re-delivering on every
|
|
82
|
+
* `sandbox-proxy-ready` (a reloaded page announces again and must be
|
|
83
|
+
* re-seeded). Returns a detach function.
|
|
84
|
+
*/
|
|
85
|
+
export function attachSandboxPageDelivery(
|
|
86
|
+
frame: ViewFrameLike,
|
|
87
|
+
config: SandboxPageDeliveryConfig,
|
|
88
|
+
): () => void {
|
|
89
|
+
const deliver = (): void => {
|
|
90
|
+
frame.contentWindow?.postMessage(
|
|
91
|
+
{
|
|
92
|
+
jsonrpc: "2.0",
|
|
93
|
+
method: SANDBOX_RESOURCE_READY_METHOD,
|
|
94
|
+
params: {
|
|
95
|
+
html: config.html,
|
|
96
|
+
...(config.sandbox !== undefined ? { sandbox: config.sandbox } : {}),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
config.pageOrigin,
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onMessage = (event: { data: unknown; source: unknown }): void => {
|
|
104
|
+
if (frame.contentWindow === null || event.source !== frame.contentWindow) return;
|
|
105
|
+
if (isSandboxProxyReady(event.data)) deliver();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const { events } = config;
|
|
109
|
+
if (events !== undefined) {
|
|
110
|
+
events.addEventListener("message", onMessage);
|
|
111
|
+
return () => events.removeEventListener("message", onMessage);
|
|
112
|
+
}
|
|
113
|
+
const domListener = (event: MessageEvent): void => onMessage(event);
|
|
114
|
+
window.addEventListener("message", domListener);
|
|
115
|
+
return () => window.removeEventListener("message", domListener);
|
|
116
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Host half of the MCP Apps (SEP-1865) App handshake, as a PURE state
|
|
3
|
+
* machine — message in, `{state, effects}` out, zero DOM (guuey#186 Gap 1).
|
|
4
|
+
*
|
|
5
|
+
* ## Why the view hangs without this
|
|
6
|
+
*
|
|
7
|
+
* A spec-following App (ggui's iframe-runtime included) opens with
|
|
8
|
+
* `ui/initialize` posted to its parent and BLOCKS — the handshake runs
|
|
9
|
+
* before the view reads any seeded boot data, so a frame whose parent
|
|
10
|
+
* never answers stays blank forever. `toolResultViewMount` prepares the
|
|
11
|
+
* document; THIS module answers the document. The two halves together are
|
|
12
|
+
* the Host role the package is named for.
|
|
13
|
+
*
|
|
14
|
+
* ## Why a pure machine
|
|
15
|
+
*
|
|
16
|
+
* Only a real browser runs the postMessage/sandbox physics, but the
|
|
17
|
+
* publish gate (guuey-sdks mirror CI) is Node-only — the exact blind spot
|
|
18
|
+
* that let the missing-host gap ship (mount-material assertions run in
|
|
19
|
+
* Node; nothing ran the negotiation). So the protocol logic lives here,
|
|
20
|
+
* exhaustively unit-tested against scripted message sequences, and the DOM
|
|
21
|
+
* glue in `view-host.ts` stays thin enough to carry no decisions. Even the
|
|
22
|
+
* negotiation timeout is a machine INPUT ({@link viewHostElapsed}), not a
|
|
23
|
+
* timer here.
|
|
24
|
+
*
|
|
25
|
+
* ## Spec posture
|
|
26
|
+
*
|
|
27
|
+
* Types and method names come from `@modelcontextprotocol/ext-apps` — the
|
|
28
|
+
* SEP-1865 surface itself, someone else's frozen contract. Zero ggui
|
|
29
|
+
* imports, deliberately (guuey#123): this host answers ANY spec-following
|
|
30
|
+
* view; everything ggui-specific stays in `ggui-render.ts` behind ggui's
|
|
31
|
+
* own published protocol package.
|
|
32
|
+
*/
|
|
33
|
+
import {
|
|
34
|
+
INITIALIZE_METHOD,
|
|
35
|
+
LATEST_PROTOCOL_VERSION,
|
|
36
|
+
RESOURCE_TEARDOWN_METHOD,
|
|
37
|
+
SIZE_CHANGED_METHOD,
|
|
38
|
+
type McpUiHostCapabilities,
|
|
39
|
+
type McpUiHostContext,
|
|
40
|
+
type McpUiInitializeResult,
|
|
41
|
+
} from "@modelcontextprotocol/ext-apps";
|
|
42
|
+
import type { McpToolStructuredContent } from "./action.js";
|
|
43
|
+
import type { McpResourceReadResult } from "./reader.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The host identity in the initialize result — structurally the spec's
|
|
47
|
+
* `Implementation` (which the ext-apps root does not re-export), narrowed
|
|
48
|
+
* to the two fields a host must supply.
|
|
49
|
+
*/
|
|
50
|
+
export interface ViewHostInfo {
|
|
51
|
+
name: string;
|
|
52
|
+
version: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** JSON-RPC `method not found` — the spec's code, not an invented one. */
|
|
56
|
+
const METHOD_NOT_SUPPORTED = -32601;
|
|
57
|
+
|
|
58
|
+
/** The standard MCP method a view uses to reach host-proxied tools. */
|
|
59
|
+
export const TOOLS_CALL_METHOD = "tools/call";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The standard MCP method a view uses to read host-proxied resources —
|
|
63
|
+
* `ReadResourceRequest` in the spec's App→Host request union
|
|
64
|
+
* (`@modelcontextprotocol/ext-apps` `AppRequest`). A local constant, same
|
|
65
|
+
* as {@link TOOLS_CALL_METHOD}: the string is MCP-core vocabulary the
|
|
66
|
+
* ext-apps root does not re-export, and this package deliberately carries
|
|
67
|
+
* no `@modelcontextprotocol/sdk` dependency.
|
|
68
|
+
*/
|
|
69
|
+
export const RESOURCES_READ_METHOD = "resources/read";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* MCP's `Resource not found` JSON-RPC code — the one answer for a miss, a
|
|
73
|
+
* transport deny, AND a relay failure (deny == miss: the reader discipline,
|
|
74
|
+
* `reader.ts` — the view gets no oracle for which locators resolve).
|
|
75
|
+
*/
|
|
76
|
+
const RESOURCE_NOT_FOUND = -32002;
|
|
77
|
+
|
|
78
|
+
/** A JSON-RPC id as the wire allows it. */
|
|
79
|
+
export type ViewRequestId = number | string;
|
|
80
|
+
|
|
81
|
+
/** The messages this host posts INTO the view frame. */
|
|
82
|
+
export interface ViewHostOutbound {
|
|
83
|
+
jsonrpc: "2.0";
|
|
84
|
+
id?: ViewRequestId;
|
|
85
|
+
method?: string;
|
|
86
|
+
params?: { [key: string]: unknown };
|
|
87
|
+
result?: { [key: string]: unknown };
|
|
88
|
+
error?: { code: number; message: string };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where the negotiation stands, from the host's side of the boundary.
|
|
93
|
+
*
|
|
94
|
+
* - `"negotiating"` — attached; no `ui/initialize` seen yet. A plain-HTML
|
|
95
|
+
* inline card may stay here forever, legitimately: the handshake is how
|
|
96
|
+
* a spec App boots, not an obligation on arbitrary tenant HTML.
|
|
97
|
+
* - `"connected"` — `ui/initialize` was answered. The view owns its own
|
|
98
|
+
* pixels (and its own failures) from here on.
|
|
99
|
+
* - `"no-handshake"` — the caller declared the negotiation window over
|
|
100
|
+
* ({@link viewHostElapsed}) before any `ui/initialize` arrived. What
|
|
101
|
+
* that MEANS depends on the mount channel and is the renderer's call:
|
|
102
|
+
* a `"ggui"` shell always handshakes, so this phase is a boot failure
|
|
103
|
+
* there; an `"inline"` card may simply not be an App.
|
|
104
|
+
*
|
|
105
|
+
* A renderer binds to this — the failure mode must be a labeled state,
|
|
106
|
+
* never a blank page (guuey#186 audit).
|
|
107
|
+
*/
|
|
108
|
+
export type ViewHostPhase = "negotiating" | "connected" | "no-handshake";
|
|
109
|
+
|
|
110
|
+
/** The machine's whole state. Immutable — every transition returns a new one. */
|
|
111
|
+
export interface ViewHostState {
|
|
112
|
+
phase: ViewHostPhase;
|
|
113
|
+
/** `ui/notifications/initialized` seen (the App's post-handshake ack). */
|
|
114
|
+
initializedSeen: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function initialViewHostState(): ViewHostState {
|
|
118
|
+
return { phase: "negotiating", initializedSeen: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What the glue must DO after a transition. Effects are data so the machine
|
|
123
|
+
* stays synchronous and Node-testable; the glue performs them.
|
|
124
|
+
*/
|
|
125
|
+
export type ViewHostEffect =
|
|
126
|
+
| { kind: "respond"; message: ViewHostOutbound }
|
|
127
|
+
| {
|
|
128
|
+
/**
|
|
129
|
+
* A `tools/call` the config accepted for relaying. The glue runs the
|
|
130
|
+
* (async) relay hook and posts {@link toolCallResponse} with the
|
|
131
|
+
* result. Only ever emitted when {@link ViewHostBehavior.toolRelay}
|
|
132
|
+
* is true — with no relay wired, the machine refuses the call
|
|
133
|
+
* in-band instead (an honest `method_not_supported`).
|
|
134
|
+
*/
|
|
135
|
+
kind: "relay-tool-call";
|
|
136
|
+
id: ViewRequestId;
|
|
137
|
+
name: string;
|
|
138
|
+
arguments?: McpToolStructuredContent;
|
|
139
|
+
}
|
|
140
|
+
| {
|
|
141
|
+
/**
|
|
142
|
+
* A `resources/read` the config accepted for relaying (spec surface:
|
|
143
|
+
* `ReadResourceRequest` rides the App→Host union, and the matching
|
|
144
|
+
* advertisement is `hostCapabilities.serverResources`). The glue runs
|
|
145
|
+
* the read hook and posts {@link resourceReadResponse}. Only emitted
|
|
146
|
+
* when {@link ViewHostBehavior.resourceRelay} is true — unwired, the
|
|
147
|
+
* machine refuses in-band like every other unsupported request.
|
|
148
|
+
*/
|
|
149
|
+
kind: "relay-resource-read";
|
|
150
|
+
id: ViewRequestId;
|
|
151
|
+
uri: string;
|
|
152
|
+
}
|
|
153
|
+
| {
|
|
154
|
+
/**
|
|
155
|
+
* The view reported its content size (`ui/notifications/size-changed`
|
|
156
|
+
* — spec notification, App → Host). At least one of the two fields is
|
|
157
|
+
* a finite number; a notification carrying neither is consumed
|
|
158
|
+
* silently instead. The glue forwards this to the embedder
|
|
159
|
+
* ({@link AttachViewHostConfig.onSizeChanged} in `view-host.ts`) —
|
|
160
|
+
* whether/how to resize the frame is the embedder's layout decision,
|
|
161
|
+
* never the machine's.
|
|
162
|
+
*/
|
|
163
|
+
kind: "size-changed";
|
|
164
|
+
width?: number;
|
|
165
|
+
height?: number;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The host identity/behavior the machine answers with. Everything here is
|
|
170
|
+
* explicit config — the machine assumes nothing about the embedder.
|
|
171
|
+
*/
|
|
172
|
+
export interface ViewHostBehavior {
|
|
173
|
+
hostInfo: ViewHostInfo;
|
|
174
|
+
/**
|
|
175
|
+
* The capabilities to advertise. Empty is a correct, honest default for
|
|
176
|
+
* views that ride their own live channel (ggui views do — they boot from
|
|
177
|
+
* their seeded envelope and talk to their pod directly, so the host's
|
|
178
|
+
* whole job is unblocking the handshake). Advertise ONLY what the
|
|
179
|
+
* embedder actually implements: a capability the host does not honor
|
|
180
|
+
* makes the view attribute later failures to the wrong layer.
|
|
181
|
+
*/
|
|
182
|
+
hostCapabilities: McpUiHostCapabilities;
|
|
183
|
+
/** The context handed to the view in the initialize result. */
|
|
184
|
+
hostContext: McpUiHostContext;
|
|
185
|
+
/** Whether a `tools/call` relay hook is wired (see `view-host.ts`). */
|
|
186
|
+
toolRelay: boolean;
|
|
187
|
+
/** Whether a `resources/read` relay hook is wired (see `view-host.ts`). */
|
|
188
|
+
resourceRelay: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The result of feeding one inbound frame (or the timeout) to the machine. */
|
|
192
|
+
export interface ViewHostTransition {
|
|
193
|
+
state: ViewHostState;
|
|
194
|
+
effects: ViewHostEffect[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface InboundEnvelope {
|
|
198
|
+
id?: ViewRequestId;
|
|
199
|
+
method: string;
|
|
200
|
+
params?: { [key: string]: unknown };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Plain-object narrowing, same idiom as `action.ts`'s `isJsonObjectLike`. */
|
|
204
|
+
function isPlainObject(value: unknown): value is { [key: string]: unknown } {
|
|
205
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Narrow untrusted postMessage data to a JSON-RPC envelope this host could
|
|
210
|
+
* answer. Anything else — other windows' chatter, the view's own non-RPC
|
|
211
|
+
* messages — is silently not ours (`undefined`), NOT an error: a shared
|
|
212
|
+
* `message` listener hears the whole page.
|
|
213
|
+
*/
|
|
214
|
+
function asInboundEnvelope(data: unknown): InboundEnvelope | undefined {
|
|
215
|
+
if (!isPlainObject(data)) return undefined;
|
|
216
|
+
if (data["jsonrpc"] !== "2.0") return undefined;
|
|
217
|
+
const method = data["method"];
|
|
218
|
+
if (typeof method !== "string") return undefined;
|
|
219
|
+
const id = data["id"];
|
|
220
|
+
const params = data["params"];
|
|
221
|
+
return {
|
|
222
|
+
...(typeof id === "number" || typeof id === "string" ? { id } : {}),
|
|
223
|
+
method,
|
|
224
|
+
...(isPlainObject(params) ? { params } : {}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The spec-canonical answer to `ui/initialize`. Exported for the glue/tests. */
|
|
229
|
+
export function initializeResult(
|
|
230
|
+
behavior: ViewHostBehavior,
|
|
231
|
+
requestedProtocolVersion: unknown,
|
|
232
|
+
): McpUiInitializeResult {
|
|
233
|
+
// Echo the version the view asked for when it names one — the view is
|
|
234
|
+
// the side with a fixed runtime; the host has no version-specific
|
|
235
|
+
// behavior to defend. Absent/malformed, answer with the spec's latest.
|
|
236
|
+
const protocolVersion =
|
|
237
|
+
typeof requestedProtocolVersion === "string" && requestedProtocolVersion.length > 0
|
|
238
|
+
? requestedProtocolVersion
|
|
239
|
+
: LATEST_PROTOCOL_VERSION;
|
|
240
|
+
return {
|
|
241
|
+
protocolVersion,
|
|
242
|
+
hostInfo: behavior.hostInfo,
|
|
243
|
+
hostCapabilities: behavior.hostCapabilities,
|
|
244
|
+
hostContext: behavior.hostContext,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Build the in-band response for a relayed `tools/call`'s settled result. */
|
|
249
|
+
export function toolCallResponse(
|
|
250
|
+
id: ViewRequestId,
|
|
251
|
+
result: { [key: string]: unknown },
|
|
252
|
+
): ViewHostOutbound {
|
|
253
|
+
return { jsonrpc: "2.0", id, result };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build the in-band response for a relayed `resources/read`. An entry
|
|
258
|
+
* becomes the spec's `ReadResourceResult` (`contents: [entry]`); `undefined`
|
|
259
|
+
* — a miss, a deny, or a relay failure alike — becomes the one
|
|
260
|
+
* `Resource not found` error (deny == miss, {@link RESOURCE_NOT_FOUND}).
|
|
261
|
+
*/
|
|
262
|
+
export function resourceReadResponse(
|
|
263
|
+
id: ViewRequestId,
|
|
264
|
+
entry: McpResourceReadResult | undefined,
|
|
265
|
+
): ViewHostOutbound {
|
|
266
|
+
if (entry === undefined) {
|
|
267
|
+
return {
|
|
268
|
+
jsonrpc: "2.0",
|
|
269
|
+
id,
|
|
270
|
+
error: { code: RESOURCE_NOT_FOUND, message: "resource unavailable" },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { jsonrpc: "2.0", id, result: { contents: [entry] } };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The spec-mannered farewell a detaching host posts (`ui/resource-teardown`).
|
|
278
|
+
* Sent WITHOUT an id — a host that is tearing the frame down cannot await a
|
|
279
|
+
* response, and an id-less JSON-RPC message is a notification the view may
|
|
280
|
+
* use for cleanup or ignore.
|
|
281
|
+
*/
|
|
282
|
+
export function teardownMessage(): ViewHostOutbound {
|
|
283
|
+
return { jsonrpc: "2.0", method: RESOURCE_TEARDOWN_METHOD, params: {} };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Feed one inbound postMessage payload to the machine.
|
|
288
|
+
*
|
|
289
|
+
* The contract, exactly:
|
|
290
|
+
* - non-RPC data → ignored (not ours);
|
|
291
|
+
* - notifications (no id) → consumed silently, JSON-RPC-correctly; the
|
|
292
|
+
* `ui/notifications/initialized` ack is remembered on the state;
|
|
293
|
+
* - `ui/initialize` → answered spec-canonically; phase → `"connected"`
|
|
294
|
+
* (also from `"no-handshake"` — a late handshake still gets answered:
|
|
295
|
+
* the timeout labels a state, it does not close the door);
|
|
296
|
+
* - `tools/call` with a relay wired → `relay-tool-call` effect;
|
|
297
|
+
* - every other REQUEST → `method_not_supported`, honestly.
|
|
298
|
+
*/
|
|
299
|
+
export function viewHostReceive(
|
|
300
|
+
state: ViewHostState,
|
|
301
|
+
behavior: ViewHostBehavior,
|
|
302
|
+
data: unknown,
|
|
303
|
+
): ViewHostTransition {
|
|
304
|
+
const req = asInboundEnvelope(data);
|
|
305
|
+
if (req === undefined) return { state, effects: [] };
|
|
306
|
+
|
|
307
|
+
if (req.id === undefined) {
|
|
308
|
+
// A notification. Track the one the handshake defines, surface the one
|
|
309
|
+
// the embedder may act on; consume the rest.
|
|
310
|
+
if (req.method === "ui/notifications/initialized" && !state.initializedSeen) {
|
|
311
|
+
return { state: { ...state, initializedSeen: true }, effects: [] };
|
|
312
|
+
}
|
|
313
|
+
if (req.method === SIZE_CHANGED_METHOD) {
|
|
314
|
+
const width = req.params?.["width"];
|
|
315
|
+
const height = req.params?.["height"];
|
|
316
|
+
const validWidth = typeof width === "number" && Number.isFinite(width);
|
|
317
|
+
const validHeight = typeof height === "number" && Number.isFinite(height);
|
|
318
|
+
if (validWidth || validHeight) {
|
|
319
|
+
return {
|
|
320
|
+
state,
|
|
321
|
+
effects: [
|
|
322
|
+
{
|
|
323
|
+
kind: "size-changed",
|
|
324
|
+
...(validWidth ? { width } : {}),
|
|
325
|
+
...(validHeight ? { height } : {}),
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { state, effects: [] };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (req.method === INITIALIZE_METHOD) {
|
|
335
|
+
const result = initializeResult(behavior, req.params?.["protocolVersion"]);
|
|
336
|
+
return {
|
|
337
|
+
state: { ...state, phase: "connected" },
|
|
338
|
+
effects: [{ kind: "respond", message: { jsonrpc: "2.0", id: req.id, result } }],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (req.method === TOOLS_CALL_METHOD && behavior.toolRelay) {
|
|
343
|
+
const name = req.params?.["name"];
|
|
344
|
+
if (typeof name === "string") {
|
|
345
|
+
const args = req.params?.["arguments"];
|
|
346
|
+
return {
|
|
347
|
+
state,
|
|
348
|
+
effects: [
|
|
349
|
+
{
|
|
350
|
+
kind: "relay-tool-call",
|
|
351
|
+
id: req.id,
|
|
352
|
+
name,
|
|
353
|
+
...(isPlainObject(args) ? { arguments: args } : {}),
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// fall through: a nameless tools/call is not a call we can relay.
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (req.method === RESOURCES_READ_METHOD && behavior.resourceRelay) {
|
|
362
|
+
const uri = req.params?.["uri"];
|
|
363
|
+
if (typeof uri === "string") {
|
|
364
|
+
return { state, effects: [{ kind: "relay-resource-read", id: req.id, uri }] };
|
|
365
|
+
}
|
|
366
|
+
// fall through: a uri-less read is not a read we can relay.
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const answered = [
|
|
370
|
+
INITIALIZE_METHOD,
|
|
371
|
+
...(behavior.toolRelay ? [TOOLS_CALL_METHOD] : []),
|
|
372
|
+
...(behavior.resourceRelay ? [RESOURCES_READ_METHOD] : []),
|
|
373
|
+
];
|
|
374
|
+
return {
|
|
375
|
+
state,
|
|
376
|
+
effects: [
|
|
377
|
+
{
|
|
378
|
+
kind: "respond",
|
|
379
|
+
message: {
|
|
380
|
+
jsonrpc: "2.0",
|
|
381
|
+
id: req.id,
|
|
382
|
+
error: {
|
|
383
|
+
code: METHOD_NOT_SUPPORTED,
|
|
384
|
+
message: `method_not_supported: ${req.method} — this host answers ${answered.join(", ")} only`,
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Declare the negotiation window over. Meaningful only while
|
|
394
|
+
* `"negotiating"`: a connected view stays connected, and an already-lapsed
|
|
395
|
+
* one stays lapsed. The caller owns the clock — this machine has none.
|
|
396
|
+
*/
|
|
397
|
+
export function viewHostElapsed(state: ViewHostState): ViewHostState {
|
|
398
|
+
return state.phase === "negotiating" ? { ...state, phase: "no-handshake" } : state;
|
|
399
|
+
}
|