@hyperframes/studio 0.8.16 → 0.8.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DejBDgXB.js} +1 -1
  2. package/dist/assets/index-BX3KHhGX.js +71 -0
  3. package/dist/assets/{index-D8o3ZIo2.js → index-CU6o8PuW.js} +128 -128
  4. package/dist/assets/{index-Cf-mbMRL.js → index-CklNVmi3.js} +1 -1
  5. package/dist/assets/{index-YmetcS6L.js → index-uu4Zd3BU.js} +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +1638 -1280
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/components/EditorShell.tsx +4 -0
  12. package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
  13. package/src/components/editor/DomEditCropHandles.tsx +1 -1
  14. package/src/components/editor/DomEditOverlay.tsx +1 -1
  15. package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
  16. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
  17. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  18. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
  19. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
  20. package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
  23. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  24. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  25. package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
  26. package/src/components/editor/propertyPanelTypes.ts +1 -1
  27. package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
  28. package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
  29. package/src/hooks/domEditCommitRunner.ts +47 -0
  30. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
  31. package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
  32. package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
  33. package/src/hooks/useDomEditTextCommits.ts +17 -9
  34. package/src/hooks/useDomEditWiring.ts +1 -1
  35. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  36. package/src/hooks/useDomGeometryCommits.ts +10 -2
  37. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
  38. package/src/hooks/useElementLifecycleOps.ts +11 -5
  39. package/src/hooks/useGsapSelectionHandlers.ts +4 -2
  40. package/src/utils/studioUiPreferences.ts +11 -0
  41. package/src/webmcp/StudioAgentTools.tsx +46 -0
  42. package/src/webmcp/handles.test.ts +130 -0
  43. package/src/webmcp/handles.ts +129 -0
  44. package/src/webmcp/polyfill.test.ts +98 -0
  45. package/src/webmcp/polyfill.ts +60 -0
  46. package/src/webmcp/registrar.test.ts +150 -0
  47. package/src/webmcp/registrar.ts +115 -0
  48. package/src/webmcp/toolResult.ts +67 -0
  49. package/src/webmcp/tools/lookTools.test.ts +225 -0
  50. package/src/webmcp/tools/lookTools.ts +201 -0
  51. package/src/webmcp/types.ts +90 -0
  52. package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
  53. package/src/webmcp/useStudioAgentTools.ts +119 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The shape every Studio tool resolves with.
3
+ *
4
+ * Tools resolve, they never reject. That is forced by the spec, not a style
5
+ * choice: a rejected `execute` has its reason DISCARDED and the caller is
6
+ * rejected with a bare `UnknownError` (`index.bs`, the execute-tool completion
7
+ * steps). Rejecting would therefore guarantee the agent cannot see why the edit
8
+ * failed, which is the one thing it needs most.
9
+ *
10
+ * There is no `outputSchema` in the platform yet, so this discriminant is
11
+ * invisible to the agent's schema layer. Every tool's `description` has to say
12
+ * that it returns `ok`.
13
+ */
14
+
15
+ export type ToolFailureKind =
16
+ /** A real state the agent can route around: save queue paused, capability off. */
17
+ | "blocked"
18
+ /** The agent's fault: unknown handle, bad enum, out of range. */
19
+ | "invalid"
20
+ /** Exogenous: the server said no, the patch target could not be resolved. */
21
+ | "failed"
22
+ /** Our bug. Reported AND re-thrown, so it is findable instead of plausible. */
23
+ | "internal";
24
+
25
+ export interface ToolFailure {
26
+ ok: false;
27
+ kind: ToolFailureKind;
28
+ reason: string;
29
+ /** What to try instead. This is what turns a failure into a next action. */
30
+ hint?: string;
31
+ }
32
+
33
+ export type ToolResult<T> = ({ ok: true } & T) | ToolFailure;
34
+
35
+ export function toolOk<T extends object>(value: T): { ok: true } & T {
36
+ return { ok: true, ...value };
37
+ }
38
+
39
+ function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
40
+ return hint ? { ok: false, kind, reason, hint } : { ok: false, kind, reason };
41
+ }
42
+
43
+ /**
44
+ * Wrap a tool body so a thrown bug becomes a legible result instead of a
45
+ * rejection the agent cannot read.
46
+ *
47
+ * The split matters. A `TypeError` means a handler signature moved under us and
48
+ * the tool is permanently broken; reporting that as an ordinary failure would
49
+ * let it ship looking like a bad request forever. So it is tagged `internal`
50
+ * AND re-thrown to the console, where it is findable. Everything else is a
51
+ * failure the agent should route around.
52
+ */
53
+ export async function runToolBody<T>(
54
+ toolName: string,
55
+ body: () => Promise<ToolResult<T>>,
56
+ ): Promise<ToolResult<T>> {
57
+ try {
58
+ return await body();
59
+ } catch (error) {
60
+ const reason = error instanceof Error ? error.message : String(error);
61
+ if (error instanceof TypeError || error instanceof ReferenceError) {
62
+ console.error(`[hf-webmcp] ${toolName} threw`, error);
63
+ return toolFailure("internal", reason);
64
+ }
65
+ return toolFailure("failed", reason);
66
+ }
67
+ }
@@ -0,0 +1,225 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import type { DomEditSelection } from "../../components/editor/domEditingTypes";
4
+ import type { TimelineElement } from "../../player/store/timelineElement";
5
+ import { buildStudioLook, STUDIO_LOOK_INPUT_SCHEMA, type StudioLookSnapshot } from "./lookTools";
6
+
7
+ function element(overrides: Partial<TimelineElement>): TimelineElement {
8
+ return { id: "synthetic", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
9
+ }
10
+
11
+ function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapshot {
12
+ return {
13
+ projectId: "demo",
14
+ compositionPath: "index.html",
15
+ currentTime: 1.5,
16
+ duration: 10,
17
+ isPlaying: false,
18
+ elements: [],
19
+ selection: null,
20
+ selectionAnimationCount: 0,
21
+ history: { canUndo: true, canRedo: false, undoLabel: "Move layer", redoLabel: null },
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function selection(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
27
+ return {
28
+ id: "headline",
29
+ hfId: "abc123",
30
+ element: document.createElement("div"),
31
+ label: "Headline",
32
+ tagName: "h1",
33
+ sourceFile: "index.html",
34
+ compositionPath: "index.html",
35
+ isCompositionHost: false,
36
+ isInsideLockedComposition: false,
37
+ boundingBox: { x: 40, y: 12, width: 880, height: 96 },
38
+ textContent: "Ship it",
39
+ dataAttributes: {},
40
+ inlineStyles: {},
41
+ computedStyles: {},
42
+ textFields: [],
43
+ capabilities: {
44
+ canSelect: true,
45
+ canEditStyles: true,
46
+ canCrop: true,
47
+ canMove: true,
48
+ canResize: true,
49
+ canApplyManualOffset: true,
50
+ canApplyManualSize: true,
51
+ canApplyManualRotation: true,
52
+ },
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ function expectOk<T>(result: { ok: boolean } & Record<string, unknown>): T {
58
+ if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
59
+ return result as unknown as T;
60
+ }
61
+
62
+ describe("buildStudioLook", () => {
63
+ it("reports the playhead, duration and undo label an agent needs to checkpoint", () => {
64
+ const look = expectOk<{
65
+ playhead: number;
66
+ duration: number;
67
+ history: { undoLabel: string | null };
68
+ }>(
69
+ buildStudioLook(
70
+ snapshot({
71
+ currentTime: 2.4,
72
+ duration: 30,
73
+ history: { canUndo: true, canRedo: false, undoLabel: "Edit text", redoLabel: null },
74
+ }),
75
+ ),
76
+ );
77
+
78
+ expect(look.playhead).toBe(2.4);
79
+ expect(look.duration).toBe(30);
80
+ expect(look.history.undoLabel).toBe("Edit text");
81
+ });
82
+
83
+ it("gives every addressable element a handle a write tool can consume", () => {
84
+ const look = expectOk<{ elements: { handle: string | null; label: string | null }[] }>(
85
+ buildStudioLook(
86
+ snapshot({
87
+ elements: [
88
+ element({ hfId: "abc", label: "Headline" }),
89
+ element({ domId: "cta", label: "Button" }),
90
+ element({ selector: ".card", selectorIndex: 2, label: "Card" }),
91
+ ],
92
+ }),
93
+ ),
94
+ );
95
+
96
+ expect(look.elements.map((e) => e.handle)).toEqual(["hf:abc", "dom:cta", "sel:.card#2"]);
97
+ });
98
+
99
+ it("reports an unaddressable element with a null handle rather than hiding it", () => {
100
+ const look = expectOk<{ elements: { handle: string | null }[]; elementCount: number }>(
101
+ buildStudioLook(snapshot({ elements: [element({ label: "Anonymous" })] })),
102
+ );
103
+
104
+ expect(look.elementCount).toBe(1);
105
+ expect(look.elements[0]?.handle).toBeNull();
106
+ });
107
+
108
+ it("returns an empty list for an empty timeline, not a failure", () => {
109
+ const look = expectOk<{ elements: unknown[]; elementCount: number }>(
110
+ buildStudioLook(snapshot()),
111
+ );
112
+
113
+ expect(look.elements).toEqual([]);
114
+ expect(look.elementCount).toBe(0);
115
+ });
116
+
117
+ it("filters on label, tag and handle, case-insensitively", () => {
118
+ const elements = [
119
+ element({ hfId: "abc", label: "Headline", tag: "h1" }),
120
+ element({ domId: "cta", label: "Button", tag: "button" }),
121
+ ];
122
+
123
+ const byLabel = expectOk<{ elements: { handle: string | null }[] }>(
124
+ buildStudioLook(snapshot({ elements }), { filter: "HEADLINE" }),
125
+ );
126
+ const byTag = expectOk<{ elements: { handle: string | null }[] }>(
127
+ buildStudioLook(snapshot({ elements }), { filter: "button" }),
128
+ );
129
+ const byHandle = expectOk<{ elements: { handle: string | null }[] }>(
130
+ buildStudioLook(snapshot({ elements }), { filter: "hf:abc" }),
131
+ );
132
+
133
+ expect(byLabel.elements.map((e) => e.handle)).toEqual(["hf:abc"]);
134
+ expect(byTag.elements.map((e) => e.handle)).toEqual(["dom:cta"]);
135
+ expect(byHandle.elements.map((e) => e.handle)).toEqual(["hf:abc"]);
136
+ });
137
+
138
+ it("bounds a filter before normalizing it", () => {
139
+ const boundedFilter = "x".repeat(128);
140
+ const look = expectOk<{ elements: { handle: string | null }[] }>(
141
+ buildStudioLook(
142
+ snapshot({ elements: [element({ domId: "bounded", label: boundedFilter })] }),
143
+ { filter: `${boundedFilter}${"y".repeat(10_000)}` },
144
+ ),
145
+ );
146
+
147
+ expect(look.elements.map((entry) => entry.handle)).toEqual(["dom:bounded"]);
148
+ expect(STUDIO_LOOK_INPUT_SCHEMA.properties.filter.maxLength).toBe(128);
149
+ });
150
+
151
+ it("keeps the true match count when the list is truncated", () => {
152
+ const elements = Array.from({ length: 5 }, (_, index) =>
153
+ element({ domId: `el-${index}`, label: "Card" }),
154
+ );
155
+
156
+ const look = expectOk<{ elements: unknown[]; elementCount: number }>(
157
+ buildStudioLook(snapshot({ elements }), { limit: 2 }),
158
+ );
159
+
160
+ // A truncated list must not read as "that is all there is".
161
+ expect(look.elements).toHaveLength(2);
162
+ expect(look.elementCount).toBe(5);
163
+ });
164
+
165
+ it("clamps a nonsense limit instead of failing the call", () => {
166
+ const elements = [element({ domId: "a" }), element({ domId: "b" })];
167
+
168
+ for (const limit of [0, -3, 1.5, Number.NaN]) {
169
+ const look = expectOk<{ elements: unknown[] }>(
170
+ buildStudioLook(snapshot({ elements }), { limit }),
171
+ );
172
+ expect(look.elements).toHaveLength(2);
173
+ }
174
+ });
175
+
176
+ it("surfaces the selection with its capabilities and a usable handle", () => {
177
+ const look = expectOk<{
178
+ selection: { handle: string | null; box: { width: number }; can: { editStyles: boolean } };
179
+ }>(buildStudioLook(snapshot({ selection: selection() })));
180
+
181
+ expect(look.selection?.handle).toBe("hf:abc123");
182
+ expect(look.selection?.box.width).toBe(880);
183
+ expect(look.selection?.can.editStyles).toBe(true);
184
+ });
185
+
186
+ it("reports the live animation count supplied outside the DOM selection", () => {
187
+ const look = expectOk<{ selection: { animationCount: number } | null }>(
188
+ buildStudioLook(snapshot({ selection: selection(), selectionAnimationCount: 3 })),
189
+ );
190
+
191
+ expect(look.selection?.animationCount).toBe(3);
192
+ });
193
+
194
+ it("passes the disabled reason through so the agent learns it from a read", () => {
195
+ const locked = selection({
196
+ capabilities: {
197
+ ...selection().capabilities,
198
+ canEditStyles: false,
199
+ canMove: false,
200
+ canApplyManualOffset: false,
201
+ reasonIfDisabled: "Element is inside a locked composition",
202
+ },
203
+ });
204
+
205
+ const look = expectOk<{
206
+ selection: { can: { editStyles: boolean; move: boolean; reasonIfDisabled: string | null } };
207
+ }>(buildStudioLook(snapshot({ selection: locked })));
208
+
209
+ expect(look.selection?.can.editStyles).toBe(false);
210
+ expect(look.selection?.can.move).toBe(false);
211
+ expect(look.selection?.can.reasonIfDisabled).toBe("Element is inside a locked composition");
212
+ });
213
+
214
+ it("reports null selection rather than an empty one when nothing is selected", () => {
215
+ const look = expectOk<{ selection: unknown }>(buildStudioLook(snapshot({ selection: null })));
216
+ expect(look.selection).toBeNull();
217
+ });
218
+
219
+ it("does not advertise write readiness before the real write gate exists", () => {
220
+ const look = expectOk<Record<string, unknown>>(buildStudioLook(snapshot()));
221
+
222
+ expect(look).not.toHaveProperty("canWrite");
223
+ expect(look).not.toHaveProperty("writeBlockedReason");
224
+ });
225
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * `studio_look`: the one call that orients an agent.
3
+ *
4
+ * Deliberately fat. Every field here is one the agent would otherwise have to
5
+ * spend a round trip discovering.
6
+ *
7
+ * The building is a pure function over a snapshot so it can be tested with
8
+ * values. Gathering the snapshot is the React layer's job.
9
+ */
10
+
11
+ import type { DomEditSelection } from "../../components/editor/domEditingTypes";
12
+ import type { TimelineElement } from "../../player/store/timelineElement";
13
+ import { mintElementHandle, patchTargetAddress, timelineElementAddress } from "../handles";
14
+ import { toolOk, type ToolResult } from "../toolResult";
15
+
16
+ export interface StudioLookSnapshot {
17
+ projectId: string | null;
18
+ compositionPath: string | null;
19
+ currentTime: number;
20
+ duration: number;
21
+ isPlaying: boolean;
22
+ elements: readonly TimelineElement[];
23
+ selection: DomEditSelection | null;
24
+ /** Live animations for the current selection arrive outside DomEditSelection. */
25
+ selectionAnimationCount: number;
26
+ /**
27
+ * The undo stack as Studio's shell actually exposes it.
28
+ *
29
+ * This is a weaker signal than a revision counter, and deliberately not
30
+ * dressed up as one: the depth lives in component-local state and is not
31
+ * reachable here without plumbing it through the shell context. What an agent
32
+ * CAN do is checkpoint `undoLabel` before a batch and notice it change to
33
+ * something it did not do, which means a human pressed undo and its earlier
34
+ * edits are gone.
35
+ */
36
+ history: {
37
+ canUndo: boolean;
38
+ canRedo: boolean;
39
+ undoLabel: string | null;
40
+ redoLabel: string | null;
41
+ };
42
+ }
43
+
44
+ interface LookElement {
45
+ /** Pass back to any tool that takes a handle. Null means unaddressable. */
46
+ handle: string | null;
47
+ label: string | null;
48
+ tag: string;
49
+ kind: string | null;
50
+ start: number;
51
+ duration: number;
52
+ track: number;
53
+ zIndex: number | null;
54
+ }
55
+
56
+ interface LookSelection {
57
+ handle: string | null;
58
+ label: string;
59
+ tagName: string;
60
+ sourceFile: string;
61
+ box: { x: number; y: number; width: number; height: number };
62
+ text: string | null;
63
+ /** What this element will and will not accept, straight from Studio. */
64
+ can: {
65
+ editStyles: boolean;
66
+ move: boolean;
67
+ resize: boolean;
68
+ editText: boolean;
69
+ reasonIfDisabled: string | null;
70
+ };
71
+ animationCount: number;
72
+ }
73
+
74
+ /**
75
+ * Session-scoped response shape. There is intentionally no schema version:
76
+ * WebMCP consumers discover the current tool and schema when they connect
77
+ * rather than pinning a cached REST response contract.
78
+ */
79
+ export interface StudioLook {
80
+ projectId: string | null;
81
+ compositionPath: string | null;
82
+ playhead: number;
83
+ duration: number;
84
+ isPlaying: boolean;
85
+ history: StudioLookSnapshot["history"];
86
+ selection: LookSelection | null;
87
+ elementCount: number;
88
+ elements: LookElement[];
89
+ }
90
+
91
+ export interface StudioLookInput {
92
+ /** Case-insensitive substring match against label, tag, and handle. */
93
+ filter?: string;
94
+ /** Cap the returned list. The full count is always reported separately. */
95
+ limit?: number;
96
+ }
97
+
98
+ const DEFAULT_LIMIT = 200;
99
+ const MAX_FILTER_LENGTH = 128;
100
+
101
+ function describeElement(element: TimelineElement): LookElement {
102
+ return {
103
+ handle: mintElementHandle(timelineElementAddress(element)),
104
+ label: element.label ?? null,
105
+ tag: element.tag,
106
+ kind: element.kind ?? null,
107
+ start: element.start,
108
+ duration: element.duration,
109
+ track: element.track,
110
+ zIndex: element.zIndex ?? null,
111
+ };
112
+ }
113
+
114
+ function describeSelection(selection: DomEditSelection, animationCount: number): LookSelection {
115
+ const { capabilities } = selection;
116
+ return {
117
+ handle: mintElementHandle(patchTargetAddress(selection)),
118
+ label: selection.label,
119
+ tagName: selection.tagName,
120
+ sourceFile: selection.sourceFile,
121
+ box: selection.boundingBox,
122
+ text: selection.textContent,
123
+ can: {
124
+ editStyles: capabilities.canEditStyles,
125
+ move: capabilities.canMove || capabilities.canApplyManualOffset,
126
+ resize: capabilities.canResize || capabilities.canApplyManualSize,
127
+ editText: selection.textFields.length > 0,
128
+ reasonIfDisabled: capabilities.reasonIfDisabled ?? null,
129
+ },
130
+ animationCount,
131
+ };
132
+ }
133
+
134
+ function matchesFilter(element: LookElement, needle: string): boolean {
135
+ return (
136
+ (element.label?.toLowerCase().includes(needle) ?? false) ||
137
+ element.tag.toLowerCase().includes(needle) ||
138
+ (element.handle?.toLowerCase().includes(needle) ?? false)
139
+ );
140
+ }
141
+
142
+ export function buildStudioLook(
143
+ snapshot: StudioLookSnapshot,
144
+ input: StudioLookInput = {},
145
+ ): ToolResult<StudioLook> {
146
+ const described = snapshot.elements.map(describeElement);
147
+ const needle = input.filter?.slice(0, MAX_FILTER_LENGTH).trim().toLowerCase();
148
+ const matched = needle
149
+ ? described.filter((element) => matchesFilter(element, needle))
150
+ : described;
151
+
152
+ // Clamp rather than reject: a bad limit should not cost the agent a round trip
153
+ // when the answer it wants is right here.
154
+ const requested =
155
+ Number.isInteger(input.limit) && input.limit! > 0 ? input.limit! : DEFAULT_LIMIT;
156
+ const limit = Math.min(requested, DEFAULT_LIMIT);
157
+
158
+ return toolOk<StudioLook>({
159
+ projectId: snapshot.projectId,
160
+ compositionPath: snapshot.compositionPath,
161
+ playhead: snapshot.currentTime,
162
+ duration: snapshot.duration,
163
+ isPlaying: snapshot.isPlaying,
164
+ history: snapshot.history,
165
+ selection: snapshot.selection
166
+ ? describeSelection(snapshot.selection, snapshot.selectionAnimationCount)
167
+ : null,
168
+ // The count is of everything that MATCHED, so a truncated list is visible
169
+ // as a truncated list rather than reading as "that is all there is".
170
+ elementCount: matched.length,
171
+ elements: matched.slice(0, limit),
172
+ });
173
+ }
174
+
175
+ export const STUDIO_LOOK_INPUT_SCHEMA = {
176
+ type: "object",
177
+ properties: {
178
+ filter: {
179
+ type: "string",
180
+ maxLength: MAX_FILTER_LENGTH,
181
+ description: "Case-insensitive substring matched against element label, tag, and handle.",
182
+ },
183
+ limit: {
184
+ type: "integer",
185
+ minimum: 1,
186
+ maximum: DEFAULT_LIMIT,
187
+ description: `Cap the returned elements (default and max ${DEFAULT_LIMIT}). elementCount always reports the full match count.`,
188
+ },
189
+ },
190
+ additionalProperties: false,
191
+ } as const;
192
+
193
+ export const STUDIO_LOOK_DESCRIPTION = [
194
+ "Read HyperFrames Studio's live state in one call: the open project and composition,",
195
+ "the playhead and duration, what the human currently has selected (including what that",
196
+ "element will and will not accept), and the timeline's elements with a handle for each.",
197
+ "Pass a handle back to any tool that edits an element.",
198
+ "Returns an object with `ok: true`, or `ok: false` with `kind`, `reason` and often a `hint`.",
199
+ "`history.undoLabel` is worth checkpointing before a batch: if it later names something",
200
+ "you did not do, a human pressed undo and your earlier edits are gone.",
201
+ ].join(" ");
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The slice of the WebMCP browser API that Studio uses.
3
+ *
4
+ * Mirrors the WebIDL in the W3C spec (`webmachinelearning/webmcp`, `index.bs`)
5
+ * as of 2026-08-26. Two things worth knowing before editing this file:
6
+ *
7
+ * - The API hangs off `document`, NOT `navigator`. `navigator.modelContext` is
8
+ * a polyfill compatibility shim, not a spec member, so feature-detecting it
9
+ * is wrong even where an article's sample "works".
10
+ * - The spec is pre-stable (Origin Trial). This file and `registrar.ts` are the
11
+ * only places that touch the API, so a spec change is a two-file edit. Re-read
12
+ * `index.bs` rather than trusting this transcription.
13
+ *
14
+ * Only the surface Studio registers against is declared. `getTools` and
15
+ * `executeTool` are the consumer side; Studio registers, it does not call.
16
+ *
17
+ * These stay hand-written rather than imported from `@mcp-b/webmcp-types`,
18
+ * which the polyfill pulls in. That package's `registerTool` is overloaded to
19
+ * infer argument types from a literal `inputSchema`, which is useful when you
20
+ * register one tool inline and actively hostile when you register a uniform
21
+ * list of them, as `registerStudioTools` does. Narrower is the safe operation
22
+ * here. It does mean this file can drift from the spec, hence the note above.
23
+ */
24
+
25
+ export interface ModelContextToolAnnotations {
26
+ /** The tool does not change state. Lets an agent decide when calling is free. */
27
+ readOnlyHint?: boolean;
28
+ /** The tool's output contains data the page's author does not vouch for. */
29
+ untrustedContentHint?: boolean;
30
+ }
31
+
32
+ export interface ToolExecuteCallbackOptions {
33
+ /**
34
+ * Aborted when the caller cancels. Studio's commit path is not cancellable
35
+ * once dispatched, so tools check this BEFORE dispatching and document that a
36
+ * late abort does not unwind a write.
37
+ */
38
+ signal: AbortSignal;
39
+ }
40
+
41
+ export interface ModelContextTool {
42
+ /**
43
+ * Max 128 characters, ASCII alphanumeric plus `_`, `-`, `.`. Registering a
44
+ * name that already exists REJECTS with InvalidStateError; it does not
45
+ * replace.
46
+ */
47
+ name: string;
48
+ title?: string;
49
+ /** Required and non-empty; an empty string rejects with InvalidStateError. */
50
+ description: string;
51
+ /** JSON Schema. Nothing in the platform validates input against it. */
52
+ inputSchema?: object;
53
+ /**
54
+ * The user agent JSON-serializes whatever this resolves with, so it must
55
+ * return an object. Returning `undefined` fails the serialization.
56
+ *
57
+ * A rejection is NOT a usable error channel: the spec discards the reason and
58
+ * rejects the caller with a bare UnknownError. Resolve with a tagged failure
59
+ * instead. See `toolResult.ts`.
60
+ */
61
+ execute: (input: object, options: ToolExecuteCallbackOptions) => Promise<unknown>;
62
+ annotations?: ModelContextToolAnnotations;
63
+ }
64
+
65
+ export interface ModelContextRegisterToolOptions {
66
+ exposedTo?: string[];
67
+ /** Aborting unregisters the tool. It does not cancel a running `execute`. */
68
+ signal?: AbortSignal;
69
+ }
70
+
71
+ export interface ModelContext {
72
+ registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): Promise<void>;
73
+ }
74
+
75
+ function isModelContext(value: unknown): value is ModelContext {
76
+ if (typeof value !== "object" || value === null) return false;
77
+ return typeof Reflect.get(value, "registerTool") === "function";
78
+ }
79
+
80
+ /**
81
+ * The live WebMCP entry point, or null when this browser has not shipped it.
82
+ *
83
+ * Reads through a guard rather than augmenting the `Document` interface. The
84
+ * polyfill's typings already declare `Document.modelContext` globally, and a
85
+ * second, narrower declaration of the same property is a type error.
86
+ */
87
+ export function getModelContext(doc: Document = document): ModelContext | null {
88
+ const candidate = Reflect.get(doc, "modelContext");
89
+ return isModelContext(candidate) ? candidate : null;
90
+ }