diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx
index 472a91df64..f2ab2f3755 100644
--- a/packages/studio/src/components/EditorShell.tsx
+++ b/packages/studio/src/components/EditorShell.tsx
@@ -15,6 +15,7 @@ import { usePlayerStore, type TimelineElement } from "../player";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import type { GestureRecordingState } from "./editor/GestureRecordControl";
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
+import { StudioAgentTools } from "../webmcp/StudioAgentTools";
type RenderClipContent = (
element: TimelineElement,
@@ -262,6 +263,9 @@ function EditorShellBody({
onKeyDown={handleKeyDown}
tabIndex={-1}
>
+ {/* Renders nothing; exposes Studio's state to an agentic browser. Mounted
+ here rather than in App because it needs the DomEdit contexts. */}
+
{/* Top row: [left | preview | right] — outer padding + the 8px resize
seams give the panels CapCut-style separation on the dark canvas. */}
diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts
index 801a584858..1a14318d7e 100644
--- a/packages/studio/src/utils/studioUiPreferences.ts
+++ b/packages/studio/src/utils/studioUiPreferences.ts
@@ -34,6 +34,14 @@ export interface StudioUiPreferences {
timelineZoomMode?: "fit" | "manual";
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
timelineManualZoomPercent?: number;
+ /**
+ * Expose Studio's editing capabilities to an agentic browser as WebMCP tools.
+ * Absent means on: the browser still gates every actual call behind its own
+ * permission prompt, so "registered" is not "reachable without consent".
+ * Changes take effect on the next Studio reload because registration is
+ * intentionally scoped to one mount.
+ */
+ agentToolsEnabled?: boolean;
}
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
@@ -140,6 +148,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
) {
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
}
+ if (typeof parsed.agentToolsEnabled === "boolean") {
+ preferences.agentToolsEnabled = parsed.agentToolsEnabled;
+ }
return preferences;
} catch {
return {};
diff --git a/packages/studio/src/webmcp/StudioAgentTools.tsx b/packages/studio/src/webmcp/StudioAgentTools.tsx
new file mode 100644
index 0000000000..8060d7382b
--- /dev/null
+++ b/packages/studio/src/webmcp/StudioAgentTools.tsx
@@ -0,0 +1,46 @@
+import { useCallback } from "react";
+import { useDomEditSelectionContext } from "../contexts/DomEditContext";
+import { useStudioShellContext } from "../contexts/StudioContext";
+import { usePlayerStore } from "../player";
+import { useStudioAgentTools } from "./useStudioAgentTools";
+import type { StudioLookSnapshot } from "./tools/lookTools";
+
+/**
+ * Mounts Studio's WebMCP tool surface. Renders nothing.
+ *
+ * Lives inside `EditorShell` rather than `App` for two reasons: the DomEdit
+ * contexts are only readable below `DomEditProvider`, which `App` renders, and
+ * `App.tsx` sits three lines under the 600-line cap.
+ *
+ * The player store is read IMPERATIVELY through `getState()` inside the
+ * snapshot callback rather than subscribed to. Subscribing to `currentTime`
+ * would re-render this component on every animation frame during playback for
+ * a value nothing here displays.
+ */
+export function StudioAgentTools() {
+ const { projectId, activeCompPath, editHistory } = useStudioShellContext();
+ const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
+
+ const getSnapshot = useCallback((): StudioLookSnapshot => {
+ const player = usePlayerStore.getState();
+ return {
+ projectId,
+ compositionPath: activeCompPath,
+ currentTime: player.currentTime,
+ duration: player.duration,
+ isPlaying: player.isPlaying,
+ elements: player.elements,
+ selection: domEditSelection,
+ selectionAnimationCount: selectedGsapAnimations.length,
+ history: {
+ canUndo: editHistory.canUndo,
+ canRedo: editHistory.canRedo,
+ undoLabel: editHistory.undoLabel ?? null,
+ redoLabel: editHistory.redoLabel ?? null,
+ },
+ };
+ }, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
+
+ useStudioAgentTools({ getSnapshot });
+ return null;
+}
diff --git a/packages/studio/src/webmcp/handles.test.ts b/packages/studio/src/webmcp/handles.test.ts
new file mode 100644
index 0000000000..d164dbadff
--- /dev/null
+++ b/packages/studio/src/webmcp/handles.test.ts
@@ -0,0 +1,130 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import type { TimelineElement } from "../player/store/timelineElement";
+import {
+ mintElementHandle,
+ parseElementHandle,
+ resolveElementHandle,
+ timelineElementAddress,
+} from "./handles";
+
+function timelineElement(overrides: Partial
): TimelineElement {
+ return { id: "synthetic-id", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
+}
+
+/** A separate document, standing in for the preview iframe's realm. */
+function previewDoc(html: string): Document {
+ const iframe = document.createElement("iframe");
+ document.body.append(iframe);
+ const doc = iframe.contentDocument;
+ if (!doc) throw new Error("expected iframe document");
+ doc.body.innerHTML = html;
+ return doc;
+}
+
+describe("mintElementHandle", () => {
+ it("prefers data-hf-id, the stable patch target", () => {
+ const handle = mintElementHandle(
+ timelineElementAddress(
+ timelineElement({ hfId: "abc123", domId: "headline", selector: ".title" }),
+ ),
+ );
+ expect(handle).toBe("hf:abc123");
+ });
+
+ it("falls back to the DOM id when there is no hf id", () => {
+ expect(
+ mintElementHandle(
+ timelineElementAddress(timelineElement({ domId: "headline", selector: ".title" })),
+ ),
+ ).toBe("dom:headline");
+ });
+
+ it("falls back to a selector with its occurrence index", () => {
+ expect(
+ mintElementHandle(
+ timelineElementAddress(timelineElement({ selector: ".card", selectorIndex: 2 })),
+ ),
+ ).toBe("sel:.card#2");
+ });
+
+ it("defaults a missing occurrence index to the first match", () => {
+ expect(mintElementHandle(timelineElementAddress(timelineElement({ selector: ".card" })))).toBe(
+ "sel:.card#0",
+ );
+ });
+
+ it("returns null when the element carries no way to address it", () => {
+ // The synthesised `id` is deliberately NOT used: it cannot resolve.
+ expect(mintElementHandle(timelineElementAddress(timelineElement({})))).toBeNull();
+ });
+});
+
+describe("parseElementHandle", () => {
+ it("splits the index off the LAST hash, so id selectors survive", () => {
+ expect(parseElementHandle("sel:#card > .title#3")).toEqual({
+ scheme: "sel",
+ value: "#card > .title",
+ index: 3,
+ });
+ });
+
+ it("treats a selector with no index as the first match", () => {
+ expect(parseElementHandle("sel:.card")).toEqual({ scheme: "sel", value: ".card", index: 0 });
+ });
+
+ it("rejects an unknown scheme", () => {
+ expect(parseElementHandle("xpath://div")).toBeNull();
+ });
+
+ it("rejects a handle with no value", () => {
+ expect(parseElementHandle("dom:")).toBeNull();
+ expect(parseElementHandle("")).toBeNull();
+ expect(parseElementHandle(":headline")).toBeNull();
+ });
+});
+
+describe("resolveElementHandle", () => {
+ it("round-trips every handle scheme a read can mint", () => {
+ const doc = previewDoc(
+ `A
+ first
+ second
`,
+ );
+
+ expect(resolveElementHandle(doc, "hf:abc123")?.id).toBe("headline");
+ expect(resolveElementHandle(doc, "dom:headline")?.id).toBe("headline");
+ expect(resolveElementHandle(doc, "sel:.card#1")?.textContent).toBe("second");
+ });
+
+ it("resolves across realms, where a naive instanceof check fails", () => {
+ const doc = previewDoc('A
');
+ const resolved = resolveElementHandle(doc, "dom:headline");
+
+ expect(resolved).not.toBeNull();
+ // The preview element is NOT an instance of Studio's own HTMLElement.
+ expect(resolved instanceof HTMLElement).toBe(false);
+ });
+
+ it("returns null for a handle that no longer matches", () => {
+ const doc = previewDoc('A
');
+ expect(resolveElementHandle(doc, "dom:deleted")).toBeNull();
+ expect(resolveElementHandle(doc, "hf:missing")).toBeNull();
+ expect(resolveElementHandle(doc, "sel:.card#0")).toBeNull();
+ });
+
+ it("returns null for an out-of-range occurrence rather than the wrong element", () => {
+ const doc = previewDoc('only
');
+ expect(resolveElementHandle(doc, "sel:.card#4")).toBeNull();
+ });
+
+ it("returns null for a selector that is invalid in this document", () => {
+ const doc = previewDoc('only
');
+ expect(resolveElementHandle(doc, "sel:>>>broken#0")).toBeNull();
+ });
+
+ it("returns null for a malformed handle", () => {
+ const doc = previewDoc('A
');
+ expect(resolveElementHandle(doc, "nonsense")).toBeNull();
+ });
+});
diff --git a/packages/studio/src/webmcp/handles.ts b/packages/studio/src/webmcp/handles.ts
new file mode 100644
index 0000000000..9f8420bcd1
--- /dev/null
+++ b/packages/studio/src/webmcp/handles.ts
@@ -0,0 +1,129 @@
+/**
+ * Opaque element handles: the one thing reads mint and writes consume.
+ *
+ * `TimelineElement.id` cannot do this job. It is a SYNTHESISED identity built
+ * from label, index, selector and source file when the clip has no authored id
+ * (`timelineElementHelpers.buildTimelineElementIdentity`), so
+ * `getElementById(element.id)` misses most elements. The real addressing fields
+ * are separate: `hfId` (the `data-hf-id` the codebase calls the stable primary
+ * patch target), `domId`, and a `selector` plus occurrence index.
+ *
+ * Handles are strings so they survive a JSON round trip through the agent
+ * untouched. The agent never builds one; it passes back what a read gave it.
+ */
+
+import type { TimelineElement } from "../player/store/timelineElement";
+import type { PatchTarget } from "../utils/sourcePatcher";
+
+const SEPARATOR = ":";
+const INDEX_SEPARATOR = "#";
+
+/**
+ * How to find one element. `TimelineElement` calls the DOM id `domId` and
+ * `PatchTarget` calls it `id`, so both adapt into this rather than the minter
+ * knowing about either.
+ */
+export interface ElementAddress {
+ hfId?: string;
+ domId?: string | null;
+ selector?: string;
+ selectorIndex?: number;
+}
+
+/**
+ * Address an element the same way Studio's own patcher does, most stable first.
+ * `data-hf-id` survives edits that renumber or reorder; a bare selector does not.
+ */
+export function mintElementHandle(address: ElementAddress): string | null {
+ if (address.hfId) return `hf${SEPARATOR}${address.hfId}`;
+ if (address.domId) return `dom${SEPARATOR}${address.domId}`;
+ if (address.selector) {
+ const index = address.selectorIndex ?? 0;
+ return `sel${SEPARATOR}${address.selector}${INDEX_SEPARATOR}${index}`;
+ }
+ return null;
+}
+
+export function timelineElementAddress(element: TimelineElement): ElementAddress {
+ return {
+ hfId: element.hfId,
+ domId: element.domId,
+ selector: element.selector,
+ selectorIndex: element.selectorIndex,
+ };
+}
+
+export function patchTargetAddress(target: PatchTarget): ElementAddress {
+ return {
+ hfId: target.hfId,
+ domId: target.id,
+ selector: target.selector,
+ selectorIndex: target.selectorIndex,
+ };
+}
+
+interface ParsedHandle {
+ scheme: "hf" | "dom" | "sel";
+ value: string;
+ index: number;
+}
+
+export function parseElementHandle(handle: string): ParsedHandle | null {
+ const separatorAt = handle.indexOf(SEPARATOR);
+ if (separatorAt <= 0) return null;
+ const scheme = handle.slice(0, separatorAt);
+ const rest = handle.slice(separatorAt + 1);
+ if (!rest) return null;
+ if (scheme === "hf" || scheme === "dom") return { scheme, value: rest, index: 0 };
+ if (scheme !== "sel") return null;
+
+ // Only the LAST `#` splits the index off: CSS selectors contain `#` themselves.
+ const indexAt = rest.lastIndexOf(INDEX_SEPARATOR);
+ if (indexAt <= 0) return { scheme, value: rest, index: 0 };
+ const index = Number(rest.slice(indexAt + 1));
+ if (!Number.isInteger(index) || index < 0) return { scheme, value: rest, index: 0 };
+ return { scheme, value: rest.slice(0, indexAt), index };
+}
+
+/**
+ * Resolve a handle against the preview document.
+ *
+ * Always re-resolve per call rather than holding an element across calls: a
+ * preview reload replaces the document, and a node from the destroyed one is
+ * detached but still looks like an element.
+ */
+export function resolveElementHandle(doc: Document, handle: string): HTMLElement | null {
+ const parsed = parseElementHandle(handle);
+ if (!parsed) return null;
+
+ if (parsed.scheme === "dom") return asHtmlElement(doc, doc.getElementById(parsed.value));
+ if (parsed.scheme === "hf") {
+ return asHtmlElement(doc, doc.querySelector(`[data-hf-id="${cssEscape(parsed.value)}"]`));
+ }
+
+ let matches: NodeListOf;
+ try {
+ matches = doc.querySelectorAll(parsed.value);
+ } catch {
+ // A selector minted from a previous document can be invalid in this one.
+ return null;
+ }
+ return asHtmlElement(doc, matches.item(parsed.index));
+}
+
+function cssEscape(value: string): string {
+ // ponytail: happy-dom and jsdom don't always ship CSS.escape; quoting the two
+ // characters that can break out of an attribute selector covers this use.
+ return typeof CSS?.escape === "function" ? CSS.escape(value) : value.replace(/["\\]/g, "\\$&");
+}
+
+/**
+ * `instanceof HTMLElement` is checked against the OWNING document's realm.
+ * The preview lives in an iframe, so Studio's own `HTMLElement` is a different
+ * constructor and the naive check fails on every real preview element.
+ */
+function asHtmlElement(doc: Document, node: Element | null): HTMLElement | null {
+ if (!node) return null;
+ const ctor = doc.defaultView?.HTMLElement;
+ return ctor && node instanceof ctor ? node : null;
+}
diff --git a/packages/studio/src/webmcp/registrar.test.ts b/packages/studio/src/webmcp/registrar.test.ts
new file mode 100644
index 0000000000..212c62b9fc
--- /dev/null
+++ b/packages/studio/src/webmcp/registrar.test.ts
@@ -0,0 +1,150 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi } from "vitest";
+import { findToolDefinitionError, registerStudioTools } from "./registrar";
+import type { ModelContext, ModelContextTool } from "./types";
+
+function tool(overrides: Partial = {}): ModelContextTool {
+ return {
+ name: "studio_look",
+ description: "Read Studio's live state.",
+ execute: async () => ({ ok: true }),
+ ...overrides,
+ };
+}
+
+function fakeModelContext(
+ registerTool: ModelContext["registerTool"] = vi.fn().mockResolvedValue(undefined),
+): ModelContext {
+ return { registerTool };
+}
+
+function domException(name: string, message = name): DOMException {
+ return new DOMException(message, name);
+}
+
+describe("findToolDefinitionError", () => {
+ it("accepts the names Studio actually uses", () => {
+ expect(findToolDefinitionError(tool({ name: "studio_look" }))).toBeNull();
+ expect(findToolDefinitionError(tool({ name: "studio.look-2" }))).toBeNull();
+ });
+
+ it("rejects a name the browser would reject, naming the tool", () => {
+ expect(findToolDefinitionError(tool({ name: "studio look" }))).toMatch(/name must be/);
+ expect(findToolDefinitionError(tool({ name: "a".repeat(129) }))).toMatch(/name must be/);
+ expect(findToolDefinitionError(tool({ name: "" }))).toMatch(/name must be/);
+ });
+
+ it("rejects an empty description", () => {
+ expect(findToolDefinitionError(tool({ description: " " }))).toBe(
+ "description must not be empty",
+ );
+ });
+});
+
+describe("registerStudioTools", () => {
+ it("registers every tool with the shared abort signal", async () => {
+ const registerTool = vi.fn().mockResolvedValue(undefined);
+ const controller = new AbortController();
+
+ const report = await registerStudioTools(
+ fakeModelContext(registerTool),
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
+ controller.signal,
+ );
+
+ expect(report.registered).toEqual(["studio_look", "studio_frame"]);
+ expect(report.failed).toEqual([]);
+ expect(registerTool).toHaveBeenCalledTimes(2);
+ expect(registerTool.mock.calls[0]?.[1]).toEqual({ signal: controller.signal });
+ });
+
+ it("stops silently when the signal aborts mid-registration", async () => {
+ // A StrictMode mount-cleanup-mount rejects the in-flight registrations with
+ // AbortError. That is teardown working; it must not surface as a failure or
+ // escape as an unhandled rejection.
+ const registerTool = vi
+ .fn()
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(domException("AbortError"));
+
+ const report = await registerStudioTools(
+ fakeModelContext(registerTool),
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
+ new AbortController().signal,
+ );
+
+ expect(report.registered).toEqual(["studio_look"]);
+ expect(report.failed).toEqual([]);
+ });
+
+ it("keeps the DOMException name, which is the only thing that tells the gates apart", async () => {
+ const registerTool = vi
+ .fn()
+ .mockRejectedValue(domException("SecurityError", "not origin-keyed"));
+
+ const report = await registerStudioTools(
+ fakeModelContext(registerTool),
+ [tool()],
+ new AbortController().signal,
+ );
+
+ expect(report.failed).toHaveLength(1);
+ expect(report.failed[0]?.tool).toBe("studio_look");
+ expect(report.failed[0]?.name).toBe("SecurityError");
+ // Substring, not equality: jsdom prefixes DOMException.message with the
+ // name and real browsers do not.
+ expect(report.failed[0]?.message).toContain("not origin-keyed");
+ expect(report.registered).toEqual([]);
+ });
+
+ it("keeps going after one tool fails", async () => {
+ const registerTool = vi
+ .fn()
+ .mockRejectedValueOnce(domException("NotAllowedError", "tools policy"))
+ .mockResolvedValueOnce(undefined);
+
+ const report = await registerStudioTools(
+ fakeModelContext(registerTool),
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
+ new AbortController().signal,
+ );
+
+ expect(report.registered).toEqual(["studio_frame"]);
+ expect(report.failed.map((f) => f.tool)).toEqual(["studio_look"]);
+ });
+
+ it("catches a duplicate name before the browser does, so the report names it", async () => {
+ const registerTool = vi.fn().mockResolvedValue(undefined);
+
+ const report = await registerStudioTools(
+ fakeModelContext(registerTool),
+ [tool({ name: "studio_look" }), tool({ name: "studio_look" })],
+ new AbortController().signal,
+ );
+
+ expect(report.registered).toEqual(["studio_look"]);
+ expect(report.failed).toEqual([
+ {
+ tool: "studio_look",
+ name: "InvalidStateError",
+ message: "duplicate tool name in this registration set",
+ },
+ ]);
+ // Registering the same name twice REJECTS rather than replacing, so the
+ // second one must never reach the browser.
+ expect(registerTool).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not send a tool the browser would reject", async () => {
+ const registerTool = vi.fn().mockResolvedValue(undefined);
+
+ const report = await registerStudioTools(
+ fakeModelContext(registerTool),
+ [tool({ name: "studio look" })],
+ new AbortController().signal,
+ );
+
+ expect(registerTool).not.toHaveBeenCalled();
+ expect(report.failed[0]?.name).toBe("InvalidStateError");
+ });
+});
diff --git a/packages/studio/src/webmcp/registrar.ts b/packages/studio/src/webmcp/registrar.ts
new file mode 100644
index 0000000000..7480caac0f
--- /dev/null
+++ b/packages/studio/src/webmcp/registrar.ts
@@ -0,0 +1,115 @@
+/**
+ * Registers Studio's tools with the browser, once.
+ *
+ * The only file besides `types.ts` that touches the WebMCP API, so a spec
+ * change lands here. Tool names are document-scoped, so Studio relies on its
+ * single live `EditorShell` mounting one `StudioAgentTools`. A second live
+ * shell would register the same names and receive `InvalidStateError`; the
+ * duplicate check below only owns duplicates within one registration set.
+ */
+
+import type { ModelContext, ModelContextTool } from "./types";
+
+export interface ToolRegistrationFailure {
+ tool: string;
+ /** The DOMException name where there is one. It is the only thing that tells
+ * a duplicate name (InvalidStateError) apart from a document that is not
+ * origin-keyed (SecurityError) or not permitted to use `tools`
+ * (NotAllowedError), and all three look identical without it. */
+ name: string;
+ message: string;
+}
+
+export interface ToolRegistrationReport {
+ registered: string[];
+ failed: ToolRegistrationFailure[];
+}
+
+/** Max 128 chars, ASCII alphanumeric plus `_`, `-`, `.` (`index.bs`). */
+const VALID_TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
+
+/**
+ * A tool whose name or description the browser would reject anyway. Caught here
+ * so the failure names the offending tool instead of arriving as one of N
+ * identical InvalidStateErrors.
+ */
+export function findToolDefinitionError(tool: ModelContextTool): string | null {
+ if (!VALID_TOOL_NAME.test(tool.name)) {
+ return `name must be 1-128 chars of A-Z a-z 0-9 _ - . (got ${JSON.stringify(tool.name)})`;
+ }
+ if (!tool.description.trim()) return "description must not be empty";
+ return null;
+}
+
+function isAbortError(error: unknown): boolean {
+ return error instanceof DOMException && error.name === "AbortError";
+}
+
+function invalidState(tool: string, message: string): ToolRegistrationFailure {
+ return { tool, name: "InvalidStateError", message };
+}
+
+/**
+ * `"aborted"` is a third outcome, not a failure: teardown got there first and
+ * the caller should stop rather than record anything.
+ */
+type RegisterOneOutcome =
+ | { status: "registered" }
+ | { status: "failed"; failure: ToolRegistrationFailure }
+ | { status: "aborted" };
+
+async function registerOne(
+ modelContext: ModelContext,
+ tool: ModelContextTool,
+ signal: AbortSignal,
+): Promise {
+ const definitionError = findToolDefinitionError(tool);
+ if (definitionError) {
+ return { status: "failed", failure: invalidState(tool.name, definitionError) };
+ }
+
+ try {
+ await modelContext.registerTool(tool, { signal });
+ return { status: "registered" };
+ } catch (error) {
+ // A mount-cleanup-mount cycle (React StrictMode in dev) aborts the signal in
+ // the same task the registration promise is queued in, which rejects every
+ // registerTool with AbortError. That is teardown working, not a failure, and
+ // letting it escape fills the dev console with unhandled rejections.
+ if (isAbortError(error)) return { status: "aborted" };
+ return {
+ status: "failed",
+ failure: {
+ tool: tool.name,
+ name: error instanceof DOMException ? error.name : "Error",
+ message: error instanceof Error ? error.message : String(error),
+ },
+ };
+ }
+}
+
+export async function registerStudioTools(
+ modelContext: ModelContext,
+ tools: readonly ModelContextTool[],
+ signal: AbortSignal,
+): Promise {
+ const report: ToolRegistrationReport = { registered: [], failed: [] };
+ const seen = new Set();
+
+ for (const tool of tools) {
+ // Registering a name twice REJECTS rather than replacing, so a duplicate
+ // must never reach the browser.
+ if (seen.has(tool.name)) {
+ report.failed.push(invalidState(tool.name, "duplicate tool name in this registration set"));
+ continue;
+ }
+ seen.add(tool.name);
+
+ const outcome = await registerOne(modelContext, tool, signal);
+ if (outcome.status === "aborted") return report;
+ if (outcome.status === "failed") report.failed.push(outcome.failure);
+ else report.registered.push(tool.name);
+ }
+
+ return report;
+}
diff --git a/packages/studio/src/webmcp/toolResult.ts b/packages/studio/src/webmcp/toolResult.ts
new file mode 100644
index 0000000000..fd803ec10c
--- /dev/null
+++ b/packages/studio/src/webmcp/toolResult.ts
@@ -0,0 +1,67 @@
+/**
+ * The shape every Studio tool resolves with.
+ *
+ * Tools resolve, they never reject. That is forced by the spec, not a style
+ * choice: a rejected `execute` has its reason DISCARDED and the caller is
+ * rejected with a bare `UnknownError` (`index.bs`, the execute-tool completion
+ * steps). Rejecting would therefore guarantee the agent cannot see why the edit
+ * failed, which is the one thing it needs most.
+ *
+ * There is no `outputSchema` in the platform yet, so this discriminant is
+ * invisible to the agent's schema layer. Every tool's `description` has to say
+ * that it returns `ok`.
+ */
+
+export type ToolFailureKind =
+ /** A real state the agent can route around: save queue paused, capability off. */
+ | "blocked"
+ /** The agent's fault: unknown handle, bad enum, out of range. */
+ | "invalid"
+ /** Exogenous: the server said no, the patch target could not be resolved. */
+ | "failed"
+ /** Our bug. Reported AND re-thrown, so it is findable instead of plausible. */
+ | "internal";
+
+export interface ToolFailure {
+ ok: false;
+ kind: ToolFailureKind;
+ reason: string;
+ /** What to try instead. This is what turns a failure into a next action. */
+ hint?: string;
+}
+
+export type ToolResult = ({ ok: true } & T) | ToolFailure;
+
+export function toolOk(value: T): { ok: true } & T {
+ return { ok: true, ...value };
+}
+
+function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
+ return hint ? { ok: false, kind, reason, hint } : { ok: false, kind, reason };
+}
+
+/**
+ * Wrap a tool body so a thrown bug becomes a legible result instead of a
+ * rejection the agent cannot read.
+ *
+ * The split matters. A `TypeError` means a handler signature moved under us and
+ * the tool is permanently broken; reporting that as an ordinary failure would
+ * let it ship looking like a bad request forever. So it is tagged `internal`
+ * AND re-thrown to the console, where it is findable. Everything else is a
+ * failure the agent should route around.
+ */
+export async function runToolBody(
+ toolName: string,
+ body: () => Promise>,
+): Promise> {
+ try {
+ return await body();
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ if (error instanceof TypeError || error instanceof ReferenceError) {
+ console.error(`[hf-webmcp] ${toolName} threw`, error);
+ return toolFailure("internal", reason);
+ }
+ return toolFailure("failed", reason);
+ }
+}
diff --git a/packages/studio/src/webmcp/tools/lookTools.test.ts b/packages/studio/src/webmcp/tools/lookTools.test.ts
new file mode 100644
index 0000000000..33c37ce9c6
--- /dev/null
+++ b/packages/studio/src/webmcp/tools/lookTools.test.ts
@@ -0,0 +1,225 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import type { DomEditSelection } from "../../components/editor/domEditingTypes";
+import type { TimelineElement } from "../../player/store/timelineElement";
+import { buildStudioLook, STUDIO_LOOK_INPUT_SCHEMA, type StudioLookSnapshot } from "./lookTools";
+
+function element(overrides: Partial): TimelineElement {
+ return { id: "synthetic", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
+}
+
+function snapshot(overrides: Partial = {}): StudioLookSnapshot {
+ return {
+ projectId: "demo",
+ compositionPath: "index.html",
+ currentTime: 1.5,
+ duration: 10,
+ isPlaying: false,
+ elements: [],
+ selection: null,
+ selectionAnimationCount: 0,
+ history: { canUndo: true, canRedo: false, undoLabel: "Move layer", redoLabel: null },
+ ...overrides,
+ };
+}
+
+function selection(overrides: Partial = {}): DomEditSelection {
+ return {
+ id: "headline",
+ hfId: "abc123",
+ element: document.createElement("div"),
+ label: "Headline",
+ tagName: "h1",
+ sourceFile: "index.html",
+ compositionPath: "index.html",
+ isCompositionHost: false,
+ isInsideLockedComposition: false,
+ boundingBox: { x: 40, y: 12, width: 880, height: 96 },
+ textContent: "Ship it",
+ dataAttributes: {},
+ inlineStyles: {},
+ computedStyles: {},
+ textFields: [],
+ capabilities: {
+ canSelect: true,
+ canEditStyles: true,
+ canCrop: true,
+ canMove: true,
+ canResize: true,
+ canApplyManualOffset: true,
+ canApplyManualSize: true,
+ canApplyManualRotation: true,
+ },
+ ...overrides,
+ };
+}
+
+function expectOk(result: { ok: boolean } & Record): T {
+ if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
+ return result as unknown as T;
+}
+
+describe("buildStudioLook", () => {
+ it("reports the playhead, duration and undo label an agent needs to checkpoint", () => {
+ const look = expectOk<{
+ playhead: number;
+ duration: number;
+ history: { undoLabel: string | null };
+ }>(
+ buildStudioLook(
+ snapshot({
+ currentTime: 2.4,
+ duration: 30,
+ history: { canUndo: true, canRedo: false, undoLabel: "Edit text", redoLabel: null },
+ }),
+ ),
+ );
+
+ expect(look.playhead).toBe(2.4);
+ expect(look.duration).toBe(30);
+ expect(look.history.undoLabel).toBe("Edit text");
+ });
+
+ it("gives every addressable element a handle a write tool can consume", () => {
+ const look = expectOk<{ elements: { handle: string | null; label: string | null }[] }>(
+ buildStudioLook(
+ snapshot({
+ elements: [
+ element({ hfId: "abc", label: "Headline" }),
+ element({ domId: "cta", label: "Button" }),
+ element({ selector: ".card", selectorIndex: 2, label: "Card" }),
+ ],
+ }),
+ ),
+ );
+
+ expect(look.elements.map((e) => e.handle)).toEqual(["hf:abc", "dom:cta", "sel:.card#2"]);
+ });
+
+ it("reports an unaddressable element with a null handle rather than hiding it", () => {
+ const look = expectOk<{ elements: { handle: string | null }[]; elementCount: number }>(
+ buildStudioLook(snapshot({ elements: [element({ label: "Anonymous" })] })),
+ );
+
+ expect(look.elementCount).toBe(1);
+ expect(look.elements[0]?.handle).toBeNull();
+ });
+
+ it("returns an empty list for an empty timeline, not a failure", () => {
+ const look = expectOk<{ elements: unknown[]; elementCount: number }>(
+ buildStudioLook(snapshot()),
+ );
+
+ expect(look.elements).toEqual([]);
+ expect(look.elementCount).toBe(0);
+ });
+
+ it("filters on label, tag and handle, case-insensitively", () => {
+ const elements = [
+ element({ hfId: "abc", label: "Headline", tag: "h1" }),
+ element({ domId: "cta", label: "Button", tag: "button" }),
+ ];
+
+ const byLabel = expectOk<{ elements: { handle: string | null }[] }>(
+ buildStudioLook(snapshot({ elements }), { filter: "HEADLINE" }),
+ );
+ const byTag = expectOk<{ elements: { handle: string | null }[] }>(
+ buildStudioLook(snapshot({ elements }), { filter: "button" }),
+ );
+ const byHandle = expectOk<{ elements: { handle: string | null }[] }>(
+ buildStudioLook(snapshot({ elements }), { filter: "hf:abc" }),
+ );
+
+ expect(byLabel.elements.map((e) => e.handle)).toEqual(["hf:abc"]);
+ expect(byTag.elements.map((e) => e.handle)).toEqual(["dom:cta"]);
+ expect(byHandle.elements.map((e) => e.handle)).toEqual(["hf:abc"]);
+ });
+
+ it("bounds a filter before normalizing it", () => {
+ const boundedFilter = "x".repeat(128);
+ const look = expectOk<{ elements: { handle: string | null }[] }>(
+ buildStudioLook(
+ snapshot({ elements: [element({ domId: "bounded", label: boundedFilter })] }),
+ { filter: `${boundedFilter}${"y".repeat(10_000)}` },
+ ),
+ );
+
+ expect(look.elements.map((entry) => entry.handle)).toEqual(["dom:bounded"]);
+ expect(STUDIO_LOOK_INPUT_SCHEMA.properties.filter.maxLength).toBe(128);
+ });
+
+ it("keeps the true match count when the list is truncated", () => {
+ const elements = Array.from({ length: 5 }, (_, index) =>
+ element({ domId: `el-${index}`, label: "Card" }),
+ );
+
+ const look = expectOk<{ elements: unknown[]; elementCount: number }>(
+ buildStudioLook(snapshot({ elements }), { limit: 2 }),
+ );
+
+ // A truncated list must not read as "that is all there is".
+ expect(look.elements).toHaveLength(2);
+ expect(look.elementCount).toBe(5);
+ });
+
+ it("clamps a nonsense limit instead of failing the call", () => {
+ const elements = [element({ domId: "a" }), element({ domId: "b" })];
+
+ for (const limit of [0, -3, 1.5, Number.NaN]) {
+ const look = expectOk<{ elements: unknown[] }>(
+ buildStudioLook(snapshot({ elements }), { limit }),
+ );
+ expect(look.elements).toHaveLength(2);
+ }
+ });
+
+ it("surfaces the selection with its capabilities and a usable handle", () => {
+ const look = expectOk<{
+ selection: { handle: string | null; box: { width: number }; can: { editStyles: boolean } };
+ }>(buildStudioLook(snapshot({ selection: selection() })));
+
+ expect(look.selection?.handle).toBe("hf:abc123");
+ expect(look.selection?.box.width).toBe(880);
+ expect(look.selection?.can.editStyles).toBe(true);
+ });
+
+ it("reports the live animation count supplied outside the DOM selection", () => {
+ const look = expectOk<{ selection: { animationCount: number } | null }>(
+ buildStudioLook(snapshot({ selection: selection(), selectionAnimationCount: 3 })),
+ );
+
+ expect(look.selection?.animationCount).toBe(3);
+ });
+
+ it("passes the disabled reason through so the agent learns it from a read", () => {
+ const locked = selection({
+ capabilities: {
+ ...selection().capabilities,
+ canEditStyles: false,
+ canMove: false,
+ canApplyManualOffset: false,
+ reasonIfDisabled: "Element is inside a locked composition",
+ },
+ });
+
+ const look = expectOk<{
+ selection: { can: { editStyles: boolean; move: boolean; reasonIfDisabled: string | null } };
+ }>(buildStudioLook(snapshot({ selection: locked })));
+
+ expect(look.selection?.can.editStyles).toBe(false);
+ expect(look.selection?.can.move).toBe(false);
+ expect(look.selection?.can.reasonIfDisabled).toBe("Element is inside a locked composition");
+ });
+
+ it("reports null selection rather than an empty one when nothing is selected", () => {
+ const look = expectOk<{ selection: unknown }>(buildStudioLook(snapshot({ selection: null })));
+ expect(look.selection).toBeNull();
+ });
+
+ it("does not advertise write readiness before the real write gate exists", () => {
+ const look = expectOk>(buildStudioLook(snapshot()));
+
+ expect(look).not.toHaveProperty("canWrite");
+ expect(look).not.toHaveProperty("writeBlockedReason");
+ });
+});
diff --git a/packages/studio/src/webmcp/tools/lookTools.ts b/packages/studio/src/webmcp/tools/lookTools.ts
new file mode 100644
index 0000000000..1cadcffb2b
--- /dev/null
+++ b/packages/studio/src/webmcp/tools/lookTools.ts
@@ -0,0 +1,201 @@
+/**
+ * `studio_look`: the one call that orients an agent.
+ *
+ * Deliberately fat. Every field here is one the agent would otherwise have to
+ * spend a round trip discovering.
+ *
+ * The building is a pure function over a snapshot so it can be tested with
+ * values. Gathering the snapshot is the React layer's job.
+ */
+
+import type { DomEditSelection } from "../../components/editor/domEditingTypes";
+import type { TimelineElement } from "../../player/store/timelineElement";
+import { mintElementHandle, patchTargetAddress, timelineElementAddress } from "../handles";
+import { toolOk, type ToolResult } from "../toolResult";
+
+export interface StudioLookSnapshot {
+ projectId: string | null;
+ compositionPath: string | null;
+ currentTime: number;
+ duration: number;
+ isPlaying: boolean;
+ elements: readonly TimelineElement[];
+ selection: DomEditSelection | null;
+ /** Live animations for the current selection arrive outside DomEditSelection. */
+ selectionAnimationCount: number;
+ /**
+ * The undo stack as Studio's shell actually exposes it.
+ *
+ * This is a weaker signal than a revision counter, and deliberately not
+ * dressed up as one: the depth lives in component-local state and is not
+ * reachable here without plumbing it through the shell context. What an agent
+ * CAN do is checkpoint `undoLabel` before a batch and notice it change to
+ * something it did not do, which means a human pressed undo and its earlier
+ * edits are gone.
+ */
+ history: {
+ canUndo: boolean;
+ canRedo: boolean;
+ undoLabel: string | null;
+ redoLabel: string | null;
+ };
+}
+
+interface LookElement {
+ /** Pass back to any tool that takes a handle. Null means unaddressable. */
+ handle: string | null;
+ label: string | null;
+ tag: string;
+ kind: string | null;
+ start: number;
+ duration: number;
+ track: number;
+ zIndex: number | null;
+}
+
+interface LookSelection {
+ handle: string | null;
+ label: string;
+ tagName: string;
+ sourceFile: string;
+ box: { x: number; y: number; width: number; height: number };
+ text: string | null;
+ /** What this element will and will not accept, straight from Studio. */
+ can: {
+ editStyles: boolean;
+ move: boolean;
+ resize: boolean;
+ editText: boolean;
+ reasonIfDisabled: string | null;
+ };
+ animationCount: number;
+}
+
+/**
+ * Session-scoped response shape. There is intentionally no schema version:
+ * WebMCP consumers discover the current tool and schema when they connect
+ * rather than pinning a cached REST response contract.
+ */
+export interface StudioLook {
+ projectId: string | null;
+ compositionPath: string | null;
+ playhead: number;
+ duration: number;
+ isPlaying: boolean;
+ history: StudioLookSnapshot["history"];
+ selection: LookSelection | null;
+ elementCount: number;
+ elements: LookElement[];
+}
+
+export interface StudioLookInput {
+ /** Case-insensitive substring match against label, tag, and handle. */
+ filter?: string;
+ /** Cap the returned list. The full count is always reported separately. */
+ limit?: number;
+}
+
+const DEFAULT_LIMIT = 200;
+const MAX_FILTER_LENGTH = 128;
+
+function describeElement(element: TimelineElement): LookElement {
+ return {
+ handle: mintElementHandle(timelineElementAddress(element)),
+ label: element.label ?? null,
+ tag: element.tag,
+ kind: element.kind ?? null,
+ start: element.start,
+ duration: element.duration,
+ track: element.track,
+ zIndex: element.zIndex ?? null,
+ };
+}
+
+function describeSelection(selection: DomEditSelection, animationCount: number): LookSelection {
+ const { capabilities } = selection;
+ return {
+ handle: mintElementHandle(patchTargetAddress(selection)),
+ label: selection.label,
+ tagName: selection.tagName,
+ sourceFile: selection.sourceFile,
+ box: selection.boundingBox,
+ text: selection.textContent,
+ can: {
+ editStyles: capabilities.canEditStyles,
+ move: capabilities.canMove || capabilities.canApplyManualOffset,
+ resize: capabilities.canResize || capabilities.canApplyManualSize,
+ editText: selection.textFields.length > 0,
+ reasonIfDisabled: capabilities.reasonIfDisabled ?? null,
+ },
+ animationCount,
+ };
+}
+
+function matchesFilter(element: LookElement, needle: string): boolean {
+ return (
+ (element.label?.toLowerCase().includes(needle) ?? false) ||
+ element.tag.toLowerCase().includes(needle) ||
+ (element.handle?.toLowerCase().includes(needle) ?? false)
+ );
+}
+
+export function buildStudioLook(
+ snapshot: StudioLookSnapshot,
+ input: StudioLookInput = {},
+): ToolResult {
+ const described = snapshot.elements.map(describeElement);
+ const needle = input.filter?.slice(0, MAX_FILTER_LENGTH).trim().toLowerCase();
+ const matched = needle
+ ? described.filter((element) => matchesFilter(element, needle))
+ : described;
+
+ // Clamp rather than reject: a bad limit should not cost the agent a round trip
+ // when the answer it wants is right here.
+ const requested =
+ Number.isInteger(input.limit) && input.limit! > 0 ? input.limit! : DEFAULT_LIMIT;
+ const limit = Math.min(requested, DEFAULT_LIMIT);
+
+ return toolOk({
+ projectId: snapshot.projectId,
+ compositionPath: snapshot.compositionPath,
+ playhead: snapshot.currentTime,
+ duration: snapshot.duration,
+ isPlaying: snapshot.isPlaying,
+ history: snapshot.history,
+ selection: snapshot.selection
+ ? describeSelection(snapshot.selection, snapshot.selectionAnimationCount)
+ : null,
+ // The count is of everything that MATCHED, so a truncated list is visible
+ // as a truncated list rather than reading as "that is all there is".
+ elementCount: matched.length,
+ elements: matched.slice(0, limit),
+ });
+}
+
+export const STUDIO_LOOK_INPUT_SCHEMA = {
+ type: "object",
+ properties: {
+ filter: {
+ type: "string",
+ maxLength: MAX_FILTER_LENGTH,
+ description: "Case-insensitive substring matched against element label, tag, and handle.",
+ },
+ limit: {
+ type: "integer",
+ minimum: 1,
+ maximum: DEFAULT_LIMIT,
+ description: `Cap the returned elements (default and max ${DEFAULT_LIMIT}). elementCount always reports the full match count.`,
+ },
+ },
+ additionalProperties: false,
+} as const;
+
+export const STUDIO_LOOK_DESCRIPTION = [
+ "Read HyperFrames Studio's live state in one call: the open project and composition,",
+ "the playhead and duration, what the human currently has selected (including what that",
+ "element will and will not accept), and the timeline's elements with a handle for each.",
+ "Pass a handle back to any tool that edits an element.",
+ "Returns an object with `ok: true`, or `ok: false` with `kind`, `reason` and often a `hint`.",
+ "`history.undoLabel` is worth checkpointing before a batch: if it later names something",
+ "you did not do, a human pressed undo and your earlier edits are gone.",
+].join(" ");
diff --git a/packages/studio/src/webmcp/types.ts b/packages/studio/src/webmcp/types.ts
new file mode 100644
index 0000000000..f7be58d254
--- /dev/null
+++ b/packages/studio/src/webmcp/types.ts
@@ -0,0 +1,76 @@
+/**
+ * Local typings for the WebMCP browser API, which is not in lib.dom yet.
+ *
+ * Mirrors the WebIDL in the W3C spec (`webmachinelearning/webmcp`, `index.bs`)
+ * as of 2026-08-26. Two things worth knowing before editing this file:
+ *
+ * - The API hangs off `document`, NOT `navigator`. `navigator.modelContext` is
+ * a polyfill compatibility shim, not a spec member, so feature-detecting it
+ * is wrong even where an article's sample "works".
+ * - The spec is pre-stable (Origin Trial). This file and `registrar.ts` are the
+ * only places that touch the API, so a spec change is a two-file edit. Re-read
+ * `index.bs` rather than trusting this transcription.
+ *
+ * Only the surface Studio actually uses is declared. `getTools` and
+ * `executeTool` are the consumer side; Studio registers, it does not call.
+ */
+
+export interface ModelContextToolAnnotations {
+ /** The tool does not change state. Lets an agent decide when calling is free. */
+ readOnlyHint?: boolean;
+ /** The tool's output contains data the page's author does not vouch for. */
+ untrustedContentHint?: boolean;
+}
+
+export interface ToolExecuteCallbackOptions {
+ /**
+ * Aborted when the caller cancels. Note that Studio's commit path is not
+ * cancellable once dispatched, so tools check this BEFORE dispatching and
+ * document that a late abort does not unwind a write.
+ */
+ signal: AbortSignal;
+}
+
+export interface ModelContextTool {
+ /**
+ * Max 128 characters, ASCII alphanumeric plus `_`, `-`, `.`. Registering a
+ * name that already exists REJECTS with InvalidStateError; it does not
+ * replace.
+ */
+ name: string;
+ title?: string;
+ /** Required and non-empty; an empty string rejects with InvalidStateError. */
+ description: string;
+ /** JSON Schema. Nothing in the platform validates input against it. */
+ inputSchema?: object;
+ /**
+ * The user agent JSON-serializes whatever this resolves with, so it must
+ * return an object. Returning `undefined` fails the serialization.
+ *
+ * A rejection is NOT a usable error channel: the spec discards the reason and
+ * rejects the caller with a bare UnknownError. Resolve with a tagged failure
+ * instead. See `toolResult.ts`.
+ */
+ execute: (input: object, options: ToolExecuteCallbackOptions) => Promise;
+ annotations?: ModelContextToolAnnotations;
+}
+
+export interface ModelContextRegisterToolOptions {
+ exposedTo?: string[];
+ /** Aborting unregisters the tool. It does not cancel a running `execute`. */
+ signal?: AbortSignal;
+}
+
+export interface ModelContext {
+ registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): Promise;
+}
+
+interface DocumentWithModelContext extends Document {
+ modelContext?: ModelContext;
+}
+
+/** The live WebMCP entry point, or null when this browser has not shipped it. */
+export function getModelContext(doc: Document = document): ModelContext | null {
+ const candidate = (doc as DocumentWithModelContext).modelContext;
+ return typeof candidate?.registerTool === "function" ? candidate : null;
+}
diff --git a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx
new file mode 100644
index 0000000000..544192ba43
--- /dev/null
+++ b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx
@@ -0,0 +1,220 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mountReactHarness } from "../hooks/domSelectionTestHarness";
+import { writeStudioUiPreferences } from "../utils/studioUiPreferences";
+import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
+import type { ModelContext, ModelContextRegisterToolOptions, ModelContextTool } from "./types";
+import type { StudioLookSnapshot } from "./tools/lookTools";
+
+const trackEvent = vi.hoisted(() => vi.fn());
+vi.mock("../telemetry/client", () => ({ trackEvent }));
+
+Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
+
+let cleanup: (() => void) | null = null;
+
+function snapshot(overrides: Partial = {}): StudioLookSnapshot {
+ return {
+ projectId: "demo",
+ compositionPath: "index.html",
+ currentTime: 0,
+ duration: 10,
+ isPlaying: false,
+ elements: [],
+ selection: null,
+ selectionAnimationCount: 0,
+ history: { canUndo: false, canRedo: false, undoLabel: null, redoLabel: null },
+ ...overrides,
+ };
+}
+
+/** Install a fake `document.modelContext` and report what got registered. */
+function installModelContext() {
+ const registered: ModelContextTool[] = [];
+ const registerTool = vi.fn(
+ async (tool: ModelContextTool, _options?: ModelContextRegisterToolOptions) => {
+ registered.push(tool);
+ },
+ );
+ const modelContext: ModelContext = { registerTool };
+ Object.defineProperty(document, "modelContext", {
+ value: modelContext,
+ configurable: true,
+ writable: true,
+ });
+ return { registered, registerTool };
+}
+
+function removeModelContext() {
+ Reflect.deleteProperty(document, "modelContext");
+}
+
+function mountTools(deps: StudioAgentToolsDeps) {
+ function Probe({ current }: { current: StudioAgentToolsDeps }) {
+ useStudioAgentTools(current);
+ return null;
+ }
+ const root = mountReactHarness();
+ cleanup = () => act(() => root.unmount());
+ return {
+ rerenderWith(next: StudioAgentToolsDeps) {
+ act(() => root.render());
+ },
+ };
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+ trackEvent.mockReset();
+});
+
+afterEach(() => {
+ cleanup?.();
+ cleanup = null;
+ removeModelContext();
+ window.localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+describe("useStudioAgentTools", () => {
+ it("registers the tool set once on mount", async () => {
+ const { registered } = installModelContext();
+
+ await act(async () => {
+ mountTools({ getSnapshot: () => snapshot() });
+ });
+
+ expect(registered.map((tool) => tool.name)).toEqual(["studio_look"]);
+ });
+
+ it("does not re-register when the deps object changes identity", async () => {
+ // The regression test for the whole design. The DomEdit actions object
+ // changes identity on nearly every interaction; if registration depended on
+ // it, the signal would abort and unregister the tools each time.
+ const { registerTool } = installModelContext();
+
+ let harness: ReturnType | null = null;
+ await act(async () => {
+ harness = mountTools({ getSnapshot: () => snapshot() });
+ });
+ expect(registerTool).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 5 }) });
+ harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 6 }) });
+ });
+
+ expect(registerTool).toHaveBeenCalledTimes(1);
+ });
+
+ it("executes against the LATEST deps, not the ones present at registration", async () => {
+ // The other half of the ref: registering once must not freeze the state the
+ // tools read, or every answer after the first render would be stale.
+ const { registered } = installModelContext();
+
+ let harness: ReturnType | null = null;
+ await act(async () => {
+ harness = mountTools({ getSnapshot: () => snapshot({ currentTime: 1 }) });
+ });
+
+ await act(async () => {
+ harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 42 }) });
+ });
+
+ const look = registered[0];
+ if (!look) throw new Error("expected studio_look to be registered");
+ const result = (await look.execute({}, { signal: new AbortController().signal })) as {
+ ok: boolean;
+ playhead: number;
+ };
+
+ expect(result.ok).toBe(true);
+ expect(result.playhead).toBe(42);
+ });
+
+ it("unregisters on unmount by aborting the registration signal", async () => {
+ const { registerTool } = installModelContext();
+
+ await act(async () => {
+ mountTools({ getSnapshot: () => snapshot() });
+ });
+ const signal = registerTool.mock.calls[0]?.[1]?.signal;
+ expect(signal?.aborted).toBe(false);
+
+ cleanup?.();
+ cleanup = null;
+
+ expect(signal?.aborted).toBe(true);
+ });
+
+ it("registers nothing when the browser has no WebMCP", async () => {
+ removeModelContext();
+
+ await act(async () => {
+ mountTools({ getSnapshot: () => snapshot() });
+ });
+
+ // The assertion is that mounting did not throw; a browser without the API
+ // must still boot Studio.
+ expect(document).not.toHaveProperty("modelContext");
+ });
+
+ it("registers nothing when the preference is turned off", async () => {
+ writeStudioUiPreferences({ agentToolsEnabled: false });
+ const { registerTool } = installModelContext();
+
+ await act(async () => {
+ mountTools({ getSnapshot: () => snapshot() });
+ });
+
+ expect(registerTool).not.toHaveBeenCalled();
+ });
+
+ it("registers when the preference is absent, because on is the default", async () => {
+ const { registerTool } = installModelContext();
+
+ await act(async () => {
+ mountTools({ getSnapshot: () => snapshot() });
+ });
+
+ expect(registerTool).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports a non-abort registration failure through production telemetry", async () => {
+ const { registerTool } = installModelContext();
+ registerTool.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
+
+ await act(async () => {
+ mountTools({ getSnapshot: () => snapshot() });
+ });
+
+ expect(trackEvent).toHaveBeenCalledWith("webmcp_registration_failed", {
+ error_name: "NotAllowedError",
+ tool_name: "studio_look",
+ });
+ });
+
+ it("reports a tool that throws as an internal failure instead of rejecting", async () => {
+ const { registered } = installModelContext();
+
+ await act(async () => {
+ mountTools({
+ getSnapshot: () => {
+ throw new TypeError("handler signature moved");
+ },
+ });
+ });
+ vi.spyOn(console, "error").mockImplementation(() => {});
+
+ const look = registered[0];
+ if (!look) throw new Error("expected studio_look to be registered");
+ const result = (await look.execute({}, { signal: new AbortController().signal })) as {
+ ok: boolean;
+ kind: string;
+ };
+
+ expect(result.ok).toBe(false);
+ expect(result.kind).toBe("internal");
+ });
+});
diff --git a/packages/studio/src/webmcp/useStudioAgentTools.ts b/packages/studio/src/webmcp/useStudioAgentTools.ts
new file mode 100644
index 0000000000..b691df2a74
--- /dev/null
+++ b/packages/studio/src/webmcp/useStudioAgentTools.ts
@@ -0,0 +1,108 @@
+import { useEffect, useRef } from "react";
+import { trackEvent } from "../telemetry/client";
+import { readStudioUiPreferences } from "../utils/studioUiPreferences";
+import { makeStudioDebugLogger } from "../utils/studioDebug";
+import { registerStudioTools, type ToolRegistrationReport } from "./registrar";
+import { runToolBody, type ToolResult } from "./toolResult";
+import { getModelContext, type ModelContext, type ModelContextTool } from "./types";
+import {
+ buildStudioLook,
+ STUDIO_LOOK_DESCRIPTION,
+ STUDIO_LOOK_INPUT_SCHEMA,
+ type StudioLook,
+ type StudioLookInput,
+ type StudioLookSnapshot,
+} from "./tools/lookTools";
+
+const log = makeStudioDebugLogger("webmcp");
+
+function reportRegistration(report: ToolRegistrationReport): void {
+ log("registered", { ...report });
+ for (const failure of report.failed) {
+ trackEvent("webmcp_registration_failed", {
+ error_name: failure.name,
+ tool_name: failure.tool,
+ });
+ }
+}
+
+export interface StudioAgentToolsDeps {
+ /** Read Studio's current state. Called per tool invocation, never cached. */
+ getSnapshot: () => StudioLookSnapshot;
+}
+
+/**
+ * Build the tool list once.
+ *
+ * Every `execute` reads `depsRef.current` at CALL time rather than closing over
+ * a snapshot, which is what lets the list be built once and still see live
+ * state. That is the whole point of the ref: see the registration note below.
+ */
+function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): ModelContextTool[] {
+ return [
+ {
+ name: "studio_look",
+ title: "Look at the composition",
+ description: STUDIO_LOOK_DESCRIPTION,
+ inputSchema: STUDIO_LOOK_INPUT_SCHEMA,
+ annotations: {
+ readOnlyHint: true,
+ // The labels, text and ids come from the user's composition, which can
+ // contain anything. This is a hint to the agent, not a sanitiser.
+ untrustedContentHint: true,
+ },
+ execute: (input): Promise> =>
+ runToolBody("studio_look", async () =>
+ buildStudioLook(depsRef.current.getSnapshot(), input as StudioLookInput),
+ ),
+ },
+ ];
+}
+
+/**
+ * Register Studio's tools with the browser, exactly once per mount.
+ *
+ * The effect has an EMPTY dependency array on purpose, and the deps live in a
+ * ref that every render refreshes. The obvious alternative — depend on the
+ * handlers — re-runs on nearly every interaction, because the DomEdit actions
+ * object changes identity whenever the selection or the element list does.
+ * Re-running means the registration signal aborts and unregisters everything,
+ * `toolchange` fires constantly so a connected agent watches the tool list
+ * churn, and the spec warns that a quick unregister-then-reregister can apply
+ * an old call's arguments against the new schema.
+ *
+ * `useStudioTestHooks` carries a comment about the same class of bug already hit
+ * in this codebase, where effect teardown revoked a lease moments after it was
+ * taken because writing state changed the effect's dependency identities.
+ *
+ * Any fallback must be awaited inside this effect before registration and then
+ * re-read here. Installing one from a sibling effect would race this mount-only
+ * lookup. Hot-module replacement can still create a brief unregister/register
+ * window in development; production has one document-scoped registration.
+ */
+export function useStudioAgentTools(deps: StudioAgentToolsDeps): void {
+ const depsRef = useRef(deps);
+ depsRef.current = deps;
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (readStudioUiPreferences().agentToolsEnabled === false) {
+ log("skipped", { why: "disabled by preference" });
+ return;
+ }
+
+ const modelContext: ModelContext | null = getModelContext();
+ if (!modelContext) {
+ // Expected on any browser that has not shipped WebMCP. Not an error.
+ log("skipped", { why: "document.modelContext absent" });
+ return;
+ }
+
+ const controller = new AbortController();
+ void registerStudioTools(modelContext, buildStudioTools(depsRef), controller.signal).then(
+ reportRegistration,
+ );
+
+ return () => controller.abort();
+ }, []);
+}