Skip to content

Commit 1c1796c

Browse files
committed
execute in-page tool
1 parent 03e4cb2 commit 1c1796c

6 files changed

Lines changed: 277 additions & 5 deletions

File tree

src/McpContext.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
} from './third_party/index.js';
3232
import {Locator} from './third_party/index.js';
3333
import {PredefinedNetworkConditions} from './third_party/index.js';
34+
import type {ToolGroup, ToolDefinition} from './tools/inPage.js';
3435
import {listPages} from './tools/pages.js';
3536
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
3637
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
@@ -101,6 +102,7 @@ export class McpContext implements Context {
101102
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
102103
null;
103104

105+
#inPageTools?: ToolGroup<ToolDefinition>;
104106
#nextPageId = 1;
105107
#extensionPages = new WeakMap<Target, Page>();
106108

@@ -464,6 +466,14 @@ export class McpContext implements Context {
464466
this.#updateSelectedPageTimeouts();
465467
}
466468

469+
setInPageTools(toolGroup?: ToolGroup<ToolDefinition>) {
470+
this.#inPageTools = toolGroup;
471+
}
472+
473+
getInPageTools(): ToolGroup<ToolDefinition> | undefined {
474+
return this.#inPageTools;
475+
}
476+
467477
#updateSelectedPageTimeouts() {
468478
const page = this.#getSelectedMcpPage();
469479
// For waiters 5sec timeout should be sufficient.

src/McpResponse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ export class McpResponse implements Response {
422422
let inPageTools: ToolGroup<ToolDefinition> | undefined;
423423
if (this.#listInPageTools) {
424424
inPageTools = await getToolGroup(context.getSelectedMcpPage());
425+
context.setInPageTools(inPageTools);
425426
}
426427

427428
let consoleMessages: Array<ConsoleFormatter | IssueFormatter> | undefined;

src/third_party/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export {
2929
type TextContent,
3030
} from '@modelcontextprotocol/sdk/types.js';
3131
export {z as zod} from 'zod';
32+
export {default as ajv} from 'ajv';
3233
export {
3334
Locator,
3435
PredefinedNetworkConditions,

src/tools/ToolDefinition.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import type {InstalledExtension} from '../utils/ExtensionRegistry.js';
2424
import type {PaginationOptions} from '../utils/types.js';
2525

2626
import type {ToolCategory} from './categories.js';
27+
import type {
28+
ToolGroup,
29+
ToolDefinition as InPageToolDefinition,
30+
} from './inPage.js';
2731

2832
export interface BaseToolDefinition<
2933
Schema extends zod.ZodRawShape = zod.ZodRawShape,
@@ -194,6 +198,8 @@ export type Context = Readonly<{
194198
triggerExtensionAction(id: string): Promise<void>;
195199
listExtensions(): InstalledExtension[];
196200
getExtension(id: string): InstalledExtension | undefined;
201+
setInPageTools(toolGroup?: ToolGroup<InPageToolDefinition>): void;
202+
getInPageTools(): ToolGroup<InPageToolDefinition> | undefined;
197203
getSelectedMcpPage(): McpPage;
198204
getExtensionServiceWorkers(): ExtensionServiceWorker[];
199205
getExtensionServiceWorkerId(

src/tools/inPage.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import {type JSONSchema7} from '../third_party/index.js';
7+
import {zod, ajv, type JSONSchema7} from '../third_party/index.js';
88

99
import {ToolCategory} from './categories.js';
1010
import {definePageTool} from './ToolDefinition.js';
@@ -37,9 +37,13 @@ declare global {
3737

3838
export const listInPageTools = definePageTool({
3939
name: 'list_in_page_tools',
40-
description: `Lists all in-page-tools the page exposes for providing runtime information.
41-
To call 'list_in_page_tools', call 'evaluate_script' with
42-
'window.__dtmcp.executeTool("list_in_page_tools", {})'.`,
40+
description: `Lists all in-page tools the page exposes for providing runtime information.
41+
In-page tools can be called via the 'execute_in_page_tool()' MCP tool.
42+
Alternatively, in-page tools can be executed by calling 'evaluate_script' and adding the
43+
following command to the script:
44+
'window.__dtmcp.executeTool(toolName, params)'
45+
This might be helpful when the in-page-tools return non-serializable values or when composing
46+
the in-page-tools with additional functionality.`,
4347
annotations: {
4448
category: ToolCategory.IN_PAGE,
4549
readOnlyHint: true,
@@ -50,3 +54,55 @@ export const listInPageTools = definePageTool({
5054
response.setListInPageTools();
5155
},
5256
});
57+
58+
export const executeInPageTool = definePageTool({
59+
name: 'execute_in_page_tool',
60+
description: `Executes a tool exposed by the page.`,
61+
annotations: {
62+
category: ToolCategory.IN_PAGE,
63+
readOnlyHint: false,
64+
conditions: ['inPageTools'],
65+
},
66+
schema: {
67+
toolName: zod.string().describe('The name of the tool to execute'),
68+
params: zod
69+
.record(zod.string(), zod.unknown())
70+
.optional()
71+
.describe('The parameters to pass to the tool'),
72+
},
73+
handler: async (request, response, context) => {
74+
const page = context.getSelectedMcpPage();
75+
const toolName = request.params.toolName;
76+
const params = request.params.params ?? {};
77+
78+
const toolGroup = context.getInPageTools();
79+
const tool = toolGroup?.tools.find(t => t.name === toolName);
80+
if (!tool) {
81+
throw new Error(`Tool ${toolName} not found`);
82+
}
83+
const ajvInstance = new ajv();
84+
const validate = ajvInstance.compile(tool.inputSchema);
85+
const valid = validate(params);
86+
if (!valid) {
87+
throw new Error(
88+
`Invalid parameters for tool ${toolName}: ${ajvInstance.errorsText(validate.errors)}`,
89+
);
90+
}
91+
92+
const result = await page.pptrPage.evaluate(
93+
async (name, args) => {
94+
if (!window.__dtmcp?.executeTool) {
95+
throw new Error('No tools found on the page');
96+
}
97+
const toolResult = await window.__dtmcp.executeTool(name, args);
98+
99+
return {
100+
result: toolResult,
101+
};
102+
},
103+
toolName,
104+
params,
105+
);
106+
response.appendResponseLine(JSON.stringify(result, null, 2));
107+
},
108+
});

tests/tools/inPage.test.ts

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import assert from 'node:assert';
88
import {describe, it} from 'node:test';
99

1010
import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
11+
import type {McpContext} from '../../src/McpContext.js';
12+
import type {McpResponse} from '../../src/McpResponse.js';
1113
import type {ToolGroup, ToolDefinition} from '../../src/tools/inPage.js';
12-
import {listInPageTools} from '../../src/tools/inPage.js';
14+
import {executeInPageTool, listInPageTools} from '../../src/tools/inPage.js';
1315
import {withMcpContext} from '../utils.js';
1416

1517
describe('inPage', () => {
@@ -145,4 +147,200 @@ describe('inPage', () => {
145147
);
146148
});
147149
});
150+
151+
describe('execute_in_page_tool', () => {
152+
async function setupInPageTools(
153+
response: McpResponse,
154+
context: McpContext,
155+
evaluateFn: () => void,
156+
) {
157+
const page = await context.newPage();
158+
await page.pptrPage.evaluate(evaluateFn);
159+
await listInPageTools.handler({params: {}, page}, response, context);
160+
await response.handle('list_in_page_tools', context);
161+
}
162+
163+
it('executes a tool', async () => {
164+
await withMcpContext(
165+
async (response, context) => {
166+
await setupInPageTools(response, context, () => {
167+
window.__dtmcp = {
168+
toolGroup: {
169+
name: 'test-group',
170+
description: 'test description',
171+
tools: [
172+
{
173+
name: 'test-tool',
174+
description: 'test tool description',
175+
inputSchema: {
176+
type: 'object',
177+
properties: {
178+
arg: {type: 'string'},
179+
},
180+
required: ['arg'],
181+
},
182+
execute: () => 'result',
183+
},
184+
],
185+
},
186+
};
187+
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
188+
// @ts-expect-error Event has `respondWith`
189+
e.respondWith(window.__dtmcp?.toolGroup);
190+
});
191+
});
192+
193+
await executeInPageTool.handler(
194+
{
195+
params: {
196+
toolName: 'test-tool',
197+
params: {arg: 'value'},
198+
},
199+
page: context.getSelectedMcpPage(),
200+
},
201+
response,
202+
context,
203+
);
204+
assert.strictEqual(
205+
response.responseLines[0],
206+
JSON.stringify({result: 'result'}, null, 2),
207+
);
208+
},
209+
undefined,
210+
{categoryInPageTools: true} as ParsedArguments,
211+
);
212+
});
213+
214+
it('throws if tool not found in list', async () => {
215+
await withMcpContext(async (response, context) => {
216+
await setupInPageTools(response, context, () => {
217+
window.__dtmcp = {
218+
toolGroup: {
219+
name: 'test-group',
220+
description: 'test description',
221+
tools: [],
222+
},
223+
};
224+
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
225+
// @ts-expect-error Event has `respondWith`
226+
e.respondWith(window.__dtmcp?.toolGroup);
227+
});
228+
});
229+
230+
await assert.rejects(
231+
async () => {
232+
await executeInPageTool.handler(
233+
{
234+
params: {
235+
toolName: 'missing-tool',
236+
params: {},
237+
},
238+
page: context.getSelectedMcpPage(),
239+
},
240+
response,
241+
context,
242+
);
243+
},
244+
{message: /Tool missing-tool not found/},
245+
);
246+
});
247+
});
248+
249+
it('throws if parameters are invalid', async () => {
250+
await withMcpContext(
251+
async (response, context) => {
252+
await setupInPageTools(response, context, () => {
253+
window.__dtmcp = {
254+
toolGroup: {
255+
name: 'test-group',
256+
description: 'test description',
257+
tools: [
258+
{
259+
name: 'test-tool',
260+
description: 'test tool description',
261+
inputSchema: {
262+
type: 'object',
263+
properties: {
264+
arg: {type: 'string'},
265+
},
266+
required: ['arg'],
267+
},
268+
execute: () => 'result',
269+
},
270+
],
271+
},
272+
};
273+
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
274+
// @ts-expect-error Event has `respondWith`
275+
e.respondWith(window.__dtmcp?.toolGroup);
276+
});
277+
});
278+
279+
await assert.rejects(
280+
async () => {
281+
await executeInPageTool.handler(
282+
{
283+
params: {
284+
toolName: 'test-tool',
285+
params: {}, // Missing required 'arg'
286+
},
287+
page: context.getSelectedMcpPage(),
288+
},
289+
response,
290+
context,
291+
);
292+
},
293+
{message: /Invalid parameters for tool test-tool/},
294+
);
295+
},
296+
undefined,
297+
{categoryInPageTools: true} as ParsedArguments,
298+
);
299+
});
300+
301+
it('handles JSON result', async () => {
302+
await withMcpContext(
303+
async (response, context) => {
304+
await setupInPageTools(response, context, () => {
305+
window.__dtmcp = {
306+
toolGroup: {
307+
name: 'test-group',
308+
description: 'test description',
309+
tools: [
310+
{
311+
name: 'test-tool',
312+
description: 'test tool description',
313+
inputSchema: {},
314+
execute: () => ({foo: 'bar'}),
315+
},
316+
],
317+
},
318+
};
319+
window.addEventListener('devtoolstooldiscovery', (e: Event) => {
320+
// @ts-expect-error Event has `respondWith`
321+
e.respondWith(window.__dtmcp?.toolGroup);
322+
});
323+
});
324+
325+
await executeInPageTool.handler(
326+
{
327+
params: {
328+
toolName: 'test-tool',
329+
params: {},
330+
},
331+
page: context.getSelectedMcpPage(),
332+
},
333+
response,
334+
context,
335+
);
336+
assert.strictEqual(
337+
response.responseLines[0],
338+
JSON.stringify({result: {foo: 'bar'}}, null, 2),
339+
);
340+
},
341+
undefined,
342+
{categoryInPageTools: true} as ParsedArguments,
343+
);
344+
});
345+
});
148346
});

0 commit comments

Comments
 (0)