Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/presubmit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
diff_file=$(mktemp doc_diff_XXXXXX)
git diff --color > $diff_file
if [[ -s $diff_file ]]; then
echo "Please update the documentation by running 'npm run generate-docs'. The following was the diff"
echo "Please update the documentation by running 'npm run docs'. The following was the diff"
cat $diff_file
rm $diff_file
exit 1
Expand Down
6 changes: 5 additions & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,11 @@

**Description:** List all requests for the currently selected page

**Parameters:** None
**Parameters:**

- **pageSize** (integer) _(optional)_: Maximum number of requests to return. When omitted, returns all requests.
- **pageToken** (string) _(optional)_: Opaque token representing the next page. Use the token returned by a previous call.
- **requestType** (enum: "document", "stylesheet", "image", "media", "font", "script", "xhr", "fetch", "prefetch", "websocket", "preflight", "other") _(optional)_: Type of request to return. When omitted, returns all requests. Available types are: document, stylesheet, image, media, font, script, xhr, fetch, prefetch, websocket, preflight, other.

---

Expand Down
80 changes: 75 additions & 5 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
} from './formatters/networkFormatter.js';
import {formatA11ySnapshot} from './formatters/snapshotFormatter.js';
import {formatConsoleEvent} from './formatters/consoleFormatter.js';
import {
paginateNetworkRequests,
type NetworkRequestsListingOptions,
sanitizeRequestTypeFilter,
} from './utils/networkUtils.js';

export class McpResponse implements Response {
#includePages: boolean = false;
Expand All @@ -23,6 +28,7 @@ export class McpResponse implements Response {
#textResponseLines: string[] = [];
#formattedConsoleData?: string[];
#images: ImageContentData[] = [];
#networkRequestsPaginationOptions?: NetworkRequestsListingOptions;

setIncludePages(value: boolean): void {
this.#includePages = value;
Expand All @@ -32,8 +38,39 @@ export class McpResponse implements Response {
this.#includeSnapshot = value;
}

setIncludeNetworkRequests(value: boolean): void {
setIncludeNetworkRequests(
value: boolean,
options?: NetworkRequestsListingOptions,
): void {
this.#includeNetworkRequests = value;
if (!value) {
this.#networkRequestsPaginationOptions = undefined;
return;
}

if (!options) {
this.#networkRequestsPaginationOptions = undefined;
return;
}

const sanitizedOptions: NetworkRequestsListingOptions = {};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not need to sanitize the values as we can rely on the types and JSON schema.

Could we replace this with the following?

this.#networkRequestsPaginationOptions = options;

if (options.pageSize !== undefined) {
sanitizedOptions.pageSize = options.pageSize;
}
if (options.pageToken !== undefined) {
sanitizedOptions.pageToken = options.pageToken ?? undefined;
}
if (options.requestType !== undefined) {
const sanitizedRequestType = sanitizeRequestTypeFilter(
options.requestType,
);
if (sanitizedRequestType !== undefined) {
sanitizedOptions.requestType = sanitizedRequestType;
}
}

this.#networkRequestsPaginationOptions =
Object.keys(sanitizedOptions).length > 0 ? sanitizedOptions : undefined;
}

setIncludeConsoleData(value: boolean): void {
Expand All @@ -58,6 +95,10 @@ export class McpResponse implements Response {
get attachedNetworkRequestUrl(): string | undefined {
return this.#attachedNetworkRequestUrl;
}
get networkRequestsPageToken(): string | undefined {
const token = this.#networkRequestsPaginationOptions?.pageToken;
return token ?? undefined;
}

appendResponseLine(value: string): void {
this.#textResponseLines.push(value);
Expand Down Expand Up @@ -161,12 +202,41 @@ Call browser_handle_dialog to handle it before continuing.`);
if (this.#includeNetworkRequests) {
const requests = context.getNetworkRequests();
response.push('## Network requests');
if (requests.length) {
for (const request of requests) {
response.push(getShortDescriptionForRequest(request));
const paginationOptions = this.#networkRequestsPaginationOptions;
const paginationResult = paginateNetworkRequests(
requests,
paginationOptions,
);

if (paginationResult.appliedRequestType) {
const summary = Array.isArray(paginationResult.appliedRequestType)
? paginationResult.appliedRequestType.join(', ')
: paginationResult.appliedRequestType;
response.push(`Filtered by type: ${summary}`);
}

if (paginationResult.invalidToken) {
response.push('Invalid page token provided. Showing first page.');
}

const {startIndex, endIndex, total} = paginationResult;
if (total === 0) {
if (paginationOptions?.requestType) {
response.push('No requests found for the selected type(s).');
} else {
response.push('No requests found.');
}
} else {
response.push('No requests found.');
response.push(`Showing ${startIndex + 1}-${endIndex} of ${total}.`);
for (const request of paginationResult.requests) {
response.push(getShortDescriptionForRequest(request));
}
if (paginationResult.nextPageToken) {
response.push(`Next: ${paginationResult.nextPageToken}`);
}
if (paginationResult.previousPageToken) {
response.push(`Prev: ${paginationResult.previousPageToken}`);
}
}
}

Expand Down
12 changes: 11 additions & 1 deletion src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import z from 'zod';
import {Dialog, ElementHandle, Page} from 'puppeteer-core';
import type {FilterableResourceType} from '../utils/networkUtils.js';
import {ToolCategories} from './categories.js';
import {TraceResult} from '../trace-processing/parse.js';

Expand Down Expand Up @@ -39,10 +40,19 @@ export type ImageContentData = {
mimeType: string;
};

export type NetworkRequestsOptions = {
pageSize?: number;
pageToken?: string | null;
requestType?: FilterableResourceType | FilterableResourceType[] | null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears that FilterableResourceType[] is not used.

};

export interface Response {
appendResponseLine(value: string): void;
setIncludePages(value: boolean): void;
setIncludeNetworkRequests(value: boolean): void;
setIncludeNetworkRequests(
value: boolean,
options?: NetworkRequestsOptions,
): void;
setIncludeConsoleData(value: boolean): void;
setIncludeSnapshot(value: boolean): void;
attachImage(value: ImageContentData): void;
Expand Down
33 changes: 30 additions & 3 deletions src/tools/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import z from 'zod';
import {FILTERABLE_RESOURCE_TYPES} from '../utils/networkUtils.js';
import {defineTool} from './ToolDefinition.js';
import {ToolCategories} from './categories.js';

Expand All @@ -15,9 +16,35 @@ export const listNetworkRequests = defineTool({
category: ToolCategories.NETWORK,
readOnlyHint: true,
},
schema: {},
handler: async (_request, response) => {
response.setIncludeNetworkRequests(true);
schema: {
pageSize: z
.number()
.int()
.positive()
.max(100)
.optional()
.describe(
'Maximum number of requests to return. When omitted, returns all requests.',
),
pageToken: z
.string()
.optional()
.describe(
'Opaque token representing the next page. Use the token returned by a previous call.',
),
requestType: z
.enum(FILTERABLE_RESOURCE_TYPES)
.optional()
.describe(
`Type of request to return. When omitted, returns all requests. Available types are: ${FILTERABLE_RESOURCE_TYPES.join(', ')}.`,
),
},
handler: async (request, response) => {
response.setIncludeNetworkRequests(true, {
pageSize: request.params.pageSize,
pageToken: request.params.pageToken ?? null,
requestType: request.params.requestType ?? null,
});
},
});

Expand Down
183 changes: 183 additions & 0 deletions src/utils/networkUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {type HTTPRequest, type ResourceType} from 'puppeteer-core';

export const FILTERABLE_RESOURCE_TYPES = [
'document',
'stylesheet',
'image',
'media',
'font',
'script',
'xhr',
'fetch',
'prefetch',
'websocket',
'preflight',
'other',
] as const satisfies readonly ResourceType[];

export type FilterableResourceType = (typeof FILTERABLE_RESOURCE_TYPES)[number];

export type NetworkRequestsListingOptions = {
pageSize?: number;
pageToken?: string;
requestType?: FilterableResourceType | FilterableResourceType[];
};

export type NetworkRequestsListingResult = {
requests: readonly HTTPRequest[];
nextPageToken?: string;
previousPageToken?: string;
startIndex: number;
endIndex: number;
invalidToken: boolean;
total: number;
appliedRequestType?: FilterableResourceType | FilterableResourceType[];
};

const DEFAULT_PAGE_SIZE = 20;
const FILTERABLE_RESOURCE_TYPES_SET = new Set<FilterableResourceType>(
FILTERABLE_RESOURCE_TYPES,
);

export function isFilterableResourceType(
value: ResourceType | string,
): value is FilterableResourceType {
return FILTERABLE_RESOURCE_TYPES_SET.has(value as FilterableResourceType);
}

export function sanitizeRequestTypeFilter(
requestType?: string | string[] | null,
): FilterableResourceType | FilterableResourceType[] | undefined {
if (requestType === undefined || requestType === null) {
return undefined;
}

const values = Array.isArray(requestType) ? requestType : [requestType];
const sanitized = values.filter(isFilterableResourceType);

if (!sanitized.length) {
return undefined;
}

return Array.isArray(requestType) ? sanitized : sanitized[0];
}

export function filterNetworkRequests(
requests: readonly HTTPRequest[],
requestType?: FilterableResourceType | FilterableResourceType[],
): readonly HTTPRequest[] {
if (!requestType) {
return requests;
}

const normalizedTypes = new Set<FilterableResourceType>(
Array.isArray(requestType) ? requestType : [requestType],
);

if (!normalizedTypes.size) {
return requests;
}

return requests.filter(request => {
const type = request.resourceType();
if (!isFilterableResourceType(type)) {
return false;
}
return normalizedTypes.has(type);
});
}

export function paginateNetworkRequests(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make paginate function generic to support any list of objects.

requests: readonly HTTPRequest[],
options?: NetworkRequestsListingOptions,
): NetworkRequestsListingResult {
const sanitizedOptions = options ?? {};
const filteredRequests = filterNetworkRequests(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's filter before the paginate function.

requests,
sanitizedOptions.requestType,
);
const total = filteredRequests.length;

const hasPaginationOptions = hasPagination(sanitizedOptions);

if (!hasPaginationOptions) {
return {
requests: filteredRequests,
nextPageToken: undefined,
previousPageToken: undefined,
startIndex: 0,
endIndex: total,
invalidToken: false,
total,
appliedRequestType: sanitizedOptions.requestType,
};
}

const pageSize = validatePageSize(sanitizedOptions.pageSize, total);
const {startIndex, invalidToken} = resolveStartIndex(
sanitizedOptions.pageToken,
total,
);

const pageRequests = filteredRequests.slice(
startIndex,
startIndex + pageSize,
);
const endIndex = startIndex + pageRequests.length;

const nextPageToken = endIndex < total ? String(endIndex) : undefined;
const previousPageToken =
startIndex > 0 ? String(Math.max(startIndex - pageSize, 0)) : undefined;

return {
requests: pageRequests,
nextPageToken,
previousPageToken,
startIndex,
endIndex,
invalidToken,
total,
appliedRequestType: sanitizedOptions.requestType,
};
}

function hasPagination(options: NetworkRequestsListingOptions): boolean {
return (
options.pageSize !== undefined ||
(options.pageToken !== undefined && options.pageToken !== null)
);
}

function validatePageSize(pageSize: number | undefined, total: number): number {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would not the JSON schema already validate the page size? If not, can we update the JSON schema.

if (pageSize === undefined) {
return total || DEFAULT_PAGE_SIZE;
}
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(pageSize, Math.max(total, 1));
}

function resolveStartIndex(
pageToken: string | undefined,
total: number,
): {
startIndex: number;
invalidToken: boolean;
} {
if (pageToken === undefined || pageToken === null) {
return {startIndex: 0, invalidToken: false};
}

const parsed = Number.parseInt(pageToken, 10);
if (Number.isNaN(parsed) || parsed < 0 || parsed >= total) {
return {startIndex: 0, invalidToken: total > 0};
}

return {startIndex: parsed, invalidToken: false};
}
Loading