Skip to content

Commit 165cf9c

Browse files
feat: expose previous navigations to the MCP (#419)
We should consider changing the order of element shown in the from the collectors to be show newest first rather then the current oldest first approach. Closes #88
1 parent 7d47d6b commit 165cf9c

File tree

11 files changed

+229
-14
lines changed

11 files changed

+229
-14
lines changed

docs/tool-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@
269269

270270
**Parameters:**
271271

272+
- **includePreviousNavigations** (boolean) _(optional)_: Whether to include requests from previous navigations.
272273
- **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page.
273274
- **pageSize** (integer) _(optional)_: Maximum number of requests to return. When omitted, returns all requests.
274275
- **resourceTypes** (array) _(optional)_: Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.

src/McpContext.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,9 @@ export class McpContext implements Context {
141141
return context;
142142
}
143143

144-
getNetworkRequests(): HTTPRequest[] {
144+
getNetworkRequests(includePreviousNavigations?: boolean): HTTPRequest[] {
145145
const page = this.getSelectedPage();
146-
return this.#networkCollector.getData(page);
146+
return this.#networkCollector.getData(page, includePreviousNavigations);
147147
}
148148

149149
getConsoleData(): Array<ConsoleMessage | Error> {
@@ -465,4 +465,21 @@ export class McpContext implements Context {
465465

466466
return locator.wait();
467467
}
468+
469+
/**
470+
* We need to ignore favicon request as they make our test flaky
471+
*/
472+
async setUpNetworkCollectorForTesting() {
473+
this.#networkCollector = new NetworkCollector(this.browser, collect => {
474+
return {
475+
request: req => {
476+
if (req.url().includes('favicon.ico')) {
477+
return;
478+
}
479+
collect(req);
480+
},
481+
} as ListenerMap;
482+
});
483+
await this.#networkCollector.init();
484+
}
468485
}

src/McpResponse.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export class McpResponse implements Response {
4242
include: boolean;
4343
pagination?: PaginationOptions;
4444
resourceTypes?: ResourceType[];
45+
includePreviousNavigations?: boolean;
4546
};
4647
#consoleDataOptions?: {
4748
include: boolean;
@@ -62,6 +63,7 @@ export class McpResponse implements Response {
6263
value: boolean,
6364
options?: PaginationOptions & {
6465
resourceTypes?: ResourceType[];
66+
includePreviousNavigations?: boolean;
6567
},
6668
): void {
6769
if (!value) {
@@ -79,6 +81,7 @@ export class McpResponse implements Response {
7981
}
8082
: undefined,
8183
resourceTypes: options?.resourceTypes,
84+
includePreviousNavigations: options?.includePreviousNavigations,
8285
};
8386
}
8487

@@ -346,7 +349,9 @@ Call ${handleDialog.name} to handle it before continuing.`);
346349
response.push(...this.#formatConsoleData(data.consoleData));
347350

348351
if (this.#networkRequestsOptions?.include) {
349-
let requests = context.getNetworkRequests();
352+
let requests = context.getNetworkRequests(
353+
this.#networkRequestsOptions?.includePreviousNavigations,
354+
);
350355

351356
// Apply resource type filtering if specified
352357
if (this.#networkRequestsOptions.resourceTypes?.length) {

src/PageCollector.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,21 @@ export class PageCollector<T> {
133133
this.storage.delete(page);
134134
}
135135

136-
getData(page: Page): T[] {
136+
getData(page: Page, includePreviousNavigations?: boolean): T[] {
137137
const navigations = this.storage.get(page);
138138
if (!navigations) {
139139
return [];
140140
}
141-
return navigations[0];
141+
142+
if (!includePreviousNavigations) {
143+
return navigations[0];
144+
}
145+
146+
const data: T[] = [];
147+
for (let index = this.#maxNavigationSaved; index >= 0; index--) {
148+
data.push(...navigations[index]);
149+
}
150+
return data;
142151
}
143152

144153
getIdForResource(resource: WithSymbolId<T>): number {
@@ -164,14 +173,19 @@ export class PageCollector<T> {
164173
}
165174

166175
export class NetworkCollector extends PageCollector<HTTPRequest> {
167-
constructor(browser: Browser) {
168-
super(browser, collect => {
176+
constructor(
177+
browser: Browser,
178+
listeners: (
179+
collector: (item: HTTPRequest) => void,
180+
) => ListenerMap<PageEvents> = collect => {
169181
return {
170182
request: req => {
171183
collect(req);
172184
},
173185
} as ListenerMap;
174-
});
186+
},
187+
) {
188+
super(browser, listeners);
175189
}
176190
override splitAfterNavigation(page: Page) {
177191
const navigations = this.storage.get(page) ?? [];
@@ -190,7 +204,7 @@ export class NetworkCollector extends PageCollector<HTTPRequest> {
190204
// Keep all requests since the last navigation request including that
191205
// navigation request itself.
192206
// Keep the reference
193-
if (lastRequestIdx) {
207+
if (lastRequestIdx !== -1) {
194208
const fromCurrentNavigation = requests.splice(lastRequestIdx);
195209
navigations.unshift(fromCurrentNavigation);
196210
} else {

src/tools/ToolDefinition.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface Response {
5353
value: boolean,
5454
options?: PaginationOptions & {
5555
resourceTypes?: string[];
56+
includePreviousNavigations?: boolean;
5657
},
5758
): void;
5859
setIncludeConsoleData(

src/tools/network.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,18 @@ export const listNetworkRequests = defineTool({
6262
.describe(
6363
'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.',
6464
),
65+
includePreviousNavigations: zod
66+
.boolean()
67+
.default(false)
68+
.optional()
69+
.describe('Whether to include requests from previous navigations.'),
6570
},
6671
handler: async (request, response) => {
6772
response.setIncludeNetworkRequests(true, {
6873
pageSize: request.params.pageSize,
6974
pageIdx: request.params.pageIdx,
7075
resourceTypes: request.params.resourceTypes,
76+
includePreviousNavigations: request.params.includePreviousNavigations,
7177
});
7278
},
7379
});

tests/PageCollector.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,40 @@ describe('NetworkCollector', () => {
250250
assert.equal(collector.getData(page)[0], navRequest);
251251
assert.equal(collector.getData(page)[1], request2);
252252
});
253+
254+
it('correctly picks up after multiple back to back navigations', async () => {
255+
const browser = getMockBrowser();
256+
const page = (await browser.pages())[0];
257+
const mainFrame = page.mainFrame();
258+
const navRequest = getMockRequest({
259+
navigationRequest: true,
260+
frame: page.mainFrame(),
261+
});
262+
const navRequest2 = getMockRequest({
263+
navigationRequest: true,
264+
frame: page.mainFrame(),
265+
});
266+
const request = getMockRequest();
267+
268+
const collector = new NetworkCollector(browser);
269+
await collector.init();
270+
page.emit('request', navRequest);
271+
assert.equal(collector.getData(page)[0], navRequest);
272+
273+
page.emit('framenavigated', mainFrame);
274+
assert.equal(collector.getData(page).length, 1);
275+
assert.equal(collector.getData(page)[0], navRequest);
276+
277+
page.emit('request', navRequest2);
278+
assert.equal(collector.getData(page).length, 2);
279+
assert.equal(collector.getData(page)[0], navRequest);
280+
assert.equal(collector.getData(page)[1], navRequest2);
281+
282+
page.emit('framenavigated', mainFrame);
283+
assert.equal(collector.getData(page).length, 1);
284+
assert.equal(collector.getData(page)[0], navRequest2);
285+
286+
page.emit('request', request);
287+
assert.equal(collector.getData(page).length, 2);
288+
});
253289
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
exports[`network > network_get_request > should get request from previous navigations 1`] = `
2+
# get_request response
3+
## Request http://localhost:<port>/one
4+
Status: [success - 200]
5+
### Request Headers
6+
- accept-language:en-US,en;q=0.9
7+
- upgrade-insecure-requests:1
8+
- user-agent:<user-agent>
9+
- sec-ch-ua:"Chromium";v="<version>", "Not?A_Brand";v="8"
10+
- sec-ch-ua-mobile:?0
11+
- sec-ch-ua-platform:"<os>"
12+
### Response Headers
13+
- connection:keep-alive
14+
- content-length:239
15+
- content-type:text/html; charset=utf-8
16+
- date:<long date>
17+
- keep-alive:timeout=5
18+
### Response Body
19+
<not available anymore>
20+
`;
21+
22+
exports[`network > network_list_requests > list requests form current navigations only 1`] = `
23+
# list_request response
24+
## Network requests
25+
Showing 1-1 of 1 (Page 1 of 1).
26+
reqid=3 GET http://localhost:<port>/three [success - 200]
27+
`;
28+
29+
exports[`network > network_list_requests > list requests from previous navigations 1`] = `
30+
# list_request response
31+
## Network requests
32+
Showing 1-3 of 3 (Page 1 of 1).
33+
reqid=1 GET http://localhost:<port>/one [success - 200]
34+
reqid=2 GET http://localhost:<port>/two [success - 200]
35+
reqid=3 GET http://localhost:<port>/three [success - 200]
36+
`;

tests/tools/network.test.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import {
1010
getNetworkRequest,
1111
listNetworkRequests,
1212
} from '../../src/tools/network.js';
13-
import {withBrowser} from '../utils.js';
13+
import {serverHooks} from '../server.js';
14+
import {html, withBrowser, stabilizeResponseOutput} from '../utils.js';
1415

1516
describe('network', () => {
17+
const server = serverHooks();
1618
describe('network_list_requests', () => {
1719
it('list requests', async () => {
1820
await withBrowser(async (response, context) => {
@@ -21,23 +23,72 @@ describe('network', () => {
2123
assert.strictEqual(response.networkRequestsPageIdx, undefined);
2224
});
2325
});
26+
27+
it('list requests form current navigations only', async t => {
28+
server.addHtmlRoute('/one', html`<main>First</main>`);
29+
server.addHtmlRoute('/two', html`<main>Second</main>`);
30+
server.addHtmlRoute('/three', html`<main>Third</main>`);
31+
32+
await withBrowser(async (response, context) => {
33+
await context.setUpNetworkCollectorForTesting();
34+
const page = context.getSelectedPage();
35+
await page.goto(server.getRoute('/one'));
36+
await page.goto(server.getRoute('/two'));
37+
await page.goto(server.getRoute('/three'));
38+
await listNetworkRequests.handler(
39+
{
40+
params: {},
41+
},
42+
response,
43+
context,
44+
);
45+
const responseData = await response.handle('list_request', context);
46+
t.assert.snapshot?.(stabilizeResponseOutput(responseData[0].text));
47+
});
48+
});
49+
50+
it('list requests from previous navigations', async t => {
51+
server.addHtmlRoute('/one', html`<main>First</main>`);
52+
server.addHtmlRoute('/two', html`<main>Second</main>`);
53+
server.addHtmlRoute('/three', html`<main>Third</main>`);
54+
55+
await withBrowser(async (response, context) => {
56+
await context.setUpNetworkCollectorForTesting();
57+
const page = context.getSelectedPage();
58+
await page.goto(server.getRoute('/one'));
59+
await page.goto(server.getRoute('/two'));
60+
await page.goto(server.getRoute('/three'));
61+
await listNetworkRequests.handler(
62+
{
63+
params: {
64+
includePreviousNavigations: true,
65+
},
66+
},
67+
response,
68+
context,
69+
);
70+
const responseData = await response.handle('list_request', context);
71+
t.assert.snapshot?.(stabilizeResponseOutput(responseData[0].text));
72+
});
73+
});
2474
});
2575
describe('network_get_request', () => {
2676
it('attaches request', async () => {
2777
await withBrowser(async (response, context) => {
28-
const page = await context.getSelectedPage();
78+
const page = context.getSelectedPage();
2979
await page.goto('data:text/html,<div>Hello MCP</div>');
3080
await getNetworkRequest.handler(
3181
{params: {reqid: 1}},
3282
response,
3383
context,
3484
);
85+
3586
assert.equal(response.attachedNetworkRequestId, 1);
3687
});
3788
});
3889
it('should not add the request list', async () => {
3990
await withBrowser(async (response, context) => {
40-
const page = await context.getSelectedPage();
91+
const page = context.getSelectedPage();
4192
await page.goto('data:text/html,<div>Hello MCP</div>');
4293
await getNetworkRequest.handler(
4394
{params: {reqid: 1}},
@@ -47,5 +98,30 @@ describe('network', () => {
4798
assert(!response.includeNetworkRequests);
4899
});
49100
});
101+
it('should get request from previous navigations', async t => {
102+
server.addHtmlRoute('/one', html`<main>First</main>`);
103+
server.addHtmlRoute('/two', html`<main>Second</main>`);
104+
server.addHtmlRoute('/three', html`<main>Third</main>`);
105+
106+
await withBrowser(async (response, context) => {
107+
await context.setUpNetworkCollectorForTesting();
108+
const page = context.getSelectedPage();
109+
await page.goto(server.getRoute('/one'));
110+
await page.goto(server.getRoute('/two'));
111+
await page.goto(server.getRoute('/three'));
112+
await getNetworkRequest.handler(
113+
{
114+
params: {
115+
reqid: 1,
116+
},
117+
},
118+
response,
119+
context,
120+
);
121+
const responseData = await response.handle('get_request', context);
122+
123+
t.assert.snapshot?.(stabilizeResponseOutput(responseData[0].text));
124+
});
125+
});
50126
});
51127
});

tests/tools/snapshot.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('snapshot', () => {
2121
describe('browser_wait_for', () => {
2222
it('should work', async () => {
2323
await withBrowser(async (response, context) => {
24-
const page = await context.getSelectedPage();
24+
const page = context.getSelectedPage();
2525

2626
await page.setContent(
2727
html`<main><span>Hello</span><span> </span><div>World</div></main>`,
@@ -98,7 +98,7 @@ describe('snapshot', () => {
9898

9999
it('should work with iframe content', async () => {
100100
await withBrowser(async (response, context) => {
101-
const page = await context.getSelectedPage();
101+
const page = context.getSelectedPage();
102102

103103
await page.setContent(
104104
html`<h1>Top level</h1>

0 commit comments

Comments
 (0)