@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/dist/assets/{hyperframes-player-D4aSRr_M.js → hyperframes-player-lPOqAC3l.js} +1 -1
- package/dist/assets/{index-CkxPQxLJ.js → index-BRr1JoHX.js} +80 -80
- package/dist/assets/{index-hch4ktcC.js → index-RPEcu_bT.js} +1 -1
- package/dist/assets/{index-aNyQBoJv.js → index-ywAULfGT.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +15 -14
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/webmcp/types.ts +5 -1
- package/src/webmcp/useStudioAgentTools.polyfill.test.tsx +219 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +81 -67
- package/src/webmcp/useStudioAgentTools.ts +49 -28
- package/src/webmcp/webmcpTestUtils.ts +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
52
|
-
"@hyperframes/parsers": "0.8.
|
|
53
|
-
"@hyperframes/player": "0.8.
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
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.
|
|
72
|
+
"@hyperframes/producer": "0.8.35"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
package/src/webmcp/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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(
|
|
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(
|
|
150
|
+
harness = mountTools(studioAgentToolsDeps({ getSnapshot: () => lookSnapshot() }));
|
|
192
151
|
});
|
|
193
152
|
expect(registerTool).toHaveBeenCalledTimes(12);
|
|
194
153
|
|
|
195
154
|
await act(async () => {
|
|
196
|
-
harness?.rerenderWith(
|
|
197
|
-
|
|
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(
|
|
173
|
+
harness = mountTools(
|
|
174
|
+
studioAgentToolsDeps({ getSnapshot: () => lookSnapshot({ currentTime: 1 }) }),
|
|
175
|
+
);
|
|
211
176
|
});
|
|
212
177
|
|
|
213
178
|
await act(async () => {
|
|
214
|
-
harness?.rerenderWith(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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,
|
|
181
|
-
|
|
182
|
-
|
|
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,
|
|
192
|
-
|
|
193
|
-
|
|
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,
|
|
203
|
-
|
|
204
|
-
|
|
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,
|
|
214
|
-
|
|
215
|
-
|
|
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:
|
|
225
|
-
|
|
226
|
-
|
|
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,
|
|
236
|
-
|
|
237
|
-
|
|
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:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
267
|
+
execute: writeTool<StudioDeleteAnimationResult>(
|
|
268
|
+
"studio_delete_animation",
|
|
269
|
+
(deps, input, signal) => studioDeleteAnimation(deps, input, signal),
|
|
270
|
+
),
|
|
250
271
|
},
|
|
251
272
|
];
|
|
252
273
|
}
|