Skip to content

Commit 911f36c

Browse files
committed
feat: Implement network log preservation features and related tools
1 parent 139ce60 commit 911f36c

4 files changed

Lines changed: 261 additions & 10 deletions

File tree

src/McpContext.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type {
1919
PredefinedNetworkConditions,
2020
} from 'puppeteer-core';
2121

22-
import {NetworkCollector, PageCollector} from './PageCollector.js';
22+
import {NetworkCollector, PageCollector, PreservedNetworkRequest} from './PageCollector.js';
2323
import {listPages} from './tools/pages.js';
2424
import {takeSnapshot} from './tools/snapshot.js';
2525
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
@@ -134,6 +134,32 @@ export class McpContext implements Context {
134134
return this.#networkCollector.getData(page);
135135
}
136136

137+
getPreservedNetworkRequests(): PreservedNetworkRequest[] {
138+
const page = this.getSelectedPage();
139+
return this.#networkCollector.getPreservedData(page);
140+
}
141+
142+
enableNetworkLogPreservation(options?: {
143+
includeRequestBodies?: boolean;
144+
includeResponseBodies?: boolean;
145+
maxRequests?: number;
146+
}): void {
147+
this.#networkCollector.enablePreservation(options);
148+
}
149+
150+
disableNetworkLogPreservation(): void {
151+
this.#networkCollector.disablePreservation();
152+
}
153+
154+
isNetworkLogPreservationEnabled(): boolean {
155+
return this.#networkCollector.isPreservationEnabled();
156+
}
157+
158+
clearPreservedNetworkLogs(): void {
159+
const page = this.getSelectedPage();
160+
this.#networkCollector.clearPreservedData(page);
161+
}
162+
137163
getConsoleData(): Array<ConsoleMessage | Error> {
138164
const page = this.getSelectedPage();
139165
return this.#consoleCollector.getData(page);

src/PageCollector.ts

Lines changed: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import type {Browser, HTTPRequest, Page} from 'puppeteer-core';
7+
import type {Browser, HTTPRequest, Page, HTTPResponse} from 'puppeteer-core';
8+
9+
export interface PreservedNetworkRequest {
10+
request: HTTPRequest;
11+
timestamp: number;
12+
requestBody?: string;
13+
responseBody?: string;
14+
}
815

916
export class PageCollector<T> {
1017
#browser: Browser;
1118
#initializer: (page: Page, collector: (item: T) => void) => void;
12-
/**
13-
* The Array in this map should only be set once
14-
* As we use the reference to it.
15-
* Use methods that manipulate the array in place.
16-
*/
1719
protected storage = new WeakMap<Page, T[]>();
1820

1921
constructor(
@@ -24,6 +26,10 @@ export class PageCollector<T> {
2426
this.#initializer = initializer;
2527
}
2628

29+
protected getBrowser(): Browser {
30+
return this.#browser;
31+
}
32+
2733
async init() {
2834
const pages = await this.#browser.pages();
2935
for (const page of pages) {
@@ -77,7 +83,86 @@ export class PageCollector<T> {
7783
}
7884

7985
export class NetworkCollector extends PageCollector<HTTPRequest> {
86+
#preservationEnabled = false;
87+
#includeRequestBodies = true;
88+
#includeResponseBodies = true;
89+
#maxRequests?: number;
90+
#preservedData = new WeakMap<Page, PreservedNetworkRequest[]>();
91+
92+
enablePreservation(options?: {
93+
includeRequestBodies?: boolean;
94+
includeResponseBodies?: boolean;
95+
maxRequests?: number;
96+
}): void {
97+
this.#preservationEnabled = true;
98+
this.#includeRequestBodies = options?.includeRequestBodies ?? true;
99+
this.#includeResponseBodies = options?.includeResponseBodies ?? true;
100+
this.#maxRequests = options?.maxRequests;
101+
}
102+
103+
disablePreservation(): void {
104+
this.#preservationEnabled = false;
105+
}
106+
107+
isPreservationEnabled(): boolean {
108+
return this.#preservationEnabled;
109+
}
110+
111+
clearPreservedData(page: Page): void {
112+
const preserved = this.#preservedData.get(page);
113+
if (preserved) {
114+
preserved.length = 0;
115+
}
116+
}
117+
118+
getPreservedData(page: Page): PreservedNetworkRequest[] {
119+
return this.#preservedData.get(page) ?? [];
120+
}
121+
122+
async #captureRequestData(request: HTTPRequest): Promise<PreservedNetworkRequest> {
123+
const preserved: PreservedNetworkRequest = {
124+
request,
125+
timestamp: Date.now(),
126+
};
127+
128+
if (this.#includeRequestBodies) {
129+
try {
130+
const postData = request.postData();
131+
if (postData) {
132+
preserved.requestBody = postData;
133+
}
134+
} catch (error) {
135+
}
136+
}
137+
138+
if (this.#includeResponseBodies) {
139+
try {
140+
const response = request.response();
141+
if (response) {
142+
const buffer = await response.buffer();
143+
const contentType = response.headers()['content-type'] || '';
144+
145+
if (contentType.includes('text/') ||
146+
contentType.includes('application/json') ||
147+
contentType.includes('application/xml') ||
148+
contentType.includes('application/javascript')) {
149+
preserved.responseBody = buffer.toString('utf-8');
150+
} else {
151+
preserved.responseBody = `[Binary data: ${contentType}, ${buffer.length} bytes]`;
152+
}
153+
}
154+
} catch (error) {
155+
}
156+
}
157+
158+
return preserved;
159+
}
160+
80161
override cleanup(page: Page) {
162+
if (this.#preservationEnabled) {
163+
return;
164+
}
165+
81166
const requests = this.storage.get(page) ?? [];
82167
if (!requests) {
83168
return;
@@ -87,9 +172,41 @@ export class NetworkCollector extends PageCollector<HTTPRequest> {
87172
? request.isNavigationRequest()
88173
: false;
89174
});
90-
// Keep all requests since the last navigation request including that
91-
// navigation request itself.
92-
// Keep the reference
93175
requests.splice(0, Math.max(lastRequestIdx, 0));
94176
}
177+
178+
public override addPage(page: Page): void {
179+
super.addPage(page);
180+
181+
if (this.#preservationEnabled) {
182+
if (!this.#preservedData.has(page)) {
183+
this.#preservedData.set(page, []);
184+
}
185+
186+
page.on('requestfinished', async (request: HTTPRequest) => {
187+
const preserved = this.#preservedData.get(page);
188+
if (!preserved) return;
189+
190+
const data = await this.#captureRequestData(request);
191+
preserved.push(data);
192+
193+
if (this.#maxRequests && preserved.length > this.#maxRequests) {
194+
preserved.shift();
195+
}
196+
});
197+
}
198+
}
199+
200+
override async init() {
201+
await super.init();
202+
203+
if (this.#preservationEnabled) {
204+
const pages = await this.getBrowser().pages();
205+
for (const page of pages) {
206+
if (!this.#preservedData.has(page)) {
207+
this.#preservedData.set(page, []);
208+
}
209+
}
210+
}
211+
}
95212
}

src/tools/ToolDefinition.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import type {Dialog, ElementHandle, Page} from 'puppeteer-core';
88
import z from 'zod';
99

10+
import type {PreservedNetworkRequest} from '../PageCollector.js';
1011
import type {TraceResult} from '../trace-processing/parse.js';
1112

1213
import type {ToolCategories} from './categories.js';
@@ -79,6 +80,15 @@ export type Context = Readonly<{
7980
filename: string,
8081
): Promise<{filename: string}>;
8182
waitForEventsAfterAction(action: () => Promise<unknown>): Promise<void>;
83+
enableNetworkLogPreservation(options?: {
84+
includeRequestBodies?: boolean;
85+
includeResponseBodies?: boolean;
86+
maxRequests?: number;
87+
}): void;
88+
disableNetworkLogPreservation(): void;
89+
isNetworkLogPreservationEnabled(): boolean;
90+
clearPreservedNetworkLogs(): void;
91+
getPreservedNetworkRequests(): PreservedNetworkRequest[];
8292
}>;
8393

8494
export function defineTool<Schema extends z.ZodRawShape>(

src/tools/network.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,101 @@ export const getNetworkRequest = defineTool({
8686
response.attachNetworkRequest(request.params.url);
8787
},
8888
});
89+
90+
export const enableNetworkLogPreservation = defineTool({
91+
name: 'enable_network_log_preservation',
92+
description: `Enable network log preservation mode. When enabled, all network requests are preserved across page navigations and response bodies are automatically captured. By default, logs are cleaned on navigation for performance.`,
93+
annotations: {
94+
category: ToolCategories.NETWORK,
95+
readOnlyHint: false,
96+
},
97+
schema: {
98+
includeRequestBodies: z
99+
.boolean()
100+
.optional()
101+
.default(true)
102+
.describe('Whether to capture and cache request bodies. Default: true'),
103+
includeResponseBodies: z
104+
.boolean()
105+
.optional()
106+
.default(true)
107+
.describe('Whether to capture and cache response bodies. Default: true'),
108+
maxRequests: z
109+
.number()
110+
.int()
111+
.positive()
112+
.optional()
113+
.describe(
114+
'Maximum number of requests to preserve. Older requests are automatically removed when limit is reached. When omitted, no limit is applied.',
115+
),
116+
},
117+
handler: async (request, response, context) => {
118+
context.enableNetworkLogPreservation({
119+
includeRequestBodies: request.params.includeRequestBodies,
120+
includeResponseBodies: request.params.includeResponseBodies,
121+
maxRequests: request.params.maxRequests,
122+
});
123+
response.appendResponseLine(
124+
'Network log preservation enabled. All network requests will be preserved across navigations.',
125+
);
126+
if (request.params.includeRequestBodies) {
127+
response.appendResponseLine('Request bodies will be captured.');
128+
}
129+
if (request.params.includeResponseBodies) {
130+
response.appendResponseLine('Response bodies will be captured.');
131+
}
132+
if (request.params.maxRequests) {
133+
response.appendResponseLine(
134+
`Maximum ${request.params.maxRequests} requests will be preserved.`,
135+
);
136+
}
137+
},
138+
});
139+
140+
export const disableNetworkLogPreservation = defineTool({
141+
name: 'disable_network_log_preservation',
142+
description: `Disable network log preservation mode and optionally clear existing preserved logs. After disabling, network logs will be cleaned on navigation (default behavior).`,
143+
annotations: {
144+
category: ToolCategories.NETWORK,
145+
readOnlyHint: false,
146+
},
147+
schema: {
148+
clearExisting: z
149+
.boolean()
150+
.optional()
151+
.default(true)
152+
.describe(
153+
'Whether to clear existing preserved logs. Default: true',
154+
),
155+
},
156+
handler: async (request, response, context) => {
157+
context.disableNetworkLogPreservation();
158+
if (request.params.clearExisting) {
159+
context.clearPreservedNetworkLogs();
160+
response.appendResponseLine(
161+
'Network log preservation disabled and existing logs cleared.',
162+
);
163+
} else {
164+
response.appendResponseLine(
165+
'Network log preservation disabled. Existing logs retained.',
166+
);
167+
}
168+
},
169+
});
170+
171+
export const clearPreservedNetworkLogs = defineTool({
172+
name: 'clear_preserved_network_logs',
173+
description: `Clear all preserved network logs for the currently selected page. This does not disable preservation mode.`,
174+
annotations: {
175+
category: ToolCategories.NETWORK,
176+
readOnlyHint: false,
177+
},
178+
schema: {},
179+
handler: async (_request, response, context) => {
180+
const preservedCount = context.getPreservedNetworkRequests().length;
181+
context.clearPreservedNetworkLogs();
182+
response.appendResponseLine(
183+
`Cleared ${preservedCount} preserved network request(s).`,
184+
);
185+
},
186+
});

0 commit comments

Comments
 (0)