diff --git a/src/McpPage.ts b/src/McpPage.ts index 485e43006..2fc9590b9 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -81,6 +81,14 @@ export class McpPage implements ContextPage { this.#dialog = undefined; } + throwIfDialogOpen(): void { + if (this.#dialog) { + throw new Error( + `A dialog is open (${this.#dialog.type()}: ${this.#dialog.message()}).`, + ); + } + } + getInPageTools(): ToolGroup | undefined { return this.inPageTools; } diff --git a/src/index.ts b/src/index.ts index 125d6ffde..f98539ef5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -233,14 +233,17 @@ export async function createMcpServer( response.setRedactNetworkHeaders(serverArgs.redactNetworkHeaders); try { + const page = + serverArgs.experimentalPageIdRouting && + params.pageId && + !serverArgs.slim + ? context.getPageById(params.pageId) + : context.getSelectedMcpPage(); + response.setPage(page); + if (tool.blockedByDialog) { + page.throwIfDialogOpen(); + } if ('pageScoped' in tool && tool.pageScoped) { - const page = - serverArgs.experimentalPageIdRouting && - params.pageId && - !serverArgs.slim - ? context.getPageById(params.pageId) - : context.getSelectedMcpPage(); - response.setPage(page); await tool.handler( { params, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 91c42dcc2..8acc24be3 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -46,6 +46,7 @@ export interface BaseToolDefinition< conditions?: string[]; }; schema: Schema; + blockedByDialog: boolean; } export interface ToolDefinition< @@ -255,6 +256,7 @@ export type ContextPage = Readonly<{ getDialog(): Dialog | undefined; clearDialog(): void; + throwIfDialogOpen(): void; waitForEventsAfterAction( action: () => Promise, options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string}, diff --git a/src/tools/console.ts b/src/tools/console.ts index 83acf9f77..c0c0fd6c6 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -78,6 +78,7 @@ export const listConsoleMessages = definePageTool(cliArgs => { 'Set to true to return the preserved messages over the last 3 navigations.', ), }, + blockedByDialog: false, handler: async (request, response) => { response.setIncludeConsoleData(true, { pageSize: request.params.pageSize, @@ -103,6 +104,7 @@ export const getConsoleMessage = definePageTool({ 'The msgid of a console message on the page from the listed console messages', ), }, + blockedByDialog: false, handler: async (request, response) => { response.attachConsoleMessage(request.params.msgid); }, diff --git a/src/tools/emulation.ts b/src/tools/emulation.ts index c9d0ea859..2dbb026f2 100644 --- a/src/tools/emulation.ts +++ b/src/tools/emulation.ts @@ -66,6 +66,7 @@ export const emulate = definePageTool({ `Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.`, ), }, + blockedByDialog: true, handler: async (request, _response, context) => { const page = request.page; await context.emulate(request.params, page.pptrPage); diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index 0b7a83367..58460ae33 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -21,6 +21,7 @@ export const installExtension = defineTool({ .string() .describe('Absolute path to the unpacked extension folder.'), }, + blockedByDialog: false, handler: async (request, response, context) => { const {path} = request.params; const id = await context.installExtension(path); @@ -38,6 +39,7 @@ export const uninstallExtension = defineTool({ schema: { id: zod.string().describe('ID of the extension to uninstall.'), }, + blockedByDialog: false, handler: async (request, response, context) => { const {id} = request.params; await context.uninstallExtension(id); @@ -54,6 +56,7 @@ export const listExtensions = defineTool({ readOnlyHint: true, }, schema: {}, + blockedByDialog: false, handler: async (_request, response, _context) => { response.setListExtensions(); }, @@ -69,6 +72,7 @@ export const reloadExtension = defineTool({ schema: { id: zod.string().describe('ID of the extension to reload.'), }, + blockedByDialog: false, handler: async (request, response, context) => { const {id} = request.params; const extension = await context.getExtension(id); @@ -90,6 +94,7 @@ export const triggerExtensionAction = defineTool({ schema: { id: zod.string().describe('ID of the extension to trigger the action for.'), }, + blockedByDialog: false, handler: async (request, response, context) => { const {id} = request.params; await context.triggerExtensionAction(id); diff --git a/src/tools/inPage.ts b/src/tools/inPage.ts index 5c3398723..74f05d496 100644 --- a/src/tools/inPage.ts +++ b/src/tools/inPage.ts @@ -51,6 +51,7 @@ export const listInPageTools = definePageTool({ conditions: ['inPageTools'], }, schema: {}, + blockedByDialog: false, handler: async (_request, response, _context) => { response.setListInPageTools(); }, @@ -71,6 +72,7 @@ export const executeInPageTool = definePageTool({ .optional() .describe('The JSON-stringified parameters to pass to the tool'), }, + blockedByDialog: false, handler: async (request, response) => { const toolName = request.params.toolName; let params: Record = {}; diff --git a/src/tools/input.ts b/src/tools/input.ts index 588f5308b..62b52e7c6 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -58,6 +58,7 @@ export const click = definePageTool({ dblClick: dblClickSchema, includeSnapshot: includeSnapshotSchema, }, + blockedByDialog: true, handler: async (request, response) => { const uid = request.params.uid; const handle = await request.page.getElementByUid(uid); @@ -97,6 +98,7 @@ export const clickAt = definePageTool({ dblClick: dblClickSchema, includeSnapshot: includeSnapshotSchema, }, + blockedByDialog: true, handler: async (request, response) => { const page = request.page; await page.waitForEventsAfterAction(async () => { @@ -130,6 +132,7 @@ export const hover = definePageTool({ ), includeSnapshot: includeSnapshotSchema, }, + blockedByDialog: true, handler: async (request, response) => { const uid = request.params.uid; const handle = await request.page.getElementByUid(uid); @@ -233,6 +236,7 @@ export const fill = definePageTool({ value: zod.string().describe('The value to fill in'), includeSnapshot: includeSnapshotSchema, }, + blockedByDialog: true, handler: async (request, response, context) => { const page = request.page; await page.waitForEventsAfterAction(async () => { @@ -261,6 +265,7 @@ export const typeText = definePageTool({ text: zod.string().describe('The text to type'), submitKey: submitKeySchema, }, + blockedByDialog: true, handler: async (request, response) => { const page = request.page; await page.waitForEventsAfterAction(async () => { @@ -289,6 +294,7 @@ export const drag = definePageTool({ to_uid: zod.string().describe('The uid of the element to drop into'), includeSnapshot: includeSnapshotSchema, }, + blockedByDialog: true, handler: async (request, response) => { const fromHandle = await request.page.getElementByUid( request.params.from_uid, @@ -330,6 +336,7 @@ export const fillForm = definePageTool({ .describe('Elements from snapshot to fill out.'), includeSnapshot: includeSnapshotSchema, }, + blockedByDialog: true, handler: async (request, response, context) => { const page = request.page; for (const element of request.params.elements) { @@ -365,6 +372,7 @@ export const uploadFile = definePageTool({ filePath: zod.string().describe('The local path of the file to upload'), includeSnapshot: includeSnapshotSchema, }, + blockedByDialog: true, handler: async (request, response, context) => { const {uid, filePath} = request.params; context.validatePath(filePath); @@ -415,6 +423,7 @@ export const pressKey = definePageTool({ ), includeSnapshot: includeSnapshotSchema, }, + blockedByDialog: true, handler: async (request, response) => { const page = request.page; const tokens = parseKey(request.params.key); diff --git a/src/tools/lighthouse.ts b/src/tools/lighthouse.ts index d4fa9cfc6..fbd057c20 100644 --- a/src/tools/lighthouse.ts +++ b/src/tools/lighthouse.ts @@ -43,6 +43,7 @@ export const lighthouseAudit = definePageTool({ .optional() .describe('Directory for reports. If omitted, uses temporary files.'), }, + blockedByDialog: true, handler: async (request, response, context) => { const page = request.page; const categories = ['accessibility', 'seo', 'best-practices']; diff --git a/src/tools/memory.ts b/src/tools/memory.ts index dcc92dad2..5e8525b08 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -22,6 +22,7 @@ export const takeMemorySnapshot = definePageTool({ .string() .describe('A path to a .heapsnapshot file to save the heapsnapshot to.'), }, + blockedByDialog: true, handler: async (request, response, context) => { const page = request.page; context.validatePath(request.params.filePath); @@ -48,6 +49,7 @@ export const exploreMemorySnapshot = defineTool({ schema: { filePath: zod.string().describe('A path to a .heapsnapshot file to read.'), }, + blockedByDialog: false, handler: async (request, response, context) => { context.validatePath(request.params.filePath); const stats = await context.getHeapSnapshotStats(request.params.filePath); @@ -79,6 +81,7 @@ export const getMemorySnapshotDetails = defineTool({ .optional() .describe('The page size for pagination of aggregates.'), }, + blockedByDialog: false, handler: async (request, response, context) => { context.validatePath(request.params.filePath); const aggregates = await context.getHeapSnapshotAggregates( @@ -111,6 +114,7 @@ export const getNodesByClass = defineTool({ pageIdx: zod.number().optional().describe('The page index for pagination.'), pageSize: zod.number().optional().describe('The page size for pagination.'), }, + blockedByDialog: false, handler: async (request, response, context) => { context.validatePath(request.params.filePath); const nodes = await context.getHeapSnapshotNodesByUid( diff --git a/src/tools/network.ts b/src/tools/network.ts index 9c1510030..ca17d478e 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -70,6 +70,7 @@ export const listNetworkRequests = definePageTool({ 'Set to true to return the preserved requests over the last 3 navigations.', ), }, + blockedByDialog: false, handler: async (request, response, context) => { const data = await request.page.getDevToolsData(); response.attachDevToolsData(data); @@ -113,6 +114,7 @@ export const getNetworkRequest = definePageTool({ 'The absolute or relative path to a .network-response file to save the response body to. If omitted, the body is returned inline.', ), }, + blockedByDialog: true, handler: async (request, response, context) => { context.validatePath(request.params.requestFilePath); context.validatePath(request.params.responseFilePath); diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 92fe6afcd..ac4a15fbd 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -84,6 +84,7 @@ export const listPages = defineTool(args => { readOnlyHint: true, }, schema: {}, + blockedByDialog: false, handler: async (_request, response) => { response.setIncludePages(true); response.setListInPageTools(); @@ -110,6 +111,7 @@ export const selectPage = defineTool({ .optional() .describe('Whether to focus the page and bring it to the top.'), }, + blockedByDialog: false, handler: async (request, response, context) => { const page = context.getPageById(request.params.pageId); context.selectPage(page); @@ -134,6 +136,7 @@ export const closePage = defineTool({ .number() .describe('The ID of the page to close. Call list_pages to list pages.'), }, + blockedByDialog: false, handler: async (request, response, context) => { try { await context.closePage(request.params.pageId); @@ -185,6 +188,7 @@ export const newPage = defineTool(args => { : {}), ...timeoutSchema, }, + blockedByDialog: false, handler: async (request, response, context) => { const page = await context.newPage( request.params.background, @@ -251,6 +255,7 @@ export const navigatePage = definePageTool(args => { : {}), ...timeoutSchema, }, + blockedByDialog: false, handler: async (request, response) => { const page = request.page; const options = { @@ -385,6 +390,7 @@ export const resizePage = definePageTool({ width: zod.number().describe('Page width'), height: zod.number().describe('Page height'), }, + blockedByDialog: false, handler: async (request, response, _context) => { const page = request.page; @@ -429,6 +435,7 @@ export const handleDialog = definePageTool({ .optional() .describe('Optional prompt text to enter into the dialog.'), }, + blockedByDialog: false, handler: async (request, response, _context) => { const page = request.page; const dialog = page.getDialog(); @@ -479,6 +486,7 @@ export const getTabId = definePageTool({ `The ID of the page to get the tab ID for. Call ${listPages().name} to get available pages.`, ), }, + blockedByDialog: false, handler: async (request, response, context) => { const page = context.getPageById(request.params.pageId); const tabId = (page.pptrPage as unknown as CdpPage)._tabId; diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 7d993dc19..39f9b3186 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -48,6 +48,7 @@ export const startTrace = definePageTool({ ), filePath: filePathSchema, }, + blockedByDialog: true, handler: async (request, response, context) => { context.validatePath(request.params.filePath); if (context.isRunningPerformanceTrace()) { @@ -126,6 +127,7 @@ export const stopTrace = definePageTool({ schema: { filePath: filePathSchema, }, + blockedByDialog: true, handler: async (request, response, context) => { context.validatePath(request.params.filePath); if (!context.isRunningPerformanceTrace()) { @@ -161,6 +163,7 @@ export const analyzeInsight = definePageTool({ 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"', ), }, + blockedByDialog: false, handler: async (request, response, context) => { const lastRecording = context.recordedTraces().at(-1); if (!lastRecording) { diff --git a/src/tools/screencast.ts b/src/tools/screencast.ts index ce7143425..aa80f3f97 100644 --- a/src/tools/screencast.ts +++ b/src/tools/screencast.ts @@ -28,7 +28,6 @@ export const startScreencast = definePageTool(args => ({ annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, - conditions: ['screencast'], }, schema: { @@ -39,6 +38,7 @@ export const startScreencast = definePageTool(args => ({ `Output file path (${supportedExtensions.join(',')} are supported). Uses mkdtemp to generate a unique path if not provided.`, ), }, + blockedByDialog: false, handler: async (request, response, context) => { context.validatePath(request.params.filePath); if (context.getScreenRecorder() !== null) { @@ -102,6 +102,7 @@ export const stopScreencast = definePageTool({ conditions: ['screencast'], }, schema: {}, + blockedByDialog: false, handler: async (_request, response, context) => { const data = context.getScreenRecorder(); if (!data) { diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 5f6e25dfc..d9476aa39 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -50,6 +50,7 @@ export const screenshot = definePageTool({ 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.', ), }, + blockedByDialog: true, handler: async (request, response, context) => { context.validatePath(request.params.filePath); if (request.params.uid && request.params.fullPage) { diff --git a/src/tools/script.ts b/src/tools/script.ts index e91e3b8de..faabf5c34 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -64,6 +64,7 @@ Example with arguments: \`(el) => { } : {}), }, + blockedByDialog: true, handler: async (request, response, context) => { const { serviceWorkerId, diff --git a/src/tools/slim/tools.ts b/src/tools/slim/tools.ts index 712dcff63..c0132da19 100644 --- a/src/tools/slim/tools.ts +++ b/src/tools/slim/tools.ts @@ -18,6 +18,7 @@ export const screenshot = definePageTool({ readOnlyHint: false, }, schema: {}, + blockedByDialog: true, handler: async (request, response, context) => { const page = request.page; const screenshot = await page.pptrPage.screenshot({ @@ -42,6 +43,7 @@ export const navigate = definePageTool({ schema: { url: zod.string().describe('URL to navigate to'), }, + blockedByDialog: false, handler: async (request, response) => { const page = request.page; @@ -79,6 +81,7 @@ export const evaluate = definePageTool({ schema: { script: zod.string().describe(`JS script to run on the page`), }, + blockedByDialog: true, handler: async (request, response) => { const page = request.page; try { diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index ab3f80a61..93f6937f0 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -33,6 +33,7 @@ in the DevTools Elements panel (if any).`, 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.', ), }, + blockedByDialog: true, handler: async (request, response, context) => { context.validatePath(request.params.filePath); response.includeSnapshot({ @@ -58,6 +59,7 @@ export const waitFor = definePageTool({ ), ...timeoutSchema, }, + blockedByDialog: true, handler: async (request, response, context) => { const page = request.page; await context.waitForTextOnPage( diff --git a/src/tools/webmcp.ts b/src/tools/webmcp.ts index d7f06afec..ef753ac67 100644 --- a/src/tools/webmcp.ts +++ b/src/tools/webmcp.ts @@ -18,6 +18,7 @@ export const listWebMcpTools = definePageTool({ conditions: ['experimentalWebmcp'], }, schema: {}, + blockedByDialog: false, handler: async (_request, response, _context) => { response.setListWebMcpTools(); }, @@ -38,6 +39,7 @@ export const executeWebMcpTool = definePageTool({ .optional() .describe('The JSON-stringified parameters to pass to the WebMCP tool'), }, + blockedByDialog: false, handler: async (request, response) => { const toolName = request.params.toolName; diff --git a/tests/index.test.js.snapshot b/tests/index.test.js.snapshot index 7d6410a02..0e5c397d8 100644 --- a/tests/index.test.js.snapshot +++ b/tests/index.test.js.snapshot @@ -1,3 +1,15 @@ +exports[`e2e > Dialogs > returns blocked message when dialog is opened during tool execution 1`] = ` +{"content":[{"type":"text","text":"# Open dialog\\nalert: test dialog.\\nCall handle_dialog to handle it before continuing.\\nError: Failed to interact with the element with uid 1_1. The element did not become interactive within the configured timeout."}],"isError":true} +`; + +exports[`e2e > Dialogs > when dialog is open and tool is blocked, returns an error 1`] = ` +{"content":[{"type":"text","text":"# Open dialog\\nalert: test dialog.\\nCall handle_dialog to handle it before continuing.\\nError: A dialog is open (alert: test dialog)."}],"isError":true} +`; + +exports[`e2e > Dialogs > when dialog is open and tool is not blocked, executes tool 1`] = ` +{"content":[{"type":"text","text":"# Open dialog\\nalert: test dialog.\\nCall handle_dialog to handle it before continuing.\\n## Pages\\n1: about:blank\\n2: data:text/html,\\n3: data:text/html,

New

[selected]"}]} +`; + exports[`e2e > calls a tool 1`] = ` [{"type":"text","text":"## Pages\\n1: about:blank [selected]"}] `; @@ -5,7 +17,3 @@ exports[`e2e > calls a tool 1`] = ` exports[`e2e > calls a tool multiple times 1`] = ` [{"type":"text","text":"## Pages\\n1: about:blank [selected]"}] `; - -exports[`e2e > returns blocked message when dialog is opened during tool execution 1`] = ` -{"content":[{"type":"text","text":"# Open dialog\\nalert: test dialog.\\nCall handle_dialog to handle it before continuing.\\nError: Failed to interact with the element with uid 1_1. The element did not become interactive within the configured timeout."}],"isError":true} -`; diff --git a/tests/index.test.ts b/tests/index.test.ts index 0694b0aa7..34b35e5b6 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -246,8 +246,8 @@ describe('e2e', () => { ); }); - it('returns blocked message when dialog is opened during tool execution', async t => { - await withClient(async client => { + describe('Dialogs', () => { + async function createNewPageAndTriggerDialog(client: Client) { // Navigate to a page with a button that triggers a dialog on click await client.callTool({ name: 'new_page', @@ -273,7 +273,42 @@ describe('e2e', () => { }, }); - t.assert.snapshot?.(JSON.stringify(result)); + return result; + } + + it('returns blocked message when dialog is opened during tool execution', async t => { + await withClient(async client => { + const result = await createNewPageAndTriggerDialog(client); + t.assert.snapshot?.(JSON.stringify(result)); + }); + }); + + it('when dialog is open and tool is blocked, returns an error', async t => { + await withClient(async client => { + await createNewPageAndTriggerDialog(client); + const result = await client.callTool({ + name: 'take_screenshot', + arguments: { + filePath: '/tmp/test.png', + }, + }); + + t.assert.snapshot?.(JSON.stringify(result)); + }); + }); + + it('when dialog is open and tool is not blocked, executes tool', async t => { + await withClient(async client => { + await createNewPageAndTriggerDialog(client); + const result = await client.callTool({ + name: 'new_page', + arguments: { + url: `data:text/html,

New

`, + }, + }); + + t.assert.snapshot?.(JSON.stringify(result)); + }); }); }); }); diff --git a/tests/telemetry/toolMetricsUtils.test.ts b/tests/telemetry/toolMetricsUtils.test.ts index 2a3fa53bf..9033bee17 100644 --- a/tests/telemetry/toolMetricsUtils.test.ts +++ b/tests/telemetry/toolMetricsUtils.test.ts @@ -46,6 +46,7 @@ describe('toolMetricsUtils', () => { argStr: zod.string(), uid: zod.string(), // Should be blocked }, + blockedByDialog: false, handler: async () => { // no-op }, @@ -70,6 +71,7 @@ describe('toolMetricsUtils', () => { schema: { argEnum: zod.enum(['foo', 'bar']), }, + blockedByDialog: false, handler: async () => { // no-op },