fix(webmcp): invalidate webmcp tools on context destruction (#15068)

This commit is contained in:
Alex Rudenko
2026-05-28 06:32:08 +00:00
committed by GitHub
parent 49daacca0b
commit 3bff0d927f
2 changed files with 91 additions and 6 deletions
+8 -6
View File
@@ -15,7 +15,6 @@ import {debugError} from '../common/util.js';
import type {CdpFrame} from './Frame.js';
import type {FrameManager} from './FrameManager.js';
import {FrameManagerEvent} from './FrameManagerEvents.js';
import {MAIN_WORLD} from './IsolatedWorlds.js';
/**
@@ -317,6 +316,7 @@ export class WebMCP extends EventEmitter<{
const frameTools = this.#tools.get(tool.frameId) ?? new Map();
if (!this.#tools.has(tool.frameId)) {
this.#tools.set(tool.frameId, frameTools);
this.#listenToContextDestroyed(frame as CdpFrame);
}
const addedTool = new WebMCPTool(this, tool, frame);
@@ -366,7 +366,7 @@ export class WebMCP extends EventEmitter<{
this.emit('toolresponded', response);
};
#onFrameNavigated = (frame: Frame) => {
#onContextDisposed = (frame: CdpFrame) => {
this.#pendingCalls.clear();
const frameTools = this.#tools.get(frame._id);
if (!frameTools) {
@@ -379,6 +379,12 @@ export class WebMCP extends EventEmitter<{
}
};
#listenToContextDestroyed(frame: CdpFrame): void {
frame.mainRealm().context?.once('disposed', () => {
this.#onContextDisposed(frame);
});
}
/**
* @internal
*/
@@ -386,10 +392,6 @@ export class WebMCP extends EventEmitter<{
super();
this.#client = client;
this.#frameManager = frameManager;
this.#frameManager.on(
FrameManagerEvent.FrameNavigated,
this.#onFrameNavigated,
);
this.#bindListeners();
}
+83
View File
@@ -302,6 +302,89 @@ describe('Page.webmcp', function () {
expect(page.webmcp.tools().length).toBe(0);
});
it('should handle multiple navigations and report tools correctly', async () => {
const {page, httpsServer} = state;
await page.goto(httpsServer.EMPTY_PAGE);
// 1. Register tool on C1
let toolsAddedPromise = new Promise<void>(resolve => {
page.webmcp.once('toolsadded', () => {
return resolve();
});
});
await page.evaluate(() => {
const form = document.createElement('form');
form.setAttribute('toolname', 'tool-1');
form.setAttribute('tooldescription', 'desc-1');
document.body.appendChild(form);
});
await toolsAddedPromise;
expect(page.webmcp.tools().length).toBe(1);
expect(page.webmcp.tools()[0]!.name).toBe('tool-1');
// 2. Navigate to C2
let toolsRemovedPromise = new Promise<void>(resolve => {
page.webmcp.once('toolsremoved', () => {
return resolve();
});
});
await page.goto(httpsServer.EMPTY_PAGE);
await toolsRemovedPromise;
expect(page.webmcp.tools().length).toBe(0);
// 3. Register tool on C2
toolsAddedPromise = new Promise<void>(resolve => {
page.webmcp.once('toolsadded', () => {
return resolve();
});
});
await page.evaluate(() => {
const form = document.createElement('form');
form.setAttribute('toolname', 'tool-2');
form.setAttribute('tooldescription', 'desc-2');
document.body.appendChild(form);
});
await toolsAddedPromise;
expect(page.webmcp.tools().length).toBe(1);
expect(page.webmcp.tools()[0]!.name).toBe('tool-2');
// 4. Navigate to C3
toolsRemovedPromise = new Promise<void>(resolve => {
page.webmcp.once('toolsremoved', () => {
return resolve();
});
});
await page.goto(httpsServer.EMPTY_PAGE);
await toolsRemovedPromise;
expect(page.webmcp.tools().length).toBe(0);
});
it('should not reset tools on same-document navigation', async () => {
const {page, httpsServer} = state;
await page.goto(httpsServer.EMPTY_PAGE);
const toolsAddedPromise = new Promise<void>(resolve => {
page.webmcp.once('toolsadded', () => {
return resolve();
});
});
await page.evaluate(() => {
const form = document.createElement('form');
form.setAttribute('toolname', 'declarative tool name');
form.setAttribute('tooldescription', 'tool description');
document.body.appendChild(form);
});
await toolsAddedPromise;
expect(page.webmcp.tools().length).toBe(1);
// Same document/hash navigation should not reset tools.
await page.goto(httpsServer.EMPTY_PAGE + '#hash');
// Tools should still be present because context was not destroyed.
expect(page.webmcp.tools().length).toBe(1);
expect(page.webmcp.tools()[0]!.name).toBe('declarative tool name');
});
it('should fire toolinvoked events', async () => {
const {page, httpsServer} = state;
await page.goto(httpsServer.EMPTY_PAGE);