diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index 62d1cf7795b17..ed4dc7ffe5fba 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -135,7 +135,10 @@ async function createRemoteBrowser(config: FullConfig): Promise const endpoint = config.browser.remoteEndpoint!; const playwrightObject = playwright as Playwright; // Use connectToBrowser instead of playwright[browserName].connect because we don't have browserName. - const browser = await connectToBrowser(playwrightObject, { endpoint }); + const browser = await connectToBrowser(playwrightObject, { + endpoint, + headers: config.browser.remoteHeaders, + }); browser._connectToBrowserType(playwrightObject[browser._browserName], {}, undefined); return { browser, browserInfo: browserInfo(browser, config), canBind: false, ownership: 'attached' }; } diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 6756d9a731650..640084178c026 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -86,6 +86,11 @@ export type Config = { */ remoteEndpoint?: string; + /** + * Headers to send with the remote endpoint connect request. + */ + remoteHeaders?: Record; + /** * Paths to TypeScript files to add as initialization scripts for Playwright page. */ diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index cc85e23f6bfb4..0c33c134a1575 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -63,6 +63,7 @@ export type CLIOptions = { port?: number; proxyBypass?: string; proxyServer?: string; + remoteHeader?: Record; saveSession?: boolean; secrets?: Record; sharedBrowserContext?: boolean; @@ -332,6 +333,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s initPage: cliOptions.initPage, initScript: cliOptions.initScript, remoteEndpoint: cliOptions.endpoint, + remoteHeaders: cliOptions.remoteHeader, }, extension: cliOptions.extension, server: { @@ -402,6 +404,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: options.port = numberParser(e.PLAYWRIGHT_MCP_PORT); options.proxyBypass = envToString(e.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyServer = envToString(e.PLAYWRIGHT_MCP_PROXY_SERVER); + options.remoteHeader = headerParser(envToString(e.PLAYWRIGHT_MCP_REMOTE_HEADERS)); options.secrets = dotenvFileLoader(e.PLAYWRIGHT_MCP_SECRETS_FILE); options.storageState = envToString(e.PLAYWRIGHT_MCP_STORAGE_STATE); options.testIdAttribute = envToString(e.PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE); diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 75c76cdf9275d..ceca2400ae984 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -63,6 +63,7 @@ export function decorateMCPCommand(command: Command) { .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"') + .option('--remote-header ', 'headers to send with the remote endpoint connect request, multiple can be specified.', headerParser) .option('--sandbox', 'enable the sandbox for all process types that are normally not sandboxed.') .option('--save-session', 'Whether to save the Playwright MCP session into the output directory.') .option('--secrets ', 'path to a file containing secrets in the dotenv format', dotenvFileLoader) diff --git a/tests/mcp/cli-remote.spec.ts b/tests/mcp/cli-remote.spec.ts new file mode 100644 index 0000000000000..40fb22b236b1c --- /dev/null +++ b/tests/mcp/cli-remote.spec.ts @@ -0,0 +1,38 @@ +/** + * 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 fs from 'fs'; +import { test, expect } from './cli-fixtures'; + +test.skip(({ mcpBrowser }) => mcpBrowser !== 'chromium', 'Run only on the chromium project; the remote server connection is browser-agnostic.'); + +test('attach to run-server endpoint with remoteHeaders from config', async ({ cli, runServerEndpoint, server }, testInfo) => { + const configPath = testInfo.outputPath('config.json'); + await fs.promises.writeFile(configPath, JSON.stringify({ + browser: { + remoteEndpoint: runServerEndpoint, + remoteHeaders: { 'x-playwright-browser': 'chromium' }, + isolated: true, + }, + }, null, 2)); + + const { exitCode } = await cli('attach', runServerEndpoint, '-s=remote', `--config=${configPath}`); + expect(exitCode).toBe(0); + + await cli('-s=remote', 'goto', server.HELLO_WORLD); + const { inlineSnapshot } = await cli('-s=remote', 'snapshot'); + expect(inlineSnapshot).toContain('Hello, world!'); +}); diff --git a/tests/mcp/fixtures.ts b/tests/mcp/fixtures.ts index 9793b65205ade..98ffc26e144ce 100644 --- a/tests/mcp/fixtures.ts +++ b/tests/mcp/fixtures.ts @@ -26,6 +26,7 @@ import { TestServer } from '../config/testserver'; import { serverFixtures } from '../config/serverFixtures'; import { tools } from '../../packages/playwright-core/lib/coreBundle'; import { commonFixtures } from '../config/commonFixtures'; +import { RunServer } from '../config/remoteServer'; import { inheritAndCleanEnv } from '../config/utils'; import type { CommonFixtures, CommonWorkerFixtures } from '../config/commonFixtures'; @@ -67,6 +68,7 @@ type TestFixtures = { client: Client; startClient: StartClient; wsEndpoint: string; + runServerEndpoint: string; cdpServer: CDPServer; server: TestServer; httpsServer: TestServer; @@ -161,6 +163,13 @@ export const test = serverTest.extend { + const runServer = new RunServer(); + await runServer.start(childProcess); + await use(runServer.wsEndpoint()); + await runServer.close(); + }, + cdpServer: async ({ mcpBrowser }, use, testInfo) => { test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers'); diff --git a/tests/mcp/remote-endpoint.spec.ts b/tests/mcp/remote-endpoint.spec.ts new file mode 100644 index 0000000000000..6aba3e9df20a2 --- /dev/null +++ b/tests/mcp/remote-endpoint.spec.ts @@ -0,0 +1,59 @@ +/** + * 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'; + +test.skip(({ mcpBrowser }) => mcpBrowser !== 'chromium', 'Run only on the chromium project; the remote server connection is browser-agnostic.'); + +test('remoteHeaders selects the browser on run-server endpoint', async ({ startClient, server, runServerEndpoint }) => { + const { client } = await startClient({ + config: { + browser: { + remoteEndpoint: runServerEndpoint, + remoteHeaders: { 'x-playwright-browser': 'chromium' }, + isolated: true, + }, + }, + }); + + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + expect(response).toHaveResponse({ + page: expect.stringContaining('Page Title: Title'), + }); +}); + +test('connect without remoteHeaders fails on run-server endpoint', async ({ startClient, server, runServerEndpoint }) => { + const { client } = await startClient({ + config: { + browser: { + remoteEndpoint: runServerEndpoint, + isolated: true, + }, + }, + }); + + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.EMPTY_PAGE }, + }); + expect(response).toHaveResponse({ + isError: true, + error: expect.stringContaining(`reading 'launch'`), + }); +});