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/README.md +25 -7
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +20 -4
- package/snippets/CoreWebVitals/LCP-Trail.js +52 -40
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -0
- package/snippets/Interaction/Interactions.js +4 -0
- package/snippets/SCHEMA.md +33 -5
- package/src/baseline.js +51 -12
- package/src/bin.js +20 -19
- package/src/crux.js +11 -4
- package/src/mcp/baseline.js +26 -0
- package/src/mcp/queue.js +72 -0
- package/src/mcp/server.js +291 -59
- package/src/mcp/summary.js +30 -8
- package/src/runner.js +26 -1
- package/src/runs.js +22 -0
- package/src/snippet-names.js +90 -19
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
|
|
96
|
-
|
|
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
|
-
|
|
161
|
+
const total = STAGES * runs;
|
|
162
|
+
return (run) => (stage, message) =>
|
|
99
163
|
progressToken === undefined
|
|
100
164
|
? undefined
|
|
101
|
-
: ctx.mcpReq.notify({
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
session
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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({
|
|
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
|
-
|
|
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;
|
package/src/mcp/summary.js
CHANGED
|
@@ -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
|
|
46
|
-
if (!
|
|
47
|
-
|
|
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
|
-
|
|
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
|
}
|