Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
10 changes: 10 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +50,7 @@ export class McpContext implements Context {
#dialog?: Dialog;

#nextSnapshotId = 1;
#traceResults: TraceResult[] = [];

private constructor(browser: Browser, logger: Debugger) {
this.browser = browser;
Expand Down Expand Up @@ -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;
}
}
3 changes: 3 additions & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 45 additions & 2 deletions src/tools/performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
46 changes: 43 additions & 3 deletions src/trace-processing/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()};
}
16 changes: 13 additions & 3 deletions tests/McpContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<!DOCTYPE html>
<button>Click me</button><input type="text" value="Input">`);
Expand All @@ -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]);
});
});
});
19 changes: 19 additions & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
@@ -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]);
42 changes: 42 additions & 0 deletions tests/tools/performance.test.js.snapshot
Original file line number Diff line number Diff line change
@@ -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
`;
Loading
Loading