From ce81f556a53411494d75700e6f15ab14b91204c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:56:45 +0000 Subject: [PATCH] Implement clipboard permissions support feature Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com> --- README.md | 2 + config.d.ts | 5 + examples/clipboard-permissions.md | 95 +++++++++++++ src/browserContextFactory.ts | 25 +++- src/config.ts | 3 + src/program.ts | 1 + tests/permissions.spec.ts | 227 ++++++++++++++++++++++++++++++ 7 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 examples/clipboard-permissions.md create mode 100644 tests/permissions.spec.ts diff --git a/README.md b/README.md index 5e16e69..6933c18 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ Playwright MCP server supports following arguments. They can be provided in the --no-sandbox disable the sandbox for all process types that are normally sandboxed. --output-dir path to the directory for output files. + --permissions comma-separated list of permissions to grant, for + example "clipboard-read,clipboard-write" --port port to listen on for SSE transport. --proxy-bypass comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com" diff --git a/config.d.ts b/config.d.ts index d63b061..14f98dc 100644 --- a/config.d.ts +++ b/config.d.ts @@ -54,6 +54,11 @@ export type Config = { */ contextOptions?: playwright.BrowserContextOptions; + /** + * Permissions to grant to the browser context, for example ["clipboard-read", "clipboard-write"]. + */ + permissions?: string[]; + /** * Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers. */ diff --git a/examples/clipboard-permissions.md b/examples/clipboard-permissions.md new file mode 100644 index 0000000..42e2d8d --- /dev/null +++ b/examples/clipboard-permissions.md @@ -0,0 +1,95 @@ +# Clipboard Permissions Example + +This example demonstrates how to use the new `--permissions` feature to enable clipboard operations without user permission prompts. + +## Usage + +### Via Command Line Arguments + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--permissions", "clipboard-read,clipboard-write" + ] + } + } +} +``` + +### Via Configuration File + +Create a config file `playwright-mcp-config.json`: + +```json +{ + "browser": { + "permissions": ["clipboard-read", "clipboard-write"] + } +} +``` + +Then use it: + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--config", "playwright-mcp-config.json" + ] + } + } +} +``` + +### Via Environment Variable + +```bash +export PLAYWRIGHT_MCP_PERMISSIONS="clipboard-read,clipboard-write" +``` + +## Clipboard Operations + +Once permissions are granted, you can use clipboard APIs via `browser_evaluate`: + +```javascript +// Write to clipboard (no permission prompt) +await browser_evaluate({ + function: "() => navigator.clipboard.writeText('Copy this!')" +}) + +// Read from clipboard (no permission prompt) +await browser_evaluate({ + function: "() => navigator.clipboard.readText()" +}) +``` + +## Supported Permissions + +You can grant multiple permissions as a comma-separated list: + +- `clipboard-read` +- `clipboard-write` +- `geolocation` +- `camera` +- `microphone` +- `notifications` +- And any other [Web API permissions](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API) + +Example with multiple permissions: + +```bash +--permissions "clipboard-read,clipboard-write,geolocation,notifications" +``` + +## Notes + +- Permissions are applied when the browser context is created +- The clipboard API requires secure contexts (HTTPS) in production environments +- Some permissions may not be supported in all browsers or may require additional user activation \ No newline at end of file diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index 470ead5..2ff811b 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -113,7 +113,10 @@ class IsolatedContextFactory extends BaseContextFactory { } protected override async _doCreateContext(browser: playwright.Browser): Promise { - return browser.newContext(this.browserConfig.contextOptions); + const contextOptions = { ...this.browserConfig.contextOptions }; + if (this.browserConfig.permissions) + contextOptions.permissions = this.browserConfig.permissions; + return browser.newContext(contextOptions); } } @@ -127,7 +130,13 @@ class CdpContextFactory extends BaseContextFactory { } protected override async _doCreateContext(browser: playwright.Browser): Promise { - return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0]; + if (this.browserConfig.isolated) { + const contextOptions: playwright.BrowserContextOptions = {}; + if (this.browserConfig.permissions) + contextOptions.permissions = this.browserConfig.permissions; + return browser.newContext(contextOptions); + } + return browser.contexts()[0]; } } @@ -145,7 +154,10 @@ class RemoteContextFactory extends BaseContextFactory { } protected override async _doCreateContext(browser: playwright.Browser): Promise { - return browser.newContext(); + const contextOptions: playwright.BrowserContextOptions = {}; + if (this.browserConfig.permissions) + contextOptions.permissions = this.browserConfig.permissions; + return browser.newContext(contextOptions); } } @@ -168,12 +180,15 @@ class PersistentContextFactory implements BrowserContextFactory { const browserType = playwright[this.browserConfig.browserName]; for (let i = 0; i < 5; i++) { try { - const browserContext = await browserType.launchPersistentContext(userDataDir, { + const contextOptions = { ...this.browserConfig.launchOptions, ...this.browserConfig.contextOptions, handleSIGINT: false, handleSIGTERM: false, - }); + }; + if (this.browserConfig.permissions) + contextOptions.permissions = this.browserConfig.permissions; + const browserContext = await browserType.launchPersistentContext(userDataDir, contextOptions); const close = () => this._closeBrowserContext(browserContext, userDataDir); return { browserContext, close }; } catch (error: any) { diff --git a/src/config.ts b/src/config.ts index 4ed58c9..9388c31 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,7 @@ export type CLIOptions = { imageResponses?: 'allow' | 'omit'; sandbox?: boolean; outputDir?: string; + permissions?: string[]; port?: number; proxyBypass?: string; proxyServer?: string; @@ -177,6 +178,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { browserName, isolated: cliOptions.isolated, userDataDir: cliOptions.userDataDir, + permissions: cliOptions.permissions, launchOptions, contextOptions, cdpEndpoint: cliOptions.cdpEndpoint, @@ -218,6 +220,7 @@ function configFromEnv(): Config { options.imageResponses = 'omit'; options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX); options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR); + options.permissions = commaSeparatedList(process.env.PLAYWRIGHT_MCP_PERMISSIONS); options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT); options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER); diff --git a/src/program.ts b/src/program.ts index f34c57e..6cbe976 100644 --- a/src/program.ts +++ b/src/program.ts @@ -46,6 +46,7 @@ program .option('--image-responses ', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".') .option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.') .option('--output-dir ', 'path to the directory for output files.') + .option('--permissions ', 'comma-separated list of permissions to grant, for example "clipboard-read,clipboard-write"', commaSeparatedList) .option('--port ', 'port to listen on for SSE transport.') .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') diff --git a/tests/permissions.spec.ts b/tests/permissions.spec.ts new file mode 100644 index 0000000..0f09803 --- /dev/null +++ b/tests/permissions.spec.ts @@ -0,0 +1,227 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures.js'; + +test('clipboard permissions support via CLI argument', async ({ startClient, server }) => { + server.setContent('/', ` + + +

Test Page

+ + + `, 'text/html'); + + const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write'] }); + + // Navigate to server page + const navigateResponse = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(navigateResponse.isError).toBeFalsy(); + + // Verify permissions are granted + const permissionsResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)' + } + }); + expect(permissionsResponse.isError).toBeFalsy(); + expect(permissionsResponse).toHaveResponse({ + result: '"granted"' + }); + + // Test clipboard write operation without user permission prompt + const writeResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.clipboard.writeText("test clipboard content")' + } + }); + expect(writeResponse.isError).toBeFalsy(); + + // Test clipboard read operation without user permission prompt + const readResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.clipboard.readText()' + } + }); + expect(readResponse.isError).toBeFalsy(); + expect(readResponse).toHaveResponse({ + result: '"test clipboard content"' + }); +}); + +test('clipboard permissions support via config file', async ({ startClient, server }) => { + server.setContent('/', ` + + +

Config Test Page

+ + + `, 'text/html'); + + const config = { + browser: { + permissions: ['clipboard-read', 'clipboard-write'] + } + }; + + const { client } = await startClient({ config }); + + // Navigate to server page + const navigateResponse = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(navigateResponse.isError).toBeFalsy(); + + // Verify permissions are granted via config + const permissionsResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)' + } + }); + expect(permissionsResponse.isError).toBeFalsy(); + expect(permissionsResponse).toHaveResponse({ + result: '"granted"' + }); + + // Test clipboard operations work with config file + const writeResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.clipboard.writeText("config test content")' + } + }); + expect(writeResponse.isError).toBeFalsy(); + + const readResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.clipboard.readText()' + } + }); + expect(readResponse.isError).toBeFalsy(); + expect(readResponse).toHaveResponse({ + result: '"config test content"' + }); +}); + +test('multiple permissions can be granted', async ({ startClient, server }) => { + server.setContent('/', ` + + +

Multiple Permissions Test

+ + + `, 'text/html'); + + const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write,geolocation'] }); + + // Navigate to server page + const navigateResponse = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(navigateResponse.isError).toBeFalsy(); + + // Test that multiple permissions can be granted + const clipboardPermissionResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)' + } + }); + expect(clipboardPermissionResponse.isError).toBeFalsy(); + expect(clipboardPermissionResponse).toHaveResponse({ + result: '"granted"' + }); + + const geolocationPermissionResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.permissions.query({ name: "geolocation" }).then(result => result.state)' + } + }); + expect(geolocationPermissionResponse.isError).toBeFalsy(); + expect(geolocationPermissionResponse).toHaveResponse({ + result: '"granted"' + }); +}); + +test('clipboard permissions via environment variable', async ({ startClient, server }) => { + server.setContent('/', ` + + +

Environment Variable Test

+ + + `, 'text/html'); + + // Set environment variable + process.env.PLAYWRIGHT_MCP_PERMISSIONS = 'clipboard-read,clipboard-write'; + + try { + const { client } = await startClient({ args: [] }); + + // Navigate to server page + const navigateResponse = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(navigateResponse.isError).toBeFalsy(); + + // Verify permissions are granted via environment variable + const permissionsResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)' + } + }); + expect(permissionsResponse.isError).toBeFalsy(); + expect(permissionsResponse).toHaveResponse({ + result: '"granted"' + }); + + // Test clipboard operations work + const writeResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.clipboard.writeText("env test content")' + } + }); + expect(writeResponse.isError).toBeFalsy(); + + const readResponse = await client.callTool({ + name: 'browser_evaluate', + arguments: { + function: '() => navigator.clipboard.readText()' + } + }); + expect(readResponse.isError).toBeFalsy(); + expect(readResponse).toHaveResponse({ + result: '"env test content"' + }); + } finally { + // Clean up environment variable + delete process.env.PLAYWRIGHT_MCP_PERMISSIONS; + } +});