fix: add proactive tool rejection when dialog is open (#1978)

Adding proactive rejection of tool execution for tools which currently
get blocked due to open dialogs (related to #1069 )
This commit is contained in:
Samiya Caur
2026-04-29 15:59:50 +02:00
committed by GitHub
parent 38d736a129
commit 6ce254e541
22 changed files with 120 additions and 15 deletions
+8
View File
@@ -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<ToolDefinition> | undefined {
return this.inPageTools;
}
+10 -7
View File
@@ -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,
+2
View File
@@ -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<unknown>,
options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string},
+2
View File
@@ -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);
},
+1
View File
@@ -66,6 +66,7 @@ export const emulate = definePageTool({
`Emulate device viewports '<width>x<height>x<devicePixelRatio>[,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);
+5
View File
@@ -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);
+2
View File
@@ -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<string, unknown> = {};
+9
View File
@@ -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);
+1
View File
@@ -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'];
+4
View File
@@ -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(
+2
View File
@@ -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);
+8
View File
@@ -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;
+3
View File
@@ -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) {
+2 -1
View File
@@ -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) {
+1
View File
@@ -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) {
+1
View File
@@ -64,6 +64,7 @@ Example with arguments: \`(el) => {
}
: {}),
},
blockedByDialog: true,
handler: async (request, response, context) => {
const {
serviceWorkerId,
+3
View File
@@ -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 {
+2
View File
@@ -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(
+2
View File
@@ -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;
+12 -4
View File
@@ -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,<button id=\\"test\\" onclick=\\"alert('test dialog')\\">Click me</button>\\n3: data:text/html,<h1>New</h1> [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}
`;
+38 -3
View File
@@ -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,<h1>New</h1>`,
},
});
t.assert.snapshot?.(JSON.stringify(result));
});
});
});
});
+2
View File
@@ -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
},