fix(network): keep redirect chain order consistent between text and JSON (#2221)

## Problem

`get_network_request` returns, from the **same call**, both a
human-readable
text block (`toStringDetailed()`) and a
`structuredContent.networkRequest`
object (`toJSONDetailed()`) — emitted together in `McpResponse.ts`. For
a
request that went through HTTP redirects, the redirect chain comes out
in
**opposite orders** in the two representations:

- `toJSONDetailed()` (`NetworkFormatter.ts`) reverses `redirectChain()`
once →
  newest→oldest in the JSON.
- the text formatter then reverses it **a second time** → oldest→newest
in the
  text.

Because each path calls `redirectChain()` separately and Puppeteer
returns a
fresh copy on every call (`HTTPRequest#redirectChain()` does
`this._redirectChain.slice()`), the two reverses operate on different
arrays and
don't cancel. So a consumer reading the text and a consumer parsing the
structured JSON from the same response see contradictory redirect
orders.

## Solution

Drop the redundant `.reverse()` in the text formatter so the rendered
text uses
the order already produced by `toJSONDetailed()`. Both representations
are now
consistent (newest→oldest), and `structuredContent` is unchanged.

## Why the existing tests didn't catch it

- The existing "handles redirect chain" test uses a **single-element**
chain,
  where reversing is a no-op.
- `getMockRequest().redirectChain()` returned the **same array
reference** on
every call, unlike real Puppeteer — so the two reverses accidentally
agreed in
tests. This PR makes the mock return a fresh copy per call (matching
Puppeteer)
and adds a regression test with a multi-element chain that asserts the
text and
  JSON orders match.

## Testing

- `npm test` for the formatter suite passes. New test
`renders the redirect chain in the same order in text and JSON` is
**red**
before the fix (text `[first, second]` vs JSON `[second, first]`) and
**green**
  after, with no change to existing snapshots.
- `npm run typecheck` and Prettier/ESLint are clean.

No existing issue tracked this; found via code inspection and confirmed
empirically.
This commit is contained in:
nyxst4ck
2026-06-18 14:18:14 -03:00
committed by GitHub
parent 789f5f8e53
commit 5a9d6af743
3 changed files with 39 additions and 2 deletions
+3 -1
View File
@@ -314,7 +314,9 @@ function converNetworkRequestDetailedToStringDetailed(
if (redirectChain?.length) {
response.push(`### Redirect chain`);
let indent = 0;
for (const request of redirectChain.reverse()) {
// `redirectChain` is already ordered by toJSONDetailed(); don't reverse it
// again here or the text output contradicts structuredContent (the JSON).
for (const request of redirectChain) {
response.push(
`${' '.repeat(indent)}${convertNetworkRequestConciseToString(request)}`,
);
+32
View File
@@ -307,6 +307,38 @@ describe('NetworkFormatter', () => {
const result = formatter.toStringDetailed();
t.assert.snapshot(result);
});
it('renders the redirect chain in the same order in text and JSON', async () => {
// A >=2 element chain makes ordering observable (a single redirect hides
// the bug). toStringDetailed() and toJSONDetailed() are emitted from the
// same get_network_request call, so they must agree on the order.
const first = getMockRequest({url: 'http://example.com/first'});
const second = getMockRequest({url: 'http://example.com/second'});
const request = getMockRequest({
url: 'http://example.com/final',
redirectChain: [first, second],
});
const formatter = await NetworkFormatter.from(request, {
requestId: 1,
requestIdResolver: () => 2,
saveFile: async () => ({filename: ''}),
redactNetworkHeaders: false,
});
const text = formatter.toStringDetailed();
const json = formatter.toJSONDetailed();
const textOrder = [
...text.matchAll(/http:\/\/example\.com\/(first|second)/g),
].map(m => m[0]);
const jsonOrder = (json.redirectChain ?? []).map(entry => entry.url);
assert.deepStrictEqual(
textOrder,
jsonOrder,
`redirect chain order differs between text (${JSON.stringify(textOrder)}) ` +
`and JSON (${JSON.stringify(jsonOrder)})`,
);
});
it('shows saved to file message in toStringDetailed', async () => {
const request = {
method: () => 'POST',
+4 -1
View File
@@ -200,7 +200,10 @@ export function getMockRequest(
);
},
redirectChain(): HTTPRequest[] {
return options.redirectChain ?? [];
// Puppeteer returns a fresh copy on every call (HTTPRequest returns
// `this._redirectChain.slice()`); mirror that so formatters can't share
// and accidentally mutate the same array across calls.
return [...(options.redirectChain ?? [])];
},
isNavigationRequest() {
return options.navigationRequest ?? false;