Skip to content

Commit 0371b9e

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

8 files changed

Lines changed: 306 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: 55 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,13 +16,17 @@ 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 {
2124
#request: HTTPRequest;
2225
#options: NetworkFormatterOptions;
2326
#requestBody?: string;
2427
#responseBody?: string;
28+
#requestBodyFilePath?: string;
29+
#responseBodyFilePath?: string;
2530

2631
private constructor(
2732
request: HTTPRequest,
@@ -47,15 +52,28 @@ export class NetworkFormatter {
4752
if (this.#request.hasPostData()) {
4853
const data = this.#request.postData();
4954
if (data) {
50-
this.#requestBody = getSizeLimitedString(data, BODY_CONTEXT_SIZE_LIMIT);
55+
if (this.#options.requestFilePath) {
56+
await writeFile(this.#options.requestFilePath, data);
57+
this.#requestBodyFilePath = this.#options.requestFilePath;
58+
} else {
59+
this.#requestBody = getSizeLimitedString(
60+
data,
61+
BODY_CONTEXT_SIZE_LIMIT,
62+
);
63+
}
5164
} else {
5265
try {
5366
const fetchData = await this.#request.fetchPostData();
5467
if (fetchData) {
55-
this.#requestBody = getSizeLimitedString(
56-
fetchData,
57-
BODY_CONTEXT_SIZE_LIMIT,
58-
);
68+
if (this.#options.requestFilePath) {
69+
await writeFile(this.#options.requestFilePath, fetchData);
70+
this.#requestBodyFilePath = this.#options.requestFilePath;
71+
} else {
72+
this.#requestBody = getSizeLimitedString(
73+
fetchData,
74+
BODY_CONTEXT_SIZE_LIMIT,
75+
);
76+
}
5977
}
6078
} catch {
6179
this.#requestBody = '<not available anymore>';
@@ -66,10 +84,17 @@ export class NetworkFormatter {
6684
// Load Response Body
6785
const response = this.#request.response();
6886
if (response) {
69-
this.#responseBody = await this.#getFormattedResponseBody(
70-
response,
71-
BODY_CONTEXT_SIZE_LIMIT,
72-
);
87+
if (this.#options.responseFilePath) {
88+
this.#responseBodyFilePath = await this.#saveResponseBodyToFile(
89+
response,
90+
this.#options.responseFilePath,
91+
);
92+
} else {
93+
this.#responseBody = await this.#getFormattedResponseBody(
94+
response,
95+
BODY_CONTEXT_SIZE_LIMIT,
96+
);
97+
}
7398
}
7499
}
75100

@@ -90,6 +115,9 @@ export class NetworkFormatter {
90115
if (this.#requestBody) {
91116
response.push(`### Request Body`);
92117
response.push(this.#requestBody);
118+
} else if (this.#requestBodyFilePath) {
119+
response.push(`### Request Body`);
120+
response.push(`Saved to ${this.#requestBodyFilePath}.`);
93121
}
94122

95123
const httpResponse = this.#request.response();
@@ -105,6 +133,9 @@ export class NetworkFormatter {
105133
if (this.#responseBody) {
106134
response.push(`### Response Body`);
107135
response.push(this.#responseBody);
136+
} else if (this.#responseBodyFilePath) {
137+
response.push(`### Response Body`);
138+
response.push(`Saved to ${this.#responseBodyFilePath}.`);
108139
}
109140

110141
const httpFailure = this.#request.failure();
@@ -158,8 +189,10 @@ export class NetworkFormatter {
158189
...this.toJSON(),
159190
requestHeaders: this.#request.headers(),
160191
requestBody: this.#requestBody,
192+
requestBodyFilePath: this.#requestBodyFilePath,
161193
responseHeaders: this.#request.response()?.headers(),
162194
responseBody: this.#responseBody,
195+
responseBodyFilePath: this.#responseBodyFilePath,
163196
failure: this.#request.failure()?.errorText,
164197
redirectChain: formattedRedirectChain.length
165198
? formattedRedirectChain
@@ -215,6 +248,19 @@ export class NetworkFormatter {
215248
return '<not available anymore>';
216249
}
217250
}
251+
252+
async #saveResponseBodyToFile(
253+
httpResponse: HTTPResponse,
254+
filePath: string,
255+
): Promise<string> {
256+
try {
257+
const responseBuffer = await httpResponse.buffer();
258+
await writeFile(filePath, responseBuffer);
259+
return filePath;
260+
} catch {
261+
return '<not available anymore>';
262+
}
263+
}
218264
}
219265

220266
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
});

0 commit comments

Comments
 (0)