Skip to content

Commit 9d944a3

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

File tree

8 files changed

+383
-26
lines changed

8 files changed

+383
-26
lines changed

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 or relative path to save the request body to. If omitted, the body is returned inline.
262+
- **responseFilePath** (string) _(optional)_: The absolute or relative path to save the response body to. If omitted, the body is returned inline.
261263

262264
---
263265

src/McpResponse.ts

Lines changed: 13 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,9 @@ 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,
231+
saveFile: (data, filename) => context.saveFile(data, filename),
221232
});
222233
detailedNetworkRequest = formatter;
223234
}
@@ -361,6 +372,7 @@ export class McpResponse implements Response {
361372
context.getNetworkRequestStableId(request) ===
362373
this.#networkRequestsOptions?.networkRequestIdInDevToolsUI,
363374
fetchData: false,
375+
saveFile: (data, filename) => context.saveFile(data, filename),
364376
}),
365377
),
366378
);

src/formatters/NetworkFormatter.ts

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,30 @@ export interface NetworkFormatterOptions {
1515
selectedInDevToolsUI?: boolean;
1616
requestIdResolver?: (request: HTTPRequest) => number | string;
1717
fetchData?: boolean;
18+
requestFilePath?: string;
19+
responseFilePath?: string;
20+
saveFile?: (
21+
data: Uint8Array<ArrayBufferLike>,
22+
filename: string,
23+
) => Promise<{filename: string}>;
1824
}
1925

2026
export class NetworkFormatter {
2127
#request: HTTPRequest;
2228
#options: NetworkFormatterOptions;
2329
#requestBody?: string;
2430
#responseBody?: string;
31+
#requestBodyFilePath?: string;
32+
#responseBodyFilePath?: string;
2533

26-
private constructor(
27-
request: HTTPRequest,
28-
options: NetworkFormatterOptions = {},
29-
) {
34+
private constructor(request: HTTPRequest, options: NetworkFormatterOptions) {
3035
this.#request = request;
3136
this.#options = options;
3237
}
3338

3439
static async from(
3540
request: HTTPRequest,
36-
options: NetworkFormatterOptions = {},
41+
options: NetworkFormatterOptions,
3742
): Promise<NetworkFormatter> {
3843
const instance = new NetworkFormatter(request, options);
3944
if (options.fetchData) {
@@ -47,15 +52,40 @@ 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+
if (!this.#options.saveFile) {
57+
throw new Error('saveFile is not provided');
58+
}
59+
await this.#options.saveFile(
60+
Buffer.from(data),
61+
this.#options.requestFilePath,
62+
);
63+
this.#requestBodyFilePath = this.#options.requestFilePath;
64+
} else {
65+
this.#requestBody = getSizeLimitedString(
66+
data,
67+
BODY_CONTEXT_SIZE_LIMIT,
68+
);
69+
}
5170
} else {
5271
try {
5372
const fetchData = await this.#request.fetchPostData();
5473
if (fetchData) {
55-
this.#requestBody = getSizeLimitedString(
56-
fetchData,
57-
BODY_CONTEXT_SIZE_LIMIT,
58-
);
74+
if (this.#options.requestFilePath) {
75+
if (!this.#options.saveFile) {
76+
throw new Error('saveFile is not provided');
77+
}
78+
await this.#options.saveFile(
79+
Buffer.from(fetchData),
80+
this.#options.requestFilePath,
81+
);
82+
this.#requestBodyFilePath = this.#options.requestFilePath;
83+
} else {
84+
this.#requestBody = getSizeLimitedString(
85+
fetchData,
86+
BODY_CONTEXT_SIZE_LIMIT,
87+
);
88+
}
5989
}
6090
} catch {
6191
this.#requestBody = '<not available anymore>';
@@ -66,10 +96,17 @@ export class NetworkFormatter {
6696
// Load Response Body
6797
const response = this.#request.response();
6898
if (response) {
69-
this.#responseBody = await this.#getFormattedResponseBody(
70-
response,
71-
BODY_CONTEXT_SIZE_LIMIT,
72-
);
99+
if (this.#options.responseFilePath) {
100+
this.#responseBodyFilePath = await this.#saveResponseBodyToFile(
101+
response,
102+
this.#options.responseFilePath,
103+
);
104+
} else {
105+
this.#responseBody = await this.#getFormattedResponseBody(
106+
response,
107+
BODY_CONTEXT_SIZE_LIMIT,
108+
);
109+
}
73110
}
74111
}
75112

@@ -90,6 +127,9 @@ export class NetworkFormatter {
90127
if (this.#requestBody) {
91128
response.push(`### Request Body`);
92129
response.push(this.#requestBody);
130+
} else if (this.#requestBodyFilePath) {
131+
response.push(`### Request Body`);
132+
response.push(`Saved to ${this.#requestBodyFilePath}.`);
93133
}
94134

95135
const httpResponse = this.#request.response();
@@ -105,6 +145,9 @@ export class NetworkFormatter {
105145
if (this.#responseBody) {
106146
response.push(`### Response Body`);
107147
response.push(this.#responseBody);
148+
} else if (this.#responseBodyFilePath) {
149+
response.push(`### Response Body`);
150+
response.push(`Saved to ${this.#responseBodyFilePath}.`);
108151
}
109152

110153
const httpFailure = this.#request.failure();
@@ -124,6 +167,7 @@ export class NetworkFormatter {
124167
// We create a temporary synchronous instance just for toString
125168
const formatter = new NetworkFormatter(request, {
126169
requestId: id,
170+
saveFile: this.#options.saveFile,
127171
});
128172
response.push(`${' '.repeat(indent)}${formatter.toString()}`);
129173
indent++;
@@ -150,6 +194,7 @@ export class NetworkFormatter {
150194
: undefined;
151195
const formatter = new NetworkFormatter(request, {
152196
requestId: id,
197+
saveFile: this.#options.saveFile,
153198
});
154199
return formatter.toJSON();
155200
});
@@ -158,8 +203,10 @@ export class NetworkFormatter {
158203
...this.toJSON(),
159204
requestHeaders: this.#request.headers(),
160205
requestBody: this.#requestBody,
206+
requestBodyFilePath: this.#requestBodyFilePath,
161207
responseHeaders: this.#request.response()?.headers(),
162208
responseBody: this.#responseBody,
209+
responseBodyFilePath: this.#responseBodyFilePath,
163210
failure: this.#request.failure()?.errorText,
164211
redirectChain: formattedRedirectChain.length
165212
? formattedRedirectChain
@@ -215,6 +262,22 @@ export class NetworkFormatter {
215262
return '<not available anymore>';
216263
}
217264
}
265+
266+
async #saveResponseBodyToFile(
267+
httpResponse: HTTPResponse,
268+
filePath: string,
269+
): Promise<string> {
270+
try {
271+
const responseBuffer = await httpResponse.buffer();
272+
if (!this.#options.saveFile) {
273+
throw new Error('saveFile is not provided');
274+
}
275+
await this.#options.saveFile(responseBuffer, filePath);
276+
return filePath;
277+
} catch {
278+
return '<not available anymore>';
279+
}
280+
}
218281
}
219282

220283
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 or relative 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 or relative 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)