@botiverse/porthole 0.0.0-stage → 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 +68 -3
- 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
- package/README.md +0 -3
package/src/mcp/view.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { EgressConfig } from "../config/schema.ts";
|
|
2
|
+
import { isOriginSource } from "../config/validate.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a host reads from an MCP Apps view's resource before showing it: the HTML, and the egress
|
|
6
|
+
* the view declares, in Porthole's vocabulary so it goes into a config (P003).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The style variables MCP Apps standardizes: one list, the core's (P028). */
|
|
10
|
+
export { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
|
|
11
|
+
|
|
12
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
13
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
|
|
15
|
+
/** A declared domain as a CSP source Porthole accepts, or null (`'self'`, `*`, paths, quotes). */
|
|
16
|
+
function originOf(value: unknown): string | null {
|
|
17
|
+
if (typeof value !== "string") {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const source = value.trim().toLowerCase().replace(/\/$/u, "");
|
|
21
|
+
return isOriginSource(source) ? source : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const origins = (value: unknown): string[] =>
|
|
25
|
+
Array.isArray(value)
|
|
26
|
+
? [...new Set(value.map(originOf).filter((each): each is string => each !== null))]
|
|
27
|
+
: [];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A view's `_meta.ui.csp` as egress: `connectDomains` to connect, `resourceDomains` to scripts,
|
|
31
|
+
* styles, images, fonts and media. What isn't an exact origin (or `https://*.` one) is dropped,
|
|
32
|
+
* never widened. `frameDomains` and `baseUriDomains` open nothing: the shell removes nested frames
|
|
33
|
+
* (P006) and `base-uri` stays `'none'`, so a view that needs either runs without it.
|
|
34
|
+
*/
|
|
35
|
+
export function viewEgress(csp: unknown): EgressConfig {
|
|
36
|
+
if (!isRecord(csp)) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
const connect = origins(csp.connectDomains);
|
|
40
|
+
const resource = origins(csp.resourceDomains);
|
|
41
|
+
return {
|
|
42
|
+
...(connect.length > 0 ? { connect } : {}),
|
|
43
|
+
...(resource.length > 0
|
|
44
|
+
? { script: resource, style: resource, img: resource, font: resource, media: resource }
|
|
45
|
+
: {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A view, as `resources/read` gives it. */
|
|
50
|
+
export interface ViewResource {
|
|
51
|
+
readonly uri: string;
|
|
52
|
+
/** The view's whole document. */
|
|
53
|
+
readonly html: string;
|
|
54
|
+
/** What the view declares it reaches (its `_meta.ui.csp`), as egress. */
|
|
55
|
+
readonly egress: EgressConfig;
|
|
56
|
+
/** Whether the view asks the host for a border and background (`_meta.ui.prefersBorder`). */
|
|
57
|
+
readonly prefersBorder?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The MIME type of an MCP Apps view. */
|
|
61
|
+
export const VIEW_MIME_TYPE = "text/html;profile=mcp-app";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The view in a `resources/read` result: its first content of the MCP Apps MIME type, as text or
|
|
65
|
+
* base64. Null when there is none.
|
|
66
|
+
*/
|
|
67
|
+
export function readViewResource(result: unknown): ViewResource | null {
|
|
68
|
+
const contents = isRecord(result) && Array.isArray(result.contents) ? result.contents : [];
|
|
69
|
+
for (const content of contents) {
|
|
70
|
+
if (!isRecord(content) || typeof content.mimeType !== "string") {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (content.mimeType.replace(/\s/gu, "").toLowerCase() !== VIEW_MIME_TYPE) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const html =
|
|
77
|
+
typeof content.text === "string"
|
|
78
|
+
? content.text
|
|
79
|
+
: typeof content.blob === "string"
|
|
80
|
+
? new TextDecoder().decode(
|
|
81
|
+
Uint8Array.from(atob(content.blob), (char) => char.charCodeAt(0)),
|
|
82
|
+
)
|
|
83
|
+
: null;
|
|
84
|
+
if (html === null) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const meta = content["_meta"];
|
|
88
|
+
const ui = isRecord(meta) && isRecord(meta.ui) ? meta.ui : {};
|
|
89
|
+
return {
|
|
90
|
+
uri: typeof content.uri === "string" ? content.uri : "",
|
|
91
|
+
html,
|
|
92
|
+
egress: viewEgress(ui.csp),
|
|
93
|
+
...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ColorScheme } from "../config/schema.ts";
|
|
2
|
+
import type { FrameSource } from "../isolation/types.ts";
|
|
3
|
+
import type { Transport } from "../transport/types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The platform-host seam (ARCHITECTURE § Platform hosts): what owns the isolation primitive and
|
|
7
|
+
* the transport. The web's is an `<iframe>` with `postMessage`; a native web view is another
|
|
8
|
+
* implementation of the same interface, not a fork.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** What to create: the frame's document and its policy attributes, all from the compiled config. */
|
|
12
|
+
export interface SurfaceSpec {
|
|
13
|
+
readonly source: FrameSource;
|
|
14
|
+
/** The frame's accessible name. */
|
|
15
|
+
readonly title: string;
|
|
16
|
+
readonly sandbox: string;
|
|
17
|
+
readonly allow: string;
|
|
18
|
+
readonly referrerPolicy: "no-referrer";
|
|
19
|
+
readonly colorScheme: ColorScheme;
|
|
20
|
+
/** The height to start at (a remembered height, so a frame mounting again doesn't jump). */
|
|
21
|
+
readonly initialHeightPx: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One isolated frame. */
|
|
25
|
+
export interface Surface {
|
|
26
|
+
readonly transport: Transport;
|
|
27
|
+
/** The platform's own element for the frame (an `HTMLIFrameElement` on the web). */
|
|
28
|
+
readonly element: unknown;
|
|
29
|
+
setHeight(px: number): void;
|
|
30
|
+
setColorScheme(scheme: ColorScheme): void;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the embedding page has transient user activation now (a gesture in the frame
|
|
33
|
+
* activates its ancestors), or undefined where the platform can't tell.
|
|
34
|
+
*/
|
|
35
|
+
userActivation(): boolean | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* The frame loaded a document other than the one it was given: the content navigated it. The
|
|
38
|
+
* framework starts it again from its content and reports it.
|
|
39
|
+
*/
|
|
40
|
+
onNavigated(listener: () => void): () => void;
|
|
41
|
+
destroy(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PlatformHost<Container = HTMLElement> {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
/** Creates an isolated frame in `container`, its policy set before its document loads. */
|
|
47
|
+
createSurface(container: Container, spec: SurfaceSpec): Surface;
|
|
48
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ColorScheme } from "../config/schema.ts";
|
|
2
|
+
import { postMessageTransport } from "../transport/post-message.ts";
|
|
3
|
+
import type { PlatformHost, Surface, SurfaceSpec } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The web platform: an `<iframe>` whose policy attributes are set before its document is given,
|
|
7
|
+
* talking over `postMessage`. Its first load is the document it was given; any later load is the
|
|
8
|
+
* content navigating its frame away (a navigation the embedding page's `frame-src` let through, or
|
|
9
|
+
* one to a document of the frame's own), which the framework answers by starting it again.
|
|
10
|
+
*/
|
|
11
|
+
export const webPlatform: PlatformHost = {
|
|
12
|
+
name: "web",
|
|
13
|
+
createSurface(container: HTMLElement, spec: SurfaceSpec): Surface {
|
|
14
|
+
// oxlint-disable-next-line react/iframe-missing-sandbox -- set from the compiled config, next
|
|
15
|
+
const frame = container.ownerDocument.createElement("iframe");
|
|
16
|
+
// Policy first: the sandbox and Permissions Policy must be in place before the document is.
|
|
17
|
+
frame.setAttribute("sandbox", spec.sandbox);
|
|
18
|
+
frame.setAttribute("allow", spec.allow);
|
|
19
|
+
frame.setAttribute("referrerpolicy", spec.referrerPolicy);
|
|
20
|
+
frame.setAttribute("loading", "eager");
|
|
21
|
+
frame.title = spec.title;
|
|
22
|
+
frame.dataset.portholeFrame = "";
|
|
23
|
+
Object.assign(frame.style, {
|
|
24
|
+
display: "block",
|
|
25
|
+
width: "100%",
|
|
26
|
+
border: "0",
|
|
27
|
+
background: "transparent",
|
|
28
|
+
colorScheme: spec.colorScheme,
|
|
29
|
+
height: `${Math.max(0, Math.round(spec.initialHeightPx))}px`,
|
|
30
|
+
});
|
|
31
|
+
const origin = spec.source.kind === "srcdoc" ? "null" : spec.source.origin;
|
|
32
|
+
if (spec.source.kind === "srcdoc") {
|
|
33
|
+
frame.srcdoc = spec.source.html;
|
|
34
|
+
} else {
|
|
35
|
+
frame.src = spec.source.url;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const navigated = new Set<() => void>();
|
|
39
|
+
let loads = 0;
|
|
40
|
+
frame.addEventListener("load", () => {
|
|
41
|
+
loads += 1;
|
|
42
|
+
if (loads > 1) {
|
|
43
|
+
for (const listener of navigated) {
|
|
44
|
+
listener();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
container.append(frame);
|
|
49
|
+
|
|
50
|
+
const view = container.ownerDocument.defaultView ?? window;
|
|
51
|
+
const transport = postMessageTransport(() => frame.contentWindow, origin, view);
|
|
52
|
+
return {
|
|
53
|
+
transport,
|
|
54
|
+
element: frame,
|
|
55
|
+
setHeight(px: number) {
|
|
56
|
+
frame.style.height = `${Math.max(0, Math.round(px))}px`;
|
|
57
|
+
},
|
|
58
|
+
setColorScheme(scheme: ColorScheme) {
|
|
59
|
+
frame.style.colorScheme = scheme;
|
|
60
|
+
},
|
|
61
|
+
userActivation() {
|
|
62
|
+
const activation = (
|
|
63
|
+
view.navigator as Navigator & { userActivation?: { isActive: boolean } }
|
|
64
|
+
).userActivation;
|
|
65
|
+
return activation === undefined ? undefined : activation.isActive;
|
|
66
|
+
},
|
|
67
|
+
onNavigated(listener) {
|
|
68
|
+
navigated.add(listener);
|
|
69
|
+
return () => navigated.delete(listener);
|
|
70
|
+
},
|
|
71
|
+
destroy() {
|
|
72
|
+
navigated.clear();
|
|
73
|
+
transport.close();
|
|
74
|
+
frame.remove();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { CapabilityName } from "../config/capabilities.ts";
|
|
2
|
+
import type {
|
|
3
|
+
CapabilityConfig,
|
|
4
|
+
CapabilityMap,
|
|
5
|
+
EgressConfig,
|
|
6
|
+
EmbedderConfig,
|
|
7
|
+
GuideConfig,
|
|
8
|
+
IsolationKind,
|
|
9
|
+
LayoutConfig,
|
|
10
|
+
Limits,
|
|
11
|
+
PortholeConfig,
|
|
12
|
+
ThemeValue,
|
|
13
|
+
} from "../config/schema.ts";
|
|
14
|
+
import { validateConfig } from "../config/validate.ts";
|
|
15
|
+
import { DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES } from "../theme/variables.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The preset policy layer (P001): named configs with safe defaults. Every preset starts with
|
|
19
|
+
* egress closed and only the capabilities that carry nothing out of the frame (its size, its
|
|
20
|
+
* errors, both to the host only); a host declares the rest, and each gets its default gates
|
|
21
|
+
* unless the host names its own. A preset returns a validated config.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What a host may change about a preset. Anything else is the preset's. */
|
|
25
|
+
export interface PresetOptions {
|
|
26
|
+
/** Capabilities to declare (merged over the preset's), or `false` to drop one the preset has. */
|
|
27
|
+
readonly capabilities?: { readonly [N in CapabilityName]?: CapabilityConfig | false };
|
|
28
|
+
/** Origins to open, added to the preset's (closed) egress. */
|
|
29
|
+
readonly egress?: EgressConfig;
|
|
30
|
+
/** Theme defaults to change or variables to add, and the theme event's name. */
|
|
31
|
+
readonly theme?: {
|
|
32
|
+
readonly variables?: Readonly<Record<string, ThemeValue>>;
|
|
33
|
+
readonly event?: string;
|
|
34
|
+
};
|
|
35
|
+
readonly limits?: Partial<Limits>;
|
|
36
|
+
readonly layout?: Partial<LayoutConfig>;
|
|
37
|
+
readonly guide?: GuideConfig;
|
|
38
|
+
/** `opaque` (default) or `domain` for a served document (needs `contentOrigins`). */
|
|
39
|
+
readonly isolation?: Exclude<IsolationKind, "external">;
|
|
40
|
+
readonly contentOrigins?: readonly string[];
|
|
41
|
+
readonly embedder?: EmbedderConfig;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The url preset's options: where the app is served, and the embedding page's extra frames. */
|
|
45
|
+
export interface UrlPresetOptions {
|
|
46
|
+
readonly contentOrigins: readonly string[];
|
|
47
|
+
readonly limits?: Partial<Limits>;
|
|
48
|
+
readonly layout?: Partial<LayoutConfig>;
|
|
49
|
+
readonly embedder?: EmbedderConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Library CDNs a host may open with one line (`egress: LIBRARY_CDNS`): read-only package caches
|
|
54
|
+
* for scripts, styles, fonts, images and data, plus Google Fonts. Not on by default: every preset
|
|
55
|
+
* starts with egress closed.
|
|
56
|
+
*/
|
|
57
|
+
export const LIBRARY_CDNS: EgressConfig = {
|
|
58
|
+
script: [
|
|
59
|
+
"https://cdnjs.cloudflare.com",
|
|
60
|
+
"https://cdn.jsdelivr.net",
|
|
61
|
+
"https://unpkg.com",
|
|
62
|
+
"https://esm.sh",
|
|
63
|
+
],
|
|
64
|
+
style: [
|
|
65
|
+
"https://cdnjs.cloudflare.com",
|
|
66
|
+
"https://cdn.jsdelivr.net",
|
|
67
|
+
"https://unpkg.com",
|
|
68
|
+
"https://esm.sh",
|
|
69
|
+
"https://fonts.googleapis.com",
|
|
70
|
+
],
|
|
71
|
+
font: [
|
|
72
|
+
"https://fonts.gstatic.com",
|
|
73
|
+
"https://cdnjs.cloudflare.com",
|
|
74
|
+
"https://cdn.jsdelivr.net",
|
|
75
|
+
"https://unpkg.com",
|
|
76
|
+
"https://esm.sh",
|
|
77
|
+
],
|
|
78
|
+
img: [
|
|
79
|
+
"https://cdnjs.cloudflare.com",
|
|
80
|
+
"https://cdn.jsdelivr.net",
|
|
81
|
+
"https://unpkg.com",
|
|
82
|
+
"https://esm.sh",
|
|
83
|
+
],
|
|
84
|
+
connect: [
|
|
85
|
+
"https://cdnjs.cloudflare.com",
|
|
86
|
+
"https://cdn.jsdelivr.net",
|
|
87
|
+
"https://unpkg.com",
|
|
88
|
+
"https://esm.sh",
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const BASE_LIMITS: Limits = {
|
|
93
|
+
maxHeightPx: 2_000,
|
|
94
|
+
minHeightPx: 24,
|
|
95
|
+
maxBytes: 200_000,
|
|
96
|
+
maxLiveFrames: 8,
|
|
97
|
+
declinesBeforeMute: 3,
|
|
98
|
+
maxPromptChars: 8_000,
|
|
99
|
+
maxErrorReports: 5,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const BASE_LAYOUT: LayoutConfig = { minWidthPx: 280, narrowWidthPx: 300, wideWidthPx: 750 };
|
|
103
|
+
|
|
104
|
+
/** Only what reports to the host and carries nothing out: the frame's size and its errors. */
|
|
105
|
+
const BASE_CAPABILITIES: CapabilityMap = { resize: {}, reportError: {} };
|
|
106
|
+
|
|
107
|
+
const mergeEgress = (base: EgressConfig, extra: EgressConfig | undefined): EgressConfig => {
|
|
108
|
+
const kinds = ["connect", "script", "style", "img", "font", "media"] as const;
|
|
109
|
+
const merged: { -readonly [K in keyof EgressConfig]: EgressConfig[K] } = {};
|
|
110
|
+
for (const kind of kinds) {
|
|
111
|
+
const origins = [...new Set([...(base[kind] ?? []), ...(extra?.[kind] ?? [])])];
|
|
112
|
+
if (origins.length > 0) {
|
|
113
|
+
merged[kind] = origins;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return merged;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function build(
|
|
120
|
+
mode: PortholeConfig["mode"],
|
|
121
|
+
defaults: { readonly limits?: Partial<Limits> },
|
|
122
|
+
options: PresetOptions,
|
|
123
|
+
): PortholeConfig {
|
|
124
|
+
const capabilities: { -readonly [N in CapabilityName]?: CapabilityConfig } = {
|
|
125
|
+
...BASE_CAPABILITIES,
|
|
126
|
+
};
|
|
127
|
+
for (const [name, value] of Object.entries(options.capabilities ?? {}) as [
|
|
128
|
+
CapabilityName,
|
|
129
|
+
CapabilityConfig | false,
|
|
130
|
+
][]) {
|
|
131
|
+
if (value === false) {
|
|
132
|
+
delete capabilities[name];
|
|
133
|
+
} else {
|
|
134
|
+
capabilities[name] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return validateConfig({
|
|
138
|
+
mode,
|
|
139
|
+
isolation: options.isolation ?? "opaque",
|
|
140
|
+
capabilities,
|
|
141
|
+
egress: mergeEgress({}, options.egress),
|
|
142
|
+
theme: {
|
|
143
|
+
variables: { ...DEFAULT_THEME_VARIABLES, ...options.theme?.variables },
|
|
144
|
+
event: options.theme?.event ?? DEFAULT_THEME_EVENT,
|
|
145
|
+
},
|
|
146
|
+
limits: { ...BASE_LIMITS, ...defaults.limits, ...options.limits },
|
|
147
|
+
layout: { ...BASE_LAYOUT, ...options.layout },
|
|
148
|
+
guide: options.guide ?? {},
|
|
149
|
+
contentOrigins: options.contentOrigins ?? [],
|
|
150
|
+
embedder: options.embedder ?? {},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const presets = {
|
|
155
|
+
/**
|
|
156
|
+
* An HTML fragment or SVG an agent wrote, drawn inline (a card in a conversation): an opaque
|
|
157
|
+
* frame with the shell, streamed as it is written.
|
|
158
|
+
*/
|
|
159
|
+
inlineWidget: (options: PresetOptions = {}): PortholeConfig => build("inline", {}, options),
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A whole HTML page: written into an opaque frame by the shell where the host has the page, or
|
|
163
|
+
* served on its own origin (`isolation: "domain"`). A document scrolls inside past a shorter cap.
|
|
164
|
+
*/
|
|
165
|
+
artifact: (options: PresetOptions = {}): PortholeConfig =>
|
|
166
|
+
build("artifact", { limits: { maxHeightPx: 720, minHeightPx: 120 } }, options),
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A standard MCP Apps view, through an MCP Apps adapter. The view's own `_meta.ui.csp` is
|
|
170
|
+
* mapped into `egress` by that adapter; the host declares the capabilities it serves.
|
|
171
|
+
*/
|
|
172
|
+
mcpApp: (options: PresetOptions = {}): PortholeConfig => build("mcp-app", {}, options),
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A running app by URL (a dev server's preview): frame, isolation and access auth only. No
|
|
176
|
+
* shell, no capabilities, no egress control: the app is someone else's, on its own origin.
|
|
177
|
+
*/
|
|
178
|
+
url: (options: UrlPresetOptions): PortholeConfig =>
|
|
179
|
+
validateConfig({
|
|
180
|
+
mode: "url",
|
|
181
|
+
isolation: "external",
|
|
182
|
+
capabilities: {},
|
|
183
|
+
egress: {},
|
|
184
|
+
theme: { variables: {}, event: DEFAULT_THEME_EVENT },
|
|
185
|
+
limits: { ...BASE_LIMITS, maxHeightPx: 4_000, minHeightPx: 120, ...options.limits },
|
|
186
|
+
layout: { ...BASE_LAYOUT, ...options.layout },
|
|
187
|
+
guide: {},
|
|
188
|
+
contentOrigins: options.contentOrigins,
|
|
189
|
+
embedder: options.embedder ?? {},
|
|
190
|
+
}),
|
|
191
|
+
} as const;
|
|
192
|
+
|
|
193
|
+
export type PresetName = keyof typeof presets;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { HostFunctionBridge } from "../bridge/types.ts";
|
|
2
|
+
import type { CompiledConfig } from "../compile/compile.ts";
|
|
3
|
+
import type { Transport } from "../transport/types.ts";
|
|
4
|
+
import type { HostContext } from "./mcp-apps.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The protocol-adapter seam (P004): the only layer that knows a wire protocol's method names and
|
|
8
|
+
* shapes. The framework speaks capabilities and lifecycle; an adapter translates.
|
|
9
|
+
*
|
|
10
|
+
* - Frame → host: the adapter validates each message (dropping what it doesn't recognize, and
|
|
11
|
+
* answering an unknown request with "method not found"), turns every capability use into
|
|
12
|
+
* `bridge.call(...)` (including sizes and errors, which the bridge turns into the framework's
|
|
13
|
+
* own events), answers the handshake with `hostContext()`, and reports lifecycle with `emit`.
|
|
14
|
+
* - Host → frame: the framework calls the connection; the adapter encodes and sends.
|
|
15
|
+
*
|
|
16
|
+
* The bridge alone decides whether a call runs (declared, valid, gated); an adapter never calls a
|
|
17
|
+
* host handler itself.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** What the framework gives an adapter for one display. */
|
|
21
|
+
export interface AdapterSession {
|
|
22
|
+
readonly display: { readonly id: string; readonly title: string };
|
|
23
|
+
readonly compiled: CompiledConfig;
|
|
24
|
+
readonly transport: Transport;
|
|
25
|
+
readonly bridge: HostFunctionBridge;
|
|
26
|
+
readonly hostInfo: { readonly name: string; readonly version: string };
|
|
27
|
+
/** The host context as of now: theme, its variables, display mode, sizes, locale. */
|
|
28
|
+
hostContext(): HostContext;
|
|
29
|
+
/** Lifecycle the framework acts on. */
|
|
30
|
+
emit(event: AdapterEvent): void;
|
|
31
|
+
/**
|
|
32
|
+
* The mount's whole document, when the host passed one (an MCP Apps view): for an adapter that
|
|
33
|
+
* delivers it itself, as through a sandbox proxy. In opaque isolation the framework has already
|
|
34
|
+
* put it in the frame, hardened.
|
|
35
|
+
*/
|
|
36
|
+
readonly document?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type AdapterEvent =
|
|
40
|
+
/** The view finished its handshake; content may be sent. */
|
|
41
|
+
| { readonly type: "initialized" }
|
|
42
|
+
/** The view asked to be closed. */
|
|
43
|
+
| { readonly type: "teardown-requested" };
|
|
44
|
+
|
|
45
|
+
/** What the framework drives on every adapter. Adapters add their protocol's own operations. */
|
|
46
|
+
export interface AdapterConnection {
|
|
47
|
+
/** The host context changed (a theme flip, a new width): tell the view. */
|
|
48
|
+
hostContextChanged(context: HostContext): void;
|
|
49
|
+
/** Asks the view to clean up before it is removed; resolves when it answered or gave up. */
|
|
50
|
+
teardown(): Promise<void>;
|
|
51
|
+
/** Stops listening. The framework closes the transport. */
|
|
52
|
+
close(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ProtocolAdapter<C extends AdapterConnection = AdapterConnection> {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
connect(session: AdapterSession): C;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The core adapter's connection: the MCP Apps subset the shell speaks (inline widgets, artifact
|
|
62
|
+
* pages). Content goes as `tool-input-partial` while it is written, then `tool-input` once.
|
|
63
|
+
*/
|
|
64
|
+
export interface ShellConnection extends AdapterConnection {
|
|
65
|
+
/** The content so far (`partial`, drawn inert, any number of times), then whole, once. */
|
|
66
|
+
setContent(html: string, options?: { readonly partial?: boolean }): void;
|
|
67
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
|
|
6
|
+
export type JsonRpcId = string | number;
|
|
7
|
+
|
|
8
|
+
export interface JsonRpcRequest {
|
|
9
|
+
readonly jsonrpc: "2.0";
|
|
10
|
+
readonly id: JsonRpcId;
|
|
11
|
+
readonly method: string;
|
|
12
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface JsonRpcNotification {
|
|
16
|
+
readonly jsonrpc: "2.0";
|
|
17
|
+
readonly method: string;
|
|
18
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface JsonRpcError {
|
|
22
|
+
readonly code: number;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly data?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type JsonRpcResponse =
|
|
28
|
+
| { readonly jsonrpc: "2.0"; readonly id: JsonRpcId; readonly result: unknown }
|
|
29
|
+
| { readonly jsonrpc: "2.0"; readonly id: JsonRpcId; readonly error: JsonRpcError };
|
|
30
|
+
|
|
31
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
32
|
+
|
|
33
|
+
/** The standard error codes the adapters answer with. */
|
|
34
|
+
export const JSON_RPC_ERRORS = {
|
|
35
|
+
invalidRequest: -32600,
|
|
36
|
+
methodNotFound: -32601,
|
|
37
|
+
invalidParams: -32602,
|
|
38
|
+
internal: -32603,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
42
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
|
|
44
|
+
const isId = (value: unknown): value is JsonRpcId =>
|
|
45
|
+
(typeof value === "string" && value.length <= 256) ||
|
|
46
|
+
(typeof value === "number" && Number.isSafeInteger(value));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `value` as a JSON-RPC message, or null when it isn't one: a request (id + method), a
|
|
50
|
+
* notification (method only), or a response (id + exactly one of result / error).
|
|
51
|
+
*/
|
|
52
|
+
export function parseJsonRpc(value: unknown): JsonRpcMessage | null {
|
|
53
|
+
if (!isRecord(value) || value.jsonrpc !== "2.0") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (value.params !== undefined && !isRecord(value.params)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (typeof value.method === "string") {
|
|
60
|
+
if (value.method.length === 0 || value.method.length > 256) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (value.id === undefined) {
|
|
64
|
+
return value as unknown as JsonRpcNotification;
|
|
65
|
+
}
|
|
66
|
+
return isId(value.id) ? (value as unknown as JsonRpcRequest) : null;
|
|
67
|
+
}
|
|
68
|
+
if (!isId(value.id) || value.method !== undefined) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const hasResult = "result" in value;
|
|
72
|
+
const hasError = "error" in value;
|
|
73
|
+
if (hasResult === hasError) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (hasError) {
|
|
77
|
+
const error = value.error;
|
|
78
|
+
if (!isRecord(error) || typeof error.code !== "number" || typeof error.message !== "string") {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return value as unknown as JsonRpcResponse;
|
|
83
|
+
}
|