diff --git a/docs/tool-reference.md b/docs/tool-reference.md index d363c5ab8..830644ca3 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -258,6 +258,8 @@ **Parameters:** - **reqid** (number) _(optional)_: The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel. +- **requestFilePath** (string) _(optional)_: The absolute or relative path to save the request body to. If omitted, the body is returned inline. +- **responseFilePath** (string) _(optional)_: The absolute or relative path to save the response body to. If omitted, the body is returned inline. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 17a8c098e..7bd56fa86 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -34,6 +34,10 @@ export class McpResponse implements Response { #includePages = false; #snapshotParams?: SnapshotParams; #attachedNetworkRequestId?: number; + #attachedNetworkRequestOptions?: { + requestFilePath?: string; + responseFilePath?: string; + }; #attachedConsoleMessageId?: number; #textResponseLines: string[] = []; #images: ImageContentData[] = []; @@ -125,8 +129,12 @@ export class McpResponse implements Response { }; } - attachNetworkRequest(reqid: number): void { + attachNetworkRequest( + reqid: number, + options?: {requestFilePath?: string; responseFilePath?: string}, + ): void { this.#attachedNetworkRequestId = reqid; + this.#attachedNetworkRequestOptions = options; } attachConsoleMessage(msgid: number): void { @@ -218,6 +226,9 @@ export class McpResponse implements Response { requestId: this.#attachedNetworkRequestId, requestIdResolver: req => context.getNetworkRequestStableId(req), fetchData: true, + requestFilePath: this.#attachedNetworkRequestOptions?.requestFilePath, + responseFilePath: this.#attachedNetworkRequestOptions?.responseFilePath, + saveFile: (data, filename) => context.saveFile(data, filename), }); detailedNetworkRequest = formatter; } @@ -361,6 +372,7 @@ export class McpResponse implements Response { context.getNetworkRequestStableId(request) === this.#networkRequestsOptions?.networkRequestIdInDevToolsUI, fetchData: false, + saveFile: (data, filename) => context.saveFile(data, filename), }), ), ); diff --git a/src/formatters/NetworkFormatter.ts b/src/formatters/NetworkFormatter.ts index 001a5fec5..7302cd4e2 100644 --- a/src/formatters/NetworkFormatter.ts +++ b/src/formatters/NetworkFormatter.ts @@ -15,6 +15,12 @@ export interface NetworkFormatterOptions { selectedInDevToolsUI?: boolean; requestIdResolver?: (request: HTTPRequest) => number | string; fetchData?: boolean; + requestFilePath?: string; + responseFilePath?: string; + saveFile?: ( + data: Uint8Array, + filename: string, + ) => Promise<{filename: string}>; } export class NetworkFormatter { @@ -22,18 +28,17 @@ export class NetworkFormatter { #options: NetworkFormatterOptions; #requestBody?: string; #responseBody?: string; + #requestBodyFilePath?: string; + #responseBodyFilePath?: string; - private constructor( - request: HTTPRequest, - options: NetworkFormatterOptions = {}, - ) { + private constructor(request: HTTPRequest, options: NetworkFormatterOptions) { this.#request = request; this.#options = options; } static async from( request: HTTPRequest, - options: NetworkFormatterOptions = {}, + options: NetworkFormatterOptions, ): Promise { const instance = new NetworkFormatter(request, options); if (options.fetchData) { @@ -47,15 +52,40 @@ export class NetworkFormatter { if (this.#request.hasPostData()) { const data = this.#request.postData(); if (data) { - this.#requestBody = getSizeLimitedString(data, BODY_CONTEXT_SIZE_LIMIT); + if (this.#options.requestFilePath) { + if (!this.#options.saveFile) { + throw new Error('saveFile is not provided'); + } + await this.#options.saveFile( + Buffer.from(data), + this.#options.requestFilePath, + ); + this.#requestBodyFilePath = this.#options.requestFilePath; + } else { + this.#requestBody = getSizeLimitedString( + data, + BODY_CONTEXT_SIZE_LIMIT, + ); + } } else { try { const fetchData = await this.#request.fetchPostData(); if (fetchData) { - this.#requestBody = getSizeLimitedString( - fetchData, - BODY_CONTEXT_SIZE_LIMIT, - ); + if (this.#options.requestFilePath) { + if (!this.#options.saveFile) { + throw new Error('saveFile is not provided'); + } + await this.#options.saveFile( + Buffer.from(fetchData), + this.#options.requestFilePath, + ); + this.#requestBodyFilePath = this.#options.requestFilePath; + } else { + this.#requestBody = getSizeLimitedString( + fetchData, + BODY_CONTEXT_SIZE_LIMIT, + ); + } } } catch { this.#requestBody = ''; @@ -66,10 +96,17 @@ export class NetworkFormatter { // Load Response Body const response = this.#request.response(); if (response) { - this.#responseBody = await this.#getFormattedResponseBody( - response, - BODY_CONTEXT_SIZE_LIMIT, - ); + if (this.#options.responseFilePath) { + this.#responseBodyFilePath = await this.#saveResponseBodyToFile( + response, + this.#options.responseFilePath, + ); + } else { + this.#responseBody = await this.#getFormattedResponseBody( + response, + BODY_CONTEXT_SIZE_LIMIT, + ); + } } } @@ -90,6 +127,9 @@ export class NetworkFormatter { if (this.#requestBody) { response.push(`### Request Body`); response.push(this.#requestBody); + } else if (this.#requestBodyFilePath) { + response.push(`### Request Body`); + response.push(`Saved to ${this.#requestBodyFilePath}.`); } const httpResponse = this.#request.response(); @@ -105,6 +145,9 @@ export class NetworkFormatter { if (this.#responseBody) { response.push(`### Response Body`); response.push(this.#responseBody); + } else if (this.#responseBodyFilePath) { + response.push(`### Response Body`); + response.push(`Saved to ${this.#responseBodyFilePath}.`); } const httpFailure = this.#request.failure(); @@ -124,6 +167,7 @@ export class NetworkFormatter { // We create a temporary synchronous instance just for toString const formatter = new NetworkFormatter(request, { requestId: id, + saveFile: this.#options.saveFile, }); response.push(`${' '.repeat(indent)}${formatter.toString()}`); indent++; @@ -150,6 +194,7 @@ export class NetworkFormatter { : undefined; const formatter = new NetworkFormatter(request, { requestId: id, + saveFile: this.#options.saveFile, }); return formatter.toJSON(); }); @@ -158,8 +203,10 @@ export class NetworkFormatter { ...this.toJSON(), requestHeaders: this.#request.headers(), requestBody: this.#requestBody, + requestBodyFilePath: this.#requestBodyFilePath, responseHeaders: this.#request.response()?.headers(), responseBody: this.#responseBody, + responseBodyFilePath: this.#responseBodyFilePath, failure: this.#request.failure()?.errorText, redirectChain: formattedRedirectChain.length ? formattedRedirectChain @@ -215,6 +262,22 @@ export class NetworkFormatter { return ''; } } + + async #saveResponseBodyToFile( + httpResponse: HTTPResponse, + filePath: string, + ): Promise { + try { + const responseBuffer = await httpResponse.buffer(); + if (!this.#options.saveFile) { + throw new Error('saveFile is not provided'); + } + await this.#options.saveFile(responseBuffer, filePath); + return filePath; + } catch { + return ''; + } + } } function getSizeLimitedString(text: string, sizeLimit: number) { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index f2b699021..ddb3d5f7d 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -73,7 +73,10 @@ export interface Response { ): void; includeSnapshot(params?: SnapshotParams): void; attachImage(value: ImageContentData): void; - attachNetworkRequest(reqid: number): void; + attachNetworkRequest( + reqid: number, + options?: {requestFilePath?: string; responseFilePath?: string}, + ): void; attachConsoleMessage(msgid: number): void; // Allows re-using DevTools data queried by some tools. attachDevToolsData(data: DevToolsData): void; diff --git a/src/tools/network.ts b/src/tools/network.ts index d3b6c1fe6..9a1d9da7c 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -91,7 +91,7 @@ export const getNetworkRequest = defineTool({ description: `Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.`, annotations: { category: ToolCategory.NETWORK, - readOnlyHint: true, + readOnlyHint: false, }, schema: { reqid: zod @@ -100,10 +100,25 @@ export const getNetworkRequest = defineTool({ .describe( 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.', ), + requestFilePath: zod + .string() + .optional() + .describe( + 'The absolute or relative path to save the request body to. If omitted, the body is returned inline.', + ), + responseFilePath: zod + .string() + .optional() + .describe( + 'The absolute or relative path to save the response body to. If omitted, the body is returned inline.', + ), }, handler: async (request, response, context) => { if (request.params.reqid) { - response.attachNetworkRequest(request.params.reqid); + response.attachNetworkRequest(request.params.reqid, { + requestFilePath: request.params.requestFilePath, + responseFilePath: request.params.responseFilePath, + }); } else { const data = await context.getDevToolsData(); response.attachDevToolsData(data); @@ -111,7 +126,10 @@ export const getNetworkRequest = defineTool({ ? context.resolveCdpRequestId(data.cdpRequestId) : undefined; if (reqid) { - response.attachNetworkRequest(reqid); + response.attachNetworkRequest(reqid, { + requestFilePath: request.params.requestFilePath, + responseFilePath: request.params.responseFilePath, + }); } else { response.appendResponseLine( `Nothing is currently selected in the DevTools Network panel.`, diff --git a/tests/McpContext.test.js.snapshot b/tests/McpContext.test.js.snapshot index 1d3fe137b..7c8928def 100644 --- a/tests/McpContext.test.js.snapshot +++ b/tests/McpContext.test.js.snapshot @@ -12,6 +12,15 @@ exports[`McpContext > should include detailed network request in structured cont } `; +exports[`McpContext > should include file paths in structured content when saving to file 1`] = ` +{ + "networkRequest": { + "requestBody": "/tmp/req.txt", + "responseBody": "/tmp/res.txt" + } +} +`; + exports[`McpContext > should include network requests in structured content 1`] = ` { "networkRequests": [ diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index cd143f000..415af6313 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -9,6 +9,8 @@ import {describe, it} from 'node:test'; import sinon from 'sinon'; +import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js'; +import type {HTTPResponse} from '../src/third_party/index.js'; import type {TraceResult} from '../src/trace-processing/parse.js'; import {getMockRequest, html, withMcpContext} from './utils.js'; @@ -134,4 +136,53 @@ describe('McpContext', () => { t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); }); }); + + it('should include file paths in structured content when saving to file', async t => { + await withMcpContext(async (response, context) => { + const mockRequest = getMockRequest({ + url: 'http://example.com/file-save', + stableId: 789, + hasPostData: true, + postData: 'some detailed data', + response: { + status: () => 200, + headers: () => ({'content-type': 'text/plain'}), + buffer: async () => Buffer.from('some response data'), + } as unknown as HTTPResponse, + }); + + sinon.stub(context, 'getNetworkRequestById').returns(mockRequest); + sinon.stub(context, 'getNetworkRequestStableId').returns(789); + + // We stub NetworkFormatter.from to avoid actual file system writes and verify arguments + const fromStub = sinon + .stub(NetworkFormatter, 'from') + .callsFake(async (_req, opts) => { + // Verify we received the file paths + assert.strictEqual(opts?.requestFilePath, '/tmp/req.txt'); + assert.strictEqual(opts?.responseFilePath, '/tmp/res.txt'); + // Return a dummy formatter that behaves as if it saved files + // We need to create a real instance or mock one. + // Since constructor is private, we can't easily new it up. + // But we can return a mock object. + return { + toStringDetailed: () => 'Detailed string', + toJSONDetailed: () => ({ + requestBody: '/tmp/req.txt', + responseBody: '/tmp/res.txt', + }), + } as unknown as NetworkFormatter; + }); + + response.attachNetworkRequest(789, { + requestFilePath: '/tmp/req.txt', + responseFilePath: '/tmp/res.txt', + }); + const result = await response.handle('test', context); + + t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); + + fromStub.restore(); + }); + }); }); diff --git a/tests/formatters/NetworkFormatter.test.ts b/tests/formatters/NetworkFormatter.test.ts index aa8f26618..94fa79f05 100644 --- a/tests/formatters/NetworkFormatter.test.ts +++ b/tests/formatters/NetworkFormatter.test.ts @@ -5,16 +5,33 @@ */ import assert from 'node:assert'; -import {describe, it} from 'node:test'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, it} from 'node:test'; import {NetworkFormatter} from '../../src/formatters/NetworkFormatter.js'; +import type {HTTPRequest} from '../../src/third_party/index.js'; import {getMockRequest, getMockResponse} from '../utils.js'; describe('NetworkFormatter', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'network-formatter-test-')); + }); + + afterEach(async () => { + await rm(tmpDir, {recursive: true, force: true}); + }); + describe('toString', () => { it('works', async () => { const request = getMockRequest(); - const formatter = await NetworkFormatter.from(request, {requestId: 1}); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + saveFile: async () => ({filename: ''}), + }); assert.equal( formatter.toString(), @@ -23,7 +40,10 @@ describe('NetworkFormatter', () => { }); it('shows correct method', async () => { const request = getMockRequest({method: 'POST'}); - const formatter = await NetworkFormatter.from(request, {requestId: 1}); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + saveFile: async () => ({filename: ''}), + }); assert.equal( formatter.toString(), @@ -33,7 +53,10 @@ describe('NetworkFormatter', () => { it('shows correct status for request with response code in 200', async () => { const response = getMockResponse(); const request = getMockRequest({response}); - const formatter = await NetworkFormatter.from(request, {requestId: 1}); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + saveFile: async () => ({filename: ''}), + }); assert.equal( formatter.toString(), @@ -45,7 +68,10 @@ describe('NetworkFormatter', () => { status: 199, }); const request = getMockRequest({response}); - const formatter = await NetworkFormatter.from(request, {requestId: 1}); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + saveFile: async () => ({filename: ''}), + }); assert.equal( formatter.toString(), @@ -57,7 +83,10 @@ describe('NetworkFormatter', () => { status: 300, }); const request = getMockRequest({response}); - const formatter = await NetworkFormatter.from(request, {requestId: 1}); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + saveFile: async () => ({filename: ''}), + }); assert.equal( formatter.toString(), @@ -72,7 +101,10 @@ describe('NetworkFormatter', () => { }; }, }); - const formatter = await NetworkFormatter.from(request, {requestId: 1}); + const formatter = await NetworkFormatter.from(request, { + requestId: 1, + saveFile: async () => ({filename: ''}), + }); assert.equal( formatter.toString(), @@ -85,6 +117,7 @@ describe('NetworkFormatter', () => { const formatter = await NetworkFormatter.from(request, { requestId: 1, selectedInDevToolsUI: true, + saveFile: async () => ({filename: ''}), }); assert.equal( @@ -104,6 +137,7 @@ describe('NetworkFormatter', () => { const formatter = await NetworkFormatter.from(request, { requestId: 200, fetchData: true, + saveFile: async () => ({filename: ''}), }); const result = formatter.toStringDetailed(); assert.match(result, /test/); @@ -119,6 +153,7 @@ describe('NetworkFormatter', () => { const formatter = await NetworkFormatter.from(request, { requestId: 200, fetchData: true, + saveFile: async () => ({filename: ''}), }); const result = formatter.toStringDetailed(); @@ -140,11 +175,92 @@ describe('NetworkFormatter', () => { const formatter = await NetworkFormatter.from(request, { requestId: 20, fetchData: true, + saveFile: async () => ({filename: ''}), }); const result = formatter.toStringDetailed(); assert.match(result, /some text/); }); + it('should save bodies to file when file paths are provided', async () => { + const request = { + method: () => 'POST', + url: () => 'http://example.com', + headers: () => ({}), + hasPostData: () => true, + postData: () => 'request body', + response: () => ({ + status: () => 200, + headers: () => ({}), + buffer: async () => Buffer.from('response body'), + }), + failure: () => null, + redirectChain: () => [], + fetchPostData: async () => undefined, + } as unknown as HTTPRequest; + + const reqPath = join(tmpDir, 'test_req_' + Date.now()); + const resPath = join(tmpDir, 'test_res_' + Date.now()); + + const formatter = await NetworkFormatter.from(request, { + fetchData: true, + requestFilePath: reqPath, + responseFilePath: resPath, + saveFile: async (data, filename) => { + await writeFile(filename, data); + return {filename}; + }, + }); + + const json = formatter.toJSONDetailed() as { + requestBody: string; + responseBody: string; + requestBodyFilePath: string; + responseBodyFilePath: string; + }; + assert.strictEqual(json.requestBodyFilePath, reqPath); + assert.strictEqual(json.responseBodyFilePath, resPath); + assert.strictEqual(json.requestBody, undefined); + assert.strictEqual(json.responseBody, undefined); + }); + + it('should not truncate large bodies when saving to file', async () => { + const largeBody = 'a'.repeat(10005); + const request = { + method: () => 'POST', + url: () => 'http://example.com', + headers: () => ({}), + hasPostData: () => true, + postData: () => largeBody, + response: () => ({ + status: () => 200, + headers: () => ({}), + buffer: async () => Buffer.from(largeBody), + }), + failure: () => null, + redirectChain: () => [], + fetchPostData: async () => undefined, + } as unknown as HTTPRequest; + + const reqPath = join(tmpDir, 'test_req_large_' + Date.now()); + const resPath = join(tmpDir, 'test_res_large_' + Date.now()); + + await NetworkFormatter.from(request, { + fetchData: true, + requestFilePath: reqPath, + responseFilePath: resPath, + saveFile: async (data, filename) => { + await writeFile(filename, data); + return {filename}; + }, + }); + + const reqContent = await readFile(reqPath, 'utf8'); + const resContent = await readFile(resPath, 'utf8'); + + assert.strictEqual(reqContent, largeBody); + assert.strictEqual(resContent, largeBody); + }); + it('handles response body', async () => { const response = getMockResponse(); response.buffer = () => { @@ -155,6 +271,7 @@ describe('NetworkFormatter', () => { const formatter = await NetworkFormatter.from(request, { requestId: 200, fetchData: true, + saveFile: async () => ({filename: ''}), }); const result = formatter.toStringDetailed(); @@ -171,11 +288,46 @@ describe('NetworkFormatter', () => { const formatter = await NetworkFormatter.from(request, { requestId: 1, requestIdResolver: () => 2, + saveFile: async () => ({filename: ''}), }); const result = formatter.toStringDetailed(); assert.match(result, /Redirect chain/); assert.match(result, /reqid=2/); }); + it('shows saved to file message in toStringDetailed', async () => { + const request = { + method: () => 'POST', + url: () => 'http://example.com', + headers: () => ({}), + hasPostData: () => true, + postData: () => 'request body', + response: () => ({ + status: () => 200, + headers: () => ({}), + buffer: async () => Buffer.from('response body'), + }), + failure: () => null, + redirectChain: () => [], + fetchPostData: async () => undefined, + } as unknown as HTTPRequest; + + const reqPath = join(tmpDir, 'req.txt'); + const resPath = join(tmpDir, 'res.txt'); + + const formatter = await NetworkFormatter.from(request, { + fetchData: true, + requestFilePath: reqPath, + responseFilePath: resPath, + saveFile: async (data, filename) => { + await writeFile(filename, data); + return {filename}; + }, + }); + + const result = formatter.toStringDetailed(); + assert.ok(result.includes(`Saved to ${reqPath}.`)); + assert.ok(result.includes(`Saved to ${resPath}.`)); + }); }); describe('toJSON', () => { @@ -184,6 +336,7 @@ describe('NetworkFormatter', () => { const formatter = await NetworkFormatter.from(request, { requestId: 1, selectedInDevToolsUI: true, + saveFile: async () => ({filename: ''}), }); const result = formatter.toJSON(); assert.deepEqual(result, { @@ -208,6 +361,7 @@ describe('NetworkFormatter', () => { const formatter = await NetworkFormatter.from(request, { requestId: 1, fetchData: true, + saveFile: async () => ({filename: ''}), }); const result = formatter.toJSONDetailed(); assert.deepEqual(result, { @@ -220,11 +374,56 @@ describe('NetworkFormatter', () => { 'content-size': '10', }, requestBody: 'request', + requestBodyFilePath: undefined, responseHeaders: {}, responseBody: 'response', + responseBodyFilePath: undefined, failure: undefined, redirectChain: undefined, }); }); + + it('returns file paths in structured detailed data', async () => { + const request = { + method: () => 'POST', + url: () => 'http://example.com', + headers: () => ({}), + hasPostData: () => true, + postData: () => 'request body', + response: () => ({ + status: () => 200, + headers: () => ({}), + buffer: async () => Buffer.from('response body'), + }), + failure: () => null, + redirectChain: () => [], + fetchPostData: async () => undefined, + } as unknown as HTTPRequest; + + const reqPath = join(tmpDir, 'req_json.txt'); + const resPath = join(tmpDir, 'res_json.txt'); + + const formatter = await NetworkFormatter.from(request, { + fetchData: true, + requestFilePath: reqPath, + responseFilePath: resPath, + saveFile: async (data, filename) => { + await writeFile(filename, data); + return {filename}; + }, + }); + + const result = formatter.toJSONDetailed() as { + requestBodyFilePath: string; + responseBodyFilePath: string; + requestBody?: string; + responseBody?: string; + }; + + assert.strictEqual(result.requestBodyFilePath, reqPath); + assert.strictEqual(result.responseBodyFilePath, resPath); + assert.strictEqual(result.requestBody, undefined); + assert.strictEqual(result.responseBody, undefined); + }); }); });