webperf-snippets 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/package.json +1 -1
- package/snippets/Loading/Content-Visibility.js +10 -2
- package/snippets/Media/Oversized-Images.js +6 -1
- package/snippets/SCHEMA.md +2 -2
- package/src/mcp/next-steps.js +29 -0
- package/src/mcp/prompts.js +3 -2
- package/src/mcp/server.js +115 -17
- package/src/mcp/summary.js +55 -0
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
|
|
|
73
73
|
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait and the version of the CLI.
|
|
74
74
|
|
|
75
75
|
```json
|
|
76
|
-
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.8.0" }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -158,7 +158,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
158
158
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
159
159
|
"navMs": 1240,
|
|
160
160
|
"results": [],
|
|
161
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
161
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.8.0" }
|
|
162
162
|
}
|
|
163
163
|
```
|
|
164
164
|
|
|
@@ -382,12 +382,13 @@ It needs the Chromium of Playwright (`npx playwright install chromium`); without
|
|
|
382
382
|
| Tool | What it does |
|
|
383
383
|
| --------------- | ---------------------------------------------------------------------------- |
|
|
384
384
|
| `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
|
|
385
|
-
| `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns
|
|
385
|
+
| `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns a summary of its result. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport` and `waitMs`. The snippets that record interactions run on a page opened with `open_page`. |
|
|
386
386
|
| `run_workflow` | Runs a workflow of the CLI (`core-web-vitals`, `audit`, `loading`, `interaction`) and the follow-ups of its decision tree. Takes `url`, `workflow`, and optional `viewport` and `waitMs`. |
|
|
387
387
|
| `get_crux` | Returns the CrUX p75 of LCP, CLS and INP for a URL, or its origin. Takes `url` and an optional `formFactor` (`phone`, `desktop`, `tablet`). Needs `CRUX_API_KEY` in the environment of the server. |
|
|
388
388
|
| `open_page` | Opens a URL in a page that stays open between calls and installs tracking snippets (`track`, such as `INP`). Returns a `sessionId`. |
|
|
389
389
|
| `interact` | Runs steps on an open page: `click`, `type`, `hover`, `scroll` or `wait`. |
|
|
390
390
|
| `read_results` | Returns what the tracking snippets of an open page have recorded. |
|
|
391
|
+
| `get_details` | Returns the whole object of one snippet of a run, by the `runId` that `run_snippet`, `run_workflow` or `read_results` returned, without measuring again. Takes `runId` and `snippet`. |
|
|
391
392
|
| `close_page` | Closes an open page. |
|
|
392
393
|
|
|
393
394
|
The server also offers resources, documents a client can attach to the context:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
5
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
6
6
|
"type": "module",
|
|
@@ -34,6 +34,14 @@ function uniqueSelector(el) {
|
|
|
34
34
|
}
|
|
35
35
|
// @end-shared uniqueSelector
|
|
36
36
|
|
|
37
|
+
// An element with content-visibility: auto has no size before it first renders unless
|
|
38
|
+
// contain-intrinsic-size gives one, so the page moves when it does. content-visibility: auto adds auto
|
|
39
|
+
// to contain-intrinsic-size, so Chrome reports an element with no size as "auto none", also when the CSS
|
|
40
|
+
// says none: what counts is whether the value has a length, as in "auto 500px".
|
|
41
|
+
function lacksIntrinsicSize(value) {
|
|
42
|
+
return !/\d/.test(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
function detectContentVisibility() {
|
|
38
46
|
// Create an object to store the results
|
|
39
47
|
const results = {
|
|
@@ -125,7 +133,7 @@ function detectContentVisibility() {
|
|
|
125
133
|
|
|
126
134
|
// Check for missing contain-intrinsic-size
|
|
127
135
|
const missingIntrinsicSize = results.autoElements.filter(
|
|
128
|
-
(el) => el.containIntrinsicSize
|
|
136
|
+
(el) => lacksIntrinsicSize(el.containIntrinsicSize),
|
|
129
137
|
);
|
|
130
138
|
|
|
131
139
|
if (missingIntrinsicSize.length > 0) {
|
|
@@ -327,7 +335,7 @@ window.analyzeContentVisibilityOpportunities = analyzeContentVisibilityOpportuni
|
|
|
327
335
|
],
|
|
328
336
|
issues: [
|
|
329
337
|
...cvResults.autoElements
|
|
330
|
-
.filter(el => el.containIntrinsicSize
|
|
338
|
+
.filter(el => lacksIntrinsicSize(el.containIntrinsicSize))
|
|
331
339
|
.map(el => ({ code: "missing-intrinsic-size", severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)`, target: el.selector })),
|
|
332
340
|
...(cvResults.autoElements.length + cvResults.hiddenElements.length === 0
|
|
333
341
|
? [{ code: "not-used", severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
|
|
@@ -76,8 +76,13 @@
|
|
|
76
76
|
}
|
|
77
77
|
// @end-shared uniqueSelector
|
|
78
78
|
|
|
79
|
+
// An SVG scales to any size, so its pixels are not measured. The extension names most of them; the
|
|
80
|
+
// content type of the response names an SVG of the page served without it, such as an image service
|
|
81
|
+
// that takes the format in the query. The browser gives the content type only for a response it can
|
|
82
|
+
// read: an SVG of another origin without the extension is measured as a raster image.
|
|
83
|
+
const typeByUrl = new Map(performance.getEntriesByType("resource").map((e) => [e.name, e.contentType]));
|
|
79
84
|
function isVector(url) {
|
|
80
|
-
return /^data:image\/svg/i.test(url) || /\.svg([?#]|$)/i.test(url);
|
|
85
|
+
return /^data:image\/svg/i.test(url) || /\.svg([?#]|$)/i.test(url) || typeByUrl.get(url) === "image/svg+xml";
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
// The size of the file in pixels. HTMLImageElement.naturalWidth is corrected by the srcset
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -1137,7 +1137,7 @@ Returns buffered long tasks immediately. Ongoing tracking continues. `items` lis
|
|
|
1137
1137
|
|
|
1138
1138
|
#### Oversized-Images (async)
|
|
1139
1139
|
|
|
1140
|
-
Compares the pixels of each loaded image file with the pixels drawn (rendered size times `devicePixelRatio`, scaled by `object-fit`). `ratio` is file pixels per pixel drawn: `oversized` at 1.5 or more, `undersized` (blurry) under 0.9. `items` lists the flagged images, the most wasted pixels first (at most 50); `count` and `details` cover all of them. `wastedBytes` and `transferBytes` are `null` when the transfer size is hidden (cross-origin without `Timing-Allow-Origin`); `details.wastedBytes` counts each URL once. Images with a zero-size box, unloaded images and SVGs are counted in `details.skipped
|
|
1140
|
+
Compares the pixels of each loaded image file with the pixels drawn (rendered size times `devicePixelRatio`, scaled by `object-fit`). `ratio` is file pixels per pixel drawn: `oversized` at 1.5 or more, `undersized` (blurry) under 0.9. `items` lists the flagged images, the most wasted pixels first (at most 50); `count` and `details` cover all of them. `wastedBytes` and `transferBytes` are `null` when the transfer size is hidden (cross-origin without `Timing-Allow-Origin`); `details.wastedBytes` counts each URL once. Images with a zero-size box, unloaded images and SVGs are counted in `details.skipped*`; an SVG is known by its extension, a `data:image/svg` URL, or, for a file of the same origin, the `contentType` of its resource entry. `selector` matches only the image of its item, built like `details.element` of LCP, so an image with no id or class has a path such as `div.partners > img:nth-of-type(3)` instead of `img`.
|
|
1141
1141
|
|
|
1142
1142
|
```json
|
|
1143
1143
|
{
|
|
@@ -1250,7 +1250,7 @@ An issue about one thing among several, one issue per thing, also names it in `t
|
|
|
1250
1250
|
|
|
1251
1251
|
| Code | Severity | When |
|
|
1252
1252
|
|------|----------|------|
|
|
1253
|
-
| `missing-intrinsic-size` | warning | An element with `content-visibility: auto`
|
|
1253
|
+
| `missing-intrinsic-size` | warning | An element with `content-visibility: auto` whose `contain-intrinsic-size` has no length, such as `none` or the `auto none` Chrome reports when no size is declared; `target` is its selector |
|
|
1254
1254
|
| `not-used` | info | No `content-visibility` on the page |
|
|
1255
1255
|
|
|
1256
1256
|
### Critical-CSS-Detection
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RULES, nextSteps } from "../decision-tree.js";
|
|
2
|
+
import { loadSnippet } from "../load-snippet.js";
|
|
3
|
+
import { isTrackingSnippet } from "../tracking.js";
|
|
4
|
+
|
|
5
|
+
const isTrackingPath = (path) => isTrackingSnippet(path, loadSnippet(path));
|
|
6
|
+
|
|
7
|
+
// The call of the tool that runs a snippet: a tracking snippet needs a page the agent interacts with. A
|
|
8
|
+
// snippet waits as long as the run that called for it, so it reads the same paint.
|
|
9
|
+
const callFor = ({ path, reason, url, viewport, waitMs, isTracking }) =>
|
|
10
|
+
isTracking(path)
|
|
11
|
+
? { tool: "open_page", args: { url, track: [path], viewport }, reason }
|
|
12
|
+
: { tool: "run_snippet", args: { url, snippet: path, viewport, ...(waitMs !== undefined ? { waitMs } : {}) }, reason };
|
|
13
|
+
|
|
14
|
+
// The follow-ups the decision tree of the CLI asks for, as the calls an agent makes next. The rules name
|
|
15
|
+
// the steps of a workflow (LCP, CLS, TTFB, FCP), which are the scripts of the snippets, so a result of
|
|
16
|
+
// run_snippet, whose id is Category/Name, is matched by its script. `ran` leaves out what already ran.
|
|
17
|
+
export function followUpCalls({ results, url, viewport, waitMs, ran = [], rules = RULES, isTracking = isTrackingPath }) {
|
|
18
|
+
const named = results.map((r) => ({ ...r, id: r.script ?? r.id }));
|
|
19
|
+
return nextSteps(named, rules, ran).map(({ path, reason }) => callFor({ path, reason, url, viewport, waitMs, isTracking }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The steps a workflow skipped because they measure clicks and key presses, in one page to open
|
|
23
|
+
export function trackingCall({ paths, url, viewport }) {
|
|
24
|
+
return {
|
|
25
|
+
tool: "open_page",
|
|
26
|
+
args: { url, track: paths, viewport },
|
|
27
|
+
reason: "These steps measure clicks and key presses: open the page with them, interact with it and read them with read_results",
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/mcp/prompts.js
CHANGED
|
@@ -31,7 +31,8 @@ export function registerPrompts(server) {
|
|
|
31
31
|
`1. Call run_workflow with url "${url}", workflow "core-web-vitals" and viewport "${viewport}". It returns LCP and CLS, and adds LCP-Subparts with the reason when LCP is over 2.5 s.`,
|
|
32
32
|
`2. Call get_crux for the same URL with formFactor "${viewport === "desktop" ? "desktop" : "phone"}", to compare the lab with what real users get. If it answers that there is no key, say so and go on with the lab data.`,
|
|
33
33
|
'3. If LCP-Subparts did not run, call run_snippet with snippet "LCP-Subparts".',
|
|
34
|
-
"4.
|
|
34
|
+
"4. If the answers are summaries (detail: summary), call get_details with the runId for LCP and LCP-Subparts: a summary leaves out the LCP element and the sub-parts.",
|
|
35
|
+
"5. Report the LCP value and rating in the lab and in the field, the LCP element, the slowest sub-part and what it means, and the fixes for that sub-part, the most effective first.",
|
|
35
36
|
"",
|
|
36
37
|
`${QUOTE} The result of each snippet is described below.`,
|
|
37
38
|
].join("\n")
|
|
@@ -63,7 +64,7 @@ export function registerPrompts(server) {
|
|
|
63
64
|
selector
|
|
64
65
|
? `2. Call interact with a click on "${selector}". Add a few more clicks or key presses if the page has other controls a user would use.`
|
|
65
66
|
: "2. Call interact with clicks on the controls a user would use first: main buttons, links that open menus, form fields. If you cannot tell which ones, ask me.",
|
|
66
|
-
"3. Call read_results. INP gives the worst interaction and its phases (input delay, processing, presentation); Input-Latency-Breakdown says which phase dominates for each type of event.",
|
|
67
|
+
"3. Call read_results; if it answers with summaries, call get_details with its runId for INP and Input-Latency-Breakdown. INP gives the worst interaction and its phases (input delay, processing, presentation); Input-Latency-Breakdown says which phase dominates for each type of event.",
|
|
67
68
|
"4. Call close_page.",
|
|
68
69
|
"5. Report the worst interaction, its target, the dominant phase and the fixes for that phase.",
|
|
69
70
|
"",
|
package/src/mcp/server.js
CHANGED
|
@@ -13,6 +13,8 @@ import { createSessions, SessionError } from "./sessions.js";
|
|
|
13
13
|
import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
|
|
14
14
|
import { registerResources } from "./documents.js";
|
|
15
15
|
import { registerPrompts } from "./prompts.js";
|
|
16
|
+
import { shapeResults, createRuns, RunError } from "./summary.js";
|
|
17
|
+
import { followUpCalls, trackingCall } from "./next-steps.js";
|
|
16
18
|
import { isHttpUrl } from "../url.js";
|
|
17
19
|
|
|
18
20
|
const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
|
|
@@ -33,6 +35,27 @@ const SnippetResult = z
|
|
|
33
35
|
|
|
34
36
|
const SessionId = z.string().describe("The id open_page returned");
|
|
35
37
|
|
|
38
|
+
const RunId = z.string().describe("The id of the run, for get_details");
|
|
39
|
+
// Only a summary names its run: whole objects leave nothing for get_details to add
|
|
40
|
+
const SummaryRunId = RunId.optional().describe("The id of the run, for get_details; only with summaries");
|
|
41
|
+
|
|
42
|
+
// auto, the default, returns whole objects when they are small and summaries when they are large: a
|
|
43
|
+
// summary of a small result costs the agent a turn for the details, which is more than it saves
|
|
44
|
+
const Detail = z
|
|
45
|
+
.enum(["auto", "summary", "full"])
|
|
46
|
+
.default("auto")
|
|
47
|
+
.describe("auto: whole objects when small, summaries when large");
|
|
48
|
+
const DetailOut = z.enum(["summary", "full"]).describe("Whether the results are summaries or the whole objects");
|
|
49
|
+
|
|
50
|
+
// The call an agent makes next, with the arguments it takes as they are
|
|
51
|
+
const NextSteps = z
|
|
52
|
+
.array(z.object({ tool: z.string(), args: z.record(z.string(), z.unknown()), reason: z.string() }))
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("The calls to make next, with their arguments and why, when the results call for them");
|
|
55
|
+
|
|
56
|
+
const DETAIL_NOTE =
|
|
57
|
+
"Large results come as summaries with a runId for get_details; nextSteps, when present, is the call to make next.";
|
|
58
|
+
|
|
36
59
|
// A page can put instructions for the agent in what a snippet returns: an alt text, a class in a
|
|
37
60
|
// selector, the URL of a script. The tools that return what a page holds say how to read it, and so
|
|
38
61
|
// do the instructions of the server, which a client gives the model before any tool.
|
|
@@ -98,6 +121,7 @@ function describeSnippet(name) {
|
|
|
98
121
|
export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env } = {}) {
|
|
99
122
|
const server = new McpServer({ name: "webperf-snippets", version: cliVersion }, { instructions: INSTRUCTIONS });
|
|
100
123
|
const sessions = createSessions({ launchBrowser, maxPages, idleMs });
|
|
124
|
+
const runs = createRuns();
|
|
101
125
|
// The client left (end of stdin, or a closed transport): close the pages and Chromium
|
|
102
126
|
server.server.onclose = () => sessions.closeAll();
|
|
103
127
|
registerResources(server);
|
|
@@ -138,24 +162,28 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
138
162
|
{
|
|
139
163
|
title: "Run a snippet",
|
|
140
164
|
description:
|
|
141
|
-
`Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) run on a page opened with open_page instead. ${PAGE_DATA}`,
|
|
165
|
+
`Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) run on a page opened with open_page instead. ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
142
166
|
inputSchema: z.object({
|
|
143
167
|
url: HttpUrl,
|
|
144
168
|
snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
|
|
145
169
|
viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
|
|
146
170
|
waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the snippet, in ms"),
|
|
171
|
+
detail: Detail,
|
|
147
172
|
}),
|
|
148
173
|
outputSchema: z.object({
|
|
149
174
|
url: z.string(),
|
|
175
|
+
runId: SummaryRunId,
|
|
176
|
+
detail: DetailOut,
|
|
150
177
|
snippet: z.string().describe("Category/Name of the snippet that ran"),
|
|
151
178
|
viewport: z.enum(VIEWPORTS),
|
|
152
179
|
navMs: z.number().describe("From the start of the navigation to the end of the wait"),
|
|
153
180
|
pageErrors: z.array(z.string()).describe("Uncaught errors of the page during the run"),
|
|
154
181
|
result: SnippetResult,
|
|
182
|
+
nextSteps: NextSteps,
|
|
155
183
|
}),
|
|
156
184
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
157
185
|
},
|
|
158
|
-
async ({ url, snippet, viewport, waitMs }, ctx) => {
|
|
186
|
+
async ({ url, snippet, viewport, waitMs, detail }, ctx) => {
|
|
159
187
|
let path;
|
|
160
188
|
try {
|
|
161
189
|
path = resolveSnippetName(snippet);
|
|
@@ -169,8 +197,21 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
169
197
|
|
|
170
198
|
try {
|
|
171
199
|
const run = await runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress: progressFor(ctx) });
|
|
172
|
-
const { id, ...result }
|
|
173
|
-
|
|
200
|
+
const shaped = shapeResults(run.results.map(({ id, ...result }) => result), detail);
|
|
201
|
+
const runId = shaped.detail === "summary" ? runs.keep(run.results) : undefined;
|
|
202
|
+
const id = run.results[0].id;
|
|
203
|
+
const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, ran: [path] });
|
|
204
|
+
return toolResult({
|
|
205
|
+
url,
|
|
206
|
+
...(runId ? { runId } : {}),
|
|
207
|
+
detail: shaped.detail,
|
|
208
|
+
snippet: id,
|
|
209
|
+
viewport,
|
|
210
|
+
navMs: run.navMs,
|
|
211
|
+
pageErrors: run.pageErrors,
|
|
212
|
+
result: shaped.results[0],
|
|
213
|
+
...(nextSteps.length ? { nextSteps } : {}),
|
|
214
|
+
});
|
|
174
215
|
} catch (err) {
|
|
175
216
|
if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
|
|
176
217
|
if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
|
|
@@ -184,15 +225,18 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
184
225
|
{
|
|
185
226
|
title: "Run a workflow",
|
|
186
227
|
description:
|
|
187
|
-
`Opens the URL in a headless Chromium and runs a workflow: a sequence of snippets plus the follow-ups its decision tree adds from the results (for example LCP-Subparts when LCP is over 2.5 s, with the reason). core-web-vitals: LCP and CLS. audit: render-blocking resources, resource hints, critical CSS, TTFB, scripts, images and more. loading: TTFB, FCP, render-blocking, scripts and fonts. interaction: long tasks, animation frames, scroll and layout shifts while it scrolls the page. ${PAGE_DATA}`,
|
|
228
|
+
`Opens the URL in a headless Chromium and runs a workflow: a sequence of snippets plus the follow-ups its decision tree adds from the results (for example LCP-Subparts when LCP is over 2.5 s, with the reason). core-web-vitals: LCP and CLS. audit: render-blocking resources, resource hints, critical CSS, TTFB, scripts, images and more. loading: TTFB, FCP, render-blocking, scripts and fonts. interaction: long tasks, animation frames, scroll and layout shifts while it scrolls the page. ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
188
229
|
inputSchema: z.object({
|
|
189
230
|
url: HttpUrl,
|
|
190
231
|
workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
|
|
191
232
|
viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
|
|
192
233
|
waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the workflow, in ms"),
|
|
234
|
+
detail: Detail,
|
|
193
235
|
}),
|
|
194
236
|
outputSchema: z.object({
|
|
195
237
|
url: z.string(),
|
|
238
|
+
runId: SummaryRunId,
|
|
239
|
+
detail: DetailOut,
|
|
196
240
|
workflow: z.string(),
|
|
197
241
|
viewport: z.enum(VIEWPORTS),
|
|
198
242
|
navMs: z.number(),
|
|
@@ -200,10 +244,11 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
200
244
|
results: z
|
|
201
245
|
.array(z.looseObject({ id: z.string(), status: z.string() }).meta({ additionalProperties: true }))
|
|
202
246
|
.describe("One object per step, in order, then the follow-ups, each with the reason it was added"),
|
|
247
|
+
nextSteps: NextSteps,
|
|
203
248
|
}),
|
|
204
249
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
205
250
|
},
|
|
206
|
-
async ({ url, workflow, viewport, waitMs }, ctx) => {
|
|
251
|
+
async ({ url, workflow, viewport, waitMs, detail }, ctx) => {
|
|
207
252
|
try {
|
|
208
253
|
const run = await runMeasurement({
|
|
209
254
|
url,
|
|
@@ -219,7 +264,24 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
219
264
|
? { ...r, reason: "This step measures clicks and key presses: open the page with open_page and this snippet in track, use it with interact, and read it with read_results." }
|
|
220
265
|
: r
|
|
221
266
|
);
|
|
222
|
-
|
|
267
|
+
const shaped = shapeResults(results, detail);
|
|
268
|
+
const runId = shaped.detail === "summary" ? runs.keep(results) : undefined;
|
|
269
|
+
// The runner already ran the follow-ups of the decision tree; what is left is the steps it skipped
|
|
270
|
+
const skipped = results
|
|
271
|
+
.filter((r) => r.status === "skipped")
|
|
272
|
+
.map((r) => WORKFLOWS[workflow].steps.find((s) => s.id === r.id)?.path)
|
|
273
|
+
.filter(Boolean);
|
|
274
|
+
const nextSteps = skipped.length ? [trackingCall({ paths: skipped, url, viewport })] : [];
|
|
275
|
+
return toolResult({
|
|
276
|
+
url,
|
|
277
|
+
...(runId ? { runId } : {}),
|
|
278
|
+
...shaped,
|
|
279
|
+
workflow,
|
|
280
|
+
viewport,
|
|
281
|
+
navMs: run.navMs,
|
|
282
|
+
pageErrors: run.pageErrors,
|
|
283
|
+
...(nextSteps.length ? { nextSteps } : {}),
|
|
284
|
+
});
|
|
223
285
|
} catch (err) {
|
|
224
286
|
if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
|
|
225
287
|
if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
|
|
@@ -358,22 +420,58 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
358
420
|
{
|
|
359
421
|
title: "Read the tracking snippets",
|
|
360
422
|
description:
|
|
361
|
-
`Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. ${PAGE_DATA}`,
|
|
362
|
-
inputSchema: z.object({ sessionId: SessionId }),
|
|
363
|
-
outputSchema: z.object({ sessionId: z.string(), pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
|
|
423
|
+
`Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
424
|
+
inputSchema: z.object({ sessionId: SessionId, detail: Detail }),
|
|
425
|
+
outputSchema: z.object({ sessionId: z.string(), runId: SummaryRunId, detail: DetailOut, pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
|
|
364
426
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
365
427
|
},
|
|
366
|
-
withSession(async ({ sessionId }) => {
|
|
428
|
+
withSession(async ({ sessionId, detail }) => {
|
|
367
429
|
const session = sessions.get(sessionId);
|
|
368
|
-
const
|
|
369
|
-
for (const installed of session.tracking)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
return toolResult({ sessionId, pageErrors: session.pageErrors, results });
|
|
430
|
+
const collected = [];
|
|
431
|
+
for (const installed of session.tracking) collected.push(await collectTrackingData(session.page, installed, session.hadInput));
|
|
432
|
+
const shaped = shapeResults(collected.map(({ id, ...result }) => result), detail);
|
|
433
|
+
const runId = shaped.detail === "summary" ? runs.keep(collected) : undefined;
|
|
434
|
+
return toolResult({ sessionId, ...(runId ? { runId } : {}), ...shaped, pageErrors: session.pageErrors });
|
|
374
435
|
})
|
|
375
436
|
);
|
|
376
437
|
|
|
438
|
+
server.registerTool(
|
|
439
|
+
"get_details",
|
|
440
|
+
{
|
|
441
|
+
title: "Get the details of a result",
|
|
442
|
+
description:
|
|
443
|
+
`Returns the whole object of one snippet of a summarized run, by its runId: what the summary leaves out, from the same run, without measuring again. ${PAGE_DATA}`,
|
|
444
|
+
inputSchema: z.object({
|
|
445
|
+
runId: RunId,
|
|
446
|
+
snippet: z.string().describe("The id of the result in the summary, or its script"),
|
|
447
|
+
}),
|
|
448
|
+
outputSchema: z.object({ runId: z.string(), snippet: z.string().describe("The id of the result in the run"), result: SnippetResult }),
|
|
449
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
450
|
+
},
|
|
451
|
+
async ({ runId, snippet }) => {
|
|
452
|
+
// The id the summary shows first, then the Category/Name an alias or a short name stands for
|
|
453
|
+
let resolved;
|
|
454
|
+
try {
|
|
455
|
+
resolved = resolveSnippetName(snippet);
|
|
456
|
+
} catch {
|
|
457
|
+
resolved = undefined;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
let result;
|
|
461
|
+
try {
|
|
462
|
+
result = runs.get(runId, snippet);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
if (!(err instanceof RunError) || !resolved || resolved === snippet) throw err;
|
|
465
|
+
result = runs.get(runId, resolved);
|
|
466
|
+
}
|
|
467
|
+
return toolResult({ runId, snippet: result.id, result });
|
|
468
|
+
} catch (err) {
|
|
469
|
+
if (err instanceof RunError) return toolError(err.message);
|
|
470
|
+
throw err;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
|
|
377
475
|
server.registerTool(
|
|
378
476
|
"close_page",
|
|
379
477
|
{
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// A failure of get_details the model can act on: a run that is not kept, or a snippet it did not run
|
|
4
|
+
export class RunError extends Error {}
|
|
5
|
+
|
|
6
|
+
// What an agent needs from a result to decide what to do next; the rest (details, items, thresholds)
|
|
7
|
+
// is in get_details, from the same run
|
|
8
|
+
const SUMMARY_FIELDS = ["id", "script", "status", "metric", "value", "unit", "rating", "error", "message", "reason"];
|
|
9
|
+
const ISSUE_FIELDS = ["code", "severity", "message", "target"];
|
|
10
|
+
|
|
11
|
+
const pick = (object, fields) => Object.fromEntries(fields.filter((f) => object[f] !== undefined).map((f) => [f, object[f]]));
|
|
12
|
+
|
|
13
|
+
export function summarize(result) {
|
|
14
|
+
const summary = pick(result, SUMMARY_FIELDS);
|
|
15
|
+
if (result.issues?.length) summary.issues = result.issues.map((issue) => pick(issue, ISSUE_FIELDS));
|
|
16
|
+
return summary;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// About 2,000 tokens. Below it the whole objects cost the agent less than the turn it would spend on
|
|
20
|
+
// get_details for what a summary leaves out: in the MCP evals of 2026-10-29, summaries of small results
|
|
21
|
+
// made the agent call get_details in 4 of 9 runs, and each turn sent 8 to 12k tokens of context again.
|
|
22
|
+
export const FULL_UP_TO_CHARS = 8000;
|
|
23
|
+
|
|
24
|
+
// The results of a call as the agent asked for them; with auto, whole when they are small, and summaries
|
|
25
|
+
// with get_details for the rest when they are large
|
|
26
|
+
export function shapeResults(results, detail = "auto", maxFullChars = FULL_UP_TO_CHARS) {
|
|
27
|
+
const full = detail === "full" || (detail === "auto" && JSON.stringify(results).length <= maxFullChars);
|
|
28
|
+
return full ? { detail: "full", results } : { detail: "summary", results: results.map(summarize) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The full results of the last runs of an MCP connection, by run id, so get_details answers with the
|
|
32
|
+
// values the summary came from: measuring again would give other ones
|
|
33
|
+
export function createRuns({ max = 20 } = {}) {
|
|
34
|
+
const runs = new Map();
|
|
35
|
+
|
|
36
|
+
function keep(results) {
|
|
37
|
+
const runId = randomUUID();
|
|
38
|
+
runs.set(runId, results);
|
|
39
|
+
// A Map keeps the order of insertion, so the first key is the oldest run
|
|
40
|
+
if (runs.size > max) runs.delete(runs.keys().next().value);
|
|
41
|
+
return runId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function get(runId, snippet) {
|
|
45
|
+
const results = runs.get(runId);
|
|
46
|
+
if (!results) {
|
|
47
|
+
throw new RunError(`Unknown runId ${runId}: this server keeps the last ${max} runs of the connection. Run the tool again for a new one.`);
|
|
48
|
+
}
|
|
49
|
+
const result = results.find((r) => r.id === snippet) ?? results.find((r) => r.script === snippet);
|
|
50
|
+
if (!result) throw new RunError(`${snippet} is not in run ${runId}, which has: ${results.map((r) => r.id).join(", ")}.`);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { keep, get };
|
|
55
|
+
}
|