diff --git a/src/McpResponse.ts b/src/McpResponse.ts index fa3c69ae..bf7603bf 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -12,6 +12,8 @@ import type {ResourceType} from 'puppeteer-core'; import {formatConsoleEvent} from './formatters/consoleFormatter.js'; import { getFormattedHeaderValue, + getFormattedResponseBody, + getFormattedRequestBody, getShortDescriptionForRequest, getStatusFromRequest, } from './formatters/networkFormatter.js'; @@ -21,10 +23,16 @@ import {handleDialog} from './tools/pages.js'; import type {ImageContentData, Response} from './tools/ToolDefinition.js'; import {paginate, type PaginationOptions} from './utils/pagination.js'; +interface NetworkRequestData { + networkRequestUrl: string; + requestBody?: string; + responseBody?: string; +} + export class McpResponse implements Response { #includePages = false; #includeSnapshot = false; - #attachedNetworkRequestUrl?: string; + #attachedNetworkRequestData?: NetworkRequestData; #includeConsoleData = false; #textResponseLines: string[] = []; #formattedConsoleData?: string[]; @@ -74,7 +82,9 @@ export class McpResponse implements Response { } attachNetworkRequest(url: string): void { - this.#attachedNetworkRequestUrl = url; + this.#attachedNetworkRequestData = { + networkRequestUrl: url, + }; } get includePages(): boolean { @@ -89,7 +99,7 @@ export class McpResponse implements Response { return this.#includeConsoleData; } get attachedNetworkRequestUrl(): string | undefined { - return this.#attachedNetworkRequestUrl; + return this.#attachedNetworkRequestData?.networkRequestUrl; } get networkRequestsPageIdx(): number | undefined { return this.#networkRequestsOptions?.pagination?.pageIdx; @@ -127,6 +137,22 @@ export class McpResponse implements Response { } let formattedConsoleMessages: string[]; + + if (this.#attachedNetworkRequestData?.networkRequestUrl) { + const request = context.getNetworkRequestByUrl( + this.#attachedNetworkRequestData.networkRequestUrl, + ); + + this.#attachedNetworkRequestData.requestBody = + await getFormattedRequestBody(request); + + const response = request.response(); + if (response) { + this.#attachedNetworkRequestData.responseBody = + await getFormattedResponseBody(response); + } + } + if (this.#includeConsoleData) { const consoleMessages = context.getConsoleData(); if (consoleMessages) { @@ -274,10 +300,11 @@ Call ${handleDialog.name} to handle it before continuing.`); #getIncludeNetworkRequestsData(context: McpContext): string[] { const response: string[] = []; - const url = this.#attachedNetworkRequestUrl; + const url = this.#attachedNetworkRequestData?.networkRequestUrl; if (!url) { return response; } + const httpRequest = context.getNetworkRequestByUrl(url); response.push(`## Request ${httpRequest.url()}`); response.push(`Status: ${getStatusFromRequest(httpRequest)}`); @@ -286,6 +313,11 @@ Call ${handleDialog.name} to handle it before continuing.`); response.push(line); } + if (this.#attachedNetworkRequestData?.requestBody) { + response.push(`### Request Body`); + response.push(this.#attachedNetworkRequestData.requestBody); + } + const httpResponse = httpRequest.response(); if (httpResponse) { response.push(`### Response Headers`); @@ -294,6 +326,11 @@ Call ${handleDialog.name} to handle it before continuing.`); } } + if (this.#attachedNetworkRequestData?.responseBody) { + response.push(`### Response Body`); + response.push(this.#attachedNetworkRequestData.responseBody); + } + const httpFailure = httpRequest.failure(); if (httpFailure) { response.push(`### Request failed with`); diff --git a/src/formatters/networkFormatter.ts b/src/formatters/networkFormatter.ts index f74e954f..7796f01a 100644 --- a/src/formatters/networkFormatter.ts +++ b/src/formatters/networkFormatter.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {HTTPRequest} from 'puppeteer-core'; +import {isUtf8} from 'node:buffer'; + +import type {HTTPRequest, HTTPResponse} from 'puppeteer-core'; + +const BODY_CONTEXT_SIZE_LIMIT = 10000; export function getShortDescriptionForRequest(request: HTTPRequest): string { return `${request.url()} ${request.method()} ${getStatusFromRequest(request)}`; @@ -37,3 +41,61 @@ export function getFormattedHeaderValue( } return response; } + +export async function getFormattedResponseBody( + httpResponse: HTTPResponse, + sizeLimit = BODY_CONTEXT_SIZE_LIMIT, +): Promise { + try { + const responseBuffer = await httpResponse.buffer(); + + if (isUtf8(responseBuffer)) { + const responseAsTest = responseBuffer.toString('utf-8'); + + if (responseAsTest.length === 0) { + return ``; + } + + return `${getSizeLimitedString(responseAsTest, sizeLimit)}`; + } + + return ``; + } catch { + // buffer() call might fail with CDP exception, in this case we don't print anything in the context + return; + } +} + +export async function getFormattedRequestBody( + httpRequest: HTTPRequest, + sizeLimit: number = BODY_CONTEXT_SIZE_LIMIT, +): Promise { + if (httpRequest.hasPostData()) { + const data = httpRequest.postData(); + + if (data) { + return `${getSizeLimitedString(data, sizeLimit)}`; + } + + try { + const fetchData = await httpRequest.fetchPostData(); + + if (fetchData) { + return `${getSizeLimitedString(fetchData, sizeLimit)}`; + } + } catch { + // fetchPostData() call might fail with CDP exception, in this case we don't print anything in the context + return; + } + } + + return; +} + +function getSizeLimitedString(text: string, sizeLimit: number) { + if (text.length > sizeLimit) { + return `${text.substring(0, sizeLimit) + '... '}`; + } + + return `${text}`; +} diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 87529102..586b524f 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -6,7 +6,7 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {getMockRequest, html, withBrowser} from './utils.js'; +import {getMockRequest, getMockResponse, html, withBrowser} from './utils.js'; describe('McpResponse', () => { it('list pages', async () => { @@ -202,6 +202,51 @@ http://example.com GET [pending]`, }); }); + it('add network request when attached with POST data', async () => { + await withBrowser(async (response, context) => { + response.setIncludeNetworkRequests(true); + const httpResponse = getMockResponse(); + httpResponse.buffer = () => { + return Promise.resolve(Buffer.from(JSON.stringify({response: 'body'}))); + }; + httpResponse.headers = () => { + return { + 'Content-Type': 'application/json', + }; + }; + const request = getMockRequest({ + method: 'POST', + hasPostData: true, + postData: JSON.stringify({request: 'body'}), + response: httpResponse, + }); + context.getNetworkRequests = () => { + return [request]; + }; + response.attachNetworkRequest(request.url()); + + const result = await response.handle('test', context); + + assert.strictEqual( + result[0].text, + `# test response +## Request http://example.com +Status: [success - 200] +### Request Headers +- content-size:10 +### Request Body +${JSON.stringify({request: 'body'})} +### Response Headers +- Content-Type:application/json +### Response Body +${JSON.stringify({response: 'body'})} +## Network requests +Showing 1-1 of 1 (Page 1 of 1). +http://example.com POST [success - 200]`, + ); + }); + }); + it('add network request when attached', async () => { await withBrowser(async (response, context) => { response.setIncludeNetworkRequests(true); diff --git a/tests/formatters/networkFormatter.test.ts b/tests/formatters/networkFormatter.test.ts index 515d94ee..23c8a323 100644 --- a/tests/formatters/networkFormatter.test.ts +++ b/tests/formatters/networkFormatter.test.ts @@ -7,8 +7,12 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; +import {ProtocolError} from 'puppeteer-core'; + import { getFormattedHeaderValue, + getFormattedRequestBody, + getFormattedResponseBody, getShortDescriptionForRequest, } from '../../src/formatters/networkFormatter.js'; import {getMockRequest, getMockResponse} from '../utils.js'; @@ -34,7 +38,6 @@ describe('networkFormatter', () => { assert.equal(result, 'http://example.com GET [success - 200]'); }); - it('shows correct status for request with response code in 100', async () => { const response = getMockResponse({ status: 199, @@ -53,7 +56,6 @@ describe('networkFormatter', () => { assert.equal(result, 'http://example.com GET [failed - 300]'); }); - it('shows correct status for request that failed', async () => { const request = getMockRequest({ failure() { @@ -100,4 +102,137 @@ describe('networkFormatter', () => { assert.deepEqual(result, []); }); }); + + describe('getFormattedRequestBody', () => { + it('shows data from fetchPostData if postData is undefined', async () => { + const request = getMockRequest({ + hasPostData: true, + postData: undefined, + fetchPostData: Promise.resolve('test'), + }); + + const result = await getFormattedRequestBody(request, 200); + + assert.strictEqual(result, 'test'); + }); + it('shows empty string when no postData available', async () => { + const request = getMockRequest({ + hasPostData: false, + }); + + const result = await getFormattedRequestBody(request, 200); + + assert.strictEqual(result, undefined); + }); + it('shows request body when postData is available', async () => { + const request = getMockRequest({ + postData: JSON.stringify({ + request: 'body', + }), + hasPostData: true, + }); + + const result = await getFormattedRequestBody(request, 200); + + assert.strictEqual( + result, + `${JSON.stringify({ + request: 'body', + })}`, + ); + }); + it('shows trunkated string correctly with postData', async () => { + const request = getMockRequest({ + postData: 'some text that is longer than expected', + hasPostData: true, + }); + + const result = await getFormattedRequestBody(request, 20); + + assert.strictEqual(result, 'some text that is lo... '); + }); + it('shows trunkated string correctly with fetchPostData', async () => { + const request = getMockRequest({ + fetchPostData: Promise.resolve( + 'some text that is longer than expected', + ), + postData: undefined, + hasPostData: true, + }); + + const result = await getFormattedRequestBody(request, 20); + + assert.strictEqual(result, 'some text that is lo... '); + }); + it('shows nothing on exception', async () => { + const request = getMockRequest({ + hasPostData: true, + postData: undefined, + fetchPostData: Promise.reject(new ProtocolError()), + }); + + const result = await getFormattedRequestBody(request, 200); + + assert.strictEqual(result, undefined); + }); + }); + + describe('getFormattedResponseBody', () => { + it('handles empty buffer correctly', async () => { + const response = getMockResponse(); + response.buffer = () => { + return Promise.resolve(Buffer.from('')); + }; + + const result = await getFormattedResponseBody(response, 200); + + assert.strictEqual(result, ''); + }); + it('handles base64 text correctly', async () => { + const binaryBuffer = Buffer.from([ + 0xde, 0xad, 0xbe, 0xef, 0x00, 0x41, 0x42, 0x43, + ]); + const response = getMockResponse(); + response.buffer = () => { + return Promise.resolve(binaryBuffer); + }; + + const result = await getFormattedResponseBody(response, 200); + + assert.strictEqual(result, ''); + }); + it('handles the text limit correctly', async () => { + const response = getMockResponse(); + response.buffer = () => { + return Promise.resolve( + Buffer.from('some text that is longer than expected'), + ); + }; + + const result = await getFormattedResponseBody(response, 20); + + assert.strictEqual(result, 'some text that is lo... '); + }); + it('handles the text format correctly', async () => { + const response = getMockResponse(); + response.buffer = () => { + return Promise.resolve(Buffer.from(JSON.stringify({response: 'body'}))); + }; + + const result = await getFormattedResponseBody(response, 200); + + assert.strictEqual(result, `${JSON.stringify({response: 'body'})}`); + }); + it('handles error correctly', async () => { + const response = getMockResponse(); + response.buffer = () => { + // CDP Error simulation + return Promise.reject(new ProtocolError()); + }; + + const result = await getFormattedResponseBody(response, 200); + + assert.strictEqual(result, undefined); + }); + }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 82b4da4e..0197e181 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -46,6 +46,9 @@ export function getMockRequest( response?: HTTPResponse; failure?: HTTPRequest['failure']; resourceType?: string; + hasPostData?: boolean; + postData?: string; + fetchPostData?: Promise; } = {}, ): HTTPRequest { return { @@ -55,6 +58,15 @@ export function getMockRequest( method() { return options.method ?? 'GET'; }, + fetchPostData() { + return options.fetchPostData ?? Promise.reject(); + }, + hasPostData() { + return options.hasPostData ?? false; + }, + postData() { + return options.postData; + }, response() { return options.response ?? null; },