diff --git a/README.md b/README.md
index 4149e57f..ba5980e2 100644
--- a/README.md
+++ b/README.md
@@ -116,7 +116,8 @@ claude mcp add chrome-devtools npx chrome-devtools-mcp@latest
- [`emulate_cpu`](docs/tool-reference.md#emulate_cpu)
- [`emulate_network`](docs/tool-reference.md#emulate_network)
- [`resize_page`](docs/tool-reference.md#resize_page)
-- **Performance** (2 tools)
+- **Performance** (3 tools)
+ - [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight)
- [`performance_start_trace`](docs/tool-reference.md#performance_start_trace)
- [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace)
- **Network** (2 tools)
diff --git a/docs/tool-reference.md b/docs/tool-reference.md
index cd71b2c1..40854d34 100644
--- a/docs/tool-reference.md
+++ b/docs/tool-reference.md
@@ -22,7 +22,8 @@
- [`emulate_cpu`](#emulate_cpu)
- [`emulate_network`](#emulate_network)
- [`resize_page`](#resize_page)
-- **[Performance](#performance)** (2 tools)
+- **[Performance](#performance)** (3 tools)
+ - [`performance_analyze_insight`](#performance_analyze_insight)
- [`performance_start_trace`](#performance_start_trace)
- [`performance_stop_trace`](#performance_stop_trace)
- **[Network](#network)** (2 tools)
@@ -216,6 +217,16 @@
## Performance
+### `performance_analyze_insight`
+
+**Description:** Provides more detailed information on a specific Performance Insight that was highlighed in the results of a trace recording
+
+**Parameters:**
+
+- **insightName** (string) **(required)**: The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"
+
+---
+
### `performance_start_trace`
**Description:** Starts a performance trace recording
diff --git a/package.json b/package.json
index fc605aa4..03949b06 100644
--- a/package.json
+++ b/package.json
@@ -9,15 +9,16 @@
"main": "index.js",
"scripts": {
"build": "tsc && node --experimental-strip-types scripts/post-build.ts",
+ "typecheck": "tsc --noEmit",
"format": "eslint --cache --fix . ;prettier --write --cache .",
"check-format": "eslint --cache .; prettier --check --cache .;",
"generate-docs": "npm run build && node --experimental-strip-types scripts/generate-docs.ts",
"start": "npm run build && node build/src/index.js",
"start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js",
- "test": "npm run build && node --test-reporter spec --test-force-exit --test 'build/tests/**/*.test.js'",
- "test:only": "npm run build && node --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'",
- "test:only:no-build": "node --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'",
- "test:update-snapshots": "npm run build && node --test-force-exit --test --test-update-snapshots 'build/tests/**/*.test.js'",
+ "test": "npm run build && node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test 'build/tests/**/*.test.js'",
+ "test:only": "npm run build && node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'",
+ "test:only:no-build": "node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'",
+ "test:update-snapshots": "npm run build && node --require ./build/tests/setup.js --test-force-exit --test --test-update-snapshots 'build/tests/**/*.test.js'",
"prepare": "node --experimental-strip-types scripts/prepare.ts"
},
"files": [
diff --git a/src/McpContext.ts b/src/McpContext.ts
index db456191..53aabfa1 100644
--- a/src/McpContext.ts
+++ b/src/McpContext.ts
@@ -19,6 +19,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {listPages} from './tools/pages.js';
+import {TraceResult} from './trace-processing/parse.js';
export interface TextSnapshotNode extends SerializedAXNode {
id: string;
@@ -49,6 +50,7 @@ export class McpContext implements Context {
#dialog?: Dialog;
#nextSnapshotId = 1;
+ #traceResults: TraceResult[] = [];
private constructor(browser: Browser, logger: Debugger) {
this.browser = browser;
@@ -292,4 +294,12 @@ export class McpContext implements Context {
throw new Error('Could not save a screenshot to a file');
}
}
+
+ storeTraceRecording(result: TraceResult): void {
+ this.#traceResults.push(result);
+ }
+
+ recordedTraces(): TraceResult[] {
+ return this.#traceResults;
+ }
}
diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts
index f141bd03..3f32652d 100644
--- a/src/tools/ToolDefinition.ts
+++ b/src/tools/ToolDefinition.ts
@@ -7,6 +7,7 @@
import z from 'zod';
import {Dialog, ElementHandle, Page} from 'puppeteer-core';
import {ToolCategories} from './categories.js';
+import {TraceResult} from '../trace-processing/parse.js';
export interface ToolDefinition<
Schema extends Zod.ZodRawShape = Zod.ZodRawShape,
@@ -54,6 +55,8 @@ export interface Response {
export type Context = Readonly<{
isRunningPerformanceTrace(): boolean;
setIsRunningPerformanceTrace(x: boolean): void;
+ recordedTraces(): TraceResult[];
+ storeTraceRecording(result: TraceResult): void;
getSelectedPage(): Page;
getDialog(): Dialog | undefined;
clearDialog(): void;
diff --git a/src/tools/performance.ts b/src/tools/performance.ts
index eaed8904..a59e73a3 100644
--- a/src/tools/performance.ts
+++ b/src/tools/performance.ts
@@ -6,7 +6,12 @@
import z from 'zod';
import {Context, defineTool, Response} from './ToolDefinition.js';
-import {insightOutput, parseRawTraceBuffer} from '../trace-processing/parse.js';
+import {
+ getInsightOutput,
+ getTraceSummary,
+ InsightName,
+ parseRawTraceBuffer,
+} from '../trace-processing/parse.js';
import {logger} from '../logger.js';
import {Page} from 'puppeteer-core';
import {ToolCategories} from './categories.js';
@@ -108,6 +113,43 @@ export const stopTrace = defineTool({
},
});
+export const analyzeInsight = defineTool({
+ name: 'performance_analyze_insight',
+ description:
+ 'Provides more detailed information on a specific Performance Insight that was highlighed in the results of a trace recording',
+ annotations: {
+ category: ToolCategories.PERFORMANCE,
+ readOnlyHint: true,
+ },
+ schema: {
+ insightName: z
+ .string()
+ .describe(
+ 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"',
+ ),
+ },
+ handler: async (request, response, context) => {
+ const lastRecording = context.recordedTraces().at(-1);
+ if (!lastRecording) {
+ response.appendResponseLine(
+ 'No recorded traces found. Record a performance trace so you have Insights to analyze.',
+ );
+ return;
+ }
+
+ const insightOutput = getInsightOutput(
+ lastRecording,
+ request.params.insightName as InsightName,
+ );
+ if ('error' in insightOutput) {
+ response.appendResponseLine(insightOutput.error);
+ return;
+ }
+
+ response.appendResponseLine(insightOutput.output);
+ },
+});
+
async function stopTracingAndAppendOutput(
page: Page,
response: Response,
@@ -118,7 +160,8 @@ async function stopTracingAndAppendOutput(
const result = await parseRawTraceBuffer(traceEventsBuffer);
response.appendResponseLine('The performance trace has been stopped.');
if (result) {
- const insightText = insightOutput(result);
+ context.storeTraceRecording(result);
+ const insightText = getTraceSummary(result);
if (insightText) {
response.appendResponseLine('Insights with performance opportunities:');
response.appendResponseLine(insightText);
diff --git a/src/trace-processing/parse.ts b/src/trace-processing/parse.ts
index c6a018a7..e1120681 100644
--- a/src/trace-processing/parse.ts
+++ b/src/trace-processing/parse.ts
@@ -5,6 +5,7 @@
*/
import {PerformanceTraceFormatter} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js';
+import {PerformanceInsightFormatter} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js';
import * as TraceEngine from '../../node_modules/chrome-devtools-frontend/front_end/models/trace/trace.js';
import {logger} from '../logger.js';
import {AgentFocus} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js';
@@ -60,12 +61,51 @@ export async function parseRawTraceBuffer(
}
}
-// TODO(jactkfranklin): move the formatters from DevTools to use here.
-// This is a very temporary helper to output some text from the tool call to aid development.
-export function insightOutput(result: TraceResult): string {
+export function getTraceSummary(result: TraceResult): string {
const focus = AgentFocus.full(result.parsedTrace);
const serializer = new TraceEngine.EventsSerializer.EventsSerializer();
const formatter = new PerformanceTraceFormatter(focus, serializer);
const output = formatter.formatTraceSummary();
return output;
}
+
+export type InsightName = keyof TraceEngine.Insights.Types.InsightModels;
+export type InsightOutput = {output: string} | {error: string};
+
+export function getInsightOutput(
+ result: TraceResult,
+ insightName: InsightName,
+): InsightOutput {
+ // Currently, we do not support inspecting traces with multiple navigations. We either:
+ // 1. Find Insights from the first navigation (common case: user records a trace with a page reload to test load performance)
+ // 2. Fall back to finding Insights not associated with a navigation (common case: user tests an interaction without a page load).
+ const mainNavigationId =
+ result.parsedTrace.data.Meta.mainFrameNavigations.at(0)?.args.data
+ ?.navigationId;
+
+ const insightsForNav = result.insights.get(
+ mainNavigationId ?? TraceEngine.Types.Events.NO_NAVIGATION,
+ );
+
+ if (!insightsForNav) {
+ return {
+ error: 'No Performance Insights for this trace.',
+ };
+ }
+
+ const matchingInsight =
+ insightName in insightsForNav.model
+ ? insightsForNav.model[insightName]
+ : null;
+ if (!matchingInsight) {
+ return {
+ error: `No Insight with the name ${insightName} found. Double check the name you provided is accurate and try again.`,
+ };
+ }
+
+ const formatter = new PerformanceInsightFormatter(
+ result.parsedTrace,
+ matchingInsight,
+ );
+ return {output: formatter.formatInsight()};
+}
diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts
index 37ec3450..c75d300a 100644
--- a/tests/McpContext.test.ts
+++ b/tests/McpContext.test.ts
@@ -5,12 +5,12 @@
*/
import {describe, it} from 'node:test';
import assert from 'assert';
-
+import {TraceResult} from '../src/trace-processing/parse.js';
import {withBrowser} from './utils.js';
-describe('McpResponse', () => {
+describe('McpContext', () => {
it('list pages', async () => {
- await withBrowser(async (response, context) => {
+ await withBrowser(async (_response, context) => {
const page = context.getSelectedPage();
await page.setContent(`
`);
@@ -28,4 +28,14 @@ describe('McpResponse', () => {
}
});
});
+
+ it('can store and retrieve performance traces', async () => {
+ await withBrowser(async (_response, context) => {
+ const fakeTrace1 = {} as unknown as TraceResult;
+ const fakeTrace2 = {} as unknown as TraceResult;
+ context.storeTraceRecording(fakeTrace1);
+ context.storeTraceRecording(fakeTrace2);
+ assert.deepEqual(context.recordedTraces(), [fakeTrace1, fakeTrace2]);
+ });
+ });
});
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 00000000..40ea2b91
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {it} from 'node:test';
+
+// This is run by Node when we execute the tests via the --require flag.
+it.snapshot.setResolveSnapshotPath(testPath => {
+ // By default the snapshots go into the build directory, but we want them
+ // in the tests/ directory.
+ const correctPath = testPath?.replace('/build/tests', '/tests');
+ return correctPath + '.snapshot';
+});
+
+// The default serializer is JSON.stringify which outputs a very hard to read
+// snapshot. So we override it to one that shows new lines literally rather
+// than via `\n`.
+it.snapshot.setDefaultSnapshotSerializers([String]);
diff --git a/tests/tools/performance.test.js.snapshot b/tests/tools/performance.test.js.snapshot
new file mode 100644
index 00000000..305647fb
--- /dev/null
+++ b/tests/tools/performance.test.js.snapshot
@@ -0,0 +1,42 @@
+exports[`performance > performance_analyze_insight > returns the information on the insight 1`] = `
+## Insight Title: LCP breakdown
+
+## Insight Summary:
+This insight is used to analyze the time spent that contributed to the final LCP time and identify which of the 4 phases (or 2 if there was no LCP resource) are contributing most to the delay in rendering the LCP element.
+
+## Detailed analysis:
+The Largest Contentful Paint (LCP) time for this navigation was 129.2 ms.
+The LCP element is an image fetched from \`https://web-dev.imgix.net/image/kheDArv5csY6rvQUJDbWRscckLr1/4i7JstVZvgTFk9dxCe4a.svg\`.
+## LCP resource network request: https://web-dev.imgix.net/image/kheDArv5csY6rvQUJDbWRscckLr1/4i7JstVZvgTFk9dxCe4a.svg
+Timings:
+- Queued at: 41.1 ms
+- Request sent at: 46.6 ms
+- Download complete at: 55.8 ms
+- Main thread processing completed at: 58.2 ms
+Durations:
+- Download time: 0.3 ms
+- Main thread processing time: 2.3 ms
+- Total duration: 17.1 ms
+Redirects: no redirects
+Status code: 200
+MIME Type: image/svg+xml
+Protocol: unknown
+Priority: VeryHigh
+Render blocking: No
+From a service worker: No
+Initiators (root request to the request that directly loaded this one): none
+
+
+We can break this time down into the 4 phases that combine to make the LCP time:
+
+- Time to first byte: 7.9 ms (6.1% of total LCP time)
+- Resource load delay: 33.2 ms (25.7% of total LCP time)
+- Resource load duration: 14.7 ms (11.4% of total LCP time)
+- Element render delay: 73.4 ms (56.8% of total LCP time)
+
+## Estimated savings: none
+
+## External resources:
+- https://web.dev/articles/lcp
+- https://web.dev/articles/optimize-lcp
+`;
diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts
index 1b293fda..e661811a 100644
--- a/tests/tools/performance.test.ts
+++ b/tests/tools/performance.test.ts
@@ -7,9 +7,17 @@ import {describe, it, afterEach} from 'node:test';
import assert from 'assert';
import sinon from 'sinon';
-import {startTrace, stopTrace} from '../../src/tools/performance.js';
+import {
+ analyzeInsight,
+ startTrace,
+ stopTrace,
+} from '../../src/tools/performance.js';
import {withBrowser} from '../utils.js';
import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js';
+import {
+ parseRawTraceBuffer,
+ TraceResult,
+} from '../../src/trace-processing/parse.js';
describe('performance', () => {
afterEach(() => {
@@ -20,7 +28,7 @@ describe('performance', () => {
it('starts a trace recording', async () => {
await withBrowser(async (response, context) => {
context.setIsRunningPerformanceTrace(false);
- const selectedPage = await context.getSelectedPage();
+ const selectedPage = context.getSelectedPage();
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
await startTrace.handler(
{params: {reload: true, autoStop: false}},
@@ -39,7 +47,7 @@ describe('performance', () => {
it('can navigate to about:blank and record a page reload', async () => {
await withBrowser(async (response, context) => {
- const selectedPage = await context.getSelectedPage();
+ const selectedPage = context.getSelectedPage();
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
const gotoStub = sinon.stub(selectedPage, 'goto');
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
@@ -64,11 +72,11 @@ describe('performance', () => {
});
});
- it('can autostop a recording', async () => {
+ it('can autostop and store a recording', async () => {
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
await withBrowser(async (response, context) => {
- const selectedPage = await context.getSelectedPage();
+ const selectedPage = context.getSelectedPage();
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
sinon.stub(selectedPage, 'goto').callsFake(() => Promise.resolve(null));
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
@@ -100,6 +108,7 @@ describe('performance', () => {
false,
'Tracing was stopped',
);
+ assert.strictEqual(context.recordedTraces().length, 1);
assert.ok(
response.responseLines
.join('\n')
@@ -111,7 +120,7 @@ describe('performance', () => {
it('errors if a recording is already active', async () => {
await withBrowser(async (response, context) => {
context.setIsRunningPerformanceTrace(true);
- const selectedPage = await context.getSelectedPage();
+ const selectedPage = context.getSelectedPage();
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
await startTrace.handler(
{params: {reload: true, autoStop: false}},
@@ -128,6 +137,79 @@ describe('performance', () => {
});
});
+ describe('performance_analyze_insight', () => {
+ async function parseTrace(fileName: string): Promise {
+ const rawData = loadTraceAsBuffer(fileName);
+ const result = await parseRawTraceBuffer(rawData);
+ assert.ok(result);
+ return result;
+ }
+
+ it('returns the information on the insight', async t => {
+ const trace = await parseTrace('web-dev-with-commit.json.gz');
+ await withBrowser(async (response, context) => {
+ context.storeTraceRecording(trace);
+ context.setIsRunningPerformanceTrace(false);
+
+ await analyzeInsight.handler(
+ {
+ params: {
+ insightName: 'LCPBreakdown',
+ },
+ },
+ response,
+ context,
+ );
+
+ t.assert.snapshot(response.responseLines.join('\n'));
+ });
+ });
+
+ it('returns an error if the insight does not exist', async () => {
+ const trace = await parseTrace('web-dev-with-commit.json.gz');
+ await withBrowser(async (response, context) => {
+ context.storeTraceRecording(trace);
+ context.setIsRunningPerformanceTrace(false);
+
+ await analyzeInsight.handler(
+ {
+ params: {
+ insightName: 'MadeUpInsightName',
+ },
+ },
+ response,
+ context,
+ );
+ assert.ok(
+ response.responseLines
+ .join('\n')
+ .match(/No Insight with the name MadeUpInsightName found./),
+ );
+ });
+ });
+
+ it('returns an error if no trace has been recorded', async () => {
+ await withBrowser(async (response, context) => {
+ await analyzeInsight.handler(
+ {
+ params: {
+ insightName: 'LCPBreakdown',
+ },
+ },
+ response,
+ context,
+ );
+ assert.ok(
+ response.responseLines
+ .join('\n')
+ .match(
+ /No recorded traces found. Record a performance trace so you have Insights to analyze./,
+ ),
+ );
+ });
+ });
+ });
+
describe('performance_stop_trace', () => {
it('does nothing if the trace is not running and does not error', async () => {
await withBrowser(async (response, context) => {
@@ -140,7 +222,7 @@ describe('performance', () => {
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
await withBrowser(async (response, context) => {
context.setIsRunningPerformanceTrace(true);
- const selectedPage = await context.getSelectedPage();
+ const selectedPage = context.getSelectedPage();
const stopTracingStub = sinon
.stub(selectedPage.tracing, 'stop')
.callsFake(async () => {
@@ -152,6 +234,7 @@ describe('performance', () => {
'The performance trace has been stopped.',
),
);
+ assert.strictEqual(context.recordedTraces().length, 1);
sinon.assert.calledOnce(stopTracingStub);
});
});
diff --git a/tests/trace-processing/parse.test.ts b/tests/trace-processing/parse.test.ts
index 30ec41c1..c8c11864 100644
--- a/tests/trace-processing/parse.test.ts
+++ b/tests/trace-processing/parse.test.ts
@@ -6,24 +6,12 @@
import {describe, it} from 'node:test';
import assert from 'node:assert';
import {
- insightOutput,
+ getTraceSummary,
parseRawTraceBuffer,
} from '../../src/trace-processing/parse.js';
import {loadTraceAsBuffer} from './fixtures/load.js';
describe('Trace parsing', async () => {
- it.snapshot.setResolveSnapshotPath(testPath => {
- // By default the snapshots go into the build directory, but we want them
- // in the tests/ directory.
- const correctPath = testPath?.replace('/build/tests', '/tests');
- return correctPath + '.snapshot';
- });
-
- // The default serializer is JSON.stringify which outputs a very hard to read
- // snapshot. So we override it to one that shows new lines literally rather
- // than via `\n`.
- it.snapshot.setDefaultSnapshotSerializers([String]);
-
it('can parse a Uint8Array from Tracing.stop())', async () => {
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
const result = await parseRawTraceBuffer(rawData);
@@ -37,7 +25,7 @@ describe('Trace parsing', async () => {
assert.ok(result?.parsedTrace);
assert.ok(result?.insights);
- const output = insightOutput(result);
+ const output = getTraceSummary(result);
t.assert.snapshot(output);
});
});