@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.
Files changed (150) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +184 -0
  4. package/dist/bridge/types.d.ts +67 -0
  5. package/dist/bridge/types.js +1 -0
  6. package/dist/compile/compile.d.ts +35 -0
  7. package/dist/compile/compile.js +30 -0
  8. package/dist/compile/policy.d.ts +55 -0
  9. package/dist/compile/policy.js +106 -0
  10. package/dist/config/capabilities.d.ts +154 -0
  11. package/dist/config/capabilities.js +63 -0
  12. package/dist/config/schema.d.ts +131 -0
  13. package/dist/config/schema.js +1 -0
  14. package/dist/config/validate.d.ts +20 -0
  15. package/dist/config/validate.js +230 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +376 -0
  18. package/dist/conformance/index.d.ts +95 -0
  19. package/dist/conformance/index.js +4 -0
  20. package/dist/conformance/kit.d.ts +42 -0
  21. package/dist/conformance/kit.js +24 -0
  22. package/dist/conformance/oracles.d.ts +35 -0
  23. package/dist/conformance/oracles.js +77 -0
  24. package/dist/conformance/probes.d.ts +9 -0
  25. package/dist/conformance/probes.js +865 -0
  26. package/dist/conformance/served.d.ts +68 -0
  27. package/dist/conformance/served.js +311 -0
  28. package/dist/guide/examples.d.ts +70 -0
  29. package/dist/guide/examples.js +69 -0
  30. package/dist/guide/guide.d.ts +16 -0
  31. package/dist/guide/guide.js +261 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +248 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +11 -0
  36. package/dist/host/types.d.ts +95 -0
  37. package/dist/host/types.js +1 -0
  38. package/dist/index.d.ts +23 -0
  39. package/dist/index.js +19 -0
  40. package/dist/isolation/served.d.ts +32 -0
  41. package/dist/isolation/served.js +97 -0
  42. package/dist/isolation/types.d.ts +87 -0
  43. package/dist/isolation/types.js +1 -0
  44. package/dist/mcp/adapter.d.ts +43 -0
  45. package/dist/mcp/adapter.js +327 -0
  46. package/dist/mcp/index.d.ts +27 -0
  47. package/dist/mcp/index.js +25 -0
  48. package/dist/mcp/proxy.d.ts +25 -0
  49. package/dist/mcp/proxy.js +106 -0
  50. package/dist/mcp/view.d.ts +31 -0
  51. package/dist/mcp/view.js +72 -0
  52. package/dist/platform/types.d.ts +44 -0
  53. package/dist/platform/types.js +1 -0
  54. package/dist/platform/web.d.ts +8 -0
  55. package/dist/platform/web.js +72 -0
  56. package/dist/presets/presets.d.ts +65 -0
  57. package/dist/presets/presets.js +125 -0
  58. package/dist/protocol/adapter.d.ts +73 -0
  59. package/dist/protocol/adapter.js +1 -0
  60. package/dist/protocol/jsonrpc.d.ts +43 -0
  61. package/dist/protocol/jsonrpc.js +50 -0
  62. package/dist/protocol/mcp-apps.d.ts +79 -0
  63. package/dist/protocol/mcp-apps.js +99 -0
  64. package/dist/protocol/shell-adapter.d.ts +5 -0
  65. package/dist/protocol/shell-adapter.js +228 -0
  66. package/dist/react/dialog.d.ts +20 -0
  67. package/dist/react/dialog.js +98 -0
  68. package/dist/react/frame.d.ts +75 -0
  69. package/dist/react/frame.js +221 -0
  70. package/dist/react/index.d.ts +13 -0
  71. package/dist/react/index.js +13 -0
  72. package/dist/react/live.d.ts +24 -0
  73. package/dist/react/live.js +57 -0
  74. package/dist/server/index.d.ts +27 -0
  75. package/dist/server/index.js +26 -0
  76. package/dist/server/keys.d.ts +19 -0
  77. package/dist/server/keys.js +78 -0
  78. package/dist/server/pages.d.ts +6 -0
  79. package/dist/server/pages.js +20 -0
  80. package/dist/server/proxy.d.ts +21 -0
  81. package/dist/server/proxy.js +107 -0
  82. package/dist/server/server.d.ts +7 -0
  83. package/dist/server/server.js +518 -0
  84. package/dist/server/tickets.d.ts +8 -0
  85. package/dist/server/tickets.js +28 -0
  86. package/dist/server/types.d.ts +97 -0
  87. package/dist/server/types.js +1 -0
  88. package/dist/shell/document.d.ts +28 -0
  89. package/dist/shell/document.js +84 -0
  90. package/dist/shell/plan.d.ts +35 -0
  91. package/dist/shell/plan.js +34 -0
  92. package/dist/shell/runtime.d.ts +28 -0
  93. package/dist/shell/runtime.js +832 -0
  94. package/dist/theme/variables.d.ts +73 -0
  95. package/dist/theme/variables.js +171 -0
  96. package/dist/transport/post-message.d.ts +9 -0
  97. package/dist/transport/post-message.js +38 -0
  98. package/dist/transport/types.d.ts +15 -0
  99. package/dist/transport/types.js +1 -0
  100. package/package.json +68 -3
  101. package/src/bridge/bridge.ts +256 -0
  102. package/src/bridge/types.ts +77 -0
  103. package/src/compile/compile.ts +68 -0
  104. package/src/compile/policy.ts +133 -0
  105. package/src/config/capabilities.ts +138 -0
  106. package/src/config/schema.ts +140 -0
  107. package/src/config/validate.ts +284 -0
  108. package/src/conformance/behavior.ts +430 -0
  109. package/src/conformance/index.ts +102 -0
  110. package/src/conformance/kit.ts +63 -0
  111. package/src/conformance/oracles.ts +126 -0
  112. package/src/conformance/probes.ts +1093 -0
  113. package/src/conformance/served.ts +472 -0
  114. package/src/guide/examples.ts +74 -0
  115. package/src/guide/guide.ts +424 -0
  116. package/src/host/create-host.ts +290 -0
  117. package/src/host/locate.ts +20 -0
  118. package/src/host/types.ts +95 -0
  119. package/src/index.ts +118 -0
  120. package/src/isolation/served.ts +127 -0
  121. package/src/isolation/types.ts +77 -0
  122. package/src/mcp/adapter.ts +441 -0
  123. package/src/mcp/index.ts +50 -0
  124. package/src/mcp/proxy.ts +117 -0
  125. package/src/mcp/view.ts +97 -0
  126. package/src/platform/types.ts +48 -0
  127. package/src/platform/web.ts +78 -0
  128. package/src/presets/presets.ts +193 -0
  129. package/src/protocol/adapter.ts +67 -0
  130. package/src/protocol/jsonrpc.ts +83 -0
  131. package/src/protocol/mcp-apps.ts +138 -0
  132. package/src/protocol/shell-adapter.ts +261 -0
  133. package/src/react/dialog.tsx +158 -0
  134. package/src/react/frame.tsx +422 -0
  135. package/src/react/index.ts +20 -0
  136. package/src/react/live.ts +76 -0
  137. package/src/server/index.ts +37 -0
  138. package/src/server/keys.ts +100 -0
  139. package/src/server/pages.ts +31 -0
  140. package/src/server/proxy.ts +131 -0
  141. package/src/server/server.ts +617 -0
  142. package/src/server/tickets.ts +30 -0
  143. package/src/server/types.ts +104 -0
  144. package/src/shell/document.ts +120 -0
  145. package/src/shell/plan.ts +66 -0
  146. package/src/shell/runtime.ts +832 -0
  147. package/src/theme/variables.ts +190 -0
  148. package/src/transport/post-message.ts +45 -0
  149. package/src/transport/types.ts +16 -0
  150. package/README.md +0 -3
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@botiverse/porthole`: the framework-agnostic core. One config (a preset's) compiles into the
3
+ * frame's policies, the shell, and the agent manual; the host mounts displays under it.
4
+ */
5
+ export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
6
+ export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
7
+ export { BUILT_IN_TOPICS, ConfigError, isOriginSource, validateConfig } from "./config/validate.ts";
8
+ export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
9
+ export { compile, type CompiledConfig } from "./compile/compile.ts";
10
+ export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, type CspDirective, type EgressKind, } from "./compile/policy.ts";
11
+ export { shellPlan, type ShellPlan } from "./shell/plan.ts";
12
+ export type { ShellDocumentOptions } from "./shell/document.ts";
13
+ export { generateGuide, generateToolDescription, type BuiltInTopic, type GuideTopic, } from "./guide/guide.ts";
14
+ export { GUIDE_EXAMPLES, type GuideExample } from "./guide/examples.ts";
15
+ export { CHART_PALETTE, DEFAULT_THEME, DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES, resolveTheme, themeDefault, } from "./theme/variables.ts";
16
+ export type { FrameLocation, FrameSource, InstanceLocator, InstanceRef, ServedDocument, } from "./isolation/types.ts";
17
+ export type { PlatformHost, Surface, SurfaceSpec } from "./platform/types.ts";
18
+ export type { Transport } from "./transport/types.ts";
19
+ export type { AdapterConnection, AdapterEvent, AdapterSession, ProtocolAdapter, ShellConnection, } from "./protocol/adapter.ts";
20
+ export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, type HostContext, } from "./protocol/mcp-apps.ts";
21
+ export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmRequest, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
22
+ export { createHost } from "./host/create-host.ts";
23
+ export type { DisplayControls, DisplayEvents, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@botiverse/porthole`: the framework-agnostic core. One config (a preset's) compiles into the
3
+ * frame's policies, the shell, and the agent manual; the host mounts displays under it.
4
+ */
5
+ // The config and its vocabulary.
6
+ export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
7
+ export { BUILT_IN_TOPICS, ConfigError, isOriginSource, validateConfig } from "./config/validate.js";
8
+ // Presets.
9
+ export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
10
+ // The compiler.
11
+ export { compile } from "./compile/compile.js";
12
+ export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, } from "./compile/policy.js";
13
+ export { shellPlan } from "./shell/plan.js";
14
+ export { generateGuide, generateToolDescription, } from "./guide/guide.js";
15
+ export { GUIDE_EXAMPLES } from "./guide/examples.js";
16
+ // Theme.
17
+ export { CHART_PALETTE, DEFAULT_THEME, DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES, resolveTheme, themeDefault, } from "./theme/variables.js";
18
+ export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "./protocol/mcp-apps.js";
19
+ export { createHost } from "./host/create-host.js";
@@ -0,0 +1,32 @@
1
+ import type { FrameLocation } from "./types.ts";
2
+ interface ParsedOrigin {
3
+ readonly scheme: string;
4
+ readonly host: string;
5
+ readonly port: string;
6
+ }
7
+ /** `scheme://host[:port]`, lowercase, nothing else; null for anything that isn't exactly that. */
8
+ export declare function parseOrigin(origin: string): ParsedOrigin | null;
9
+ /** Whether `origin` is an exact origin (`https://host[:port]`, lowercase, no path). */
10
+ export declare function isExactOrigin(origin: unknown): origin is string;
11
+ /**
12
+ * Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
13
+ * it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
14
+ * calls cross-site always is (a refused pair, like `a.github.io` and `b.github.io`, may not be:
15
+ * a safe failure). An IP address is a site of its own; `localhost`'s subdomains are each one, as
16
+ * browsers treat an unlisted top-level name as a public suffix.
17
+ */
18
+ export declare function maybeSameSite(hostA: string, hostB: string): boolean;
19
+ /**
20
+ * Whether `origin` matches a frame source as the config writes them (`https://*.example.com`,
21
+ * `http://*.localhost:8790`, or an exact origin): same scheme and port; `*.` matches one or more
22
+ * labels below the name, never the name itself.
23
+ */
24
+ export declare function originMatchesSource(origin: string, source: string): boolean;
25
+ /**
26
+ * Throws unless `location` is a frame a host on `hostOrigin` may load for a config whose content
27
+ * is served from `contentOrigins`: its URL is on its stated origin, that origin is one the config
28
+ * names, and it is not of the host's own site (a same-site frame with `allow-same-origin` could
29
+ * reach cookies and storage the host shares with its site).
30
+ */
31
+ export declare function checkFrameLocation(location: FrameLocation, contentOrigins: readonly string[], hostOrigin: string): void;
32
+ export {};
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Checks for served frames (domain and external isolation), shared by the host's mount and by
3
+ * `/server`: a served origin must be one the config names, and never of the host's own site
4
+ * (P016, P020). Pure, no DOM and no Node, so both sides run the same code.
5
+ */
6
+ const EXACT_ORIGIN = /^(https?):\/\/([a-z0-9.-]+|\[[0-9a-f:.]+\])(?::(\d{1,5}))?$/u;
7
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/u;
8
+ const DEFAULT_PORTS = { http: "80", https: "443" };
9
+ /** `scheme://host[:port]`, lowercase, nothing else; null for anything that isn't exactly that. */
10
+ export function parseOrigin(origin) {
11
+ const match = EXACT_ORIGIN.exec(origin);
12
+ if (match === null) {
13
+ return null;
14
+ }
15
+ const [, scheme = "", host = "", port] = match;
16
+ return { scheme, host, port: port ?? DEFAULT_PORTS[scheme] ?? "" };
17
+ }
18
+ /** Whether `origin` is an exact origin (`https://host[:port]`, lowercase, no path). */
19
+ export function isExactOrigin(origin) {
20
+ return typeof origin === "string" && parseOrigin(origin) !== null;
21
+ }
22
+ /**
23
+ * Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
24
+ * it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
25
+ * calls cross-site always is (a refused pair, like `a.github.io` and `b.github.io`, may not be:
26
+ * a safe failure). An IP address is a site of its own; `localhost`'s subdomains are each one, as
27
+ * browsers treat an unlisted top-level name as a public suffix.
28
+ */
29
+ export function maybeSameSite(hostA, hostB) {
30
+ const a = hostA.toLowerCase().replace(/\.$/u, "");
31
+ const b = hostB.toLowerCase().replace(/\.$/u, "");
32
+ if (a === b) {
33
+ return true;
34
+ }
35
+ if (IPV4.test(a) || IPV4.test(b) || a.startsWith("[") || b.startsWith("[")) {
36
+ return false;
37
+ }
38
+ const labelsA = a.split(".").toReversed();
39
+ const labelsB = b.split(".").toReversed();
40
+ let shared = 0;
41
+ while (shared < labelsA.length &&
42
+ shared < labelsB.length &&
43
+ labelsA[shared] === labelsB[shared]) {
44
+ shared += 1;
45
+ }
46
+ return shared >= 2;
47
+ }
48
+ /**
49
+ * Whether `origin` matches a frame source as the config writes them (`https://*.example.com`,
50
+ * `http://*.localhost:8790`, or an exact origin): same scheme and port; `*.` matches one or more
51
+ * labels below the name, never the name itself.
52
+ */
53
+ export function originMatchesSource(origin, source) {
54
+ const parsed = parseOrigin(origin);
55
+ const wildcard = /^(https?):\/\/\*\.([a-z0-9.-]+)(?::(\d{1,5}))?$/u.exec(source);
56
+ if (parsed === null) {
57
+ return false;
58
+ }
59
+ if (wildcard === null) {
60
+ const exact = parseOrigin(source);
61
+ return (exact !== null &&
62
+ exact.scheme === parsed.scheme &&
63
+ exact.host === parsed.host &&
64
+ exact.port === parsed.port);
65
+ }
66
+ const [, scheme = "", suffix = "", port] = wildcard;
67
+ return (scheme === parsed.scheme &&
68
+ (port ?? DEFAULT_PORTS[scheme]) === parsed.port &&
69
+ parsed.host.endsWith(`.${suffix}`) &&
70
+ parsed.host.length > suffix.length + 1);
71
+ }
72
+ /**
73
+ * Throws unless `location` is a frame a host on `hostOrigin` may load for a config whose content
74
+ * is served from `contentOrigins`: its URL is on its stated origin, that origin is one the config
75
+ * names, and it is not of the host's own site (a same-site frame with `allow-same-origin` could
76
+ * reach cookies and storage the host shares with its site).
77
+ */
78
+ export function checkFrameLocation(location, contentOrigins, hostOrigin) {
79
+ const origin = parseOrigin(location.origin);
80
+ let url;
81
+ try {
82
+ url = new URL(location.url);
83
+ }
84
+ catch {
85
+ throw new TypeError(`The located URL ${JSON.stringify(location.url)} is not a URL`);
86
+ }
87
+ if (origin === null || url.origin !== location.origin) {
88
+ throw new TypeError(`The located URL ${JSON.stringify(location.url)} is not on its origin ${JSON.stringify(location.origin)}`);
89
+ }
90
+ if (!contentOrigins.some((source) => originMatchesSource(location.origin, source))) {
91
+ throw new TypeError(`The located origin ${location.origin} is not one of the config's content origins`);
92
+ }
93
+ const host = parseOrigin(hostOrigin);
94
+ if (host !== null && maybeSameSite(origin.host, host.host)) {
95
+ throw new TypeError(`The located origin ${location.origin} may be the same site as the host (${hostOrigin}): content must be served from a registrable domain the host uses for nothing else`);
96
+ }
97
+ }
@@ -0,0 +1,87 @@
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
+ * Which document a served frame loads. `preset` names the config it is served under (the server's
17
+ * `configs`); the artifact and the proxy default to their presets' names.
18
+ */
19
+ export type ServedDocument =
20
+ /** The shell, served with its CSP in headers; content arrives over the transport as for opaque. */
21
+ {
22
+ readonly kind: "shell";
23
+ readonly preset: string;
24
+ }
25
+ /** A stored page (artifact mode), by id and version: versions are immutable. */
26
+ | {
27
+ readonly kind: "artifact";
28
+ readonly id: string;
29
+ readonly version: string;
30
+ /** Default `artifact`. */
31
+ readonly preset?: string;
32
+ }
33
+ /**
34
+ * The MCP Apps sandbox proxy: a second origin the host hands the view's HTML to, which becomes
35
+ * the view. `egress` is what the view declares (`_meta.ui.csp`, mapped by `/mcp`'s
36
+ * `viewEgress`), opened on top of the config's: its CSP goes out in the proxy's headers, before
37
+ * the HTML arrives.
38
+ */
39
+ | {
40
+ readonly kind: "mcp-proxy";
41
+ readonly preset?: string;
42
+ readonly egress?: EgressConfig;
43
+ }
44
+ /**
45
+ * Someone else's running app (url mode), e.g. a dev server behind the host's preview gateway:
46
+ * `url` is where the host's upstream finds it; the frame lands on its path and query.
47
+ */
48
+ | {
49
+ readonly kind: "external";
50
+ readonly url: string;
51
+ };
52
+ /** One served instance: where its storage lives, and what it shows. */
53
+ export interface InstanceRef {
54
+ /**
55
+ * What the content's origin, and so its storage, is keyed by. Instances with the same scope
56
+ * share an origin. Opaque to Porthole: the host picks it (a display id, an app id).
57
+ */
58
+ readonly scope: string;
59
+ readonly document: ServedDocument;
60
+ }
61
+ /** Where a served frame loads from. */
62
+ export interface FrameLocation {
63
+ /** The URL the frame loads, carrying a one-time ticket where the content is private. */
64
+ readonly url: string;
65
+ /** Its origin: the host takes messages only from this origin, from this frame. */
66
+ readonly origin: string;
67
+ }
68
+ /**
69
+ * Turns an instance into the URL its frame loads. Implemented by the host, usually by asking its
70
+ * own backend (which authenticates the viewer and calls `PortholeServer.issue`). Called on every
71
+ * mount: a ticket is good once.
72
+ */
73
+ export interface InstanceLocator {
74
+ locate(instance: InstanceRef, signal?: AbortSignal): Promise<FrameLocation>;
75
+ }
76
+ /**
77
+ * Where a frame's document comes from: built by the host (`srcdoc`, opaque isolation), or loaded
78
+ * from a URL (domain and external isolation).
79
+ */
80
+ export type FrameSource = {
81
+ readonly kind: "srcdoc";
82
+ readonly html: string;
83
+ } | {
84
+ readonly kind: "url";
85
+ readonly url: string;
86
+ readonly origin: string;
87
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import type { AdapterConnection, ProtocolAdapter } from "../protocol/adapter.ts";
2
+ import { type HostContext } from "../protocol/mcp-apps.ts";
3
+ /**
4
+ * The MCP Apps adapter (P004, P007): the host side of the protocol for full MCP Apps views, written
5
+ * against the core's seams and no SDK. It answers the view's handshake with the host context and
6
+ * the capabilities the config declares, carries the view's requests into the bridge (which alone
7
+ * decides whether they run), reports sizes, errors and teardown requests, and sends the view its
8
+ * tool input, result and cancellation, never before the view said it was initialized (spec §
9
+ * Sandbox proxy, 6). Behind the sandbox proxy it hands the proxy the view's HTML first.
10
+ */
11
+ /** The tool call a view was made for: its host context's `toolInfo`. */
12
+ export interface ToolInfo {
13
+ /** The JSON-RPC id of the `tools/call`. */
14
+ readonly id?: string | number;
15
+ /** The tool's definition (name, input schema, …) as the server listed it. */
16
+ readonly tool: Readonly<Record<string, unknown>>;
17
+ }
18
+ export interface McpAppsAdapterOptions {
19
+ /** The tool call each display shows, by display id. */
20
+ readonly toolInfo?: (displayId: string) => ToolInfo | undefined;
21
+ /** How long `teardown` waits for the view's answer, ms (default 3000). */
22
+ readonly teardownTimeoutMs?: number;
23
+ }
24
+ /** A tool's result as MCP carries it (`CallToolResult`): content, structured content, `_meta`. */
25
+ export type ToolResult = Readonly<Record<string, unknown>>;
26
+ /** What a host drives on an MCP Apps view, besides the framework's own controls. */
27
+ export interface McpAppConnection extends AdapterConnection {
28
+ /** The tool's arguments so far, while the agent writes them (dropped once the whole went). */
29
+ sendToolInputPartial(args: Readonly<Record<string, unknown>>): void;
30
+ /** The tool's whole arguments, once. */
31
+ sendToolInput(args: Readonly<Record<string, unknown>>): void;
32
+ /** The tool's result. */
33
+ sendToolResult(result: ToolResult): void;
34
+ /** The tool call was cancelled (by the person, an error, a policy). */
35
+ sendToolCancelled(reason?: string): void;
36
+ }
37
+ /**
38
+ * The host context as a view may receive it: Porthole's fields as they are, its theme variables
39
+ * cut to the names the standard defines (the official SDK refuses any other).
40
+ */
41
+ export declare function viewHostContext(context: HostContext, toolInfo?: ToolInfo): Readonly<Record<string, unknown>>;
42
+ /** The MCP Apps adapter: pass it as `HostOptions.adapter` with the `mcpApp` preset. */
43
+ export declare function mcpAppsAdapter(options?: McpAppsAdapterOptions): ProtocolAdapter<McpAppConnection>;
@@ -0,0 +1,327 @@
1
+ import { egressOrigins } from "../compile/policy.js";
2
+ import { JSON_RPC_ERRORS, parseJsonRpc, } from "../protocol/jsonrpc.js";
3
+ import { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, } from "../protocol/mcp-apps.js";
4
+ import { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.js";
5
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
6
+ /** Levels of `notifications/message` that are errors: they go to the host as `reportError`. */
7
+ const ERROR_LEVELS = new Set(["error", "critical", "alert", "emergency"]);
8
+ const DISPLAY_MODES = new Set(["inline", "fullscreen", "pip"]);
9
+ const STYLE_VARIABLES = new Set(MCP_APPS_STYLE_VARIABLES);
10
+ /**
11
+ * The host context as a view may receive it: Porthole's fields as they are, its theme variables
12
+ * cut to the names the standard defines (the official SDK refuses any other).
13
+ */
14
+ export function viewHostContext(context, toolInfo) {
15
+ const out = {};
16
+ for (const [key, value] of Object.entries(context)) {
17
+ if (value !== undefined && key !== "styles") {
18
+ out[key] = value;
19
+ }
20
+ }
21
+ const variables = {};
22
+ for (const [name, value] of Object.entries(context.styles?.variables ?? {})) {
23
+ if (STYLE_VARIABLES.has(name) && typeof value === "string") {
24
+ variables[name] = value;
25
+ }
26
+ }
27
+ out.styles = { variables };
28
+ if (toolInfo !== undefined) {
29
+ out.toolInfo = toolInfo;
30
+ }
31
+ return out;
32
+ }
33
+ /** The text of a `ui/message`'s content: its text blocks, in order (the spec's examples send one). */
34
+ function messageText(content) {
35
+ const blocks = Array.isArray(content) ? content : [content];
36
+ const texts = blocks.flatMap((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" ? [block.text] : []);
37
+ return texts.length === 0 ? null : texts.join("\n\n");
38
+ }
39
+ /** Whether the host page has a live user gesture (a gesture in a frame activates its ancestors). */
40
+ function hostActivation() {
41
+ const activation = globalThis
42
+ .navigator?.userActivation;
43
+ return activation === undefined ? undefined : activation.isActive;
44
+ }
45
+ function connect(session, options) {
46
+ const { transport, bridge } = session;
47
+ let closed = false;
48
+ let initialized = false;
49
+ let inputSent = false;
50
+ let resourceSent = false;
51
+ /** What the host said before the view was initialized: sent, in order, once it is. */
52
+ let queue = [];
53
+ const pending = new Map();
54
+ let nextId = 1;
55
+ const send = (message) => {
56
+ if (!closed) {
57
+ transport.send(message);
58
+ }
59
+ };
60
+ const notify = (method, params) => {
61
+ const message = { jsonrpc: "2.0", method, params };
62
+ if (initialized) {
63
+ send(message);
64
+ }
65
+ else {
66
+ queue.push(message);
67
+ }
68
+ };
69
+ const answer = (id, result) => send({ jsonrpc: "2.0", id, result });
70
+ const refuse = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
71
+ const hostCapabilities = () => {
72
+ const declared = (name) => bridge.declared(name);
73
+ const { config } = session.compiled;
74
+ const connectDomains = egressOrigins(config, "connect");
75
+ const resourceDomains = [
76
+ ...new Set(["script", "style", "img", "font", "media"].flatMap((kind) => egressOrigins(config, kind))),
77
+ ];
78
+ return {
79
+ logging: {},
80
+ ...(declared("openLink") ? { openLinks: {} } : {}),
81
+ ...(declared("downloadFile") ? { downloadFile: {} } : {}),
82
+ ...(declared("callTool") ? { serverTools: {} } : {}),
83
+ ...(declared("readData") ? { serverResources: {} } : {}),
84
+ ...(declared("sendPrompt") ? { message: { text: {} } } : {}),
85
+ ...(declared("setModelContext")
86
+ ? { updateModelContext: { text: {}, structuredContent: {} } }
87
+ : {}),
88
+ sandbox: {
89
+ csp: {
90
+ ...(connectDomains.length > 0 ? { connectDomains } : {}),
91
+ ...(resourceDomains.length > 0 ? { resourceDomains } : {}),
92
+ },
93
+ },
94
+ };
95
+ };
96
+ /** Runs a capability through the bridge and answers the request with what it gives. */
97
+ async function call(request, capability, args, result) {
98
+ const userActivated = hostActivation();
99
+ let outcome;
100
+ try {
101
+ outcome = await bridge.call(capability, args, userActivated === undefined ? undefined : { userActivated });
102
+ }
103
+ catch {
104
+ refuse(request.id, JSON_RPC_ERRORS.internal, `${request.method} failed`);
105
+ return;
106
+ }
107
+ if (outcome.ok) {
108
+ answer(request.id, result(outcome.value));
109
+ }
110
+ else if (outcome.refusal === "not-declared") {
111
+ refuse(request.id, JSON_RPC_ERRORS.methodNotFound, `${request.method} isn't offered here`);
112
+ }
113
+ else {
114
+ // The spec's "implementation-defined" error for a refused request.
115
+ refuse(request.id, -32_000, `${request.method} refused (${outcome.refusal})`);
116
+ }
117
+ }
118
+ /** `{}` when a host function did it, `{ isError: true }` when it said it couldn't. */
119
+ const done = (value) => value === false ? { isError: true } : {};
120
+ async function onRequest(request) {
121
+ const params = request.params ?? {};
122
+ const invalid = (what) => refuse(request.id, JSON_RPC_ERRORS.invalidParams, `${request.method}: ${what}`);
123
+ switch (request.method) {
124
+ case MCP_APPS_METHODS.initialize:
125
+ answer(request.id, {
126
+ protocolVersion: MCP_APPS_PROTOCOL_VERSION,
127
+ hostInfo: session.hostInfo,
128
+ hostCapabilities: hostCapabilities(),
129
+ hostContext: viewHostContext(session.hostContext(), options.toolInfo?.(session.display.id)),
130
+ });
131
+ return;
132
+ case "ping":
133
+ answer(request.id, {});
134
+ return;
135
+ case MCP_APPS_METHODS.message: {
136
+ const text = params.role === "user" ? messageText(params.content) : null;
137
+ if (text === null) {
138
+ invalid('needs role "user" and text content');
139
+ return;
140
+ }
141
+ await call(request, "sendPrompt", { text }, done);
142
+ return;
143
+ }
144
+ case MCP_APPS_METHODS.openLink:
145
+ if (typeof params.url !== "string") {
146
+ invalid("needs a url");
147
+ return;
148
+ }
149
+ await call(request, "openLink", { url: params.url }, done);
150
+ return;
151
+ case MCP_APPS_METHODS.downloadFile:
152
+ if (!Array.isArray(params.contents)) {
153
+ invalid("needs contents");
154
+ return;
155
+ }
156
+ await call(request, "downloadFile", { contents: params.contents }, done);
157
+ return;
158
+ case MCP_APPS_METHODS.callTool:
159
+ if (typeof params.name !== "string" ||
160
+ (params.arguments !== undefined && !isRecord(params.arguments))) {
161
+ invalid("needs a tool name and object arguments");
162
+ return;
163
+ }
164
+ await call(request, "callTool", { name: params.name, arguments: params.arguments ?? {} }, (value) => (isRecord(value) ? value : { content: [] }));
165
+ return;
166
+ case MCP_APPS_METHODS.readResource:
167
+ if (typeof params.uri !== "string") {
168
+ invalid("needs a uri");
169
+ return;
170
+ }
171
+ await call(request, "readData", { uri: params.uri }, (value) => isRecord(value) ? value : { contents: [] });
172
+ return;
173
+ case MCP_APPS_METHODS.updateModelContext:
174
+ if ((params.content !== undefined && !Array.isArray(params.content)) ||
175
+ (params.structuredContent !== undefined && !isRecord(params.structuredContent))) {
176
+ invalid("content is a list, structuredContent an object");
177
+ return;
178
+ }
179
+ await call(request, "setModelContext", {
180
+ ...(params.content === undefined ? {} : { content: params.content }),
181
+ ...(params.structuredContent === undefined
182
+ ? {}
183
+ : { structuredContent: params.structuredContent }),
184
+ }, () => ({}));
185
+ return;
186
+ case MCP_APPS_METHODS.requestDisplayMode: {
187
+ if (typeof params.mode !== "string" || !DISPLAY_MODES.has(params.mode)) {
188
+ invalid("needs a display mode");
189
+ return;
190
+ }
191
+ // Changing how a display is shown is the host's own layout: the view keeps its mode, and
192
+ // the answer says so (spec: return the current mode when the request isn't granted).
193
+ answer(request.id, { mode: session.hostContext().displayMode });
194
+ return;
195
+ }
196
+ default:
197
+ refuse(request.id, JSON_RPC_ERRORS.methodNotFound, `${request.method} isn't offered here`);
198
+ }
199
+ }
200
+ function onNotification(method, params) {
201
+ switch (method) {
202
+ case MCP_APPS_METHODS.sandboxProxyReady:
203
+ // Behind the sandbox proxy: it takes the view's HTML once, then becomes the view (P026).
204
+ if (!resourceSent && session.document !== undefined) {
205
+ resourceSent = true;
206
+ send({
207
+ jsonrpc: "2.0",
208
+ method: MCP_APPS_METHODS.sandboxResourceReady,
209
+ params: { html: session.document },
210
+ });
211
+ }
212
+ return;
213
+ case MCP_APPS_METHODS.initialized:
214
+ if (!initialized) {
215
+ initialized = true;
216
+ const waiting = queue;
217
+ queue = [];
218
+ for (const message of waiting) {
219
+ send(message);
220
+ }
221
+ session.emit({ type: "initialized" });
222
+ }
223
+ return;
224
+ case MCP_APPS_METHODS.sizeChanged: {
225
+ const { height, width } = params;
226
+ const size = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
227
+ if (size(height)) {
228
+ void bridge.call("resize", { height, ...(size(width) ? { width } : {}) });
229
+ }
230
+ return;
231
+ }
232
+ case MCP_APPS_METHODS.log:
233
+ if (typeof params.level === "string" && ERROR_LEVELS.has(params.level)) {
234
+ const data = params.data;
235
+ void bridge.call("reportError", {
236
+ message: (typeof data === "string" ? data : (JSON.stringify(data) ?? "")).slice(0, 2_000),
237
+ });
238
+ }
239
+ return;
240
+ case MCP_APPS_METHODS.requestTeardown:
241
+ session.emit({ type: "teardown-requested" });
242
+ return;
243
+ default:
244
+ }
245
+ }
246
+ const unlisten = transport.listen((raw) => {
247
+ const message = parseJsonRpc(raw);
248
+ if (message === null || closed) {
249
+ return;
250
+ }
251
+ if (!("method" in message)) {
252
+ const waiting = pending.get(message.id);
253
+ pending.delete(message.id);
254
+ waiting?.();
255
+ return;
256
+ }
257
+ if ("id" in message) {
258
+ void onRequest(message);
259
+ }
260
+ else {
261
+ onNotification(message.method, message.params ?? {});
262
+ }
263
+ });
264
+ return {
265
+ hostContextChanged(context) {
266
+ if (initialized) {
267
+ send({
268
+ jsonrpc: "2.0",
269
+ method: MCP_APPS_METHODS.hostContextChanged,
270
+ params: viewHostContext(context),
271
+ });
272
+ }
273
+ },
274
+ teardown() {
275
+ if (closed || !initialized) {
276
+ return Promise.resolve();
277
+ }
278
+ return new Promise((resolve) => {
279
+ const id = nextId++;
280
+ const finish = () => {
281
+ clearTimeout(timer);
282
+ pending.delete(id);
283
+ resolve();
284
+ };
285
+ const timer = setTimeout(finish, options.teardownTimeoutMs ?? 3_000);
286
+ pending.set(id, finish);
287
+ send({ jsonrpc: "2.0", id, method: MCP_APPS_METHODS.resourceTeardown, params: {} });
288
+ });
289
+ },
290
+ close() {
291
+ closed = true;
292
+ unlisten();
293
+ queue = [];
294
+ for (const waiting of pending.values()) {
295
+ waiting();
296
+ }
297
+ },
298
+ sendToolInputPartial(args) {
299
+ if (inputSent) {
300
+ return;
301
+ }
302
+ if (!initialized) {
303
+ // Only the latest draft matters to a view that hasn't seen any.
304
+ queue = queue.filter((message) => !("method" in message) || message.method !== MCP_APPS_METHODS.toolInputPartial);
305
+ }
306
+ notify(MCP_APPS_METHODS.toolInputPartial, { arguments: args });
307
+ },
308
+ sendToolInput(args) {
309
+ if (inputSent) {
310
+ return;
311
+ }
312
+ inputSent = true;
313
+ queue = queue.filter((message) => !("method" in message) || message.method !== MCP_APPS_METHODS.toolInputPartial);
314
+ notify(MCP_APPS_METHODS.toolInput, { arguments: args });
315
+ },
316
+ sendToolResult(result) {
317
+ notify(MCP_APPS_METHODS.toolResult, result);
318
+ },
319
+ sendToolCancelled(reason) {
320
+ notify(MCP_APPS_METHODS.toolCancelled, reason === undefined ? {} : { reason });
321
+ },
322
+ };
323
+ }
324
+ /** The MCP Apps adapter: pass it as `HostOptions.adapter` with the `mcpApp` preset. */
325
+ export function mcpAppsAdapter(options = {}) {
326
+ return { name: "mcp-apps", connect: (session) => connect(session, options) };
327
+ }
@@ -0,0 +1,27 @@
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 { mcpAppsAdapter, viewHostContext, type McpAppConnection, type McpAppsAdapterOptions, type ToolInfo, type ToolResult, } from "./adapter.ts";
22
+ export { sandboxProxyDocument } from "./proxy.ts";
23
+ export { VIEW_MIME_TYPE, readViewResource, viewEgress, type ViewResource } from "./view.ts";
24
+ export type { AdapterConnection, AdapterEvent, AdapterSession, ProtocolAdapter, } from "../protocol/adapter.ts";
25
+ export type { HostContext } from "../protocol/mcp-apps.ts";
26
+ export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "../protocol/mcp-apps.ts";
27
+ export { JSON_RPC_ERRORS, parseJsonRpc, type JsonRpcMessage, type JsonRpcRequest, type JsonRpcResponse, } from "../protocol/jsonrpc.ts";