fix: let resourceTypes match CORS preflights and prefetches (#3217)

Fixes #2954.

`list_network_requests` accepts `resourceTypes: ["preflight"]` and
`["prefetch"]`, but neither ever matched anything. I checked with a CDP
session: Chrome itself sends `Network.requestWillBeSent` with `type:
"Other"` for the CORS preflight `OPTIONS` and for a `<link
rel=prefetch>` request, so Puppeteer's `resourceType()` returns `other`
for both.

This adds `getFilterableResourceType()` (`src/utils/resourceType.ts`),
which the filter now uses. It only looks past `other`:
- `OPTIONS` with an `Access-Control-Request-Method` header (the headers
that make a request a CORS preflight) → `preflight`
- a `Sec-Purpose: prefetch…` header, which Chrome sends on speculative
prefetches → `prefetch`
- anything else keeps Chrome's type, so `["other"]` still matches e.g.
`favicon.ico`, and a plain `OPTIONS` without the CORS header stays
`other`.

Tests:
- `McpResponse` (real page): a page prefetches `/data?prefetch` and
sends a cross-origin `PUT` with a custom header (`127.0.0.1` →
`localhost`). `["preflight"]` returns only the `OPTIONS`, `["prefetch"]`
only the prefetch, and `["fetch"]` only the `PUT`. This fails on `main`
and passes with the change.
- `tests/utils/resourceType.test.ts`: unit cases for both rules and the
cases that must stay `other`.
- `npm run build`, `eslint src tests` and `prettier --check .` are
clean. The `McpResponse` suite passes.

_AI-assisted: I used an AI assistant to help write this change. I
reviewed the code and tests and ran them locally._
This commit is contained in:
kevin authored and GitHub committed 2026-10-19 07:58:26 +00:00
1 parent b8b1f13f15
commit e342c6e21b
4 files changed
+136 -2

No files matched your search

+2 -2
View File
@@ -73,6 +73,7 @@ import type {CD4ACommentThread, PaginationOptions} from './types.js';
import type {WithSymbolId} from './utils/id.js';
import {stableIdSymbol} from './utils/id.js';
import {paginate} from './utils/pagination.js';
import {getFilterableResourceType} from './utils/resourceType.js';
import type {WaitForEventsResult} from './utils/WaitForHelper.js';
const {formatBytesToKb} = DevTools.I18n.ByteUtilities;
@@ -726,8 +727,7 @@ export class McpResponse implements Response {
this.#networkRequestsOptions.resourceTypes,
);
requests = requests.filter(request => {
const type = request.resourceType();
return normalizedTypes.has(type);
return normalizedTypes.has(getFilterableResourceType(request));
});
}
+31
View File
@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {HTTPRequest, ResourceType} from '../third_party/index.js';
/**
* The resource type a request is filtered by. Chrome reports CORS preflights
* and `<link rel=prefetch>` requests as `Other`, so `resourceTypes: ['preflight']`
* and `['prefetch']` could never match. Those two are recognised from the
* headers Chrome sends with them; every other type is Chrome's own.
*/
export function getFilterableResourceType(request: HTTPRequest): ResourceType {
const type = request.resourceType();
if (type !== 'other') {
return type;
}
const headers = request.headers();
if (
request.method() === 'OPTIONS' &&
headers['access-control-request-method'] !== undefined
) {
return 'preflight';
}
if (/^prefetch\b/i.test(headers['sec-purpose'] ?? '')) {
return 'prefetch';
}
return type;
}
+52
View File
@@ -697,6 +697,58 @@ describe('McpResponse', () => {
});
describe('McpResponse network request filtering', () => {
const server = serverHooks();
it('filters CORS preflights and prefetches by their own types', async () => {
await withMcpContext(async (response, context) => {
server.addRoute('/data?prefetch', (_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('prefetched');
});
server.addRoute('/data?cors', (_req, res) => {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'PUT',
'Access-Control-Allow-Headers': 'x-custom',
'Content-Type': 'text/plain',
});
res.end('cors');
});
// localhost vs 127.0.0.1 makes the PUT cross-origin, so it is preflighted.
const crossOrigin = server
.getRoute('/data?cors')
.replace('127.0.0.1', 'localhost');
server.addHtmlRoute(
'/types.html',
`<!doctype html><link rel="prefetch" href="/data?prefetch"><script>fetch(${JSON.stringify(crossOrigin)}, {method: 'PUT', headers: {'x-custom': '1'}});</script>`,
);
const page = context.getSelectedMcpPage().pptrPage;
const done = Promise.all([
page.waitForResponse(r => r.url().endsWith('/data?prefetch')),
page.waitForResponse(
r => r.url() === crossOrigin && r.request().method() === 'PUT',
),
]);
await page.goto(server.getRoute('/types.html'));
await done;
response.setIncludeNetworkRequests(true, {resourceTypes: ['preflight']});
let text = getTextContent((await response.handle(context)).content[0]);
assert.match(text, /OPTIONS \S+\/data\?cors/, text);
assert.doesNotMatch(text, /PUT|prefetch/, text);
response.setIncludeNetworkRequests(true, {resourceTypes: ['prefetch']});
text = getTextContent((await response.handle(context)).content[0]);
assert.match(text, /GET \S+\/data\?prefetch/, text);
assert.doesNotMatch(text, /OPTIONS|PUT/, text);
response.setIncludeNetworkRequests(true, {resourceTypes: ['fetch']});
text = getTextContent((await response.handle(context)).content[0]);
assert.match(text, /PUT \S+\/data\?cors/, text);
assert.doesNotMatch(text, /OPTIONS|prefetch/, text);
});
});
it('filters network requests by resource type', async t => {
await withMcpContext(async (response, context) => {
response.setIncludeNetworkRequests(true, {
+51
View File
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {getFilterableResourceType} from '../../src/utils/resourceType.js';
import {getMockRequest} from '../utils.js';
describe('getFilterableResourceType', () => {
it('keeps the type Chrome reported', () => {
assert.strictEqual(
getFilterableResourceType(getMockRequest({resourceType: 'fetch'})),
'fetch',
);
});
it('recognises a CORS preflight reported as other', () => {
const request = getMockRequest({
method: 'OPTIONS',
resourceType: 'other',
headers: {'access-control-request-method': 'PUT'},
});
assert.strictEqual(getFilterableResourceType(request), 'preflight');
});
it('keeps a plain OPTIONS request as other', () => {
const request = getMockRequest({
method: 'OPTIONS',
resourceType: 'other',
headers: {},
});
assert.strictEqual(getFilterableResourceType(request), 'other');
});
it('recognises a prefetch reported as other', () => {
const request = getMockRequest({
resourceType: 'other',
headers: {'sec-purpose': 'prefetch'},
});
assert.strictEqual(getFilterableResourceType(request), 'prefetch');
});
it('keeps an unrelated other request as other', () => {
const request = getMockRequest({resourceType: 'other', headers: {}});
assert.strictEqual(getFilterableResourceType(request), 'other');
});
});