@runtypelabs/persona 3.21.3 → 3.23.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 (66) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -262
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -235
  6. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  7. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  8. package/dist/animations/wipe.cjs +2 -72
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/animations/wipe.js +2 -45
  12. package/dist/index.cjs +52 -45
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +474 -6
  15. package/dist/index.d.ts +474 -6
  16. package/dist/index.global.js +107 -97
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +52 -45
  19. package/dist/index.js.map +1 -1
  20. package/dist/smart-dom-reader.cjs +23 -0
  21. package/dist/smart-dom-reader.d.cts +4521 -0
  22. package/dist/smart-dom-reader.d.ts +4521 -0
  23. package/dist/smart-dom-reader.js +23 -0
  24. package/dist/testing.cjs +3 -84
  25. package/dist/testing.js +3 -55
  26. package/dist/theme-editor.cjs +57 -22501
  27. package/dist/theme-editor.d.cts +348 -1
  28. package/dist/theme-editor.d.ts +348 -1
  29. package/dist/theme-editor.js +57 -22503
  30. package/package.json +16 -6
  31. package/src/client.test.ts +165 -0
  32. package/src/client.ts +144 -23
  33. package/src/components/event-stream-view.ts +122 -1
  34. package/src/index.ts +26 -0
  35. package/src/session.test.ts +258 -0
  36. package/src/session.ts +886 -30
  37. package/src/session.webmcp.test.ts +815 -0
  38. package/src/smart-dom-reader.test.ts +135 -0
  39. package/src/smart-dom-reader.ts +135 -0
  40. package/src/theme-editor/color-utils.test.ts +59 -0
  41. package/src/theme-editor/color-utils.ts +38 -2
  42. package/src/theme-editor/index.ts +35 -0
  43. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  44. package/src/theme-editor/webmcp/coerce.ts +286 -0
  45. package/src/theme-editor/webmcp/index.ts +45 -0
  46. package/src/theme-editor/webmcp/summary.ts +324 -0
  47. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  48. package/src/theme-editor/webmcp/tools.ts +795 -0
  49. package/src/theme-editor/webmcp/types.ts +87 -0
  50. package/src/types.ts +186 -0
  51. package/src/ui.composer-keyboard.test.ts +229 -0
  52. package/src/ui.ts +151 -8
  53. package/src/utils/composer-history.test.ts +128 -0
  54. package/src/utils/composer-history.ts +113 -0
  55. package/src/utils/message-fingerprint.test.ts +20 -0
  56. package/src/utils/message-fingerprint.ts +2 -0
  57. package/src/utils/smart-dom-adapter.test.ts +257 -0
  58. package/src/utils/smart-dom-adapter.ts +217 -0
  59. package/src/utils/throughput-tracker.test.ts +366 -0
  60. package/src/utils/throughput-tracker.ts +427 -0
  61. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  62. package/src/vendor/smart-dom-reader/README.md +61 -0
  63. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  64. package/src/vendor/smart-dom-reader/index.js +1618 -0
  65. package/src/webmcp-bridge.test.ts +429 -0
  66. package/src/webmcp-bridge.ts +547 -0
@@ -0,0 +1,547 @@
1
+ /**
2
+ * WebMCP consumption bridge.
3
+ *
4
+ * Owns the per-widget lifecycle of `@mcp-b/webmcp-polyfill`:
5
+ * - installs the polyfill (lazily, only when enabled) so `document.modelContext`
6
+ * is present;
7
+ * - snapshots the host page's tool registry per dispatch turn for
8
+ * `dispatch.clientTools[]`;
9
+ * - executes `webmcp:*` tool calls returned by the agent, mediating a single
10
+ * confirm-bubble gate before invoking the page's `execute()`.
11
+ *
12
+ * Spec reference: WebMCP (https://webmachinelearning.github.io/webmcp/).
13
+ * Wire-level merging, namespace prefixing, and server-side allowlist
14
+ * enforcement live on the Runtype API; this bridge mirrors those checks
15
+ * client-side as a usability convenience, not a security boundary.
16
+ *
17
+ * About `@mcp-b/webmcp-polyfill`: it polyfills the *strict standard surface*
18
+ * only (`registerTool` / `getTools` / `executeTool` on `document.modelContext`),
19
+ * with no MCP-B-only extensions. The spec standardizes the *producer* side;
20
+ * Persona is an in-page *consumer*, so it reads the registry via the
21
+ * producer-facing preview API:
22
+ * - `getTools()` — async; returns `{ name, description, inputSchema }` where
23
+ * `inputSchema` is a JSON *string*. Annotations are not exposed here.
24
+ * - `executeTool(toolInfo, inputArgsJson, { signal })` — async; validates args
25
+ * against the tool's schema, runs `execute()`, and returns the raw result as
26
+ * a JSON *string* (or `null` for `undefined`). Honors `signal` for abort.
27
+ *
28
+ * The polyfill auto-installs `document.modelContext` at module-evaluation time,
29
+ * so it is imported *dynamically* and only when `config.webmcp.enabled === true`
30
+ * — a static import would install the global for every widget consumer,
31
+ * including those that never opted into WebMCP.
32
+ *
33
+ * Confirm model: every `webmcp:*` call goes through one confirm gate before
34
+ * `execute()` runs, regardless of `annotations.readOnlyHint`. (The polyfill owns
35
+ * the spec's `client.requestUserInteraction` callback internally; Persona cannot
36
+ * inject a nested confirm there, so the single outer gate is the whole story.)
37
+ */
38
+
39
+ import type {
40
+ AgentWidgetWebMcpConfig,
41
+ ClientToolDefinition,
42
+ WebMcpConfirmHandler,
43
+ WebMcpConfirmInfo,
44
+ WebMcpToolResult,
45
+ } from "./types";
46
+
47
+ /**
48
+ * Default per-call timeout for a WebMCP tool's `execute()`. Bounds how long
49
+ * Persona waits before telling the agent the tool failed, keeping a misbehaving
50
+ * tool from pinning the agent indefinitely. The timeout aborts the polyfill's
51
+ * `executeTool` via an `AbortSignal`, so the page's work is asked to stop too
52
+ * (cooperatively — a tool that ignores the signal may still complete).
53
+ */
54
+ const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
55
+
56
+ /** Server-applied wire prefix; strip when looking up registry entries. */
57
+ export const WEBMCP_TOOL_PREFIX = "webmcp:";
58
+
59
+ /**
60
+ * Minimal structural view of the `@mcp-b/webmcp-polyfill` strict-core surface
61
+ * that Persona consumes. We declare only what we use rather than depending on
62
+ * `@mcp-b/webmcp-types` so the widget's type surface stays self-contained.
63
+ */
64
+ interface ModelContextToolInfo {
65
+ name: string;
66
+ description: string;
67
+ /** JSON-encoded JSON Schema for the tool's input. */
68
+ inputSchema?: string;
69
+ }
70
+
71
+ interface ModelContextCoreLike {
72
+ getTools(): Promise<ModelContextToolInfo[]>;
73
+ executeTool(
74
+ tool: ModelContextToolInfo,
75
+ inputArgsJson: string,
76
+ options?: { signal?: AbortSignal },
77
+ ): Promise<string | null>;
78
+ }
79
+
80
+ const log = {
81
+ warn(message: string, ...rest: unknown[]): void {
82
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
83
+ // eslint-disable-next-line no-console
84
+ console.warn(`[Persona/WebMCP] ${message}`, ...rest);
85
+ }
86
+ },
87
+ };
88
+
89
+ export class WebMcpBridge {
90
+ private confirmHandler: WebMcpConfirmHandler | null;
91
+ private readonly timeoutMs: number;
92
+
93
+ /** `true` once the polyfill has been (idempotently) installed. */
94
+ private installed = false;
95
+ /** Memoizes the one-shot async install so concurrent callers share it. */
96
+ private readyPromise: Promise<void> | null = null;
97
+ /**
98
+ * Warn-once latch for a present-but-incompatible `document.modelContext`
99
+ * (some other / older WebMCP polyfill squatting the global). `getModelContext`
100
+ * is hit on every snapshot + execute, so we log the diagnostic only once.
101
+ */
102
+ private incompatibleContextWarned = false;
103
+
104
+ constructor(private readonly config: AgentWidgetWebMcpConfig) {
105
+ this.confirmHandler = config.onConfirm ?? null;
106
+ this.timeoutMs = DEFAULT_TOOL_TIMEOUT_MS;
107
+ }
108
+
109
+ /**
110
+ * Override the confirm handler post-construction. Used by `ui.ts` to wire
111
+ * the in-panel approval bubble after the client has been built (the widget
112
+ * lifecycle constructs the client before the panel renders).
113
+ */
114
+ public setConfirmHandler(handler: WebMcpConfirmHandler | null): void {
115
+ this.confirmHandler = handler;
116
+ }
117
+
118
+ /**
119
+ * `true` when the bridge can both snapshot the registry AND execute returned
120
+ * tool calls — i.e. the polyfill is installed and `document.modelContext`
121
+ * exposes the consumer surface (`getTools` / `executeTool`). Native browsers
122
+ * that ship `document.modelContext` satisfy this too.
123
+ *
124
+ * Synchronous and best-effort: returns `false` until the lazy install has
125
+ * resolved (see `ensureReady`). The snapshot/execute paths await readiness
126
+ * themselves, so this is purely an advisory check for callers.
127
+ */
128
+ public isOperational(): boolean {
129
+ if (this.config.enabled !== true) return false;
130
+ if (!this.installed) return false;
131
+ return this.getModelContext() !== null;
132
+ }
133
+
134
+ /**
135
+ * Per-turn snapshot for `dispatch.clientTools[]`. Returns the JSON-only
136
+ * surface — `execute` stays client-side, reached later via `executeToolCall`.
137
+ *
138
+ * Async because the strict polyfill's `getTools()` is async. Both payload
139
+ * builders in `client.ts` already `await`, so this adds no new ceremony.
140
+ */
141
+ public async snapshotForDispatch(): Promise<ClientToolDefinition[]> {
142
+ await this.ensureReady();
143
+ if (this.config.enabled !== true) return [];
144
+
145
+ const mc = this.getModelContext();
146
+ if (!mc) return [];
147
+
148
+ let infos: ModelContextToolInfo[];
149
+ try {
150
+ infos = await mc.getTools();
151
+ } catch (err) {
152
+ log.warn("getTools() threw — shipping an empty WebMCP snapshot.", err);
153
+ return [];
154
+ }
155
+
156
+ const pageOrigin = typeof location !== "undefined" ? location.origin : "";
157
+
158
+ return infos
159
+ .filter((info) => this.passesClientAllowlist(info.name))
160
+ .map<ClientToolDefinition>((info) => {
161
+ const def: ClientToolDefinition = {
162
+ name: info.name,
163
+ description: info.description,
164
+ origin: "webmcp",
165
+ ...(pageOrigin ? { pageOrigin } : {}),
166
+ };
167
+ const schema = parseSchema(info.inputSchema);
168
+ if (schema) def.parametersSchema = schema;
169
+ return def;
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Execute a `webmcp:<name>` tool call returned by the agent and return the
175
+ * normalized MCP-shaped result for `/resume`.
176
+ *
177
+ * Failure modes — all return `{ isError: true, content: [...] }` rather than
178
+ * throwing, so the dispatch can resume cleanly:
179
+ * - bridge not operational
180
+ * - tool not in registry (e.g. unmounted between snapshot and call)
181
+ * - tool excluded by the client allowlist
182
+ * - user declined the confirm gate
183
+ * - `execute()` threw or failed schema validation
184
+ * - `execute()` exceeded the 30s timeout
185
+ * - `signal` fired (session-level `cancel()`)
186
+ *
187
+ * When `signal` is provided, abort is honored at three points: before the
188
+ * confirm bubble renders, after the user approves but before `execute()`
189
+ * runs, and (via a combined `AbortController`) during `execute()` itself.
190
+ * Honoring abort BEFORE the confirm prevents a late approval after `cancel()`
191
+ * from firing a host-page side effect with no matching `/resume`.
192
+ */
193
+ public async executeToolCall(
194
+ wireToolName: string,
195
+ args: unknown,
196
+ signal?: AbortSignal,
197
+ ): Promise<WebMcpToolResult> {
198
+ await this.ensureReady();
199
+ if (this.config.enabled !== true) {
200
+ return errorResult(
201
+ "WebMCP is not enabled on this widget.",
202
+ );
203
+ }
204
+
205
+ const mc = this.getModelContext();
206
+ if (!mc) {
207
+ // Distinguish "no modelContext at all" from "present but incompatible"
208
+ // (a foreign/older polyfill squatting document.modelContext) so the
209
+ // resumed error is actionable. getModelContext has already warned once
210
+ // for the incompatible case.
211
+ const present =
212
+ typeof document !== "undefined" &&
213
+ Boolean((document as Document & { modelContext?: unknown }).modelContext);
214
+ return errorResult(
215
+ present
216
+ ? "WebMCP is not operational: document.modelContext is present but does not expose the strict getTools()/executeTool() surface (likely a different or older WebMCP polyfill)."
217
+ : "WebMCP bridge is not operational on this page (document.modelContext not available).",
218
+ );
219
+ }
220
+
221
+ const bareName = stripWebMcpPrefix(wireToolName);
222
+
223
+ let infos: ModelContextToolInfo[];
224
+ try {
225
+ infos = await mc.getTools();
226
+ } catch (err) {
227
+ const message = err instanceof Error ? err.message : String(err);
228
+ return errorResult(`Failed to read WebMCP registry: ${message}`);
229
+ }
230
+ const info = infos.find((candidate) => candidate.name === bareName);
231
+
232
+ if (!info) {
233
+ return errorResult(
234
+ `WebMCP tool not registered on this page: ${bareName}`,
235
+ );
236
+ }
237
+
238
+ // Re-apply the client-side allowlist at execute time. `snapshotForDispatch`
239
+ // already filters it for `clientTools[]`, but the agent could request a
240
+ // tool that the integrator excluded — e.g. a `webmcp:` call replayed from
241
+ // history, a server bug, or a page that re-registered a previously-hidden
242
+ // tool. The server is the trust boundary; this is a defense-in-depth
243
+ // convenience check to keep us symmetric with the snapshot.
244
+ if (!this.passesClientAllowlist(bareName)) {
245
+ return errorResult(
246
+ `WebMCP tool not allowed by client allowlist: ${bareName}`,
247
+ );
248
+ }
249
+
250
+ // Bail before the confirm renders — a late approval after cancel() would
251
+ // otherwise fire a host-page side effect with no matching /resume.
252
+ if (signal?.aborted) {
253
+ return errorResult("Aborted by cancel()");
254
+ }
255
+
256
+ // Confirm-by-default gate. Every `webmcp:*` call routes through here,
257
+ // regardless of `annotations.readOnlyHint`.
258
+ const gateInfo: WebMcpConfirmInfo = {
259
+ toolName: bareName,
260
+ args,
261
+ description: info.description,
262
+ reason: "gate",
263
+ };
264
+ if (!(await this.requestConfirm(gateInfo))) {
265
+ return errorResult("User declined the tool call.");
266
+ }
267
+
268
+ // The await above may have parked us long enough for cancel() to fire.
269
+ // Bail before invoking `execute()` so we don't fire a side effect that
270
+ // the server can no longer accept a `/resume` for.
271
+ if (signal?.aborted) {
272
+ return errorResult("Aborted by cancel()");
273
+ }
274
+
275
+ // Drive both the 30s timeout and the caller's `signal` through a single
276
+ // AbortController passed to `executeTool`. The polyfill races the page's
277
+ // `execute()` against this signal, so abort is cooperative — a tool that
278
+ // ignores the signal may still complete on the page after the agent gets
279
+ // an `isError` result. Side-effectful tools should bound their own work.
280
+ const controller = new AbortController();
281
+ let timedOut = false;
282
+ const timer = setTimeout(() => {
283
+ timedOut = true;
284
+ controller.abort();
285
+ }, this.timeoutMs);
286
+ const onAbort = () => controller.abort();
287
+ if (signal) {
288
+ if (signal.aborted) controller.abort();
289
+ else signal.addEventListener("abort", onAbort, { once: true });
290
+ }
291
+
292
+ try {
293
+ const raw = await mc.executeTool(info, safeStringifyArgs(args), {
294
+ signal: controller.signal,
295
+ });
296
+ return normalizeSerializedResult(raw);
297
+ } catch (err) {
298
+ if (timedOut) {
299
+ return errorResult(
300
+ `WebMCP tool '${bareName}' timed out after ${this.timeoutMs}ms`,
301
+ );
302
+ }
303
+ if (signal?.aborted) {
304
+ return errorResult("Aborted by cancel()");
305
+ }
306
+ const message = err instanceof Error ? err.message : String(err);
307
+ return errorResult(message);
308
+ } finally {
309
+ clearTimeout(timer);
310
+ if (signal) signal.removeEventListener("abort", onAbort);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Lazily install `@mcp-b/webmcp-polyfill` the first time the bridge needs the
316
+ * registry. Idempotent and memoized. Dynamic import keeps the polyfill out of
317
+ * the main bundle and prevents it from installing `document.modelContext` for
318
+ * widget consumers that never enable WebMCP.
319
+ *
320
+ * Producer pages should still install the polyfill themselves (or import it)
321
+ * before registering tools — Persona's install is a fallback, and a page that
322
+ * registers tools at load before Persona's first dispatch needs the global to
323
+ * already exist.
324
+ */
325
+ private ensureReady(): Promise<void> {
326
+ if (this.config.enabled !== true) return Promise.resolve();
327
+ if (!this.readyPromise) {
328
+ this.readyPromise = this.install();
329
+ }
330
+ return this.readyPromise;
331
+ }
332
+
333
+ private async install(): Promise<void> {
334
+ try {
335
+ const mod = await import("@mcp-b/webmcp-polyfill");
336
+ // Idempotent: no-ops if `document.modelContext` already exists (native or
337
+ // a prior install by the host page).
338
+ mod.initializeWebMCPPolyfill();
339
+ this.installed = true;
340
+ } catch (err) {
341
+ log.warn(
342
+ "Failed to load @mcp-b/webmcp-polyfill — WebMCP consumption disabled.",
343
+ err,
344
+ );
345
+ this.installed = false;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Read the consumer surface off `document.modelContext`, returning `null`
351
+ * when it is absent or doesn't expose the producer-preview API we rely on.
352
+ */
353
+ private getModelContext(): ModelContextCoreLike | null {
354
+ if (typeof document === "undefined") return null;
355
+ const mc = (document as Document & { modelContext?: unknown }).modelContext;
356
+ if (!mc || typeof mc !== "object") {
357
+ // Absent (not yet installed, or no WebMCP on this page) — not an error,
358
+ // and not worth warning about; the snapshot/execute paths fall back to a
359
+ // clean "not operational" result.
360
+ return null;
361
+ }
362
+ const core = mc as Partial<ModelContextCoreLike>;
363
+ if (
364
+ typeof core.getTools !== "function" ||
365
+ typeof core.executeTool !== "function"
366
+ ) {
367
+ // A `document.modelContext` IS present but doesn't expose the strict-core
368
+ // surface we consume (`getTools` / `executeTool`). This usually means a
369
+ // different or older WebMCP polyfill (or a native impl on a divergent
370
+ // draft) installed the global first — which `@mcp-b/webmcp-polyfill`
371
+ // correctly declines to overwrite. Warn once so integrators understand
372
+ // why WebMCP is inert instead of seeing a silent no-op.
373
+ if (!this.incompatibleContextWarned) {
374
+ this.incompatibleContextWarned = true;
375
+ log.warn(
376
+ "document.modelContext is present but does not expose getTools()/executeTool() — " +
377
+ "WebMCP consumption is disabled. Another (incompatible or older) WebMCP polyfill " +
378
+ "likely installed document.modelContext before Persona. Remove it, or use a polyfill " +
379
+ "implementing the strict standard surface (e.g. @mcp-b/webmcp-polyfill).",
380
+ );
381
+ }
382
+ return null;
383
+ }
384
+ return mc as ModelContextCoreLike;
385
+ }
386
+
387
+ private async requestConfirm(info: WebMcpConfirmInfo): Promise<boolean> {
388
+ const handler = this.confirmHandler ?? defaultBrowserConfirmHandler;
389
+ try {
390
+ return await handler(info);
391
+ } catch (err) {
392
+ log.warn(
393
+ `Confirm handler threw for WebMCP tool '${info.toolName}'; declining.`,
394
+ err,
395
+ );
396
+ return false;
397
+ }
398
+ }
399
+
400
+ private passesClientAllowlist(toolName: string): boolean {
401
+ const list = this.config.allowlist;
402
+ if (!list || list.length === 0) return true;
403
+ return list.some((pattern) => matchesGlob(toolName, pattern));
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Strip the server-applied `webmcp:` prefix from a wire-format tool name.
409
+ * Exported for tests; widget code should always go through the bridge.
410
+ */
411
+ export const stripWebMcpPrefix = (name: string): string =>
412
+ name.startsWith(WEBMCP_TOOL_PREFIX)
413
+ ? name.slice(WEBMCP_TOOL_PREFIX.length)
414
+ : name;
415
+
416
+ /**
417
+ * `true` when `wireToolName` carries the `webmcp:` prefix. Used by `client.ts`
418
+ * to route `step_await` events.
419
+ */
420
+ export const isWebMcpToolName = (name: string): boolean =>
421
+ name.startsWith(WEBMCP_TOOL_PREFIX);
422
+
423
+ /**
424
+ * Glob match with `*` as the only wildcard. Matches any sequence of any
425
+ * characters. Sufficient for the spec's prefix-style allowlists like
426
+ * `search_*` or `list_*`. Tool names themselves cannot contain `:`
427
+ * (see polyfill validation), so we don't need to special-case it.
428
+ */
429
+ const matchesGlob = (name: string, pattern: string): boolean => {
430
+ if (pattern === "*") return true;
431
+ // Escape regex metachars except `*`, then convert `*` to `.*`.
432
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
433
+ const regex = new RegExp("^" + escaped.replace(/\*/g, ".*") + "$");
434
+ return regex.test(name);
435
+ };
436
+
437
+ /**
438
+ * Parse the JSON-string `inputSchema` from `getTools()` back into an object for
439
+ * `parametersSchema`. Returns `undefined` for a missing or unparseable schema
440
+ * (the server can still accept a tool with no declared parameters).
441
+ */
442
+ const parseSchema = (raw: string | undefined): object | undefined => {
443
+ if (raw === undefined || raw === "") return undefined;
444
+ try {
445
+ const parsed = JSON.parse(raw);
446
+ return parsed !== null && typeof parsed === "object"
447
+ ? (parsed as object)
448
+ : undefined;
449
+ } catch {
450
+ return undefined;
451
+ }
452
+ };
453
+
454
+ /**
455
+ * Normalize the JSON-string result from `executeTool` into MCP `CallToolResult`
456
+ * shape. The polyfill returns `JSON.stringify(rawResult)` (the tool's raw
457
+ * `execute()` return, NOT pre-normalized) or `null` for an `undefined` return.
458
+ * Already-shaped returns (with `content: [...]`) pass through; everything else
459
+ * becomes a single text block. Tools that intentionally return MCP errors
460
+ * should set `isError: true` themselves.
461
+ */
462
+ const normalizeSerializedResult = (raw: string | null): WebMcpToolResult => {
463
+ if (raw === null || raw === undefined) {
464
+ return { content: [{ type: "text", text: "" }] };
465
+ }
466
+
467
+ let parsed: unknown;
468
+ try {
469
+ parsed = JSON.parse(raw);
470
+ } catch {
471
+ // Not valid JSON (shouldn't happen — the polyfill stringifies) — surface
472
+ // the raw string as text rather than dropping it.
473
+ return { content: [{ type: "text", text: raw }] };
474
+ }
475
+
476
+ if (
477
+ parsed !== null &&
478
+ typeof parsed === "object" &&
479
+ Array.isArray((parsed as { content?: unknown }).content)
480
+ ) {
481
+ return parsed as WebMcpToolResult;
482
+ }
483
+
484
+ const text = typeof parsed === "string" ? parsed : safeStringify(parsed);
485
+ return { content: [{ type: "text", text }] };
486
+ };
487
+
488
+ const errorResult = (message: string): WebMcpToolResult => ({
489
+ isError: true,
490
+ content: [{ type: "text", text: message }],
491
+ });
492
+
493
+ /**
494
+ * Fallback confirm UI: `window.confirm()`. Production deployments should wire
495
+ * `config.webmcp.onConfirm` to a handler matched to their UX (e.g. an inline
496
+ * approval bubble). Declines silently in non-browser environments (SSR, tests
497
+ * without a DOM).
498
+ */
499
+ const defaultBrowserConfirmHandler: WebMcpConfirmHandler = async (info) => {
500
+ if (typeof window === "undefined" || typeof window.confirm !== "function") {
501
+ return false;
502
+ }
503
+ const argsPreview = previewArgs(info.args);
504
+ const prompt =
505
+ `Allow the AI to call ${info.toolName}` +
506
+ (argsPreview ? `\n\nArguments:\n${argsPreview}` : "") +
507
+ (info.description ? `\n\n${info.description}` : "");
508
+ return window.confirm(prompt);
509
+ };
510
+
511
+ const previewArgs = (args: unknown): string => {
512
+ if (args === undefined || args === null) return "";
513
+ try {
514
+ const json = JSON.stringify(args, null, 2);
515
+ return json.length > 500 ? json.slice(0, 500) + "…" : json;
516
+ } catch {
517
+ return String(args);
518
+ }
519
+ };
520
+
521
+ /**
522
+ * Stringify tool args for `executeTool(toolInfo, inputArgsJson)`. Falls back to
523
+ * `{}` for `undefined`/non-serializable args so the polyfill always receives a
524
+ * valid JSON object string to validate against the tool schema.
525
+ */
526
+ const safeStringifyArgs = (args: unknown): string => {
527
+ if (args === undefined) return "{}";
528
+ try {
529
+ const json = JSON.stringify(args);
530
+ return json === undefined ? "{}" : json;
531
+ } catch {
532
+ return "{}";
533
+ }
534
+ };
535
+
536
+ /**
537
+ * `JSON.stringify` that tolerates circular references and non-serializable
538
+ * values. A misbehaving tool result shouldn't break the resume path.
539
+ */
540
+ const safeStringify = (value: unknown): string => {
541
+ if (value === undefined) return "";
542
+ try {
543
+ return JSON.stringify(value);
544
+ } catch {
545
+ return String(value);
546
+ }
547
+ };