fix: stop get_network_request waiting forever for an unread no-store body (#3216)

Fixes #3213.

`get_network_request` reads the response body with Puppeteer's
`HTTPResponse.buffer()`. That waits for the response to finish loading,
with no timeout of its own, before it sends `Network.getResponseBody`.
Chrome never finishes loading a `Cache-Control: no-store` response that
the page doesn't read (e.g. `if (!res.ok) throw ...` on a 500), so the
call never returns. It runs under the tool mutex, which blocks every
later tool call on the server.

I reproduced this locally with Puppeteer and headless Chrome. For a POST
that returns 500 with `no-store` and that the page doesn't read,
`buffer()` was still pending after 8 s at body sizes of 100 B, 100 KB
and 5 MB. Without `no-store`, or when the page reads the body, it
resolves in ~3 ms.

The fix: `NetworkFormatter` now races the body read against
`RESPONSE_BODY_TIMEOUT_MS` (5 s; tests can lower it with a new
`responseBodyTimeout` option). On timeout, the response body shows as
`<Response body not available: the response did not finish loading
within 5000 ms>`, and the status, headers and the rest of the output are
returned as usual. With `responseFilePath`, nothing is saved and the
same message is shown instead of the generic "not available anymore".
Responses that finish loading behave exactly as before.

Tests:
- `McpResponse`: a real page POSTs to a `no-store` 500 route and ignores
the body. The attached request now returns its status and the timeout
message instead of hanging.
- `NetworkFormatter`: a response whose `buffer()` never settles gets the
message both inline and with `responseFilePath`, and `saveFile` is not
called.
- `npm run build`, `eslint .` and `prettier --check` are clean.
`McpResponse`, `NetworkFormatter`, `tools/network` and `McpContext`
tests: 186/186 pass.

One trade-off: a large body that takes longer than 5 s to finish
downloading also gets the message now instead of a long wait. I kept the
cap short because the tool mutex is held for the whole wait. I'm happy
to change the value.

_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-20 05:59:31 +00:00
1 parent f874aeb96a
commit c556be173e
2 files changed
+114 -7

No files matched your search

+51 -7
View File
@@ -14,6 +14,26 @@ import {
const BODY_CONTEXT_SIZE_LIMIT = 10000;
const URL_CONTEXT_SIZE_LIMIT = 255;
/**
* How long to wait for a response body. Puppeteer reads a body only after the
* response finished loading, with no timeout of its own, and a response that
* never finishes (e.g. a `Cache-Control: no-store` response the page did not
* read) would otherwise hold the tool mutex forever.
*/
export const RESPONSE_BODY_TIMEOUT_MS = 5000;
class ResponseBodyTimeoutError extends Error {
readonly timeout: number;
constructor(timeout: number) {
super(`Response body timed out after ${timeout} ms`);
this.timeout = timeout;
}
}
function responseBodyTimeoutMessage(error: ResponseBodyTimeoutError): string {
return `<Response body not available: the response did not finish loading within ${error.timeout} ms>`;
}
export interface NetworkFormatterOptions {
requestId?: number | string;
@@ -28,6 +48,8 @@ export interface NetworkFormatterOptions {
extension: '.network-request' | '.network-response',
) => Promise<{filename: string}>;
redactNetworkHeaders: boolean;
/** Defaults to `RESPONSE_BODY_TIMEOUT_MS`. */
responseBodyTimeout?: number;
}
interface NetworkRequestConcise {
@@ -116,9 +138,12 @@ export class NetworkFormatter {
if (this.#options.responseFilePath) {
let buffer: Uint8Array | undefined;
try {
buffer = await response.buffer();
} catch {
// Response body is not available anymore
buffer = await this.#readResponseBody(response);
} catch (error) {
this.#responseBody =
error instanceof ResponseBodyTimeoutError
? responseBodyTimeoutMessage(error)
: responseBodyNotAvailableMessage;
}
if (buffer) {
@@ -133,8 +158,6 @@ export class NetworkFormatter {
'.network-response',
);
this.#responseBodyFilePath = result.filename;
} else {
this.#responseBody = responseBodyNotAvailableMessage;
}
} else {
this.#responseBody = await this.#getFormattedResponseBody(
@@ -238,7 +261,7 @@ export class NetworkFormatter {
sizeLimit = BODY_CONTEXT_SIZE_LIMIT,
): Promise<string | undefined> {
try {
const responseBuffer = await httpResponse.buffer();
const responseBuffer = await this.#readResponseBody(httpResponse);
if (isUtf8(responseBuffer)) {
const responseAsTest = responseBuffer.toString('utf-8');
@@ -251,10 +274,31 @@ export class NetworkFormatter {
}
return '<binary data>';
} catch {
} catch (error) {
if (error instanceof ResponseBodyTimeoutError) {
return responseBodyTimeoutMessage(error);
}
return '<not available anymore>';
}
}
async #readResponseBody(httpResponse: HTTPResponse): Promise<Buffer> {
const timeout =
this.#options.responseBodyTimeout ?? RESPONSE_BODY_TIMEOUT_MS;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
httpResponse.buffer(),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new ResponseBodyTimeoutError(timeout));
}, timeout);
}),
]);
} finally {
clearTimeout(timeoutId);
}
}
}
function getSizeLimitedString(text: string, sizeLimit: number): string {
+63
View File
@@ -9,6 +9,8 @@ import {readFile, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
import {describe, it} from 'node:test';
import sinon from 'sinon';
import {NetworkFormatter} from '../../src/formatters/NetworkFormatter.js';
import type {HTTPRequest} from '../../src/third_party/index.js';
import {createTempDir, getMockRequest, getMockResponse} from '../utils.js';
@@ -449,6 +451,67 @@ describe('NetworkFormatter', () => {
assert.ok(result.includes(`Saved to ${resPath}.`));
});
it('stops waiting for a response body that never finishes loading', async () => {
const request = {
method: () => 'GET',
url: () => 'http://example.com',
headers: () => ({}),
hasPostData: () => false,
response: () => ({
status: () => 500,
headers: () => ({}),
buffer: () => new Promise<Buffer>(() => {}),
}),
failure: () => null,
redirectChain: () => [],
} as unknown as HTTPRequest;
const formatter = await NetworkFormatter.from(request, {
fetchData: true,
responseBodyTimeout: 10,
redactNetworkHeaders: false,
});
assert.strictEqual(
formatter.toJSONDetailed().responseBody,
'<Response body not available: the response did not finish loading within 10 ms>',
);
});
it('does not save a response body that never finishes loading', async () => {
const request = {
method: () => 'GET',
url: () => 'http://example.com',
headers: () => ({}),
hasPostData: () => false,
response: () => ({
status: () => 500,
headers: () => ({}),
buffer: () => new Promise<Buffer>(() => {}),
}),
failure: () => null,
redirectChain: () => [],
} as unknown as HTTPRequest;
const resPath = '/tmp/never-written';
const saveFile = sinon.stub().resolves({filename: resPath});
const formatter = await NetworkFormatter.from(request, {
fetchData: true,
responseFilePath: resPath,
responseBodyTimeout: 10,
saveFile,
redactNetworkHeaders: false,
});
const json = formatter.toJSONDetailed();
sinon.assert.notCalled(saveFile);
assert.strictEqual(json.responseBodyFilePath, undefined);
assert.strictEqual(
json.responseBody,
'<Response body not available: the response did not finish loading within 10 ms>',
);
});
it('handles missing bodies with filepath', async () => {
const request = {
method: () => 'POST',