feat(mcp): refactor request output and add --filename option (#40454)

This commit is contained in:
Yury Semikhatsky
2026-04-28 11:27:52 -07:00
committed by GitHub
parent bebf640b3a
commit 21ca7274ed
5 changed files with 133 additions and 116 deletions
@@ -44,10 +44,13 @@ const requests = defineTabTool({
const allRequests = await tab.requests();
const filter = params.filter ? new RegExp(params.filter) : undefined;
const lines: string[] = [];
let hiddenStaticCount = 0;
for (let i = 0; i < allRequests.length; i++) {
const request = allRequests[i];
if (!params.static && !isFetch(request) && isSuccessfulResponse(request))
if (!params.static && !isFetch(request) && isSuccessfulResponse(request)) {
hiddenStaticCount++;
continue;
}
if (filter) {
filter.lastIndex = 0;
if (!filter.test(request.url()))
@@ -55,6 +58,10 @@ const requests = defineTabTool({
}
lines.push(`${i + 1}. ${renderRequestLine(request)}`);
}
if (hiddenStaticCount > 0) {
const optionName = tab.context.config.skillMode ? '--static' : '"static"';
lines.push(`\nNote: ${hiddenStaticCount} static request${hiddenStaticCount === 1 ? '' : 's'} not shown, run with ${optionName} option to see ${hiddenStaticCount === 1 ? 'it' : 'them'}.`);
}
await response.addResult('Network', lines.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename });
},
});
@@ -72,6 +79,7 @@ const request = defineTabTool({
inputSchema: z.object({
index: z.number().int().min(1).describe('1-based index of the request, as printed by browser_network_requests.'),
part: z.enum(REQUEST_PARTS).optional().describe('Return only this part of the request. Omit to return full details.'),
filename: z.string().optional().describe('Filename to save the result to. If not provided, output is returned as text.'),
}),
type: 'readOnly',
},
@@ -84,13 +92,10 @@ const request = defineTabTool({
return;
}
if (params.part) {
const partText = await renderRequestPart(request, params.part, response);
if (partText !== undefined)
response.addTextResult(partText);
await renderRequestPart(request, params.part, response, params.filename);
return;
}
const bodyPath = await saveResponseBody(request, response);
response.addTextResult(renderRequestDetails(params.index, request, bodyPath));
await response.addResult('Request', renderRequestDetails(params.index, request, !!tab.context.config.skillMode), { prefix: 'request', ext: 'log', suggestedFilename: params.filename });
},
});
@@ -130,7 +135,7 @@ export function renderRequestLine(request: playwright.Request): string {
return line;
}
function renderRequestDetails(index: number, request: playwright.Request, responseBodyPath: string | undefined): string {
function renderRequestDetails(index: number, request: playwright.Request, skillMode: boolean): string {
const httpResponse = request.existingResponse();
const responseHeaders = httpResponse?.headers();
const lines: string[] = [];
@@ -152,25 +157,35 @@ function renderRequestDetails(index: number, request: playwright.Request, respon
appendHeaderSection(lines, 'Request headers', request.headers());
const postData = request.postData();
if (postData) {
lines.push('');
lines.push(' Request body');
lines.push(` ${postData}`);
}
if (responseHeaders)
appendHeaderSection(lines, 'Response headers', responseHeaders);
if (responseBodyPath) {
lines.push('');
lines.push(' Response body');
lines.push(` ${responseBodyPath}`);
}
const hints: string[] = [];
if (request.postData())
hints.push(partHint(skillMode, 'request-body', index));
if (canHaveResponseBody(httpResponse))
hints.push(partHint(skillMode, 'response-body', index));
if (hints.length)
lines.push('', ...hints);
return lines.join('\n');
}
function partHint(skillMode: boolean, part: 'request-body' | 'response-body', index: number): string {
const subject = part === 'request-body' ? 'request body' : 'response body';
return skillMode
? `Run \`${part} ${index}\` to read the ${subject}.`
: `Call browser_network_request with part="${part}" to read the ${subject}.`;
}
function canHaveResponseBody(httpResponse: playwright.Response | null): httpResponse is playwright.Response {
if (!httpResponse)
return false;
const status = httpResponse.status();
// Status codes that cannot have a response body per RFC 7230.
return status !== 204 && status !== 304 && !(status >= 100 && status < 200);
}
function appendHeaderSection(lines: string[], title: string, headers: Record<string, string>): void {
const entries = Object.entries(headers);
if (!entries.length)
@@ -188,39 +203,48 @@ function computeDurationMs(request: playwright.Request): number | undefined {
return Math.round(timing.responseEnd);
}
async function renderRequestPart(request: playwright.Request, part: RequestPart, response: ToolResponse): Promise<string | undefined> {
if (part === 'request-headers')
return renderHeaders(request.headers());
if (part === 'request-body')
return request.postData() ?? undefined;
async function renderRequestPart(request: playwright.Request, part: RequestPart, response: ToolResponse, suggestedFilename: string | undefined): Promise<void> {
if (part === 'request-headers') {
await response.addResult('Request headers', renderHeaders(request.headers()), { prefix: 'request', ext: 'txt', suggestedFilename });
return;
}
if (part === 'request-body') {
const data = request.postData();
if (data !== null)
await response.addResult('Request body', data, { prefix: 'request', ext: 'txt', suggestedFilename });
return;
}
const httpResponse = request.existingResponse();
if (!httpResponse)
return undefined;
if (part === 'response-headers')
return renderHeaders(httpResponse.headers());
return;
if (part === 'response-headers') {
await response.addResult('Response headers', renderHeaders(httpResponse.headers()), { prefix: 'response', ext: 'txt', suggestedFilename });
return;
}
// response-body
const contentType = httpResponse.headers()['content-type'];
if (isTextualMimeType(contentType ?? '')) {
let text: string;
try {
return await httpResponse.text();
text = await httpResponse.text();
} catch {
return undefined;
return;
}
await response.addResult('Response body', text, { prefix: 'response', ext: 'txt', suggestedFilename });
return;
}
return await saveResponseBody(request, response);
const path = await saveResponseBody(request, response, suggestedFilename);
if (path !== undefined)
response.addTextResult(path);
}
function renderHeaders(headers: Record<string, string>): string {
return Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join('\n');
}
async function saveResponseBody(request: playwright.Request, response: ToolResponse): Promise<string | undefined> {
async function saveResponseBody(request: playwright.Request, response: ToolResponse, suggestedFilename?: string): Promise<string | undefined> {
const httpResponse = request.existingResponse();
if (!httpResponse)
return undefined;
const status = httpResponse.status();
// Status codes that cannot have a response body per RFC 7230.
if (status === 204 || status === 304 || (status >= 100 && status < 200))
if (!canHaveResponseBody(httpResponse))
return undefined;
let body: Buffer;
try {
@@ -231,7 +255,7 @@ async function saveResponseBody(request: playwright.Request, response: ToolRespo
if (!body.length)
return undefined;
const ext = getExtensionForMimeType(httpResponse.headers()['content-type']);
const resolved = await response.resolveClientFile({ prefix: 'response', ext }, 'Response body');
const resolved = await response.resolveClientFile({ prefix: 'response', ext, suggestedFilename }, 'Response body');
await fs.promises.writeFile(resolved.fileName, body);
return resolved.relativeName;
}
@@ -828,6 +828,8 @@ const networkRequests = declareCommand({
toolParams: ({ static: s, filter, clear }) => clear ? ({}) : ({ static: s, filter }),
});
const filenameOption = z.string().optional().describe('Filename to save the result to. If not provided, output is returned as text.');
const networkRequest = declareCommand({
name: 'request',
description: 'Show full details (headers, body, response) of a single network request by its number from the `requests` command.',
@@ -835,8 +837,11 @@ const networkRequest = declareCommand({
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index }),
toolParams: ({ index, filename }) => ({ index, filename }),
});
const networkRequestHeaders = declareCommand({
@@ -846,8 +851,11 @@ const networkRequestHeaders = declareCommand({
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'request-headers' }),
toolParams: ({ index, filename }) => ({ index, part: 'request-headers', filename }),
});
const networkRequestBody = declareCommand({
@@ -857,8 +865,11 @@ const networkRequestBody = declareCommand({
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'request-body' }),
toolParams: ({ index, filename }) => ({ index, part: 'request-body', filename }),
});
const networkResponseHeaders = declareCommand({
@@ -868,8 +879,11 @@ const networkResponseHeaders = declareCommand({
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'response-headers' }),
toolParams: ({ index, filename }) => ({ index, part: 'response-headers', filename }),
});
const networkResponseBody = declareCommand({
@@ -879,8 +893,11 @@ const networkResponseBody = declareCommand({
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'response-body' }),
toolParams: ({ index, filename }) => ({ index, part: 'response-body', filename }),
});
const tracingStart = declareCommand({
+42 -29
View File
@@ -51,12 +51,14 @@ test('requests', async ({ cli, server }) => {
const { output } = await cli('requests');
expect(output).not.toContain(`[GET] ${`${server.PREFIX}/`} => [200] OK`);
expect(output).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/hello-world`)} => \[200\] OK$`, 'm'));
expect(output).toContain('Note: 1 static request not shown, run with --static option to see it.');
});
test('requests --static', async ({ cli, server }) => {
await cli('open', server.PREFIX);
const { output } = await cli('requests', '--static');
expect(output).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/`)} => \[200\] OK$`, 'm'));
expect(output).not.toContain('not shown');
});
test('requests --filter', async ({ cli, server }) => {
@@ -101,37 +103,13 @@ test('request shows full request and response details', async ({ cli, server })
expect(output).toContain('status: [200] OK');
expect(output).toContain('Request headers');
expect(output).toContain('x-custom-header: test-value');
expect(output).toContain('Request body');
expect(output).toContain('{"key":"value"}');
expect(output).toContain('Response headers');
expect(output).toContain('x-custom-response: response-value');
const bodyMatch = output.match(/Response body\n\s+(\S+\.json)/);
expect(bodyMatch).not.toBeNull();
const bodyPath = path.resolve(test.info().outputPath(), bodyMatch![1]);
expect(fs.readFileSync(bodyPath, 'utf-8')).toBe('{"name":"John Doe"}');
});
test('request saves binary response body to a file', async ({ cli, server }) => {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
server.setContent('/', `
<button onclick="fetch('/image.png')">Click me</button>
`, 'text/html');
server.setRoute('/image.png', (_req, res) => {
res.setHeader('Content-Type', 'image/png');
res.end(pngBytes);
});
await cli('open', server.PREFIX);
await cli('click', 'e2');
const { output: list } = await cli('requests', '--static');
const match = list.match(/^(\d+)\. \[GET\] [^ ]+\/image\.png =>/m);
expect(match).not.toBeNull();
const { output } = await cli('request', match![1]);
const bodyMatch = output.match(/Response body\n\s+(\S+\.png)/);
expect(bodyMatch).not.toBeNull();
const bodyPath = path.resolve(test.info().outputPath(), bodyMatch![1]);
expect(fs.readFileSync(bodyPath)).toEqual(pngBytes);
expect(output).toContain(`Run \`request-body ${match![1]}\` to read the request body.`);
expect(output).toContain(`Run \`response-body ${match![1]}\` to read the response body.`);
expect(output).not.toContain('Request body');
expect(output).not.toContain('Response body');
expect(output).not.toContain('{"key":"value"}');
});
test('per-part commands extract individual parts', async ({ cli, server }) => {
@@ -157,6 +135,41 @@ test('per-part commands extract individual parts', async ({ cli, server }) => {
expect((await cli('response-body', num)).output).toContain('{"name":"John Doe"}');
});
test('request* and response* commands support --filename', async ({ cli, server }, testInfo) => {
server.setContent('/', `
<button onclick="fetch('/api', { method: 'POST', body: 'hello' })">Click me</button>
`, 'text/html');
server.setRoute('/api', (_req, res) => {
res.setHeader('X-Custom-Response', 'response-value');
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ name: 'John Doe' }));
});
await cli('open', server.PREFIX);
await cli('click', 'e2');
const { output: list } = await cli('requests');
const match = list.match(/^(\d+)\. \[POST\] [^ ]+\/api =>/m);
expect(match).not.toBeNull();
const num = match![1];
const read = (file: string) => fs.readFileSync(testInfo.outputPath(file), 'utf-8');
expect((await cli('request', num, '--filename=req.log')).output).toContain('[Request](./req.log)');
expect(read('req.log')).toContain(`[POST] ${server.PREFIX}/api`);
expect((await cli('request-headers', num, '--filename=req-h.txt')).output).toContain('[Request headers](./req-h.txt)');
expect(read('req-h.txt')).toContain('content-type: text/plain;charset=UTF-8');
expect((await cli('request-body', num, '--filename=req-b.txt')).output).toContain('[Request body](./req-b.txt)');
expect(read('req-b.txt')).toBe('hello');
expect((await cli('response-headers', num, '--filename=res-h.txt')).output).toContain('[Response headers](./res-h.txt)');
expect(read('res-h.txt')).toContain('x-custom-response: response-value');
expect((await cli('response-body', num, '--filename=res-b.json')).output).toContain('[Response body](./res-b.json)');
expect(read('res-b.json')).toBe('{"name":"John Doe"}');
});
test('--raw response-body returns just the body', async ({ cli, server }) => {
server.setContent('/', `
<button onclick="fetch('/api')">Click me</button>
+1 -1
View File
@@ -213,7 +213,7 @@ test('request and per-part commands return JSON result', async ({ cli, server })
const detail = JSON.parse((await cli('--json', 'request', num)).output);
expect(detail.result).toContain(`#${num} [POST] ${server.PREFIX}/api`);
expect(detail.result).toContain('Response body');
expect(detail.result).toContain(`Run \`response-body ${num}\` to read the response body.`);
});
test('request with out-of-range index returns JSON error', async ({ cli, server }) => {
+7 -44
View File
@@ -49,6 +49,7 @@ test('browser_network_requests', async ({ client, server }) => {
expect(response.result).not.toContain(`[GET] ${`${server.PREFIX}/`} => [200] OK`);
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/json`)} => \[200\] OK$`, 'm'));
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/image.png`)} => \[404\]`, 'm'));
expect(response.result).toContain('Note: 1 static request not shown, run with "static" option to see it.');
}
{
@@ -61,6 +62,7 @@ test('browser_network_requests', async ({ client, server }) => {
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/`)} => \[200\] OK$`, 'm'));
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/json`)} => \[200\] OK$`, 'm'));
expect(response.result).toMatch(new RegExp(String.raw`^\d+\. \[GET\] ${escapeRegExp(`${server.PREFIX}/image.png`)} => \[404\]`, 'm'));
expect(response.result).not.toContain('not shown');
}
});
@@ -146,52 +148,13 @@ test('browser_network_request shows full request and response details', async ({
expect(detail.result).toContain('mimeType: application/json');
expect(detail.result).toContain('Request headers');
expect(detail.result).toContain('x-custom-header: test-value');
expect(detail.result).toContain('Request body');
expect(detail.result).toContain('{"key":"value"}');
expect(detail.result).toContain('Response headers');
expect(detail.result).toContain('x-custom-response: response-value');
const bodyMatch = detail.result.match(/Response body\n\s+(\S+\.json)/);
expect(bodyMatch).not.toBeNull();
const bodyPath = path.resolve(test.info().outputPath(), bodyMatch![1]);
expect(fs.readFileSync(bodyPath, 'utf-8')).toBe('{"name":"John Doe"}');
});
test('browser_network_request saves binary response body to a file', async ({ client, server }) => {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
server.setContent('/', `
<button onclick="fetch('/image.png')">Click me</button>
`, 'text/html');
server.setRoute('/image.png', (_req, res) => {
res.setHeader('Content-Type', 'image/png');
res.end(pngBytes);
});
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
await client.callTool({
name: 'browser_click',
arguments: { element: 'Click me button', target: 'e2' },
});
const list = parseResponse(await client.callTool({
name: 'browser_network_requests',
arguments: { static: true },
}));
const match = list.result.match(/^(\d+)\. \[GET\] [^ ]+\/image\.png =>/m);
expect(match).not.toBeNull();
const detail = parseResponse(await client.callTool({
name: 'browser_network_request',
arguments: { index: Number(match![1]) },
}));
const bodyMatch = detail.result.match(/Response body\n\s+(\S+\.png)/);
expect(bodyMatch).not.toBeNull();
const bodyPath = path.resolve(test.info().outputPath(), bodyMatch![1]);
expect(fs.readFileSync(bodyPath)).toEqual(pngBytes);
expect(detail.result).toContain('Call browser_network_request with part="request-body" to read the request body.');
expect(detail.result).toContain('Call browser_network_request with part="response-body" to read the response body.');
expect(detail.result).not.toContain('Request body');
expect(detail.result).not.toContain('Response body');
expect(detail.result).not.toContain('{"key":"value"}');
});
test('browser_network_request reports failed requests', async ({ client, server }) => {