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 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.7.0" }
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.7.0" }
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 the object it returns. 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`. |
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.7.0",
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 === "not set" || el.containIntrinsicSize === "none",
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 === "not set" || el.containIntrinsicSize === "none")
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
@@ -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*`. `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`.
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` and no `contain-intrinsic-size`; `target` is its selector |
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
+ }
@@ -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. 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.",
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 } = run.results[0];
173
- return toolResult({ url, snippet: id, viewport, navMs: run.navMs, pageErrors: run.pageErrors, result });
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
- return toolResult({ url, workflow, viewport, navMs: run.navMs, pageErrors: run.pageErrors, results });
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 results = [];
369
- for (const installed of session.tracking) {
370
- const { id, ...result } = await collectTrackingData(session.page, installed, session.hadInput);
371
- results.push(result);
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
+ }