Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
14 changes: 13 additions & 1 deletion src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -361,6 +372,7 @@ export class McpResponse implements Response {
context.getNetworkRequestStableId(request) ===
this.#networkRequestsOptions?.networkRequestIdInDevToolsUI,
fetchData: false,
saveFile: (data, filename) => context.saveFile(data, filename),
}),
),
);
Expand Down
91 changes: 77 additions & 14 deletions src/formatters/NetworkFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,30 @@ export interface NetworkFormatterOptions {
selectedInDevToolsUI?: boolean;
requestIdResolver?: (request: HTTPRequest) => number | string;
fetchData?: boolean;
requestFilePath?: string;
responseFilePath?: string;
saveFile?: (
data: Uint8Array<ArrayBufferLike>,
filename: string,
) => Promise<{filename: string}>;
}

export class NetworkFormatter {
#request: HTTPRequest;
#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<NetworkFormatter> {
const instance = new NetworkFormatter(request, options);
if (options.fetchData) {
Expand All @@ -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 = '<not available anymore>';
Expand All @@ -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,
);
}
}
}

Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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++;
Expand All @@ -150,6 +194,7 @@ export class NetworkFormatter {
: undefined;
const formatter = new NetworkFormatter(request, {
requestId: id,
saveFile: this.#options.saveFile,
});
return formatter.toJSON();
});
Expand All @@ -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
Expand Down Expand Up @@ -215,6 +262,22 @@ export class NetworkFormatter {
return '<not available anymore>';
}
}

async #saveResponseBodyToFile(
httpResponse: HTTPResponse,
filePath: string,
): Promise<string> {
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 '<not available anymore>';
}
}
}

function getSizeLimitedString(text: string, sizeLimit: number) {
Expand Down
5 changes: 4 additions & 1 deletion src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 21 additions & 3 deletions src/tools/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -100,18 +100,36 @@ 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);
const reqid = data?.cdpRequestId
? 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.`,
Expand Down
9 changes: 9 additions & 0 deletions tests/McpContext.test.js.snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
51 changes: 51 additions & 0 deletions tests/McpContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
});
Loading