From 5be157eac31e72c51cd35a51ad6dcfac413fa5b4 Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Wed, 24 Sep 2025 10:49:26 +0300 Subject: [PATCH 1/8] feat: add pagination to `list_network_requests` --- src/McpResponse.ts | 63 ++++++++++++++++++++--- src/tools/ToolDefinition.ts | 13 +++-- src/tools/network.ts | 29 +++++++++-- src/utils/networkPagination.ts | 92 ++++++++++++++++++++++++++++++++++ tests/McpResponse.test.ts | 83 +++++++++++++++++++++++++++--- tests/tools/network.test.ts | 9 ++-- 6 files changed, 260 insertions(+), 29 deletions(-) create mode 100644 src/utils/networkPagination.ts diff --git a/src/McpResponse.ts b/src/McpResponse.ts index e1af6f9c8..aeecbd8de 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -3,16 +3,20 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import type {ImageContentData, Response} from './tools/ToolDefinition.js'; -import type {McpContext} from './McpContext.js'; -import {ImageContent, TextContent} from '@modelcontextprotocol/sdk/types.js'; +import type { ImageContentData, Response } from './tools/ToolDefinition.js'; +import type { McpContext } from './McpContext.js'; +import { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import { getFormattedHeaderValue, getShortDescriptionForRequest, getStatusFromRequest, } from './formatters/networkFormatter.js'; -import {formatA11ySnapshot} from './formatters/snapshotFormatter.js'; -import {formatConsoleEvent} from './formatters/consoleFormatter.js'; +import { formatA11ySnapshot } from './formatters/snapshotFormatter.js'; +import { formatConsoleEvent } from './formatters/consoleFormatter.js'; +import { + paginateNetworkRequests, + type NetworkPaginationOptions, +} from './utils/networkPagination.js'; export class McpResponse implements Response { #includePages: boolean = false; @@ -23,6 +27,7 @@ export class McpResponse implements Response { #textResponseLines: string[] = []; #formattedConsoleData?: string[]; #images: ImageContentData[] = []; + #networkRequestsPaginationOptions?: NetworkPaginationOptions; setIncludePages(value: boolean): void { this.#includePages = value; @@ -32,8 +37,31 @@ export class McpResponse implements Response { this.#includeSnapshot = value; } - setIncludeNetworkRequests(value: boolean): void { + setIncludeNetworkRequests( + value: boolean, + options?: { pageSize?: number; pageToken?: string | null }, + ): void { this.#includeNetworkRequests = value; + if (!value) { + this.#networkRequestsPaginationOptions = undefined; + return; + } + + if (!options) { + this.#networkRequestsPaginationOptions = undefined; + return; + } + + const sanitizedOptions: NetworkPaginationOptions = {}; + if (options.pageSize !== undefined) { + sanitizedOptions.pageSize = options.pageSize; + } + if (options.pageToken !== undefined) { + sanitizedOptions.pageToken = options.pageToken ?? undefined; + } + + this.#networkRequestsPaginationOptions = + Object.keys(sanitizedOptions).length > 0 ? sanitizedOptions : undefined; } setIncludeConsoleData(value: boolean): void { @@ -58,6 +86,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); @@ -162,9 +194,26 @@ 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 = paginateNetworkRequests( + requests, + this.#networkRequestsPaginationOptions, + ); + if (paginationResult.invalidToken) { + response.push('Invalid page token provided. Showing first page.'); + } + const { startIndex, endIndex } = paginationResult; + response.push( + `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, + ); + 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}`); + } } else { response.push('No requests found.'); } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 37b0f50d8..5dec40c24 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -5,9 +5,9 @@ */ import z from 'zod'; -import {Dialog, ElementHandle, Page} from 'puppeteer-core'; -import {ToolCategories} from './categories.js'; -import {TraceResult} from '../trace-processing/parse.js'; +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, @@ -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; pageToken?: string | null }, + ): void; setIncludeConsoleData(value: boolean): void; setIncludeSnapshot(value: boolean): void; attachImage(value: ImageContentData): void; @@ -69,7 +72,7 @@ export type Context = Readonly<{ saveTemporaryFile( data: Uint8Array, mimeType: 'image/png' | 'image/jpeg', - ): Promise<{filename: string}>; + ): Promise<{ filename: string }>; waitForEventsAfterAction(action: () => Promise): Promise; }>; diff --git a/src/tools/network.ts b/src/tools/network.ts index c14fe9ff2..21ba515d6 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -5,8 +5,8 @@ */ import z from 'zod'; -import {defineTool} from './ToolDefinition.js'; -import {ToolCategories} from './categories.js'; +import { defineTool } from './ToolDefinition.js'; +import { ToolCategories } from './categories.js'; export const listNetworkRequests = defineTool({ name: 'list_network_requests', @@ -15,9 +15,28 @@ 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.', + ), + }, + handler: async (request, response) => { + response.setIncludeNetworkRequests(true, { + pageSize: request.params.pageSize, + pageToken: request.params.pageToken ?? null, + }); }, }); diff --git a/src/utils/networkPagination.ts b/src/utils/networkPagination.ts new file mode 100644 index 000000000..948fcb4b0 --- /dev/null +++ b/src/utils/networkPagination.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { type HTTPRequest } from 'puppeteer-core'; + +export type NetworkPaginationOptions = { + pageSize?: number; + pageToken?: string; +}; + +export type NetworkPaginationResult = { + requests: readonly HTTPRequest[]; + nextPageToken?: string; + previousPageToken?: string; + startIndex: number; + endIndex: number; + invalidToken: boolean; +}; + +const DEFAULT_PAGE_SIZE = 20; + +export function paginateNetworkRequests( + requests: readonly HTTPRequest[], + options?: NetworkPaginationOptions, +): NetworkPaginationResult { + const total = requests.length; + + if (!options || noPaginationOptions(options)) { + return { + requests, + nextPageToken: undefined, + previousPageToken: undefined, + startIndex: 0, + endIndex: total, + invalidToken: false, + }; + } + + const pageSize = validatePageSize(options.pageSize, total); + const { startIndex, invalidToken } = resolveStartIndex(options.pageToken, total); + + const pageRequests = requests.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, + }; +} + +function noPaginationOptions(options: NetworkPaginationOptions): 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: true }; + } + + return { startIndex: parsed, invalidToken: false }; +} diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 40f775f76..e8220d51b 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -3,10 +3,10 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {describe, it} from 'node:test'; +import { describe, it } from 'node:test'; import assert from 'assert'; -import {getMockRequest, html, withBrowser} from './utils.js'; +import { getMockRequest, html, withBrowser } from './utils.js'; describe('McpResponse', () => { it('list pages', async () => { @@ -120,7 +120,7 @@ Navigation timeout set to 100000 ms`, }); it('adds image when image is attached', async () => { await withBrowser(async (response, context) => { - response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); + response.attachImage({ data: 'imageBase64', mimeType: 'image/png' }); const result = await response.handle('test', context); assert.strictEqual(result[0].text, `# test response`); assert.equal(result[1].type, 'image'); @@ -181,12 +181,11 @@ 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 +216,7 @@ Status: [pending] ### Request Headers - content-size:10 ## Network requests +Showing 1-1 of 1. http://example.com GET [pending]`, ); }); @@ -261,3 +261,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.')); + 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 }, () => getMockRequest()); + 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.')); + }); + }); +}); diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index a563d051b..88c7fefc9 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -3,10 +3,10 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {describe, it} from 'node:test'; +import { describe, it } from 'node:test'; import assert from 'assert'; -import {withBrowser} from '../utils.js'; +import { withBrowser } from '../utils.js'; import { getNetworkRequest, listNetworkRequests, @@ -16,8 +16,9 @@ describe('network', () => { describe('network_list_requests', () => { it('list requests', async () => { await withBrowser(async (response, context) => { - await listNetworkRequests.handler({params: {}}, response, context); + await listNetworkRequests.handler({ params: {} }, response, context); assert.ok(response.includeNetworkRequests); + assert.strictEqual(response.networkRequestsPageToken, undefined); }); }); }); @@ -27,7 +28,7 @@ describe('network', () => { const page = await context.getSelectedPage(); await page.goto('data:text/html,
Hello MCP
'); await getNetworkRequest.handler( - {params: {url: 'data:text/html,
Hello MCP
'}}, + { params: { url: 'data:text/html,
Hello MCP
' } }, response, context, ); From 953fe35455fb4d0da16488ba7693539e34195bfe Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Thu, 25 Sep 2025 15:46:10 +0300 Subject: [PATCH 2/8] docs: generate docs --- docs/tool-reference.md | 5 +- src/McpResponse.ts | 14 ++-- src/tools/ToolDefinition.ts | 10 +-- src/tools/network.ts | 4 +- src/utils/networkPagination.ts | 124 +++++++++++++++++---------------- tests/McpResponse.test.ts | 24 +++---- tests/tools/network.test.ts | 8 +-- 7 files changed, 98 insertions(+), 91 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index dd661efcc..64cc8cc1e 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:** + +- **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. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index aeecbd8de..522d4279d 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -3,16 +3,16 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import type { ImageContentData, Response } from './tools/ToolDefinition.js'; -import type { McpContext } from './McpContext.js'; -import { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; +import type {ImageContentData, Response} from './tools/ToolDefinition.js'; +import type {McpContext} from './McpContext.js'; +import {ImageContent, TextContent} from '@modelcontextprotocol/sdk/types.js'; import { getFormattedHeaderValue, getShortDescriptionForRequest, getStatusFromRequest, } from './formatters/networkFormatter.js'; -import { formatA11ySnapshot } from './formatters/snapshotFormatter.js'; -import { formatConsoleEvent } from './formatters/consoleFormatter.js'; +import {formatA11ySnapshot} from './formatters/snapshotFormatter.js'; +import {formatConsoleEvent} from './formatters/consoleFormatter.js'; import { paginateNetworkRequests, type NetworkPaginationOptions, @@ -39,7 +39,7 @@ export class McpResponse implements Response { setIncludeNetworkRequests( value: boolean, - options?: { pageSize?: number; pageToken?: string | null }, + options?: {pageSize?: number; pageToken?: string | null}, ): void { this.#includeNetworkRequests = value; if (!value) { @@ -201,7 +201,7 @@ Call browser_handle_dialog to handle it before continuing.`); if (paginationResult.invalidToken) { response.push('Invalid page token provided. Showing first page.'); } - const { startIndex, endIndex } = paginationResult; + const {startIndex, endIndex} = paginationResult; response.push( `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, ); diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 5dec40c24..75c1cab6f 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -5,9 +5,9 @@ */ import z from 'zod'; -import { Dialog, ElementHandle, Page } from 'puppeteer-core'; -import { ToolCategories } from './categories.js'; -import { TraceResult } from '../trace-processing/parse.js'; +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, @@ -44,7 +44,7 @@ export interface Response { setIncludePages(value: boolean): void; setIncludeNetworkRequests( value: boolean, - options?: { pageSize?: number; pageToken?: string | null }, + options?: {pageSize?: number; pageToken?: string | null}, ): void; setIncludeConsoleData(value: boolean): void; setIncludeSnapshot(value: boolean): void; @@ -72,7 +72,7 @@ export type Context = Readonly<{ saveTemporaryFile( data: Uint8Array, mimeType: 'image/png' | 'image/jpeg', - ): Promise<{ filename: string }>; + ): Promise<{filename: string}>; waitForEventsAfterAction(action: () => Promise): Promise; }>; diff --git a/src/tools/network.ts b/src/tools/network.ts index 21ba515d6..c8ae7f82f 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -5,8 +5,8 @@ */ import z from 'zod'; -import { defineTool } from './ToolDefinition.js'; -import { ToolCategories } from './categories.js'; +import {defineTool} from './ToolDefinition.js'; +import {ToolCategories} from './categories.js'; export const listNetworkRequests = defineTool({ name: 'list_network_requests', diff --git a/src/utils/networkPagination.ts b/src/utils/networkPagination.ts index 948fcb4b0..176488bb0 100644 --- a/src/utils/networkPagination.ts +++ b/src/utils/networkPagination.ts @@ -3,90 +3,96 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { type HTTPRequest } from 'puppeteer-core'; +import {type HTTPRequest} from 'puppeteer-core'; export type NetworkPaginationOptions = { - pageSize?: number; - pageToken?: string; + pageSize?: number; + pageToken?: string; }; export type NetworkPaginationResult = { - requests: readonly HTTPRequest[]; - nextPageToken?: string; - previousPageToken?: string; - startIndex: number; - endIndex: number; - invalidToken: boolean; + requests: readonly HTTPRequest[]; + nextPageToken?: string; + previousPageToken?: string; + startIndex: number; + endIndex: number; + invalidToken: boolean; }; const DEFAULT_PAGE_SIZE = 20; export function paginateNetworkRequests( - requests: readonly HTTPRequest[], - options?: NetworkPaginationOptions, + requests: readonly HTTPRequest[], + options?: NetworkPaginationOptions, ): NetworkPaginationResult { - const total = requests.length; + const total = requests.length; - if (!options || noPaginationOptions(options)) { - return { - requests, - nextPageToken: undefined, - previousPageToken: undefined, - startIndex: 0, - endIndex: total, - invalidToken: false, - }; - } + if (!options || noPaginationOptions(options)) { + return { + requests, + nextPageToken: undefined, + previousPageToken: undefined, + startIndex: 0, + endIndex: total, + invalidToken: false, + }; + } - const pageSize = validatePageSize(options.pageSize, total); - const { startIndex, invalidToken } = resolveStartIndex(options.pageToken, total); + const pageSize = validatePageSize(options.pageSize, total); + const {startIndex, invalidToken} = resolveStartIndex( + options.pageToken, + total, + ); - const pageRequests = requests.slice(startIndex, startIndex + pageSize); - const endIndex = startIndex + pageRequests.length; + const pageRequests = requests.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; + 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, - }; + return { + requests: pageRequests, + nextPageToken, + previousPageToken, + startIndex, + endIndex, + invalidToken, + }; } function noPaginationOptions(options: NetworkPaginationOptions): boolean { - return ( - options.pageSize === undefined && - (options.pageToken === undefined || options.pageToken === null) - ); + 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)); + 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; +function resolveStartIndex( + pageToken: string | undefined, + total: number, +): { + startIndex: number; + invalidToken: boolean; } { - if (pageToken === undefined || pageToken === null) { - return { startIndex: 0, invalidToken: false }; - } + 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: true }; - } + const parsed = Number.parseInt(pageToken, 10); + if (Number.isNaN(parsed) || parsed < 0 || parsed >= total) { + return {startIndex: 0, invalidToken: true}; + } - return { startIndex: parsed, invalidToken: false }; + return {startIndex: parsed, invalidToken: false}; } diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index e8220d51b..28f58b551 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -3,10 +3,10 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it } from 'node:test'; +import {describe, it} from 'node:test'; import assert from 'assert'; -import { getMockRequest, html, withBrowser } from './utils.js'; +import {getMockRequest, html, withBrowser} from './utils.js'; describe('McpResponse', () => { it('list pages', async () => { @@ -120,7 +120,7 @@ Navigation timeout set to 100000 ms`, }); it('adds image when image is attached', async () => { await withBrowser(async (response, context) => { - response.attachImage({ data: 'imageBase64', mimeType: 'image/png' }); + response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); const result = await response.handle('test', context); assert.strictEqual(result[0].text, `# test response`); assert.equal(result[1].type, 'image'); @@ -182,9 +182,7 @@ Call browser_handle_dialog to handle it before continuing.`, }; const result = await response.handle('test', context); const text = result[0].text as string; - assert.ok( - text.includes(`## Network requests`), - ); + assert.ok(text.includes(`## Network requests`)); assert.ok(text.includes('http://example.com GET [pending]')); }); }); @@ -265,7 +263,7 @@ 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()); + const requests = Array.from({length: 5}, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true); const result = await response.handle('test', context); @@ -278,13 +276,13 @@ describe('McpResponse network pagination', () => { it('returns first page by default', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 30 }, (_, idx) => - getMockRequest({ method: `GET-${idx}` }), + const requests = Array.from({length: 30}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), ); context.getNetworkRequests = () => { return requests; }; - response.setIncludeNetworkRequests(true, { pageSize: 10 }); + 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.')); @@ -295,8 +293,8 @@ describe('McpResponse network pagination', () => { it('returns subsequent page when token provided', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 25 }, (_, idx) => - getMockRequest({ method: `GET-${idx}` }), + const requests = Array.from({length: 25}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), ); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { @@ -313,7 +311,7 @@ describe('McpResponse network pagination', () => { it('handles invalid token by showing first page', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 5 }, () => getMockRequest()); + const requests = Array.from({length: 5}, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { pageSize: 2, diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index 88c7fefc9..bcf2eb65d 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -3,10 +3,10 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it } from 'node:test'; +import {describe, it} from 'node:test'; import assert from 'assert'; -import { withBrowser } from '../utils.js'; +import {withBrowser} from '../utils.js'; import { getNetworkRequest, listNetworkRequests, @@ -16,7 +16,7 @@ describe('network', () => { describe('network_list_requests', () => { it('list requests', async () => { await withBrowser(async (response, context) => { - await listNetworkRequests.handler({ params: {} }, response, context); + await listNetworkRequests.handler({params: {}}, response, context); assert.ok(response.includeNetworkRequests); assert.strictEqual(response.networkRequestsPageToken, undefined); }); @@ -28,7 +28,7 @@ describe('network', () => { const page = await context.getSelectedPage(); await page.goto('data:text/html,
Hello MCP
'); await getNetworkRequest.handler( - { params: { url: 'data:text/html,
Hello MCP
' } }, + {params: {url: 'data:text/html,
Hello MCP
'}}, response, context, ); From 9ce76363fd708e19951b2f584621fedce22049fc Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Thu, 25 Sep 2025 15:53:48 +0300 Subject: [PATCH 3/8] fix: don't sanitize pagination options --- src/McpResponse.ts | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 522d4279d..933e0ee3d 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -3,16 +3,16 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import type {ImageContentData, Response} from './tools/ToolDefinition.js'; -import type {McpContext} from './McpContext.js'; -import {ImageContent, TextContent} from '@modelcontextprotocol/sdk/types.js'; +import type { ImageContentData, Response } from './tools/ToolDefinition.js'; +import type { McpContext } from './McpContext.js'; +import { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import { getFormattedHeaderValue, getShortDescriptionForRequest, getStatusFromRequest, } from './formatters/networkFormatter.js'; -import {formatA11ySnapshot} from './formatters/snapshotFormatter.js'; -import {formatConsoleEvent} from './formatters/consoleFormatter.js'; +import { formatA11ySnapshot } from './formatters/snapshotFormatter.js'; +import { formatConsoleEvent } from './formatters/consoleFormatter.js'; import { paginateNetworkRequests, type NetworkPaginationOptions, @@ -39,29 +39,18 @@ export class McpResponse implements Response { setIncludeNetworkRequests( value: boolean, - options?: {pageSize?: number; pageToken?: string | null}, + options?: { pageSize?: number; pageToken?: string | null }, ): void { this.#includeNetworkRequests = value; - if (!value) { + if (!value || !options) { this.#networkRequestsPaginationOptions = undefined; return; } - if (!options) { - this.#networkRequestsPaginationOptions = undefined; - return; - } - - const sanitizedOptions: NetworkPaginationOptions = {}; - if (options.pageSize !== undefined) { - sanitizedOptions.pageSize = options.pageSize; - } - if (options.pageToken !== undefined) { - sanitizedOptions.pageToken = options.pageToken ?? undefined; - } - - this.#networkRequestsPaginationOptions = - Object.keys(sanitizedOptions).length > 0 ? sanitizedOptions : undefined; + this.#networkRequestsPaginationOptions = { + pageSize: options.pageSize, + pageToken: options.pageToken ?? undefined, + }; } setIncludeConsoleData(value: boolean): void { @@ -201,7 +190,7 @@ Call browser_handle_dialog to handle it before continuing.`); if (paginationResult.invalidToken) { response.push('Invalid page token provided. Showing first page.'); } - const {startIndex, endIndex} = paginationResult; + const { startIndex, endIndex } = paginationResult; response.push( `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, ); From 6925f5ae8cd435c6a8f790a68a79e0c71fd86400 Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Thu, 25 Sep 2025 16:02:36 +0300 Subject: [PATCH 4/8] feat: create a generic pagination utility --- src/McpResponse.ts | 38 +++++++------ src/tools/network.ts | 5 +- src/utils/networkPagination.ts | 98 ---------------------------------- src/utils/pagination.ts | 87 ++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 121 deletions(-) delete mode 100644 src/utils/networkPagination.ts create mode 100644 src/utils/pagination.ts diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 933e0ee3d..9cf7aaa56 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -13,10 +13,7 @@ import { } from './formatters/networkFormatter.js'; import { formatA11ySnapshot } from './formatters/snapshotFormatter.js'; import { formatConsoleEvent } from './formatters/consoleFormatter.js'; -import { - paginateNetworkRequests, - type NetworkPaginationOptions, -} from './utils/networkPagination.js'; +import { paginate, type PaginationOptions } from './utils/pagination.js'; export class McpResponse implements Response { #includePages: boolean = false; @@ -27,7 +24,7 @@ export class McpResponse implements Response { #textResponseLines: string[] = []; #formattedConsoleData?: string[]; #images: ImageContentData[] = []; - #networkRequestsPaginationOptions?: NetworkPaginationOptions; + #networkRequestsPaginationOptions?: PaginationOptions; setIncludePages(value: boolean): void { this.#includePages = value; @@ -183,25 +180,26 @@ Call browser_handle_dialog to handle it before continuing.`); const requests = context.getNetworkRequests(); response.push('## Network requests'); if (requests.length) { - const paginationResult = paginateNetworkRequests( - requests, - this.#networkRequestsPaginationOptions, - ); + const paginationResult = paginate(requests, this.#networkRequestsPaginationOptions); if (paginationResult.invalidToken) { response.push('Invalid page token provided. Showing first page.'); } - const { startIndex, endIndex } = paginationResult; - response.push( - `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, - ); - for (const request of paginationResult.requests) { - response.push(getShortDescriptionForRequest(request)); - } - if (paginationResult.nextPageToken) { - response.push(`Next: ${paginationResult.nextPageToken}`); + + if (this.#networkRequestsPaginationOptions) { + const { startIndex, endIndex } = paginationResult; + response.push( + `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, + ); + if (paginationResult.nextPageToken) { + response.push(`Next: ${paginationResult.nextPageToken}`); + } + if (paginationResult.previousPageToken) { + response.push(`Prev: ${paginationResult.previousPageToken}`); + } } - if (paginationResult.previousPageToken) { - response.push(`Prev: ${paginationResult.previousPageToken}`); + + for (const request of paginationResult.items) { + response.push(getShortDescriptionForRequest(request)); } } else { response.push('No requests found.'); diff --git a/src/tools/network.ts b/src/tools/network.ts index c8ae7f82f..5d84838ba 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -5,8 +5,8 @@ */ import z from 'zod'; -import {defineTool} from './ToolDefinition.js'; -import {ToolCategories} from './categories.js'; +import { defineTool } from './ToolDefinition.js'; +import { ToolCategories } from './categories.js'; export const listNetworkRequests = defineTool({ name: 'list_network_requests', @@ -20,7 +20,6 @@ export const listNetworkRequests = defineTool({ .number() .int() .positive() - .max(100) .optional() .describe( 'Maximum number of requests to return. When omitted, returns all requests.', diff --git a/src/utils/networkPagination.ts b/src/utils/networkPagination.ts deleted file mode 100644 index 176488bb0..000000000 --- a/src/utils/networkPagination.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ -import {type HTTPRequest} from 'puppeteer-core'; - -export type NetworkPaginationOptions = { - pageSize?: number; - pageToken?: string; -}; - -export type NetworkPaginationResult = { - requests: readonly HTTPRequest[]; - nextPageToken?: string; - previousPageToken?: string; - startIndex: number; - endIndex: number; - invalidToken: boolean; -}; - -const DEFAULT_PAGE_SIZE = 20; - -export function paginateNetworkRequests( - requests: readonly HTTPRequest[], - options?: NetworkPaginationOptions, -): NetworkPaginationResult { - const total = requests.length; - - if (!options || noPaginationOptions(options)) { - return { - requests, - nextPageToken: undefined, - previousPageToken: undefined, - startIndex: 0, - endIndex: total, - invalidToken: false, - }; - } - - const pageSize = validatePageSize(options.pageSize, total); - const {startIndex, invalidToken} = resolveStartIndex( - options.pageToken, - total, - ); - - const pageRequests = requests.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, - }; -} - -function noPaginationOptions(options: NetworkPaginationOptions): 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: true}; - } - - return {startIndex: parsed, invalidToken: false}; -} diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts new file mode 100644 index 000000000..4f3f81a82 --- /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; + pageToken?: string; +}; + +export type PaginationResult = { + items: readonly TItem[]; + nextPageToken?: string; + previousPageToken?: string; + startIndex: number; + endIndex: number; + invalidToken: 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, + nextPageToken: undefined, + previousPageToken: undefined, + startIndex: 0, + endIndex: total, + invalidToken: false, + }; + } + + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const { startIndex, invalidToken } = resolveStartIndex(options.pageToken, total); + + const pageItems = items.slice(startIndex, startIndex + pageSize); + const endIndex = startIndex + pageItems.length; + + const nextPageToken = endIndex < total ? String(endIndex) : undefined; + const previousPageToken = + startIndex > 0 ? String(Math.max(startIndex - pageSize, 0)) : undefined; + + return { + items: pageItems, + nextPageToken, + previousPageToken, + startIndex, + endIndex, + invalidToken, + }; +} + +function noPaginationOptions(options: PaginationOptions): boolean { + return ( + options.pageSize === undefined && + (options.pageToken === undefined || options.pageToken === null) + ); +} + + +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: true }; + } + + return { startIndex: parsed, invalidToken: false }; +} + + From d870210c9c1ef3049520ad5a18c3e1229f55113b Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Thu, 25 Sep 2025 16:11:08 +0300 Subject: [PATCH 5/8] fix: align test output with McpResponse --- src/McpResponse.ts | 9 +++++---- tests/McpResponse.test.ts | 30 +++++++++++++++++------------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 9cf7aaa56..2cb109210 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -185,11 +185,12 @@ Call browser_handle_dialog to handle it before continuing.`); response.push('Invalid page token provided. Showing first page.'); } + const { startIndex, endIndex } = paginationResult; + response.push( + `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, + ); + if (this.#networkRequestsPaginationOptions) { - const { startIndex, endIndex } = paginationResult; - response.push( - `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, - ); if (paginationResult.nextPageToken) { response.push(`Next: ${paginationResult.nextPageToken}`); } diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 28f58b551..814031d9c 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -3,10 +3,10 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {describe, it} from 'node:test'; +import { describe, it } from 'node:test'; import assert from 'assert'; -import {getMockRequest, html, withBrowser} from './utils.js'; +import { getMockRequest, html, withBrowser } from './utils.js'; describe('McpResponse', () => { it('list pages', async () => { @@ -120,7 +120,7 @@ Navigation timeout set to 100000 ms`, }); it('adds image when image is attached', async () => { await withBrowser(async (response, context) => { - response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); + response.attachImage({ data: 'imageBase64', mimeType: 'image/png' }); const result = await response.handle('test', context); assert.strictEqual(result[0].text, `# test response`); assert.equal(result[1].type, 'image'); @@ -181,9 +181,13 @@ Call browser_handle_dialog to handle it before continuing.`, return [getMockRequest()]; }; const result = await response.handle('test', context); - const text = result[0].text as string; - assert.ok(text.includes(`## Network requests`)); - assert.ok(text.includes('http://example.com GET [pending]')); + assert.strictEqual( + result[0].text, + `# test response +## Network requests +Showing 1-1 of 1. +http://example.com GET [pending]`, + ); }); }); it('does not include network requests when setting is false', async () => { @@ -263,7 +267,7 @@ 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()); + const requests = Array.from({ length: 5 }, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true); const result = await response.handle('test', context); @@ -276,13 +280,13 @@ describe('McpResponse network pagination', () => { it('returns first page by default', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({length: 30}, (_, idx) => - getMockRequest({method: `GET-${idx}`}), + const requests = Array.from({ length: 30 }, (_, idx) => + getMockRequest({ method: `GET-${idx}` }), ); context.getNetworkRequests = () => { return requests; }; - response.setIncludeNetworkRequests(true, {pageSize: 10}); + 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.')); @@ -293,8 +297,8 @@ describe('McpResponse network pagination', () => { it('returns subsequent page when token provided', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({length: 25}, (_, idx) => - getMockRequest({method: `GET-${idx}`}), + const requests = Array.from({ length: 25 }, (_, idx) => + getMockRequest({ method: `GET-${idx}` }), ); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { @@ -311,7 +315,7 @@ describe('McpResponse network pagination', () => { it('handles invalid token by showing first page', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({length: 5}, () => getMockRequest()); + const requests = Array.from({ length: 5 }, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { pageSize: 2, From 522374e4d17d1b84d1e4744c1e06b0cd28d1e549 Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Thu, 25 Sep 2025 16:11:33 +0300 Subject: [PATCH 6/8] fix: code formatting --- src/McpResponse.ts | 21 ++++--- src/tools/network.ts | 4 +- src/utils/pagination.ts | 114 +++++++++++++++++++------------------- tests/McpResponse.test.ts | 20 +++---- 4 files changed, 81 insertions(+), 78 deletions(-) diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 2cb109210..8319d5cdb 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -3,17 +3,17 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import type { ImageContentData, Response } from './tools/ToolDefinition.js'; -import type { McpContext } from './McpContext.js'; -import { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; +import type {ImageContentData, Response} from './tools/ToolDefinition.js'; +import type {McpContext} from './McpContext.js'; +import {ImageContent, TextContent} from '@modelcontextprotocol/sdk/types.js'; import { getFormattedHeaderValue, getShortDescriptionForRequest, getStatusFromRequest, } from './formatters/networkFormatter.js'; -import { formatA11ySnapshot } from './formatters/snapshotFormatter.js'; -import { formatConsoleEvent } from './formatters/consoleFormatter.js'; -import { paginate, type PaginationOptions } from './utils/pagination.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; @@ -36,7 +36,7 @@ export class McpResponse implements Response { setIncludeNetworkRequests( value: boolean, - options?: { pageSize?: number; pageToken?: string | null }, + options?: {pageSize?: number; pageToken?: string | null}, ): void { this.#includeNetworkRequests = value; if (!value || !options) { @@ -180,12 +180,15 @@ Call browser_handle_dialog to handle it before continuing.`); const requests = context.getNetworkRequests(); response.push('## Network requests'); if (requests.length) { - const paginationResult = paginate(requests, this.#networkRequestsPaginationOptions); + const paginationResult = paginate( + requests, + this.#networkRequestsPaginationOptions, + ); if (paginationResult.invalidToken) { response.push('Invalid page token provided. Showing first page.'); } - const { startIndex, endIndex } = paginationResult; + const {startIndex, endIndex} = paginationResult; response.push( `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, ); diff --git a/src/tools/network.ts b/src/tools/network.ts index 5d84838ba..e1c7d2637 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -5,8 +5,8 @@ */ import z from 'zod'; -import { defineTool } from './ToolDefinition.js'; -import { ToolCategories } from './categories.js'; +import {defineTool} from './ToolDefinition.js'; +import {ToolCategories} from './categories.js'; export const listNetworkRequests = defineTool({ name: 'list_network_requests', diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts index 4f3f81a82..b80aec517 100644 --- a/src/utils/pagination.ts +++ b/src/utils/pagination.ts @@ -5,83 +5,83 @@ */ export type PaginationOptions = { - pageSize?: number; - pageToken?: string; + pageSize?: number; + pageToken?: string; }; export type PaginationResult = { - items: readonly TItem[]; - nextPageToken?: string; - previousPageToken?: string; - startIndex: number; - endIndex: number; - invalidToken: boolean; + items: readonly TItem[]; + nextPageToken?: string; + previousPageToken?: string; + startIndex: number; + endIndex: number; + invalidToken: boolean; }; const DEFAULT_PAGE_SIZE = 20; export function paginate( - items: readonly TItem[], - options?: PaginationOptions, + items: readonly TItem[], + options?: PaginationOptions, ): PaginationResult { - const total = items.length; - - if (!options || noPaginationOptions(options)) { - return { - items, - nextPageToken: undefined, - previousPageToken: undefined, - startIndex: 0, - endIndex: total, - invalidToken: false, - }; - } - - const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; - const { startIndex, invalidToken } = resolveStartIndex(options.pageToken, total); - - const pageItems = items.slice(startIndex, startIndex + pageSize); - const endIndex = startIndex + pageItems.length; - - const nextPageToken = endIndex < total ? String(endIndex) : undefined; - const previousPageToken = - startIndex > 0 ? String(Math.max(startIndex - pageSize, 0)) : undefined; + const total = items.length; + if (!options || noPaginationOptions(options)) { return { - items: pageItems, - nextPageToken, - previousPageToken, - startIndex, - endIndex, - invalidToken, + items, + nextPageToken: undefined, + previousPageToken: undefined, + startIndex: 0, + endIndex: total, + invalidToken: false, }; + } + + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const {startIndex, invalidToken} = resolveStartIndex( + options.pageToken, + total, + ); + + const pageItems = items.slice(startIndex, startIndex + pageSize); + const endIndex = startIndex + pageItems.length; + + const nextPageToken = endIndex < total ? String(endIndex) : undefined; + const previousPageToken = + startIndex > 0 ? String(Math.max(startIndex - pageSize, 0)) : undefined; + + return { + items: pageItems, + nextPageToken, + previousPageToken, + startIndex, + endIndex, + invalidToken, + }; } function noPaginationOptions(options: PaginationOptions): boolean { - return ( - options.pageSize === undefined && - (options.pageToken === undefined || options.pageToken === null) - ); + return ( + options.pageSize === undefined && + (options.pageToken === undefined || options.pageToken === null) + ); } - function resolveStartIndex( - pageToken: string | undefined, - total: number, + pageToken: string | undefined, + total: number, ): { - startIndex: number; - invalidToken: boolean; + startIndex: number; + invalidToken: boolean; } { - if (pageToken === undefined || pageToken === null) { - return { startIndex: 0, invalidToken: false }; - } + 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: true }; - } + const parsed = Number.parseInt(pageToken, 10); + if (Number.isNaN(parsed) || parsed < 0 || parsed >= total) { + return {startIndex: 0, invalidToken: true}; + } - return { startIndex: parsed, invalidToken: false }; + return {startIndex: parsed, invalidToken: false}; } - - diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 814031d9c..09645132c 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -3,10 +3,10 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it } from 'node:test'; +import {describe, it} from 'node:test'; import assert from 'assert'; -import { getMockRequest, html, withBrowser } from './utils.js'; +import {getMockRequest, html, withBrowser} from './utils.js'; describe('McpResponse', () => { it('list pages', async () => { @@ -120,7 +120,7 @@ Navigation timeout set to 100000 ms`, }); it('adds image when image is attached', async () => { await withBrowser(async (response, context) => { - response.attachImage({ data: 'imageBase64', mimeType: 'image/png' }); + response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); const result = await response.handle('test', context); assert.strictEqual(result[0].text, `# test response`); assert.equal(result[1].type, 'image'); @@ -267,7 +267,7 @@ 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()); + const requests = Array.from({length: 5}, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true); const result = await response.handle('test', context); @@ -280,13 +280,13 @@ describe('McpResponse network pagination', () => { it('returns first page by default', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 30 }, (_, idx) => - getMockRequest({ method: `GET-${idx}` }), + const requests = Array.from({length: 30}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), ); context.getNetworkRequests = () => { return requests; }; - response.setIncludeNetworkRequests(true, { pageSize: 10 }); + 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.')); @@ -297,8 +297,8 @@ describe('McpResponse network pagination', () => { it('returns subsequent page when token provided', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 25 }, (_, idx) => - getMockRequest({ method: `GET-${idx}` }), + const requests = Array.from({length: 25}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), ); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { @@ -315,7 +315,7 @@ describe('McpResponse network pagination', () => { it('handles invalid token by showing first page', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 5 }, () => getMockRequest()); + const requests = Array.from({length: 5}, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { pageSize: 2, From aa222584657af1232bc7fb1e93513619947f9ba0 Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Thu, 25 Sep 2025 17:18:45 +0300 Subject: [PATCH 7/8] feat: use pageIdx as token --- docs/tool-reference.md | 2 +- src/McpResponse.ts | 26 +++++++-------- src/tools/ToolDefinition.ts | 2 +- src/tools/network.ts | 14 ++++---- src/utils/pagination.ts | 64 ++++++++++++++++++------------------- tests/McpResponse.test.ts | 54 +++++++++++++++---------------- tests/tools/network.test.ts | 2 +- 7 files changed, 83 insertions(+), 81 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index b6cbcebb4..60b45e791 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -264,8 +264,8 @@ **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. -- **pageToken** (string) _(optional)_: Opaque token representing the next page. Use the token returned by a previous call. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 8319d5cdb..48428365f 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -36,7 +36,7 @@ export class McpResponse implements Response { setIncludeNetworkRequests( value: boolean, - options?: {pageSize?: number; pageToken?: string | null}, + options?: {pageSize?: number; pageIdx?: number}, ): void { this.#includeNetworkRequests = value; if (!value || !options) { @@ -46,7 +46,7 @@ export class McpResponse implements Response { this.#networkRequestsPaginationOptions = { pageSize: options.pageSize, - pageToken: options.pageToken ?? undefined, + pageIdx: options.pageIdx, }; } @@ -72,9 +72,8 @@ export class McpResponse implements Response { get attachedNetworkRequestUrl(): string | undefined { return this.#attachedNetworkRequestUrl; } - get networkRequestsPageToken(): string | undefined { - const token = this.#networkRequestsPaginationOptions?.pageToken; - return token ?? undefined; + get networkRequestsPageIdx(): number | undefined { + return this.#networkRequestsPaginationOptions?.pageIdx; } appendResponseLine(value: string): void { @@ -184,21 +183,22 @@ Call browser_handle_dialog to handle it before continuing.`); requests, this.#networkRequestsPaginationOptions, ); - if (paginationResult.invalidToken) { - response.push('Invalid page token provided. Showing first page.'); + if (paginationResult.invalidPage) { + response.push('Invalid page number provided. Showing first page.'); } - const {startIndex, endIndex} = paginationResult; + const {startIndex, endIndex, currentPage, totalPages} = + paginationResult; response.push( - `Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`, + `Showing ${startIndex + 1}-${endIndex} of ${requests.length} (Page ${currentPage + 1} of ${totalPages}).`, ); if (this.#networkRequestsPaginationOptions) { - if (paginationResult.nextPageToken) { - response.push(`Next: ${paginationResult.nextPageToken}`); + if (paginationResult.hasNextPage) { + response.push(`Next page: ${currentPage + 1}`); } - if (paginationResult.previousPageToken) { - response.push(`Prev: ${paginationResult.previousPageToken}`); + if (paginationResult.hasPreviousPage) { + response.push(`Previous page: ${currentPage - 1}`); } } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index a22053b57..8c6267503 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -44,7 +44,7 @@ export interface Response { setIncludePages(value: boolean): void; setIncludeNetworkRequests( value: boolean, - options?: {pageSize?: number; pageToken?: string | null}, + options?: {pageSize?: number; pageIdx?: number}, ): void; setIncludeConsoleData(value: boolean): void; setIncludeSnapshot(value: boolean): void; diff --git a/src/tools/network.ts b/src/tools/network.ts index e1c7d2637..f51ba1732 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -5,8 +5,8 @@ */ import z from 'zod'; -import {defineTool} from './ToolDefinition.js'; -import {ToolCategories} from './categories.js'; +import { defineTool } from './ToolDefinition.js'; +import { ToolCategories } from './categories.js'; export const listNetworkRequests = defineTool({ name: 'list_network_requests', @@ -24,17 +24,19 @@ export const listNetworkRequests = defineTool({ .describe( 'Maximum number of requests to return. When omitted, returns all requests.', ), - pageToken: z - .string() + pageIdx: z + .number() + .int() + .min(0) .optional() .describe( - 'Opaque token representing the next page. Use the token returned by a previous call.', + 'Page number to return (0-based). When omitted, returns the first page.', ), }, handler: async (request, response) => { response.setIncludeNetworkRequests(true, { pageSize: request.params.pageSize, - pageToken: request.params.pageToken ?? null, + pageIdx: request.params.pageIdx, }); }, }); diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts index b80aec517..dbd35b990 100644 --- a/src/utils/pagination.ts +++ b/src/utils/pagination.ts @@ -6,16 +6,18 @@ export type PaginationOptions = { pageSize?: number; - pageToken?: string; + pageIdx?: number; }; export type PaginationResult = { items: readonly TItem[]; - nextPageToken?: string; - previousPageToken?: string; + currentPage: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; startIndex: number; endIndex: number; - invalidToken: boolean; + invalidPage: boolean; }; const DEFAULT_PAGE_SIZE = 20; @@ -29,59 +31,57 @@ export function paginate( if (!options || noPaginationOptions(options)) { return { items, - nextPageToken: undefined, - previousPageToken: undefined, + currentPage: 0, + totalPages: 1, + hasNextPage: false, + hasPreviousPage: false, startIndex: 0, endIndex: total, - invalidToken: false, + invalidPage: false, }; } const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; - const {startIndex, invalidToken} = resolveStartIndex( - options.pageToken, - total, + 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; - const nextPageToken = endIndex < total ? String(endIndex) : undefined; - const previousPageToken = - startIndex > 0 ? String(Math.max(startIndex - pageSize, 0)) : undefined; - return { items: pageItems, - nextPageToken, - previousPageToken, + currentPage, + totalPages, + hasNextPage: currentPage < totalPages - 1, + hasPreviousPage: currentPage > 0, startIndex, endIndex, - invalidToken, + invalidPage, }; } function noPaginationOptions(options: PaginationOptions): boolean { - return ( - options.pageSize === undefined && - (options.pageToken === undefined || options.pageToken === null) - ); + return options.pageSize === undefined && options.pageIdx === undefined; } -function resolveStartIndex( - pageToken: string | undefined, - total: number, +function resolvePageIndex( + pageIdx: number | undefined, + totalPages: number, ): { - startIndex: number; - invalidToken: boolean; + currentPage: number; + invalidPage: boolean; } { - if (pageToken === undefined || pageToken === null) { - return {startIndex: 0, invalidToken: false}; + if (pageIdx === undefined) { + return {currentPage: 0, invalidPage: false}; } - const parsed = Number.parseInt(pageToken, 10); - if (Number.isNaN(parsed) || parsed < 0 || parsed >= total) { - return {startIndex: 0, invalidToken: true}; + if (pageIdx < 0 || pageIdx >= totalPages) { + return {currentPage: 0, invalidPage: true}; } - return {startIndex: parsed, invalidToken: false}; + return {currentPage: pageIdx, invalidPage: false}; } diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 09645132c..6bb181f28 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -3,10 +3,10 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {describe, it} from 'node:test'; +import { describe, it } from 'node:test'; import assert from 'assert'; -import {getMockRequest, html, withBrowser} from './utils.js'; +import { getMockRequest, html, withBrowser } from './utils.js'; describe('McpResponse', () => { it('list pages', async () => { @@ -120,7 +120,7 @@ Navigation timeout set to 100000 ms`, }); it('adds image when image is attached', async () => { await withBrowser(async (response, context) => { - response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); + response.attachImage({ data: 'imageBase64', mimeType: 'image/png' }); const result = await response.handle('test', context); assert.strictEqual(result[0].text, `# test response`); assert.equal(result[1].type, 'image'); @@ -185,7 +185,7 @@ Call browser_handle_dialog to handle it before continuing.`, result[0].text, `# test response ## Network requests -Showing 1-1 of 1. +Showing 1-1 of 1 (Page 1 of 1). http://example.com GET [pending]`, ); }); @@ -218,7 +218,7 @@ Status: [pending] ### Request Headers - content-size:10 ## Network requests -Showing 1-1 of 1. +Showing 1-1 of 1 (Page 1 of 1). http://example.com GET [pending]`, ); }); @@ -267,66 +267,66 @@ 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()); + 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.')); - assert.ok(!text.includes('Next:')); - assert.ok(!text.includes('Prev:')); + 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}`}), + const requests = Array.from({ length: 30 }, (_, idx) => + getMockRequest({ method: `GET-${idx}` }), ); context.getNetworkRequests = () => { return requests; }; - response.setIncludeNetworkRequests(true, {pageSize: 10}); + 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:')); + 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 token provided', async () => { + it('returns subsequent page when pageIdx provided', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({length: 25}, (_, idx) => - getMockRequest({method: `GET-${idx}`}), + const requests = Array.from({ length: 25 }, (_, idx) => + getMockRequest({ method: `GET-${idx}` }), ); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { pageSize: 10, - pageToken: '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.')); - assert.ok(text.includes('Next: 20')); - assert.ok(text.includes('Prev: 0')); + 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 token by showing first page', async () => { + it('handles invalid page number by showing first page', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({length: 5}, () => getMockRequest()); + const requests = Array.from({ length: 5 }, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { pageSize: 2, - pageToken: 'invalid', + 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 token provided. Showing first page.'), + text.includes('Invalid page number provided. Showing first page.'), ); - assert.ok(text.includes('Showing 1-2 of 5.')); + 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 bcf2eb65d..43a780517 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -18,7 +18,7 @@ describe('network', () => { await withBrowser(async (response, context) => { await listNetworkRequests.handler({params: {}}, response, context); assert.ok(response.includeNetworkRequests); - assert.strictEqual(response.networkRequestsPageToken, undefined); + assert.strictEqual(response.networkRequestsPageIdx, undefined); }); }); }); From 3d0117a98b8b20256f8edb0dcb7aa65ea743b65d Mon Sep 17 00:00:00 2001 From: Oz Tamir Date: Thu, 25 Sep 2025 17:19:38 +0300 Subject: [PATCH 8/8] fix: code formatting --- src/tools/network.ts | 4 ++-- tests/McpResponse.test.ts | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/tools/network.ts b/src/tools/network.ts index f51ba1732..d4746b960 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -5,8 +5,8 @@ */ import z from 'zod'; -import { defineTool } from './ToolDefinition.js'; -import { ToolCategories } from './categories.js'; +import {defineTool} from './ToolDefinition.js'; +import {ToolCategories} from './categories.js'; export const listNetworkRequests = defineTool({ name: 'list_network_requests', diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 6bb181f28..41cbdc94c 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -3,10 +3,10 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it } from 'node:test'; +import {describe, it} from 'node:test'; import assert from 'assert'; -import { getMockRequest, html, withBrowser } from './utils.js'; +import {getMockRequest, html, withBrowser} from './utils.js'; describe('McpResponse', () => { it('list pages', async () => { @@ -120,7 +120,7 @@ Navigation timeout set to 100000 ms`, }); it('adds image when image is attached', async () => { await withBrowser(async (response, context) => { - response.attachImage({ data: 'imageBase64', mimeType: 'image/png' }); + response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); const result = await response.handle('test', context); assert.strictEqual(result[0].text, `# test response`); assert.equal(result[1].type, 'image'); @@ -267,7 +267,7 @@ 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()); + const requests = Array.from({length: 5}, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true); const result = await response.handle('test', context); @@ -280,13 +280,13 @@ describe('McpResponse network pagination', () => { it('returns first page by default', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 30 }, (_, idx) => - getMockRequest({ method: `GET-${idx}` }), + const requests = Array.from({length: 30}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), ); context.getNetworkRequests = () => { return requests; }; - response.setIncludeNetworkRequests(true, { pageSize: 10 }); + 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).')); @@ -297,8 +297,8 @@ describe('McpResponse network pagination', () => { it('returns subsequent page when pageIdx provided', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 25 }, (_, idx) => - getMockRequest({ method: `GET-${idx}` }), + const requests = Array.from({length: 25}, (_, idx) => + getMockRequest({method: `GET-${idx}`}), ); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { @@ -315,7 +315,7 @@ describe('McpResponse network pagination', () => { it('handles invalid page number by showing first page', async () => { await withBrowser(async (response, context) => { - const requests = Array.from({ length: 5 }, () => getMockRequest()); + const requests = Array.from({length: 5}, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { pageSize: 2,