Skip to main content

forge_workspace/mcp/browser/
specs.rs

1//! The tool surface: what a session is offered, transcribed from
2//! `@playwright/mcp` 0.0.83's own `tools/list` - the capture preserved with
3//! the V1 spec.
4//!
5//! **Names and argument schemas are upstream's, exactly**, and so are the
6//! descriptions except the two named below. A prompt written against a
7//! Playwright MCP server names `browser_navigate`
8//! and passes a snapshot's `target` ref, so a renamed tool or a moved
9//! argument is a prompt that resolves to something else. The additions this
10//! family carries beyond upstream are marked as such, and each names an
11//! argument upstream does not have - `force`, `expression`, the hand-off's
12//! `tab` - or stands as a tool of its own built from the same driver. Two
13//! descriptions differ too: `browser_snapshot` gains the sentences on a ref's
14//! lifetime and the route that fits a page still changing, neither of which
15//! upstream states, and `browser_close`'s is this family's own, naming the
16//! session's current tab because that is what a close means here.
17//!
18//! Kept apart from the tools themselves so the surface a model reads is one
19//! table rather than a struct apiece: a shape is edited here and nowhere
20//! else.
21
22use serde_json::{Value, json};
23
24/// One upstream tool: the name the model calls it by, the description it
25/// reads, and the argument schema the CLI validates against.
26pub(crate) struct ToolSpec {
27    pub(crate) name: &'static str,
28    pub(crate) description: &'static str,
29    pub(crate) schema: Value,
30}
31
32/// The whole surface, in upstream's own order.
33///
34/// **No tool names a profile.** Profiles are config-owned: a session drives
35/// the one its org and project resolve to in `forge.toml`, and its calls land
36/// on its own tabs.
37pub(crate) fn specs() -> Vec<ToolSpec> {
38    let specs = vec![
39        ToolSpec {
40            name: "browser_close",
41            description: "Close the calling session's current tab",
42            schema: json!({
43                "$schema": "https://json-schema.org/draft/2020-12/schema",
44                "type": "object",
45                "properties": {},
46                "additionalProperties": false
47            }),
48        },
49        ToolSpec {
50            name: "browser_resize",
51            description: "Resize the browser window",
52            schema: json!({
53                "$schema": "https://json-schema.org/draft/2020-12/schema",
54                "type": "object",
55                "properties": {
56                    "width": {
57                        "type": "number",
58                        "description": "Width of the browser window"
59                    },
60                    "height": {
61                        "type": "number",
62                        "description": "Height of the browser window"
63                    }
64                },
65                "required": ["width", "height"],
66                "additionalProperties": false
67            }),
68        },
69        ToolSpec {
70            name: "browser_console_messages",
71            description: "Returns all console messages",
72            schema: json!({
73                "$schema": "https://json-schema.org/draft/2020-12/schema",
74                "type": "object",
75                "properties": {
76                    "level": {
77                        "default": "info",
78                        "description": "Level of the console messages to return. Each level includes the messages of more severe levels. Defaults to \"info\".",
79                        "type": "string",
80                        "enum": ["error", "warning", "info", "debug"]
81                    },
82                    "all": {
83                        "description": "Return all console messages since the beginning of the session, not just since the last navigation. Defaults to false.",
84                        "type": "boolean"
85                    },
86                    "filename": {
87                        "description": "File name to save the console messages to. Relative file names are resolved against the workspace root. If not provided, messages are returned as text.",
88                        "type": "string"
89                    }
90                },
91                "required": ["level"],
92                "additionalProperties": false
93            }),
94        },
95        ToolSpec {
96            name: "browser_handle_dialog",
97            description: "Handle a dialog. A page's dialog is held until it is answered, and every call that would drive that tab is refused until then; this is the way to answer or dismiss it.",
98            schema: json!({
99                "$schema": "https://json-schema.org/draft/2020-12/schema",
100                "type": "object",
101                "properties": {
102                    "accept": {
103                        "type": "boolean",
104                        "description": "Whether to accept the dialog."
105                    },
106                    "promptText": {
107                        "description": "The text of the prompt in case of a prompt dialog.",
108                        "type": "string"
109                    }
110                },
111                "required": ["accept"],
112                "additionalProperties": false
113            }),
114        },
115        ToolSpec {
116            name: "browser_emulate_media",
117            description: "Emulate CSS media features for the page, for example switch between the light and dark color scheme. Omitted parameters are left unchanged; null clears an override.",
118            schema: json!({
119                "$schema": "https://json-schema.org/draft/2020-12/schema",
120                "type": "object",
121                "properties": {
122                    "colorScheme": {
123                        "description": "Emulates the prefers-color-scheme media feature",
124                        "anyOf": [
125                            { "type": "string", "enum": ["light", "dark"] },
126                            { "type": "null" }
127                        ]
128                    },
129                    "reducedMotion": {
130                        "description": "Emulates the prefers-reduced-motion media feature",
131                        "anyOf": [
132                            { "type": "string", "enum": ["reduce", "no-preference"] },
133                            { "type": "null" }
134                        ]
135                    },
136                    "forcedColors": {
137                        "description": "Emulates the forced-colors media feature",
138                        "anyOf": [
139                            { "type": "string", "enum": ["active", "none"] },
140                            { "type": "null" }
141                        ]
142                    },
143                    "contrast": {
144                        "description": "Emulates the prefers-contrast media feature",
145                        "anyOf": [
146                            { "type": "string", "enum": ["more", "no-preference"] },
147                            { "type": "null" }
148                        ]
149                    },
150                    "media": {
151                        "description": "Changes the CSS media type of the page",
152                        "anyOf": [
153                            { "type": "string", "enum": ["screen", "print"] },
154                            { "type": "null" }
155                        ]
156                    }
157                },
158                "additionalProperties": false
159            }),
160        },
161        ToolSpec {
162            name: "browser_evaluate",
163            description: "Evaluate JavaScript expression on page or element",
164            schema: json!({
165                "$schema": "https://json-schema.org/draft/2020-12/schema",
166                "type": "object",
167                "properties": {
168                    "element": {
169                        "description": "Human-readable element description used to obtain permission to interact with the element",
170                        "type": "string"
171                    },
172                    "target": {
173                        "description": "Exact target element reference from the page snapshot, or a unique element selector",
174                        "type": "string"
175                    },
176                    "function": {
177                        "type": "string",
178                        "description": "() => { /* code */ } or (element) => { /* code */ } when element is provided"
179                    },
180                    "filename": {
181                        "description": "File name to save the result to. Relative file names are resolved against the workspace root. If not provided, result is returned as text.",
182                        "type": "string"
183                    }
184                },
185                "required": ["function"],
186                "additionalProperties": false
187            }),
188        },
189        ToolSpec {
190            name: "browser_file_upload",
191            description: "Upload one or multiple files",
192            schema: json!({
193                "$schema": "https://json-schema.org/draft/2020-12/schema",
194                "type": "object",
195                "properties": {
196                    "paths": {
197                        "description": "The absolute paths to the files to upload. Can be single file or multiple files. If omitted, file chooser is cancelled.",
198                        "type": "array",
199                        "items": { "type": "string" }
200                    }
201                },
202                "additionalProperties": false
203            }),
204        },
205        ToolSpec {
206            name: "browser_drop",
207            description: "Drop files or MIME-typed data onto an element, as if dragged from outside the page. At least one of \"paths\" or \"data\" must be provided.",
208            schema: json!({
209                "$schema": "https://json-schema.org/draft/2020-12/schema",
210                "type": "object",
211                "properties": {
212                    "element": {
213                        "description": "Human-readable element description used to obtain permission to interact with the element",
214                        "type": "string"
215                    },
216                    "target": {
217                        "type": "string",
218                        "description": "Exact target element reference from the page snapshot, or a unique element selector"
219                    },
220                    "paths": {
221                        "description": "Absolute paths to files to drop onto the element.",
222                        "type": "array",
223                        "items": { "type": "string" }
224                    },
225                    "data": {
226                        "description": "Data to drop, as a map of MIME type to string value (e.g. {\"text/plain\": \"hello\", \"text/uri-list\": \"https://example.com\"}).",
227                        "type": "object",
228                        "propertyNames": { "type": "string" },
229                        "additionalProperties": { "type": "string" }
230                    }
231                },
232                "required": ["target"],
233                "additionalProperties": false
234            }),
235        },
236        ToolSpec {
237            name: "browser_find",
238            description: "Search the accessibility snapshot of the current page for text or a regular expression. Returns matching snapshot nodes with a few lines of surrounding context (like search snippets), each shown under its path from the root of the tree, which is cheaper than capturing the whole snapshot when you only need to locate an element and its ref.",
239            schema: json!({
240                "$schema": "https://json-schema.org/draft/2020-12/schema",
241                "type": "object",
242                "properties": {
243                    "text": {
244                        "description": "Plain text to search for in the page snapshot (case-insensitive substring match). Provide either text or regex, not both.",
245                        "type": "string"
246                    },
247                    "regex": {
248                        "description": "Regular expression to search for in the page snapshot. Matching is case-sensitive by default; wrap the pattern in slashes to add flags, e.g. \"/error/i\" for case-insensitive. Provide either text or regex, not both.",
249                        "type": "string"
250                    },
251                    "filename": {
252                        "description": "Save results to a file instead of returning them in the response. Relative file names are resolved against the workspace root.",
253                        "type": "string"
254                    }
255                },
256                "additionalProperties": false
257            }),
258        },
259        ToolSpec {
260            name: "browser_fill_form",
261            description: "Fill multiple form fields",
262            schema: json!({
263                "$schema": "https://json-schema.org/draft/2020-12/schema",
264                "type": "object",
265                "properties": {
266                    "fields": {
267                        "type": "array",
268                        "items": {
269                            "type": "object",
270                            "properties": {
271                                "element": {
272                                    "description": "Human-readable element description used to obtain permission to interact with the element",
273                                    "type": "string"
274                                },
275                                "target": {
276                                    "type": "string",
277                                    "description": "Exact target element reference from the page snapshot, or a unique element selector"
278                                },
279                                "name": {
280                                    "type": "string",
281                                    "description": "Human-readable field name"
282                                },
283                                "type": {
284                                    "type": "string",
285                                    "enum": ["textbox", "checkbox", "radio", "combobox", "slider"],
286                                    "description": "Type of the field"
287                                },
288                                "value": {
289                                    "type": "string",
290                                    "description": "Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option."
291                                }
292                            },
293                            "required": ["target", "name", "type", "value"],
294                            "additionalProperties": false
295                        },
296                        "description": "Fields to fill in"
297                    }
298                },
299                "required": ["fields"],
300                "additionalProperties": false
301            }),
302        },
303        ToolSpec {
304            name: "browser_press_key",
305            description: "Press a key on the keyboard",
306            schema: json!({
307                "$schema": "https://json-schema.org/draft/2020-12/schema",
308                "type": "object",
309                "properties": {
310                    "key": {
311                        "type": "string",
312                        "description": "Name of the key to press or a character to generate, such as `ArrowLeft` or `a`"
313                    }
314                },
315                "required": ["key"],
316                "additionalProperties": false
317            }),
318        },
319        ToolSpec {
320            name: "browser_type",
321            description: "Type text into editable element",
322            schema: json!({
323                "$schema": "https://json-schema.org/draft/2020-12/schema",
324                "type": "object",
325                "properties": {
326                    "element": {
327                        "description": "Human-readable element description used to obtain permission to interact with the element",
328                        "type": "string"
329                    },
330                    "target": {
331                        "type": "string",
332                        "description": "Exact target element reference from the page snapshot, or a unique element selector"
333                    },
334                    "text": {
335                        "type": "string",
336                        "description": "Text to type into the element"
337                    },
338                    "submit": {
339                        "description": "Whether to submit entered text (press Enter after)",
340                        "type": "boolean"
341                    },
342                    "slowly": {
343                        "description": "Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.",
344                        "type": "boolean"
345                    }
346                },
347                "required": ["target", "text"],
348                "additionalProperties": false
349            }),
350        },
351        ToolSpec {
352            name: "browser_navigate",
353            description: "Navigate to a URL",
354            schema: json!({
355                "$schema": "https://json-schema.org/draft/2020-12/schema",
356                "type": "object",
357                "properties": {
358                    "url": {
359                        "type": "string",
360                        "description": "The URL to navigate to"
361                    }
362                },
363                "required": ["url"],
364                "additionalProperties": false
365            }),
366        },
367        ToolSpec {
368            name: "browser_navigate_back",
369            description: "Go back to the previous page in the history",
370            schema: json!({
371                "$schema": "https://json-schema.org/draft/2020-12/schema",
372                "type": "object",
373                "properties": {},
374                "additionalProperties": false
375            }),
376        },
377        ToolSpec {
378            name: "browser_network_requests",
379            description: "Returns a numbered list of network requests since loading the page. Use browser_network_request with the number to get full details.",
380            schema: json!({
381                "$schema": "https://json-schema.org/draft/2020-12/schema",
382                "type": "object",
383                "properties": {
384                    "static": {
385                        "default": false,
386                        "description": "Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.",
387                        "type": "boolean"
388                    },
389                    "filter": {
390                        "description": "Only return requests whose URL matches this regexp (e.g. \"/api/.*user\").",
391                        "type": "string"
392                    },
393                    "filename": {
394                        "description": "File name to save the network requests to. Relative file names are resolved against the workspace root. If not provided, requests are returned as text.",
395                        "type": "string"
396                    }
397                },
398                "required": ["static"],
399                "additionalProperties": false
400            }),
401        },
402        ToolSpec {
403            name: "browser_network_request",
404            description: "Returns full details (headers and body) of a single network request, or a single part if `part` is set. Use the number from browser_network_requests.",
405            schema: json!({
406                "$schema": "https://json-schema.org/draft/2020-12/schema",
407                "type": "object",
408                "properties": {
409                    "index": {
410                        "type": "integer",
411                        "minimum": 1,
412                        "maximum": 9_007_199_254_740_991_u64,
413                        "description": "1-based index of the request, as printed by browser_network_requests."
414                    },
415                    "part": {
416                        "description": "Return only this part of the request. Omit to return full details.",
417                        "type": "string",
418                        "enum": ["request-headers", "request-body", "response-headers", "response-body"]
419                    },
420                    "filename": {
421                        "description": "File name to save the result to. Relative file names are resolved against the workspace root. If not provided, output is returned as text.",
422                        "type": "string"
423                    }
424                },
425                "required": ["index"],
426                "additionalProperties": false
427            }),
428        },
429        ToolSpec {
430            name: "browser_run_code_unsafe",
431            description: "Run a Playwright code snippet. Unsafe: executes arbitrary JavaScript in the Playwright server process and is RCE-equivalent.",
432            schema: json!({
433                "$schema": "https://json-schema.org/draft/2020-12/schema",
434                "type": "object",
435                "properties": {
436                    "code": {
437                        "description": "A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: `async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }`",
438                        "type": "string"
439                    },
440                    "filename": {
441                        "description": "Load code from the specified file. Relative file names are resolved against the workspace root. If both code and filename are provided, code will be ignored.",
442                        "type": "string"
443                    }
444                },
445                "additionalProperties": false
446            }),
447        },
448        ToolSpec {
449            name: "browser_take_screenshot",
450            description: "Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.",
451            schema: json!({
452                "$schema": "https://json-schema.org/draft/2020-12/schema",
453                "type": "object",
454                "properties": {
455                    "element": {
456                        "description": "Human-readable element description used to obtain permission to interact with the element",
457                        "type": "string"
458                    },
459                    "target": {
460                        "description": "Exact target element reference from the page snapshot, or a unique element selector",
461                        "type": "string"
462                    },
463                    "type": {
464                        "description": "Image format for the screenshot. If unset, inferred from the filename extension, otherwise png.",
465                        "type": "string",
466                        "enum": ["png", "jpeg", "webp"]
467                    },
468                    "filename": {
469                        "description": "File name to save the screenshot to. Relative file names are resolved against the workspace root. If not specified, the screenshot is saved into the output directory as `page-{timestamp}.{png|jpeg|webp}`.",
470                        "type": "string"
471                    },
472                    "fullPage": {
473                        "description": "When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.",
474                        "type": "boolean"
475                    },
476                    "scale": {
477                        "default": "css",
478                        "description": "Image resolution scale. \"css\" produces a screenshot sized in CSS pixels (smaller, consistent across devices). \"device\" produces a high-resolution screenshot using device pixels (larger, accounts for the device pixel ratio). Default is css.",
479                        "type": "string",
480                        "enum": ["css", "device"]
481                    }
482                },
483                "required": ["scale"],
484                "additionalProperties": false
485            }),
486        },
487        ToolSpec {
488            name: "browser_snapshot",
489            // Beyond upstream: the sentences after its own. A ref's lifetime
490            // and the route that fits a page still changing are what a caller
491            // writing a prompt against this tool needs, and upstream's
492            // description never says either (measured on @playwright/mcp
493            // 0.0.83, 2026-10-27).
494            description: "Capture accessibility snapshot of the current page, this is better than \
495                          screenshot. A ref belongs to the snapshot that minted it, and only to \
496                          an element that was visible and pointer-interactive at that moment. A \
497                          page that keeps replacing its elements retires refs before the next \
498                          call, so drive it with a unique role/name selector such as \
499                          role=button[name=\"Save\"], scoped when names repeat, which resolves at \
500                          call time; once the page has settled, take a full-page snapshot with no \
501                          target and its refs hold. A target-scoped snapshot answers with that \
502                          subtree alone, so refs outside it do not resolve until a full-page \
503                          snapshot.",
504            schema: json!({
505                "$schema": "https://json-schema.org/draft/2020-12/schema",
506                "type": "object",
507                "properties": {
508                    "target": {
509                        "description": "Exact target element reference from the page snapshot, or a unique element selector",
510                        "type": "string"
511                    },
512                    "filename": {
513                        "description": "Save snapshot to a file instead of returning it in the response. Relative file names are resolved against the workspace root.",
514                        "type": "string"
515                    },
516                    "depth": {
517                        "description": "Limit the depth of the snapshot tree",
518                        "type": "number"
519                    },
520                    "boxes": {
521                        "description": "Include each element's bounding box as [box=x,y,width,height] in the snapshot. Coordinates are viewport-relative, in CSS pixels (Element.getBoundingClientRect)",
522                        "type": "boolean"
523                    }
524                },
525                "additionalProperties": false
526            }),
527        },
528        ToolSpec {
529            name: "browser_click",
530            description: "Perform click on a web page",
531            schema: json!({
532                "$schema": "https://json-schema.org/draft/2020-12/schema",
533                "type": "object",
534                "properties": {
535                    "element": {
536                        "description": "Human-readable element description used to obtain permission to interact with the element",
537                        "type": "string"
538                    },
539                    "target": {
540                        "type": "string",
541                        "description": "Exact target element reference from the page snapshot, or a unique element selector"
542                    },
543                    "doubleClick": {
544                        "description": "Whether to perform a double click instead of a single click",
545                        "type": "boolean"
546                    },
547                    "button": {
548                        "description": "Button to click, defaults to left",
549                        "type": "string",
550                        "enum": ["left", "right", "middle"]
551                    },
552                    "modifiers": {
553                        "description": "Modifier keys to press",
554                        "type": "array",
555                        "items": {
556                            "type": "string",
557                            "enum": ["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]
558                        }
559                    },
560                    // Beyond upstream: the actionability gate is what a
561                    // click waits on (its stability step on the page's
562                    // animation frames), and a call that means "the element
563                    // is there, click it anyway" has no other form.
564                    "force": {
565                        "description": "Click through the actionability gate with a real mouse event at the element's current position, instead of waiting for it to be visible, stable, enabled and uncovered.",
566                        "type": "boolean"
567                    }
568                },
569                "required": ["target"],
570                "additionalProperties": false
571            }),
572        },
573        ToolSpec {
574            name: "browser_drag",
575            description: "Perform drag and drop between two elements",
576            schema: json!({
577                "$schema": "https://json-schema.org/draft/2020-12/schema",
578                "type": "object",
579                "properties": {
580                    "startElement": {
581                        "description": "Human-readable source element description used to obtain the permission to interact with the element",
582                        "type": "string"
583                    },
584                    "startTarget": {
585                        "type": "string",
586                        "description": "Exact target element reference from the page snapshot, or a unique element selector"
587                    },
588                    "endElement": {
589                        "description": "Human-readable target element description used to obtain the permission to interact with the element",
590                        "type": "string"
591                    },
592                    "endTarget": {
593                        "type": "string",
594                        "description": "Exact target element reference from the page snapshot, or a unique element selector"
595                    }
596                },
597                "required": ["startTarget", "endTarget"],
598                "additionalProperties": false
599            }),
600        },
601        ToolSpec {
602            name: "browser_hover",
603            description: "Hover over element on page",
604            schema: json!({
605                "$schema": "https://json-schema.org/draft/2020-12/schema",
606                "type": "object",
607                "properties": {
608                    "element": {
609                        "description": "Human-readable element description used to obtain permission to interact with the element",
610                        "type": "string"
611                    },
612                    "target": {
613                        "type": "string",
614                        "description": "Exact target element reference from the page snapshot, or a unique element selector"
615                    }
616                },
617                "required": ["target"],
618                "additionalProperties": false
619            }),
620        },
621        ToolSpec {
622            name: "browser_select_option",
623            description: "Select an option in a dropdown",
624            schema: json!({
625                "$schema": "https://json-schema.org/draft/2020-12/schema",
626                "type": "object",
627                "properties": {
628                    "element": {
629                        "description": "Human-readable element description used to obtain permission to interact with the element",
630                        "type": "string"
631                    },
632                    "target": {
633                        "type": "string",
634                        "description": "Exact target element reference from the page snapshot, or a unique element selector"
635                    },
636                    "values": {
637                        "type": "array",
638                        "items": { "type": "string" },
639                        "description": "Array of values to select in the dropdown. This can be a single value or multiple values."
640                    }
641                },
642                "required": ["target", "values"],
643                "additionalProperties": false
644            }),
645        },
646        ToolSpec {
647            name: "browser_tabs",
648            description: "List, create, close, or select a browser tab.",
649            schema: json!({
650                "$schema": "https://json-schema.org/draft/2020-12/schema",
651                "type": "object",
652                "properties": {
653                    "action": {
654                        "type": "string",
655                        "enum": ["list", "new", "close", "select"],
656                        "description": "Operation to perform"
657                    },
658                    "tab": {
659                        "description": "The tab's page id, printed first in its row by browser_tabs list. Used for close/select; another session's tab is refused, and an id no tab carries answers with the tabs open now.",
660                        "type": "string"
661                    },
662                    "url": {
663                        "description": "URL to navigate to in the new tab, used for new.",
664                        "type": "string"
665                    }
666                },
667                "required": ["action"],
668                "additionalProperties": false
669            }),
670        },
671        ToolSpec {
672            name: "browser_wait_for",
673            description: "Wait for text to appear or disappear or a specified time to pass",
674            schema: json!({
675                "$schema": "https://json-schema.org/draft/2020-12/schema",
676                "type": "object",
677                "properties": {
678                    "time": {
679                        "description": "The time to wait in seconds, at most 30",
680                        "type": "number"
681                    },
682                    "text": {
683                        "description": "The text to wait for",
684                        "type": "string"
685                    },
686                    "textGone": {
687                        "description": "The text to wait for to disappear",
688                        "type": "string"
689                    },
690                    // Beyond upstream: a page whose readiness is a condition
691                    // rather than a string has nothing here to wait on.
692                    "expression": {
693                        "description": "A JavaScript expression, evaluated repeatedly until it is truthy. Use this to wait for a condition no text can state, e.g. `document.querySelectorAll('.row').length > 3`.",
694                        "type": "string"
695                    }
696                },
697                "additionalProperties": false
698            }),
699        },
700        // ------------------------------------------------------------------
701        // Beyond upstream: tools built from the same driver, because the
702        // question they answer is one a call cannot compose out of the 25.
703        // ------------------------------------------------------------------
704        ToolSpec {
705            name: "browser_click_and_capture",
706            description: "Click an element and return the network responses the click triggered - url, status and body - so a click whose meaning is its request does not need two calls and a poll in between.",
707            schema: json!({
708                "$schema": "https://json-schema.org/draft/2020-12/schema",
709                "type": "object",
710                "properties": {
711                    "target": {
712                        "type": "string",
713                        "description": "Exact target element reference from the page snapshot, or a unique element selector"
714                    },
715                    "element": {
716                        "description": "Human-readable element description used to obtain permission to interact with the element",
717                        "type": "string"
718                    },
719                    "settleMs": {
720                        "description": "How long to keep collecting responses after the click before answering. Defaults to 1500 ms.",
721                        "type": "number"
722                    }
723                },
724                "required": ["target"],
725                "additionalProperties": false
726            }),
727        },
728        ToolSpec {
729            name: "browser_form_state",
730            description: "Read a form's state: every field's current value, whether it is marked invalid, and the text of the control itself - which is where a committed value lives for a picker that keeps it outside its input.",
731            schema: json!({
732                "$schema": "https://json-schema.org/draft/2020-12/schema",
733                "type": "object",
734                "properties": {
735                    "target": {
736                        "type": "string",
737                        "description": "Exact target element reference from the page snapshot, or a unique element selector, for a form or a container of fields. Omit to read every form on the page."
738                    },
739                    "element": {
740                        "description": "Human-readable element description used to obtain permission to interact with the element",
741                        "type": "string"
742                    }
743                },
744                "additionalProperties": false
745            }),
746        },
747        ToolSpec {
748            name: "browser_challenge_state",
749            description: "Report each CAPTCHA challenge iframe in the top-level document: its vendor, presence, visibility (its own and its ancestors' computed display, opacity and filter included), whether it is on screen, whether a click at its centre would reach it, and its rect. `live` is true only when the frame is present, visible, on screen and hit-testable, and `challengePresent` says whether any challenge iframe was found at all.\n\nThis probe reads the top-level document, not inside it: a challenge in an iframe, frame, object or embed document, or in a shadow root, is not classified, and a closed shadow root cannot be seen at all. `unseen` names every region it did not read (each unmatched iframe, every frame, object and embed, every open shadow root) and `complete` is false whenever that list is non-empty, so an empty challenge list beside unseen entries is not 'no CAPTCHA here'. Presence is not liveness: Google's EXPIRED overlay is a reCAPTCHA iframe parked off screen and hidden, and it is still present with a real rect. When a challenge is present but none is live, reload the page and probe again; if it stays hidden, hand off to the person rather than clicking blind.",
750            schema: json!({
751                "$schema": "https://json-schema.org/draft/2020-12/schema",
752                "type": "object",
753                "properties": {},
754                "additionalProperties": false
755            }),
756        },
757        ToolSpec {
758            name: "browser_hand_off",
759            description: "Ask the person at the client to act in the browser - solve a CAPTCHA, sign in, confirm something only they can - and wait for their word. **A visual artifact - an HTML mockup, a PNG - is raised here**: navigate to it first, then hand off the tab it landed on; a document - Markdown, PDF, plain text - goes through `documents__hand_off`, never this tool. The prompt sits with no timeout, like any other ask; it returns once they are done, send you a message about the page, or decline. The raise shows the page this session has been driving, exactly as it stands, and their answer does not close it: prepare the page first, and the person acts on the very page you prepared.",
760            schema: json!({
761                "$schema": "https://json-schema.org/draft/2020-12/schema",
762                "type": "object",
763                "properties": {
764                    "reason": {
765                        "type": "string",
766                        "description": "What needs doing, in the session's own words. It is shown on the prompt, so name the page and the act rather than the tool."
767                    },
768                    "tab": {
769                        "type": "string",
770                        "description": "The page id of one of this session's tabs, from browser_tabs list: the 32 hex characters it prints first in the tab's row, naming the tab the person is asked to look at. A tool list that still shows `tab` as a number was recorded before forge addressed tabs by page id, and neither a restart nor a resume refreshes it - only a fresh session in this slot does."
771                    }
772                },
773                "required": ["reason", "tab"],
774                "additionalProperties": false
775            }),
776        },
777        // **A take is the host's, not the driver's.** The page is laid out
778        // at the file's own size for the duration, and the host's encoder
779        // writes the viewport; the controller's view comes back on stop.
780        ToolSpec {
781            name: "browser_record_start",
782            description: "Record the page's viewport into a silent 1280x720 MP4 at 30 frames a second, laying the page out at that size for the take. The file stays on the machine the browser runs on. Call browser_record_stop to finish it; the recording stops itself at 180 seconds.",
783            schema: json!({
784                "$schema": "https://json-schema.org/draft/2020-12/schema",
785                "type": "object",
786                "properties": {},
787                "additionalProperties": false
788            }),
789        },
790        ToolSpec {
791            name: "browser_record_stop",
792            description: "Stop the running recording, answer the file it wrote with its length and size, and put the controller's own view back.",
793            schema: json!({
794                "$schema": "https://json-schema.org/draft/2020-12/schema",
795                "type": "object",
796                "properties": {},
797                "additionalProperties": false
798            }),
799        },
800        // The fleet's own door, not a driver call: a host no page call can
801        // drive is ended here rather than by a kill from outside.
802        ToolSpec {
803            name: "browser_end_host",
804            description: "End the browser host this session's profile resolves to and let the next call start a fresh one. Use it when page calls keep failing on a host that browser_tabs can still list, especially after a popup wedged the browser. The profile is the org's, or a project's override, so every session using it loses its tabs, including sessions in other projects; the profile's data is kept. Any lead whose project resolves to that profile can call it - a worker asks its lead instead.",
805            schema: json!({
806                "$schema": "https://json-schema.org/draft/2020-12/schema",
807                "type": "object",
808                "properties": {},
809                "additionalProperties": false
810            }),
811        },
812    ];
813    specs
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819
820    /// **Profiles are config-owned.** A session drives the profile its project
821    /// resolves to, so no tool takes a profile name any more: a schema that
822    /// still declared one would let a model pin a call to a browser it cannot
823    /// choose.
824    #[test]
825    fn no_tool_names_a_profile_any_more() {
826        for spec in specs() {
827            let properties = spec.schema["properties"].as_object().expect("a schema");
828            assert!(!properties.contains_key("profile"), "{} still takes a profile", spec.name);
829        }
830    }
831
832    /// **A hand-off's tab is what the person is shown**, so the tool requires
833    /// it and says which id it comes from - and profiles being config-owned,
834    /// there is no delete verb.
835    #[test]
836    fn a_hand_off_names_the_tab_it_wants_seen() {
837        let spec =
838            specs().into_iter().find(|spec| spec.name == "browser_hand_off").expect("the tool");
839        let required = spec.schema["required"].as_array().expect("required");
840        assert!(required.iter().any(|value| value == "tab"), "the tab is required");
841        assert!(
842            spec.schema["properties"]["tab"]["description"]
843                .as_str()
844                .expect("a description")
845                .contains("one of this session's tabs")
846        );
847        assert_eq!(
848            spec.schema["properties"]["tab"]["type"], "string",
849            "the page id names the tab, not a host number",
850        );
851        assert!(
852            !specs().iter().any(|spec| spec.name == "browser_profile_delete"),
853            "profiles are config-owned; there is no delete verb",
854        );
855    }
856
857    /// **A visual artifact is raised in the browser, and the stale catalogue
858    /// is named.** The description is read at the moment of the call: an HTML
859    /// mockup or a PNG belongs here while a document goes to
860    /// `documents__hand_off`, and a tool list recorded while `tab` was a
861    /// number only a fresh session refreshes.
862    #[test]
863    fn a_hand_off_routes_visual_artifacts_and_names_the_stale_catalogue() {
864        let spec =
865            specs().into_iter().find(|spec| spec.name == "browser_hand_off").expect("the tool");
866        for phrase in [
867            "A visual artifact - an HTML mockup, a PNG - is raised here",
868            "goes through `documents__hand_off`, never this tool",
869        ] {
870            assert!(
871                spec.description.contains(phrase),
872                "the routing rule is stated: {}",
873                spec.description,
874            );
875        }
876        let tab = spec.schema["properties"]["tab"]["description"].as_str().expect("a description");
877        assert!(tab.contains("32 hex characters"), "the page id's shape is stated: {tab}");
878        assert!(
879            tab.contains("fresh session in this slot"),
880            "the stale list's one recovery is named: {tab}",
881        );
882    }
883
884    /// **The tabs tool addresses a tab by its page id.** The host numbers
885    /// restart with every host, so the schema offers no number to keep.
886    #[test]
887    fn the_tabs_tool_addresses_a_tab_by_its_page_id() {
888        let spec = specs().into_iter().find(|spec| spec.name == "browser_tabs").expect("the tool");
889        let properties = spec.schema["properties"].as_object().expect("a schema");
890        assert!(!properties.contains_key("index"), "the host's number is not an address");
891        assert_eq!(properties["tab"]["type"], "string", "the page id is");
892        assert!(
893            properties["tab"]["description"].as_str().expect("a description").contains("page id"),
894            "and the description says which id it is",
895        );
896    }
897
898    /// **The door states whose profile it takes and who may call it.** The
899    /// description is read at the moment of the call, so the scope - every
900    /// session on the resolved profile, other projects included - and the
901    /// lead-only rule are pinned there.
902    #[test]
903    fn the_end_host_door_states_its_scope_and_its_caller() {
904        let spec =
905            specs().into_iter().find(|spec| spec.name == "browser_end_host").expect("the tool");
906        for phrase in [
907            "The profile is the org's, or a project's override",
908            "including sessions in other projects",
909            "Any lead whose project resolves to that profile can call it",
910            "a worker asks its lead instead",
911        ] {
912            assert!(
913                spec.description.contains(phrase),
914                "the description states `{phrase}`: {}",
915                spec.description,
916            );
917        }
918    }
919
920    /// **The advertised names and the live tables agree.** The parity check
921    /// is `forge-server`'s `surface_parity_live` test, which may not depend on
922    /// this crate, so it reads `forge_primitives::browser::ADVERTISED_TOOLS`;
923    /// a spec added here without the const would be a tool the check never
924    /// looks at.
925    #[test]
926    fn the_advertised_const_is_the_spec_tables_names() {
927        let mut table: Vec<&str> = specs().iter().map(|spec| spec.name).collect();
928        let mut advertised = forge_primitives::browser::ADVERTISED_TOOLS.to_vec();
929        table.sort_unstable();
930        advertised.sort_unstable();
931        assert_eq!(table, advertised, "the const carries every advertised name and nothing else");
932    }
933
934    /// The surface is thirty-two tools: the 25 upstream published and the
935    /// seven this family adds. A count is the cheap half; the shape of each
936    /// is pinned below.
937    #[test]
938    fn the_surface_is_the_captures_tools_and_the_additions() {
939        let names: Vec<&str> = specs().iter().map(|spec| spec.name).collect();
940        assert_eq!(names.len(), 32, "25 upstream tools and seven additions: {names:?}");
941        for upstream in [
942            "browser_close",
943            "browser_resize",
944            "browser_console_messages",
945            "browser_handle_dialog",
946            "browser_emulate_media",
947            "browser_evaluate",
948            "browser_file_upload",
949            "browser_drop",
950            "browser_find",
951            "browser_fill_form",
952            "browser_press_key",
953            "browser_type",
954            "browser_navigate",
955            "browser_navigate_back",
956            "browser_network_requests",
957            "browser_network_request",
958            "browser_run_code_unsafe",
959            "browser_take_screenshot",
960            "browser_snapshot",
961            "browser_click",
962            "browser_drag",
963            "browser_hover",
964            "browser_select_option",
965            "browser_tabs",
966            "browser_wait_for",
967        ] {
968            assert!(names.contains(&upstream), "{upstream} is missing from the surface");
969        }
970        assert!(
971            names.contains(&"browser_click_and_capture")
972                && names.contains(&"browser_form_state")
973                && names.contains(&"browser_challenge_state")
974                && names.contains(&"browser_hand_off")
975                && names.contains(&"browser_record_start")
976                && names.contains(&"browser_record_stop")
977                && names.contains(&"browser_end_host"),
978            "the seven beyond-upstream tools are on the surface: {names:?}",
979        );
980    }
981
982    /// Every schema is an object schema that refuses arguments it does not
983    /// know, and every name is the `browser_` prefix upstream's carry: a
984    /// tool named otherwise is a prompt that resolves to something else.
985    #[test]
986    fn every_tool_is_a_closed_object_schema_with_upstreams_prefix() {
987        for spec in specs() {
988            assert!(spec.name.starts_with("browser_"), "{} is not a browser tool", spec.name);
989            assert_eq!(spec.schema["type"], "object", "{} is not an object schema", spec.name);
990            assert_eq!(
991                spec.schema["additionalProperties"],
992                json!(false),
993                "{} accepts arguments it does not declare",
994                spec.name,
995            );
996            assert!(
997                !spec.description.is_empty(),
998                "{} has no description for the model to read",
999                spec.name,
1000            );
1001        }
1002    }
1003
1004    /// **The snapshot's description states a ref's lifetime**, which is the
1005    /// one description sentence beyond upstream's: a caller writing a prompt
1006    /// against this tool reads it there, and the measured behaviour is that a
1007    /// ref survives an ordinary re-render and dies when its element is
1008    /// replaced or removed, or the page navigates.
1009    #[test]
1010    fn the_snapshot_description_states_a_refs_lifetime() {
1011        let snapshot = specs()
1012            .into_iter()
1013            .find(|spec| spec.name == "browser_snapshot")
1014            .expect("browser_snapshot");
1015        assert!(
1016            snapshot.description.starts_with("Capture accessibility snapshot"),
1017            "upstream's first sentence is kept: {}",
1018            snapshot.description,
1019        );
1020        for phrase in [
1021            "A ref belongs to the snapshot that minted it",
1022            "only to an element that was visible and pointer-interactive",
1023            "drive it with a unique role/name selector",
1024            "scoped when names repeat",
1025            "take a full-page snapshot with no target and its refs hold",
1026            "A target-scoped snapshot answers with that subtree alone",
1027            "refs outside it do not resolve until a full-page snapshot",
1028        ] {
1029            assert!(
1030                snapshot.description.contains(phrase),
1031                "the description says {phrase:?}: {}",
1032                snapshot.description,
1033            );
1034        }
1035        let selector = snapshot
1036            .description
1037            .find("drive it with a unique role/name selector")
1038            .expect("the selector route is stated");
1039        let settled = snapshot
1040            .description
1041            .find("take a full-page snapshot with no target and its refs hold")
1042            .expect("the settled route");
1043        assert!(
1044            selector < settled,
1045            "the route for a still-changing page comes first: {}",
1046            snapshot.description,
1047        );
1048    }
1049
1050    /// The two arguments this family adds sit beside upstream's rather than
1051    /// replacing any: `force` on the click, `expression` on the wait.
1052    #[test]
1053    fn the_added_arguments_ride_tools_upstream_already_has() {
1054        let specs = specs();
1055        let click = specs.iter().find(|spec| spec.name == "browser_click").expect("browser_click");
1056        assert_eq!(
1057            click.schema["properties"]["force"]["type"],
1058            json!("boolean"),
1059            "the click carries `force`: {}",
1060            click.schema,
1061        );
1062        assert_eq!(click.schema["required"], json!(["target"]), "and requires what upstream does");
1063
1064        let wait =
1065            specs.iter().find(|spec| spec.name == "browser_wait_for").expect("browser_wait_for");
1066        assert_eq!(
1067            wait.schema["properties"]["expression"]["type"],
1068            json!("string"),
1069            "the wait carries `expression`: {}",
1070            wait.schema,
1071        );
1072    }
1073}