@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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0, as MCP Apps carries it over a transport. Every message crossing the frame boundary
|
|
3
|
+
* is checked against these shapes before use; anything else is dropped, never coerced.
|
|
4
|
+
*/
|
|
5
|
+
export type JsonRpcId = string | number;
|
|
6
|
+
export interface JsonRpcRequest {
|
|
7
|
+
readonly jsonrpc: "2.0";
|
|
8
|
+
readonly id: JsonRpcId;
|
|
9
|
+
readonly method: string;
|
|
10
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
11
|
+
}
|
|
12
|
+
export interface JsonRpcNotification {
|
|
13
|
+
readonly jsonrpc: "2.0";
|
|
14
|
+
readonly method: string;
|
|
15
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
16
|
+
}
|
|
17
|
+
export interface JsonRpcError {
|
|
18
|
+
readonly code: number;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly data?: unknown;
|
|
21
|
+
}
|
|
22
|
+
export type JsonRpcResponse = {
|
|
23
|
+
readonly jsonrpc: "2.0";
|
|
24
|
+
readonly id: JsonRpcId;
|
|
25
|
+
readonly result: unknown;
|
|
26
|
+
} | {
|
|
27
|
+
readonly jsonrpc: "2.0";
|
|
28
|
+
readonly id: JsonRpcId;
|
|
29
|
+
readonly error: JsonRpcError;
|
|
30
|
+
};
|
|
31
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
32
|
+
/** The standard error codes the adapters answer with. */
|
|
33
|
+
export declare const JSON_RPC_ERRORS: {
|
|
34
|
+
readonly invalidRequest: -32600;
|
|
35
|
+
readonly methodNotFound: -32601;
|
|
36
|
+
readonly invalidParams: -32602;
|
|
37
|
+
readonly internal: -32603;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* `value` as a JSON-RPC message, or null when it isn't one: a request (id + method), a
|
|
41
|
+
* notification (method only), or a response (id + exactly one of result / error).
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseJsonRpc(value: unknown): JsonRpcMessage | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0, as MCP Apps carries it over a transport. Every message crossing the frame boundary
|
|
3
|
+
* is checked against these shapes before use; anything else is dropped, never coerced.
|
|
4
|
+
*/
|
|
5
|
+
/** The standard error codes the adapters answer with. */
|
|
6
|
+
export const JSON_RPC_ERRORS = {
|
|
7
|
+
invalidRequest: -32600,
|
|
8
|
+
methodNotFound: -32601,
|
|
9
|
+
invalidParams: -32602,
|
|
10
|
+
internal: -32603,
|
|
11
|
+
};
|
|
12
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
const isId = (value) => (typeof value === "string" && value.length <= 256) ||
|
|
14
|
+
(typeof value === "number" && Number.isSafeInteger(value));
|
|
15
|
+
/**
|
|
16
|
+
* `value` as a JSON-RPC message, or null when it isn't one: a request (id + method), a
|
|
17
|
+
* notification (method only), or a response (id + exactly one of result / error).
|
|
18
|
+
*/
|
|
19
|
+
export function parseJsonRpc(value) {
|
|
20
|
+
if (!isRecord(value) || value.jsonrpc !== "2.0") {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (value.params !== undefined && !isRecord(value.params)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof value.method === "string") {
|
|
27
|
+
if (value.method.length === 0 || value.method.length > 256) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (value.id === undefined) {
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
return isId(value.id) ? value : null;
|
|
34
|
+
}
|
|
35
|
+
if (!isId(value.id) || value.method !== undefined) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const hasResult = "result" in value;
|
|
39
|
+
const hasError = "error" in value;
|
|
40
|
+
if (hasResult === hasError) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (hasError) {
|
|
44
|
+
const error = value.error;
|
|
45
|
+
if (!isRecord(error) || typeof error.code !== "number" || typeof error.message !== "string") {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Apps (`io.modelcontextprotocol/ui`, 2026-01-26): the method names Porthole speaks. The shell
|
|
3
|
+
* is an MCP Apps view of its host; content may also use the official App SDK directly, so the host
|
|
4
|
+
* side answers these whoever sends them. This module holds names and shapes only: the core's own
|
|
5
|
+
* adapter (the subset the shell speaks) and the `/mcp` adapter (full MCP Apps views) both use it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MCP_APPS_PROTOCOL_VERSION = "2026-01-26";
|
|
8
|
+
export declare const MCP_APPS_METHODS: {
|
|
9
|
+
readonly initialize: "ui/initialize";
|
|
10
|
+
readonly message: "ui/message";
|
|
11
|
+
readonly openLink: "ui/open-link";
|
|
12
|
+
readonly downloadFile: "ui/download-file";
|
|
13
|
+
readonly requestDisplayMode: "ui/request-display-mode";
|
|
14
|
+
readonly updateModelContext: "ui/update-model-context";
|
|
15
|
+
readonly callTool: "tools/call";
|
|
16
|
+
readonly readResource: "resources/read";
|
|
17
|
+
readonly initialized: "ui/notifications/initialized";
|
|
18
|
+
readonly sizeChanged: "ui/notifications/size-changed";
|
|
19
|
+
readonly requestTeardown: "ui/notifications/request-teardown";
|
|
20
|
+
readonly log: "notifications/message";
|
|
21
|
+
readonly toolInput: "ui/notifications/tool-input";
|
|
22
|
+
readonly toolInputPartial: "ui/notifications/tool-input-partial";
|
|
23
|
+
readonly toolResult: "ui/notifications/tool-result";
|
|
24
|
+
readonly toolCancelled: "ui/notifications/tool-cancelled";
|
|
25
|
+
readonly hostContextChanged: "ui/notifications/host-context-changed";
|
|
26
|
+
readonly resourceTeardown: "ui/resource-teardown";
|
|
27
|
+
readonly sandboxProxyReady: "ui/notifications/sandbox-proxy-ready";
|
|
28
|
+
readonly sandboxResourceReady: "ui/notifications/sandbox-resource-ready";
|
|
29
|
+
};
|
|
30
|
+
export type McpAppsMethod = (typeof MCP_APPS_METHODS)[keyof typeof MCP_APPS_METHODS];
|
|
31
|
+
/**
|
|
32
|
+
* What the shell's `tool-input(-partial)` carries: the content to draw. Porthole's own argument
|
|
33
|
+
* names, since the shell is the only view that reads them.
|
|
34
|
+
*/
|
|
35
|
+
export interface ShellToolInput {
|
|
36
|
+
readonly html: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The style variables MCP Apps defines (`McpUiStyleVariableKey`, 2026-01-26). The official App SDK
|
|
40
|
+
* refuses a host context whose `styles.variables` holds any other name ("Unrecognized key"), so a
|
|
41
|
+
* host sends exactly these there; Porthole's own names go under `PORTHOLE_STYLES` instead.
|
|
42
|
+
*/
|
|
43
|
+
export declare const MCP_APPS_STYLE_VARIABLES: readonly string[];
|
|
44
|
+
/**
|
|
45
|
+
* The host context's own-prefixed field for the theme variables MCP Apps doesn't define (an
|
|
46
|
+
* accent, a chart palette): `{ variables }`. The shell applies them with the standard ones; a view
|
|
47
|
+
* that doesn't know the field ignores it (the host context is open to extra fields).
|
|
48
|
+
*/
|
|
49
|
+
export declare const PORTHOLE_STYLES = "porthole/styles";
|
|
50
|
+
/** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
|
|
51
|
+
export declare const SHELL_LOGGER = "porthole";
|
|
52
|
+
/**
|
|
53
|
+
* The host context a view receives in `ui/initialize`'s result and in `host-context-changed`
|
|
54
|
+
* (the fields Porthole sets; the spec allows more, which views ignore).
|
|
55
|
+
*/
|
|
56
|
+
export interface HostContext {
|
|
57
|
+
readonly theme: "light" | "dark";
|
|
58
|
+
readonly styles: {
|
|
59
|
+
readonly variables: Readonly<Record<string, string>>;
|
|
60
|
+
};
|
|
61
|
+
readonly displayMode: "inline" | "fullscreen" | "pip";
|
|
62
|
+
readonly availableDisplayModes: readonly ("inline" | "fullscreen" | "pip")[];
|
|
63
|
+
readonly containerDimensions?: {
|
|
64
|
+
readonly maxWidth?: number;
|
|
65
|
+
readonly maxHeight?: number;
|
|
66
|
+
};
|
|
67
|
+
readonly locale?: string;
|
|
68
|
+
readonly timeZone?: string;
|
|
69
|
+
readonly platform?: "web" | "desktop" | "mobile";
|
|
70
|
+
readonly deviceCapabilities?: {
|
|
71
|
+
readonly touch?: boolean;
|
|
72
|
+
readonly hover?: boolean;
|
|
73
|
+
};
|
|
74
|
+
/** Porthole's own theme variables, outside the spec's list (`PORTHOLE_STYLES`). */
|
|
75
|
+
readonly "porthole/styles"?: {
|
|
76
|
+
readonly variables: Readonly<Record<string, string>>;
|
|
77
|
+
};
|
|
78
|
+
readonly [key: string]: unknown;
|
|
79
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Apps (`io.modelcontextprotocol/ui`, 2026-01-26): the method names Porthole speaks. The shell
|
|
3
|
+
* is an MCP Apps view of its host; content may also use the official App SDK directly, so the host
|
|
4
|
+
* side answers these whoever sends them. This module holds names and shapes only: the core's own
|
|
5
|
+
* adapter (the subset the shell speaks) and the `/mcp` adapter (full MCP Apps views) both use it.
|
|
6
|
+
*/
|
|
7
|
+
export const MCP_APPS_PROTOCOL_VERSION = "2026-01-26";
|
|
8
|
+
export const MCP_APPS_METHODS = {
|
|
9
|
+
// View → host requests.
|
|
10
|
+
initialize: "ui/initialize",
|
|
11
|
+
message: "ui/message",
|
|
12
|
+
openLink: "ui/open-link",
|
|
13
|
+
downloadFile: "ui/download-file",
|
|
14
|
+
requestDisplayMode: "ui/request-display-mode",
|
|
15
|
+
updateModelContext: "ui/update-model-context",
|
|
16
|
+
callTool: "tools/call",
|
|
17
|
+
readResource: "resources/read",
|
|
18
|
+
// View → host notifications.
|
|
19
|
+
initialized: "ui/notifications/initialized",
|
|
20
|
+
sizeChanged: "ui/notifications/size-changed",
|
|
21
|
+
requestTeardown: "ui/notifications/request-teardown",
|
|
22
|
+
log: "notifications/message",
|
|
23
|
+
// Host → view notifications.
|
|
24
|
+
toolInput: "ui/notifications/tool-input",
|
|
25
|
+
toolInputPartial: "ui/notifications/tool-input-partial",
|
|
26
|
+
toolResult: "ui/notifications/tool-result",
|
|
27
|
+
toolCancelled: "ui/notifications/tool-cancelled",
|
|
28
|
+
hostContextChanged: "ui/notifications/host-context-changed",
|
|
29
|
+
// Host → view requests.
|
|
30
|
+
resourceTeardown: "ui/resource-teardown",
|
|
31
|
+
// Sandbox proxy (a second origin that relays between host and view).
|
|
32
|
+
sandboxProxyReady: "ui/notifications/sandbox-proxy-ready",
|
|
33
|
+
sandboxResourceReady: "ui/notifications/sandbox-resource-ready",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The style variables MCP Apps defines (`McpUiStyleVariableKey`, 2026-01-26). The official App SDK
|
|
37
|
+
* refuses a host context whose `styles.variables` holds any other name ("Unrecognized key"), so a
|
|
38
|
+
* host sends exactly these there; Porthole's own names go under `PORTHOLE_STYLES` instead.
|
|
39
|
+
*/
|
|
40
|
+
export const MCP_APPS_STYLE_VARIABLES = [
|
|
41
|
+
...["background", "text", "border", "ring"].flatMap((kind) => (kind === "background"
|
|
42
|
+
? [
|
|
43
|
+
"primary",
|
|
44
|
+
"secondary",
|
|
45
|
+
"tertiary",
|
|
46
|
+
"inverse",
|
|
47
|
+
"ghost",
|
|
48
|
+
"info",
|
|
49
|
+
"danger",
|
|
50
|
+
"success",
|
|
51
|
+
"warning",
|
|
52
|
+
"disabled",
|
|
53
|
+
]
|
|
54
|
+
: kind === "text"
|
|
55
|
+
? [
|
|
56
|
+
"primary",
|
|
57
|
+
"secondary",
|
|
58
|
+
"tertiary",
|
|
59
|
+
"inverse",
|
|
60
|
+
"ghost",
|
|
61
|
+
"info",
|
|
62
|
+
"danger",
|
|
63
|
+
"success",
|
|
64
|
+
"warning",
|
|
65
|
+
"disabled",
|
|
66
|
+
]
|
|
67
|
+
: kind === "border"
|
|
68
|
+
? [
|
|
69
|
+
"primary",
|
|
70
|
+
"secondary",
|
|
71
|
+
"tertiary",
|
|
72
|
+
"inverse",
|
|
73
|
+
"ghost",
|
|
74
|
+
"info",
|
|
75
|
+
"danger",
|
|
76
|
+
"success",
|
|
77
|
+
"warning",
|
|
78
|
+
"disabled",
|
|
79
|
+
]
|
|
80
|
+
: ["primary", "secondary", "inverse", "info", "danger", "success", "warning"]).map((name) => `--color-${kind}-${name}`)),
|
|
81
|
+
"--font-sans",
|
|
82
|
+
"--font-mono",
|
|
83
|
+
...["normal", "medium", "semibold", "bold"].map((weight) => `--font-weight-${weight}`),
|
|
84
|
+
...["xs", "sm", "md", "lg"].map((size) => `--font-text-${size}-size`),
|
|
85
|
+
...["xs", "sm", "md", "lg", "xl", "2xl", "3xl"].map((size) => `--font-heading-${size}-size`),
|
|
86
|
+
...["xs", "sm", "md", "lg"].map((size) => `--font-text-${size}-line-height`),
|
|
87
|
+
...["xs", "sm", "md", "lg", "xl", "2xl", "3xl"].map((size) => `--font-heading-${size}-line-height`),
|
|
88
|
+
...["xs", "sm", "md", "lg", "xl", "full"].map((size) => `--border-radius-${size}`),
|
|
89
|
+
"--border-width-regular",
|
|
90
|
+
...["hairline", "sm", "md", "lg"].map((size) => `--shadow-${size}`),
|
|
91
|
+
];
|
|
92
|
+
/**
|
|
93
|
+
* The host context's own-prefixed field for the theme variables MCP Apps doesn't define (an
|
|
94
|
+
* accent, a chart palette): `{ variables }`. The shell applies them with the standard ones; a view
|
|
95
|
+
* that doesn't know the field ignores it (the host context is open to extra fields).
|
|
96
|
+
*/
|
|
97
|
+
export const PORTHOLE_STYLES = "porthole/styles";
|
|
98
|
+
/** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
|
|
99
|
+
export const SHELL_LOGGER = "porthole";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CapabilityName } from "../config/capabilities.ts";
|
|
2
|
+
import type { ProtocolAdapter, ShellConnection } from "./adapter.ts";
|
|
3
|
+
/** What the host offers, from the declared capabilities (MCP Apps `hostCapabilities`). */
|
|
4
|
+
export declare function hostCapabilities(declared: (name: CapabilityName) => boolean): Record<string, object>;
|
|
5
|
+
export declare const shellAdapter: ProtocolAdapter<ShellConnection>;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { JSON_RPC_ERRORS, parseJsonRpc, } from "./jsonrpc.js";
|
|
2
|
+
import { MCP_APPS_METHODS as M, MCP_APPS_PROTOCOL_VERSION } from "./mcp-apps.js";
|
|
3
|
+
/**
|
|
4
|
+
* The core's adapter: the host side of the MCP Apps subset the shell speaks, which content using
|
|
5
|
+
* the official App SDK speaks too. Every message is checked before use; every power goes through
|
|
6
|
+
* the bridge. Content goes as `tool-input-partial` while it is written and `tool-input` once,
|
|
7
|
+
* after the view has initialized.
|
|
8
|
+
*/
|
|
9
|
+
const LOG_ERRORS = new Set(["error", "critical", "alert", "emergency"]);
|
|
10
|
+
/** Requests that are capability calls, and how their params become the capability's args. */
|
|
11
|
+
const REQUESTS = {
|
|
12
|
+
[M.message]: {
|
|
13
|
+
capability: "sendPrompt",
|
|
14
|
+
args: (params) => ({
|
|
15
|
+
text: Array.isArray(params.content)
|
|
16
|
+
? params.content
|
|
17
|
+
.map((part) => {
|
|
18
|
+
const block = part;
|
|
19
|
+
return block !== null &&
|
|
20
|
+
typeof block === "object" &&
|
|
21
|
+
block.type === "text" &&
|
|
22
|
+
typeof block.text === "string"
|
|
23
|
+
? block.text
|
|
24
|
+
: "";
|
|
25
|
+
})
|
|
26
|
+
.join("")
|
|
27
|
+
: undefined,
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
[M.openLink]: { capability: "openLink", args: (params) => ({ url: params.url }) },
|
|
31
|
+
[M.downloadFile]: {
|
|
32
|
+
capability: "downloadFile",
|
|
33
|
+
args: (params) => ({ contents: params.contents }),
|
|
34
|
+
},
|
|
35
|
+
[M.callTool]: {
|
|
36
|
+
capability: "callTool",
|
|
37
|
+
args: (params) => ({ name: params.name, arguments: params.arguments }),
|
|
38
|
+
},
|
|
39
|
+
[M.readResource]: { capability: "readData", args: (params) => ({ uri: params.uri }) },
|
|
40
|
+
[M.updateModelContext]: {
|
|
41
|
+
capability: "setModelContext",
|
|
42
|
+
args: (params) => ({ content: params.content, structuredContent: params.structuredContent }),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
/** What a capability's outcome looks like to the view. */
|
|
46
|
+
function answer(capability, outcome) {
|
|
47
|
+
const flagged = capability === "sendPrompt" || capability === "openLink" || capability === "downloadFile";
|
|
48
|
+
if (!outcome.ok) {
|
|
49
|
+
return flagged
|
|
50
|
+
? { result: { isError: true } }
|
|
51
|
+
: { error: { code: -32000, message: `Refused (${outcome.refusal})` } };
|
|
52
|
+
}
|
|
53
|
+
if (flagged) {
|
|
54
|
+
return { result: outcome.value === true ? {} : { isError: true } };
|
|
55
|
+
}
|
|
56
|
+
return { result: outcome.value ?? {} };
|
|
57
|
+
}
|
|
58
|
+
/** What the host offers, from the declared capabilities (MCP Apps `hostCapabilities`). */
|
|
59
|
+
export function hostCapabilities(declared) {
|
|
60
|
+
const offered = {};
|
|
61
|
+
if (declared("openLink")) {
|
|
62
|
+
offered.openLinks = {};
|
|
63
|
+
}
|
|
64
|
+
if (declared("sendPrompt")) {
|
|
65
|
+
offered.message = { text: {} };
|
|
66
|
+
}
|
|
67
|
+
if (declared("downloadFile")) {
|
|
68
|
+
offered.downloadFile = {};
|
|
69
|
+
}
|
|
70
|
+
if (declared("callTool")) {
|
|
71
|
+
offered.serverTools = {};
|
|
72
|
+
}
|
|
73
|
+
if (declared("readData")) {
|
|
74
|
+
offered.serverResources = {};
|
|
75
|
+
}
|
|
76
|
+
if (declared("setModelContext")) {
|
|
77
|
+
offered.updateModelContext = { text: {} };
|
|
78
|
+
}
|
|
79
|
+
if (declared("reportError")) {
|
|
80
|
+
offered.logging = {};
|
|
81
|
+
}
|
|
82
|
+
return offered;
|
|
83
|
+
}
|
|
84
|
+
export const shellAdapter = {
|
|
85
|
+
name: "porthole-shell",
|
|
86
|
+
connect(session) {
|
|
87
|
+
const { transport, bridge } = session;
|
|
88
|
+
let initialized = false;
|
|
89
|
+
let closed = false;
|
|
90
|
+
let drafted = null;
|
|
91
|
+
let content = null;
|
|
92
|
+
let sentWhole = false;
|
|
93
|
+
let nextId = 1;
|
|
94
|
+
const pending = new Map();
|
|
95
|
+
const respond = (id, body) => transport.send({ jsonrpc: "2.0", id, ...body });
|
|
96
|
+
const notify = (method, params) => transport.send({ jsonrpc: "2.0", method, params });
|
|
97
|
+
const flush = () => {
|
|
98
|
+
if (!initialized || closed || content === null || sentWhole) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (content.partial) {
|
|
102
|
+
if (drafted !== content.html) {
|
|
103
|
+
drafted = content.html;
|
|
104
|
+
notify(M.toolInputPartial, { arguments: { html: content.html } });
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
sentWhole = true;
|
|
109
|
+
notify(M.toolInput, { arguments: { html: content.html } });
|
|
110
|
+
};
|
|
111
|
+
const onRequest = async (request) => {
|
|
112
|
+
const params = request.params ?? {};
|
|
113
|
+
if (request.method === M.initialize) {
|
|
114
|
+
respond(request.id, {
|
|
115
|
+
result: {
|
|
116
|
+
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
|
|
117
|
+
hostInfo: session.hostInfo,
|
|
118
|
+
hostCapabilities: hostCapabilities((name) => bridge.declared(name)),
|
|
119
|
+
hostContext: session.hostContext(),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (request.method === "ping") {
|
|
125
|
+
respond(request.id, { result: {} });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (request.method === M.requestDisplayMode) {
|
|
129
|
+
respond(request.id, { result: { mode: "inline" } });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const mapped = REQUESTS[request.method];
|
|
133
|
+
if (mapped === undefined) {
|
|
134
|
+
respond(request.id, {
|
|
135
|
+
error: {
|
|
136
|
+
code: JSON_RPC_ERRORS.methodNotFound,
|
|
137
|
+
message: `${request.method} isn't offered here`,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const outcome = await bridge.call(mapped.capability, mapped.args(params));
|
|
143
|
+
if (!closed) {
|
|
144
|
+
respond(request.id, answer(mapped.capability, outcome));
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const onNotification = (method, params) => {
|
|
148
|
+
switch (method) {
|
|
149
|
+
case M.initialized:
|
|
150
|
+
if (!initialized) {
|
|
151
|
+
initialized = true;
|
|
152
|
+
session.emit({ type: "initialized" });
|
|
153
|
+
flush();
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
case M.sizeChanged:
|
|
157
|
+
void bridge.call("resize", { height: params.height, width: params.width });
|
|
158
|
+
return;
|
|
159
|
+
case M.log:
|
|
160
|
+
if (LOG_ERRORS.has(String(params.level))) {
|
|
161
|
+
const data = params.data;
|
|
162
|
+
void bridge.call("reportError", {
|
|
163
|
+
message: typeof data === "string" ? data : JSON.stringify(data ?? null).slice(0, 2_000),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
case M.requestTeardown:
|
|
168
|
+
session.emit({ type: "teardown-requested" });
|
|
169
|
+
return;
|
|
170
|
+
default:
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const stop = transport.listen((raw) => {
|
|
175
|
+
const message = parseJsonRpc(raw);
|
|
176
|
+
if (message === null || closed) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if ("method" in message) {
|
|
180
|
+
if ("id" in message) {
|
|
181
|
+
void onRequest(message);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
onNotification(message.method, message.params ?? {});
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
pending.get(message.id)?.(message);
|
|
189
|
+
pending.delete(message.id);
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
setContent(html, options) {
|
|
193
|
+
if (sentWhole) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
content = { html, partial: options?.partial === true };
|
|
197
|
+
flush();
|
|
198
|
+
},
|
|
199
|
+
hostContextChanged(context) {
|
|
200
|
+
if (initialized && !closed) {
|
|
201
|
+
notify(M.hostContextChanged, { ...context });
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
teardown() {
|
|
205
|
+
if (!initialized || closed) {
|
|
206
|
+
return Promise.resolve();
|
|
207
|
+
}
|
|
208
|
+
const id = `teardown-${nextId++}`;
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
const timer = setTimeout(() => {
|
|
211
|
+
pending.delete(id);
|
|
212
|
+
resolve();
|
|
213
|
+
}, 500);
|
|
214
|
+
pending.set(id, () => {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
resolve();
|
|
217
|
+
});
|
|
218
|
+
transport.send({ jsonrpc: "2.0", id, method: M.resourceTeardown, params: {} });
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
close() {
|
|
222
|
+
closed = true;
|
|
223
|
+
stop();
|
|
224
|
+
pending.clear();
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { ConfirmRequest } from "../bridge/types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The default confirmation dialog (P019): drawn by the host's page, outside every frame, as a
|
|
5
|
+
* modal (the top layer covers the frames, so content can't draw over it or click it). It shows
|
|
6
|
+
* the call's whole effect (the prompt's full text, the link's full URL), the display's title as
|
|
7
|
+
* the host named it, Cancel focused, and ignores presses on its accept button for a moment after
|
|
8
|
+
* it opens (a click meant for the content mustn't land on it). Escape cancels. A host replaces it
|
|
9
|
+
* by passing its own `confirm`; it styles it through the `porthole-confirm` class and data
|
|
10
|
+
* attributes.
|
|
11
|
+
*/
|
|
12
|
+
/** How long the accept button ignores presses after the dialog opens. */
|
|
13
|
+
export declare const ARM_DELAY_MS = 500;
|
|
14
|
+
export interface ConfirmDialogProps {
|
|
15
|
+
/** The call to confirm, or null when nothing is asked. */
|
|
16
|
+
readonly request: ConfirmRequest | null;
|
|
17
|
+
readonly onAnswer: (accepted: boolean) => void;
|
|
18
|
+
readonly className?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function ConfirmDialog({ request, onAnswer, className }: ConfirmDialogProps): ReactElement;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* The default confirmation dialog (P019): drawn by the host's page, outside every frame, as a
|
|
5
|
+
* modal (the top layer covers the frames, so content can't draw over it or click it). It shows
|
|
6
|
+
* the call's whole effect (the prompt's full text, the link's full URL), the display's title as
|
|
7
|
+
* the host named it, Cancel focused, and ignores presses on its accept button for a moment after
|
|
8
|
+
* it opens (a click meant for the content mustn't land on it). Escape cancels. A host replaces it
|
|
9
|
+
* by passing its own `confirm`; it styles it through the `porthole-confirm` class and data
|
|
10
|
+
* attributes.
|
|
11
|
+
*/
|
|
12
|
+
/** How long the accept button ignores presses after the dialog opens. */
|
|
13
|
+
export const ARM_DELAY_MS = 500;
|
|
14
|
+
const json = (value) => JSON.stringify(value, null, 2) ?? "";
|
|
15
|
+
function words(request) {
|
|
16
|
+
switch (request.capability) {
|
|
17
|
+
case "sendPrompt":
|
|
18
|
+
return { heading: "Send this message?", accept: "Send", detail: request.args.text };
|
|
19
|
+
case "openLink": {
|
|
20
|
+
let host = "";
|
|
21
|
+
try {
|
|
22
|
+
host = new URL(request.args.url).host;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
host = "";
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
heading: "Open this link?",
|
|
29
|
+
accept: "Open link",
|
|
30
|
+
detail: request.args.url,
|
|
31
|
+
...(host === "" ? {} : { note: `It opens ${host}.` }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
case "downloadFile":
|
|
35
|
+
return {
|
|
36
|
+
heading: "Download this file?",
|
|
37
|
+
accept: "Download",
|
|
38
|
+
detail: json(request.args.contents),
|
|
39
|
+
};
|
|
40
|
+
case "callTool":
|
|
41
|
+
return {
|
|
42
|
+
heading: "Run this tool?",
|
|
43
|
+
accept: "Run",
|
|
44
|
+
detail: `${request.args.name}\n${json(request.args.arguments)}`,
|
|
45
|
+
};
|
|
46
|
+
case "setModelContext":
|
|
47
|
+
return { heading: "Share this with the agent?", accept: "Share", detail: json(request.args) };
|
|
48
|
+
case "readData":
|
|
49
|
+
return { heading: "Let it read this?", accept: "Allow", detail: request.args.uri };
|
|
50
|
+
default:
|
|
51
|
+
return { heading: "Allow this?", accept: "Allow", detail: json(request.args) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const STYLE = `.porthole-confirm{color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:20px;width:min(440px,calc(100vw - 32px));font:inherit;box-shadow:0 12px 40px rgb(0 0 0 / .28)}
|
|
55
|
+
.porthole-confirm::backdrop{background:rgb(0 0 0 / .4)}
|
|
56
|
+
.porthole-confirm h2{font-size:1.05em;margin:0 0 4px}
|
|
57
|
+
.porthole-confirm [data-porthole-source],.porthole-confirm [data-porthole-note]{opacity:.72;font-size:.9em;margin:0 0 12px}
|
|
58
|
+
.porthole-confirm [data-porthole-detail]{white-space:pre-wrap;overflow-wrap:anywhere;max-height:40vh;overflow:auto;padding:10px 12px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);margin:0 0 12px;font:inherit}
|
|
59
|
+
.porthole-confirm menu{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0;padding:0}
|
|
60
|
+
.porthole-confirm button{font:inherit;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}
|
|
61
|
+
.porthole-confirm button[data-porthole-accept]{background:Highlight;background:AccentColor;color:HighlightText;color:AccentColorText;border-color:transparent}`;
|
|
62
|
+
export function ConfirmDialog({ request, onAnswer, className }) {
|
|
63
|
+
const dialog = useRef(null);
|
|
64
|
+
const cancel = useRef(null);
|
|
65
|
+
// The request the accept button is armed for: a new one starts unarmed.
|
|
66
|
+
const [armedFor, setArmedFor] = useState(null);
|
|
67
|
+
const armed = request !== null && armedFor === request;
|
|
68
|
+
const heading = useId();
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const element = dialog.current;
|
|
71
|
+
if (element === null) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (request === null) {
|
|
75
|
+
if (element.open) {
|
|
76
|
+
element.close();
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!element.open) {
|
|
81
|
+
element.showModal();
|
|
82
|
+
}
|
|
83
|
+
cancel.current?.focus();
|
|
84
|
+
const timer = setTimeout(() => setArmedFor(request), ARM_DELAY_MS);
|
|
85
|
+
return () => clearTimeout(timer);
|
|
86
|
+
}, [request]);
|
|
87
|
+
const said = request === null ? null : words(request);
|
|
88
|
+
return (_jsxs("dialog", { ref: dialog, className: className === undefined ? "porthole-confirm" : `porthole-confirm ${className}`, "aria-labelledby": heading, "data-porthole-confirm": request?.capability ?? "", onCancel: (event) => {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
if (request !== null) {
|
|
91
|
+
onAnswer(false);
|
|
92
|
+
}
|
|
93
|
+
}, children: [_jsx("style", { children: STYLE }), said === null || request === null ? null : (_jsxs(_Fragment, { children: [_jsx("h2", { id: heading, children: said.heading }), _jsx("p", { "data-porthole-source": "", children: `From “${request.display.title}”` }), _jsx("div", { "data-porthole-detail": "", children: said.detail }), said.note === undefined ? null : _jsx("p", { "data-porthole-note": "", children: said.note }), _jsxs("menu", { children: [_jsx("button", { type: "button", ref: cancel, autoFocus: true, "data-porthole-cancel": "", onClick: () => onAnswer(false), children: "Cancel" }), _jsx("button", { type: "button", "data-porthole-accept": "", "data-armed": String(armed), onClick: () => {
|
|
94
|
+
if (armed) {
|
|
95
|
+
onAnswer(true);
|
|
96
|
+
}
|
|
97
|
+
}, children: said.accept })] })] }))] }));
|
|
98
|
+
}
|