From 7e9c0c6fabd87f2a69f641f6b06df6a139fa86ef Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 25 Sep 2026 09:33:29 -0700 Subject: [PATCH 1/4] feat(mcp): allow disabling core capabilities with --disable-caps Move browser_run_code_unsafe and WebMCP tools into core-run-code and core-webmcp capabilities that stay enabled by default but can be turned off via --disable-caps, PLAYWRIGHT_MCP_DISABLE_CAPS or the disabledCapabilities config field. Fixes: https://github.com/microsoft/playwright-mcp/issues/1770 --- .../src/tools/backend/context.ts | 1 + .../src/tools/backend/runCode.ts | 2 +- .../playwright-core/src/tools/backend/tab.ts | 2 +- .../playwright-core/src/tools/backend/tool.ts | 2 +- .../src/tools/backend/tools.ts | 7 ++--- .../src/tools/backend/webmcp.ts | 4 +-- .../playwright-core/src/tools/mcp/config.d.ts | 10 +++++++ .../playwright-core/src/tools/mcp/config.ts | 14 ++++++++++ .../src/tools/mcp/configIni.ts | 1 + .../playwright-core/src/tools/mcp/program.ts | 1 + tests/mcp/capabilities.spec.ts | 26 +++++++++++++++++++ tests/mcp/config-resolve.spec.ts | 13 ++++++++++ tests/mcp/webmcp-dynamic.spec.ts | 21 +++++++++++++++ 13 files changed, 96 insertions(+), 8 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 132f7e790cadf..818db89bed5a2 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -41,6 +41,7 @@ const testDebug = debug('pw:mcp:test'); export type ContextConfig = { allowUnrestrictedFileAccess?: boolean; capabilities?: ToolCapability[]; + disabledCapabilities?: ToolCapability[]; codegen?: 'typescript' | 'python' | 'java' | 'csharp' | 'none'; console?: { level?: 'error' | 'warning' | 'info' | 'debug' }; imageResponses?: 'allow' | 'omit' | 'only'; diff --git a/packages/playwright-core/src/tools/backend/runCode.ts b/packages/playwright-core/src/tools/backend/runCode.ts index 62905a7b0c9ed..a7c4ec91a6b30 100644 --- a/packages/playwright-core/src/tools/backend/runCode.ts +++ b/packages/playwright-core/src/tools/backend/runCode.ts @@ -28,7 +28,7 @@ const codeSchema = z.object({ }); const runCode = defineTabTool({ - capability: 'core', + capability: 'core-run-code', schema: { name: 'browser_run_code_unsafe', title: 'Run Playwright code (unsafe)', diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index de61c443c4ed0..82f079cf1bd7e 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -491,7 +491,7 @@ export class Tab extends EventEmitter { } async updateWebMCPTools(): Promise { - if (this.context.config.webmcp === false) + if (this.context.config.webmcp === false || this.context.config.disabledCapabilities?.includes('core-webmcp')) return; if (this._javaScriptBlocked()) return; diff --git a/packages/playwright-core/src/tools/backend/tool.ts b/packages/playwright-core/src/tools/backend/tool.ts index 0b091c4d63c21..64e3ad23b9570 100644 --- a/packages/playwright-core/src/tools/backend/tool.ts +++ b/packages/playwright-core/src/tools/backend/tool.ts @@ -30,7 +30,7 @@ type ToolSchema = { type: 'input' | 'assertion' | 'action' | 'readOnly'; }; -export type ToolCapability = 'config' | 'core' | 'core-navigation' | 'core-tabs' | 'core-input' | 'core-install' | 'network' | 'pdf' | 'storage' | 'testing' | 'vision' | 'devtools'; +export type ToolCapability = 'config' | 'core' | 'core-navigation' | 'core-tabs' | 'core-input' | 'core-install' | 'core-run-code' | 'core-webmcp' | 'network' | 'pdf' | 'storage' | 'testing' | 'vision' | 'devtools'; export type FileUploadModalState = { type: 'fileChooser'; diff --git a/packages/playwright-core/src/tools/backend/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index 7024c30b38766..fa44667c339cd 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -45,7 +45,7 @@ import wait from './wait'; import webmcp from './webmcp'; import webstorage from './webstorage'; -import type { Tool } from './tool'; +import type { Tool, ToolCapability } from './tool'; import type { ContextConfig } from './context'; export const browserTools: Tool[] = [ @@ -80,8 +80,9 @@ export const browserTools: Tool[] = [ ...webstorage, ]; -export function filteredTools(config: Pick) { - return browserTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)).filter(tool => !tool.skillOnly).map(tool => ({ +export function filteredTools(config: Pick) { + const isEnabled = (capability: ToolCapability) => (capability.startsWith('core') || config.capabilities?.includes(capability)) && !config.disabledCapabilities?.includes(capability); + return browserTools.filter(tool => isEnabled(tool.capability)).filter(tool => !tool.skillOnly).map(tool => ({ ...tool, schema: { ...tool.schema, diff --git a/packages/playwright-core/src/tools/backend/webmcp.ts b/packages/playwright-core/src/tools/backend/webmcp.ts index b8d8a7afdb5e3..fa88bc5d74e8b 100644 --- a/packages/playwright-core/src/tools/backend/webmcp.ts +++ b/packages/playwright-core/src/tools/backend/webmcp.ts @@ -257,7 +257,7 @@ export function webmcpToolsJSON(listing: WebMCPListing): Record } const webmcpList = defineTabTool({ - capability: 'core', + capability: 'core-webmcp', skillOnly: true, schema: { @@ -281,7 +281,7 @@ const webmcpList = defineTabTool({ }); const webmcpCall = defineTabTool({ - capability: 'core', + capability: 'core-webmcp', skillOnly: true, schema: { diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 0ab34a9ba4e6e..00dde5c7007ec 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -23,6 +23,8 @@ export type ToolCapability = 'core-tabs' | 'core-input' | 'core-install' | + 'core-run-code' | + 'core-webmcp' | 'network' | 'pdf' | 'storage' | @@ -136,6 +138,14 @@ export type Config = { */ capabilities?: ToolCapability[]; + /** + * List of core tool capabilities to disable. Possible values: + * - 'core-run-code': the `browser_run_code_unsafe` tool. + * - 'core-webmcp': tools registered by the page through WebMCP. + * - 'core-navigation', 'core-tabs', 'core-input', 'core-install'. + */ + disabledCapabilities?: ToolCapability[]; + /** * Whether to save the Playwright session into the output directory. */ diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 28062016e4f30..7248a9a236c6f 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -47,6 +47,7 @@ export type CLIOptions = { config?: string; consoleLevel?: 'error' | 'warning' | 'info' | 'debug'; device?: string; + disableCaps?: string[]; endpoint?: string; extension?: boolean; executablePath?: string; @@ -124,6 +125,7 @@ export type FullConfig = MergedConfig & { export async function resolveConfig(config: Config): Promise { const merged = mergeConfig(defaultConfig, config); const browser = await validateBrowserConfig(merged.browser); + validateDisabledCapabilities(merged.disabledCapabilities); return { ...merged, browser }; } @@ -144,10 +146,20 @@ export async function resolveCLIConfigForMCP(cliOptions: CLIOptions, env?: NodeJ browser.launchOptions.headless = os.platform() === 'linux' && !process.env.DISPLAY; validateOutputDir(result.outputDir); + validateDisabledCapabilities(result.disabledCapabilities); return { ...result, browser, configFile }; } +const disableableCapabilities: ToolCapability[] = ['core-navigation', 'core-tabs', 'core-input', 'core-install', 'core-run-code', 'core-webmcp']; + +function validateDisabledCapabilities(capabilities: ToolCapability[] | undefined) { + for (const capability of capabilities ?? []) { + if (!disableableCapabilities.includes(capability)) + throw new Error(`Invalid --disable-caps: ${capability}. Valid values are: ${disableableCapabilities.join(', ')}`); + } +} + function validateOutputDir(outputDir: string | undefined) { if (!outputDir) return; @@ -376,6 +388,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s allowedHosts: cliOptions.allowedHosts, }, capabilities: cliOptions.caps as ToolCapability[], + disabledCapabilities: cliOptions.disableCaps as ToolCapability[], console: { level: cliOptions.consoleLevel, }, @@ -433,6 +446,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: if (e.PLAYWRIGHT_MCP_CONSOLE_LEVEL) options.consoleLevel = enumParser<'error' | 'warning' | 'info' | 'debug'>('--console-level', ['error', 'warning', 'info', 'debug'], e.PLAYWRIGHT_MCP_CONSOLE_LEVEL); options.device = envToString(e.PLAYWRIGHT_MCP_DEVICE); + options.disableCaps = commaSeparatedList(e.PLAYWRIGHT_MCP_DISABLE_CAPS); options.executablePath = envToString(e.PLAYWRIGHT_MCP_EXECUTABLE_PATH); options.extension = envToBoolean(e.PLAYWRIGHT_MCP_EXTENSION); if (e.PLAYWRIGHT_MCP_FILE_PATHS) diff --git a/packages/playwright-core/src/tools/mcp/configIni.ts b/packages/playwright-core/src/tools/mcp/configIni.ts index c6e952ceb60c4..86946be33cd44 100644 --- a/packages/playwright-core/src/tools/mcp/configIni.ts +++ b/packages/playwright-core/src/tools/mcp/configIni.ts @@ -156,6 +156,7 @@ const longhandTypes: Record = { // top-level 'extension': 'boolean', 'capabilities': 'string[]', + 'disabledCapabilities': 'string[]', 'saveSession': 'boolean', 'saveVideo': 'size', 'sharedBrowserContext': 'boolean', diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index a921a42c94b72..052ab64de520c 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -47,6 +47,7 @@ export function decorateMCPCommand(command: Command) { .option('--config ', 'path to the configuration file.') .option('--console-level ', 'level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels.', enumParser.bind(null, '--console-level', ['error', 'warning', 'info', 'debug'])) .option('--device ', 'device to emulate, for example: "iPhone 15"') + .option('--disable-caps ', 'comma-separated list of core capabilities to disable, possible values: core-run-code, core-webmcp, core-navigation, core-tabs, core-input, core-install.', commaSeparatedList) .option('--mobile', 'emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). Mobile pages are usually lighter, which saves tokens. Cannot be combined with --device.') .option('--executable-path ', 'path to the browser executable.') .option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright Extension" to be installed.') diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index 3ec281140fa19..54359c2fa7881 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -87,3 +87,29 @@ test('legacy --vision option combined with --caps', async ({ startClient }) => { expect(toolNames).toContain('browser_mouse_move_xy'); expect(toolNames).toContain('browser_pdf_save'); }); + +test('--disable-caps removes core tools and rejects their calls', async ({ startClient }) => { + const { client } = await startClient({ + args: ['--disable-caps=core-run-code,core-tabs'], + }); + const toolNames = (await client.listTools()).tools.map(t => t.name); + expect(toolNames).not.toContain('browser_run_code_unsafe'); + expect(toolNames).not.toContain('browser_tabs'); + expect(toolNames).toContain('browser_navigate'); + expect(await client.callTool({ + name: 'browser_run_code_unsafe', + arguments: { code: 'async () => 42' }, + })).toHaveResponse({ + isError: true, + error: expect.stringContaining('Tool "browser_run_code_unsafe" not found'), + }); +}); + +test('disabledCapabilities in config file', async ({ startClient }) => { + const { client } = await startClient({ + config: { disabledCapabilities: ['core-run-code'] }, + }); + const toolNames = (await client.listTools()).tools.map(t => t.name); + expect(toolNames).not.toContain('browser_run_code_unsafe'); + expect(toolNames).toContain('browser_snapshot'); +}); diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts index 7890dd522e113..270288ced48bc 100644 --- a/tests/mcp/config-resolve.spec.ts +++ b/tests/mcp/config-resolve.spec.ts @@ -398,6 +398,19 @@ test.describe('validation', () => { await expect(resolveCLIConfigForMCP({ isolated: true, userDataDir: '/tmp/data' }, emptyEnv)) .rejects.toThrow('Browser userDataDir is not supported in isolated mode.'); }); + + test('--disable-caps is parsed from cli and env', async () => { + const options = await parseCLIOptions(['--disable-caps=core-run-code,core-webmcp']); + expect((await resolveCLIConfigForMCP(options, emptyEnv)).disabledCapabilities).toEqual(['core-run-code', 'core-webmcp']); + expect((await resolveCLIConfigForMCP({}, { PLAYWRIGHT_MCP_DISABLE_CAPS: 'core-run-code' })).disabledCapabilities).toEqual(['core-run-code']); + }); + + test('--disable-caps rejects non-core capabilities', async () => { + await expect(resolveCLIConfigForMCP({ disableCaps: ['core'] }, emptyEnv)) + .rejects.toThrow('Invalid --disable-caps: core.'); + await expect(resolveCLIConfigForMCP({ disableCaps: ['pdf'] }, emptyEnv)) + .rejects.toThrow('Invalid --disable-caps: pdf.'); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/mcp/webmcp-dynamic.spec.ts b/tests/mcp/webmcp-dynamic.spec.ts index 8e55c9fe4ceaa..2035c8d0d670f 100644 --- a/tests/mcp/webmcp-dynamic.spec.ts +++ b/tests/mcp/webmcp-dynamic.spec.ts @@ -224,3 +224,24 @@ test('--no-webmcp opts out of page tools entirely', async ({ startClient, server }); expect((await client.listTools()).tools.map(tool => tool.name)).not.toContain('webmcp_add'); }); + +test('--disable-caps=core-webmcp opts out of page tools entirely', async ({ startClient, server, mcpBrowser }) => { + server.setRoute('/', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`WebMCP${registerScript(kAdd)}`); + }); + + const { client } = await startClient({ + config: webmcpConfig(mcpBrowser), + args: ['--disable-caps=core-webmcp'], + }); + const response = await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + expect(response).toHaveResponse({ + page: expect.not.stringContaining('webmcp tool'), + }); + expect((await client.listTools()).tools.map(tool => tool.name)).not.toContain('webmcp_add'); + expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 1, b: 1 } })).toHaveResponse({ + isError: true, + error: expect.stringContaining('Tool "webmcp_add" not found'), + }); +}); From 5fc934b3e1750267c2ddd7e253ead9c5fa1d027d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 25 Sep 2026 11:05:21 -0700 Subject: [PATCH 2/4] feat(mcp): replace --disable-caps with --allowed-tools/--blocked-tools Revert the core capability split and instead filter tools by exact name. Both lists also apply to the tools registered by the page through WebMCP. --- .../src/tools/backend/context.ts | 12 +++++- .../src/tools/backend/runCode.ts | 2 +- .../playwright-core/src/tools/backend/tab.ts | 2 +- .../playwright-core/src/tools/backend/tool.ts | 2 +- .../src/tools/backend/tools.ts | 20 +++++++-- .../src/tools/backend/webmcp.ts | 4 +- .../playwright-core/src/tools/mcp/config.d.ts | 17 ++++---- .../playwright-core/src/tools/mcp/config.ts | 20 +++------ .../src/tools/mcp/configIni.ts | 3 +- .../playwright-core/src/tools/mcp/program.ts | 3 +- tests/mcp/capabilities.spec.ts | 25 +++++++---- tests/mcp/config-resolve.spec.ts | 23 ++++++----- tests/mcp/webmcp-dynamic.spec.ts | 41 ++++++++++++++++--- 13 files changed, 117 insertions(+), 57 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 818db89bed5a2..11caec29926e7 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -41,7 +41,8 @@ const testDebug = debug('pw:mcp:test'); export type ContextConfig = { allowUnrestrictedFileAccess?: boolean; capabilities?: ToolCapability[]; - disabledCapabilities?: ToolCapability[]; + allowedTools?: string[]; + blockedTools?: string[]; codegen?: 'typescript' | 'python' | 'java' | 'csharp' | 'none'; console?: { level?: 'error' | 'warning' | 'info' | 'debug' }; imageResponses?: 'allow' | 'omit' | 'only'; @@ -74,6 +75,12 @@ export type ContextConfig = { skillMode?: boolean; }; +export function isToolAllowed(config: Pick, name: string) { + if (config.blockedTools?.includes(name)) + return false; + return !config.allowedTools || config.allowedTools.includes(name); +} + type ContextOptions = { config: ContextConfig; sessionLog?: SessionLog; @@ -329,7 +336,8 @@ export class Context { currentWebMCPTools(): WebMCPToolDefinition[] { // Handlers are bound to a tab and frame, always take the fresh ones. - return this._currentTab?.webmcpTools()?.tools.map(tool => tool.mcpTool) ?? []; + const tools = this._currentTab?.webmcpTools()?.tools.map(tool => tool.mcpTool) ?? []; + return tools.filter(tool => isToolAllowed(this.config, tool.schema.name)); } maybeNotifyWebMCPToolsChanged() { diff --git a/packages/playwright-core/src/tools/backend/runCode.ts b/packages/playwright-core/src/tools/backend/runCode.ts index a7c4ec91a6b30..62905a7b0c9ed 100644 --- a/packages/playwright-core/src/tools/backend/runCode.ts +++ b/packages/playwright-core/src/tools/backend/runCode.ts @@ -28,7 +28,7 @@ const codeSchema = z.object({ }); const runCode = defineTabTool({ - capability: 'core-run-code', + capability: 'core', schema: { name: 'browser_run_code_unsafe', title: 'Run Playwright code (unsafe)', diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index 82f079cf1bd7e..de61c443c4ed0 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -491,7 +491,7 @@ export class Tab extends EventEmitter { } async updateWebMCPTools(): Promise { - if (this.context.config.webmcp === false || this.context.config.disabledCapabilities?.includes('core-webmcp')) + if (this.context.config.webmcp === false) return; if (this._javaScriptBlocked()) return; diff --git a/packages/playwright-core/src/tools/backend/tool.ts b/packages/playwright-core/src/tools/backend/tool.ts index 64e3ad23b9570..0b091c4d63c21 100644 --- a/packages/playwright-core/src/tools/backend/tool.ts +++ b/packages/playwright-core/src/tools/backend/tool.ts @@ -30,7 +30,7 @@ type ToolSchema = { type: 'input' | 'assertion' | 'action' | 'readOnly'; }; -export type ToolCapability = 'config' | 'core' | 'core-navigation' | 'core-tabs' | 'core-input' | 'core-install' | 'core-run-code' | 'core-webmcp' | 'network' | 'pdf' | 'storage' | 'testing' | 'vision' | 'devtools'; +export type ToolCapability = 'config' | 'core' | 'core-navigation' | 'core-tabs' | 'core-input' | 'core-install' | 'network' | 'pdf' | 'storage' | 'testing' | 'vision' | 'devtools'; export type FileUploadModalState = { type: 'fileChooser'; diff --git a/packages/playwright-core/src/tools/backend/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index fa44667c339cd..fad5ff6994670 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -45,7 +45,9 @@ import wait from './wait'; import webmcp from './webmcp'; import webstorage from './webstorage'; -import type { Tool, ToolCapability } from './tool'; +import { isToolAllowed } from './context'; + +import type { Tool } from './tool'; import type { ContextConfig } from './context'; export const browserTools: Tool[] = [ @@ -80,9 +82,19 @@ export const browserTools: Tool[] = [ ...webstorage, ]; -export function filteredTools(config: Pick) { - const isEnabled = (capability: ToolCapability) => (capability.startsWith('core') || config.capabilities?.includes(capability)) && !config.disabledCapabilities?.includes(capability); - return browserTools.filter(tool => isEnabled(tool.capability)).filter(tool => !tool.skillOnly).map(tool => ({ +function validateToolNames(option: string, names: string[] | undefined) { + const knownNames = new Set(browserTools.filter(tool => !tool.skillOnly).map(tool => tool.schema.name)); + for (const name of names ?? []) { + // Page-registered WebMCP tools are only known at runtime. + if (!knownNames.has(name) && !name.startsWith('webmcp_')) + throw new Error(`Unknown tool in ${option}: ${name}`); + } +} + +export function filteredTools(config: Pick) { + validateToolNames('--allowed-tools', config.allowedTools); + validateToolNames('--blocked-tools', config.blockedTools); + return browserTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)).filter(tool => !tool.skillOnly && isToolAllowed(config, tool.schema.name)).map(tool => ({ ...tool, schema: { ...tool.schema, diff --git a/packages/playwright-core/src/tools/backend/webmcp.ts b/packages/playwright-core/src/tools/backend/webmcp.ts index fa88bc5d74e8b..b8d8a7afdb5e3 100644 --- a/packages/playwright-core/src/tools/backend/webmcp.ts +++ b/packages/playwright-core/src/tools/backend/webmcp.ts @@ -257,7 +257,7 @@ export function webmcpToolsJSON(listing: WebMCPListing): Record } const webmcpList = defineTabTool({ - capability: 'core-webmcp', + capability: 'core', skillOnly: true, schema: { @@ -281,7 +281,7 @@ const webmcpList = defineTabTool({ }); const webmcpCall = defineTabTool({ - capability: 'core-webmcp', + capability: 'core', skillOnly: true, schema: { diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 00dde5c7007ec..220d1b57c534b 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -23,8 +23,6 @@ export type ToolCapability = 'core-tabs' | 'core-input' | 'core-install' | - 'core-run-code' | - 'core-webmcp' | 'network' | 'pdf' | 'storage' | @@ -139,12 +137,17 @@ export type Config = { capabilities?: ToolCapability[]; /** - * List of core tool capabilities to disable. Possible values: - * - 'core-run-code': the `browser_run_code_unsafe` tool. - * - 'core-webmcp': tools registered by the page through WebMCP. - * - 'core-navigation', 'core-tabs', 'core-input', 'core-install'. + * List of tool names to expose. When set, all other tools are disabled, including the + * tools registered by the page through WebMCP (named `webmcp_`). Tools matching + * both `allowedTools` and `blockedTools` are disabled. */ - disabledCapabilities?: ToolCapability[]; + allowedTools?: string[]; + + /** + * List of tool names to disable, for example `browser_run_code_unsafe`. Tools matching + * both `allowedTools` and `blockedTools` are disabled. + */ + blockedTools?: string[]; /** * Whether to save the Playwright session into the output directory. diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 7248a9a236c6f..bdfe20bb45ee5 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -35,8 +35,10 @@ type ViewportSize = { width: number; height: number }; export type CLIOptions = { allowedHosts?: string[]; allowedOrigins?: string[]; + allowedTools?: string[]; allowUnrestrictedFileAccess?: boolean; blockedOrigins?: string[]; + blockedTools?: string[]; blockServiceWorkers?: boolean; browser?: string; caps?: string[]; @@ -47,7 +49,6 @@ export type CLIOptions = { config?: string; consoleLevel?: 'error' | 'warning' | 'info' | 'debug'; device?: string; - disableCaps?: string[]; endpoint?: string; extension?: boolean; executablePath?: string; @@ -125,7 +126,6 @@ export type FullConfig = MergedConfig & { export async function resolveConfig(config: Config): Promise { const merged = mergeConfig(defaultConfig, config); const browser = await validateBrowserConfig(merged.browser); - validateDisabledCapabilities(merged.disabledCapabilities); return { ...merged, browser }; } @@ -146,20 +146,10 @@ export async function resolveCLIConfigForMCP(cliOptions: CLIOptions, env?: NodeJ browser.launchOptions.headless = os.platform() === 'linux' && !process.env.DISPLAY; validateOutputDir(result.outputDir); - validateDisabledCapabilities(result.disabledCapabilities); return { ...result, browser, configFile }; } -const disableableCapabilities: ToolCapability[] = ['core-navigation', 'core-tabs', 'core-input', 'core-install', 'core-run-code', 'core-webmcp']; - -function validateDisabledCapabilities(capabilities: ToolCapability[] | undefined) { - for (const capability of capabilities ?? []) { - if (!disableableCapabilities.includes(capability)) - throw new Error(`Invalid --disable-caps: ${capability}. Valid values are: ${disableableCapabilities.join(', ')}`); - } -} - function validateOutputDir(outputDir: string | undefined) { if (!outputDir) return; @@ -388,7 +378,8 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s allowedHosts: cliOptions.allowedHosts, }, capabilities: cliOptions.caps as ToolCapability[], - disabledCapabilities: cliOptions.disableCaps as ToolCapability[], + allowedTools: cliOptions.allowedTools, + blockedTools: cliOptions.blockedTools, console: { level: cliOptions.consoleLevel, }, @@ -432,8 +423,10 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: const options: CLIOptions = {}; options.allowedHosts = commaSeparatedList(e.PLAYWRIGHT_MCP_ALLOWED_HOSTS); options.allowedOrigins = semicolonSeparatedList(e.PLAYWRIGHT_MCP_ALLOWED_ORIGINS); + options.allowedTools = commaSeparatedList(e.PLAYWRIGHT_MCP_ALLOWED_TOOLS); options.allowUnrestrictedFileAccess = envToBoolean(e.PLAYWRIGHT_MCP_ALLOW_UNRESTRICTED_FILE_ACCESS); options.blockedOrigins = semicolonSeparatedList(e.PLAYWRIGHT_MCP_BLOCKED_ORIGINS); + options.blockedTools = commaSeparatedList(e.PLAYWRIGHT_MCP_BLOCKED_TOOLS); options.blockServiceWorkers = envToBoolean(e.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS); options.browser = envToString(e.PLAYWRIGHT_MCP_BROWSER); options.caps = commaSeparatedList(e.PLAYWRIGHT_MCP_CAPS); @@ -446,7 +439,6 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: if (e.PLAYWRIGHT_MCP_CONSOLE_LEVEL) options.consoleLevel = enumParser<'error' | 'warning' | 'info' | 'debug'>('--console-level', ['error', 'warning', 'info', 'debug'], e.PLAYWRIGHT_MCP_CONSOLE_LEVEL); options.device = envToString(e.PLAYWRIGHT_MCP_DEVICE); - options.disableCaps = commaSeparatedList(e.PLAYWRIGHT_MCP_DISABLE_CAPS); options.executablePath = envToString(e.PLAYWRIGHT_MCP_EXECUTABLE_PATH); options.extension = envToBoolean(e.PLAYWRIGHT_MCP_EXTENSION); if (e.PLAYWRIGHT_MCP_FILE_PATHS) diff --git a/packages/playwright-core/src/tools/mcp/configIni.ts b/packages/playwright-core/src/tools/mcp/configIni.ts index 86946be33cd44..1314cf6e5db89 100644 --- a/packages/playwright-core/src/tools/mcp/configIni.ts +++ b/packages/playwright-core/src/tools/mcp/configIni.ts @@ -156,7 +156,8 @@ const longhandTypes: Record = { // top-level 'extension': 'boolean', 'capabilities': 'string[]', - 'disabledCapabilities': 'string[]', + 'allowedTools': 'string[]', + 'blockedTools': 'string[]', 'saveSession': 'boolean', 'saveVideo': 'size', 'sharedBrowserContext': 'boolean', diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 052ab64de520c..5914b0beeee30 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -35,8 +35,10 @@ export function decorateMCPCommand(command: Command) { command .option('--allowed-hosts ', 'comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass \'*\' to disable the host check.', commaSeparatedList) .option('--allowed-origins ', 'semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all.\nImportant: *does not* serve as a security boundary and *does not* affect redirects. ', semicolonSeparatedList) + .option('--allowed-tools ', 'comma-separated list of tool names to expose, all other tools are disabled. Also applies to the tools registered by the page through WebMCP (named "webmcp_").', commaSeparatedList) .option('--allow-unrestricted-file-access', 'allow access to files outside of the workspace roots. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to workspace root directories (or cwd if no roots are configured) only, and navigation to file:// URLs is blocked.') .option('--blocked-origins ', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.\nImportant: *does not* serve as a security boundary and *does not* affect redirects.', semicolonSeparatedList) + .option('--blocked-tools ', 'comma-separated list of tool names to disable, for example "browser_run_code_unsafe". Blocklist is evaluated before allowlist.', commaSeparatedList) .option('--block-service-workers', 'block service workers') .option('--browser ', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--caps ', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf, devtools.', commaSeparatedList) @@ -47,7 +49,6 @@ export function decorateMCPCommand(command: Command) { .option('--config ', 'path to the configuration file.') .option('--console-level ', 'level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels.', enumParser.bind(null, '--console-level', ['error', 'warning', 'info', 'debug'])) .option('--device ', 'device to emulate, for example: "iPhone 15"') - .option('--disable-caps ', 'comma-separated list of core capabilities to disable, possible values: core-run-code, core-webmcp, core-navigation, core-tabs, core-input, core-install.', commaSeparatedList) .option('--mobile', 'emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). Mobile pages are usually lighter, which saves tokens. Cannot be combined with --device.') .option('--executable-path ', 'path to the browser executable.') .option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright Extension" to be installed.') diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index 54359c2fa7881..27cd95b3ade45 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -88,13 +88,13 @@ test('legacy --vision option combined with --caps', async ({ startClient }) => { expect(toolNames).toContain('browser_pdf_save'); }); -test('--disable-caps removes core tools and rejects their calls', async ({ startClient }) => { +test('--blocked-tools removes tools and rejects their calls', async ({ startClient }) => { const { client } = await startClient({ - args: ['--disable-caps=core-run-code,core-tabs'], + args: ['--blocked-tools=browser_run_code_unsafe,browser_evaluate'], }); const toolNames = (await client.listTools()).tools.map(t => t.name); expect(toolNames).not.toContain('browser_run_code_unsafe'); - expect(toolNames).not.toContain('browser_tabs'); + expect(toolNames).not.toContain('browser_evaluate'); expect(toolNames).toContain('browser_navigate'); expect(await client.callTool({ name: 'browser_run_code_unsafe', @@ -105,11 +105,22 @@ test('--disable-caps removes core tools and rejects their calls', async ({ start }); }); -test('disabledCapabilities in config file', async ({ startClient }) => { +test('--allowed-tools exposes only the listed tools', async ({ startClient }) => { const { client } = await startClient({ - config: { disabledCapabilities: ['core-run-code'] }, + args: ['--allowed-tools=browser_navigate,browser_snapshot'], }); const toolNames = (await client.listTools()).tools.map(t => t.name); - expect(toolNames).not.toContain('browser_run_code_unsafe'); - expect(toolNames).toContain('browser_snapshot'); + expect(new Set(toolNames)).toEqual(new Set(['browser_navigate', 'browser_snapshot'])); + expect(await client.callTool({ name: 'browser_close', arguments: {} })).toHaveResponse({ + isError: true, + error: expect.stringContaining('Tool "browser_close" not found'), + }); +}); + +test('blockedTools wins over allowedTools in config file', async ({ startClient }) => { + const { client } = await startClient({ + config: { allowedTools: ['browser_navigate', 'browser_run_code_unsafe'], blockedTools: ['browser_run_code_unsafe'] }, + }); + const toolNames = (await client.listTools()).tools.map(t => t.name); + expect(toolNames).toEqual(['browser_navigate']); }); diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts index 270288ced48bc..3f90971ac89e2 100644 --- a/tests/mcp/config-resolve.spec.ts +++ b/tests/mcp/config-resolve.spec.ts @@ -25,7 +25,7 @@ import { test, expect } from './fixtures'; import { tools } from '../../packages/playwright-core/lib/coreBundle'; import type { Config } from '../../packages/playwright-core/src/tools/mcp/config.d'; -const { decorateMCPCommand, resolveCLIConfigForCLI, resolveCLIConfigForMCP, isSystemDirectory, outputDir } = tools; +const { decorateMCPCommand, filteredTools, resolveCLIConfigForCLI, resolveCLIConfigForMCP, isSystemDirectory, outputDir } = tools; // Parses the command line the same way the mcp server entry point does, without starting the server. async function parseCLIOptions(argv: string[]): Promise { @@ -399,17 +399,20 @@ test.describe('validation', () => { .rejects.toThrow('Browser userDataDir is not supported in isolated mode.'); }); - test('--disable-caps is parsed from cli and env', async () => { - const options = await parseCLIOptions(['--disable-caps=core-run-code,core-webmcp']); - expect((await resolveCLIConfigForMCP(options, emptyEnv)).disabledCapabilities).toEqual(['core-run-code', 'core-webmcp']); - expect((await resolveCLIConfigForMCP({}, { PLAYWRIGHT_MCP_DISABLE_CAPS: 'core-run-code' })).disabledCapabilities).toEqual(['core-run-code']); + test('--allowed-tools and --blocked-tools are parsed from cli and env', async () => { + const options = await parseCLIOptions(['--allowed-tools=browser_navigate,browser_snapshot', '--blocked-tools=browser_run_code_unsafe']); + const config = await resolveCLIConfigForMCP(options, emptyEnv); + expect(config.allowedTools).toEqual(['browser_navigate', 'browser_snapshot']); + expect(config.blockedTools).toEqual(['browser_run_code_unsafe']); + const envConfig = await resolveCLIConfigForMCP({}, { PLAYWRIGHT_MCP_ALLOWED_TOOLS: 'browser_navigate', PLAYWRIGHT_MCP_BLOCKED_TOOLS: 'browser_evaluate' }); + expect(envConfig.allowedTools).toEqual(['browser_navigate']); + expect(envConfig.blockedTools).toEqual(['browser_evaluate']); }); - test('--disable-caps rejects non-core capabilities', async () => { - await expect(resolveCLIConfigForMCP({ disableCaps: ['core'] }, emptyEnv)) - .rejects.toThrow('Invalid --disable-caps: core.'); - await expect(resolveCLIConfigForMCP({ disableCaps: ['pdf'] }, emptyEnv)) - .rejects.toThrow('Invalid --disable-caps: pdf.'); + test('unknown tool names are rejected', async () => { + expect(() => filteredTools({ blockedTools: ['browser_run_code'] })).toThrow('Unknown tool in --blocked-tools: browser_run_code'); + expect(() => filteredTools({ allowedTools: ['browser_nav'] })).toThrow('Unknown tool in --allowed-tools: browser_nav'); + expect(() => filteredTools({ allowedTools: ['browser_navigate', 'webmcp_add'] })).not.toThrow(); }); }); diff --git a/tests/mcp/webmcp-dynamic.spec.ts b/tests/mcp/webmcp-dynamic.spec.ts index 2035c8d0d670f..f9de69df841b8 100644 --- a/tests/mcp/webmcp-dynamic.spec.ts +++ b/tests/mcp/webmcp-dynamic.spec.ts @@ -225,20 +225,49 @@ test('--no-webmcp opts out of page tools entirely', async ({ startClient, server expect((await client.listTools()).tools.map(tool => tool.name)).not.toContain('webmcp_add'); }); -test('--disable-caps=core-webmcp opts out of page tools entirely', async ({ startClient, server, mcpBrowser }) => { +test('--allowed-tools hides page tools that are not listed', async ({ startClient, server, mcpBrowser }) => { server.setRoute('/', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(`WebMCP${registerScript(kAdd)}`); }); const { client } = await startClient({ - config: webmcpConfig(mcpBrowser), - args: ['--disable-caps=core-webmcp'], + config: { ...webmcpConfig(mcpBrowser), allowedTools: ['browser_navigate'] }, }); - const response = await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); - expect(response).toHaveResponse({ - page: expect.not.stringContaining('webmcp tool'), + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['browser_navigate']); + expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 1, b: 1 } })).toHaveResponse({ + isError: true, + error: expect.stringContaining('Tool "webmcp_add" not found'), + }); +}); + +test('--allowed-tools can expose a page tool by name', async ({ startClient, server, mcpBrowser }) => { + server.setRoute('/', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`WebMCP${registerScript(kAdd)}`); + }); + + const { client } = await startClient({ + config: { ...webmcpConfig(mcpBrowser), allowedTools: ['browser_navigate', 'webmcp_add'] }, + }); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['browser_navigate', 'webmcp_add']); + expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 2, b: 40 } })).toHaveResponse({ + result: expect.stringContaining('"text": "42"'), }); +}); + +test('--blocked-tools hides a page tool', async ({ startClient, server, mcpBrowser }) => { + server.setRoute('/', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`WebMCP${registerScript(kAdd)}`); + }); + + const { client } = await startClient({ + config: { ...webmcpConfig(mcpBrowser), blockedTools: ['webmcp_add'] }, + }); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); expect((await client.listTools()).tools.map(tool => tool.name)).not.toContain('webmcp_add'); expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 1, b: 1 } })).toHaveResponse({ isError: true, From 42477eece1707d1ec2c34b047ddf99db5ab58066 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 25 Sep 2026 11:39:23 -0700 Subject: [PATCH 3/4] test(mcp): consolidate allowed/blocked tools tests --- tests/mcp/capabilities.spec.ts | 16 ++---------- tests/mcp/webmcp-dynamic.spec.ts | 44 ++++++++------------------------ 2 files changed, 12 insertions(+), 48 deletions(-) diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index 27cd95b3ade45..0ec6c934f4c26 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -105,22 +105,10 @@ test('--blocked-tools removes tools and rejects their calls', async ({ startClie }); }); -test('--allowed-tools exposes only the listed tools', async ({ startClient }) => { +test('allowedTools and blockedTools in config file', async ({ startClient }) => { const { client } = await startClient({ - args: ['--allowed-tools=browser_navigate,browser_snapshot'], + config: { allowedTools: ['browser_navigate', 'browser_snapshot', 'browser_run_code_unsafe'], blockedTools: ['browser_run_code_unsafe'] }, }); const toolNames = (await client.listTools()).tools.map(t => t.name); expect(new Set(toolNames)).toEqual(new Set(['browser_navigate', 'browser_snapshot'])); - expect(await client.callTool({ name: 'browser_close', arguments: {} })).toHaveResponse({ - isError: true, - error: expect.stringContaining('Tool "browser_close" not found'), - }); -}); - -test('blockedTools wins over allowedTools in config file', async ({ startClient }) => { - const { client } = await startClient({ - config: { allowedTools: ['browser_navigate', 'browser_run_code_unsafe'], blockedTools: ['browser_run_code_unsafe'] }, - }); - const toolNames = (await client.listTools()).tools.map(t => t.name); - expect(toolNames).toEqual(['browser_navigate']); }); diff --git a/tests/mcp/webmcp-dynamic.spec.ts b/tests/mcp/webmcp-dynamic.spec.ts index f9de69df841b8..3854d740edbe0 100644 --- a/tests/mcp/webmcp-dynamic.spec.ts +++ b/tests/mcp/webmcp-dynamic.spec.ts @@ -225,27 +225,16 @@ test('--no-webmcp opts out of page tools entirely', async ({ startClient, server expect((await client.listTools()).tools.map(tool => tool.name)).not.toContain('webmcp_add'); }); -test('--allowed-tools hides page tools that are not listed', async ({ startClient, server, mcpBrowser }) => { +test('--allowed-tools filters page tools by name', async ({ startClient, server, mcpBrowser }) => { server.setRoute('/', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`WebMCP${registerScript(kAdd)}`); - }); - - const { client } = await startClient({ - config: { ...webmcpConfig(mcpBrowser), allowedTools: ['browser_navigate'] }, - }); - await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); - expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['browser_navigate']); - expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 1, b: 1 } })).toHaveResponse({ - isError: true, - error: expect.stringContaining('Tool "webmcp_add" not found'), - }); -}); - -test('--allowed-tools can expose a page tool by name', async ({ startClient, server, mcpBrowser }) => { - server.setRoute('/', (req, res) => { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`WebMCP${registerScript(kAdd)}`); + res.end(`WebMCP${registerScript(kAdd + ` + modelContext.registerTool({ + name: 'checkout', + description: 'Places the order', + async execute() { return { content: [{ type: 'text', text: 'ordered' }] }; }, + }); + `)}`); }); const { client } = await startClient({ @@ -256,21 +245,8 @@ test('--allowed-tools can expose a page tool by name', async ({ startClient, ser expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 2, b: 40 } })).toHaveResponse({ result: expect.stringContaining('"text": "42"'), }); -}); - -test('--blocked-tools hides a page tool', async ({ startClient, server, mcpBrowser }) => { - server.setRoute('/', (req, res) => { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`WebMCP${registerScript(kAdd)}`); - }); - - const { client } = await startClient({ - config: { ...webmcpConfig(mcpBrowser), blockedTools: ['webmcp_add'] }, - }); - await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); - expect((await client.listTools()).tools.map(tool => tool.name)).not.toContain('webmcp_add'); - expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 1, b: 1 } })).toHaveResponse({ + expect(await client.callTool({ name: 'webmcp_checkout', arguments: {} })).toHaveResponse({ isError: true, - error: expect.stringContaining('Tool "webmcp_add" not found'), + error: expect.stringContaining('Tool "webmcp_checkout" not found'), }); }); From a5def40d94269f458fa4d4bc15c4c83b4e13b013 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 25 Sep 2026 12:20:02 -0700 Subject: [PATCH 4/4] feat(mcp): make --allowed-tools additive, do not filter WebMCP tools --allowed-tools now enables tools on top of the enabled capabilities. WebMCP tools are controlled by the webmcp option only. --- .../src/tools/backend/context.ts | 9 +------ .../src/tools/backend/tools.ts | 8 +++--- .../playwright-core/src/tools/mcp/config.d.ts | 6 ++--- .../playwright-core/src/tools/mcp/program.ts | 4 +-- tests/mcp/capabilities.spec.ts | 17 +++++++++--- tests/mcp/config-resolve.spec.ts | 2 +- tests/mcp/webmcp-dynamic.spec.ts | 26 ------------------- 7 files changed, 24 insertions(+), 48 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 11caec29926e7..45fa2e570ddd8 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -75,12 +75,6 @@ export type ContextConfig = { skillMode?: boolean; }; -export function isToolAllowed(config: Pick, name: string) { - if (config.blockedTools?.includes(name)) - return false; - return !config.allowedTools || config.allowedTools.includes(name); -} - type ContextOptions = { config: ContextConfig; sessionLog?: SessionLog; @@ -336,8 +330,7 @@ export class Context { currentWebMCPTools(): WebMCPToolDefinition[] { // Handlers are bound to a tab and frame, always take the fresh ones. - const tools = this._currentTab?.webmcpTools()?.tools.map(tool => tool.mcpTool) ?? []; - return tools.filter(tool => isToolAllowed(this.config, tool.schema.name)); + return this._currentTab?.webmcpTools()?.tools.map(tool => tool.mcpTool) ?? []; } maybeNotifyWebMCPToolsChanged() { diff --git a/packages/playwright-core/src/tools/backend/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index fad5ff6994670..3d0ab4e09c223 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -45,8 +45,6 @@ import wait from './wait'; import webmcp from './webmcp'; import webstorage from './webstorage'; -import { isToolAllowed } from './context'; - import type { Tool } from './tool'; import type { ContextConfig } from './context'; @@ -85,8 +83,7 @@ export const browserTools: Tool[] = [ function validateToolNames(option: string, names: string[] | undefined) { const knownNames = new Set(browserTools.filter(tool => !tool.skillOnly).map(tool => tool.schema.name)); for (const name of names ?? []) { - // Page-registered WebMCP tools are only known at runtime. - if (!knownNames.has(name) && !name.startsWith('webmcp_')) + if (!knownNames.has(name)) throw new Error(`Unknown tool in ${option}: ${name}`); } } @@ -94,7 +91,8 @@ function validateToolNames(option: string, names: string[] | undefined) { export function filteredTools(config: Pick) { validateToolNames('--allowed-tools', config.allowedTools); validateToolNames('--blocked-tools', config.blockedTools); - return browserTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)).filter(tool => !tool.skillOnly && isToolAllowed(config, tool.schema.name)).map(tool => ({ + const isEnabled = (tool: Tool) => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability) || config.allowedTools?.includes(tool.schema.name); + return browserTools.filter(tool => !tool.skillOnly && isEnabled(tool) && !config.blockedTools?.includes(tool.schema.name)).map(tool => ({ ...tool, schema: { ...tool.schema, diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 220d1b57c534b..58fac9a70ecb7 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -137,9 +137,9 @@ export type Config = { capabilities?: ToolCapability[]; /** - * List of tool names to expose. When set, all other tools are disabled, including the - * tools registered by the page through WebMCP (named `webmcp_`). Tools matching - * both `allowedTools` and `blockedTools` are disabled. + * List of tool names to enable in addition to the tools from the enabled capabilities, + * for example `browser_pdf_save`. Tools matching both `allowedTools` and `blockedTools` + * are disabled. */ allowedTools?: string[]; diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 5914b0beeee30..2eeef9086e20f 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -35,10 +35,10 @@ export function decorateMCPCommand(command: Command) { command .option('--allowed-hosts ', 'comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass \'*\' to disable the host check.', commaSeparatedList) .option('--allowed-origins ', 'semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all.\nImportant: *does not* serve as a security boundary and *does not* affect redirects. ', semicolonSeparatedList) - .option('--allowed-tools ', 'comma-separated list of tool names to expose, all other tools are disabled. Also applies to the tools registered by the page through WebMCP (named "webmcp_").', commaSeparatedList) + .option('--allowed-tools ', 'comma-separated list of tool names to enable in addition to the tools from the enabled capabilities, for example "browser_pdf_save".', commaSeparatedList) .option('--allow-unrestricted-file-access', 'allow access to files outside of the workspace roots. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to workspace root directories (or cwd if no roots are configured) only, and navigation to file:// URLs is blocked.') .option('--blocked-origins ', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.\nImportant: *does not* serve as a security boundary and *does not* affect redirects.', semicolonSeparatedList) - .option('--blocked-tools ', 'comma-separated list of tool names to disable, for example "browser_run_code_unsafe". Blocklist is evaluated before allowlist.', commaSeparatedList) + .option('--blocked-tools ', 'comma-separated list of tool names to disable, for example "browser_run_code_unsafe". Takes precedence over --allowed-tools.', commaSeparatedList) .option('--block-service-workers', 'block service workers') .option('--browser ', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--caps ', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf, devtools.', commaSeparatedList) diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index 0ec6c934f4c26..28829d50eb9d7 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -105,10 +105,21 @@ test('--blocked-tools removes tools and rejects their calls', async ({ startClie }); }); -test('allowedTools and blockedTools in config file', async ({ startClient }) => { +test('--allowed-tools adds tools from capabilities that are not enabled', async ({ startClient }) => { const { client } = await startClient({ - config: { allowedTools: ['browser_navigate', 'browser_snapshot', 'browser_run_code_unsafe'], blockedTools: ['browser_run_code_unsafe'] }, + args: ['--allowed-tools=browser_pdf_save'], }); const toolNames = (await client.listTools()).tools.map(t => t.name); - expect(new Set(toolNames)).toEqual(new Set(['browser_navigate', 'browser_snapshot'])); + expect(toolNames).toContain('browser_pdf_save'); + expect(toolNames).toContain('browser_navigate'); + expect(toolNames).not.toContain('browser_mouse_click_xy'); +}); + +test('blockedTools takes precedence over allowedTools', async ({ startClient }) => { + const { client } = await startClient({ + config: { allowedTools: ['browser_pdf_save', 'browser_mouse_click_xy'], blockedTools: ['browser_pdf_save'] }, + }); + const toolNames = (await client.listTools()).tools.map(t => t.name); + expect(toolNames).not.toContain('browser_pdf_save'); + expect(toolNames).toContain('browser_mouse_click_xy'); }); diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts index 3f90971ac89e2..7be84235bd703 100644 --- a/tests/mcp/config-resolve.spec.ts +++ b/tests/mcp/config-resolve.spec.ts @@ -412,7 +412,7 @@ test.describe('validation', () => { test('unknown tool names are rejected', async () => { expect(() => filteredTools({ blockedTools: ['browser_run_code'] })).toThrow('Unknown tool in --blocked-tools: browser_run_code'); expect(() => filteredTools({ allowedTools: ['browser_nav'] })).toThrow('Unknown tool in --allowed-tools: browser_nav'); - expect(() => filteredTools({ allowedTools: ['browser_navigate', 'webmcp_add'] })).not.toThrow(); + expect(() => filteredTools({ allowedTools: ['webmcp_add'] })).toThrow('Unknown tool in --allowed-tools: webmcp_add'); }); }); diff --git a/tests/mcp/webmcp-dynamic.spec.ts b/tests/mcp/webmcp-dynamic.spec.ts index 3854d740edbe0..8e55c9fe4ceaa 100644 --- a/tests/mcp/webmcp-dynamic.spec.ts +++ b/tests/mcp/webmcp-dynamic.spec.ts @@ -224,29 +224,3 @@ test('--no-webmcp opts out of page tools entirely', async ({ startClient, server }); expect((await client.listTools()).tools.map(tool => tool.name)).not.toContain('webmcp_add'); }); - -test('--allowed-tools filters page tools by name', async ({ startClient, server, mcpBrowser }) => { - server.setRoute('/', (req, res) => { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`WebMCP${registerScript(kAdd + ` - modelContext.registerTool({ - name: 'checkout', - description: 'Places the order', - async execute() { return { content: [{ type: 'text', text: 'ordered' }] }; }, - }); - `)}`); - }); - - const { client } = await startClient({ - config: { ...webmcpConfig(mcpBrowser), allowedTools: ['browser_navigate', 'webmcp_add'] }, - }); - await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); - expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['browser_navigate', 'webmcp_add']); - expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 2, b: 40 } })).toHaveResponse({ - result: expect.stringContaining('"text": "42"'), - }); - expect(await client.callTool({ name: 'webmcp_checkout', arguments: {} })).toHaveResponse({ - isError: true, - error: expect.stringContaining('Tool "webmcp_checkout" not found'), - }); -});