diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 84d4fc9d1..60b45e791 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -262,7 +262,10 @@ **Description:** List all requests for the currently selected page -**Parameters:** None +**Parameters:** + +- **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page. +- **pageSize** (integer) _(optional)_: Maximum number of requests to return. When omitted, returns all requests. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index e1af6f9c8..48428365f 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -13,6 +13,7 @@ import { } from './formatters/networkFormatter.js'; import {formatA11ySnapshot} from './formatters/snapshotFormatter.js'; import {formatConsoleEvent} from './formatters/consoleFormatter.js'; +import {paginate, type PaginationOptions} from './utils/pagination.js'; export class McpResponse implements Response { #includePages: boolean = false; @@ -23,6 +24,7 @@ export class McpResponse implements Response { #textResponseLines: string[] = []; #formattedConsoleData?: string[]; #images: ImageContentData[] = []; + #networkRequestsPaginationOptions?: PaginationOptions; setIncludePages(value: boolean): void { this.#includePages = value; @@ -32,8 +34,20 @@ export class McpResponse implements Response { this.#includeSnapshot = value; } - setIncludeNetworkRequests(value: boolean): void { + setIncludeNetworkRequests( + value: boolean, + options?: {pageSize?: number; pageIdx?: number}, + ): void { this.#includeNetworkRequests = value; + if (!value || !options) { + this.#networkRequestsPaginationOptions = undefined; + return; + } + + this.#networkRequestsPaginationOptions = { + pageSize: options.pageSize, + pageIdx: options.pageIdx, + }; } setIncludeConsoleData(value: boolean): void { @@ -58,6 +72,9 @@ export class McpResponse implements Response { get attachedNetworkRequestUrl(): string | undefined { return this.#attachedNetworkRequestUrl; } + get networkRequestsPageIdx(): number | undefined { + return this.#networkRequestsPaginationOptions?.pageIdx; + } appendResponseLine(value: string): void { this.#textResponseLines.push(value); @@ -162,7 +179,30 @@ Call browser_handle_dialog to handle it before continuing.`); const requests = context.getNetworkRequests(); response.push('## Network requests'); if (requests.length) { - for (const request of requests) { + const paginationResult = paginate( + requests, + this.#networkRequestsPaginationOptions, + ); + if (paginationResult.invalidPage) { + response.push('Invalid page number provided. Showing first page.'); + } + + const {startIndex, endIndex, currentPage, totalPages} = + paginationResult; + response.push( + `Showing ${startIndex + 1}-${endIndex} of ${requests.length} (Page ${currentPage + 1} of ${totalPages}).`, + ); + + if (this.#networkRequestsPaginationOptions) { + if (paginationResult.hasNextPage) { + response.push(`Next page: ${currentPage + 1}`); + } + if (paginationResult.hasPreviousPage) { + response.push(`Previous page: ${currentPage - 1}`); + } + } + + for (const request of paginationResult.items) { response.push(getShortDescriptionForRequest(request)); } } else { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 926a104cb..8c6267503 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -42,7 +42,10 @@ export type ImageContentData = { export interface Response { appendResponseLine(value: string): void; setIncludePages(value: boolean): void; - setIncludeNetworkRequests(value: boolean): void; + setIncludeNetworkRequests( + value: boolean, + options?: {pageSize?: number; pageIdx?: number}, + ): 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 c14fe9ff2..d4746b960 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -15,9 +15,29 @@ export const listNetworkRequests = defineTool({ category: ToolCategories.NETWORK, readOnlyHint: true, }, - schema: {}, - handler: async (_request, response) => { - response.setIncludeNetworkRequests(true); + schema: { + pageSize: z + .number() + .int() + .positive() + .optional() + .describe( + 'Maximum number of requests to return. When omitted, returns all requests.', + ), + pageIdx: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Page number to return (0-based). When omitted, returns the first page.', + ), + }, + handler: async (request, response) => { + response.setIncludeNetworkRequests(true, { + pageSize: request.params.pageSize, + pageIdx: request.params.pageIdx, + }); }, }); diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts new file mode 100644 index 000000000..dbd35b990 --- /dev/null +++ b/src/utils/pagination.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type PaginationOptions = { + pageSize?: number; + pageIdx?: number; +}; + +export type PaginationResult = { + items: readonly TItem[]; + currentPage: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + startIndex: number; + endIndex: number; + invalidPage: boolean; +}; + +const DEFAULT_PAGE_SIZE = 20; + +export function paginate( + items: readonly TItem[], + options?: PaginationOptions, +): PaginationResult { + const total = items.length; + + if (!options || noPaginationOptions(options)) { + return { + items, + currentPage: 0, + totalPages: 1, + hasNextPage: false, + hasPreviousPage: false, + startIndex: 0, + endIndex: total, + invalidPage: false, + }; + } + + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const {currentPage, invalidPage} = resolvePageIndex( + options.pageIdx, + totalPages, + ); + + const startIndex = currentPage * pageSize; + const pageItems = items.slice(startIndex, startIndex + pageSize); + const endIndex = startIndex + pageItems.length; + + return { + items: pageItems, + currentPage, + totalPages, + hasNextPage: currentPage < totalPages - 1, + hasPreviousPage: currentPage > 0, + startIndex, + endIndex, + invalidPage, + }; +} + +function noPaginationOptions(options: PaginationOptions): boolean { + return options.pageSize === undefined && options.pageIdx === undefined; +} + +function resolvePageIndex( + pageIdx: number | undefined, + totalPages: number, +): { + currentPage: number; + invalidPage: boolean; +} { + if (pageIdx === undefined) { + return {currentPage: 0, invalidPage: false}; + } + + if (pageIdx < 0 || pageIdx >= totalPages) { + return {currentPage: 0, invalidPage: true}; + } + + return {currentPage: pageIdx, invalidPage: false}; +} diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 40f775f76..41cbdc94c 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -185,6 +185,7 @@ Call browser_handle_dialog to handle it before continuing.`, result[0].text, `# test response ## Network requests +Showing 1-1 of 1 (Page 1 of 1). http://example.com GET [pending]`, ); }); @@ -217,6 +218,7 @@ Status: [pending] ### Request Headers - content-size:10 ## Network requests +Showing 1-1 of 1 (Page 1 of 1). http://example.com GET [pending]`, ); }); @@ -261,3 +263,70 @@ 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}, () => getMockRequest()); + 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 (Page 1 of 1).')); + assert.ok(!text.includes('Next page:')); + assert.ok(!text.includes('Previous page:')); + }); + }); + + 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 (Page 1 of 3).')); + assert.ok(text.includes('Next page: 1')); + assert.ok(!text.includes('Previous page:')); + }); + }); + + it('returns subsequent page when pageIdx 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, + pageIdx: 1, + }); + const result = await response.handle('test', context); + const text = (result[0].text as string).toString(); + assert.ok(text.includes('Showing 11-20 of 25 (Page 2 of 3).')); + assert.ok(text.includes('Next page: 2')); + assert.ok(text.includes('Previous page: 0')); + }); + }); + + it('handles invalid page number by showing first page', async () => { + await withBrowser(async (response, context) => { + const requests = Array.from({length: 5}, () => getMockRequest()); + context.getNetworkRequests = () => requests; + response.setIncludeNetworkRequests(true, { + pageSize: 2, + pageIdx: 10, // Invalid page number + }); + const result = await response.handle('test', context); + const text = (result[0].text as string).toString(); + assert.ok( + text.includes('Invalid page number provided. Showing first page.'), + ); + assert.ok(text.includes('Showing 1-2 of 5 (Page 1 of 3).')); + }); + }); +}); diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index a563d051b..43a780517 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.networkRequestsPageIdx, undefined); }); }); });