@runtypelabs/persona 4.28.0 → 4.28.1

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 (62) hide show
  1. package/README.md +25 -0
  2. package/dist/{approval-ui-QAKWNFNM.js → approval-ui-OBBI62ES.js} +1 -1
  3. package/dist/approval-ui.cjs +2 -2
  4. package/dist/approval-ui.d.cts +7 -5
  5. package/dist/approval-ui.d.ts +7 -5
  6. package/dist/approval-ui.js +2 -2
  7. package/dist/artifacts-ui.d.cts +7 -5
  8. package/dist/artifacts-ui.d.ts +7 -5
  9. package/dist/{chunk-NQLDCJAP.js → chunk-D4TDKCT5.js} +2 -2
  10. package/dist/chunk-FNXK3VTC.js +1 -0
  11. package/dist/{client-history-LV3KHEFA.js → client-history-QGAFMCBE.js} +1 -1
  12. package/dist/client-history.js +1 -1
  13. package/dist/{client-stream-N22JHTLE.js → client-stream-WXD2FL23.js} +1 -1
  14. package/dist/client-stream.js +3 -3
  15. package/dist/codegen.cjs +4 -4
  16. package/dist/codegen.js +5 -5
  17. package/dist/context-mentions.d.cts +7 -5
  18. package/dist/context-mentions.d.ts +7 -5
  19. package/dist/event-stream-view.d.cts +7 -5
  20. package/dist/event-stream-view.d.ts +7 -5
  21. package/dist/forms-ui.d.cts +7 -5
  22. package/dist/forms-ui.d.ts +7 -5
  23. package/dist/index.cjs +40 -40
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +32 -30
  26. package/dist/index.d.ts +32 -30
  27. package/dist/index.global.js +1 -1
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +41 -41
  30. package/dist/index.js.map +1 -1
  31. package/dist/session-reconnect.d.cts +7 -5
  32. package/dist/session-reconnect.d.ts +7 -5
  33. package/dist/smart-dom-reader.d.cts +7 -5
  34. package/dist/smart-dom-reader.d.ts +7 -5
  35. package/dist/theme-editor-preview.cjs +51 -51
  36. package/dist/theme-editor-preview.d.cts +7 -5
  37. package/dist/theme-editor-preview.d.ts +7 -5
  38. package/dist/theme-editor-preview.js +4 -4
  39. package/dist/theme-editor.d.cts +8 -6
  40. package/dist/theme-editor.d.ts +8 -6
  41. package/dist/{voice-runtime-LVUMPUDS.js → voice-runtime-TWFJ4UUS.js} +1 -1
  42. package/dist/voice-runtime.cjs +1 -1
  43. package/dist/voice-runtime.d.cts +7 -5
  44. package/dist/voice-runtime.d.ts +7 -5
  45. package/dist/voice-runtime.js +1 -1
  46. package/dist/webmcp-runtime-5Q3IHAHP.js +6 -0
  47. package/dist/webmcp-runtime.cjs +2 -2
  48. package/dist/webmcp-runtime.d.cts +22 -26
  49. package/dist/webmcp-runtime.d.ts +22 -26
  50. package/dist/webmcp-runtime.js +2 -2
  51. package/package.json +5 -2
  52. package/src/generated/runtype-openapi-contract.ts +3 -0
  53. package/src/theme-editor/webmcp/types.ts +1 -1
  54. package/src/types.ts +7 -5
  55. package/src/webmcp-bridge.test.ts +87 -21
  56. package/src/webmcp-bridge.ts +21 -26
  57. package/src/webmcp-polyfill.integration.test.ts +339 -0
  58. package/src/webmcp-polyfill.ts +7 -4
  59. package/src/webmcp-runtime-bundle.test.ts +1 -1
  60. package/src/webmcp-runtime-entry.ts +34 -19
  61. package/dist/chunk-AALCT54S.js +0 -1
  62. package/dist/webmcp-runtime-V6ZWW3PI.js +0 -6
@@ -8,13 +8,13 @@ import type {
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
10
  // Mock the strict @mcp-b/webmcp-polyfill. The bridge dynamically imports it and
11
- // calls `initializeWebMCPPolyfill()` (idempotent install of document.modelContext).
11
+ // calls `initializeWebMCPPolyfill()` for the v5 automatic fallback.
12
12
  // We stub the install as a no-op and provide `document.modelContext` ourselves,
13
13
  // modeling the strict producer-preview surface the bridge consumes:
14
14
  // - getTools(): async; inputSchema in either generation (see
15
15
  // `registry.schemaShape`); annotations only when the tool declared them
16
- // - executeTool(info, argsJson, { signal }): async; validates+runs execute(),
17
- // returns JSON.stringify(rawResult) or null; honors the abort signal.
16
+ // - executeTool(info, input, { signal }): async; runs execute(),
17
+ // returns JSON.stringify(rawResult); honors the abort signal.
18
18
  // ---------------------------------------------------------------------------
19
19
 
20
20
  const polyfillMock = { initThrows: false };
@@ -39,7 +39,7 @@ import {
39
39
  } from "./webmcp-bridge";
40
40
  import type { ClientToolDefinition } from "./types";
41
41
 
42
- type MockClient = { requestUserInteraction: (cb: () => unknown) => Promise<unknown> };
42
+ type MockClient = { signal: AbortSignal };
43
43
 
44
44
  type MockTool = {
45
45
  name: string;
@@ -85,22 +85,22 @@ const makeModelContext = () => ({
85
85
  : JSON.stringify(t.inputSchema),
86
86
  }),
87
87
  title: t.title ?? "",
88
+ window,
89
+ origin: location.origin,
88
90
  ...(t.annotations === undefined ? {} : { annotations: t.annotations }),
89
91
  }));
90
92
  },
91
93
  async executeTool(
92
94
  info: { name: string },
93
- inputArgsJson: string,
95
+ input: object,
94
96
  options?: { signal?: AbortSignal },
95
97
  ): Promise<string | null> {
96
98
  if (options?.signal?.aborted) throw new Error("Tool execution was cancelled");
97
99
  const tool = registry.tools.find((t) => t.name === info.name);
98
100
  if (!tool) throw new Error(`Tool not found: ${info.name}`);
99
- const args = inputArgsJson ? JSON.parse(inputArgsJson) : {};
100
- // The polyfill owns this client; `requestUserInteraction` is a pass-through.
101
- const client: MockClient = {
102
- requestUserInteraction: async (cb) => cb(),
103
- };
101
+ if (!input || typeof input !== "object") throw new TypeError("inputObject must be an object");
102
+ const args = input as Record<string, unknown>;
103
+ const client: MockClient = { signal: options?.signal ?? new AbortController().signal };
104
104
  const execPromise = Promise.resolve(tool.execute(args, client));
105
105
  const raced = options?.signal
106
106
  ? Promise.race<unknown>([
@@ -118,7 +118,9 @@ const makeModelContext = () => ({
118
118
  ])
119
119
  : execPromise;
120
120
  const raw = await raced;
121
- return raw === undefined ? null : JSON.stringify(raw);
121
+ const json = JSON.stringify(raw);
122
+ if (json === undefined) throw new Error("Tool execution failed");
123
+ return json;
122
124
  },
123
125
  });
124
126
 
@@ -137,6 +139,7 @@ beforeEach(() => {
137
139
  polyfillMock.initThrows = false;
138
140
  registry.tools = [];
139
141
  registry.schemaShape = "object";
142
+ vi.stubGlobal("window", {});
140
143
  // location is read by snapshotForDispatch; document.modelContext is the
141
144
  // consumer surface. Both are absent in the Node test environment.
142
145
  vi.stubGlobal("location", { origin: "https://example.test" });
@@ -183,7 +186,7 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
183
186
  expect(bridge.isOperational()).toBe(false);
184
187
  });
185
188
 
186
- it("returns empty when initializeWebMCPPolyfill() throws", async () => {
189
+ it("returns empty when the fallback initializer throws", async () => {
187
190
  // No pre-existing modelContext: otherwise install() short-circuits
188
191
  // before importing the polyfill and the throwing init never runs.
189
192
  vi.stubGlobal("document", {});
@@ -331,7 +334,7 @@ describe("setWebMcpPolyfillLoader", () => {
331
334
  vi.stubGlobal("document", doc);
332
335
  registry.tools = [fakeTool({ name: "search" })];
333
336
  const loader = vi.fn(async () => ({
334
- initializeWebMCPPolyfill: () => {
337
+ installWebMCP: () => {
335
338
  doc.modelContext = makeModelContext();
336
339
  },
337
340
  }));
@@ -457,7 +460,7 @@ describe("WebMcpBridge.executeToolCall", () => {
457
460
  it("warns once and degrades cleanly when document.modelContext is present but incompatible", async () => {
458
461
  // A different / older WebMCP polyfill (or divergent native draft) squats
459
462
  // document.modelContext without the strict getTools()/executeTool() surface.
460
- // @mcp-b's initializeWebMCPPolyfill correctly declines to overwrite it, so
463
+ // @mcp-b's installWebMCP correctly declines to overwrite it, so
461
464
  // Persona must (a) report non-operational, (b) surface an actionable error
462
465
  // distinct from "not available", and (c) warn exactly once.
463
466
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
@@ -531,32 +534,31 @@ describe("WebMcpBridge.executeToolCall", () => {
531
534
  expect(r).toEqual(shaped);
532
535
  });
533
536
 
534
- it("normalizes an undefined return into an empty text block", async () => {
537
+ it("returns an error for a non-serializable undefined result", async () => {
535
538
  registry.tools = [fakeTool({ name: "act", execute: () => undefined })];
536
539
  const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
537
540
  const r = await bridge.executeToolCall("webmcp:act", {});
538
- expect(r).toEqual({ content: [{ type: "text", text: "" }] });
541
+ expect(r.isError).toBe(true);
539
542
  });
540
543
 
541
- it("runs a tool's client.requestUserInteraction callback without a second confirm", async () => {
544
+ it("passes an invocation signal with a single confirmation", async () => {
542
545
  const confirmSpy = vi.fn(async () => true);
543
546
  registry.tools = [
544
547
  fakeTool({
545
548
  name: "sensitive",
546
549
  execute: async (_args, client) => {
547
- const ack = await client.requestUserInteraction(async () => "ok!");
548
- return { ack };
550
+ return { aborted: client.signal.aborted };
549
551
  },
550
552
  }),
551
553
  ];
552
554
  const bridge = new WebMcpBridge({ enabled: true, onConfirm: confirmSpy });
553
555
  const r = await bridge.executeToolCall("webmcp:sensitive", {});
554
- // Only the single outer gate fires: the polyfill owns the in-tool callback.
556
+ // Only the single outer gate fires; the callback receives a cancellation signal.
555
557
  expect(confirmSpy).toHaveBeenCalledTimes(1);
556
558
  expect(r.isError).toBeUndefined();
557
559
  expect(r.content[0]).toEqual({
558
560
  type: "text",
559
- text: JSON.stringify({ ack: "ok!" }),
561
+ text: JSON.stringify({ aborted: false }),
560
562
  });
561
563
  });
562
564
 
@@ -745,3 +747,67 @@ describe("computeClientToolsFingerprint: diff-only / send-once", () => {
745
747
  expect(fp.length).toBeLessThanOrEqual(128);
746
748
  });
747
749
  });
750
+
751
+ describe("v6 local-document boundary", () => {
752
+ it("installs via the current lazy-module export", async () => {
753
+ const context = makeModelContext();
754
+ registry.tools = [fakeTool({ name: "local" })];
755
+ vi.stubGlobal("document", {});
756
+ setWebMcpPolyfillLoader(async () => ({
757
+ installWebMCP: () => { vi.stubGlobal("document", { modelContext: context }); },
758
+ }));
759
+ const snapshot = await new WebMcpBridge({ enabled: true }).snapshotForDispatch();
760
+ expect(snapshot.map(tool => tool.name)).toEqual(["local"]);
761
+ });
762
+
763
+ it("excludes other frames from snapshots and same-name execution", async () => {
764
+ const localWindow = {};
765
+ vi.stubGlobal("window", localWindow);
766
+ const local = { name: "shared", description: "Local", title: "Local title", origin: location.origin, window: localWindow };
767
+ const remote = { ...local, description: "Remote", title: "Remote title", window: {} };
768
+ vi.stubGlobal("document", { modelContext: {
769
+ getTools: async () => [remote, local, { ...remote, name: "remote_only" }],
770
+ executeTool: async (info: unknown) => JSON.stringify(info === local ? "local" : "remote"),
771
+ } });
772
+ const consumer = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
773
+ const snapshot = await consumer.snapshotForDispatch();
774
+ expect(snapshot.map(tool => tool.name)).toEqual(["shared"]);
775
+ expect(getWebMcpToolDisplayTitle("shared")).toBe("Local title");
776
+ expect(await consumer.executeToolCall("webmcp:shared", {})).toEqual({ content: [{ type: "text", text: "local" }] });
777
+ expect((await consumer.executeToolCall("webmcp:remote_only", {})).isError).toBe(true);
778
+ });
779
+ });
780
+
781
+ describe("polyfill initializer compatibility", () => {
782
+ it("accepts the legacy initializer used by the automatic fallback", async () => {
783
+ vi.stubGlobal("document", {});
784
+ const initializeWebMCPPolyfill = vi.fn(() => {
785
+ vi.stubGlobal("document", { modelContext: makeModelContext() });
786
+ });
787
+ setWebMcpPolyfillLoader(async () => ({ initializeWebMCPPolyfill }));
788
+ const bridge = new WebMcpBridge({ enabled: true });
789
+ await bridge.snapshotForDispatch();
790
+ expect(initializeWebMCPPolyfill).toHaveBeenCalledTimes(1);
791
+ expect(bridge.isOperational()).toBe(true);
792
+ });
793
+ });
794
+
795
+ describe("native preview argument conversion", () => {
796
+ it("supports DOMString coercion without relying on polyfill metadata", async () => {
797
+ const nativeExecute = vi.fn(async (_tool: unknown, input: unknown) => {
798
+ // WebIDL converts an object to DOMString before the native JSON parser.
799
+ return JSON.stringify({ received: JSON.parse(String(input)) });
800
+ });
801
+ vi.stubGlobal("document", {
802
+ modelContext: {
803
+ getTools: async () => [{ name: "native", description: "Native preview tool" }],
804
+ executeTool: nativeExecute,
805
+ },
806
+ });
807
+ const consumer = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
808
+ expect(await consumer.executeToolCall("webmcp:native", { count: 2 })).toEqual({
809
+ content: [{ type: "text", text: '{"received":{"count":2}}' }],
810
+ });
811
+ expect(nativeExecute).toHaveBeenCalledTimes(1);
812
+ });
813
+ });
@@ -14,26 +14,19 @@
14
14
  * enforcement live on the Runtype API; this bridge mirrors those checks
15
15
  * client-side as a usability convenience, not a security boundary.
16
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.
17
+ * Supports v5's JSON-string input and v6's object input with a single call:
18
+ * the runtime passes a JSON object that also string-coerces to JSON text.
19
+ * Never infer the execution contract from metadata or retry a failed call.
20
+ * Only tools owned by this document are exposed: the wire addresses tools by
21
+ * name, not by frame. Callbacks must validate their own input.
27
22
  *
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.
23
+ * The automatic fallback stays on v5 to preserve its browser requirements and
24
+ * result semantics. Host pages may install v5 or v6 themselves; Persona does
25
+ * not replace an existing context. v5's initializeWebMCPPolyfill() and v6's
26
+ * installWebMCP() are both accepted by custom loaders.
32
27
  *
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.)
28
+ * Every call passes one confirm gate before execution, regardless of
29
+ * annotations.readOnlyHint. v6 callbacks also receive an invocation signal.
37
30
  */
38
31
 
39
32
  import type { ClientToolDefinition } from "./types";
@@ -59,18 +52,20 @@ export interface ModelContextToolInfo {
59
52
  * object. `@mcp-b/webmcp-polyfill` made the switch in v5, and Chrome ships it
60
53
  * from 154.0.8013 (cross-document tools first) -- but Chrome 149-153, most of
61
54
  * the Origin Trial population, and 154's same-document tools still emit the
62
- * string. Both are live in the field, so we accept both.
55
+ * string. Both input execution contracts are supported independently of
56
+ * schema representation.
63
57
  *
64
58
  * Absent when the tool registered no schema: v5 omits the member, where v4
65
59
  * always emitted a `{"type":"object"}` string.
66
60
  */
67
61
  inputSchema?: object | string;
62
+ /** Owning frame and origin, when supplied by the registry. Never sent on the wire. */
63
+ window?: Window;
64
+ origin?: string;
68
65
  /**
69
66
  * Display title declared on the tool (`ToolDescriptor.title` in the WebMCP
70
67
  * spec). The polyfill returns `""` when the tool didn't declare one. Note:
71
- * `annotations` (incl. the legacy `annotations.title`) are NOT exposed on
72
- * this strict consumer surface: top-level `title` is the only display-name
73
- * channel available to us.
68
+ * top-level `title` is the display-name channel, not `annotations.title`.
74
69
  */
75
70
  title?: string;
76
71
  /**
@@ -89,7 +84,7 @@ export interface ModelContextCoreLike {
89
84
  getTools(): Promise<ModelContextToolInfo[]>;
90
85
  executeTool(
91
86
  tool: ModelContextToolInfo,
92
- inputArgsJson: string,
87
+ input: object | string,
93
88
  options?: { signal?: AbortSignal },
94
89
  ): Promise<string | null>;
95
90
  }
@@ -132,9 +127,9 @@ export const getWebMcpToolDisplayTitle = (
132
127
  ): string | undefined => webMcpToolDisplayTitles.get(stripWebMcpPrefix(toolName));
133
128
 
134
129
  /** The slice of `@mcp-b/webmcp-polyfill` the bridge consumes on install. */
135
- export type WebMcpPolyfillModule = {
136
- initializeWebMCPPolyfill: () => void;
137
- };
130
+ export type WebMcpPolyfillModule =
131
+ | { initializeWebMCPPolyfill: () => void }
132
+ | { installWebMCP: () => void };
138
133
 
139
134
  /**
140
135
  * Override how the polyfill module is obtained. By default the bridge does
@@ -0,0 +1,339 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ describe,
6
+ expect,
7
+ it,
8
+ vi,
9
+ } from "vitest";
10
+ import { JSDOM } from "jsdom";
11
+ import { MessageChannel } from "node:worker_threads";
12
+ import { readFileSync } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { WebMcpBridge } from "./webmcp-bridge-public";
15
+ import { setWebMcpPolyfillLoader } from "./webmcp-bridge";
16
+ import type { ModelContextCoreLike } from "./webmcp-bridge";
17
+
18
+ type ToolCallback = (
19
+ input: unknown,
20
+ options?: { signal: AbortSignal }
21
+ ) => unknown;
22
+ type TestContext = ModelContextCoreLike & {
23
+ registerTool(
24
+ tool: {
25
+ name: string;
26
+ description: string;
27
+ title?: string;
28
+ inputSchema?: object;
29
+ annotations?: { readOnlyHint: boolean; untrustedContentHint: boolean };
30
+ execute: ToolCallback;
31
+ },
32
+ options?: { signal: AbortSignal }
33
+ ): Promise<void>;
34
+ };
35
+
36
+ // Run the actual published distributions, not version-shaped mocks. The v6
37
+ // alias is test-only: Persona's automatic fallback deliberately stays on v5.
38
+ describe.each([
39
+ { version: "v5", module: "@mcp-b/webmcp-polyfill", legacy: true },
40
+ { version: "v6", module: "webmcp-polyfill-v6", legacy: false },
41
+ ])("WebMcpBridge with published $version", ({ module, legacy }) => {
42
+ const dom = new JSDOM("<!doctype html><body></body>", {
43
+ url: "https://example.test",
44
+ runScripts: "outside-only",
45
+ });
46
+ const browser = dom.window;
47
+ const channels: MessageChannel[] = [];
48
+ const registrations: AbortController[] = [];
49
+ let context: TestContext;
50
+
51
+ beforeAll(() => {
52
+ // v5 also runs without the newer browser APIs required by v6. These are
53
+ // intentionally absent to exercise the compatibility of our fallback.
54
+ Object.defineProperty(browser, "isSecureContext", { value: true });
55
+ if (legacy) {
56
+ Object.defineProperty(browser.Promise, "withResolvers", {
57
+ value: undefined,
58
+ });
59
+ Object.defineProperty(browser.AbortSignal, "any", { value: undefined });
60
+ Object.defineProperty(browser.URL, "parse", { value: undefined });
61
+ }
62
+ Object.defineProperty(browser, "MessageChannel", {
63
+ value: class extends MessageChannel {
64
+ constructor() {
65
+ super();
66
+ channels.push(this);
67
+ }
68
+ },
69
+ });
70
+ vi.stubGlobal("AbortController", browser.AbortController);
71
+ vi.stubGlobal("AbortSignal", browser.AbortSignal);
72
+ vi.stubGlobal("window", browser);
73
+ vi.stubGlobal("document", browser.document);
74
+ vi.stubGlobal("location", browser.location);
75
+ // A true Window realm is required by v6's native Window.window getter.
76
+ browser.eval(
77
+ readFileSync(
78
+ createRequire(import.meta.url).resolve(`${module}/iife`),
79
+ "utf8"
80
+ )
81
+ );
82
+ context = (browser.document as unknown as { modelContext: TestContext })
83
+ .modelContext;
84
+ });
85
+ afterEach(() => {
86
+ for (const registration of registrations.splice(0)) registration.abort();
87
+ setWebMcpPolyfillLoader(null);
88
+ });
89
+ afterAll(() => {
90
+ for (const channel of channels) {
91
+ channel.port1.close();
92
+ channel.port2.close();
93
+ }
94
+ browser.close();
95
+ vi.unstubAllGlobals();
96
+ });
97
+
98
+ const register = async (name: string, execute: ToolCallback) => {
99
+ const registration = new browser.AbortController();
100
+ registrations.push(registration);
101
+ await context.registerTool(
102
+ {
103
+ name,
104
+ description: `Run ${name}`,
105
+ title: "Local tool",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: { query: { type: "string" } },
109
+ },
110
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
111
+ execute,
112
+ },
113
+ { signal: registration.signal }
114
+ );
115
+ };
116
+ const bridge = (onConfirm = vi.fn(async () => true)) =>
117
+ new WebMcpBridge({ enabled: true, onConfirm });
118
+
119
+ it("discovers tools without replacing the host registry or loading a fallback", async () => {
120
+ const load = vi.fn(async () => {
121
+ throw new Error("must preserve the host registry");
122
+ });
123
+ setWebMcpPolyfillLoader(load);
124
+ await register("lookup", (input) => ({ received: input }));
125
+ expect(await bridge().snapshotForDispatch()).toEqual([
126
+ expect.objectContaining({
127
+ name: "lookup",
128
+ parametersSchema: {
129
+ type: "object",
130
+ properties: { query: { type: "string" } },
131
+ },
132
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
133
+ untrustedContentHint: true,
134
+ }),
135
+ ]);
136
+ expect(load).not.toHaveBeenCalled();
137
+ expect(
138
+ (browser.document as unknown as { modelContext: TestContext })
139
+ .modelContext
140
+ ).toBe(context);
141
+ });
142
+
143
+ it.each(
144
+ [
145
+ { query: "shoes" },
146
+ { nested: { text: 'quotes " and emoji 😀' }, values: [true, null, 2] },
147
+ { toString: "an ordinary JSON property", valueOf: 7, __proto__: null },
148
+ ["array", { nested: true }],
149
+ ].map((args) => ({ args }))
150
+ )(
151
+ "executes JSON arguments once without mutating them: $args",
152
+ async ({ args }) => {
153
+ const execute = vi.fn((input: unknown) => ({ received: input }));
154
+ const confirm = vi.fn(async () => true);
155
+ const before = JSON.stringify(args);
156
+ await register("lookup", execute);
157
+ const result = await bridge(confirm).executeToolCall(
158
+ "webmcp:lookup",
159
+ args
160
+ );
161
+ expect(result).toEqual({
162
+ content: [{ type: "text", text: JSON.stringify({ received: args }) }],
163
+ });
164
+ expect(execute).toHaveBeenCalledTimes(1);
165
+ expect(execute.mock.calls[0]![0]).toEqual(JSON.parse(before));
166
+ expect(confirm).toHaveBeenCalledTimes(1);
167
+ expect(JSON.stringify(args)).toBe(before);
168
+ expect(Object.getOwnPropertySymbols(args)).toEqual([]);
169
+ }
170
+ );
171
+
172
+ it.each([undefined, () => "not JSON"])(
173
+ "defaults absent/non-JSON arguments to an empty object",
174
+ async (args) => {
175
+ const execute = vi.fn((input: unknown) => input);
176
+ await register("empty", execute);
177
+ expect(await bridge().executeToolCall("webmcp:empty", args)).toEqual({
178
+ content: [{ type: "text", text: "{}" }],
179
+ });
180
+ expect(execute).toHaveBeenCalledTimes(1);
181
+ }
182
+ );
183
+
184
+ it("keeps the legacy empty-object fallback for circular arguments", async () => {
185
+ const args: { self?: unknown } = {};
186
+ args.self = args;
187
+ await register("empty", (input) => input);
188
+ expect(await bridge().executeToolCall("webmcp:empty", args)).toEqual({
189
+ content: [{ type: "text", text: "{}" }],
190
+ });
191
+ });
192
+
193
+ it.each([
194
+ ["string", "hello", "hello"],
195
+ ["null", null, "null"],
196
+ [
197
+ "envelope",
198
+ { isError: true, content: [{ type: "text", text: "invalid input" }] },
199
+ null,
200
+ ],
201
+ ])("preserves a %s result", async (_label, result, text) => {
202
+ await register("result", () => result);
203
+ expect(await bridge().executeToolCall("webmcp:result", {})).toEqual(
204
+ text === null ? result : { content: [{ type: "text", text }] }
205
+ );
206
+ });
207
+
208
+ const circularResult: { self?: unknown } = {};
209
+ circularResult.self = circularResult;
210
+ it.each([
211
+ { label: "undefined", result: undefined },
212
+ { label: "circular object", result: circularResult },
213
+ { label: "function", result: () => "function result" },
214
+ { label: "BigInt", result: 1n },
215
+ ])(
216
+ "preserves the installed runtime's $label-result semantics without retries",
217
+ async ({ result }) => {
218
+ const execute = vi.fn(() => result);
219
+ await register("unserializable_result", execute);
220
+ const output = await bridge().executeToolCall(
221
+ "webmcp:unserializable_result",
222
+ {}
223
+ );
224
+ if (legacy) {
225
+ expect(output).toEqual({
226
+ content: [{ type: "text", text: String(result) }],
227
+ });
228
+ } else {
229
+ expect(output.isError).toBe(true);
230
+ }
231
+ expect(execute).toHaveBeenCalledTimes(1);
232
+ }
233
+ );
234
+
235
+ it("preserves the installed runtime's empty-string result", async () => {
236
+ await register("empty_result", () => "");
237
+ expect(await bridge().executeToolCall("webmcp:empty_result", {})).toEqual({
238
+ content: [{ type: "text", text: legacy ? "Operation succeeded" : "" }],
239
+ });
240
+ });
241
+
242
+ it("returns callback failures without retrying a side effect", async () => {
243
+ const execute = vi.fn(() => {
244
+ throw new Error("failure after side effect");
245
+ });
246
+ await register("failed", execute);
247
+ expect((await bridge().executeToolCall("webmcp:failed", {})).isError).toBe(
248
+ true
249
+ );
250
+ expect(execute).toHaveBeenCalledTimes(1);
251
+ });
252
+
253
+ it("does not execute when approval is denied", async () => {
254
+ const execute = vi.fn(() => "unexpected");
255
+ await register("denied", execute);
256
+ expect(
257
+ (
258
+ await bridge(vi.fn(async () => false)).executeToolCall(
259
+ "webmcp:denied",
260
+ {}
261
+ )
262
+ ).isError
263
+ ).toBe(true);
264
+ expect(execute).not.toHaveBeenCalled();
265
+ });
266
+
267
+ it("cancels the invocation and forwards the signal when the runtime supports it", async () => {
268
+ let started!: () => void;
269
+ const ready = new Promise<void>((resolve) => {
270
+ started = resolve;
271
+ });
272
+ let callbackSignal: AbortSignal | undefined;
273
+ const execute = vi.fn(
274
+ (_input: unknown, options?: { signal: AbortSignal }) => {
275
+ callbackSignal = options?.signal;
276
+ started();
277
+ return new Promise(() => {});
278
+ }
279
+ );
280
+ await register("wait", execute);
281
+ const controller = new AbortController();
282
+ const pending = bridge().executeToolCall(
283
+ "webmcp:wait",
284
+ {},
285
+ controller.signal
286
+ );
287
+ await ready;
288
+ controller.abort();
289
+ expect((await pending).isError).toBe(true);
290
+ expect(execute).toHaveBeenCalledTimes(1);
291
+ if (!legacy)
292
+ await vi.waitFor(() => expect(callbackSignal?.aborted).toBe(true));
293
+ });
294
+
295
+ it("does not execute a removed registration", async () => {
296
+ const execute = vi.fn(() => "unexpected");
297
+ await register("removed", execute);
298
+ registrations.at(-1)!.abort();
299
+ expect((await bridge().executeToolCall("webmcp:removed", {})).isError).toBe(
300
+ true
301
+ );
302
+ expect(execute).not.toHaveBeenCalled();
303
+ });
304
+
305
+ it.runIf(legacy)(
306
+ "lazily installs the real v5 fallback without v6 browser APIs",
307
+ async () => {
308
+ const runtime = (
309
+ browser as unknown as {
310
+ WebMCPPolyfill: {
311
+ cleanupWebMCPPolyfill(): void;
312
+ initializeWebMCPPolyfill(): void;
313
+ };
314
+ }
315
+ ).WebMCPPolyfill;
316
+ runtime.cleanupWebMCPPolyfill();
317
+ expect(
318
+ (browser.document as unknown as { modelContext?: unknown }).modelContext
319
+ ).toBeUndefined();
320
+ expect(Reflect.get(browser.Promise, "withResolvers")).toBeUndefined();
321
+ expect(browser.AbortSignal.any).toBeUndefined();
322
+ expect(browser.URL.parse).toBeUndefined();
323
+ const load = vi.fn(async () => runtime);
324
+ setWebMcpPolyfillLoader(load);
325
+ const consumer = bridge();
326
+ expect(await consumer.snapshotForDispatch()).toEqual([]);
327
+ context = (browser.document as unknown as { modelContext: TestContext })
328
+ .modelContext;
329
+ await register("legacy_fallback", () => undefined);
330
+ expect(
331
+ await consumer.executeToolCall("webmcp:legacy_fallback", {})
332
+ ).toEqual({
333
+ content: [{ type: "text", text: "undefined" }],
334
+ });
335
+ expect(load).toHaveBeenCalledTimes(1);
336
+ expect(consumer.isOperational()).toBe(true);
337
+ }
338
+ );
339
+ });
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Standalone WebMCP polyfill chunk (`dist/webmcp-polyfill.js`).
2
+ * Standalone WebMCP v5 fallback chunk (`dist/webmcp-polyfill.js`).
3
+ * Keep this fallback on v5 so upgrades do not change existing consumers'
4
+ * browser requirements or callback-result semantics. Host-installed v6 is
5
+ * supported separately by the bridge's dual-contract argument adapter.
3
6
  *
4
7
  * The IIFE/CDN widget bundle marks `@mcp-b/webmcp-polyfill` external so the
5
- * ~35 kB (minified) polyfill + its transitive `@cfworker/json-schema` never
6
- * ship to consumers who don't enable WebMCP. When `config.webmcp.enabled` is
7
- * true and no `document.modelContext` exists yet, the bridge dynamically
8
+ * polyfill never ships to consumers who don't enable WebMCP. When
9
+ * `config.webmcp.enabled` is true and no `document.modelContext` exists yet,
10
+ * the bridge dynamically
8
11
  * imports this chunk from a URL derived from the widget script's own `src`
9
12
  * (see the loader registered in `index-global.ts`).
10
13
  *
@@ -48,7 +48,7 @@ describe("webmcp-runtime bundle split", () => {
48
48
  }
49
49
  // No polyfill inlined: `registerTool` / `cfworker` only exist inside
50
50
  // @mcp-b/webmcp-polyfill (the class merely CALLS the injected module's
51
- // initializeWebMCPPolyfill, so that name legitimately appears here).
51
+ // initializer, so both initializer names legitimately appear here).
52
52
  expect(chunk.includes("registerTool")).toBe(false);
53
53
  expect(chunk.includes("cfworker")).toBe(false);
54
54
  // A CJS twin exists for require()-based consumers.