Skip to content

Commit f0be0b1

Browse files
committed
feat(mcp): add response body and headers to network requests
Adds --response-body and --response-headers options to the network CLI command, and corresponding responseBody / responseHeaders parameters to the browser_network_requests MCP tool. Binary response bodies are rendered as a placeholder using @isomorphic/mimeType detection. Fixes microsoft/playwright-cli#377
1 parent 47b75ea commit f0be0b1

4 files changed

Lines changed: 176 additions & 5 deletions

File tree

packages/playwright-core/src/tools/backend/network.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
*/
1616

1717
import * as z from 'zod';
18+
19+
import { isTextualMimeType } from '@isomorphic/mimeType';
20+
1821
import { defineTool, defineTabTool } from './tool';
1922

2023
import type * as playwright from '../../..';
@@ -30,6 +33,8 @@ const requests = defineTabTool({
3033
static: z.boolean().default(false).describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'),
3134
requestBody: z.boolean().default(false).describe('Whether to include request body. Defaults to false.'),
3235
requestHeaders: z.boolean().default(false).describe('Whether to include request headers. Defaults to false.'),
36+
responseBody: z.boolean().default(false).describe('Whether to include response body. Defaults to false.'),
37+
responseHeaders: z.boolean().default(false).describe('Whether to include response headers. Defaults to false.'),
3338
filter: z.string().optional().describe('Only return requests whose URL matches this regexp (e.g. "/api/.*user").'),
3439
filename: z.string().optional().describe('Filename to save the network requests to. If not provided, requests are returned as text.'),
3540
}),
@@ -48,7 +53,7 @@ const requests = defineTabTool({
4853
if (!filter.test(request.url()))
4954
continue;
5055
}
51-
text.push(await renderRequest(request, params.requestBody, params.requestHeaders));
56+
text.push(await renderRequest(request, params.requestBody, params.requestHeaders, params.responseBody, params.responseHeaders));
5257
}
5358
await response.addResult('Network', text.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename });
5459
},
@@ -80,7 +85,7 @@ export function isFetch(request: playwright.Request): boolean {
8085
return ['fetch', 'xhr'].includes(request.resourceType());
8186
}
8287

83-
export async function renderRequest(request: playwright.Request, includeBody = false, includeHeaders = false): Promise<string> {
88+
export async function renderRequest(request: playwright.Request, includeRequestBody = false, includeRequestHeaders = false, includeResponseBody = false, includeResponseHeaders = false): Promise<string> {
8489
const response = request.existingResponse();
8590

8691
const result: string[] = [];
@@ -89,17 +94,36 @@ export async function renderRequest(request: playwright.Request, includeBody = f
8994
result.push(` => [${response.status()}] ${response.statusText()}`);
9095
else if (request.failure())
9196
result.push(` => [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`);
92-
if (includeHeaders) {
97+
if (includeRequestHeaders) {
9398
const headers = request.headers();
9499
const headerLines = Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n');
95100
if (headerLines)
96101
result.push(`\n Request headers:\n${headerLines}`);
97102
}
98-
if (includeBody) {
103+
if (includeRequestBody) {
99104
const postData = request.postData();
100105
if (postData)
101106
result.push(`\n Request body: ${postData}`);
102107
}
108+
if (includeResponseHeaders && response) {
109+
const headers = response.headers();
110+
const headerLines = Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n');
111+
if (headerLines)
112+
result.push(`\n Response headers:\n${headerLines}`);
113+
}
114+
if (includeResponseBody && response) {
115+
const contentType = response.headers()['content-type'] || '';
116+
if (isTextualMimeType(contentType)) {
117+
try {
118+
const body = await response.text();
119+
if (body)
120+
result.push(`\n Response body: ${body}`);
121+
} catch {
122+
}
123+
} else {
124+
result.push(`\n Response body: <binary data${contentType ? ` (${contentType.split(';')[0].trim()})` : ''}>`);
125+
}
126+
}
103127
return result.join('');
104128
}
105129

packages/playwright-core/src/tools/cli-daemon/commands.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -823,11 +823,13 @@ const networkRequests = declareCommand({
823823
static: z.boolean().optional().describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'),
824824
['request-body']: z.boolean().optional().describe('Whether to include request body. Defaults to false.'),
825825
['request-headers']: z.boolean().optional().describe('Whether to include request headers. Defaults to false.'),
826+
['response-body']: z.boolean().optional().describe('Whether to include response body. Defaults to false.'),
827+
['response-headers']: z.boolean().optional().describe('Whether to include response headers. Defaults to false.'),
826828
filter: z.string().optional().describe('Only return requests whose URL matches this regexp (e.g. "/api/.*user").'),
827829
clear: z.boolean().optional().describe('Whether to clear the network list'),
828830
}),
829831
toolName: ({ clear }) => clear ? 'browser_network_clear' : 'browser_network_requests',
830-
toolParams: ({ static: s, 'request-body': requestBody, 'request-headers': requestHeaders, filter, clear }) => clear ? ({}) : ({ static: s, requestBody, requestHeaders, filter }),
832+
toolParams: ({ static: s, 'request-body': requestBody, 'request-headers': requestHeaders, 'response-body': responseBody, 'response-headers': responseHeaders, filter, clear }) => clear ? ({}) : ({ static: s, requestBody, requestHeaders, responseBody, responseHeaders, filter }),
831833
});
832834

833835
const tracingStart = declareCommand({

tests/mcp/cli-devtools.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,52 @@ test('network --request-headers', async ({ cli, server }) => {
112112
}
113113
});
114114

115+
test('network --response-body', async ({ cli, server }) => {
116+
server.setContent('/', `
117+
<button onclick="fetch('/api')">Click me</button>
118+
`, 'text/html');
119+
server.setContent('/api', JSON.stringify({ name: 'John Doe' }), 'application/json');
120+
await cli('open', server.PREFIX);
121+
await cli('click', 'e2');
122+
123+
{
124+
const { output } = await cli('network');
125+
expect(output).toContain(`[GET] ${server.PREFIX}/api => [200] OK`);
126+
expect(output).not.toContain('Response body:');
127+
}
128+
129+
{
130+
const { output } = await cli('network', '--response-body');
131+
expect(output).toContain(`[GET] ${server.PREFIX}/api => [200] OK`);
132+
expect(output).toContain('Response body: {"name":"John Doe"}');
133+
}
134+
});
135+
136+
test('network --response-headers', async ({ cli, server }) => {
137+
server.setContent('/', `
138+
<button onclick="fetch('/api')">Click me</button>
139+
`, 'text/html');
140+
server.setRoute('/api', (_req, res) => {
141+
res.setHeader('X-Custom-Response', 'response-value');
142+
res.setHeader('Content-Type', 'application/json');
143+
res.end('{}');
144+
});
145+
await cli('open', server.PREFIX);
146+
await cli('click', 'e2');
147+
148+
{
149+
const { output } = await cli('network');
150+
expect(output).not.toContain('Response headers:');
151+
}
152+
153+
{
154+
const { output } = await cli('network', '--response-headers');
155+
expect(output).toContain(`[GET] ${server.PREFIX}/api => [200] OK`);
156+
expect(output).toContain('Response headers:');
157+
expect(output).toContain('x-custom-response: response-value');
158+
}
159+
});
160+
115161
test('network --clear', async ({ cli, server }) => {
116162
await cli('open', server.PREFIX);
117163
await cli('eval', '() => fetch("/hello-world")');

tests/mcp/network.spec.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,105 @@ test('browser_network_requests includes request headers', async ({ client, serve
116116
}
117117
});
118118

119+
test('browser_network_requests includes response headers', async ({ client, server }) => {
120+
server.setContent('/', `
121+
<button onclick="fetch('/api')">Click me</button>
122+
`, 'text/html');
123+
server.setRoute('/api', (_req, res) => {
124+
res.setHeader('X-Custom-Response', 'response-value');
125+
res.setHeader('Content-Type', 'application/json');
126+
res.end('{}');
127+
});
128+
129+
await client.callTool({
130+
name: 'browser_navigate',
131+
arguments: { url: server.PREFIX },
132+
});
133+
134+
await client.callTool({
135+
name: 'browser_click',
136+
arguments: { element: 'Click me button', target: 'e2' },
137+
});
138+
139+
{
140+
const response = parseResponse(await client.callTool({
141+
name: 'browser_network_requests',
142+
}));
143+
expect(response.result).not.toContain('Response headers:');
144+
}
145+
146+
{
147+
const response = parseResponse(await client.callTool({
148+
name: 'browser_network_requests',
149+
arguments: { responseHeaders: true },
150+
}));
151+
expect(response.result).toContain(`[GET] ${server.PREFIX}/api => [200] OK`);
152+
expect(response.result).toContain('Response headers:');
153+
expect(response.result).toContain('x-custom-response: response-value');
154+
}
155+
});
156+
157+
test('browser_network_requests includes response body', async ({ client, server }) => {
158+
server.setContent('/', `
159+
<button onclick="fetch('/api')">Click me</button>
160+
`, 'text/html');
161+
server.setContent('/api', JSON.stringify({ name: 'John Doe' }), 'application/json');
162+
163+
await client.callTool({
164+
name: 'browser_navigate',
165+
arguments: { url: server.PREFIX },
166+
});
167+
168+
await client.callTool({
169+
name: 'browser_click',
170+
arguments: { element: 'Click me button', target: 'e2' },
171+
});
172+
173+
{
174+
const response = parseResponse(await client.callTool({
175+
name: 'browser_network_requests',
176+
}));
177+
expect(response.result).toContain(`[GET] ${server.PREFIX}/api => [200] OK`);
178+
expect(response.result).not.toContain('Response body:');
179+
}
180+
181+
{
182+
const response = parseResponse(await client.callTool({
183+
name: 'browser_network_requests',
184+
arguments: { responseBody: true },
185+
}));
186+
expect(response.result).toContain(`[GET] ${server.PREFIX}/api => [200] OK`);
187+
expect(response.result).toContain('Response body: {"name":"John Doe"}');
188+
}
189+
});
190+
191+
test('browser_network_requests skips binary response body', async ({ client, server }) => {
192+
server.setContent('/', `
193+
<button onclick="fetch('/image.png')">Click me</button>
194+
`, 'text/html');
195+
server.setRoute('/image.png', (_req, res) => {
196+
res.setHeader('Content-Type', 'image/png');
197+
res.end(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
198+
});
199+
200+
await client.callTool({
201+
name: 'browser_navigate',
202+
arguments: { url: server.PREFIX },
203+
});
204+
205+
await client.callTool({
206+
name: 'browser_click',
207+
arguments: { element: 'Click me button', target: 'e2' },
208+
});
209+
210+
const response = parseResponse(await client.callTool({
211+
name: 'browser_network_requests',
212+
arguments: { responseBody: true, static: true },
213+
}));
214+
expect(response.result).toContain(`[GET] ${server.PREFIX}/image.png => [200] OK`);
215+
expect(response.result).toContain('Response body: <binary data (image/png)>');
216+
});
217+
119218
test('browser_network_requests includes request payload', async ({ client, server }) => {
120219
server.setContent('/', `
121220
<button onclick="fetch('/api', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'value' }) })">Click me</button>

0 commit comments

Comments
 (0)