fix: discover third-party developer tools before tool lookup (#3208)

Run tool discovery via `discoverThirdPartyDeveloperTools()` before
looking up the requested tool in `execute_3p_developer_tool`.

Previously, `execute_3p_developer_tool` looked up the tool definition in
the cached `McpPage.eventBasedThirdPartyTools` list without running
discovery. If the page reloaded itself, `window.__dtmcp` on the page was
cleared while `eventBasedThirdPartyTools` kept the stale tool
definitions, causing execution to fail with `No tools found on the
page`.

Rename `McpPage.getToolGroups()` to
`discoverThirdPartyDeveloperTools()`, remove the cached
`eventBasedThirdPartyTools` field and `getThirdPartyDeveloperTools()`,
and call `await request.page.discoverThirdPartyDeveloperTools()` in
`executeThirdPartyDeveloperTool`.

Fixes #2973
This commit is contained in:
Wolfgang Beyer authored and GitHub committed 2026-10-16 17:18:47 +00:00
1 parent 552e1f8d25
commit 2777fa05b2
8 files changed
+247 -128

No files matched your search

+3 -15
View File
@@ -174,8 +174,6 @@ export class McpPage implements ContextPage {
// Screencast
#screenRecorder: ScreenRecorderData | null = null;
eventBasedThirdPartyTools: ToolGroups = [];
#networkCollector?: NetworkCollector;
#consoleCollector?: ConsoleCollector;
@@ -411,14 +409,7 @@ export class McpPage implements ContextPage {
];
}
getThirdPartyDeveloperTools(): ToolGroups {
return [
...this.#getDebuggingWebMcpToolGroups(),
...this.eventBasedThirdPartyTools,
];
}
async getToolGroups(): Promise<ToolGroups> {
async discoverThirdPartyDeveloperTools(): Promise<ToolGroups> {
// Check if there is a `devtoolstooldiscovery` event listener
using windowHandle = await this.pptrPage.evaluateHandle(() => window);
// @ts-expect-error internal API
@@ -428,8 +419,7 @@ export class McpPage implements ContextPage {
objectId: windowHandle.remoteObject().objectId,
});
if (listeners.find(l => l.type === 'devtoolstooldiscovery') === undefined) {
this.eventBasedThirdPartyTools = [];
return this.getThirdPartyDeveloperTools();
return this.#getDebuggingWebMcpToolGroups();
}
const eventToolGroups = await this.pptrPage.evaluate(() => {
@@ -515,9 +505,7 @@ export class McpPage implements ContextPage {
}
}
this.eventBasedThirdPartyTools = eventToolGroups;
return this.getThirdPartyDeveloperTools();
return [...this.#getDebuggingWebMcpToolGroups(), ...eventToolGroups];
}
getWebMcpTools(): WebMCPTool[] {
+1 -1
View File
@@ -624,7 +624,7 @@ export class McpResponse implements Response {
this.#listThirdPartyDeveloperTools &&
this.#page
) {
return await this.#page.getToolGroups();
return await this.#page.discoverThirdPartyDeveloperTools();
}
return undefined;
}
+1 -1
View File
@@ -475,7 +475,7 @@ export type ContextPage = Readonly<{
},
): Promise<WaitForEventsResult>;
getWebMcpTools(): WebMCPTool[];
getThirdPartyDeveloperTools(): ToolGroups;
discoverThirdPartyDeveloperTools(): Promise<ToolGroups>;
executeThirdPartyDeveloperTool(
toolName: string,
+1 -1
View File
@@ -101,7 +101,7 @@ export const executeThirdPartyDeveloperTool = definePageTool(() => ({
}
}
const toolGroups = request.page.getThirdPartyDeveloperTools();
const toolGroups = await request.page.discoverThirdPartyDeveloperTools();
let tool;
if (toolGroups) {
for (const group of toolGroups) {
+23 -15
View File
@@ -327,8 +327,8 @@ describe('McpPage', () => {
sinon.assert.notCalled(target.page);
sinon.assert.notCalled(target.asPage);
assert.throws(() => mcpPage.pptrPage, /not initialized/);
assert.throws(
() => mcpPage.getThirdPartyDeveloperTools(),
await assert.rejects(
() => mcpPage.discoverThirdPartyDeveloperTools(),
/not initialized/,
);
assert.throws(() => mcpPage.getWebMcpTools(), /not initialized/);
@@ -1476,7 +1476,7 @@ describe('McpPage', () => {
sinon.restore();
});
it('filters WebMCP tools by annotations.debugging in getToolGroups()', async () => {
it('filters WebMCP tools by annotations.debugging in discoverThirdPartyDeveloperTools()', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
const debuggingTool = createMockWebMCPTool({
@@ -1502,7 +1502,7 @@ describe('McpPage', () => {
unannotatedTool,
]);
const groups = await mcpPage.getToolGroups();
const groups = await mcpPage.discoverThirdPartyDeveloperTools();
assert.deepStrictEqual(groups, [
{
name: 'WebMCP Tools',
@@ -1516,10 +1516,9 @@ describe('McpPage', () => {
],
},
]);
assert.deepStrictEqual(mcpPage.getThirdPartyDeveloperTools(), groups);
});
it('combines WebMCP debugging tools and devtoolstooldiscovery tools in getThirdPartyDeveloperTools()', async () => {
it('combines WebMCP debugging tools and devtoolstooldiscovery tools in discoverThirdPartyDeveloperTools()', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
const debuggingTool = createMockWebMCPTool({
@@ -1528,7 +1527,10 @@ describe('McpPage', () => {
annotations: {debugging: true},
});
pptrPage.webmcp.tools.returns([debuggingTool]);
mcpPage.eventBasedThirdPartyTools = [
pptrPage.cdpSession.send.resolves({
listeners: [{type: 'devtoolstooldiscovery'}],
});
pptrPage.evaluate.resolves([
{
name: 'Custom Tools',
description: 'Tools exposed via event',
@@ -1540,17 +1542,16 @@ describe('McpPage', () => {
type: 'object',
properties: {
el: {
properties: {uid: {type: 'string'}},
required: ['uid'],
'x-mcp-type': 'HTMLElement',
},
},
},
},
],
},
];
]);
const groups = mcpPage.getThirdPartyDeveloperTools();
const groups = await mcpPage.discoverThirdPartyDeveloperTools();
assert.deepStrictEqual(groups, [
{
name: 'WebMCP Tools',
@@ -1574,6 +1575,7 @@ describe('McpPage', () => {
type: 'object',
properties: {
el: {
'x-mcp-type': 'HTMLElement',
properties: {uid: {type: 'string'}},
required: ['uid'],
},
@@ -1585,10 +1587,13 @@ describe('McpPage', () => {
]);
});
it('reflects dynamically added and removed WebMCP debugging tools in getThirdPartyDeveloperTools()', async () => {
it('reflects dynamically added and removed WebMCP debugging tools in discoverThirdPartyDeveloperTools()', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
assert.deepStrictEqual(mcpPage.getThirdPartyDeveloperTools(), []);
assert.deepStrictEqual(
await mcpPage.discoverThirdPartyDeveloperTools(),
[],
);
const debuggingTool = createMockWebMCPTool({
name: 'dynamic_debug_tool',
@@ -1598,7 +1603,7 @@ describe('McpPage', () => {
});
pptrPage.webmcp.tools.returns([debuggingTool]);
assert.deepStrictEqual(mcpPage.getThirdPartyDeveloperTools(), [
assert.deepStrictEqual(await mcpPage.discoverThirdPartyDeveloperTools(), [
{
name: 'WebMCP Tools',
description: 'Tools exposed via WebMCP',
@@ -1613,7 +1618,10 @@ describe('McpPage', () => {
]);
pptrPage.webmcp.tools.returns([]);
assert.deepStrictEqual(mcpPage.getThirdPartyDeveloperTools(), []);
assert.deepStrictEqual(
await mcpPage.discoverThirdPartyDeveloperTools(),
[],
);
});
it('executes a WebMCP tool in executeThirdPartyDeveloperTool()', async () => {
+3 -3
View File
@@ -1127,7 +1127,7 @@ describe('third-party developer tools', () => {
categoryExperimentalThirdParty: true,
});
const page = createMockMcpPage();
page.getToolGroups.resolves([
page.discoverThirdPartyDeveloperTools.resolves([
{
name: 'My Tool Group',
description: 'A group of tools',
@@ -1165,7 +1165,7 @@ describe('third-party developer tools', () => {
createMockParsedArguments({categoryExperimentalThirdParty: true}),
);
const page = createMockMcpPage();
page.getToolGroups.resolves([]);
page.discoverThirdPartyDeveloperTools.resolves([]);
response.setPage(page);
response.setListThirdPartyDeveloperTools();
@@ -1192,7 +1192,7 @@ describe('third-party developer tools', () => {
const page = createMockMcpPage();
Object.defineProperty(page, 'id', {value: 1});
page.url.returns('about:blank');
page.getToolGroups.resolves([
page.discoverThirdPartyDeveloperTools.resolves([
{
name: 'Tool group name',
description: 'Test tools',
+15 -1
View File
@@ -81,6 +81,13 @@ export type MockPuppeteerPage = sinon.SinonStubbedInstance<Page> & {
tracing: sinon.SinonStubbedInstance<Tracing>;
mouse: sinon.SinonStubbedInstance<Mouse>;
accessibility: sinon.SinonStubbedInstance<Accessibility>;
cdpSession: {
on: sinon.SinonStub;
off: sinon.SinonStub;
send: sinon.SinonStub;
target: sinon.SinonStub;
emit: (eventName: string | symbol | number, data?: unknown) => void;
};
};
export type MockMcpPage = sinon.SinonStubbedInstance<McpPage> & {
@@ -346,7 +353,13 @@ export function createMockPuppeteerPage(): MockPuppeteerPage {
page.emulateFocusedPage.resolves();
page.evaluateHandle.resolves(createMockElementHandle().handle);
return Object.assign(page, {webmcp, tracing, mouse, accessibility});
return Object.assign(page, {
webmcp,
tracing,
mouse,
accessibility,
cdpSession,
});
}
export function createMockWebMCPTool(options: {
@@ -438,6 +451,7 @@ export function createMockMcpPage(
return {};
});
page.getWebMcpTools.returns([]);
page.discoverThirdPartyDeveloperTools.resolves([]);
Object.defineProperty(page, 'pptrPage', {
value: pptrPage,
writable: true,
+200 -91
View File
@@ -243,7 +243,7 @@ describe('thirdPartyDeveloperTools', () => {
);
});
it('clears window.__dtmcp.toolGroups on subsequent getToolGroups calls', async () => {
it('clears window.__dtmcp.toolGroups on subsequent discoverThirdPartyDeveloperTools calls', async () => {
await withMcpContext(
async (response, context, args) => {
const page = await context.newPage();
@@ -320,7 +320,7 @@ describe('thirdPartyDeveloperTools', () => {
const {page, context, response, args} = createHandlerMocks({
categoryExperimentalThirdParty: true,
});
page.getThirdPartyDeveloperTools.returns([
page.discoverThirdPartyDeveloperTools.resolves([
{
name: 'test-group',
description: 'test description',
@@ -352,19 +352,24 @@ describe('thirdPartyDeveloperTools', () => {
context,
);
sinon.assert.calledOnceWithExactly(page.discoverThirdPartyDeveloperTools);
sinon.assert.calledOnceWithExactly(
page.executeThirdPartyDeveloperTool,
'test-tool',
{arg: 'value'},
response,
);
sinon.assert.callOrder(
page.discoverThirdPartyDeveloperTools,
page.executeThirdPartyDeveloperTool,
);
});
it('throws if tool not found in list', async () => {
const {page, context, response, args} = createHandlerMocks({
categoryExperimentalThirdParty: true,
});
page.getThirdPartyDeveloperTools.returns([
page.discoverThirdPartyDeveloperTools.resolves([
{
name: 'test-group',
description: 'test description',
@@ -389,6 +394,7 @@ describe('thirdPartyDeveloperTools', () => {
{message: /Tool missing-tool not found/},
);
sinon.assert.calledOnceWithExactly(page.discoverThirdPartyDeveloperTools);
sinon.assert.notCalled(page.executeThirdPartyDeveloperTool);
});
@@ -414,7 +420,7 @@ describe('thirdPartyDeveloperTools', () => {
const {page, context, response, args} = createHandlerMocks({
categoryExperimentalThirdParty: true,
});
page.getThirdPartyDeveloperTools.returns([
page.discoverThirdPartyDeveloperTools.resolves([
{
name: 'test-group',
description: 'test description',
@@ -451,6 +457,7 @@ describe('thirdPartyDeveloperTools', () => {
{message: /Invalid parameters for tool test-tool/},
);
sinon.assert.calledOnceWithExactly(page.discoverThirdPartyDeveloperTools);
sinon.assert.notCalled(page.executeThirdPartyDeveloperTool);
});
@@ -502,8 +509,8 @@ describe('thirdPartyDeveloperTools', () => {
const page = await context.newPage();
response.setPage(page);
page.eventBasedThirdPartyTools = [
{
await page.pptrPage.evaluate(() => {
const mockToolGroup = {
name: 'test-group',
description: 'test description',
tools: [
@@ -517,32 +524,29 @@ describe('thirdPartyDeveloperTools', () => {
},
required: ['element'],
},
execute: async (toolArgs: Record<string, unknown>) => {
const el = toolArgs.element;
if (el instanceof HTMLElement) {
return {
isElement: true,
tagName: el.tagName,
id: el.id,
};
}
return {
isElement: false,
tagName: '',
id: '',
};
},
},
],
},
];
await page.pptrPage.evaluate(() => {
window.__dtmcp = {
executeTool: async (
_name: string,
args: Record<string, unknown>,
) => {
const el = args.element;
if (el instanceof HTMLElement) {
return {
isElement: true,
tagName: el.tagName,
id: el.id,
};
}
return {
isElement: false,
tagName: '',
id: '',
};
},
};
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
if ('respondWith' in e && typeof e.respondWith === 'function') {
e.respondWith(mockToolGroup);
}
});
});
await page.pptrPage.evaluate(() => {
@@ -742,8 +746,8 @@ describe('thirdPartyDeveloperTools', () => {
const page = await context.newPage();
response.setPage(page);
page.eventBasedThirdPartyTools = [
{
await page.pptrPage.evaluate(() => {
const mockToolGroup = {
name: 'test-group',
description: 'test description',
tools: [
@@ -751,32 +755,32 @@ describe('thirdPartyDeveloperTools', () => {
name: 'test-tool',
description: 'test tool description',
inputSchema: {},
execute: async () => {
const circularArr: unknown[] = ['item'];
circularArr.push(circularArr);
const sharedObj = {sharedKey: 'sharedVal'};
const nullProtoObj: Record<string, unknown> =
Object.create(null);
nullProtoObj.key = 'val';
const AnonymousClass = (() => class {})();
return {
sym: Symbol('test-sym'),
big: BigInt(42),
circularArr,
shared1: sharedObj,
shared2: sharedObj,
nullProtoObj,
anonInstance: new AnonymousClass(),
};
},
},
],
},
];
await page.pptrPage.evaluate(() => {
window.__dtmcp = {
executeTool: async () => {
const circularArr: unknown[] = ['item'];
circularArr.push(circularArr);
const sharedObj = {sharedKey: 'sharedVal'};
const nullProtoObj: Record<string, unknown> =
Object.create(null);
nullProtoObj.key = 'val';
const AnonymousClass = (() => class {})();
return {
sym: Symbol('test-sym'),
big: BigInt(42),
circularArr,
shared1: sharedObj,
shared2: sharedObj,
nullProtoObj,
anonInstance: new AnonymousClass(),
};
},
};
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
if ('respondWith' in e && typeof e.respondWith === 'function') {
e.respondWith(mockToolGroup);
}
});
});
await executeThirdPartyDeveloperTool(args).handler(
@@ -818,8 +822,8 @@ describe('thirdPartyDeveloperTools', () => {
const page = await context.newPage();
response.setPage(page);
page.eventBasedThirdPartyTools = [
{
await page.pptrPage.evaluate(() => {
const mockToolGroup = {
name: 'test-group',
description: 'test description',
tools: [
@@ -827,20 +831,20 @@ describe('thirdPartyDeveloperTools', () => {
name: 'test-tool',
description: 'test tool description',
inputSchema: {},
execute: async () => {
const div = document.createElement('div');
div.id = 'test-element';
document.body.appendChild(div);
return div;
},
},
],
},
];
await page.pptrPage.evaluate(() => {
window.__dtmcp = {
executeTool: async () => {
const div = document.createElement('div');
div.id = 'test-element';
document.body.appendChild(div);
return div;
},
};
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
if ('respondWith' in e && typeof e.respondWith === 'function') {
e.respondWith(mockToolGroup);
}
});
});
await executeThirdPartyDeveloperTool(args).handler(
@@ -871,8 +875,8 @@ describe('thirdPartyDeveloperTools', () => {
const page = await context.newPage();
response.setPage(page);
page.eventBasedThirdPartyTools = [
{
await page.pptrPage.evaluate(() => {
const mockToolGroup = {
name: 'test-group',
description: 'test description',
tools: [
@@ -880,20 +884,20 @@ describe('thirdPartyDeveloperTools', () => {
name: 'test-tool',
description: 'test tool description',
inputSchema: {},
execute: async () => {
const div = document.createElement('div');
div.id = 'test-element';
document.body.appendChild(div);
return div;
},
},
],
},
];
await page.pptrPage.evaluate(() => {
window.__dtmcp = {
executeTool: async () => {
const div = document.createElement('div');
div.id = 'test-element';
document.body.appendChild(div);
return div;
},
};
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
if ('respondWith' in e && typeof e.respondWith === 'function') {
e.respondWith(mockToolGroup);
}
});
});
await executeThirdPartyDeveloperTool(args).handler(
@@ -924,8 +928,8 @@ describe('thirdPartyDeveloperTools', () => {
const page = await context.newPage();
response.setPage(page);
page.eventBasedThirdPartyTools = [
{
await page.pptrPage.evaluate(() => {
const mockToolGroup = {
name: 'test-group',
description: 'test description',
tools: [
@@ -933,22 +937,22 @@ describe('thirdPartyDeveloperTools', () => {
name: 'test-tool',
description: 'test tool description',
inputSchema: {},
execute: async () => {
return 'simple-result';
},
},
],
},
];
await page.pptrPage.evaluate(() => {
window.__dtmcp = {
executeTool: async () => {
return 'simple-result';
},
};
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
if ('respondWith' in e && typeof e.respondWith === 'function') {
e.respondWith(mockToolGroup);
}
});
});
const stubSnapshot = sinon
.stub(TextSnapshot, 'create')
.resolves({} as TextSnapshot);
.resolves(sinon.createStubInstance(TextSnapshot));
await executeThirdPartyDeveloperTool(args).handler(
{
@@ -978,6 +982,111 @@ describe('thirdPartyDeveloperTools', () => {
);
});
it('executes a tool after the page reloads itself', async () => {
await withMcpContext(
async (response, context, args) => {
const page = await context.newPage();
response.setPage(page);
await page.pptrPage.evaluateOnNewDocument(() => {
const mockToolGroup = {
name: 'test-group',
description: 'test description',
tools: [
{
name: 'test-tool',
description: 'test tool description',
inputSchema: {},
execute: () => ({reloaded: true}),
},
],
};
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
if ('respondWith' in e && typeof e.respondWith === 'function') {
e.respondWith(mockToolGroup);
}
});
});
await page.pptrPage.reload();
await listThirdPartyDeveloperTools(args).handler(
{params: {}, page},
response,
context,
);
await response.handle(context);
await page.pptrPage.reload();
await executeThirdPartyDeveloperTool(args).handler(
{
params: {
toolName: 'test-tool',
params: JSON.stringify({}),
},
page,
},
response,
context,
);
assert.strictEqual(
response.responseLines[0],
JSON.stringify({reloaded: true}, null, 2),
);
},
undefined,
{categoryExperimentalThirdParty: true},
);
});
it('throws if tool is no longer exposed after the page reloads itself', async () => {
await withMcpContext(
async (response, context, args) => {
await setupThirdPartyDeveloperTools(response, context, args, () => {
const mockToolGroup = {
name: 'test-group',
description: 'test description',
tools: [
{
name: 'test-tool',
description: 'test tool description',
inputSchema: {},
execute: () => ({foo: 'bar'}),
},
],
};
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
if ('respondWith' in e && typeof e.respondWith === 'function') {
e.respondWith(mockToolGroup);
}
});
});
const page = context.getSelectedMcpPage();
await page.pptrPage.reload();
await assert.rejects(
async () => {
await executeThirdPartyDeveloperTool(args).handler(
{
params: {
toolName: 'test-tool',
params: JSON.stringify({}),
},
page,
},
response,
context,
);
},
{message: /Tool test-tool not found/},
);
},
undefined,
{categoryExperimentalThirdParty: true},
);
});
it('disposes old handles when executing third party developer tools', async () => {
await withMcpContext(
async (response, context, args) => {