From 17b02fc506f250ed17437f4da3b4b085716d1a1c Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Fri, 22 May 2026 05:56:06 -0700 Subject: [PATCH 1/2] feat(mcp): accept ConnectOptions object for remoteEndpoint The CLI / MCP config exposed `remoteEndpoint` as a plain URL string, so there was no way to forward `exposeNetwork`, `headers`, `slowMo`, or `timeout` when connecting to a remote browser. The test runner already accepts a `connectOptions` object via `playwright.config.ts`; this brings the same surface to the MCP config. `remoteEndpoint` now accepts `string | playwright.ConnectOptions & { endpoint: string }`. The string form keeps the existing behavior. The object form is normalized inside `createRemoteBrowser` and forwarded to `connectToBrowser`, so the underlying connect call receives the full set of options including `exposeNetwork: ''` for SOCKS tunneling back to the client. Fixes: https://github.com/microsoft/playwright/issues/40478 --- .../src/tools/mcp/browserFactory.ts | 17 +++++++++----- .../playwright-core/src/tools/mcp/config.d.ts | 12 +++++++--- tests/mcp/remote-endpoint.spec.ts | 22 +++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index 2f33851a7c566..ffb04c170885d 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -115,7 +115,16 @@ async function createCDPBrowser(config: FullConfig, clientInfo: ClientInfo): Pro async function createRemoteBrowser(config: FullConfig): Promise { testDebug('create browser (remote)'); - const descriptor = await serverRegistry.find(config.browser.remoteEndpoint!); + // `remoteEndpoint` may be a plain URL string or a ConnectOptions object that + // carries additional fields such as `exposeNetwork`, `headers`, `slowMo`, and + // `timeout`. Normalize once so the rest of the function deals with a single + // shape. + const remote = config.browser.remoteEndpoint!; + const remoteOptions = typeof remote === 'string' + ? { endpoint: remote, headers: config.browser.remoteHeaders } + : remote; + + const descriptor = await serverRegistry.find(remoteOptions.endpoint); if (descriptor) { const browser = await connectToBrowserAcrossVersions(descriptor); return { @@ -131,13 +140,9 @@ 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, - headers: config.browser.remoteHeaders, - }); + const browser = await connectToBrowser(playwrightObject, remoteOptions); 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 640084178c026..a2fc50caf5c9e 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -82,12 +82,18 @@ export type Config = { cdpTimeout?: number; /** - * Remote endpoint to connect to an existing Playwright server. + * Remote endpoint to connect to an existing Playwright server. May be a + * WebSocket URL string, or a [ConnectOptions] object that mirrors the + * `connectOptions` shape used by the test runner. When passed as an object, + * `exposeNetwork`, `headers`, `slowMo`, and `timeout` are forwarded to the + * underlying connect call. */ - remoteEndpoint?: string; + remoteEndpoint?: string | playwright.ConnectOptions & { endpoint: string }; /** - * Headers to send with the remote endpoint connect request. + * Headers to send with the remote endpoint connect request. Ignored when + * `remoteEndpoint` is provided as a [ConnectOptions] object; supply + * `headers` on that object instead. */ remoteHeaders?: Record; diff --git a/tests/mcp/remote-endpoint.spec.ts b/tests/mcp/remote-endpoint.spec.ts index 6aba3e9df20a2..ebd77395d7e39 100644 --- a/tests/mcp/remote-endpoint.spec.ts +++ b/tests/mcp/remote-endpoint.spec.ts @@ -57,3 +57,25 @@ test('connect without remoteHeaders fails on run-server endpoint', async ({ star error: expect.stringContaining(`reading 'launch'`), }); }); + +test('remoteEndpoint accepts ConnectOptions object with headers', async ({ startClient, server, runServerEndpoint }) => { + const { client } = await startClient({ + config: { + browser: { + remoteEndpoint: { + endpoint: runServerEndpoint, + headers: { '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'), + }); +}); From 39a5531ed27152c8fd495ac01dfb168bee28cf45 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Fri, 29 May 2026 04:40:58 -0700 Subject: [PATCH 2/2] feat(mcp): remove remoteHeaders in favor of remoteEndpoint headers Drops the remoteHeaders config field, the --remote-header CLI flag, and the PLAYWRIGHT_MCP_REMOTE_HEADERS env var. Headers for a remote connect are now supplied through the ConnectOptions object form of remoteEndpoint. --- .../src/tools/mcp/browserFactory.ts | 2 +- .../playwright-core/src/tools/mcp/config.d.ts | 7 ------ .../playwright-core/src/tools/mcp/config.ts | 3 --- .../playwright-core/src/tools/mcp/program.ts | 1 - tests/mcp/cli-remote.spec.ts | 8 ++++--- tests/mcp/remote-endpoint.spec.ts | 22 +------------------ 6 files changed, 7 insertions(+), 36 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index ffb04c170885d..927e87b40a117 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -121,7 +121,7 @@ async function createRemoteBrowser(config: FullConfig): Promise // shape. const remote = config.browser.remoteEndpoint!; const remoteOptions = typeof remote === 'string' - ? { endpoint: remote, headers: config.browser.remoteHeaders } + ? { endpoint: remote } : remote; const descriptor = await serverRegistry.find(remoteOptions.endpoint); diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index a2fc50caf5c9e..0bca85026512f 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -90,13 +90,6 @@ export type Config = { */ remoteEndpoint?: string | playwright.ConnectOptions & { endpoint: string }; - /** - * Headers to send with the remote endpoint connect request. Ignored when - * `remoteEndpoint` is provided as a [ConnectOptions] object; supply - * `headers` on that object instead. - */ - 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 0c33c134a1575..cc85e23f6bfb4 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -63,7 +63,6 @@ export type CLIOptions = { port?: number; proxyBypass?: string; proxyServer?: string; - remoteHeader?: Record; saveSession?: boolean; secrets?: Record; sharedBrowserContext?: boolean; @@ -333,7 +332,6 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s initPage: cliOptions.initPage, initScript: cliOptions.initScript, remoteEndpoint: cliOptions.endpoint, - remoteHeaders: cliOptions.remoteHeader, }, extension: cliOptions.extension, server: { @@ -404,7 +402,6 @@ 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 ceca2400ae984..75c76cdf9275d 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -63,7 +63,6 @@ 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 index 40fb22b236b1c..397a6f36ec691 100644 --- a/tests/mcp/cli-remote.spec.ts +++ b/tests/mcp/cli-remote.spec.ts @@ -19,12 +19,14 @@ 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) => { +test('attach to run-server endpoint with headers 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' }, + remoteEndpoint: { + endpoint: runServerEndpoint, + headers: { 'x-playwright-browser': 'chromium' }, + }, isolated: true, }, }, null, 2)); diff --git a/tests/mcp/remote-endpoint.spec.ts b/tests/mcp/remote-endpoint.spec.ts index ebd77395d7e39..2a19e737543e6 100644 --- a/tests/mcp/remote-endpoint.spec.ts +++ b/tests/mcp/remote-endpoint.spec.ts @@ -18,27 +18,7 @@ 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 }) => { +test('connect without headers fails on run-server endpoint', async ({ startClient, server, runServerEndpoint }) => { const { client } = await startClient({ config: { browser: {