Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
15 changes: 13 additions & 2 deletions packages/playwright-core/src/tools/backend/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,19 @@ export const browserTools: Tool<any>[] = [
...webstorage,
];

export function filteredTools(config: Pick<ContextConfig, 'capabilities'>) {
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<ContextConfig, 'capabilities' | 'allowedTools' | 'blockedTools'>) {
validateToolNames('--allowed-tools', config.allowedTools);
validateToolNames('--blocked-tools', config.blockedTools);
const isEnabled = (tool: Tool<any>) => 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,
Expand Down
13 changes: 13 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/mcp/configIni.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ const longhandTypes: Record<string, LonghandType> = {
// top-level
'extension': 'boolean',
'capabilities': 'string[]',
'allowedTools': 'string[]',
'blockedTools': 'string[]',
'saveSession': 'boolean',
'saveVideo': 'size',
'sharedBrowserContext': 'boolean',
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ export function decorateMCPCommand(command: Command) {
command
.option('--allowed-hosts <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 <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 <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 <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 <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>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf, devtools.', commaSeparatedList)
Expand Down
36 changes: 36 additions & 0 deletions tests/mcp/capabilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
18 changes: 17 additions & 1 deletion tests/mcp/config-resolve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
Expand Down Expand Up @@ -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');
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading