refactor: move tool groups (#2365)

Moves the getter for tool groups to McpPage.
This commit is contained in:
Alex Rudenko
2026-07-14 17:09:55 +02:00
committed by GitHub
parent 5012b07799
commit 2d944f9f4e
4 changed files with 412 additions and 410 deletions
+150
View File
@@ -4,6 +4,57 @@
* SPDX-License-Identifier: Apache-2.0
*/
export function replaceHtmlElementsWithUids(schema: JSONSchema7Definition) {
if (typeof schema === 'boolean') {
return;
}
let isHtmlElement = false;
for (const [key, value] of Object.entries(schema)) {
if (key === 'x-mcp-type' && value === 'HTMLElement') {
isHtmlElement = true;
break;
}
}
if (isHtmlElement) {
schema.properties = {uid: {type: 'string'}};
schema.required = ['uid'];
}
if (schema.properties) {
for (const key of Object.keys(schema.properties)) {
replaceHtmlElementsWithUids(schema.properties[key]);
}
}
if (schema.items) {
if (Array.isArray(schema.items)) {
for (const item of schema.items) {
replaceHtmlElementsWithUids(item);
}
} else {
replaceHtmlElementsWithUids(schema.items);
}
}
if (schema.anyOf) {
for (const s of schema.anyOf) {
replaceHtmlElementsWithUids(s);
}
}
if (schema.allOf) {
for (const s of schema.allOf) {
replaceHtmlElementsWithUids(s);
}
}
if (schema.oneOf) {
for (const s of schema.oneOf) {
replaceHtmlElementsWithUids(s);
}
}
}
import {
createTargetUniverse,
type TargetUniverse,
@@ -27,6 +78,7 @@ import {
type ConsoleMessage,
type HTTPRequest,
type DevTools,
type JSONSchema7Definition,
} from './third_party/index.js';
import {takeSnapshot} from './tools/snapshot.js';
import type {ToolGroups} from './tools/thirdPartyDeveloper.js';
@@ -171,6 +223,104 @@ export class McpPage implements ContextPage {
return this.thirdPartyDeveloperTools;
}
async getToolGroups(): Promise<ToolGroups> {
// Check if there is a `devtoolstooldiscovery` event listener
const windowHandle = await this.pptrPage.evaluateHandle(() => window);
// @ts-expect-error internal API
const client = this.pptrPage._client();
const {listeners}: {listeners: Protocol.DOMDebugger.EventListener[]} =
await client.send('DOMDebugger.getEventListeners', {
objectId: windowHandle.remoteObject().objectId,
});
if (listeners.find(l => l.type === 'devtoolstooldiscovery') === undefined) {
return [];
}
const toolGroups = await this.pptrPage.evaluate(() => {
if (window.__dtmcp) {
window.__dtmcp.toolGroups = [];
}
return new Promise<ToolGroups>(resolve => {
const event = new CustomEvent('devtoolstooldiscovery');
const groups: ToolGroups = [];
// @ts-expect-error Adding custom property
event.respondWith = toolGroup => {
if (!window.__dtmcp) {
window.__dtmcp = {};
}
if (!window.__dtmcp.toolGroups) {
window.__dtmcp.toolGroups = [];
}
if (
typeof toolGroup.name !== 'string' ||
(toolGroup.description &&
typeof toolGroup.description !== 'string') ||
!Array.isArray(toolGroup.tools)
) {
console.error('Invalid toolGroup:', toolGroup);
return;
}
for (const tool of toolGroup.tools) {
if (
typeof tool.name !== 'string' ||
typeof tool.description !== 'string' ||
typeof tool.inputSchema !== 'object' ||
typeof tool.execute !== 'function'
) {
console.error('Invalid tool:', tool);
return;
}
}
window.__dtmcp.toolGroups.push(toolGroup);
// When receiving a toolGroup for the first time, expose a simple execution helper
if (!window.__dtmcp.executeTool) {
window.__dtmcp.executeTool = async (toolName, args) => {
if (
!window.__dtmcp?.toolGroups ||
window.__dtmcp.toolGroups.length === 0
) {
throw new Error('No tools found on the page');
}
for (const group of window.__dtmcp.toolGroups) {
const tool = group.tools?.find(t => t.name === toolName);
if (tool) {
return await tool.execute(args);
}
}
throw new Error(`Tool ${toolName} not found`);
};
}
groups.push(toolGroup);
};
window.dispatchEvent(event);
// If at least one toolGroup was added synchronously, resolve with the array.
// Otherwise, use setTimeout to allow for any microtask/asynchronous respondWith calls, or resolve with an empty array.
if (groups.length > 0) {
resolve(groups);
} else {
setTimeout(() => {
if (groups.length > 0) {
resolve(groups);
} else {
resolve([]);
}
}, 0);
}
});
});
for (const group of toolGroups) {
for (const tool of group.tools ?? []) {
replaceHtmlElementsWithUids(tool.inputSchema);
}
}
return toolGroups;
}
getWebMcpTools(): WebMCPTool[] {
return this.pptrPage.webmcp.tools();
}
+2 -157
View File
@@ -26,19 +26,13 @@ import type {McpContext} from './McpContext.js';
import type {McpPage} from './McpPage.js';
import {UncaughtError} from './PageCollector.js';
import {TextSnapshot} from './TextSnapshot.js';
import {
DevTools,
getToonEncode,
getGcfEncode,
type Protocol,
} from './third_party/index.js';
import {DevTools, getToonEncode, getGcfEncode} from './third_party/index.js';
import type {
ConsoleMessage,
ImageContent,
Page,
ResourceType,
TextContent,
JSONSchema7Definition,
Extension,
HTTPRequest,
} from './third_party/index.js';
@@ -67,155 +61,6 @@ interface TraceInsightData {
insightName: InsightName;
}
export function replaceHtmlElementsWithUids(schema: JSONSchema7Definition) {
if (typeof schema === 'boolean') {
return;
}
let isHtmlElement = false;
for (const [key, value] of Object.entries(schema)) {
if (key === 'x-mcp-type' && value === 'HTMLElement') {
isHtmlElement = true;
break;
}
}
if (isHtmlElement) {
schema.properties = {uid: {type: 'string'}};
schema.required = ['uid'];
}
if (schema.properties) {
for (const key of Object.keys(schema.properties)) {
replaceHtmlElementsWithUids(schema.properties[key]);
}
}
if (schema.items) {
if (Array.isArray(schema.items)) {
for (const item of schema.items) {
replaceHtmlElementsWithUids(item);
}
} else {
replaceHtmlElementsWithUids(schema.items);
}
}
if (schema.anyOf) {
for (const s of schema.anyOf) {
replaceHtmlElementsWithUids(s);
}
}
if (schema.allOf) {
for (const s of schema.allOf) {
replaceHtmlElementsWithUids(s);
}
}
if (schema.oneOf) {
for (const s of schema.oneOf) {
replaceHtmlElementsWithUids(s);
}
}
}
async function getToolGroups(page: McpPage): Promise<ToolGroups> {
// Check if there is a `devtoolstooldiscovery` event listener
const windowHandle = await page.pptrPage.evaluateHandle(() => window);
// @ts-expect-error internal API
const client = page.pptrPage._client();
const {listeners}: {listeners: Protocol.DOMDebugger.EventListener[]} =
await client.send('DOMDebugger.getEventListeners', {
objectId: windowHandle.remoteObject().objectId,
});
if (listeners.find(l => l.type === 'devtoolstooldiscovery') === undefined) {
return [];
}
const toolGroups = await page.pptrPage.evaluate(() => {
if (window.__dtmcp) {
window.__dtmcp.toolGroups = [];
}
return new Promise<ToolGroups>(resolve => {
const event = new CustomEvent('devtoolstooldiscovery');
const groups: ToolGroups = [];
// @ts-expect-error Adding custom property
event.respondWith = toolGroup => {
if (!window.__dtmcp) {
window.__dtmcp = {};
}
if (!window.__dtmcp.toolGroups) {
window.__dtmcp.toolGroups = [];
}
if (
typeof toolGroup.name !== 'string' ||
(toolGroup.description &&
typeof toolGroup.description !== 'string') ||
!Array.isArray(toolGroup.tools)
) {
console.error('Invalid toolGroup:', toolGroup);
return;
}
for (const tool of toolGroup.tools) {
if (
typeof tool.name !== 'string' ||
typeof tool.description !== 'string' ||
typeof tool.inputSchema !== 'object' ||
typeof tool.execute !== 'function'
) {
console.error('Invalid tool:', tool);
return;
}
}
window.__dtmcp.toolGroups.push(toolGroup);
// When receiving a toolGroup for the first time, expose a simple execution helper
if (!window.__dtmcp.executeTool) {
window.__dtmcp.executeTool = async (toolName, args) => {
if (
!window.__dtmcp?.toolGroups ||
window.__dtmcp.toolGroups.length === 0
) {
throw new Error('No tools found on the page');
}
for (const group of window.__dtmcp.toolGroups) {
const tool = group.tools?.find(t => t.name === toolName);
if (tool) {
return await tool.execute(args);
}
}
throw new Error(`Tool ${toolName} not found`);
};
}
groups.push(toolGroup);
};
window.dispatchEvent(event);
// If at least one toolGroup was added synchronously, resolve with the array.
// Otherwise, use setTimeout to allow for any microtask/asynchronous respondWith calls, or resolve with an empty array.
if (groups.length > 0) {
resolve(groups);
} else {
setTimeout(() => {
if (groups.length > 0) {
resolve(groups);
} else {
resolve([]);
}
}, 0);
}
});
});
for (const group of toolGroups) {
for (const tool of group.tools ?? []) {
replaceHtmlElementsWithUids(tool.inputSchema);
}
}
return toolGroups;
}
export class McpResponse implements Response {
#includePages = false;
#includeExtensionServiceWorkers = false;
@@ -685,7 +530,7 @@ export class McpResponse implements Response {
this.#listThirdPartyDeveloperTools &&
this.#page
) {
thirdPartyDeveloperTools = await getToolGroups(this.#page);
thirdPartyDeveloperTools = await this.#page.getToolGroups();
if (thirdPartyDeveloperTools) {
this.#page.thirdPartyDeveloperTools = thirdPartyDeveloperTools;
}
+259
View File
@@ -0,0 +1,259 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {replaceHtmlElementsWithUids} from '../src/McpPage.js';
import type {JSONSchema7Definition} from '../src/third_party/index.js';
describe('replaceHtmlElementsWithUids', () => {
it('does nothing for boolean schemas', () => {
const schemaTrue: JSONSchema7Definition = true;
const schemaFalse: JSONSchema7Definition = false;
replaceHtmlElementsWithUids(schemaTrue);
replaceHtmlElementsWithUids(schemaFalse);
assert.strictEqual(schemaTrue, true);
assert.strictEqual(schemaFalse, false);
});
it('replaces HTMLElement type with uid string', () => {
const schema: JSONSchema7Definition = {
type: 'object',
properties: {
foo: {type: 'string'},
bar: {type: 'number'},
},
required: ['foo'],
};
Object.assign(schema, {'x-mcp-type': 'HTMLElement'});
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object') {
assert.deepStrictEqual(schema.properties, {
uid: {type: 'string'},
});
assert.deepStrictEqual(schema.required, ['uid']);
} else {
assert.fail('Schema should be an object');
}
});
it('does not replace if x-mcp-type is not HTMLElement', () => {
const schema: JSONSchema7Definition = {
type: 'object',
properties: {
foo: {type: 'string'},
},
};
Object.assign(schema, {'x-mcp-type': 'OtherType'});
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object') {
assert.deepStrictEqual(schema.properties, {
foo: {type: 'string'},
});
assert.strictEqual(schema.required, undefined);
} else {
assert.fail('Schema should be an object');
}
});
it('recurses into nested properties', () => {
const schema: JSONSchema7Definition = {
type: 'object',
properties: {
element: {
type: 'object',
properties: {
foo: {type: 'string'},
},
},
other: {
type: 'string',
},
},
};
if (typeof schema === 'object' && schema.properties) {
Object.assign(schema.properties.element, {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (
typeof schema === 'object' &&
schema.properties &&
typeof schema.properties.element === 'object'
) {
const elementSchema = schema.properties.element;
assert.deepStrictEqual(elementSchema.properties, {
uid: {type: 'string'},
});
assert.deepStrictEqual(elementSchema.required, ['uid']);
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into array items (single schema object)', () => {
const schema: JSONSchema7Definition = {
type: 'array',
items: {
type: 'object',
},
};
if (typeof schema === 'object' && typeof schema.items === 'object') {
Object.assign(schema.items, {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && typeof schema.items === 'object') {
const itemsSchema = schema.items;
if (!Array.isArray(itemsSchema)) {
assert.deepStrictEqual(itemsSchema.properties, {
uid: {type: 'string'},
});
assert.deepStrictEqual(itemsSchema.required, ['uid']);
} else {
assert.fail('items should not be an array in this test case');
}
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into array items (array of schemas)', () => {
const schema: JSONSchema7Definition = {
type: 'array',
items: [
{
type: 'object',
},
{
type: 'string',
},
],
};
if (typeof schema === 'object' && Array.isArray(schema.items)) {
Object.assign(schema.items[0], {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && Array.isArray(schema.items)) {
const firstItem = schema.items[0];
if (typeof firstItem === 'object') {
assert.deepStrictEqual(firstItem.properties, {
uid: {type: 'string'},
});
assert.deepStrictEqual(firstItem.required, ['uid']);
} else {
assert.fail('First item should be an object');
}
const secondItem = schema.items[1];
if (typeof secondItem === 'object') {
assert.strictEqual(secondItem.properties, undefined);
} else {
assert.fail('Second item should be an object');
}
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into anyOf', () => {
const schema: JSONSchema7Definition = {
anyOf: [
{
type: 'object',
},
{
type: 'string',
},
],
};
if (typeof schema === 'object' && Array.isArray(schema.anyOf)) {
Object.assign(schema.anyOf[0], {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && Array.isArray(schema.anyOf)) {
const firstItem = schema.anyOf[0];
if (typeof firstItem === 'object') {
assert.deepStrictEqual(firstItem.properties, {
uid: {type: 'string'},
});
} else {
assert.fail('First item should be an object');
}
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into allOf', () => {
const schema: JSONSchema7Definition = {
allOf: [
{
type: 'object',
},
],
};
if (typeof schema === 'object' && Array.isArray(schema.allOf)) {
Object.assign(schema.allOf[0], {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && Array.isArray(schema.allOf)) {
const firstItem = schema.allOf[0];
if (typeof firstItem === 'object') {
assert.deepStrictEqual(firstItem.properties, {
uid: {type: 'string'},
});
} else {
assert.fail('First item should be an object');
}
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into oneOf', () => {
const schema: JSONSchema7Definition = {
oneOf: [
{
type: 'object',
},
],
};
if (typeof schema === 'object' && Array.isArray(schema.oneOf)) {
Object.assign(schema.oneOf[0], {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && Array.isArray(schema.oneOf)) {
const firstItem = schema.oneOf[0];
if (typeof firstItem === 'object') {
assert.deepStrictEqual(firstItem.properties, {
uid: {type: 'string'},
});
} else {
assert.fail('First item should be an object');
}
} else {
assert.fail('Unexpected schema structure');
}
});
});
+1 -253
View File
@@ -15,11 +15,7 @@ import sinon from 'sinon';
import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js';
import type {McpContext} from '../src/McpContext.js';
import type {McpResponse} from '../src/McpResponse.js';
import {replaceHtmlElementsWithUids} from '../src/McpResponse.js';
import type {
Extension,
JSONSchema7Definition,
} from '../src/third_party/index.js';
import type {Extension} from '../src/third_party/index.js';
import {
closePage,
listPages,
@@ -1254,254 +1250,6 @@ describe('third-party developer tools', () => {
});
});
describe('replaceHtmlElementsWithUids', () => {
it('does nothing for boolean schemas', () => {
const schemaTrue: JSONSchema7Definition = true;
const schemaFalse: JSONSchema7Definition = false;
replaceHtmlElementsWithUids(schemaTrue);
replaceHtmlElementsWithUids(schemaFalse);
assert.strictEqual(schemaTrue, true);
assert.strictEqual(schemaFalse, false);
});
it('replaces HTMLElement type with uid string', () => {
const schema: JSONSchema7Definition = {
type: 'object',
properties: {
foo: {type: 'string'},
bar: {type: 'number'},
},
required: ['foo'],
};
Object.assign(schema, {'x-mcp-type': 'HTMLElement'});
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object') {
assert.deepStrictEqual(schema.properties, {
uid: {type: 'string'},
});
assert.deepStrictEqual(schema.required, ['uid']);
} else {
assert.fail('Schema should be an object');
}
});
it('does not replace if x-mcp-type is not HTMLElement', () => {
const schema: JSONSchema7Definition = {
type: 'object',
properties: {
foo: {type: 'string'},
},
};
Object.assign(schema, {'x-mcp-type': 'OtherType'});
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object') {
assert.deepStrictEqual(schema.properties, {
foo: {type: 'string'},
});
assert.strictEqual(schema.required, undefined);
} else {
assert.fail('Schema should be an object');
}
});
it('recurses into nested properties', () => {
const schema: JSONSchema7Definition = {
type: 'object',
properties: {
element: {
type: 'object',
properties: {
foo: {type: 'string'},
},
},
other: {
type: 'string',
},
},
};
if (typeof schema === 'object' && schema.properties) {
Object.assign(schema.properties.element, {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (
typeof schema === 'object' &&
schema.properties &&
typeof schema.properties.element === 'object'
) {
const elementSchema = schema.properties.element;
assert.deepStrictEqual(elementSchema.properties, {
uid: {type: 'string'},
});
assert.deepStrictEqual(elementSchema.required, ['uid']);
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into array items (single schema object)', () => {
const schema: JSONSchema7Definition = {
type: 'array',
items: {
type: 'object',
},
};
if (typeof schema === 'object' && typeof schema.items === 'object') {
Object.assign(schema.items, {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && typeof schema.items === 'object') {
const itemsSchema = schema.items;
if (!Array.isArray(itemsSchema)) {
assert.deepStrictEqual(itemsSchema.properties, {
uid: {type: 'string'},
});
assert.deepStrictEqual(itemsSchema.required, ['uid']);
} else {
assert.fail('items should not be an array in this test case');
}
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into array items (array of schemas)', () => {
const schema: JSONSchema7Definition = {
type: 'array',
items: [
{
type: 'object',
},
{
type: 'string',
},
],
};
if (typeof schema === 'object' && Array.isArray(schema.items)) {
Object.assign(schema.items[0], {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && Array.isArray(schema.items)) {
const firstItem = schema.items[0];
if (typeof firstItem === 'object') {
assert.deepStrictEqual(firstItem.properties, {
uid: {type: 'string'},
});
assert.deepStrictEqual(firstItem.required, ['uid']);
} else {
assert.fail('First item should be an object');
}
const secondItem = schema.items[1];
if (typeof secondItem === 'object') {
assert.strictEqual(secondItem.properties, undefined);
} else {
assert.fail('Second item should be an object');
}
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into anyOf', () => {
const schema: JSONSchema7Definition = {
anyOf: [
{
type: 'object',
},
{
type: 'string',
},
],
};
if (typeof schema === 'object' && Array.isArray(schema.anyOf)) {
Object.assign(schema.anyOf[0], {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && Array.isArray(schema.anyOf)) {
const firstItem = schema.anyOf[0];
if (typeof firstItem === 'object') {
assert.deepStrictEqual(firstItem.properties, {
uid: {type: 'string'},
});
} else {
assert.fail('First item should be an object');
}
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into allOf', () => {
const schema: JSONSchema7Definition = {
allOf: [
{
type: 'object',
},
],
};
if (typeof schema === 'object' && Array.isArray(schema.allOf)) {
Object.assign(schema.allOf[0], {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && Array.isArray(schema.allOf)) {
const firstItem = schema.allOf[0];
if (typeof firstItem === 'object') {
assert.deepStrictEqual(firstItem.properties, {
uid: {type: 'string'},
});
} else {
assert.fail('First item should be an object');
}
} else {
assert.fail('Unexpected schema structure');
}
});
it('recurses into oneOf', () => {
const schema: JSONSchema7Definition = {
oneOf: [
{
type: 'object',
},
],
};
if (typeof schema === 'object' && Array.isArray(schema.oneOf)) {
Object.assign(schema.oneOf[0], {'x-mcp-type': 'HTMLElement'});
}
replaceHtmlElementsWithUids(schema);
if (typeof schema === 'object' && Array.isArray(schema.oneOf)) {
const firstItem = schema.oneOf[0];
if (typeof firstItem === 'object') {
assert.deepStrictEqual(firstItem.properties, {
uid: {type: 'string'},
});
} else {
assert.fail('First item should be an object');
}
} else {
assert.fail('Unexpected schema structure');
}
});
});
describe('webmcp', () => {
async function testIncludesWebmcpTools(
t: it.TestContext,