@botiverse/porthole 0.0.1 → 0.1.1

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.
Files changed (152) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +198 -0
  4. package/dist/bridge/types.d.ts +83 -0
  5. package/dist/bridge/types.js +1 -0
  6. package/dist/compile/compile.d.ts +35 -0
  7. package/dist/compile/compile.js +30 -0
  8. package/dist/compile/policy.d.ts +55 -0
  9. package/dist/compile/policy.js +103 -0
  10. package/dist/config/capabilities.d.ts +154 -0
  11. package/dist/config/capabilities.js +63 -0
  12. package/dist/config/schema.d.ts +152 -0
  13. package/dist/config/schema.js +11 -0
  14. package/dist/config/validate.d.ts +22 -0
  15. package/dist/config/validate.js +261 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +426 -0
  18. package/dist/conformance/index.d.ts +101 -0
  19. package/dist/conformance/index.js +4 -0
  20. package/dist/conformance/kit.d.ts +42 -0
  21. package/dist/conformance/kit.js +24 -0
  22. package/dist/conformance/oracles.d.ts +35 -0
  23. package/dist/conformance/oracles.js +77 -0
  24. package/dist/conformance/probes.d.ts +33 -0
  25. package/dist/conformance/probes.js +876 -0
  26. package/dist/conformance/served.d.ts +75 -0
  27. package/dist/conformance/served.js +335 -0
  28. package/dist/guide/examples.d.ts +70 -0
  29. package/dist/guide/examples.js +69 -0
  30. package/dist/guide/guide.d.ts +16 -0
  31. package/dist/guide/guide.js +265 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +292 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +16 -0
  36. package/dist/host/outcome.d.ts +24 -0
  37. package/dist/host/outcome.js +134 -0
  38. package/dist/host/types.d.ts +127 -0
  39. package/dist/host/types.js +1 -0
  40. package/dist/index.d.ts +25 -0
  41. package/dist/index.js +21 -0
  42. package/dist/isolation/served.d.ts +35 -0
  43. package/dist/isolation/served.js +117 -0
  44. package/dist/isolation/types.d.ts +96 -0
  45. package/dist/isolation/types.js +2 -0
  46. package/dist/mcp/adapter.d.ts +43 -0
  47. package/dist/mcp/adapter.js +327 -0
  48. package/dist/mcp/index.d.ts +27 -0
  49. package/dist/mcp/index.js +25 -0
  50. package/dist/mcp/proxy.d.ts +25 -0
  51. package/dist/mcp/proxy.js +106 -0
  52. package/dist/mcp/view.d.ts +31 -0
  53. package/dist/mcp/view.js +72 -0
  54. package/dist/platform/types.d.ts +51 -0
  55. package/dist/platform/types.js +1 -0
  56. package/dist/platform/web.d.ts +8 -0
  57. package/dist/platform/web.js +85 -0
  58. package/dist/presets/presets.d.ts +71 -0
  59. package/dist/presets/presets.js +140 -0
  60. package/dist/protocol/adapter.d.ts +73 -0
  61. package/dist/protocol/adapter.js +1 -0
  62. package/dist/protocol/jsonrpc.d.ts +43 -0
  63. package/dist/protocol/jsonrpc.js +50 -0
  64. package/dist/protocol/mcp-apps.d.ts +79 -0
  65. package/dist/protocol/mcp-apps.js +99 -0
  66. package/dist/protocol/shell-adapter.d.ts +5 -0
  67. package/dist/protocol/shell-adapter.js +228 -0
  68. package/dist/react/dialog.d.ts +20 -0
  69. package/dist/react/dialog.js +98 -0
  70. package/dist/react/frame.d.ts +91 -0
  71. package/dist/react/frame.js +255 -0
  72. package/dist/react/index.d.ts +13 -0
  73. package/dist/react/index.js +13 -0
  74. package/dist/react/live.d.ts +24 -0
  75. package/dist/react/live.js +57 -0
  76. package/dist/server/index.d.ts +27 -0
  77. package/dist/server/index.js +26 -0
  78. package/dist/server/keys.d.ts +19 -0
  79. package/dist/server/keys.js +78 -0
  80. package/dist/server/pages.d.ts +11 -0
  81. package/dist/server/pages.js +86 -0
  82. package/dist/server/proxy.d.ts +21 -0
  83. package/dist/server/proxy.js +107 -0
  84. package/dist/server/server.d.ts +7 -0
  85. package/dist/server/server.js +542 -0
  86. package/dist/server/tickets.d.ts +8 -0
  87. package/dist/server/tickets.js +28 -0
  88. package/dist/server/types.d.ts +97 -0
  89. package/dist/server/types.js +1 -0
  90. package/dist/shell/document.d.ts +29 -0
  91. package/dist/shell/document.js +85 -0
  92. package/dist/shell/plan.d.ts +37 -0
  93. package/dist/shell/plan.js +35 -0
  94. package/dist/shell/runtime.d.ts +28 -0
  95. package/dist/shell/runtime.js +848 -0
  96. package/dist/theme/variables.d.ts +73 -0
  97. package/dist/theme/variables.js +171 -0
  98. package/dist/transport/post-message.d.ts +9 -0
  99. package/dist/transport/post-message.js +38 -0
  100. package/dist/transport/types.d.ts +15 -0
  101. package/dist/transport/types.js +1 -0
  102. package/package.json +63 -1
  103. package/src/bridge/bridge.ts +278 -0
  104. package/src/bridge/types.ts +95 -0
  105. package/src/compile/compile.ts +68 -0
  106. package/src/compile/policy.ts +130 -0
  107. package/src/config/capabilities.ts +138 -0
  108. package/src/config/schema.ts +168 -0
  109. package/src/config/validate.ts +318 -0
  110. package/src/conformance/behavior.ts +485 -0
  111. package/src/conformance/index.ts +116 -0
  112. package/src/conformance/kit.ts +63 -0
  113. package/src/conformance/oracles.ts +126 -0
  114. package/src/conformance/probes.ts +1129 -0
  115. package/src/conformance/served.ts +501 -0
  116. package/src/guide/examples.ts +74 -0
  117. package/src/guide/guide.ts +430 -0
  118. package/src/host/create-host.ts +335 -0
  119. package/src/host/locate.ts +25 -0
  120. package/src/host/outcome.ts +159 -0
  121. package/src/host/types.ts +126 -0
  122. package/src/index.ts +128 -0
  123. package/src/isolation/served.ts +151 -0
  124. package/src/isolation/types.ts +91 -0
  125. package/src/mcp/adapter.ts +441 -0
  126. package/src/mcp/index.ts +50 -0
  127. package/src/mcp/proxy.ts +117 -0
  128. package/src/mcp/view.ts +97 -0
  129. package/src/platform/types.ts +55 -0
  130. package/src/platform/web.ts +91 -0
  131. package/src/presets/presets.ts +217 -0
  132. package/src/protocol/adapter.ts +67 -0
  133. package/src/protocol/jsonrpc.ts +83 -0
  134. package/src/protocol/mcp-apps.ts +138 -0
  135. package/src/protocol/shell-adapter.ts +261 -0
  136. package/src/react/dialog.tsx +158 -0
  137. package/src/react/frame.tsx +486 -0
  138. package/src/react/index.ts +20 -0
  139. package/src/react/live.ts +76 -0
  140. package/src/server/index.ts +37 -0
  141. package/src/server/keys.ts +100 -0
  142. package/src/server/pages.ts +117 -0
  143. package/src/server/proxy.ts +131 -0
  144. package/src/server/server.ts +657 -0
  145. package/src/server/tickets.ts +30 -0
  146. package/src/server/types.ts +104 -0
  147. package/src/shell/document.ts +121 -0
  148. package/src/shell/plan.ts +69 -0
  149. package/src/shell/runtime.ts +848 -0
  150. package/src/theme/variables.ts +190 -0
  151. package/src/transport/post-message.ts +45 -0
  152. package/src/transport/types.ts +16 -0
@@ -0,0 +1,441 @@
1
+ import type { CallOutcome } from "../bridge/types.ts";
2
+ import { egressOrigins } from "../compile/policy.ts";
3
+ import type { CapabilityName } from "../config/capabilities.ts";
4
+ import {
5
+ JSON_RPC_ERRORS,
6
+ parseJsonRpc,
7
+ type JsonRpcId,
8
+ type JsonRpcMessage,
9
+ type JsonRpcRequest,
10
+ } from "../protocol/jsonrpc.ts";
11
+ import type { AdapterConnection, AdapterSession, ProtocolAdapter } from "../protocol/adapter.ts";
12
+ import {
13
+ MCP_APPS_METHODS,
14
+ MCP_APPS_PROTOCOL_VERSION,
15
+ type HostContext,
16
+ } from "../protocol/mcp-apps.ts";
17
+ import { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
18
+
19
+ /**
20
+ * The MCP Apps adapter (P004, P007): the host side of the protocol for full MCP Apps views, written
21
+ * against the core's seams and no SDK. It answers the view's handshake with the host context and
22
+ * the capabilities the config declares, carries the view's requests into the bridge (which alone
23
+ * decides whether they run), reports sizes, errors and teardown requests, and sends the view its
24
+ * tool input, result and cancellation, never before the view said it was initialized (spec §
25
+ * Sandbox proxy, 6). Behind the sandbox proxy it hands the proxy the view's HTML first.
26
+ */
27
+
28
+ /** The tool call a view was made for: its host context's `toolInfo`. */
29
+ export interface ToolInfo {
30
+ /** The JSON-RPC id of the `tools/call`. */
31
+ readonly id?: string | number;
32
+ /** The tool's definition (name, input schema, …) as the server listed it. */
33
+ readonly tool: Readonly<Record<string, unknown>>;
34
+ }
35
+
36
+ export interface McpAppsAdapterOptions {
37
+ /** The tool call each display shows, by display id. */
38
+ readonly toolInfo?: (displayId: string) => ToolInfo | undefined;
39
+ /** How long `teardown` waits for the view's answer, ms (default 3000). */
40
+ readonly teardownTimeoutMs?: number;
41
+ }
42
+
43
+ /** A tool's result as MCP carries it (`CallToolResult`): content, structured content, `_meta`. */
44
+ export type ToolResult = Readonly<Record<string, unknown>>;
45
+
46
+ /** What a host drives on an MCP Apps view, besides the framework's own controls. */
47
+ export interface McpAppConnection extends AdapterConnection {
48
+ /** The tool's arguments so far, while the agent writes them (dropped once the whole went). */
49
+ sendToolInputPartial(args: Readonly<Record<string, unknown>>): void;
50
+ /** The tool's whole arguments, once. */
51
+ sendToolInput(args: Readonly<Record<string, unknown>>): void;
52
+ /** The tool's result. */
53
+ sendToolResult(result: ToolResult): void;
54
+ /** The tool call was cancelled (by the person, an error, a policy). */
55
+ sendToolCancelled(reason?: string): void;
56
+ }
57
+
58
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
59
+ value !== null && typeof value === "object" && !Array.isArray(value);
60
+
61
+ /** Levels of `notifications/message` that are errors: they go to the host as `reportError`. */
62
+ const ERROR_LEVELS = new Set(["error", "critical", "alert", "emergency"]);
63
+ const DISPLAY_MODES = new Set(["inline", "fullscreen", "pip"]);
64
+ const STYLE_VARIABLES = new Set(MCP_APPS_STYLE_VARIABLES);
65
+
66
+ /**
67
+ * The host context as a view may receive it: Porthole's fields as they are, its theme variables
68
+ * cut to the names the standard defines (the official SDK refuses any other).
69
+ */
70
+ export function viewHostContext(
71
+ context: HostContext,
72
+ toolInfo?: ToolInfo,
73
+ ): Readonly<Record<string, unknown>> {
74
+ const out: Record<string, unknown> = {};
75
+ for (const [key, value] of Object.entries(context)) {
76
+ if (value !== undefined && key !== "styles") {
77
+ out[key] = value;
78
+ }
79
+ }
80
+ const variables: Record<string, string> = {};
81
+ for (const [name, value] of Object.entries(context.styles?.variables ?? {})) {
82
+ if (STYLE_VARIABLES.has(name) && typeof value === "string") {
83
+ variables[name] = value;
84
+ }
85
+ }
86
+ out.styles = { variables };
87
+ if (toolInfo !== undefined) {
88
+ out.toolInfo = toolInfo;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /** The text of a `ui/message`'s content: its text blocks, in order (the spec's examples send one). */
94
+ function messageText(content: unknown): string | null {
95
+ const blocks = Array.isArray(content) ? content : [content];
96
+ const texts = blocks.flatMap((block) =>
97
+ isRecord(block) && block.type === "text" && typeof block.text === "string" ? [block.text] : [],
98
+ );
99
+ return texts.length === 0 ? null : texts.join("\n\n");
100
+ }
101
+
102
+ /** Whether the host page has a live user gesture (a gesture in a frame activates its ancestors). */
103
+ function hostActivation(): boolean | undefined {
104
+ const activation = (globalThis as { navigator?: { userActivation?: { isActive: boolean } } })
105
+ .navigator?.userActivation;
106
+ return activation === undefined ? undefined : activation.isActive;
107
+ }
108
+
109
+ function connect(session: AdapterSession, options: McpAppsAdapterOptions): McpAppConnection {
110
+ const { transport, bridge } = session;
111
+ let closed = false;
112
+ let initialized = false;
113
+ let inputSent = false;
114
+ let resourceSent = false;
115
+ /** What the host said before the view was initialized: sent, in order, once it is. */
116
+ let queue: JsonRpcMessage[] = [];
117
+ const pending = new Map<JsonRpcId, () => void>();
118
+ let nextId = 1;
119
+
120
+ const send = (message: JsonRpcMessage): void => {
121
+ if (!closed) {
122
+ transport.send(message);
123
+ }
124
+ };
125
+ const notify = (method: string, params: Readonly<Record<string, unknown>>): void => {
126
+ const message: JsonRpcMessage = { jsonrpc: "2.0", method, params };
127
+ if (initialized) {
128
+ send(message);
129
+ } else {
130
+ queue.push(message);
131
+ }
132
+ };
133
+ const answer = (id: JsonRpcId, result: unknown): void => send({ jsonrpc: "2.0", id, result });
134
+ const refuse = (id: JsonRpcId, code: number, message: string): void =>
135
+ send({ jsonrpc: "2.0", id, error: { code, message } });
136
+
137
+ const hostCapabilities = (): Record<string, unknown> => {
138
+ const declared = (name: CapabilityName) => bridge.declared(name);
139
+ const { config } = session.compiled;
140
+ const connectDomains = egressOrigins(config, "connect");
141
+ const resourceDomains = [
142
+ ...new Set(
143
+ (["script", "style", "img", "font", "media"] as const).flatMap((kind) =>
144
+ egressOrigins(config, kind),
145
+ ),
146
+ ),
147
+ ];
148
+ return {
149
+ logging: {},
150
+ ...(declared("openLink") ? { openLinks: {} } : {}),
151
+ ...(declared("downloadFile") ? { downloadFile: {} } : {}),
152
+ ...(declared("callTool") ? { serverTools: {} } : {}),
153
+ ...(declared("readData") ? { serverResources: {} } : {}),
154
+ ...(declared("sendPrompt") ? { message: { text: {} } } : {}),
155
+ ...(declared("setModelContext")
156
+ ? { updateModelContext: { text: {}, structuredContent: {} } }
157
+ : {}),
158
+ sandbox: {
159
+ csp: {
160
+ ...(connectDomains.length > 0 ? { connectDomains } : {}),
161
+ ...(resourceDomains.length > 0 ? { resourceDomains } : {}),
162
+ },
163
+ },
164
+ };
165
+ };
166
+
167
+ /** Runs a capability through the bridge and answers the request with what it gives. */
168
+ async function call(
169
+ request: JsonRpcRequest,
170
+ capability: CapabilityName,
171
+ args: unknown,
172
+ result: (value: unknown) => unknown,
173
+ ): Promise<void> {
174
+ const userActivated = hostActivation();
175
+ let outcome: CallOutcome<unknown>;
176
+ try {
177
+ outcome = await bridge.call(
178
+ capability,
179
+ args,
180
+ userActivated === undefined ? undefined : { userActivated },
181
+ );
182
+ } catch {
183
+ refuse(request.id, JSON_RPC_ERRORS.internal, `${request.method} failed`);
184
+ return;
185
+ }
186
+ if (outcome.ok) {
187
+ answer(request.id, result(outcome.value));
188
+ } else if (outcome.refusal === "not-declared") {
189
+ refuse(request.id, JSON_RPC_ERRORS.methodNotFound, `${request.method} isn't offered here`);
190
+ } else {
191
+ // The spec's "implementation-defined" error for a refused request.
192
+ refuse(request.id, -32_000, `${request.method} refused (${outcome.refusal})`);
193
+ }
194
+ }
195
+
196
+ /** `{}` when a host function did it, `{ isError: true }` when it said it couldn't. */
197
+ const done = (value: unknown): Record<string, unknown> =>
198
+ value === false ? { isError: true } : {};
199
+
200
+ async function onRequest(request: JsonRpcRequest): Promise<void> {
201
+ const params = request.params ?? {};
202
+ const invalid = (what: string) =>
203
+ refuse(request.id, JSON_RPC_ERRORS.invalidParams, `${request.method}: ${what}`);
204
+ switch (request.method) {
205
+ case MCP_APPS_METHODS.initialize:
206
+ answer(request.id, {
207
+ protocolVersion: MCP_APPS_PROTOCOL_VERSION,
208
+ hostInfo: session.hostInfo,
209
+ hostCapabilities: hostCapabilities(),
210
+ hostContext: viewHostContext(
211
+ session.hostContext(),
212
+ options.toolInfo?.(session.display.id),
213
+ ),
214
+ });
215
+ return;
216
+ case "ping":
217
+ answer(request.id, {});
218
+ return;
219
+ case MCP_APPS_METHODS.message: {
220
+ const text = params.role === "user" ? messageText(params.content) : null;
221
+ if (text === null) {
222
+ invalid('needs role "user" and text content');
223
+ return;
224
+ }
225
+ await call(request, "sendPrompt", { text }, done);
226
+ return;
227
+ }
228
+ case MCP_APPS_METHODS.openLink:
229
+ if (typeof params.url !== "string") {
230
+ invalid("needs a url");
231
+ return;
232
+ }
233
+ await call(request, "openLink", { url: params.url }, done);
234
+ return;
235
+ case MCP_APPS_METHODS.downloadFile:
236
+ if (!Array.isArray(params.contents)) {
237
+ invalid("needs contents");
238
+ return;
239
+ }
240
+ await call(request, "downloadFile", { contents: params.contents }, done);
241
+ return;
242
+ case MCP_APPS_METHODS.callTool:
243
+ if (
244
+ typeof params.name !== "string" ||
245
+ (params.arguments !== undefined && !isRecord(params.arguments))
246
+ ) {
247
+ invalid("needs a tool name and object arguments");
248
+ return;
249
+ }
250
+ await call(
251
+ request,
252
+ "callTool",
253
+ { name: params.name, arguments: params.arguments ?? {} },
254
+ (value) => (isRecord(value) ? value : { content: [] }),
255
+ );
256
+ return;
257
+ case MCP_APPS_METHODS.readResource:
258
+ if (typeof params.uri !== "string") {
259
+ invalid("needs a uri");
260
+ return;
261
+ }
262
+ await call(request, "readData", { uri: params.uri }, (value) =>
263
+ isRecord(value) ? value : { contents: [] },
264
+ );
265
+ return;
266
+ case MCP_APPS_METHODS.updateModelContext:
267
+ if (
268
+ (params.content !== undefined && !Array.isArray(params.content)) ||
269
+ (params.structuredContent !== undefined && !isRecord(params.structuredContent))
270
+ ) {
271
+ invalid("content is a list, structuredContent an object");
272
+ return;
273
+ }
274
+ await call(
275
+ request,
276
+ "setModelContext",
277
+ {
278
+ ...(params.content === undefined ? {} : { content: params.content }),
279
+ ...(params.structuredContent === undefined
280
+ ? {}
281
+ : { structuredContent: params.structuredContent }),
282
+ },
283
+ () => ({}),
284
+ );
285
+ return;
286
+ case MCP_APPS_METHODS.requestDisplayMode: {
287
+ if (typeof params.mode !== "string" || !DISPLAY_MODES.has(params.mode)) {
288
+ invalid("needs a display mode");
289
+ return;
290
+ }
291
+ // Changing how a display is shown is the host's own layout: the view keeps its mode, and
292
+ // the answer says so (spec: return the current mode when the request isn't granted).
293
+ answer(request.id, { mode: session.hostContext().displayMode });
294
+ return;
295
+ }
296
+ default:
297
+ refuse(request.id, JSON_RPC_ERRORS.methodNotFound, `${request.method} isn't offered here`);
298
+ }
299
+ }
300
+
301
+ function onNotification(method: string, params: Readonly<Record<string, unknown>>): void {
302
+ switch (method) {
303
+ case MCP_APPS_METHODS.sandboxProxyReady:
304
+ // Behind the sandbox proxy: it takes the view's HTML once, then becomes the view (P026).
305
+ if (!resourceSent && session.document !== undefined) {
306
+ resourceSent = true;
307
+ send({
308
+ jsonrpc: "2.0",
309
+ method: MCP_APPS_METHODS.sandboxResourceReady,
310
+ params: { html: session.document },
311
+ });
312
+ }
313
+ return;
314
+ case MCP_APPS_METHODS.initialized:
315
+ if (!initialized) {
316
+ initialized = true;
317
+ const waiting = queue;
318
+ queue = [];
319
+ for (const message of waiting) {
320
+ send(message);
321
+ }
322
+ session.emit({ type: "initialized" });
323
+ }
324
+ return;
325
+ case MCP_APPS_METHODS.sizeChanged: {
326
+ const { height, width } = params;
327
+ const size = (value: unknown): value is number =>
328
+ typeof value === "number" && Number.isFinite(value) && value >= 0;
329
+ if (size(height)) {
330
+ void bridge.call("resize", { height, ...(size(width) ? { width } : {}) });
331
+ }
332
+ return;
333
+ }
334
+ case MCP_APPS_METHODS.log:
335
+ if (typeof params.level === "string" && ERROR_LEVELS.has(params.level)) {
336
+ const data = params.data;
337
+ void bridge.call("reportError", {
338
+ message: (typeof data === "string" ? data : (JSON.stringify(data) ?? "")).slice(
339
+ 0,
340
+ 2_000,
341
+ ),
342
+ });
343
+ }
344
+ return;
345
+ case MCP_APPS_METHODS.requestTeardown:
346
+ session.emit({ type: "teardown-requested" });
347
+ return;
348
+ default:
349
+ }
350
+ }
351
+
352
+ const unlisten = transport.listen((raw) => {
353
+ const message = parseJsonRpc(raw);
354
+ if (message === null || closed) {
355
+ return;
356
+ }
357
+ if (!("method" in message)) {
358
+ const waiting = pending.get(message.id);
359
+ pending.delete(message.id);
360
+ waiting?.();
361
+ return;
362
+ }
363
+ if ("id" in message) {
364
+ void onRequest(message);
365
+ } else {
366
+ onNotification(message.method, message.params ?? {});
367
+ }
368
+ });
369
+
370
+ return {
371
+ hostContextChanged(context) {
372
+ if (initialized) {
373
+ send({
374
+ jsonrpc: "2.0",
375
+ method: MCP_APPS_METHODS.hostContextChanged,
376
+ params: viewHostContext(context),
377
+ });
378
+ }
379
+ },
380
+ teardown() {
381
+ if (closed || !initialized) {
382
+ return Promise.resolve();
383
+ }
384
+ return new Promise((resolve) => {
385
+ const id = nextId++;
386
+ const finish = () => {
387
+ clearTimeout(timer);
388
+ pending.delete(id);
389
+ resolve();
390
+ };
391
+ const timer = setTimeout(finish, options.teardownTimeoutMs ?? 3_000);
392
+ pending.set(id, finish);
393
+ send({ jsonrpc: "2.0", id, method: MCP_APPS_METHODS.resourceTeardown, params: {} });
394
+ });
395
+ },
396
+ close() {
397
+ closed = true;
398
+ unlisten();
399
+ queue = [];
400
+ for (const waiting of pending.values()) {
401
+ waiting();
402
+ }
403
+ },
404
+ sendToolInputPartial(args) {
405
+ if (inputSent) {
406
+ return;
407
+ }
408
+ if (!initialized) {
409
+ // Only the latest draft matters to a view that hasn't seen any.
410
+ queue = queue.filter(
411
+ (message) =>
412
+ !("method" in message) || message.method !== MCP_APPS_METHODS.toolInputPartial,
413
+ );
414
+ }
415
+ notify(MCP_APPS_METHODS.toolInputPartial, { arguments: args });
416
+ },
417
+ sendToolInput(args) {
418
+ if (inputSent) {
419
+ return;
420
+ }
421
+ inputSent = true;
422
+ queue = queue.filter(
423
+ (message) => !("method" in message) || message.method !== MCP_APPS_METHODS.toolInputPartial,
424
+ );
425
+ notify(MCP_APPS_METHODS.toolInput, { arguments: args });
426
+ },
427
+ sendToolResult(result) {
428
+ notify(MCP_APPS_METHODS.toolResult, result);
429
+ },
430
+ sendToolCancelled(reason) {
431
+ notify(MCP_APPS_METHODS.toolCancelled, reason === undefined ? {} : { reason });
432
+ },
433
+ };
434
+ }
435
+
436
+ /** The MCP Apps adapter: pass it as `HostOptions.adapter` with the `mcpApp` preset. */
437
+ export function mcpAppsAdapter(
438
+ options: McpAppsAdapterOptions = {},
439
+ ): ProtocolAdapter<McpAppConnection> {
440
+ return { name: "mcp-apps", connect: (session) => connect(session, options) };
441
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `@botiverse/porthole/mcp`: the MCP Apps adapter for full MCP Apps views (P007): the host-side
3
+ * subset of the protocol (the handshake and host context, tool input and result, cancellation,
4
+ * teardown, sizes and errors; `ui/message`, `ui/open-link`, `ui/download-file`, `tools/call`,
5
+ * `resources/read` and `ui/update-model-context` through the bridge; the sandbox-proxy handshake),
6
+ * implemented as a `ProtocolAdapter` against the core's seams. It depends on the core only, never
7
+ * on the MCP SDK.
8
+ *
9
+ * const view = readViewResource(await client.readResource({ uri }));
10
+ * const host = createHost(presets.mcpApp({ egress: view.egress, capabilities: { callTool: {} } }), {
11
+ * adapter: mcpAppsAdapter(),
12
+ * handlers: { callTool: ({ name, arguments: args }) => client.callTool({ name, arguments: args }) },
13
+ * });
14
+ * const display = host.mount(element, { displayId, title, scheme, document: view.html });
15
+ * display.sendToolInput(args);
16
+ * display.sendToolResult(await client.callTool({ name, arguments: args }));
17
+ *
18
+ * Tool visibility (`_meta.ui.visibility`) is the host's policy: its `callTool` handler refuses a
19
+ * tool the view may not call.
20
+ */
21
+ export {
22
+ mcpAppsAdapter,
23
+ viewHostContext,
24
+ type McpAppConnection,
25
+ type McpAppsAdapterOptions,
26
+ type ToolInfo,
27
+ type ToolResult,
28
+ } from "./adapter.ts";
29
+ export { sandboxProxyDocument } from "./proxy.ts";
30
+ export { VIEW_MIME_TYPE, readViewResource, viewEgress, type ViewResource } from "./view.ts";
31
+ export type {
32
+ AdapterConnection,
33
+ AdapterEvent,
34
+ AdapterSession,
35
+ ProtocolAdapter,
36
+ } from "../protocol/adapter.ts";
37
+ export type { HostContext } from "../protocol/mcp-apps.ts";
38
+ export {
39
+ MCP_APPS_METHODS,
40
+ MCP_APPS_PROTOCOL_VERSION,
41
+ MCP_APPS_STYLE_VARIABLES,
42
+ PORTHOLE_STYLES,
43
+ } from "../protocol/mcp-apps.ts";
44
+ export {
45
+ JSON_RPC_ERRORS,
46
+ parseJsonRpc,
47
+ type JsonRpcMessage,
48
+ type JsonRpcRequest,
49
+ type JsonRpcResponse,
50
+ } from "../protocol/jsonrpc.ts";
@@ -0,0 +1,117 @@
1
+ import type { CompiledConfig } from "../compile/compile.ts";
2
+ import { MCP_APPS_METHODS } from "../protocol/mcp-apps.ts";
3
+
4
+ /**
5
+ * The MCP Apps sandbox proxy (spec § Sandbox proxy), served by `/server` on a scope's origin: a
6
+ * second origin, never the host's, where a view gets `allow-scripts allow-same-origin` (P016).
7
+ * It announces itself (`sandbox-proxy-ready`) until its host answers with the view's HTML
8
+ * (`sandbox-resource-ready`), then becomes the view: it writes the shell's document into its own,
9
+ * with the view's HTML as data in the shell's plan (never as markup for the page's parser, which
10
+ * could build what the shell's guards can't see), and the shell renders it inert, guarded, then
11
+ * runs it, under the CSP the proxy's headers already carry. Writing a document erases
12
+ * the window's listeners, so nothing of the proxy is left once the view runs, and the view talks
13
+ * to its host directly: the relay the spec describes is the identity here (P026).
14
+ *
15
+ * The proxy takes the HTML only from its parent, only from the host origins, and only once. It
16
+ * ignores the message's `csp`, `sandbox` and `permissions`: the policy is the one its headers set
17
+ * from the config and the view's declared egress when the host's backend issued it, and the
18
+ * frame's sandbox and `allow` are the host's, from the same config.
19
+ */
20
+ export const SANDBOX_PROXY_RUNTIME = String.raw`(() => {
21
+ "use strict";
22
+ const setup = __SETUP__;
23
+ if (window.parent === window) {
24
+ return;
25
+ }
26
+ let done = false;
27
+ window.addEventListener("message", (event) => {
28
+ if (done || event.source !== window.parent || setup.origins.indexOf(event.origin) === -1) {
29
+ return;
30
+ }
31
+ const data = event.data;
32
+ if (data === null || typeof data !== "object" || data.jsonrpc !== "2.0" || data.method !== setup.resource) {
33
+ return;
34
+ }
35
+ const params = data.params;
36
+ if (params === null || typeof params !== "object" || typeof params.html !== "string") {
37
+ return;
38
+ }
39
+ done = true;
40
+ // The view goes into the shell's plan as a JSON string, safe inside its <script>.
41
+ const literal = JSON.stringify(params.html)
42
+ .replace(/</g, "\\u003c")
43
+ .replace(/\u2028/g, "\\u2028")
44
+ .replace(/\u2029/g, "\\u2029");
45
+ const page = setup.shell.replace(setup.slot, () => literal);
46
+ document.open();
47
+ document.write(page);
48
+ document.close();
49
+ });
50
+ // Say so until answered: the host may start listening after this runs. Nothing secret is said.
51
+ let tries = 0;
52
+ const announce = () => {
53
+ if (done || tries >= 40) {
54
+ return;
55
+ }
56
+ tries += 1;
57
+ window.parent.postMessage({ jsonrpc: "2.0", method: setup.ready, params: {} }, "*");
58
+ setTimeout(announce, 250);
59
+ };
60
+ announce();
61
+ })();`;
62
+
63
+ /** Where the view's HTML goes in the shell's plan: a string no view can produce as JSON. */
64
+ const VIEW_SLOT = "porthole-view-slot-7f3a9c";
65
+
66
+ /** JSON that is safe inside a `<script>`: no `</script>`, no `<!--`. */
67
+ const scriptJson = (value: unknown): string =>
68
+ JSON.stringify(value)
69
+ .replace(/</gu, "\\u003c")
70
+ .replace(/\u2028/gu, "\\u2028")
71
+ .replace(/\u2029/gu, "\\u2029");
72
+
73
+ /**
74
+ * The proxy's document for a view under `compiled` (an `mcp-app` config with domain isolation),
75
+ * taking the view only from `hostOrigins`. The document it becomes is exactly
76
+ * `compiled.shellDocument({ origins: hostOrigins, document: html, cspMeta: false })`: the shell,
77
+ * carrying the view as data.
78
+ */
79
+ export function sandboxProxyDocument(
80
+ compiled: CompiledConfig,
81
+ hostOrigins: readonly string[],
82
+ ): string {
83
+ if (compiled.config.mode !== "mcp-app") {
84
+ throw new TypeError(`The sandbox proxy serves MCP Apps views, not ${compiled.config.mode}`);
85
+ }
86
+ // The shell with a slot for the view: the slot's JSON literal, replaced by the view's.
87
+ const shell = compiled.shellDocument({
88
+ origins: hostOrigins,
89
+ document: VIEW_SLOT,
90
+ cspMeta: false,
91
+ });
92
+ const slot = JSON.stringify(VIEW_SLOT);
93
+ if (shell.split(slot).length !== 2) {
94
+ throw new Error("The shell document must hold the view's slot exactly once");
95
+ }
96
+ const runtime = SANDBOX_PROXY_RUNTIME.replace("__SETUP__", () =>
97
+ scriptJson({
98
+ origins: hostOrigins,
99
+ shell,
100
+ slot,
101
+ ready: MCP_APPS_METHODS.sandboxProxyReady,
102
+ resource: MCP_APPS_METHODS.sandboxResourceReady,
103
+ }),
104
+ );
105
+ return `<!doctype html>
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="utf-8">
109
+ <meta name="referrer" content="no-referrer">
110
+ <title>Sandbox</title>
111
+ </head>
112
+ <body>
113
+ <script>${runtime}</script>
114
+ </body>
115
+ </html>
116
+ `;
117
+ }