diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 132f7e790cadf..45fa2e570ddd8 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -41,6 +41,8 @@ const testDebug = debug('pw:mcp:test'); export type ContextConfig = { allowUnrestrictedFileAccess?: boolean; capabilities?: ToolCapability[]; + allowedTools?: string[]; + blockedTools?: string[]; 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/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index 7024c30b38766..3d0ab4e09c223 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -80,8 +80,19 @@ 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 => ({ +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 ?? []) { + if (!knownNames.has(name)) + throw new Error(`Unknown tool in ${option}: ${name}`); + } +} + +export function filteredTools(config: Pick) { + validateToolNames('--allowed-tools', config.allowedTools); + validateToolNames('--blocked-tools', config.blockedTools); + 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 0ab34a9ba4e6e..58fac9a70ecb7 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -136,6 +136,19 @@ export type Config = { */ capabilities?: ToolCapability[]; + /** + * 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[]; + + /** + * 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 28062016e4f30..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[]; @@ -376,6 +378,8 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s allowedHosts: cliOptions.allowedHosts, }, capabilities: cliOptions.caps as ToolCapability[], + allowedTools: cliOptions.allowedTools, + blockedTools: cliOptions.blockedTools, console: { level: cliOptions.consoleLevel, }, @@ -419,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); diff --git a/packages/playwright-core/src/tools/mcp/configIni.ts b/packages/playwright-core/src/tools/mcp/configIni.ts index c6e952ceb60c4..1314cf6e5db89 100644 --- a/packages/playwright-core/src/tools/mcp/configIni.ts +++ b/packages/playwright-core/src/tools/mcp/configIni.ts @@ -156,6 +156,8 @@ const longhandTypes: Record = { // top-level 'extension': 'boolean', 'capabilities': '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 a921a42c94b72..2eeef9086e20f 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 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". 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 3ec281140fa19..28829d50eb9d7 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -87,3 +87,39 @@ test('legacy --vision option combined with --caps', async ({ startClient }) => { expect(toolNames).toContain('browser_mouse_move_xy'); expect(toolNames).toContain('browser_pdf_save'); }); + +test('--blocked-tools removes tools and rejects their calls', async ({ startClient }) => { + const { client } = await startClient({ + 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_evaluate'); + 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('--allowed-tools adds tools from capabilities that are not enabled', async ({ startClient }) => { + const { client } = await startClient({ + args: ['--allowed-tools=browser_pdf_save'], + }); + const toolNames = (await client.listTools()).tools.map(t => t.name); + 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 7890dd522e113..7be84235bd703 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 { @@ -398,6 +398,22 @@ test.describe('validation', () => { await expect(resolveCLIConfigForMCP({ isolated: true, userDataDir: '/tmp/data' }, emptyEnv)) .rejects.toThrow('Browser userDataDir is not supported in isolated mode.'); }); + + 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('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: ['webmcp_add'] })).toThrow('Unknown tool in --allowed-tools: webmcp_add'); + }); }); // ---------------------------------------------------------------------------