diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index b7faaf61..4c3e67ee 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -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 diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 84d4fc9d..db6ceaba 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -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. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index e1af6f9c..ea959978 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -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; @@ -23,6 +28,7 @@ export class McpResponse implements Response { #textResponseLines: string[] = []; #formattedConsoleData?: string[]; #images: ImageContentData[] = []; + #networkRequestsPaginationOptions?: NetworkRequestsListingOptions; setIncludePages(value: boolean): void { this.#includePages = value; @@ -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 = {}; + 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 { @@ -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); @@ -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}`); + } } } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index be07fdbb..88e3a836 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -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'; @@ -39,10 +40,19 @@ export type ImageContentData = { mimeType: string; }; +export type NetworkRequestsOptions = { + pageSize?: number; + pageToken?: string | null; + requestType?: FilterableResourceType | FilterableResourceType[] | null; +}; + 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; diff --git a/src/tools/network.ts b/src/tools/network.ts index c14fe9ff..72c8b06d 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -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'; @@ -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, + }); }, }); diff --git a/src/utils/networkUtils.ts b/src/utils/networkUtils.ts new file mode 100644 index 00000000..18cd92ac --- /dev/null +++ b/src/utils/networkUtils.ts @@ -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( + 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( + 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( + requests: readonly HTTPRequest[], + options?: NetworkRequestsListingOptions, +): NetworkRequestsListingResult { + const sanitizedOptions = options ?? {}; + const filteredRequests = filterNetworkRequests( + 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 { + 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}; +} diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 40f775f7..2eac1ebb 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -181,12 +181,9 @@ Call browser_handle_dialog to handle it before continuing.`, return [getMockRequest()]; }; const result = await response.handle('test', context); - assert.strictEqual( - result[0].text, - `# test response -## Network requests -http://example.com GET [pending]`, - ); + const text = result[0].text as string; + assert.ok(text.includes(`## Network requests`)); + assert.ok(text.includes('http://example.com GET [pending]')); }); }); it('does not include network requests when setting is false', async () => { @@ -217,6 +214,7 @@ Status: [pending] ### Request Headers - content-size:10 ## Network requests +Showing 1-1 of 1. http://example.com GET [pending]`, ); }); @@ -261,3 +259,170 @@ Log>`), }); }); }); + +describe('McpResponse network pagination', () => { + it('returns all requests when pagination is not provided', async () => { + await withBrowser(async (response, context) => { + const requests = Array.from({length: 5}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), + ); + context.getNetworkRequests = () => requests; + response.setIncludeNetworkRequests(true); + const result = await response.handle('test', context); + const text = (result[0].text as string).toString(); + assert.ok(text.includes('Showing 1-5 of 5.')); + assert.ok(!text.includes('Next:')); + assert.ok(!text.includes('Prev:')); + }); + }); + + it('returns first page by default', async () => { + await withBrowser(async (response, context) => { + const requests = Array.from({length: 30}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), + ); + context.getNetworkRequests = () => { + return requests; + }; + response.setIncludeNetworkRequests(true, {pageSize: 10}); + const result = await response.handle('test', context); + const text = (result[0].text as string).toString(); + assert.ok(text.includes('Showing 1-10 of 30.')); + assert.ok(text.includes('Next: 10')); + assert.ok(!text.includes('Prev:')); + }); + }); + + it('returns subsequent page when token provided', async () => { + await withBrowser(async (response, context) => { + const requests = Array.from({length: 25}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), + ); + context.getNetworkRequests = () => requests; + response.setIncludeNetworkRequests(true, { + pageSize: 10, + pageToken: '10', + }); + const result = await response.handle('test', context); + const text = (result[0].text as string).toString(); + assert.ok(text.includes('Showing 11-20 of 25.')); + assert.ok(text.includes('Next: 20')); + assert.ok(text.includes('Prev: 0')); + }); + }); + + it('handles invalid token by showing first page', async () => { + await withBrowser(async (response, context) => { + const requests = Array.from({length: 5}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), + ); + context.getNetworkRequests = () => requests; + response.setIncludeNetworkRequests(true, { + pageSize: 2, + pageToken: 'invalid', + }); + const result = await response.handle('test', context); + const text = (result[0].text as string).toString(); + assert.ok( + text.includes('Invalid page token provided. Showing first page.'), + ); + assert.ok(text.includes('Showing 1-2 of 5.')); + }); + }); +}); + +describe('McpResponse network filtering', () => { + it('filters requests by provided type', async () => { + await withBrowser(async (response, context) => { + const requests = [ + getMockRequest({ + resourceType: 'document', + method: 'GET', + url: 'https://example.com/document', + }), + getMockRequest({ + resourceType: 'image', + method: 'GET', + url: 'https://example.com/image.png', + }), + getMockRequest({ + resourceType: 'xhr', + method: 'GET', + url: 'https://example.com/api', + }), + ]; + + context.getNetworkRequests = () => requests; + response.setIncludeNetworkRequests(true, { + requestType: 'image', + }); + + const result = await response.handle('test', context); + const text = result[0].text as string; + + assert.ok(text.includes('Filtered by type: image')); + assert.ok(text.includes('Showing 1-1 of 1.')); + assert.ok(text.includes('https://example.com/image.png')); + assert.ok(!text.includes('https://example.com/document')); + assert.ok(!text.includes('https://example.com/api')); + }); + }); + + it('supports array request type filters', async () => { + await withBrowser(async (response, context) => { + const requests = [ + getMockRequest({ + resourceType: 'document', + method: 'GET', + url: 'https://example.com/document', + }), + getMockRequest({ + resourceType: 'image', + method: 'GET', + url: 'https://example.com/image.png', + }), + getMockRequest({ + resourceType: 'script', + method: 'GET', + url: 'https://example.com/script.js', + }), + ]; + + context.getNetworkRequests = () => requests; + response.setIncludeNetworkRequests(true, { + requestType: ['image', 'script'], + }); + + const result = await response.handle('test', context); + const text = result[0].text as string; + + assert.ok(text.includes('Filtered by type: image, script')); + assert.ok(text.includes('Showing 1-2 of 2.')); + assert.ok(text.includes('https://example.com/image.png')); + assert.ok(text.includes('https://example.com/script.js')); + assert.ok(!text.includes('https://example.com/document')); + }); + }); + + it('returns message when no matching requests exist', async () => { + await withBrowser(async (response, context) => { + const requests = [ + getMockRequest({ + resourceType: 'document', + url: 'https://example.com/document', + }), + ]; + + context.getNetworkRequests = () => requests; + response.setIncludeNetworkRequests(true, { + requestType: 'image', + }); + + const result = await response.handle('test', context); + const text = result[0].text as string; + + assert.ok(text.includes('Filtered by type: image')); + assert.ok(text.includes('No requests found for the selected type(s).')); + }); + }); +}); diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index a563d051..bcf2eb65 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -18,6 +18,7 @@ describe('network', () => { await withBrowser(async (response, context) => { await listNetworkRequests.handler({params: {}}, response, context); assert.ok(response.includeNetworkRequests); + assert.strictEqual(response.networkRequestsPageToken, undefined); }); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 0ee9da59..e027596b 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -5,7 +5,7 @@ */ import puppeteer, {Browser} from 'puppeteer'; -import {HTTPRequest, HTTPResponse} from 'puppeteer-core'; +import {HTTPRequest, HTTPResponse, type ResourceType} from 'puppeteer-core'; import {McpResponse} from '../src/McpResponse.js'; import {McpContext} from '../src/McpContext.js'; import logger from 'debug'; @@ -44,11 +44,13 @@ export function getMockRequest( method?: string; response?: HTTPResponse; failure?: HTTPRequest['failure']; + resourceType?: ResourceType; + url?: string; } = {}, ): HTTPRequest { return { url() { - return 'http://example.com'; + return options.url ?? 'http://example.com'; }, method() { return options.method ?? 'GET'; @@ -67,6 +69,9 @@ export function getMockRequest( redirectChain(): HTTPRequest[] { return []; }, + resourceType() { + return options.resourceType ?? 'document'; + }, } as HTTPRequest; }