webperf-snippets 1.10.0 → 1.12.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/src/mcp/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { McpServer } from "@modelcontextprotocol/server";
1
+ import { McpServer, ResourceTemplate, ResourceNotFoundError } from "@modelcontextprotocol/server";
2
2
  import * as z from "zod/v4";
3
3
  import { cliVersion } from "../report-to.js";
4
4
  import { CATEGORIES, aliasesOf, listSnippets, resolveSnippetName } from "../snippet-names.js";
@@ -10,16 +10,25 @@ import { RULES } from "../decision-tree.js";
10
10
  import { fetchCrux, fetchCruxHistory } from "../crux.js";
11
11
  import { runInteractions, isInputStep, settle } from "../interactions.js";
12
12
  import { createSessions, SessionError } from "./sessions.js";
13
+ import { createQueue } from "./queue.js";
13
14
  import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
14
15
  import { registerResources } from "./documents.js";
15
16
  import { registerPrompts } from "./prompts.js";
16
17
  import { shapeResults, createRuns, RunError } from "./summary.js";
17
18
  import { followUpCalls, trackingCall } from "./next-steps.js";
18
19
  import { isHttpUrl } from "../url.js";
20
+ import { machineOf } from "../machine.js";
21
+ import { measurementConditions } from "../conditions.js";
22
+ import { prepareBaseline, compareWithBaseline } from "./baseline.js";
23
+ import { MAX_RUNS, measureRuns } from "../runs.js";
19
24
 
20
25
  const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
21
26
  const STAGES = 3;
22
27
  const NAV_TIMEOUT_MS = 30000;
28
+ // How long a page load can keep its turn: far beyond the load and the wait, which have their own limits
29
+ // (30 s and 10 s), because a run that reaches this is not slow. A page whose main thread never yields
30
+ // answers no evaluation, and Playwright has no timeout for one, so it would hold up the queue for ever.
31
+ const CALL_LIMIT_MS = 120000;
23
32
 
24
33
  const HttpUrl = z
25
34
  .string()
@@ -36,8 +45,59 @@ const SnippetResult = z
36
45
  const SessionId = z.string().describe("The id open_page returned");
37
46
 
38
47
  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
48
+ // Only a summary of read_results names its run: whole objects leave nothing for get_details to add
40
49
  const SummaryRunId = RunId.optional().describe("The id of the run, for get_details; only with summaries");
50
+ // A run of a page load is also the baseline of a later one, so it always has its id
51
+ const PageRunId = RunId.describe("The id of the run: the baseline of a later run_snippet or run_workflow; with summaries, also for get_details");
52
+
53
+ const Baseline = z
54
+ .string()
55
+ .min(1)
56
+ .optional()
57
+ .describe(
58
+ "Compare this run with a baseline, as --baseline of the CLI: the runId of an earlier run_snippet or run_workflow of this connection (listed as webperf://runs/{runId}), or the path of a page the CLI wrote with --json, absolute or relative to the working directory of the server"
59
+ );
60
+ const Thresholds = z
61
+ .array(z.string())
62
+ .optional()
63
+ .describe("With baseline, the threshold of a metric, as --threshold of the CLI: LCP=15% (relative), LCP=200ms (absolute), CLS=0.1. The part not given keeps its default");
64
+
65
+ const Runs = z
66
+ .number()
67
+ .int()
68
+ .min(1)
69
+ .max(MAX_RUNS)
70
+ .default(1)
71
+ .describe(
72
+ "Measure the page this many times, one after the other, as --runs of the CLI: the answer is the median run, plus runs with the median, min, max and agreement of each metric. With baseline, 5 or more on both sides give the statistical verdict: use 5 for a comparison, and more only when agreement says noisy. Each run is a full page load and wait, so it takes that much longer"
73
+ );
74
+
75
+ const LooseObject = (shape) => z.looseObject(shape).meta({ additionalProperties: true });
76
+ const RunsSummary = LooseObject({
77
+ count: z.number(),
78
+ failed: z.number().describe("Runs left out because they failed"),
79
+ medianRun: z.number().describe("The run the answer comes from, from 1"),
80
+ medianBy: z.string().describe("The metric that picked it"),
81
+ summary: z.record(z.string(), LooseObject({ median: z.number() })).describe("By metric: median, min, max, values of each run (null for a failed one), unit, cv and agreement (consistent, variable, noisy)"),
82
+ })
83
+ .optional()
84
+ .describe("With several runs, the runs field of the JSON of the CLI");
85
+ const BaselineError = LooseObject({ id: z.string(), message: z.string().optional() });
86
+ const Comparison = z
87
+ .object({
88
+ runId: z.string().optional().describe("The run the baseline was, when it was a runId"),
89
+ file: z.string().optional().describe("The file the baseline was, when it was a path"),
90
+ url: z.string().describe("The URL the baseline measured"),
91
+ runs: LooseObject({ count: z.number() }).describe("The runs of the baseline"),
92
+ metrics: z
93
+ .record(z.string(), LooseObject({ verdict: z.string() }))
94
+ .describe("By metric: baseline, current, delta, deltaPercent, threshold, verdict (regressed, improved, unchanged, not measured) and, with five runs a side, stats (p, shift, ci, method)"),
95
+ errors: z.object({ new: z.array(BaselineError), fixed: z.array(BaselineError) }).describe("The errors only this run has, and the ones only the baseline had"),
96
+ warnings: z.array(z.string()).describe("What differs in how both were measured: how far to trust the comparison"),
97
+ regressions: z.array(z.string()).describe("What got worse, one line each: the metrics that regressed and the new errors"),
98
+ })
99
+ .optional()
100
+ .describe("With baseline, the comparison, as the baseline field of the JSON of the CLI");
41
101
 
42
102
  // auto, the default, returns whole objects when they are small and summaries when they are large: a
43
103
  // summary of a small result costs the agent a turn for the details, which is more than it saves
@@ -55,6 +115,8 @@ const NextSteps = z
55
115
 
56
116
  const DETAIL_NOTE =
57
117
  "Large results come as summaries with a runId for get_details; nextSteps, when present, is the call to make next.";
118
+ const BASELINE_NOTE = "With baseline, it also compares the run with an earlier one, as --baseline of the CLI, and says what regressed.";
119
+ const QUEUE_NOTE = "Measurements run one at a time, so several in one turn save no time.";
58
120
 
59
121
  // A page can put instructions for the agent in what a snippet returns: an alt text, a class in a
60
122
  // selector, the URL of a script. The tools that return what a page holds say how to read it, and so
@@ -92,13 +154,60 @@ const toolError = (text) => ({ isError: true, content: [{ type: "text", text }]
92
154
 
93
155
  const firstLine = (message) => message.split("\n")[0];
94
156
 
95
- // A client that wants progress sends a token with the call; the stages of a run go to it
96
- function progressFor(ctx) {
157
+ // A client that wants progress sends a token with the call; the stages of each run go to it, numbered
158
+ // across the runs. Returns the reporter of a run, by its number.
159
+ function progressFor(ctx, runs) {
97
160
  const progressToken = ctx.mcpReq._meta?.progressToken;
98
- return (progress, message) =>
161
+ const total = STAGES * runs;
162
+ return (run) => (stage, message) =>
99
163
  progressToken === undefined
100
164
  ? undefined
101
- : ctx.mcpReq.notify({ method: "notifications/progress", params: { progressToken, progress, total: STAGES, message } });
165
+ : ctx.mcpReq.notify({
166
+ method: "notifications/progress",
167
+ params: { progressToken, progress: (run - 1) * STAGES + stage, total, message: runs > 1 ? `Run ${run} of ${runs}: ${message}` : message },
168
+ });
169
+ }
170
+
171
+ // The report of a call that waits for its turn, for a client that wants progress: how many calls are
172
+ // ahead. Progress has to grow with each report, and the stages of a run start at 1, so it stays below 1.
173
+ function waitProgressFor(ctx, total) {
174
+ const progressToken = ctx.mcpReq._meta?.progressToken;
175
+ if (progressToken === undefined) return undefined;
176
+ let reports = 0;
177
+ return (ahead) => {
178
+ reports++;
179
+ const message = `Waiting for its turn: ${ahead} ${ahead === 1 ? "call" : "calls"} ahead`;
180
+ return ctx.mcpReq.notify({ method: "notifications/progress", params: { progressToken, progress: reports / (reports + 1), ...(total ? { total } : {}), message } });
181
+ };
182
+ }
183
+
184
+ const stoppedAnswering = (limitMs) => `The page did not answer within ${limitMs / 1000} seconds`;
185
+
186
+ // A page that stopped answering is closed, and the runs left of the call would only hold up the queue
187
+ class PageStoppedError extends Error {}
188
+ const endsTheCall = (err) => isMissingBrowser(err) || err instanceof PageStoppedError;
189
+
190
+ // A page load measured once, or `count` times with the median run as the answer, as --runs of the CLI.
191
+ // `measure` gets the signal of its run, which closes its Chromium. When the client gives up (a cancel,
192
+ // its timeout, or leaving), the run in progress ends and the runs left never start.
193
+ async function measurePage(measure, count, ctx, limitMs) {
194
+ const progress = progressFor(ctx, count);
195
+ const measureRun = async (run) => {
196
+ const limit = AbortSignal.timeout(limitMs);
197
+ let measured;
198
+ try {
199
+ measured = await measure(progress(run), AbortSignal.any([ctx.mcpReq.signal, limit]));
200
+ } catch (err) {
201
+ if (!limit.aborted) throw err;
202
+ }
203
+ // The runner keeps a failed evaluation as a result, and closing Chromium fails every one
204
+ if (limit.aborted) throw new PageStoppedError(stoppedAnswering(limitMs));
205
+ return measured;
206
+ };
207
+ if (count === 1) return measureRun(1);
208
+ const { payload, runs, errors } = await measureRuns(measureRun, count, { isFatal: endsTheCall, signal: ctx.mcpReq.signal });
209
+ if (!payload) throw new Error(`Every run failed: ${[...new Set(errors.map(firstLine))].join("; ")}`);
210
+ return { ...payload, runs };
102
211
  }
103
212
 
104
213
  const CRUX_FORM_FACTORS = { phone: "PHONE", desktop: "DESKTOP", tablet: "TABLET" };
@@ -116,16 +225,86 @@ function describeSnippet(name) {
116
225
  };
117
226
  }
118
227
 
228
+ // The runs of the connection that measured a page, as resources: an agent picks the baseline of a run
229
+ // among them. Each is the JSON the CLI prints with --json, so saved to a file it is a baseline of the
230
+ // CLI too.
231
+ function registerRuns(server, runs) {
232
+ const JSON_TYPE = "application/json";
233
+ server.registerResource(
234
+ "runs",
235
+ new ResourceTemplate("webperf://runs/{runId}", {
236
+ list: async () => ({
237
+ resources: runs.pages().map(({ runId, url, conditions, at, runs: count }) => ({
238
+ uri: `webperf://runs/${runId}`,
239
+ name: `${conditions.snippet ?? conditions.workflow} on ${url} (${conditions.viewport})${count ? `, ${count} runs` : ""}`,
240
+ description: `Measured at ${at}`,
241
+ mimeType: JSON_TYPE,
242
+ })),
243
+ }),
244
+ }),
245
+ {
246
+ title: "Runs of this connection",
247
+ description: `The page an earlier run_snippet or run_workflow of this connection measured, with its results and conditions, as the JSON of the CLI (--json). Its runId is the baseline argument of a later run. The server keeps the last 20 runs. ${PAGE_DATA}`,
248
+ mimeType: JSON_TYPE,
249
+ },
250
+ async (uri, { runId }) => {
251
+ let page;
252
+ try {
253
+ page = runs.page(runId);
254
+ } catch (err) {
255
+ if (err instanceof RunError) throw new ResourceNotFoundError(uri.href);
256
+ throw err;
257
+ }
258
+ return { contents: [{ uri: uri.href, mimeType: JSON_TYPE, text: JSON.stringify(page) }] };
259
+ }
260
+ );
261
+ }
262
+
119
263
  // The MCP server of the CLI: the same snippets and runner, offered as tools an agent calls by name.
120
264
  // serveStdio creates one instance per connection, so the pages it keeps open belong to that client.
121
- export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env } = {}) {
265
+ export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env, machine = machineOf, callLimitMs = CALL_LIMIT_MS, reportEveryMs } = {}) {
122
266
  const server = new McpServer({ name: "webperf-snippets", version: cliVersion }, { instructions: INSTRUCTIONS });
123
267
  const sessions = createSessions({ launchBrowser, maxPages, idleMs });
124
268
  const runs = createRuns();
269
+ // An agent sends several calls in one turn and the client runs them at once. The calls that make a page
270
+ // work take turns (run_snippet, run_workflow, open_page, interact): a load or a click competes for the
271
+ // CPU with any other, which inflates what each page reports. A call keeps its turn only while it runs,
272
+ // so it can wait only for something that ends on its own: never for a later call of the agent, and
273
+ // never for a page left open, which keeps no turn. read_results and close_page take no turn: they ask
274
+ // little of Chromium, and they are how an agent gets out of a page.
275
+ const queue = createQueue({ reportEveryMs });
276
+ const inTurn = (ctx, total, task) => queue.run(task, { signal: ctx.mcpReq.signal, onWait: waitProgressFor(ctx, total) });
125
277
  // The client left (end of stdin, or a closed transport): close the pages and Chromium
126
278
  server.server.onclose = () => sessions.closeAll();
127
279
  registerResources(server);
128
280
  registerPrompts(server);
281
+ registerRuns(server, runs);
282
+
283
+ // The machine goes in the conditions of every run, as in the JSON of the CLI, so a baseline measured
284
+ // on another one warns. It is measured once, in the turn of the first run, before its Chromium starts.
285
+ let machineOfServer;
286
+ const conditionsOf = (conditions) => measurementConditions({ ...conditions, machine: (machineOfServer ??= machine()) });
287
+
288
+ const measureInTurn = (conditions, measure, count, ctx) =>
289
+ inTurn(ctx, STAGES * count, async () => ({ conditions: conditionsOf(conditions), run: await measurePage(measure, count, ctx, callLimitMs) }));
290
+
291
+ // A run of a page load, kept with its page in the shape of the JSON of the CLI: its runId is the
292
+ // baseline of a later run, and the resources list it
293
+ const keepPage = (run, results, conditions) => {
294
+ const page = { url: run.url, navMs: run.navMs, pageErrors: run.pageErrors, ...(run.throttling ? { throttling: run.throttling } : {}), ...(run.runs ? { runs: run.runs } : {}), conditions };
295
+ const runId = runs.keep(results, page);
296
+ server.sendResourceListChanged();
297
+ return { runId, current: { ...page, results } };
298
+ };
299
+
300
+ // The baseline of a tool, read and checked before the page loads; a wrong one is a tool error
301
+ const baselineFor = ({ baseline, thresholds }) => {
302
+ if (baseline === undefined) {
303
+ if (thresholds?.length) throw new Error("thresholds need a baseline");
304
+ return undefined;
305
+ }
306
+ return prepareBaseline({ baseline, thresholds }, runs);
307
+ };
129
308
 
130
309
  // Runs a session tool, and turns the errors the model can act on into tool errors
131
310
  const withSession = (handler) => async (args, ctx) => {
@@ -162,28 +341,33 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
162
341
  {
163
342
  title: "Run a snippet",
164
343
  description:
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}`,
344
+ `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. ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
166
345
  inputSchema: z.object({
167
346
  url: HttpUrl,
168
347
  snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
169
348
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
170
349
  waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the snippet, in ms"),
350
+ runs: Runs,
171
351
  detail: Detail,
352
+ baseline: Baseline,
353
+ thresholds: Thresholds,
172
354
  }),
173
355
  outputSchema: z.object({
174
356
  url: z.string(),
175
- runId: SummaryRunId,
357
+ runId: PageRunId,
176
358
  detail: DetailOut,
177
359
  snippet: z.string().describe("Category/Name of the snippet that ran"),
178
360
  viewport: z.enum(VIEWPORTS),
179
361
  navMs: z.number().describe("From the start of the navigation to the end of the wait"),
180
362
  pageErrors: z.array(z.string()).describe("Uncaught errors of the page during the run"),
181
363
  result: SnippetResult,
364
+ runs: RunsSummary,
365
+ baseline: Comparison,
182
366
  nextSteps: NextSteps,
183
367
  }),
184
368
  annotations: { readOnlyHint: true, openWorldHint: true },
185
369
  },
186
- async ({ url, snippet, viewport, waitMs, detail }, ctx) => {
370
+ async ({ url, snippet, viewport, waitMs, runs: count, detail, baseline, thresholds }, ctx) => {
187
371
  let path;
188
372
  try {
189
373
  path = resolveSnippetName(snippet);
@@ -194,22 +378,35 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
194
378
  if (isTrackingSnippet(path, source)) {
195
379
  return toolError(`${path} records interactions: open the page with open_page and this snippet in track, use it with interact, and read it with read_results.`);
196
380
  }
381
+ let base;
382
+ try {
383
+ base = baselineFor({ baseline, thresholds });
384
+ } catch (err) {
385
+ return toolError(err.message);
386
+ }
197
387
 
198
388
  try {
199
- const run = await runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress: progressFor(ctx) });
389
+ const { conditions, run } = await measureInTurn(
390
+ { viewport, snippet: path, waitMs },
391
+ (onProgress, signal) => runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress, signal }),
392
+ count,
393
+ ctx
394
+ );
200
395
  const shaped = shapeResults(run.results.map(({ id, ...result }) => result), detail);
201
- const runId = shaped.detail === "summary" ? runs.keep(run.results) : undefined;
396
+ const { runId, current } = keepPage(run, run.results, conditions);
202
397
  const id = run.results[0].id;
203
398
  const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, ran: [path] });
204
399
  return toolResult({
205
400
  url,
206
- ...(runId ? { runId } : {}),
401
+ runId,
207
402
  detail: shaped.detail,
208
403
  snippet: id,
209
404
  viewport,
210
405
  navMs: run.navMs,
211
406
  pageErrors: run.pageErrors,
212
407
  result: shaped.results[0],
408
+ ...(run.runs ? { runs: run.runs } : {}),
409
+ ...(base ? { baseline: compareWithBaseline(base, current) } : {}),
213
410
  ...(nextSteps.length ? { nextSteps } : {}),
214
411
  });
215
412
  } catch (err) {
@@ -225,17 +422,20 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
225
422
  {
226
423
  title: "Run a workflow",
227
424
  description:
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}`,
425
+ `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. ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
229
426
  inputSchema: z.object({
230
427
  url: HttpUrl,
231
428
  workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
232
429
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
233
430
  waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the workflow, in ms"),
431
+ runs: Runs,
234
432
  detail: Detail,
433
+ baseline: Baseline,
434
+ thresholds: Thresholds,
235
435
  }),
236
436
  outputSchema: z.object({
237
437
  url: z.string(),
238
- runId: SummaryRunId,
438
+ runId: PageRunId,
239
439
  detail: DetailOut,
240
440
  workflow: z.string(),
241
441
  viewport: z.enum(VIEWPORTS),
@@ -244,20 +444,26 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
244
444
  results: z
245
445
  .array(z.looseObject({ id: z.string(), status: z.string() }).meta({ additionalProperties: true }))
246
446
  .describe("One object per step, in order, then the follow-ups, each with the reason it was added"),
447
+ runs: RunsSummary,
448
+ baseline: Comparison,
247
449
  nextSteps: NextSteps,
248
450
  }),
249
451
  annotations: { readOnlyHint: true, openWorldHint: true },
250
452
  },
251
- async ({ url, workflow, viewport, waitMs, detail }, ctx) => {
453
+ async ({ url, workflow, viewport, waitMs, runs: count, detail, baseline, thresholds }, ctx) => {
454
+ let base;
252
455
  try {
253
- const run = await runMeasurement({
254
- url,
255
- workflow: WORKFLOWS[workflow],
256
- rules: RULES,
257
- waitMs,
258
- viewport: VIEWPORT_PRESETS[viewport],
259
- onProgress: progressFor(ctx),
260
- });
456
+ base = baselineFor({ baseline, thresholds });
457
+ } catch (err) {
458
+ return toolError(err.message);
459
+ }
460
+ try {
461
+ const { conditions, run } = await measureInTurn(
462
+ { viewport, workflow, waitMs },
463
+ (onProgress, signal) => runMeasurement({ url, workflow: WORKFLOWS[workflow], rules: RULES, waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress, signal }),
464
+ count,
465
+ ctx
466
+ );
261
467
  // The runner explains a skipped step with the flag of the CLI; here the tools are what the model has
262
468
  const results = run.results.map((r) =>
263
469
  r.status === "skipped"
@@ -265,7 +471,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
265
471
  : r
266
472
  );
267
473
  const shaped = shapeResults(results, detail);
268
- const runId = shaped.detail === "summary" ? runs.keep(results) : undefined;
474
+ const { runId, current } = keepPage(run, results, conditions);
269
475
  // The runner already ran the follow-ups of the decision tree; what is left is the steps it skipped
270
476
  const skipped = results
271
477
  .filter((r) => r.status === "skipped")
@@ -274,12 +480,14 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
274
480
  const nextSteps = skipped.length ? [trackingCall({ paths: skipped, url, viewport })] : [];
275
481
  return toolResult({
276
482
  url,
277
- ...(runId ? { runId } : {}),
483
+ runId,
278
484
  ...shaped,
279
485
  workflow,
280
486
  viewport,
281
487
  navMs: run.navMs,
282
488
  pageErrors: run.pageErrors,
489
+ ...(run.runs ? { runs: run.runs } : {}),
490
+ ...(base ? { baseline: compareWithBaseline(base, current) } : {}),
283
491
  ...(nextSteps.length ? { nextSteps } : {}),
284
492
  });
285
493
  } catch (err) {
@@ -382,27 +590,33 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
382
590
  items.push({ id: path, path, source });
383
591
  }
384
592
 
385
- let session;
386
- try {
387
- session = await sessions.create({ viewport: VIEWPORT_PRESETS[viewport], signal: ctx.mcpReq.signal });
388
- } catch (err) {
389
- if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
390
- throw err;
391
- }
392
- try {
393
- const navStart = Date.now();
394
- await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
395
- if (waitMs > 0) await session.page.waitForTimeout(waitMs);
396
- const navMs = Date.now() - navStart;
397
- session.tracking = await evaluateItems(session.page, items);
398
- session.stopListening();
399
- const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
400
- return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
401
- } catch (err) {
402
- await sessions.close(session.id);
403
- if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
404
- return toolError(`Opening the page failed: ${firstLine(err.message)}`);
405
- }
593
+ return inTurn(ctx, undefined, async () => {
594
+ const limit = AbortSignal.timeout(callLimitMs);
595
+ let session;
596
+ try {
597
+ session = await sessions.create({ viewport: VIEWPORT_PRESETS[viewport], signal: AbortSignal.any([ctx.mcpReq.signal, limit]) });
598
+ } catch (err) {
599
+ if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
600
+ throw err;
601
+ }
602
+ try {
603
+ const navStart = Date.now();
604
+ await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
605
+ if (waitMs > 0) await session.page.waitForTimeout(waitMs);
606
+ const navMs = Date.now() - navStart;
607
+ session.tracking = await evaluateItems(session.page, items);
608
+ // evaluateItems keeps a failed evaluation as a result, and closing the page fails every one
609
+ if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
610
+ session.stopListening();
611
+ const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
612
+ return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
613
+ } catch (err) {
614
+ await sessions.close(session.id);
615
+ if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
616
+ if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
617
+ return toolError(`Opening the page failed: ${firstLine(err.message)}`);
618
+ }
619
+ });
406
620
  })
407
621
  );
408
622
 
@@ -420,16 +634,28 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
420
634
  // A click can submit a form or change data on the site, so this tool is not read-only
421
635
  annotations: { readOnlyHint: false, openWorldHint: true },
422
636
  },
423
- withSession(async ({ sessionId, steps }) => {
424
- const session = sessions.get(sessionId);
425
- try {
426
- await runInteractions(session.page, steps);
427
- } catch (err) {
428
- return toolError(`A step failed: ${firstLine(err.message)}`);
429
- }
430
- session.hadInput ||= steps.some(isInputStep);
431
- await settle(session.page);
432
- return toolResult({ sessionId, steps: steps.length, url: session.page.url() });
637
+ withSession(async ({ sessionId, steps }, ctx) => {
638
+ // A page that is not open answers now, not after a turn it does not need
639
+ sessions.get(sessionId);
640
+ return inTurn(ctx, undefined, async () => {
641
+ const session = sessions.get(sessionId);
642
+ // A wait step is time the agent asked for. Closing the page fails whatever it stopped answering.
643
+ const limitMs = callLimitMs + steps.reduce((sum, step) => sum + (step.ms ?? 0), 0);
644
+ const limit = AbortSignal.timeout(limitMs);
645
+ const closePage = () => sessions.close(sessionId);
646
+ limit.addEventListener("abort", closePage, { once: true });
647
+ try {
648
+ await runInteractions(session.page, steps);
649
+ session.hadInput ||= steps.some(isInputStep);
650
+ await settle(session.page);
651
+ if (!limit.aborted) return toolResult({ sessionId, steps: steps.length, url: session.page.url() });
652
+ } catch (err) {
653
+ if (!limit.aborted) return toolError(`A step failed: ${firstLine(err.message)}`);
654
+ } finally {
655
+ limit.removeEventListener("abort", closePage);
656
+ }
657
+ return toolError(`${stoppedAnswering(limitMs)}, and it was closed. Open it again with open_page.`);
658
+ });
433
659
  })
434
660
  );
435
661
 
@@ -463,7 +689,12 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
463
689
  runId: RunId,
464
690
  snippet: z.string().describe("The id of the result in the summary, or its script"),
465
691
  }),
466
- outputSchema: z.object({ runId: z.string(), snippet: z.string().describe("The id of the result in the run"), result: SnippetResult }),
692
+ outputSchema: z.object({
693
+ runId: z.string(),
694
+ snippet: z.string().describe("The id of the result in the run"),
695
+ medianRun: z.number().optional().describe("With several runs, the one this result comes from: the median run, the only one kept"),
696
+ result: SnippetResult,
697
+ }),
467
698
  annotations: { readOnlyHint: true, openWorldHint: false },
468
699
  },
469
700
  async ({ runId, snippet }) => {
@@ -482,7 +713,8 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
482
713
  if (!(err instanceof RunError) || !resolved || resolved === snippet) throw err;
483
714
  result = runs.get(runId, resolved);
484
715
  }
485
- return toolResult({ runId, snippet: result.id, result });
716
+ const medianRun = runs.medianRun(runId);
717
+ return toolResult({ runId, snippet: result.id, ...(medianRun ? { medianRun } : {}), result });
486
718
  } catch (err) {
487
719
  if (err instanceof RunError) return toolError(err.message);
488
720
  throw err;
@@ -29,27 +29,49 @@ export function shapeResults(results, detail = "auto", maxFullChars = FULL_UP_TO
29
29
  }
30
30
 
31
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
32
+ // values the summary came from: measuring again would give other ones. A run of a page load also keeps
33
+ // its page (url, navMs, pageErrors, conditions), so a later run can take it as its baseline.
33
34
  export function createRuns({ max = 20 } = {}) {
34
35
  const runs = new Map();
36
+ const unknown = (runId) =>
37
+ new RunError(`Unknown runId ${runId}: this server keeps the last ${max} runs of the connection. Run the tool again for a new one.`);
35
38
 
36
- function keep(results) {
39
+ function keep(results, page) {
37
40
  const runId = randomUUID();
38
- runs.set(runId, results);
41
+ runs.set(runId, { results, page, at: new Date().toISOString() });
39
42
  // A Map keeps the order of insertion, so the first key is the oldest run
40
43
  if (runs.size > max) runs.delete(runs.keys().next().value);
41
44
  return runId;
42
45
  }
43
46
 
44
47
  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
- }
48
+ const run = runs.get(runId);
49
+ if (!run) throw unknown(runId);
50
+ const { results } = run;
49
51
  const result = results.find((r) => r.id === snippet) ?? results.find((r) => r.script === snippet);
50
52
  if (!result) throw new RunError(`${snippet} is not in run ${runId}, which has: ${results.map((r) => r.id).join(", ")}.`);
51
53
  return result;
52
54
  }
53
55
 
54
- return { keep, get };
56
+ // A run as the JSON of the CLI gives a page, which is what a baseline is
57
+ function page(runId) {
58
+ const run = runs.get(runId);
59
+ if (!run) throw unknown(runId);
60
+ if (!run.page) throw new RunError(`Run ${runId} read an open page; a baseline is a run of run_snippet or run_workflow.`);
61
+ return { ...run.page, results: run.results };
62
+ }
63
+
64
+ // The runs that can be a baseline, the oldest first, with the number of measurements of each when it
65
+ // has several: the statistics of a comparison need five a side
66
+ const pages = () =>
67
+ [...runs]
68
+ .filter(([, run]) => run.page)
69
+ .map(([runId, { page, at }]) => ({ runId, url: page.url, conditions: page.conditions, at, ...(page.runs ? { runs: page.runs.count } : {}) }));
70
+
71
+ // A measurement of several runs keeps the results of the median one, as the JSON of the CLI does
72
+ const medianRun = (runId) => runs.get(runId)?.page?.runs?.medianRun;
73
+
74
+ const has = (runId) => runs.has(runId);
75
+
76
+ return { keep, get, page, pages, medianRun, has };
55
77
  }