@botiverse/porthole 0.1.2 → 0.1.3

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 (64) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +4 -3
  8. package/dist/compile/policy.js +7 -4
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/conformance/clipboard.d.ts +2 -0
  12. package/dist/conformance/clipboard.js +167 -0
  13. package/dist/conformance/kit.d.ts +1 -1
  14. package/dist/conformance/probes.js +2 -0
  15. package/dist/guide/guide.js +8 -4
  16. package/dist/host/clipboard.d.ts +11 -0
  17. package/dist/host/clipboard.js +43 -0
  18. package/dist/host/create-host.js +141 -0
  19. package/dist/host/types.d.ts +24 -0
  20. package/dist/index.d.ts +6 -3
  21. package/dist/index.js +6 -2
  22. package/dist/mcp/adapter.js +10 -1
  23. package/dist/protocol/mcp-apps.d.ts +5 -0
  24. package/dist/protocol/mcp-apps.js +5 -0
  25. package/dist/protocol/shell-adapter.js +6 -2
  26. package/dist/react/dialog.js +2 -0
  27. package/dist/react/frame.d.ts +18 -0
  28. package/dist/react/frame.js +82 -2
  29. package/dist/react/index.d.ts +2 -1
  30. package/dist/react/index.js +2 -1
  31. package/dist/react/toast.d.ts +33 -0
  32. package/dist/react/toast.js +42 -0
  33. package/dist/shell/annotate.d.ts +20 -0
  34. package/dist/shell/annotate.js +669 -0
  35. package/dist/shell/document.js +4 -1
  36. package/dist/shell/plan.d.ts +2 -0
  37. package/dist/shell/plan.js +2 -1
  38. package/dist/shell/runtime.d.ts +4 -1
  39. package/dist/shell/runtime.js +38 -2
  40. package/package.json +1 -1
  41. package/src/annotate/types.ts +126 -0
  42. package/src/annotate/validate.ts +186 -0
  43. package/src/bridge/bridge.ts +13 -0
  44. package/src/compile/policy.ts +7 -4
  45. package/src/config/capabilities.ts +33 -3
  46. package/src/conformance/clipboard.ts +184 -0
  47. package/src/conformance/kit.ts +1 -1
  48. package/src/conformance/probes.ts +2 -0
  49. package/src/guide/guide.ts +19 -4
  50. package/src/host/clipboard.ts +41 -0
  51. package/src/host/create-host.ts +155 -0
  52. package/src/host/types.ts +25 -0
  53. package/src/index.ts +22 -0
  54. package/src/mcp/adapter.ts +10 -1
  55. package/src/protocol/mcp-apps.ts +6 -0
  56. package/src/protocol/shell-adapter.ts +11 -2
  57. package/src/react/dialog.tsx +2 -0
  58. package/src/react/frame.tsx +117 -1
  59. package/src/react/index.ts +9 -1
  60. package/src/react/toast.tsx +96 -0
  61. package/src/shell/annotate.ts +669 -0
  62. package/src/shell/document.ts +6 -1
  63. package/src/shell/plan.ts +4 -1
  64. package/src/shell/runtime.ts +38 -2
@@ -0,0 +1,184 @@
1
+ import { expect, type Page } from "@playwright/test";
2
+ import type { LaunchedDisplay } from "./index.ts";
3
+ import { inline, type Probe } from "./kit.ts";
4
+ import { settle } from "./oracles.ts";
5
+
6
+ /**
7
+ * The clipboard (item 13, P051): content can't write it, whatever it tries in a person's click,
8
+ * and what a person copies themselves is what they selected. `copyText` is the way copy buttons
9
+ * work instead (item 12, P052). The oracle is the person's own clipboard: known text put on it
10
+ * from the host's page, then pasted back into a text area there.
11
+ */
12
+
13
+ const SEED = "porthole-seed";
14
+
15
+ /** A text area of the oracle's own on the host's page. */
16
+ async function area(page: Page, id: string): Promise<void> {
17
+ await page.evaluate((each) => {
18
+ if (document.getElementById(each) === null) {
19
+ const element = document.createElement("textarea");
20
+ element.id = each;
21
+ element.style.cssText = "display:block;width:200px;height:40px";
22
+ document.body.prepend(element);
23
+ }
24
+ }, id);
25
+ }
26
+
27
+ /** Puts `text` on the clipboard the way a person does: selects it on the host's page, copies. */
28
+ async function seed(page: Page, text: string): Promise<void> {
29
+ await area(page, "porthole-oracle-seed");
30
+ await page.evaluate((each) => {
31
+ (document.getElementById("porthole-oracle-seed") as HTMLTextAreaElement).value = each;
32
+ }, text);
33
+ await page.locator("#porthole-oracle-seed").click();
34
+ await page.keyboard.press("ControlOrMeta+A");
35
+ await page.keyboard.press("ControlOrMeta+C");
36
+ }
37
+
38
+ /** What is on the clipboard: pasted into a text area on the host's page. */
39
+ async function pasted(page: Page): Promise<string> {
40
+ await area(page, "porthole-oracle-paste");
41
+ await page.evaluate(() => {
42
+ (document.getElementById("porthole-oracle-paste") as HTMLTextAreaElement).value = "";
43
+ });
44
+ await page.locator("#porthole-oracle-paste").click();
45
+ await page.keyboard.press("ControlOrMeta+V");
46
+ await settle(200);
47
+ return page.evaluate(
48
+ () => (document.getElementById("porthole-oracle-paste") as HTMLTextAreaElement).value,
49
+ );
50
+ }
51
+
52
+ /** Clicks `selector` in the display (a real click: the user activation a person gives). */
53
+ async function click(display: LaunchedDisplay, selector: string): Promise<void> {
54
+ await (await display.frame()).locator(selector).click();
55
+ }
56
+
57
+ async function bodyData(display: LaunchedDisplay, name: string): Promise<string> {
58
+ const frame = await display.frame();
59
+ await expect
60
+ .poll(() => frame.locator("body").getAttribute(`data-${name}`), { timeout: 5_000 })
61
+ .not.toBeNull();
62
+ return (await frame.locator("body").getAttribute(`data-${name}`)) ?? "";
63
+ }
64
+
65
+ const settled = (name: string) =>
66
+ `(value) => (document.body.dataset.${name} = String(value)), (error) => (document.body.dataset.${name} = "refused: " + (error && error.name))`;
67
+
68
+ export const CLIPBOARD_PROBES: readonly Probe[] = [
69
+ {
70
+ id: "13.clipboard-api",
71
+ item: 13,
72
+ title:
73
+ "content can't write (or read) the clipboard through the async Clipboard API, even in a click",
74
+ async run(context) {
75
+ const display = await context.launch({
76
+ config: inline(),
77
+ content: `<button id="go">copy</button>
78
+ <script>
79
+ document.getElementById("go").addEventListener("click", () => {
80
+ navigator.clipboard.writeText("porthole-pwned-text").then(${settled("text")});
81
+ if (typeof ClipboardItem === "function") {
82
+ navigator.clipboard.write([new ClipboardItem({ "text/plain": new Blob(["porthole-pwned-item"], { type: "text/plain" }) })]).then(${settled("item")});
83
+ } else {
84
+ document.body.dataset.item = "refused: no ClipboardItem";
85
+ }
86
+ navigator.clipboard.readText().then(${settled("read")});
87
+ });
88
+ </script>`,
89
+ });
90
+ await seed(context.page, SEED);
91
+ await click(display, "#go");
92
+ expect(await bodyData(display, "text")).toMatch(/^refused/u);
93
+ expect(await bodyData(display, "item")).toMatch(/^refused/u);
94
+ expect(await bodyData(display, "read")).toMatch(/^refused/u);
95
+ expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
96
+ },
97
+ },
98
+ {
99
+ id: "13.clipboard-command",
100
+ item: 13,
101
+ title:
102
+ "content can't copy or cut its own text onto the clipboard with execCommand, even in a click",
103
+ async run(context) {
104
+ const display = await context.launch({
105
+ config: inline(),
106
+ content: `<button id="go">copy</button>
107
+ <script>
108
+ document.getElementById("go").addEventListener("click", () => {
109
+ const area = document.createElement("textarea");
110
+ area.value = "porthole-pwned-command";
111
+ area.style.cssText = "position:absolute;left:-9999px";
112
+ document.body.append(area);
113
+ area.select();
114
+ let copied = "threw";
115
+ let cut = "threw";
116
+ try { copied = document.execCommand("copy"); } catch (_) {}
117
+ area.select();
118
+ try { cut = document.execCommand("cut"); } catch (_) {}
119
+ document.body.dataset.copied = String(copied) + "," + String(cut);
120
+ });
121
+ </script>`,
122
+ });
123
+ await seed(context.page, SEED);
124
+ await click(display, "#go");
125
+ expect(await bodyData(display, "copied")).not.toContain("true");
126
+ expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
127
+ },
128
+ },
129
+ {
130
+ id: "13.clipboard-event",
131
+ item: 13,
132
+ title:
133
+ "what a person copies is what they selected: no copy listener of the content's can swap it",
134
+ async run(context) {
135
+ const display = await context.launch({
136
+ config: inline(),
137
+ content: `<p id="safe">porthole-selected-text</p>
138
+ <script>
139
+ const swap = (event) => {
140
+ event.clipboardData.setData("text/plain", "porthole-pwned-event");
141
+ event.preventDefault();
142
+ document.body.dataset.ran = "yes";
143
+ };
144
+ document.addEventListener("copy", swap);
145
+ document.addEventListener("copy", swap, true);
146
+ window.addEventListener("copy", swap);
147
+ document.getElementById("safe").oncopy = swap;
148
+ </script>`,
149
+ });
150
+ await seed(context.page, SEED);
151
+ const frame = await display.frame();
152
+ await frame.locator("#safe").click({ clickCount: 3 });
153
+ await context.page.keyboard.press("ControlOrMeta+C");
154
+ await settle(200);
155
+ // The positive control: the person's own copy works, and copies what they selected.
156
+ expect((await pasted(context.page)).trim()).toBe("porthole-selected-text");
157
+ expect(await frame.locator("body").getAttribute("data-ran")).toBeNull();
158
+ },
159
+ },
160
+ {
161
+ id: "12.copy-text",
162
+ item: 12,
163
+ title:
164
+ "copyText reaches the host's handler from a click, with the whole text, never without one",
165
+ async run(context) {
166
+ const display = await context.launch({
167
+ config: inline({ capabilities: { copyText: {} } }),
168
+ content: `<button id="copy">Copy the command</button>
169
+ <script>
170
+ document.getElementById("copy").addEventListener("click", () => {
171
+ copyText("npm test -- --run").then(${settled("result")});
172
+ });
173
+ copyText("copied on load").then(${settled("onload")});
174
+ </script>`,
175
+ });
176
+ expect(await bodyData(display, "onload")).toMatch(/^refused/u);
177
+ await click(display, "#copy");
178
+ expect(await bodyData(display, "result")).toBe("true");
179
+ expect(await display.calls()).toEqual([
180
+ { capability: "copyText", args: { text: "npm test -- --run" } },
181
+ ]);
182
+ },
183
+ },
184
+ ];
@@ -29,7 +29,7 @@ export interface Probe {
29
29
  readonly id: string;
30
30
  /**
31
31
  * The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
32
- * behavior (streaming, sizing, theming), 12 for host-function gates.
32
+ * behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard.
33
33
  */
34
34
  readonly item: number;
35
35
  readonly title: string;
@@ -13,6 +13,7 @@ import { resolveTheme } from "../theme/variables.ts";
13
13
  import type { ConformanceHost, LaunchedDisplay, LeakSuiteOptions } from "./index.ts";
14
14
  import { attack, inline, js, type Probe, type ProbeContext } from "./kit.ts";
15
15
  import { BEHAVIOR_PROBES } from "./behavior.ts";
16
+ import { CLIPBOARD_PROBES } from "./clipboard.ts";
16
17
  import { readFileSync } from "node:fs";
17
18
  import { createRequire } from "node:module";
18
19
  import {
@@ -1025,6 +1026,7 @@ export const PROBES: readonly Probe[] = [
1025
1026
  },
1026
1027
  },
1027
1028
  ...BEHAVIOR_PROBES,
1029
+ ...CLIPBOARD_PROBES,
1028
1030
  ];
1029
1031
 
1030
1032
  type AnyTest = TestType<
@@ -1,4 +1,9 @@
1
- import { capabilitySpec, type CapabilityName, type Gate } from "../config/capabilities.ts";
1
+ import {
2
+ MAX_COPY_CHARS,
3
+ capabilitySpec,
4
+ type CapabilityName,
5
+ type Gate,
6
+ } from "../config/capabilities.ts";
2
7
  import type { PortholeConfig } from "../config/schema.ts";
3
8
  import { BUILT_IN_TOPICS } from "../config/validate.ts";
4
9
  import {
@@ -115,7 +120,7 @@ const loadsScripts = (config: PortholeConfig): boolean =>
115
120
  egressOrigins(config, "script").length > 0;
116
121
 
117
122
  /** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
118
- function forbidden(config: PortholeConfig): string {
123
+ function forbidden(config: PortholeConfig, c: Context): string {
119
124
  const tokens = sandboxTokens(config);
120
125
  const items = ["position: fixed", "full screen"];
121
126
  if (!tokens.includes("allow-modals")) {
@@ -130,7 +135,11 @@ function forbidden(config: PortholeConfig): string {
130
135
  if (!tokens.includes("allow-downloads")) {
131
136
  items.push("downloads");
132
137
  }
133
- items.push("clipboard writes", "frames (iframe, object, embed)", "WebRTC");
138
+ items.push(
139
+ c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard",
140
+ "frames (iframe, object, embed)",
141
+ "WebRTC",
142
+ );
134
143
  return `Not allowed: ${items.join(", ")}.`;
135
144
  }
136
145
 
@@ -172,7 +181,7 @@ function widget(c: Context): string {
172
181
  "- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, …).",
173
182
  "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.",
174
183
  "",
175
- `${forbidden(config)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`,
184
+ `${forbidden(config, c)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`,
176
185
  loadsScripts(config)
177
186
  ? "\nLibraries: pin an exact version at least two weeks old (chart.js@4.4.1 in its URL), never @latest or a range, so the code can't be swapped under you and draws the same every time."
178
187
  : null,
@@ -337,6 +346,12 @@ const ENTRIES: { readonly [N in CapabilityName]: (c: Context, gates: readonly Ga
337
346
  `- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${gateWords(gates, "the request")}`,
338
347
  setModelContext: (_c, gates) =>
339
348
  `- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${gateWords(gates, "it")}`,
349
+ copyText: (c, gates) =>
350
+ c.global("copyText")
351
+ ? `- copyText(text): copies text to the person's clipboard (at most ${MAX_COPY_CHARS.toLocaleString("en-US")} characters), and the app shows them what was copied. Use it for every copy button: nothing else in the ${c.noun} can write the clipboard. ${gateWords(gates, "the text")} It returns a promise: true if it was copied.`
352
+ : `- Copying: the request porthole/copy-text with { text } (at most ${MAX_COPY_CHARS.toLocaleString("en-US")} characters) copies text to the person's clipboard, and the app shows them what was copied; nothing else in the ${c.noun} can write the clipboard. ${gateWords(gates, "the text")}`,
353
+ annotate: (c) =>
354
+ `- Comments: the person may point at parts of the ${c.noun} to comment on them. Give headings and the parts that matter stable ids (id="revenue"), so comments stay on them when the ${c.noun} is written again.`,
340
355
  reportError: (c, gates) =>
341
356
  `- Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${c.config.limits.maxErrorReports} a run). ${gateWords(gates, "it")}`.trim(),
342
357
  resize: () => "",
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Writing the clipboard from the host's page, for a `copyText` handler (P052): content never
3
+ * writes it itself (P051). The person's click in the frame gives the host's page user activation
4
+ * (it propagates to ancestors; measured on Chromium, WebKit and Firefox), which the write needs.
5
+ */
6
+
7
+ /**
8
+ * Writes `text` to the clipboard: the async Clipboard API, else a selected text area and the copy
9
+ * command. Resolves whether it worked; it can fail without a recent gesture, so a host shows the
10
+ * text either way (and offers its own Copy button when it failed).
11
+ */
12
+ export async function writeClipboard(text: string, document: Document): Promise<boolean> {
13
+ const view = document.defaultView;
14
+ try {
15
+ if (view?.navigator.clipboard !== undefined) {
16
+ await view.navigator.clipboard.writeText(text);
17
+ return true;
18
+ }
19
+ } catch {
20
+ // Fall through to the copy command.
21
+ }
22
+ const focused = document.activeElement;
23
+ const area = document.createElement("textarea");
24
+ area.value = text;
25
+ area.setAttribute("readonly", "");
26
+ area.setAttribute("aria-hidden", "true");
27
+ area.style.cssText =
28
+ "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;border:0;padding:0";
29
+ try {
30
+ document.body.append(area);
31
+ area.select();
32
+ return document.execCommand("copy");
33
+ } catch {
34
+ return false;
35
+ } finally {
36
+ area.remove();
37
+ if (focused !== null && typeof (focused as HTMLElement).focus === "function") {
38
+ (focused as HTMLElement).focus({ preventScroll: true });
39
+ }
40
+ }
41
+ }
@@ -1,4 +1,12 @@
1
+ import {
2
+ ANNOTATE_METHODS,
3
+ type AnnotateMode,
4
+ type AnnotationMark,
5
+ type AnnotationReport,
6
+ } from "../annotate/types.ts";
7
+ import { annotateMode, checkMarks, parseReport } from "../annotate/validate.ts";
1
8
  import { createBridge, type BridgeEvent } from "../bridge/bridge.ts";
9
+ import type { HostFunctionBridge } from "../bridge/types.ts";
2
10
  import { compile } from "../compile/compile.ts";
3
11
  import { declaredCapabilities } from "../compile/policy.ts";
4
12
  import { capabilitySpec } from "../config/capabilities.ts";
@@ -17,6 +25,7 @@ import {
17
25
  import { shellAdapter } from "../protocol/shell-adapter.ts";
18
26
  import { resolveTheme } from "../theme/variables.ts";
19
27
  import type {
28
+ AnnotationControls,
20
29
  DisplayEvents,
21
30
  DisplayHandle,
22
31
  HostOptions,
@@ -31,6 +40,17 @@ const DEFAULT_LOAD_TIMEOUT_MS = 15_000;
31
40
  const MAX_RESTARTS = 3;
32
41
 
33
42
  const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
43
+
44
+ /** The frame's annotation reports, by method. */
45
+ const REPORTS: Readonly<Record<string, AnnotationReport["type"]>> = {
46
+ [ANNOTATE_METHODS.selected]: "selected",
47
+ [ANNOTATE_METHODS.unselected]: "unselected",
48
+ [ANNOTATE_METHODS.cancelled]: "cancelled",
49
+ [ANNOTATE_METHODS.anchored]: "anchored",
50
+ };
51
+
52
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
53
+ value !== null && typeof value === "object" && !Array.isArray(value);
34
54
  const STANDARD = new Set(MCP_APPS_STYLE_VARIABLES);
35
55
 
36
56
  /**
@@ -90,6 +110,129 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
90
110
  const view = container.ownerDocument.defaultView;
91
111
  // The adapter's own operations, the last call of each, replayed into every new frame.
92
112
  const replay = new Map<string, unknown[]>();
113
+ // Annotations: the host's state, given to each frame once it finished its handshake.
114
+ const annotates = config.capabilities.annotate !== undefined;
115
+ let annotating: Required<AnnotateMode> | null = null;
116
+ let marks: AnnotationMark[] = [];
117
+ let activeMark: string | null = null;
118
+ let handshaken = false;
119
+ const toFrame = (method: string, params: Record<string, unknown>) => {
120
+ if (handshaken && surface !== null) {
121
+ surface.transport.send({ jsonrpc: "2.0", method, params });
122
+ }
123
+ };
124
+ const giveAnnotations = () => {
125
+ if (marks.length > 0) {
126
+ toFrame(ANNOTATE_METHODS.show, {
127
+ marks,
128
+ ...(activeMark === null ? {} : { active: activeMark }),
129
+ });
130
+ }
131
+ if (annotating !== null) {
132
+ toFrame(ANNOTATE_METHODS.start, { ...annotating });
133
+ }
134
+ };
135
+ /**
136
+ * A report from the frame: checked, taken only while the host asked for it (a selection
137
+ * only in select mode, results only for its own marks), then through the bridge's gates.
138
+ */
139
+ const onAnnotation = (raw: unknown, bridge: HostFunctionBridge) => {
140
+ if (
141
+ !isRecord(raw) ||
142
+ raw.jsonrpc !== "2.0" ||
143
+ "id" in raw ||
144
+ typeof raw.method !== "string"
145
+ ) {
146
+ return;
147
+ }
148
+ const type = REPORTS[raw.method];
149
+ if (type === undefined) {
150
+ return;
151
+ }
152
+ let report = parseReport({ ...(isRecord(raw.params) ? raw.params : {}), type });
153
+ if (report === null) {
154
+ void bridge.call("annotate", null);
155
+ return;
156
+ }
157
+ switch (report.type) {
158
+ case "selected": {
159
+ const kind = report.anchor.kind;
160
+ if (
161
+ annotating === null ||
162
+ !(kind === "element" ? annotating.elements : annotating.text)
163
+ ) {
164
+ return;
165
+ }
166
+ if (kind === "element") {
167
+ annotating = annotating.text ? { elements: false, text: true } : null;
168
+ }
169
+ break;
170
+ }
171
+ case "unselected":
172
+ if (annotating?.text !== true) {
173
+ return;
174
+ }
175
+ break;
176
+ case "cancelled":
177
+ if (annotating?.elements !== true) {
178
+ return;
179
+ }
180
+ annotating = annotating.text ? { elements: false, text: true } : null;
181
+ break;
182
+ case "anchored": {
183
+ const known = new Set(marks.map((mark) => mark.id));
184
+ const results = report.results.filter((result) => known.has(result.id));
185
+ if (results.length === 0) {
186
+ return;
187
+ }
188
+ report = { type: "anchored", results };
189
+ break;
190
+ }
191
+ }
192
+ void bridge.call("annotate", report);
193
+ };
194
+ const needsAnnotate = (operation: string) => {
195
+ if (!annotates) {
196
+ throw new TypeError(`annotations.${operation}: the config doesn't declare annotate`);
197
+ }
198
+ };
199
+ const annotations: AnnotationControls = {
200
+ start(mode) {
201
+ needsAnnotate("start");
202
+ annotating = annotateMode(mode);
203
+ toFrame(ANNOTATE_METHODS.start, { ...annotating });
204
+ },
205
+ stop() {
206
+ needsAnnotate("stop");
207
+ annotating = null;
208
+ toFrame(ANNOTATE_METHODS.stop, {});
209
+ },
210
+ show(next) {
211
+ needsAnnotate("show");
212
+ marks = checkMarks(next);
213
+ if (activeMark !== null && !marks.some((mark) => mark.id === activeMark)) {
214
+ activeMark = null;
215
+ }
216
+ toFrame(ANNOTATE_METHODS.show, {
217
+ marks,
218
+ ...(activeMark === null ? {} : { active: activeMark }),
219
+ });
220
+ },
221
+ focus(id) {
222
+ needsAnnotate("focus");
223
+ if (!marks.some((mark) => mark.id === id)) {
224
+ throw new TypeError(`annotations.focus: no mark ${JSON.stringify(id)} is shown`);
225
+ }
226
+ activeMark = id;
227
+ toFrame(ANNOTATE_METHODS.focus, { id });
228
+ },
229
+ clear() {
230
+ needsAnnotate("clear");
231
+ marks = [];
232
+ activeMark = null;
233
+ toFrame(ANNOTATE_METHODS.clear, {});
234
+ },
235
+ };
93
236
 
94
237
  const hostContext = (): HostContext => {
95
238
  const width = container.getBoundingClientRect().width;
@@ -142,10 +285,14 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
142
285
  case "muted":
143
286
  emit("muted", undefined);
144
287
  return;
288
+ case "annotation":
289
+ emit("annotation", event.report);
290
+ return;
145
291
  }
146
292
  };
147
293
 
148
294
  const stop = () => {
295
+ handshaken = false;
149
296
  abort?.abort();
150
297
  abort = null;
151
298
  outcomeWatch?.stop();
@@ -197,6 +344,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
197
344
  userActivation: () => created.userActivation(),
198
345
  onEvent: onBridge,
199
346
  });
347
+ if (annotates) {
348
+ created.transport.listen((raw) => onAnnotation(raw, bridge));
349
+ }
200
350
  const opened = adapter.connect({
201
351
  display: { id: mount.displayId, title: mount.title },
202
352
  compiled,
@@ -207,6 +357,10 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
207
357
  emit: (event) => {
208
358
  if (event.type === "initialized") {
209
359
  watch.initialized();
360
+ if (!handshaken) {
361
+ handshaken = true;
362
+ giveAnnotations();
363
+ }
210
364
  emit("initialized", undefined);
211
365
  }
212
366
  },
@@ -274,6 +428,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
274
428
 
275
429
  const controls = {
276
430
  displayId: mount.displayId,
431
+ annotations,
277
432
  setTheme(next: ColorScheme) {
278
433
  scheme = next;
279
434
  surface?.setColorScheme(next);
package/src/host/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
1
2
  import type { Confirm, CapabilityHandlers, Refusal } from "../bridge/types.ts";
2
3
  import type { CompiledConfig } from "../compile/compile.ts";
3
4
  import type { CapabilityName } from "../config/capabilities.ts";
@@ -100,6 +101,28 @@ export interface DisplayEvents {
100
101
  readonly refused: { readonly capability: CapabilityName; readonly refusal: Refusal };
101
102
  /** Too many declines: the display stops asking until it runs again. */
102
103
  readonly muted: undefined;
104
+ /**
105
+ * What the frame reported about annotations (`annotate`, P053), checked and taken only while
106
+ * the host asked for it: a selection, its clearing, a cancelled pick, where marks are. The
107
+ * content's word: UI hints, never permissions.
108
+ */
109
+ readonly annotation: AnnotationReport;
110
+ }
111
+
112
+ /**
113
+ * Annotations on a display whose config declares `annotate` (P053). The host's state is kept and
114
+ * given to every frame the display runs (a reload, a navigation recovered from).
115
+ */
116
+ export interface AnnotationControls {
117
+ /** Lets the person point at elements, select text, or both (default both). */
118
+ start(mode?: AnnotateMode): void;
119
+ stop(): void;
120
+ /** Marks these anchors (replacing the marks before); the frame reports where they are. */
121
+ show(marks: readonly AnnotationMark[]): void;
122
+ /** Brings a mark into view in the frame and makes it the active one. */
123
+ focus(id: string): void;
124
+ /** Removes every mark. */
125
+ clear(): void;
103
126
  }
104
127
 
105
128
  export interface DisplayControls {
@@ -115,6 +138,8 @@ export interface DisplayControls {
115
138
  event: E,
116
139
  listener: (detail: DisplayEvents[E]) => void,
117
140
  ): () => void;
141
+ /** Annotations (`annotate`): throws on use where the config doesn't declare it. */
142
+ readonly annotations: AnnotationControls;
118
143
  /** Runs the content again from the start, in a new frame. */
119
144
  reload(): void;
120
145
  /** Tears the view down and removes the frame. */
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  export {
8
8
  CAPABILITIES,
9
9
  CAPABILITY_NAMES,
10
+ MAX_COPY_CHARS,
10
11
  capabilitySpec,
11
12
  isCapabilityName,
12
13
  type CapabilityArgs,
@@ -105,6 +106,7 @@ export type {
105
106
  ShellConnection,
106
107
  } from "./protocol/adapter.ts";
107
108
  export {
109
+ COPY_TEXT_METHOD,
108
110
  MCP_APPS_METHODS,
109
111
  MCP_APPS_PROTOCOL_VERSION,
110
112
  MCP_APPS_STYLE_VARIABLES,
@@ -124,7 +126,9 @@ export type {
124
126
  Refusal,
125
127
  } from "./bridge/types.ts";
126
128
  export { createHost } from "./host/create-host.ts";
129
+ export { writeClipboard } from "./host/clipboard.ts";
127
130
  export type {
131
+ AnnotationControls,
128
132
  DisplayControls,
129
133
  DisplayEvents,
130
134
  FrameBlock,
@@ -134,3 +138,21 @@ export type {
134
138
  MountOptions,
135
139
  PortholeHost,
136
140
  } from "./host/types.ts";
141
+
142
+ // Annotations (P053): anchors, what a frame reports, and the host's checks on both.
143
+ export {
144
+ ANNOTATE_METHODS,
145
+ ANNOTATION_LIMITS,
146
+ type AnchorResult,
147
+ type AnchorState,
148
+ type AnnotateMode,
149
+ type AnnotationAnchor,
150
+ type AnnotationMark,
151
+ type AnnotationRect,
152
+ type AnnotationReport,
153
+ type AnnotationSelector,
154
+ type CssSelector,
155
+ type TextPositionSelector,
156
+ type TextQuoteSelector,
157
+ } from "./annotate/types.ts";
158
+ export { parseAnchor, parseRect, parseReport } from "./annotate/validate.ts";
@@ -14,7 +14,7 @@ import {
14
14
  MCP_APPS_PROTOCOL_VERSION,
15
15
  type HostContext,
16
16
  } from "../protocol/mcp-apps.ts";
17
- import { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
17
+ import { COPY_TEXT_METHOD, MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
18
18
 
19
19
  /**
20
20
  * The MCP Apps adapter (P004, P007): the host side of the protocol for full MCP Apps views, written
@@ -283,6 +283,15 @@ function connect(session: AdapterSession, options: McpAppsAdapterOptions): McpAp
283
283
  () => ({}),
284
284
  );
285
285
  return;
286
+ case COPY_TEXT_METHOD:
287
+ // Porthole's own request (the standard has no clipboard request): the host copies, and
288
+ // shows the person what (P052).
289
+ if (typeof params.text !== "string") {
290
+ invalid("needs text");
291
+ return;
292
+ }
293
+ await call(request, "copyText", { text: params.text }, done);
294
+ return;
286
295
  case MCP_APPS_METHODS.requestDisplayMode: {
287
296
  if (typeof params.mode !== "string" || !DISPLAY_MODES.has(params.mode)) {
288
297
  invalid("needs a display mode");
@@ -115,6 +115,12 @@ export const MCP_APPS_STYLE_VARIABLES: readonly string[] = [
115
115
  */
116
116
  export const PORTHOLE_STYLES = "porthole/styles";
117
117
 
118
+ /**
119
+ * Porthole's own request for copying text (`copyText`, P052): `{ text }`, answered `{}` when the
120
+ * host copied it and `{ isError: true }` when it didn't. The standard has no clipboard request.
121
+ */
122
+ export const COPY_TEXT_METHOD = "porthole/copy-text";
123
+
118
124
  /** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
119
125
  export const SHELL_LOGGER = "porthole";
120
126
 
@@ -8,7 +8,12 @@ import {
8
8
  type JsonRpcRequest,
9
9
  type JsonRpcResponse,
10
10
  } from "./jsonrpc.ts";
11
- import { MCP_APPS_METHODS as M, MCP_APPS_PROTOCOL_VERSION, type HostContext } from "./mcp-apps.ts";
11
+ import {
12
+ COPY_TEXT_METHOD,
13
+ MCP_APPS_METHODS as M,
14
+ MCP_APPS_PROTOCOL_VERSION,
15
+ type HostContext,
16
+ } from "./mcp-apps.ts";
12
17
 
13
18
  /**
14
19
  * The core's adapter: the host side of the MCP Apps subset the shell speaks, which content using
@@ -42,6 +47,7 @@ const REQUESTS: Readonly<
42
47
  }),
43
48
  },
44
49
  [M.openLink]: { capability: "openLink", args: (params) => ({ url: params.url }) },
50
+ [COPY_TEXT_METHOD]: { capability: "copyText", args: (params) => ({ text: params.text }) },
45
51
  [M.downloadFile]: {
46
52
  capability: "downloadFile",
47
53
  args: (params) => ({ contents: params.contents }),
@@ -63,7 +69,10 @@ function answer(
63
69
  outcome: CallOutcome<unknown>,
64
70
  ): { result: unknown } | { error: { code: number; message: string } } {
65
71
  const flagged =
66
- capability === "sendPrompt" || capability === "openLink" || capability === "downloadFile";
72
+ capability === "sendPrompt" ||
73
+ capability === "openLink" ||
74
+ capability === "downloadFile" ||
75
+ capability === "copyText";
67
76
  if (!outcome.ok) {
68
77
  return flagged
69
78
  ? { result: { isError: true } }