Skip to content

Commit 5be157e

Browse files
committed
feat: add pagination to list_network_requests
1 parent 64c446f commit 5be157e

File tree

6 files changed

+260
-29
lines changed

6 files changed

+260
-29
lines changed

src/McpResponse.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33
* Copyright 2025 Google LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
import type {ImageContentData, Response} from './tools/ToolDefinition.js';
7-
import type {McpContext} from './McpContext.js';
8-
import {ImageContent, TextContent} from '@modelcontextprotocol/sdk/types.js';
6+
import type { ImageContentData, Response } from './tools/ToolDefinition.js';
7+
import type { McpContext } from './McpContext.js';
8+
import { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
99
import {
1010
getFormattedHeaderValue,
1111
getShortDescriptionForRequest,
1212
getStatusFromRequest,
1313
} from './formatters/networkFormatter.js';
14-
import {formatA11ySnapshot} from './formatters/snapshotFormatter.js';
15-
import {formatConsoleEvent} from './formatters/consoleFormatter.js';
14+
import { formatA11ySnapshot } from './formatters/snapshotFormatter.js';
15+
import { formatConsoleEvent } from './formatters/consoleFormatter.js';
16+
import {
17+
paginateNetworkRequests,
18+
type NetworkPaginationOptions,
19+
} from './utils/networkPagination.js';
1620

1721
export class McpResponse implements Response {
1822
#includePages: boolean = false;
@@ -23,6 +27,7 @@ export class McpResponse implements Response {
2327
#textResponseLines: string[] = [];
2428
#formattedConsoleData?: string[];
2529
#images: ImageContentData[] = [];
30+
#networkRequestsPaginationOptions?: NetworkPaginationOptions;
2631

2732
setIncludePages(value: boolean): void {
2833
this.#includePages = value;
@@ -32,8 +37,31 @@ export class McpResponse implements Response {
3237
this.#includeSnapshot = value;
3338
}
3439

35-
setIncludeNetworkRequests(value: boolean): void {
40+
setIncludeNetworkRequests(
41+
value: boolean,
42+
options?: { pageSize?: number; pageToken?: string | null },
43+
): void {
3644
this.#includeNetworkRequests = value;
45+
if (!value) {
46+
this.#networkRequestsPaginationOptions = undefined;
47+
return;
48+
}
49+
50+
if (!options) {
51+
this.#networkRequestsPaginationOptions = undefined;
52+
return;
53+
}
54+
55+
const sanitizedOptions: NetworkPaginationOptions = {};
56+
if (options.pageSize !== undefined) {
57+
sanitizedOptions.pageSize = options.pageSize;
58+
}
59+
if (options.pageToken !== undefined) {
60+
sanitizedOptions.pageToken = options.pageToken ?? undefined;
61+
}
62+
63+
this.#networkRequestsPaginationOptions =
64+
Object.keys(sanitizedOptions).length > 0 ? sanitizedOptions : undefined;
3765
}
3866

3967
setIncludeConsoleData(value: boolean): void {
@@ -58,6 +86,10 @@ export class McpResponse implements Response {
5886
get attachedNetworkRequestUrl(): string | undefined {
5987
return this.#attachedNetworkRequestUrl;
6088
}
89+
get networkRequestsPageToken(): string | undefined {
90+
const token = this.#networkRequestsPaginationOptions?.pageToken;
91+
return token ?? undefined;
92+
}
6193

6294
appendResponseLine(value: string): void {
6395
this.#textResponseLines.push(value);
@@ -162,9 +194,26 @@ Call browser_handle_dialog to handle it before continuing.`);
162194
const requests = context.getNetworkRequests();
163195
response.push('## Network requests');
164196
if (requests.length) {
165-
for (const request of requests) {
197+
const paginationResult = paginateNetworkRequests(
198+
requests,
199+
this.#networkRequestsPaginationOptions,
200+
);
201+
if (paginationResult.invalidToken) {
202+
response.push('Invalid page token provided. Showing first page.');
203+
}
204+
const { startIndex, endIndex } = paginationResult;
205+
response.push(
206+
`Showing ${startIndex + 1}-${endIndex} of ${requests.length}.`,
207+
);
208+
for (const request of paginationResult.requests) {
166209
response.push(getShortDescriptionForRequest(request));
167210
}
211+
if (paginationResult.nextPageToken) {
212+
response.push(`Next: ${paginationResult.nextPageToken}`);
213+
}
214+
if (paginationResult.previousPageToken) {
215+
response.push(`Prev: ${paginationResult.previousPageToken}`);
216+
}
168217
} else {
169218
response.push('No requests found.');
170219
}

src/tools/ToolDefinition.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
*/
66

77
import z from 'zod';
8-
import {Dialog, ElementHandle, Page} from 'puppeteer-core';
9-
import {ToolCategories} from './categories.js';
10-
import {TraceResult} from '../trace-processing/parse.js';
8+
import { Dialog, ElementHandle, Page } from 'puppeteer-core';
9+
import { ToolCategories } from './categories.js';
10+
import { TraceResult } from '../trace-processing/parse.js';
1111

1212
export interface ToolDefinition<
1313
Schema extends Zod.ZodRawShape = Zod.ZodRawShape,
@@ -42,7 +42,10 @@ export type ImageContentData = {
4242
export interface Response {
4343
appendResponseLine(value: string): void;
4444
setIncludePages(value: boolean): void;
45-
setIncludeNetworkRequests(value: boolean): void;
45+
setIncludeNetworkRequests(
46+
value: boolean,
47+
options?: { pageSize?: number; pageToken?: string | null },
48+
): void;
4649
setIncludeConsoleData(value: boolean): void;
4750
setIncludeSnapshot(value: boolean): void;
4851
attachImage(value: ImageContentData): void;
@@ -69,7 +72,7 @@ export type Context = Readonly<{
6972
saveTemporaryFile(
7073
data: Uint8Array<ArrayBufferLike>,
7174
mimeType: 'image/png' | 'image/jpeg',
72-
): Promise<{filename: string}>;
75+
): Promise<{ filename: string }>;
7376
waitForEventsAfterAction(action: () => Promise<unknown>): Promise<void>;
7477
}>;
7578

src/tools/network.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
*/
66

77
import z from 'zod';
8-
import {defineTool} from './ToolDefinition.js';
9-
import {ToolCategories} from './categories.js';
8+
import { defineTool } from './ToolDefinition.js';
9+
import { ToolCategories } from './categories.js';
1010

1111
export const listNetworkRequests = defineTool({
1212
name: 'list_network_requests',
@@ -15,9 +15,28 @@ export const listNetworkRequests = defineTool({
1515
category: ToolCategories.NETWORK,
1616
readOnlyHint: true,
1717
},
18-
schema: {},
19-
handler: async (_request, response) => {
20-
response.setIncludeNetworkRequests(true);
18+
schema: {
19+
pageSize: z
20+
.number()
21+
.int()
22+
.positive()
23+
.max(100)
24+
.optional()
25+
.describe(
26+
'Maximum number of requests to return. When omitted, returns all requests.',
27+
),
28+
pageToken: z
29+
.string()
30+
.optional()
31+
.describe(
32+
'Opaque token representing the next page. Use the token returned by a previous call.',
33+
),
34+
},
35+
handler: async (request, response) => {
36+
response.setIncludeNetworkRequests(true, {
37+
pageSize: request.params.pageSize,
38+
pageToken: request.params.pageToken ?? null,
39+
});
2140
},
2241
});
2342

src/utils/networkPagination.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import { type HTTPRequest } from 'puppeteer-core';
7+
8+
export type NetworkPaginationOptions = {
9+
pageSize?: number;
10+
pageToken?: string;
11+
};
12+
13+
export type NetworkPaginationResult = {
14+
requests: readonly HTTPRequest[];
15+
nextPageToken?: string;
16+
previousPageToken?: string;
17+
startIndex: number;
18+
endIndex: number;
19+
invalidToken: boolean;
20+
};
21+
22+
const DEFAULT_PAGE_SIZE = 20;
23+
24+
export function paginateNetworkRequests(
25+
requests: readonly HTTPRequest[],
26+
options?: NetworkPaginationOptions,
27+
): NetworkPaginationResult {
28+
const total = requests.length;
29+
30+
if (!options || noPaginationOptions(options)) {
31+
return {
32+
requests,
33+
nextPageToken: undefined,
34+
previousPageToken: undefined,
35+
startIndex: 0,
36+
endIndex: total,
37+
invalidToken: false,
38+
};
39+
}
40+
41+
const pageSize = validatePageSize(options.pageSize, total);
42+
const { startIndex, invalidToken } = resolveStartIndex(options.pageToken, total);
43+
44+
const pageRequests = requests.slice(startIndex, startIndex + pageSize);
45+
const endIndex = startIndex + pageRequests.length;
46+
47+
const nextPageToken = endIndex < total ? String(endIndex) : undefined;
48+
const previousPageToken =
49+
startIndex > 0 ? String(Math.max(startIndex - pageSize, 0)) : undefined;
50+
51+
return {
52+
requests: pageRequests,
53+
nextPageToken,
54+
previousPageToken,
55+
startIndex,
56+
endIndex,
57+
invalidToken,
58+
};
59+
}
60+
61+
function noPaginationOptions(options: NetworkPaginationOptions): boolean {
62+
return (
63+
options.pageSize === undefined &&
64+
(options.pageToken === undefined || options.pageToken === null)
65+
);
66+
}
67+
68+
function validatePageSize(pageSize: number | undefined, total: number): number {
69+
if (pageSize === undefined) {
70+
return total || DEFAULT_PAGE_SIZE;
71+
}
72+
if (!Number.isInteger(pageSize) || pageSize <= 0) {
73+
return DEFAULT_PAGE_SIZE;
74+
}
75+
return Math.min(pageSize, Math.max(total, 1));
76+
}
77+
78+
function resolveStartIndex(pageToken: string | undefined, total: number): {
79+
startIndex: number;
80+
invalidToken: boolean;
81+
} {
82+
if (pageToken === undefined || pageToken === null) {
83+
return { startIndex: 0, invalidToken: false };
84+
}
85+
86+
const parsed = Number.parseInt(pageToken, 10);
87+
if (Number.isNaN(parsed) || parsed < 0 || parsed >= total) {
88+
return { startIndex: 0, invalidToken: true };
89+
}
90+
91+
return { startIndex: parsed, invalidToken: false };
92+
}

tests/McpResponse.test.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
* Copyright 2025 Google LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
import {describe, it} from 'node:test';
6+
import { describe, it } from 'node:test';
77
import assert from 'assert';
88

9-
import {getMockRequest, html, withBrowser} from './utils.js';
9+
import { getMockRequest, html, withBrowser } from './utils.js';
1010

1111
describe('McpResponse', () => {
1212
it('list pages', async () => {
@@ -120,7 +120,7 @@ Navigation timeout set to 100000 ms`,
120120
});
121121
it('adds image when image is attached', async () => {
122122
await withBrowser(async (response, context) => {
123-
response.attachImage({data: 'imageBase64', mimeType: 'image/png'});
123+
response.attachImage({ data: 'imageBase64', mimeType: 'image/png' });
124124
const result = await response.handle('test', context);
125125
assert.strictEqual(result[0].text, `# test response`);
126126
assert.equal(result[1].type, 'image');
@@ -181,12 +181,11 @@ Call browser_handle_dialog to handle it before continuing.`,
181181
return [getMockRequest()];
182182
};
183183
const result = await response.handle('test', context);
184-
assert.strictEqual(
185-
result[0].text,
186-
`# test response
187-
## Network requests
188-
http://example.com GET [pending]`,
184+
const text = result[0].text as string;
185+
assert.ok(
186+
text.includes(`## Network requests`),
189187
);
188+
assert.ok(text.includes('http://example.com GET [pending]'));
190189
});
191190
});
192191
it('does not include network requests when setting is false', async () => {
@@ -217,6 +216,7 @@ Status: [pending]
217216
### Request Headers
218217
- content-size:10
219218
## Network requests
219+
Showing 1-1 of 1.
220220
http://example.com GET [pending]`,
221221
);
222222
});
@@ -261,3 +261,70 @@ Log>`),
261261
});
262262
});
263263
});
264+
265+
describe('McpResponse network pagination', () => {
266+
it('returns all requests when pagination is not provided', async () => {
267+
await withBrowser(async (response, context) => {
268+
const requests = Array.from({ length: 5 }, () => getMockRequest());
269+
context.getNetworkRequests = () => requests;
270+
response.setIncludeNetworkRequests(true);
271+
const result = await response.handle('test', context);
272+
const text = (result[0].text as string).toString();
273+
assert.ok(text.includes('Showing 1-5 of 5.'));
274+
assert.ok(!text.includes('Next:'));
275+
assert.ok(!text.includes('Prev:'));
276+
});
277+
});
278+
279+
it('returns first page by default', async () => {
280+
await withBrowser(async (response, context) => {
281+
const requests = Array.from({ length: 30 }, (_, idx) =>
282+
getMockRequest({ method: `GET-${idx}` }),
283+
);
284+
context.getNetworkRequests = () => {
285+
return requests;
286+
};
287+
response.setIncludeNetworkRequests(true, { pageSize: 10 });
288+
const result = await response.handle('test', context);
289+
const text = (result[0].text as string).toString();
290+
assert.ok(text.includes('Showing 1-10 of 30.'));
291+
assert.ok(text.includes('Next: 10'));
292+
assert.ok(!text.includes('Prev:'));
293+
});
294+
});
295+
296+
it('returns subsequent page when token provided', async () => {
297+
await withBrowser(async (response, context) => {
298+
const requests = Array.from({ length: 25 }, (_, idx) =>
299+
getMockRequest({ method: `GET-${idx}` }),
300+
);
301+
context.getNetworkRequests = () => requests;
302+
response.setIncludeNetworkRequests(true, {
303+
pageSize: 10,
304+
pageToken: '10',
305+
});
306+
const result = await response.handle('test', context);
307+
const text = (result[0].text as string).toString();
308+
assert.ok(text.includes('Showing 11-20 of 25.'));
309+
assert.ok(text.includes('Next: 20'));
310+
assert.ok(text.includes('Prev: 0'));
311+
});
312+
});
313+
314+
it('handles invalid token by showing first page', async () => {
315+
await withBrowser(async (response, context) => {
316+
const requests = Array.from({ length: 5 }, () => getMockRequest());
317+
context.getNetworkRequests = () => requests;
318+
response.setIncludeNetworkRequests(true, {
319+
pageSize: 2,
320+
pageToken: 'invalid',
321+
});
322+
const result = await response.handle('test', context);
323+
const text = (result[0].text as string).toString();
324+
assert.ok(
325+
text.includes('Invalid page token provided. Showing first page.'),
326+
);
327+
assert.ok(text.includes('Showing 1-2 of 5.'));
328+
});
329+
});
330+
});

0 commit comments

Comments
 (0)