@botiverse/porthole 0.0.1 → 0.1.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/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +184 -0
- package/dist/bridge/types.d.ts +67 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +106 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +131 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/validate.d.ts +20 -0
- package/dist/config/validate.js +230 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +376 -0
- package/dist/conformance/index.d.ts +95 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +9 -0
- package/dist/conformance/probes.js +865 -0
- package/dist/conformance/served.d.ts +68 -0
- package/dist/conformance/served.js +311 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +261 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +248 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +11 -0
- package/dist/host/types.d.ts +95 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +19 -0
- package/dist/isolation/served.d.ts +32 -0
- package/dist/isolation/served.js +97 -0
- package/dist/isolation/types.d.ts +87 -0
- package/dist/isolation/types.js +1 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +44 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +72 -0
- package/dist/presets/presets.d.ts +65 -0
- package/dist/presets/presets.js +125 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +75 -0
- package/dist/react/frame.js +221 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +6 -0
- package/dist/server/pages.js +20 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +518 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +28 -0
- package/dist/shell/document.js +84 -0
- package/dist/shell/plan.d.ts +35 -0
- package/dist/shell/plan.js +34 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +832 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +71 -1
- package/src/bridge/bridge.ts +256 -0
- package/src/bridge/types.ts +77 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +133 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +140 -0
- package/src/config/validate.ts +284 -0
- package/src/conformance/behavior.ts +430 -0
- package/src/conformance/index.ts +102 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1093 -0
- package/src/conformance/served.ts +472 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +424 -0
- package/src/host/create-host.ts +290 -0
- package/src/host/locate.ts +20 -0
- package/src/host/types.ts +95 -0
- package/src/index.ts +118 -0
- package/src/isolation/served.ts +127 -0
- package/src/isolation/types.ts +77 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +48 -0
- package/src/platform/web.ts +78 -0
- package/src/presets/presets.ts +193 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +422 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +31 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +617 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +120 -0
- package/src/shell/plan.ts +66 -0
- package/src/shell/runtime.ts +832 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolation-mode seam. Opaque frames need nothing from outside: the host builds the shell
|
|
3
|
+
* document and the frame is `srcdoc`. Served modes (domain, and url mode's external app) load a
|
|
4
|
+
* URL, so the host asks a locator for one; the locator is the host's own code, typically a call to
|
|
5
|
+
* the host's backend, which asks `/server` for a ticketed URL after deciding the viewer may see it.
|
|
6
|
+
*
|
|
7
|
+
* Storage is keyed by origin. In domain mode each storage scope gets its own subdomain of the
|
|
8
|
+
* content domain (`<label>.example-usercontent.com`, the label derived from the scope by `/server`,
|
|
9
|
+
* not guessable from it), so content with the same scope shares an origin and its storage and
|
|
10
|
+
* nothing else does. The host chooses the scope (PRINCIPLE 8): per display, per conversation, per
|
|
11
|
+
* app. Browsers partition a framed origin's storage by the top-level site, so it is stable within
|
|
12
|
+
* one host app.
|
|
13
|
+
*/
|
|
14
|
+
import type { EgressConfig } from "../config/schema.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Which document a served frame loads. `preset` names the config it is served under (the server's
|
|
18
|
+
* `configs`); the artifact and the proxy default to their presets' names.
|
|
19
|
+
*/
|
|
20
|
+
export type ServedDocument =
|
|
21
|
+
/** The shell, served with its CSP in headers; content arrives over the transport as for opaque. */
|
|
22
|
+
| { readonly kind: "shell"; readonly preset: string }
|
|
23
|
+
/** A stored page (artifact mode), by id and version: versions are immutable. */
|
|
24
|
+
| {
|
|
25
|
+
readonly kind: "artifact";
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly version: string;
|
|
28
|
+
/** Default `artifact`. */
|
|
29
|
+
readonly preset?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The MCP Apps sandbox proxy: a second origin the host hands the view's HTML to, which becomes
|
|
33
|
+
* the view. `egress` is what the view declares (`_meta.ui.csp`, mapped by `/mcp`'s
|
|
34
|
+
* `viewEgress`), opened on top of the config's: its CSP goes out in the proxy's headers, before
|
|
35
|
+
* the HTML arrives.
|
|
36
|
+
*/
|
|
37
|
+
| { readonly kind: "mcp-proxy"; readonly preset?: string; readonly egress?: EgressConfig }
|
|
38
|
+
/**
|
|
39
|
+
* Someone else's running app (url mode), e.g. a dev server behind the host's preview gateway:
|
|
40
|
+
* `url` is where the host's upstream finds it; the frame lands on its path and query.
|
|
41
|
+
*/
|
|
42
|
+
| { readonly kind: "external"; readonly url: string };
|
|
43
|
+
|
|
44
|
+
/** One served instance: where its storage lives, and what it shows. */
|
|
45
|
+
export interface InstanceRef {
|
|
46
|
+
/**
|
|
47
|
+
* What the content's origin, and so its storage, is keyed by. Instances with the same scope
|
|
48
|
+
* share an origin. Opaque to Porthole: the host picks it (a display id, an app id).
|
|
49
|
+
*/
|
|
50
|
+
readonly scope: string;
|
|
51
|
+
readonly document: ServedDocument;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Where a served frame loads from. */
|
|
55
|
+
export interface FrameLocation {
|
|
56
|
+
/** The URL the frame loads, carrying a one-time ticket where the content is private. */
|
|
57
|
+
readonly url: string;
|
|
58
|
+
/** Its origin: the host takes messages only from this origin, from this frame. */
|
|
59
|
+
readonly origin: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Turns an instance into the URL its frame loads. Implemented by the host, usually by asking its
|
|
64
|
+
* own backend (which authenticates the viewer and calls `PortholeServer.issue`). Called on every
|
|
65
|
+
* mount: a ticket is good once.
|
|
66
|
+
*/
|
|
67
|
+
export interface InstanceLocator {
|
|
68
|
+
locate(instance: InstanceRef, signal?: AbortSignal): Promise<FrameLocation>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Where a frame's document comes from: built by the host (`srcdoc`, opaque isolation), or loaded
|
|
73
|
+
* from a URL (domain and external isolation).
|
|
74
|
+
*/
|
|
75
|
+
export type FrameSource =
|
|
76
|
+
| { readonly kind: "srcdoc"; readonly html: string }
|
|
77
|
+
| { readonly kind: "url"; readonly url: string; readonly origin: string };
|
|
@@ -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
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -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";
|
package/src/mcp/proxy.ts
ADDED
|
@@ -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
|
+
}
|