Skip to content

Commit 3e8d922

Browse files
authored
fix: keep page ids unique across browser reconnects (#2345)
Closes #2339 Reconnecting after a browser restart builds a fresh `McpContext`, which restarted the page id counter at 1 – ids from before the restart silently resolved to unrelated pages of the new browser (repro in #2339). Two changes: - The new context continues the id counter where the previous one left off (`startingPageId` option), so a stale id now fails with the existing "No page found" error and the agent re-lists - The first response after a reconnect carries a one-time note ("the browser was restarted or reconnected since the last call. Page ids have changed..."), like the #2308 fallback note. Since tool errors run through the same response formatting, the note shows up together with the very error the stale id produces Verified against the #2339 repro: the stale `select_page` now returns the note plus "No page found", and the next listing shows the new browser's pages under fresh ids. One thing I noticed but left alone: The replaced context isn't `dispose()`d on reconnect – pre-existing, and everything it holds is tied to the dead browser anyway. Happy to add that here if you'd like.
1 parent 4094e8e commit 3e8d922

7 files changed

Lines changed: 140 additions & 4 deletions

File tree

src/McpContext.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,17 @@ interface McpContextOptions {
5555
// capability. When false (default), file-writing tools are restricted to the
5656
// OS temp directory. When true, the previous permissive behavior is restored.
5757
allowUnrestrictedPaths?: boolean;
58+
// Whether this context replaces a previous one after a browser reconnect.
59+
// Surfaces a one-time note in the next response.
60+
reconnected?: boolean;
5861
}
5962

63+
// Page ids are handed out from a process-wide counter so they stay unique
64+
// across all contexts, in particular across browser reconnects. An id issued
65+
// before a reconnect then fails to resolve instead of hitting an unrelated
66+
// page of the reconnected browser.
67+
let nextPageId = 1;
68+
6069
export class McpContext implements Context {
6170
browser: Browser;
6271
logger: Logger;
@@ -78,7 +87,7 @@ export class McpContext implements Context {
7887
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
7988
null;
8089

81-
#nextPageId = 1;
90+
#reconnectNotice = false;
8291
#extensionPages = new WeakMap<Target, Page>();
8392

8493
#extensionServiceWorkerMap = new WeakMap<Target, string>();
@@ -109,6 +118,7 @@ export class McpContext implements Context {
109118
this.#locatorClass = locatorClass;
110119
this.#options = options;
111120
this.#allowUnrestrictedPaths = options.allowUnrestrictedPaths ?? false;
121+
this.#reconnectNotice = options.reconnected ?? false;
112122

113123
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(
114124
this.browser,
@@ -185,6 +195,10 @@ export class McpContext implements Context {
185195
return context;
186196
}
187197

198+
static resetPageIdsForTesting(): void {
199+
nextPageId = 1;
200+
}
201+
188202
roots(): Root[] {
189203
return [
190204
...(this.#roots ?? []),
@@ -358,6 +372,17 @@ export class McpContext implements Context {
358372
return page;
359373
}
360374

375+
/**
376+
* Returns true once if this context was created by reconnecting after the
377+
* previous browser connection was lost, so the next response can surface a
378+
* note. Cleared on first call.
379+
*/
380+
consumeReconnectNotice(): boolean {
381+
const notice = this.#reconnectNotice;
382+
this.#reconnectNotice = false;
383+
return notice;
384+
}
385+
361386
getPageById(pageId: number): McpPage {
362387
const page = this.#mcpPages.values().find(mcpPage => mcpPage.id === pageId);
363388
if (!page) {
@@ -449,7 +474,7 @@ export class McpContext implements Context {
449474
async #createMcpPage(page: Page): Promise<McpPage> {
450475
let mcpPage = this.#mcpPages.get(page);
451476
if (!mcpPage) {
452-
mcpPage = new McpPage(page, this.#nextPageId++, {
477+
mcpPage = new McpPage(page, nextPageId++, {
453478
locatorClass: this.#locatorClass,
454479
hasNetworkBlockOrAllowlist: this.#hasNetworkBlockOrAllowlist,
455480
isolatedContextName: this.#getBrowserContextToNameMap().get(

src/McpResponse.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import type {
4242
Extension,
4343
HTTPRequest,
4444
} from './third_party/index.js';
45-
import {handleDialog} from './tools/pages.js';
45+
import {handleDialog, listPages} from './tools/pages.js';
4646
import type {ToolGroups} from './tools/thirdPartyDeveloper.js';
4747
import type {
4848
DevToolsData,
@@ -269,6 +269,7 @@ export class McpResponse implements Response {
269269
#redactNetworkHeaders = true;
270270
#error?: Error;
271271
#attachedWaitForResult?: WaitForEventsResult;
272+
#reconnectNotice = false;
272273

273274
get #deviceScope(): DevTools.CrUXManager.DeviceScope {
274275
return this.#page?.viewport?.isMobile ? 'PHONE' : 'DESKTOP';
@@ -286,6 +287,14 @@ export class McpResponse implements Response {
286287
this.#redactNetworkHeaders = value;
287288
}
288289

290+
/**
291+
* Surfaces a one-time note that the browser reconnected and page ids changed.
292+
* Set by the tool handler when the context reports a pending reconnect notice.
293+
*/
294+
setReconnectNotice(): void {
295+
this.#reconnectNotice = true;
296+
}
297+
289298
attachDevToolsData(data: DevToolsData): void {
290299
this.#devToolsData = data;
291300
}
@@ -859,6 +868,7 @@ export class McpResponse implements Response {
859868
thirdPartyDeveloperTools?: object[];
860869
webmcpTools?: object[];
861870
message?: string;
871+
reconnected?: boolean;
862872
networkConditions?: string;
863873
navigationTimeout?: number;
864874
viewport?: object;
@@ -921,6 +931,12 @@ export class McpResponse implements Response {
921931
}
922932

923933
const response = [];
934+
if (this.#reconnectNotice) {
935+
structuredContent.reconnected = true;
936+
response.push(
937+
`Note: the browser was restarted or reconnected since the last call. Page ids have changed. Call ${listPages().name} to see open pages.`,
938+
);
939+
}
924940
if (this.#textResponseLines.length) {
925941
structuredContent.message = this.#textResponseLines.join('\n');
926942
response.push(...this.#textResponseLines);

src/ToolHandler.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ export class ToolHandler {
219219
: new McpResponse(this.serverArgs);
220220

221221
response.setRedactNetworkHeaders(this.serverArgs.redactNetworkHeaders);
222+
if (context.consumeReconnectNotice()) {
223+
response.setReconnectNotice();
224+
}
222225
try {
223226
if (this.tool.verifyFilesSchema) {
224227
for (const key of this.tool.verifyFilesSchema) {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export async function createMcpServer(
154154
allowList: allowlist,
155155
blocklist: blocklist,
156156
allowUnrestrictedPaths: serverArgs.allowUnrestrictedPaths,
157+
// Surfaces a one-time note in the next response after a reconnect.
158+
reconnected: context !== undefined,
157159
});
158160
await updateRoots();
159161
}

tests/McpContext.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ import path from 'node:path';
1111
import {afterEach, describe, it} from 'node:test';
1212
import {pathToFileURL} from 'node:url';
1313

14+
import logger from 'debug';
15+
import {Locator} from 'puppeteer';
1416
import sinon from 'sinon';
1517

1618
import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js';
19+
import {McpContext} from '../src/McpContext.js';
1720
import {TextSnapshot} from '../src/TextSnapshot.js';
1821
import type {HTTPResponse} from '../src/third_party/index.js';
1922
import type {TraceResult} from '../src/trace-processing/parse.js';
2023

21-
import {getMockRequest, html, withMcpContext} from './utils.js';
24+
import {getMockRequest, html, withBrowser, withMcpContext} from './utils.js';
2225

2326
describe('McpContext', () => {
2427
afterEach(() => {
@@ -222,6 +225,66 @@ describe('McpContext', () => {
222225
});
223226
});
224227

228+
it('continues page ids across contexts so stale ids do not resolve', async () => {
229+
await withBrowser(async browser => {
230+
const options = {
231+
experimentalDevToolsDebugging: false,
232+
performanceCrux: false,
233+
};
234+
const first = await McpContext.from(
235+
browser,
236+
logger('test'),
237+
options,
238+
Locator,
239+
);
240+
const idBeforeReconnect = (await first.newPage()).id;
241+
first.dispose();
242+
243+
// A new context (as created after a browser reconnect) continues the
244+
// shared id counter, so an id handed out before no longer resolves and
245+
// the next page keeps counting up rather than colliding with it.
246+
const second = await McpContext.from(
247+
browser,
248+
logger('test'),
249+
options,
250+
Locator,
251+
);
252+
try {
253+
assert.throws(
254+
() => second.getPageById(idBeforeReconnect),
255+
/No page found/,
256+
);
257+
assert.ok(
258+
(await second.newPage()).id > idBeforeReconnect,
259+
'ids continue past the pre-reconnect ids',
260+
);
261+
} finally {
262+
second.dispose();
263+
}
264+
});
265+
});
266+
267+
it('reports the reconnect notice once', async () => {
268+
await withBrowser(async browser => {
269+
const context = await McpContext.from(
270+
browser,
271+
logger('test'),
272+
{
273+
experimentalDevToolsDebugging: false,
274+
performanceCrux: false,
275+
reconnected: true,
276+
},
277+
Locator,
278+
);
279+
try {
280+
assert.ok(context.consumeReconnectNotice(), 'notice available once');
281+
assert.ok(!context.consumeReconnectNotice(), 'notice does not repeat');
282+
} finally {
283+
context.dispose();
284+
}
285+
});
286+
});
287+
225288
it('should include network requests in structured content', async t => {
226289
await withMcpContext(async (response, context) => {
227290
const mockRequest = getMockRequest({

tests/McpResponse.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,32 @@ describe('McpResponse', () => {
6363
});
6464
});
6565

66+
it('includes a reconnect notice only when set', async () => {
67+
await withMcpContext(async (response, context) => {
68+
const before = await response.handle('test', context);
69+
assert.ok(
70+
!JSON.stringify(before.content).includes('Page ids have changed'),
71+
'no reconnect notice by default',
72+
);
73+
assert.ok(
74+
!(before.structuredContent as {reconnected?: boolean}).reconnected,
75+
'structuredContent is not flagged reconnected by default',
76+
);
77+
78+
response.setReconnectNotice();
79+
const after = await response.handle('test', context);
80+
assert.ok(
81+
JSON.stringify(after.content).includes('Page ids have changed'),
82+
'reconnect notice is included once set',
83+
);
84+
assert.strictEqual(
85+
(after.structuredContent as {reconnected?: boolean}).reconnected,
86+
true,
87+
'structuredContent is flagged reconnected once set',
88+
);
89+
});
90+
});
91+
6692
it('allows response text lines to be added', async t => {
6793
await withMcpContext(async (response, context) => {
6894
response.appendResponseLine('Testing 1');

tests/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export async function withMcpContext(
127127
) {
128128
await withBrowser(async browser => {
129129
TextSnapshot.resetCounter();
130+
McpContext.resetPageIdsForTesting();
130131
const response = new McpResponse(args as ParsedArguments);
131132
if (context) {
132133
context.dispose();

0 commit comments

Comments
 (0)