Skip to content

Commit c8f5157

Browse files
committed
feat: support filePath for network request and response bodies
1 parent 9b21f8b commit c8f5157

8 files changed

Lines changed: 221 additions & 14 deletions

File tree

docs/tool-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@
258258
**Parameters:**
259259

260260
- **reqid** (number) _(optional)_: The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.
261+
- **requestFilePath** (string) _(optional)_: The absolute path to save the request body to. If omitted, the body is returned inline.
262+
- **responseFilePath** (string) _(optional)_: The absolute path to save the response body to. If omitted, the body is returned inline.
261263

262264
---
263265

src/McpResponse.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export class McpResponse implements Response {
3434
#includePages = false;
3535
#snapshotParams?: SnapshotParams;
3636
#attachedNetworkRequestId?: number;
37+
#attachedNetworkRequestOptions?: {
38+
requestFilePath?: string;
39+
responseFilePath?: string;
40+
};
3741
#attachedConsoleMessageId?: number;
3842
#textResponseLines: string[] = [];
3943
#images: ImageContentData[] = [];
@@ -125,8 +129,12 @@ export class McpResponse implements Response {
125129
};
126130
}
127131

128-
attachNetworkRequest(reqid: number): void {
132+
attachNetworkRequest(
133+
reqid: number,
134+
options?: {requestFilePath?: string; responseFilePath?: string},
135+
): void {
129136
this.#attachedNetworkRequestId = reqid;
137+
this.#attachedNetworkRequestOptions = options;
130138
}
131139

132140
attachConsoleMessage(msgid: number): void {
@@ -218,6 +226,8 @@ export class McpResponse implements Response {
218226
requestId: this.#attachedNetworkRequestId,
219227
requestIdResolver: req => context.getNetworkRequestStableId(req),
220228
fetchData: true,
229+
requestFilePath: this.#attachedNetworkRequestOptions?.requestFilePath,
230+
responseFilePath: this.#attachedNetworkRequestOptions?.responseFilePath,
221231
});
222232
detailedNetworkRequest = formatter;
223233
}

src/formatters/NetworkFormatter.ts

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

77
import {isUtf8} from 'node:buffer';
8+
import {writeFile} from 'node:fs/promises';
89

910
import type {HTTPRequest, HTTPResponse} from '../third_party/index.js';
1011

@@ -15,6 +16,8 @@ export interface NetworkFormatterOptions {
1516
selectedInDevToolsUI?: boolean;
1617
requestIdResolver?: (request: HTTPRequest) => number | string;
1718
fetchData?: boolean;
19+
requestFilePath?: string;
20+
responseFilePath?: string;
1821
}
1922

2023
export class NetworkFormatter {
@@ -47,15 +50,28 @@ export class NetworkFormatter {
4750
if (this.#request.hasPostData()) {
4851
const data = this.#request.postData();
4952
if (data) {
50-
this.#requestBody = getSizeLimitedString(data, BODY_CONTEXT_SIZE_LIMIT);
53+
if (this.#options.requestFilePath) {
54+
await writeFile(this.#options.requestFilePath, data);
55+
this.#requestBody = this.#options.requestFilePath;
56+
} else {
57+
this.#requestBody = getSizeLimitedString(
58+
data,
59+
BODY_CONTEXT_SIZE_LIMIT,
60+
);
61+
}
5162
} else {
5263
try {
5364
const fetchData = await this.#request.fetchPostData();
5465
if (fetchData) {
55-
this.#requestBody = getSizeLimitedString(
56-
fetchData,
57-
BODY_CONTEXT_SIZE_LIMIT,
58-
);
66+
if (this.#options.requestFilePath) {
67+
await writeFile(this.#options.requestFilePath, fetchData);
68+
this.#requestBody = this.#options.requestFilePath;
69+
} else {
70+
this.#requestBody = getSizeLimitedString(
71+
fetchData,
72+
BODY_CONTEXT_SIZE_LIMIT,
73+
);
74+
}
5975
}
6076
} catch {
6177
this.#requestBody = '<not available anymore>';
@@ -66,10 +82,17 @@ export class NetworkFormatter {
6682
// Load Response Body
6783
const response = this.#request.response();
6884
if (response) {
69-
this.#responseBody = await this.#getFormattedResponseBody(
70-
response,
71-
BODY_CONTEXT_SIZE_LIMIT,
72-
);
85+
if (this.#options.responseFilePath) {
86+
this.#responseBody = await this.#saveResponseBodyToFile(
87+
response,
88+
this.#options.responseFilePath,
89+
);
90+
} else {
91+
this.#responseBody = await this.#getFormattedResponseBody(
92+
response,
93+
BODY_CONTEXT_SIZE_LIMIT,
94+
);
95+
}
7396
}
7497
}
7598

@@ -215,6 +238,19 @@ export class NetworkFormatter {
215238
return '<not available anymore>';
216239
}
217240
}
241+
242+
async #saveResponseBodyToFile(
243+
httpResponse: HTTPResponse,
244+
filePath: string,
245+
): Promise<string> {
246+
try {
247+
const responseBuffer = await httpResponse.buffer();
248+
await writeFile(filePath, responseBuffer);
249+
return filePath;
250+
} catch {
251+
return '<not available anymore>';
252+
}
253+
}
218254
}
219255

220256
function getSizeLimitedString(text: string, sizeLimit: number) {

src/tools/ToolDefinition.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ export interface Response {
7373
): void;
7474
includeSnapshot(params?: SnapshotParams): void;
7575
attachImage(value: ImageContentData): void;
76-
attachNetworkRequest(reqid: number): void;
76+
attachNetworkRequest(
77+
reqid: number,
78+
options?: {requestFilePath?: string; responseFilePath?: string},
79+
): void;
7780
attachConsoleMessage(msgid: number): void;
7881
// Allows re-using DevTools data queried by some tools.
7982
attachDevToolsData(data: DevToolsData): void;

src/tools/network.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export const getNetworkRequest = defineTool({
9191
description: `Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.`,
9292
annotations: {
9393
category: ToolCategory.NETWORK,
94-
readOnlyHint: true,
94+
readOnlyHint: false,
9595
},
9696
schema: {
9797
reqid: zod
@@ -100,18 +100,36 @@ export const getNetworkRequest = defineTool({
100100
.describe(
101101
'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
102102
),
103+
requestFilePath: zod
104+
.string()
105+
.optional()
106+
.describe(
107+
'The absolute path to save the request body to. If omitted, the body is returned inline.',
108+
),
109+
responseFilePath: zod
110+
.string()
111+
.optional()
112+
.describe(
113+
'The absolute path to save the response body to. If omitted, the body is returned inline.',
114+
),
103115
},
104116
handler: async (request, response, context) => {
105117
if (request.params.reqid) {
106-
response.attachNetworkRequest(request.params.reqid);
118+
response.attachNetworkRequest(request.params.reqid, {
119+
requestFilePath: request.params.requestFilePath,
120+
responseFilePath: request.params.responseFilePath,
121+
});
107122
} else {
108123
const data = await context.getDevToolsData();
109124
response.attachDevToolsData(data);
110125
const reqid = data?.cdpRequestId
111126
? context.resolveCdpRequestId(data.cdpRequestId)
112127
: undefined;
113128
if (reqid) {
114-
response.attachNetworkRequest(reqid);
129+
response.attachNetworkRequest(reqid, {
130+
requestFilePath: request.params.requestFilePath,
131+
responseFilePath: request.params.responseFilePath,
132+
});
115133
} else {
116134
response.appendResponseLine(
117135
`Nothing is currently selected in the DevTools Network panel.`,

tests/McpContext.test.js.snapshot

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ exports[`McpContext > should include detailed network request in structured cont
1212
}
1313
`;
1414

15+
exports[`McpContext > should include file paths in structured content when saving to file 1`] = `
16+
{
17+
"networkRequest": {
18+
"requestBody": "/tmp/req.txt",
19+
"responseBody": "/tmp/res.txt"
20+
}
21+
}
22+
`;
23+
1524
exports[`McpContext > should include network requests in structured content 1`] = `
1625
{
1726
"networkRequests": [

tests/McpContext.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {describe, it} from 'node:test';
99

1010
import sinon from 'sinon';
1111

12+
import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js';
13+
import type {HTTPResponse} from '../src/third_party/index.js';
1214
import type {TraceResult} from '../src/trace-processing/parse.js';
1315

1416
import {getMockRequest, html, withMcpContext} from './utils.js';
@@ -134,4 +136,53 @@ describe('McpContext', () => {
134136
t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2));
135137
});
136138
});
139+
140+
it('should include file paths in structured content when saving to file', async t => {
141+
await withMcpContext(async (response, context) => {
142+
const mockRequest = getMockRequest({
143+
url: 'http://example.com/file-save',
144+
stableId: 789,
145+
hasPostData: true,
146+
postData: 'some detailed data',
147+
response: {
148+
status: () => 200,
149+
headers: () => ({'content-type': 'text/plain'}),
150+
buffer: async () => Buffer.from('some response data'),
151+
} as unknown as HTTPResponse,
152+
});
153+
154+
sinon.stub(context, 'getNetworkRequestById').returns(mockRequest);
155+
sinon.stub(context, 'getNetworkRequestStableId').returns(789);
156+
157+
// We stub NetworkFormatter.from to avoid actual file system writes and verify arguments
158+
const fromStub = sinon
159+
.stub(NetworkFormatter, 'from')
160+
.callsFake(async (_req, opts) => {
161+
// Verify we received the file paths
162+
assert.strictEqual(opts?.requestFilePath, '/tmp/req.txt');
163+
assert.strictEqual(opts?.responseFilePath, '/tmp/res.txt');
164+
// Return a dummy formatter that behaves as if it saved files
165+
// We need to create a real instance or mock one.
166+
// Since constructor is private, we can't easily new it up.
167+
// But we can return a mock object.
168+
return {
169+
toStringDetailed: () => 'Detailed string',
170+
toJSONDetailed: () => ({
171+
requestBody: '/tmp/req.txt',
172+
responseBody: '/tmp/res.txt',
173+
}),
174+
} as unknown as NetworkFormatter;
175+
});
176+
177+
response.attachNetworkRequest(789, {
178+
requestFilePath: '/tmp/req.txt',
179+
responseFilePath: '/tmp/res.txt',
180+
});
181+
const result = await response.handle('test', context);
182+
183+
t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2));
184+
185+
fromStub.restore();
186+
});
187+
});
137188
});

tests/formatters/NetworkFormatter.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import assert from 'node:assert';
88
import {describe, it} from 'node:test';
99

1010
import {NetworkFormatter} from '../../src/formatters/NetworkFormatter.js';
11+
import type {HTTPRequest} from '../../src/third_party/index.js';
1112
import {getMockRequest, getMockResponse} from '../utils.js';
1213

1314
describe('NetworkFormatter', () => {
@@ -145,6 +146,83 @@ describe('NetworkFormatter', () => {
145146
assert.match(result, /some text/);
146147
});
147148

149+
it('should save bodies to file when file paths are provided', async () => {
150+
const request = {
151+
method: () => 'POST',
152+
url: () => 'http://example.com',
153+
headers: () => ({}),
154+
hasPostData: () => true,
155+
postData: () => 'request body',
156+
response: () => ({
157+
status: () => 200,
158+
headers: () => ({}),
159+
buffer: async () => Buffer.from('response body'),
160+
}),
161+
failure: () => null,
162+
redirectChain: () => [],
163+
fetchPostData: async () => undefined,
164+
} as unknown as HTTPRequest;
165+
166+
// Let's create a temporary file path
167+
// We are just verifying logic flow here, actual FS write might fail if we don't have permissions or path is weird,
168+
// but /tmp should be fine on the system.
169+
// However, to be safe and avoid flakiness, we might want to mock writeFile.
170+
// But since we can't easily mock it, we try to use real files.
171+
const reqPath = '/tmp/test_req_' + Date.now();
172+
const resPath = '/tmp/test_res_' + Date.now();
173+
174+
const formatter = await NetworkFormatter.from(request, {
175+
fetchData: true,
176+
requestFilePath: reqPath,
177+
responseFilePath: resPath,
178+
});
179+
180+
const json = formatter.toJSONDetailed() as {
181+
requestBody: string;
182+
responseBody: string;
183+
};
184+
assert.strictEqual(json.requestBody, reqPath);
185+
assert.strictEqual(json.responseBody, resPath);
186+
});
187+
188+
it('should not truncate large bodies when saving to file', async () => {
189+
const largeBody = 'a'.repeat(10005);
190+
const request = {
191+
method: () => 'POST',
192+
url: () => 'http://example.com',
193+
headers: () => ({}),
194+
hasPostData: () => true,
195+
postData: () => largeBody,
196+
response: () => ({
197+
status: () => 200,
198+
headers: () => ({}),
199+
buffer: async () => Buffer.from(largeBody),
200+
}),
201+
failure: () => null,
202+
redirectChain: () => [],
203+
fetchPostData: async () => undefined,
204+
} as unknown as HTTPRequest;
205+
206+
const reqPath = '/tmp/test_req_large_' + Date.now();
207+
const resPath = '/tmp/test_res_large_' + Date.now();
208+
209+
await NetworkFormatter.from(request, {
210+
fetchData: true,
211+
requestFilePath: reqPath,
212+
responseFilePath: resPath,
213+
});
214+
215+
// We need to verify the file content.
216+
// Since we import fs/promises in the source, we can try to use node:fs/promises here too.
217+
// Dynamic import to avoid top-level import issues if any (though we stick to standard imports).
218+
const {readFile} = await import('node:fs/promises');
219+
const reqContent = await readFile(reqPath, 'utf8');
220+
const resContent = await readFile(resPath, 'utf8');
221+
222+
assert.strictEqual(reqContent, largeBody);
223+
assert.strictEqual(resContent, largeBody);
224+
});
225+
148226
it('handles response body', async () => {
149227
const response = getMockResponse();
150228
response.buffer = () => {

0 commit comments

Comments
 (0)