diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 83873579b..3356b06ab 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -266,6 +266,7 @@ - **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. +- **resourceTypes** (array) _(optional)_: Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 3f44c8f14..eff7e7085 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -7,6 +7,7 @@ import type { ImageContent, TextContent, } from '@modelcontextprotocol/sdk/types.js'; +import type {ResourceType} from 'puppeteer-core'; import {formatConsoleEvent} from './formatters/consoleFormatter.js'; import { @@ -22,13 +23,16 @@ import {paginate, type PaginationOptions} from './utils/pagination.js'; export class McpResponse implements Response { #includePages = false; #includeSnapshot = false; - #includeNetworkRequests = false; #attachedNetworkRequestUrl?: string; #includeConsoleData = false; #textResponseLines: string[] = []; #formattedConsoleData?: string[]; #images: ImageContentData[] = []; - #networkRequestsPaginationOptions?: PaginationOptions; + #networkRequestsOptions?: { + include: boolean; + pagination?: PaginationOptions; + resourceTypes?: ResourceType[]; + }; setIncludePages(value: boolean): void { this.#includePages = value; @@ -40,17 +44,27 @@ export class McpResponse implements Response { setIncludeNetworkRequests( value: boolean, - options?: {pageSize?: number; pageIdx?: number}, + options?: { + pageSize?: number; + pageIdx?: number; + resourceTypes?: ResourceType[]; + }, ): void { - this.#includeNetworkRequests = value; - if (!value || !options) { - this.#networkRequestsPaginationOptions = undefined; + if (!value) { + this.#networkRequestsOptions = undefined; return; } - this.#networkRequestsPaginationOptions = { - pageSize: options.pageSize, - pageIdx: options.pageIdx, + this.#networkRequestsOptions = { + include: value, + pagination: + options?.pageSize || options?.pageIdx + ? { + pageSize: options.pageSize, + pageIdx: options.pageIdx, + } + : undefined, + resourceTypes: options?.resourceTypes, }; } @@ -67,7 +81,7 @@ export class McpResponse implements Response { } get includeNetworkRequests(): boolean { - return this.#includeNetworkRequests; + return this.#networkRequestsOptions?.include ?? false; } get includeConsoleData(): boolean { @@ -77,7 +91,7 @@ export class McpResponse implements Response { return this.#attachedNetworkRequestUrl; } get networkRequestsPageIdx(): number | undefined { - return this.#networkRequestsPaginationOptions?.pageIdx; + return this.#networkRequestsOptions?.pagination?.pageIdx; } appendResponseLine(value: string): void { @@ -179,13 +193,25 @@ Call browser_handle_dialog to handle it before continuing.`); response.push(...this.#getIncludeNetworkRequestsData(context)); - if (this.#includeNetworkRequests) { - const requests = context.getNetworkRequests(); + if (this.#networkRequestsOptions?.include) { + let requests = context.getNetworkRequests(); + + // Apply resource type filtering if specified + if (this.#networkRequestsOptions.resourceTypes?.length) { + const normalizedTypes = new Set( + this.#networkRequestsOptions.resourceTypes, + ); + requests = requests.filter(request => { + const type = request.resourceType(); + return normalizedTypes.has(type); + }); + } + response.push('## Network requests'); if (requests.length) { const paginationResult = paginate( requests, - this.#networkRequestsPaginationOptions, + this.#networkRequestsOptions.pagination, ); if (paginationResult.invalidPage) { response.push('Invalid page number provided. Showing first page.'); @@ -197,7 +223,7 @@ Call browser_handle_dialog to handle it before continuing.`); `Showing ${startIndex + 1}-${endIndex} of ${requests.length} (Page ${currentPage + 1} of ${totalPages}).`, ); - if (this.#networkRequestsPaginationOptions) { + if (this.#networkRequestsOptions.pagination) { if (paginationResult.hasNextPage) { response.push(`Next page: ${currentPage + 1}`); } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 8e8f027f7..a0741f4cd 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; pageIdx?: number}, + options?: {pageSize?: number; pageIdx?: number; resourceTypes?: string[]}, ): void; setIncludeConsoleData(value: boolean): void; setIncludeSnapshot(value: boolean): void; diff --git a/src/tools/network.ts b/src/tools/network.ts index f4367bf53..6ff2bb92c 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -4,11 +4,34 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {ResourceType} from 'puppeteer-core'; import z from 'zod'; import {ToolCategories} from './categories.js'; import {defineTool} from './ToolDefinition.js'; +const FILTERABLE_RESOURCE_TYPES: readonly [ResourceType, ...ResourceType[]] = [ + 'document', + 'stylesheet', + 'image', + 'media', + 'font', + 'script', + 'texttrack', + 'xhr', + 'fetch', + 'prefetch', + 'eventsource', + 'websocket', + 'manifest', + 'signedexchange', + 'ping', + 'cspviolationreport', + 'preflight', + 'fedcm', + 'other', +]; + export const listNetworkRequests = defineTool({ name: 'list_network_requests', description: `List all requests for the currently selected page`, @@ -33,11 +56,18 @@ export const listNetworkRequests = defineTool({ .describe( 'Page number to return (0-based). When omitted, returns the first page.', ), + resourceTypes: z + .array(z.enum(FILTERABLE_RESOURCE_TYPES)) + .optional() + .describe( + 'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.', + ), }, handler: async (request, response) => { response.setIncludeNetworkRequests(true, { pageSize: request.params.pageSize, pageIdx: request.params.pageIdx, + resourceTypes: request.params.resourceTypes, }); }, }); diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 7ac63acf0..e72cd99cd 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -190,6 +190,7 @@ http://example.com GET [pending]`, ); }); }); + it('does not include network requests when setting is false', async () => { await withBrowser(async (response, context) => { response.setIncludeNetworkRequests(false); @@ -264,6 +265,134 @@ Log>`), }); }); +describe('McpResponse network request filtering', () => { + it('filters network requests by resource type', async () => { + await withBrowser(async (response, context) => { + response.setIncludeNetworkRequests(true, { + resourceTypes: ['script', 'stylesheet'], + }); + context.getNetworkRequests = () => { + return [ + getMockRequest({resourceType: 'script'}), + getMockRequest({resourceType: 'image'}), + getMockRequest({resourceType: 'stylesheet'}), + getMockRequest({resourceType: 'document'}), + ]; + }; + const result = await response.handle('test', context); + assert.strictEqual( + result[0].text, + `# test response +## Network requests +Showing 1-2 of 2 (Page 1 of 1). +http://example.com GET [pending] +http://example.com GET [pending]`, + ); + }); + }); + + it('filters network requests by single resource type', async () => { + await withBrowser(async (response, context) => { + response.setIncludeNetworkRequests(true, { + resourceTypes: ['image'], + }); + context.getNetworkRequests = () => { + return [ + getMockRequest({resourceType: 'script'}), + getMockRequest({resourceType: 'image'}), + getMockRequest({resourceType: 'stylesheet'}), + ]; + }; + const result = await response.handle('test', context); + assert.strictEqual( + result[0].text, + `# test response +## Network requests +Showing 1-1 of 1 (Page 1 of 1). +http://example.com GET [pending]`, + ); + }); + }); + + it('shows no requests when filter matches nothing', async () => { + await withBrowser(async (response, context) => { + response.setIncludeNetworkRequests(true, { + resourceTypes: ['font'], + }); + context.getNetworkRequests = () => { + return [ + getMockRequest({resourceType: 'script'}), + getMockRequest({resourceType: 'image'}), + getMockRequest({resourceType: 'stylesheet'}), + ]; + }; + const result = await response.handle('test', context); + assert.strictEqual( + result[0].text, + `# test response +## Network requests +No requests found.`, + ); + }); + }); + + it('shows all requests when no filters are provided', async () => { + await withBrowser(async (response, context) => { + response.setIncludeNetworkRequests(true); + context.getNetworkRequests = () => { + return [ + getMockRequest({resourceType: 'script'}), + getMockRequest({resourceType: 'image'}), + getMockRequest({resourceType: 'stylesheet'}), + getMockRequest({resourceType: 'document'}), + getMockRequest({resourceType: 'font'}), + ]; + }; + const result = await response.handle('test', context); + assert.strictEqual( + result[0].text, + `# test response +## Network requests +Showing 1-5 of 5 (Page 1 of 1). +http://example.com GET [pending] +http://example.com GET [pending] +http://example.com GET [pending] +http://example.com GET [pending] +http://example.com GET [pending]`, + ); + }); + }); + + it('shows all requests when empty resourceTypes array is provided', async () => { + await withBrowser(async (response, context) => { + response.setIncludeNetworkRequests(true, { + resourceTypes: [], + }); + context.getNetworkRequests = () => { + return [ + getMockRequest({resourceType: 'script'}), + getMockRequest({resourceType: 'image'}), + getMockRequest({resourceType: 'stylesheet'}), + getMockRequest({resourceType: 'document'}), + getMockRequest({resourceType: 'font'}), + ]; + }; + const result = await response.handle('test', context); + assert.strictEqual( + result[0].text, + `# test response +## Network requests +Showing 1-5 of 5 (Page 1 of 1). +http://example.com GET [pending] +http://example.com GET [pending] +http://example.com GET [pending] +http://example.com GET [pending] +http://example.com GET [pending]`, + ); + }); + }); +}); + describe('McpResponse network pagination', () => { it('returns all requests when pagination is not provided', async () => { await withBrowser(async (response, context) => { diff --git a/tests/utils.ts b/tests/utils.ts index b13a6a72b..6fc2cb797 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -46,6 +46,7 @@ export function getMockRequest( method?: string; response?: HTTPResponse; failure?: HTTPRequest['failure']; + resourceType?: string; } = {}, ): HTTPRequest { return { @@ -61,6 +62,9 @@ export function getMockRequest( failure() { return options.failure?.() ?? null; }, + resourceType() { + return options.resourceType ?? 'document'; + }, headers(): Record { return { 'content-size': '10',