@hyperframes/studio 0.8.34 → 0.8.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.34",
3
+ "version": "0.8.35",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.34",
52
- "@hyperframes/parsers": "0.8.34",
53
- "@hyperframes/player": "0.8.34",
54
- "@hyperframes/sdk": "0.8.34",
55
- "@hyperframes/studio-server": "0.8.34"
51
+ "@hyperframes/core": "0.8.35",
52
+ "@hyperframes/parsers": "0.8.35",
53
+ "@hyperframes/player": "0.8.35",
54
+ "@hyperframes/studio-server": "0.8.35",
55
+ "@hyperframes/sdk": "0.8.35"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^4.1.11",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.34"
72
+ "@hyperframes/producer": "0.8.35"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
@@ -57,8 +57,12 @@ export interface ModelContextTool {
57
57
  * A rejection is NOT a usable error channel: the spec discards the reason and
58
58
  * rejects the caller with a bare UnknownError. Resolve with a tagged failure
59
59
  * instead. See `toolResult.ts`.
60
+ *
61
+ * `options` is optional here although the spec always passes it: the
62
+ * `@mcp-b/global` polyfill (through 5.1.0) calls `execute(input)` with no
63
+ * second argument, so a Studio handler must not depend on receiving one.
60
64
  */
61
- execute: (input: object, options: ToolExecuteCallbackOptions) => Promise<unknown>;
65
+ execute: (input: object, options?: ToolExecuteCallbackOptions) => Promise<unknown>;
62
66
  annotations?: ModelContextToolAnnotations;
63
67
  }
64
68
 
@@ -0,0 +1,219 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Drives the write tools through the REAL `@mcp-b/global` package rather than
4
+ * a fake `document.modelContext`.
5
+ *
6
+ * The package invokes a registered `execute` with the input alone, on both of
7
+ * its paths: the in-page `BrowserMcpServer` wrapper that agents reach through
8
+ * the registry and `executeTool`, and the descriptor it mirrors into a native
9
+ * `document.modelContext` when the browser ships one. A Studio handler that
10
+ * destructured `{ signal }` from the missing second argument threw before it
11
+ * ran, so every write tool failed with "Cannot destructure property 'signal'
12
+ * of 'undefined'" while the read tools kept working. This file proves the fix
13
+ * against the code that ships in the polyfill chunk. The spec-shaped call and
14
+ * the abort path live in `useStudioAgentTools.test.tsx`; the polyfill cannot
15
+ * exercise them because it drops the caller's signal before the handler.
16
+ *
17
+ * A native-looking `modelContext` is installed BEFORE the import so the bridge
18
+ * wraps it and mirrors every registration into it, which is how both paths get
19
+ * covered from one setup. Its own file because the import is a one-shot side
20
+ * effect that the sibling suite's fake would otherwise have to fight.
21
+ */
22
+ import { act } from "react";
23
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
24
+ import { mountReactHarness } from "../hooks/domSelectionTestHarness";
25
+ import { mintElementHandle } from "./handles";
26
+ import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
27
+ import { previewDoc, selectionFor, studioAgentToolsDeps } from "./webmcpTestUtils";
28
+
29
+ vi.mock("../telemetry/client", () => ({ trackEvent: vi.fn() }));
30
+
31
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
32
+
33
+ /** What a browser's own `registerTool` receives from the bridge. */
34
+ interface MirroredTool {
35
+ name: string;
36
+ description: string;
37
+ execute: (input: object, options?: { signal?: AbortSignal }) => Promise<unknown>;
38
+ }
39
+
40
+ /** What `getTools()` returns and `executeTool()` accepts. */
41
+ interface RegisteredTool {
42
+ name: string;
43
+ description: string;
44
+ window: Window;
45
+ origin: string;
46
+ }
47
+
48
+ /** The bridge's registry entry, as the reporter of the bug drove it. */
49
+ interface BridgeToolEntry {
50
+ item: { name: string };
51
+ execute: (input: object, signal?: AbortSignal) => Promise<unknown>;
52
+ }
53
+
54
+ interface BridgeModelContext {
55
+ tools: Map<string, BridgeToolEntry>;
56
+ getTools(): Promise<RegisteredTool[]>;
57
+ executeTool(
58
+ tool: RegisteredTool,
59
+ inputArguments: string,
60
+ options?: { signal?: AbortSignal },
61
+ ): Promise<string | null>;
62
+ }
63
+
64
+ const mirrored: MirroredTool[] = [];
65
+ /** Enough of a browser's `modelContext` for the bridge to wrap and mirror into. */
66
+ const fakeNative = {
67
+ registerTool: async (tool: MirroredTool): Promise<void> => {
68
+ mirrored.push(tool);
69
+ },
70
+ // The bridge delegates `getTools()` to the native context when there is one,
71
+ // and its own `executeTool` then insists on a full `RegisteredTool`.
72
+ getTools: async (): Promise<RegisteredTool[]> =>
73
+ mirrored.map((tool) => ({
74
+ name: tool.name,
75
+ description: tool.description,
76
+ window,
77
+ origin: window.location.origin,
78
+ })),
79
+ };
80
+
81
+ let cleanup: (() => void) | null = null;
82
+
83
+ beforeAll(async () => {
84
+ // Configurable, so the bridge is allowed to replace it with itself.
85
+ Object.defineProperty(document, "modelContext", {
86
+ value: fakeNative,
87
+ configurable: true,
88
+ writable: true,
89
+ });
90
+ await import("@mcp-b/global");
91
+ expect(Reflect.get(document, "modelContext")).not.toBe(fakeNative);
92
+ });
93
+
94
+ afterEach(async () => {
95
+ cleanup?.();
96
+ cleanup = null;
97
+ mirrored.length = 0;
98
+ // The polyfill watches the document with a MutationObserver that reads jsdom
99
+ // globals. Empty the document and let the observer settle now, while those
100
+ // globals still exist, instead of when vitest closes the window.
101
+ document.body.replaceChildren();
102
+ await new Promise((resolve) => setTimeout(resolve, 0));
103
+ window.localStorage.clear();
104
+ });
105
+
106
+ function bridge(): BridgeModelContext {
107
+ // Through `unknown`: importing the package brings its own `Document.modelContext`
108
+ // typing into scope, and this file reads the bridge's non-standard surface.
109
+ return Reflect.get(document, "modelContext") as unknown as BridgeModelContext;
110
+ }
111
+
112
+ /** Registration is async, so wait for the whole tool set to land. */
113
+ async function waitForTools(count: number): Promise<void> {
114
+ const deadline = Date.now() + 5_000;
115
+ while (bridge().tools.size !== count || mirrored.length !== count) {
116
+ if (Date.now() > deadline) {
117
+ throw new Error(
118
+ `expected ${count} tools, saw ${bridge().tools.size} in the bridge and ${mirrored.length} mirrored`,
119
+ );
120
+ }
121
+ await new Promise((resolve) => setTimeout(resolve, 10));
122
+ }
123
+ }
124
+
125
+ function bridgeEntry(name: string): BridgeToolEntry {
126
+ const entry = [...bridge().tools.values()].find((candidate) => candidate.item.name === name);
127
+ if (!entry) throw new Error(`expected ${name} in the bridge registry`);
128
+ return entry;
129
+ }
130
+
131
+ function mirroredTool(name: string): MirroredTool {
132
+ const tool = mirrored.find((candidate) => candidate.name === name);
133
+ if (!tool) throw new Error(`expected ${name} to be mirrored into the native context`);
134
+ return tool;
135
+ }
136
+
137
+ /** Mount Studio's tools against a preview whose `#agent` element can be edited. */
138
+ async function mountEditableAgent() {
139
+ const doc = previewDoc('<h1 id="agent">Agent</h1>');
140
+ const agent = doc.getElementById("agent") as HTMLElement;
141
+ const handle = mintElementHandle({
142
+ projectId: "demo",
143
+ domId: "agent",
144
+ sourceFile: "index.html",
145
+ activeCompositionPath: "index.html",
146
+ });
147
+ if (!handle) throw new Error("expected agent handle");
148
+ const setText = vi.fn(
149
+ async () =>
150
+ ({
151
+ ok: true,
152
+ persistence: { sourceFile: "index.html", version: '"sha256:after"', changed: true },
153
+ }) as const,
154
+ );
155
+ const deps: StudioAgentToolsDeps = studioAgentToolsDeps({
156
+ getPreviewDocument: () => doc,
157
+ buildSelection: async (element) => selectionFor(element),
158
+ setText,
159
+ });
160
+
161
+ function Probe() {
162
+ useStudioAgentTools(deps);
163
+ return null;
164
+ }
165
+ await act(async () => {
166
+ const root = mountReactHarness(<Probe />);
167
+ cleanup = () => act(() => root.unmount());
168
+ });
169
+ await waitForTools(12);
170
+ return { agent, handle, setText };
171
+ }
172
+
173
+ describe("useStudioAgentTools through @mcp-b/global", () => {
174
+ it("saves through studio_set_text when the in-page server calls execute with the input alone", async () => {
175
+ const { agent, handle, setText } = await mountEditableAgent();
176
+
177
+ // Registry entry, as `[...document.modelContext.tools.values()]` exposes it.
178
+ const viaEntry = await bridgeEntry("studio_set_text").execute(
179
+ { handle, text: "Through the registry" },
180
+ new AbortController().signal,
181
+ );
182
+ expect(viaEntry).toMatchObject({ ok: true, stage: "saved", changed: true });
183
+
184
+ // Chromium's `executeTool` extension, which the bridge also implements.
185
+ const descriptor = (await bridge().getTools()).find((tool) => tool.name === "studio_set_text");
186
+ if (!descriptor) throw new Error("expected studio_set_text in getTools()");
187
+ const viaExecuteTool = await bridge().executeTool(
188
+ descriptor,
189
+ JSON.stringify({ handle, text: "Through executeTool" }),
190
+ { signal: new AbortController().signal },
191
+ );
192
+ expect(JSON.parse(viaExecuteTool ?? "null")).toMatchObject({ ok: true, stage: "saved" });
193
+
194
+ expect(setText).toHaveBeenCalledTimes(2);
195
+ expect(setText).toHaveBeenCalledWith(
196
+ expect.objectContaining({ element: agent }),
197
+ "Through the registry",
198
+ "self",
199
+ );
200
+ });
201
+
202
+ it("saves through the descriptor the bridge mirrors into a native modelContext", async () => {
203
+ const { agent, handle, setText } = await mountEditableAgent();
204
+
205
+ // The browser would call this with `(input, { signal })`; the mirror drops
206
+ // the options before Studio's handler sees them.
207
+ const result = await mirroredTool("studio_set_text").execute(
208
+ { handle, text: "Through the native mirror" },
209
+ { signal: new AbortController().signal },
210
+ );
211
+
212
+ expect(result).toMatchObject({ ok: true, stage: "saved", changed: true });
213
+ expect(setText).toHaveBeenCalledWith(
214
+ expect.objectContaining({ element: agent }),
215
+ "Through the native mirror",
216
+ "self",
217
+ );
218
+ });
219
+ });
@@ -6,8 +6,7 @@ import { writeStudioUiPreferences } from "../utils/studioUiPreferences";
6
6
  import { mintElementHandle } from "./handles";
7
7
  import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
8
8
  import type { ModelContext, ModelContextRegisterToolOptions, ModelContextTool } from "./types";
9
- import type { StudioLookSnapshot } from "./tools/lookTools";
10
- import { previewDoc, selectionFor } from "./webmcpTestUtils";
9
+ import { lookSnapshot, previewDoc, selectionFor, studioAgentToolsDeps } from "./webmcpTestUtils";
11
10
 
12
11
  const trackEvent = vi.hoisted(() => vi.fn());
13
12
  vi.mock("../telemetry/client", () => ({ trackEvent }));
@@ -16,57 +15,6 @@ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
16
15
 
17
16
  let cleanup: (() => void) | null = null;
18
17
 
19
- function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapshot {
20
- return {
21
- projectId: "demo",
22
- compositionPath: "index.html",
23
- currentTime: 0,
24
- duration: 10,
25
- isPlaying: false,
26
- elements: [],
27
- scene: { status: "ready", items: [], drillInItem: null },
28
- selection: null,
29
- selectionAnimationCount: 0,
30
- history: { canUndo: false, canRedo: false, undoLabel: null, redoLabel: null },
31
- ...overrides,
32
- };
33
- }
34
-
35
- /** Full deps with inert defaults; override only what the test is about. */
36
- function deps(overrides: Partial<StudioAgentToolsDeps> = {}): StudioAgentToolsDeps {
37
- return {
38
- getSnapshot: () => snapshot(),
39
- getPreviewDocument: () => null,
40
- buildSelection: async () => null,
41
- applySelection: () => undefined,
42
- requestSeek: () => undefined,
43
- readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
44
- getProjectId: () => "demo",
45
- getCompositionPath: () => "index.html",
46
- probeFrame: async () => ({ ok: true, status: 200 }),
47
- wait: async () => undefined,
48
- getCurrentSelection: () => null,
49
- getWriteBlockedReason: () => null,
50
- setText: async () => ({ ok: true }),
51
- setStyle: async () => ({ ok: true }),
52
- readBox: () => ({ x: 0, y: 0, width: 100, height: 50 }),
53
- moveTo: async () => undefined,
54
- resizeTo: async () => undefined,
55
- rotateTo: async () => undefined,
56
- addAnimation: async () => true,
57
- updateAnimation: async () => true,
58
- addKeyframe: async () => undefined,
59
- deleteAnimation: async () => true,
60
- getAnimationsForSelection: async () => [],
61
- getGsapDiagnostics: () => ({
62
- animations: [],
63
- multipleTimelines: false,
64
- unsupportedTimelinePattern: false,
65
- }),
66
- ...overrides,
67
- };
68
- }
69
-
70
18
  async function executeRegistered<T>(
71
19
  registered: ModelContextTool[],
72
20
  name: string,
@@ -78,6 +26,17 @@ async function executeRegistered<T>(
78
26
  return (await tool.execute(input, { signal })) as T;
79
27
  }
80
28
 
29
+ /** The call shape `@mcp-b/global` uses: the input and nothing else. */
30
+ async function executeRegisteredWithoutOptions<T>(
31
+ registered: ModelContextTool[],
32
+ name: string,
33
+ input: object,
34
+ ): Promise<T> {
35
+ const tool = registered.find((candidate) => candidate.name === name);
36
+ if (!tool) throw new Error(`expected ${name} to be registered`);
37
+ return (await tool.execute(input)) as T;
38
+ }
39
+
81
40
  function mountedTargetDeps(overrides: Partial<StudioAgentToolsDeps> = {}) {
82
41
  const doc = previewDoc('<h1 id="human">Human</h1><h1 id="agent">Agent</h1>');
83
42
  const human = doc.getElementById("human") as HTMLElement;
@@ -93,7 +52,7 @@ function mountedTargetDeps(overrides: Partial<StudioAgentToolsDeps> = {}) {
93
52
  agent,
94
53
  agentHandle,
95
54
  currentSelection: selectionFor(human),
96
- deps: deps({
55
+ deps: studioAgentToolsDeps({
97
56
  getPreviewDocument: () => doc,
98
57
  buildSelection: async (element) => selectionFor(element),
99
58
  ...overrides,
@@ -160,7 +119,7 @@ describe("useStudioAgentTools", () => {
160
119
  const { registered } = installModelContext();
161
120
 
162
121
  await act(async () => {
163
- mountTools(deps({ getSnapshot: () => snapshot() }));
122
+ mountTools(studioAgentToolsDeps({ getSnapshot: () => lookSnapshot() }));
164
123
  });
165
124
 
166
125
  expect(registered.map((tool) => tool.name)).toEqual([
@@ -188,13 +147,17 @@ describe("useStudioAgentTools", () => {
188
147
 
189
148
  let harness: ReturnType<typeof mountTools> | null = null;
190
149
  await act(async () => {
191
- harness = mountTools(deps({ getSnapshot: () => snapshot() }));
150
+ harness = mountTools(studioAgentToolsDeps({ getSnapshot: () => lookSnapshot() }));
192
151
  });
193
152
  expect(registerTool).toHaveBeenCalledTimes(12);
194
153
 
195
154
  await act(async () => {
196
- harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) }));
197
- harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) }));
155
+ harness?.rerenderWith(
156
+ studioAgentToolsDeps({ getSnapshot: () => lookSnapshot({ currentTime: 5 }) }),
157
+ );
158
+ harness?.rerenderWith(
159
+ studioAgentToolsDeps({ getSnapshot: () => lookSnapshot({ currentTime: 6 }) }),
160
+ );
198
161
  });
199
162
 
200
163
  expect(registerTool).toHaveBeenCalledTimes(12);
@@ -207,11 +170,15 @@ describe("useStudioAgentTools", () => {
207
170
 
208
171
  let harness: ReturnType<typeof mountTools> | null = null;
209
172
  await act(async () => {
210
- harness = mountTools(deps({ getSnapshot: () => snapshot({ currentTime: 1 }) }));
173
+ harness = mountTools(
174
+ studioAgentToolsDeps({ getSnapshot: () => lookSnapshot({ currentTime: 1 }) }),
175
+ );
211
176
  });
212
177
 
213
178
  await act(async () => {
214
- harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 42 }) }));
179
+ harness?.rerenderWith(
180
+ studioAgentToolsDeps({ getSnapshot: () => lookSnapshot({ currentTime: 42 }) }),
181
+ );
215
182
  });
216
183
 
217
184
  const result = await executeFirstRegistered<{
@@ -227,7 +194,7 @@ describe("useStudioAgentTools", () => {
227
194
  const { registerTool } = installModelContext();
228
195
 
229
196
  await act(async () => {
230
- mountTools(deps({ getSnapshot: () => snapshot() }));
197
+ mountTools(studioAgentToolsDeps({ getSnapshot: () => lookSnapshot() }));
231
198
  });
232
199
  const signal = registerTool.mock.calls[0]?.[1]?.signal;
233
200
  expect(signal?.aborted).toBe(false);
@@ -242,7 +209,7 @@ describe("useStudioAgentTools", () => {
242
209
  removeModelContext();
243
210
 
244
211
  await act(async () => {
245
- mountTools(deps({ getSnapshot: () => snapshot() }));
212
+ mountTools(studioAgentToolsDeps({ getSnapshot: () => lookSnapshot() }));
246
213
  });
247
214
 
248
215
  // The assertion is that mounting did not throw; a browser without the
@@ -255,7 +222,7 @@ describe("useStudioAgentTools", () => {
255
222
  const { registerTool } = installModelContext();
256
223
 
257
224
  await act(async () => {
258
- mountTools(deps({ getSnapshot: () => snapshot() }));
225
+ mountTools(studioAgentToolsDeps({ getSnapshot: () => lookSnapshot() }));
259
226
  });
260
227
 
261
228
  expect(registerTool).not.toHaveBeenCalled();
@@ -265,7 +232,7 @@ describe("useStudioAgentTools", () => {
265
232
  const { registerTool } = installModelContext();
266
233
 
267
234
  await act(async () => {
268
- mountTools(deps({ getSnapshot: () => snapshot() }));
235
+ mountTools(studioAgentToolsDeps({ getSnapshot: () => lookSnapshot() }));
269
236
  });
270
237
 
271
238
  expect(registerTool).toHaveBeenCalledTimes(12);
@@ -276,7 +243,7 @@ describe("useStudioAgentTools", () => {
276
243
  registerTool.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
277
244
 
278
245
  await act(async () => {
279
- mountTools(deps({ getSnapshot: () => snapshot() }));
246
+ mountTools(studioAgentToolsDeps({ getSnapshot: () => lookSnapshot() }));
280
247
  });
281
248
 
282
249
  expect(trackEvent).toHaveBeenCalledWith("webmcp_registration_failed", {
@@ -290,7 +257,7 @@ describe("useStudioAgentTools", () => {
290
257
 
291
258
  await act(async () => {
292
259
  mountTools(
293
- deps({
260
+ studioAgentToolsDeps({
294
261
  getSnapshot: () => {
295
262
  throw new TypeError("handler signature moved");
296
263
  },
@@ -310,7 +277,7 @@ describe("useStudioAgentTools", () => {
310
277
 
311
278
  it("requires a source-safe handle on every source-writing tool", async () => {
312
279
  const { registered } = installModelContext();
313
- await act(async () => mountTools(deps()));
280
+ await act(async () => mountTools(studioAgentToolsDeps()));
314
281
 
315
282
  for (const name of [
316
283
  "studio_set_text",
@@ -394,6 +361,53 @@ describe("useStudioAgentTools", () => {
394
361
  expect(setText).not.toHaveBeenCalled();
395
362
  });
396
363
 
364
+ it("runs every write tool when execute is called with the input alone", async () => {
365
+ // `@mcp-b/global` invokes `execute(input)` with no options object, both from
366
+ // its in-page server and from the descriptor it mirrors into a native
367
+ // `document.modelContext`. Handlers that destructured `{ signal }` threw a
368
+ // TypeError before running, which `runToolBody` reports as `internal`.
369
+ const { registered } = installModelContext();
370
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
371
+ const targeted = mountedTargetDeps();
372
+ const setText = vi.fn(
373
+ async () =>
374
+ ({
375
+ ok: true,
376
+ persistence: { sourceFile: "index.html", version: '"sha256:after"', changed: true },
377
+ }) as const,
378
+ );
379
+ await act(async () => mountTools({ ...targeted.deps, setText }));
380
+
381
+ const writeTools = registered.filter((tool) => tool.annotations?.readOnlyHint === false);
382
+ expect(writeTools.map((tool) => tool.name)).toEqual([
383
+ "studio_select",
384
+ "studio_seek",
385
+ "studio_set_text",
386
+ "studio_set_style",
387
+ "studio_transform",
388
+ "studio_add_animation",
389
+ "studio_update_animation",
390
+ "studio_add_keyframe",
391
+ "studio_delete_animation",
392
+ ]);
393
+ for (const tool of writeTools) {
394
+ // One argument, exactly as the polyfill calls it. An empty input is a bad
395
+ // request, never a crash: the failure kind must not be `internal`.
396
+ const result = (await tool.execute({})) as { ok: boolean; kind?: string };
397
+ expect(result.ok, tool.name).toBe(false);
398
+ expect(result.kind, tool.name).not.toBe("internal");
399
+ }
400
+ expect(consoleError).not.toHaveBeenCalled();
401
+
402
+ const saved = await executeRegisteredWithoutOptions<{ ok: boolean; stage: string }>(
403
+ registered,
404
+ "studio_set_text",
405
+ { handle: targeted.agentHandle, text: "Edited without options" },
406
+ );
407
+ expect(saved).toMatchObject({ ok: true, stage: "saved" });
408
+ expect(setText).toHaveBeenCalledTimes(1);
409
+ });
410
+
397
411
  it("reports dispatched, saved, and verified only from their corresponding evidence", async () => {
398
412
  const { registered } = installModelContext();
399
413
  const targeted = mountedTargetDeps();
@@ -112,6 +112,32 @@ export interface StudioAgentToolsDeps
112
112
  * state. That is the whole point of the ref: see the registration note below.
113
113
  */
114
114
  function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): ModelContextTool[] {
115
+ /**
116
+ * `execute` for a tool whose handler takes the caller's abort signal.
117
+ *
118
+ * The spec shape is `execute(input, { signal })`, but `@mcp-b/global` (5.0.1
119
+ * through 5.1.0; the polyfill chunk is byte-identical across them) invokes a
120
+ * registered `execute` with the input ALONE, both from its in-page
121
+ * `BrowserMcpServer` wrapper and from the descriptor it mirrors into a native
122
+ * `document.modelContext`. A handler that destructures its second argument
123
+ * throws before it runs, so every write tool failed with "Cannot destructure
124
+ * property 'signal' of 'undefined'" while the read tools, which ignore the
125
+ * argument, kept working. This is the one boundary that tolerates the missing
126
+ * options object. Each handler already defaults an undefined signal to a
127
+ * never-aborted one, so nothing downstream has to.
128
+ */
129
+ const writeTool =
130
+ <T>(
131
+ name: string,
132
+ run: (
133
+ deps: StudioAgentToolsDeps,
134
+ input: object,
135
+ signal: AbortSignal | undefined,
136
+ ) => Promise<ToolResult<T>>,
137
+ ): ModelContextTool["execute"] =>
138
+ (input, options) =>
139
+ runToolBody<T>(name, () => run(depsRef.current, input, options?.signal));
140
+
115
141
  return [
116
142
  {
117
143
  name: "studio_look",
@@ -177,10 +203,9 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
177
203
  description: STUDIO_SET_TEXT_DESCRIPTION,
178
204
  inputSchema: STUDIO_SET_TEXT_INPUT_SCHEMA,
179
205
  annotations: { readOnlyHint: false, untrustedContentHint: true },
180
- execute: (input, { signal }): Promise<ToolResult<StudioSetTextResult>> =>
181
- runToolBody<StudioSetTextResult>("studio_set_text", () =>
182
- studioSetText(depsRef.current, input, signal),
183
- ),
206
+ execute: writeTool<StudioSetTextResult>("studio_set_text", (deps, input, signal) =>
207
+ studioSetText(deps, input, signal),
208
+ ),
184
209
  },
185
210
  {
186
211
  name: "studio_set_style",
@@ -188,10 +213,9 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
188
213
  description: STUDIO_SET_STYLE_DESCRIPTION,
189
214
  inputSchema: STUDIO_SET_STYLE_INPUT_SCHEMA,
190
215
  annotations: { readOnlyHint: false },
191
- execute: (input, { signal }): Promise<ToolResult<StudioSetStyleResult>> =>
192
- runToolBody<StudioSetStyleResult>("studio_set_style", () =>
193
- studioSetStyle(depsRef.current, input, signal),
194
- ),
216
+ execute: writeTool<StudioSetStyleResult>("studio_set_style", (deps, input, signal) =>
217
+ studioSetStyle(deps, input, signal),
218
+ ),
195
219
  },
196
220
  {
197
221
  name: "studio_transform",
@@ -199,10 +223,9 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
199
223
  description: STUDIO_TRANSFORM_DESCRIPTION,
200
224
  inputSchema: STUDIO_TRANSFORM_INPUT_SCHEMA,
201
225
  annotations: { readOnlyHint: false },
202
- execute: (input, { signal }): Promise<ToolResult<StudioTransformResult>> =>
203
- runToolBody<StudioTransformResult>("studio_transform", () =>
204
- studioTransform(depsRef.current, input as StudioTransformInput, signal),
205
- ),
226
+ execute: writeTool<StudioTransformResult>("studio_transform", (deps, input, signal) =>
227
+ studioTransform(deps, input as StudioTransformInput, signal),
228
+ ),
206
229
  },
207
230
  {
208
231
  name: "studio_add_animation",
@@ -210,10 +233,9 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
210
233
  description: STUDIO_ADD_ANIMATION_DESCRIPTION,
211
234
  inputSchema: STUDIO_ADD_ANIMATION_INPUT_SCHEMA,
212
235
  annotations: { readOnlyHint: false },
213
- execute: (input, { signal }): Promise<ToolResult<StudioAddAnimationResult>> =>
214
- runToolBody<StudioAddAnimationResult>("studio_add_animation", () =>
215
- studioAddAnimation(depsRef.current, input, signal),
216
- ),
236
+ execute: writeTool<StudioAddAnimationResult>("studio_add_animation", (deps, input, signal) =>
237
+ studioAddAnimation(deps, input, signal),
238
+ ),
217
239
  },
218
240
  {
219
241
  name: "studio_update_animation",
@@ -221,10 +243,10 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
221
243
  description: STUDIO_UPDATE_ANIMATION_DESCRIPTION,
222
244
  inputSchema: STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA,
223
245
  annotations: { readOnlyHint: false },
224
- execute: (input, { signal }): Promise<ToolResult<StudioUpdateAnimationResult>> =>
225
- runToolBody<StudioUpdateAnimationResult>("studio_update_animation", () =>
226
- studioUpdateAnimation(depsRef.current, input, signal),
227
- ),
246
+ execute: writeTool<StudioUpdateAnimationResult>(
247
+ "studio_update_animation",
248
+ (deps, input, signal) => studioUpdateAnimation(deps, input, signal),
249
+ ),
228
250
  },
229
251
  {
230
252
  name: "studio_add_keyframe",
@@ -232,10 +254,9 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
232
254
  description: STUDIO_ADD_KEYFRAME_DESCRIPTION,
233
255
  inputSchema: STUDIO_ADD_KEYFRAME_INPUT_SCHEMA,
234
256
  annotations: { readOnlyHint: false },
235
- execute: (input, { signal }): Promise<ToolResult<StudioAddKeyframeResult>> =>
236
- runToolBody<StudioAddKeyframeResult>("studio_add_keyframe", () =>
237
- studioAddKeyframe(depsRef.current, input, signal),
238
- ),
257
+ execute: writeTool<StudioAddKeyframeResult>("studio_add_keyframe", (deps, input, signal) =>
258
+ studioAddKeyframe(deps, input, signal),
259
+ ),
239
260
  },
240
261
  {
241
262
  name: "studio_delete_animation",
@@ -243,10 +264,10 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
243
264
  description: STUDIO_DELETE_ANIMATION_DESCRIPTION,
244
265
  inputSchema: STUDIO_DELETE_ANIMATION_INPUT_SCHEMA,
245
266
  annotations: { readOnlyHint: false },
246
- execute: (input, { signal }): Promise<ToolResult<StudioDeleteAnimationResult>> =>
247
- runToolBody<StudioDeleteAnimationResult>("studio_delete_animation", () =>
248
- studioDeleteAnimation(depsRef.current, input, signal),
249
- ),
267
+ execute: writeTool<StudioDeleteAnimationResult>(
268
+ "studio_delete_animation",
269
+ (deps, input, signal) => studioDeleteAnimation(deps, input, signal),
270
+ ),
250
271
  },
251
272
  ];
252
273
  }