From b9a7b83e11be9864896071076cfe0bf4a874949b Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 5 Jun 2026 09:47:54 +0200 Subject: [PATCH] fix(mcp): keep remoteHeaders working for remote browser endpoint #40964 replaced the string-only remoteEndpoint with a ConnectOptions object and dropped the remoteHeaders config field, the --remote-header CLI flag, and the PLAYWRIGHT_MCP_REMOTE_HEADERS env var, regressing existing setups that relied on them. Restore all three runtime surfaces as an undocumented back-compat shim (kept out of the public Config type). Headers on the remoteEndpoint object take precedence over remoteHeaders. The --remote-header CLI flag is hidden from help. --- .../src/tools/mcp/browserFactory.ts | 7 +++++-- .../playwright-core/src/tools/mcp/config.ts | 7 +++++++ .../playwright-core/src/tools/mcp/program.ts | 1 + tests/mcp/remote-endpoint.spec.ts | 20 +++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index 4ea7e58372bd2..e6b808459c5ac 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -120,9 +120,12 @@ async function createRemoteBrowser(config: FullConfig): Promise // `timeout`. Normalize once so the rest of the function deals with a single // shape. const remote = config.browser.remoteEndpoint!; + // `remoteHeaders` is for back-compat, `remoteEndpoint.headers` takes precedence. + // eslint-disable-next-line no-restricted-syntax + const remoteHeaders = (config.browser as any).remoteHeaders as Record | undefined; const remoteOptions = typeof remote === 'string' - ? { endpoint: remote } - : remote; + ? { endpoint: remote, headers: remoteHeaders } + : { ...remote, headers: { ...remoteHeaders, ...remote.headers } }; const descriptor = await serverRegistry.find(remoteOptions.endpoint); if (descriptor) { diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 2583e2684d8ce..c90c1cf36c9e9 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -64,6 +64,7 @@ export type CLIOptions = { port?: number; proxyBypass?: string; proxyServer?: string; + remoteHeader?: Record; saveSession?: boolean; secrets?: Record; sharedBrowserContext?: boolean; @@ -364,6 +365,11 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s }, }; + // `remoteHeaders` is for back-compat, assign it here so it survives config merging. + if (cliOptions.remoteHeader) + // eslint-disable-next-line no-restricted-syntax + (config.browser as any).remoteHeaders = cliOptions.remoteHeader; + return { ...config, configFile: cliOptions.config }; } @@ -405,6 +411,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 f42c1b0dcc38c..06de1755de454 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -64,6 +64,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"') + .addOption(new ProgramOption('--remote-header ', 'headers to send with the remote endpoint connect request, multiple can be specified.').argParser(headerParser).hideHelp()) .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/remote-endpoint.spec.ts b/tests/mcp/remote-endpoint.spec.ts index 2a19e737543e6..42521074bbfe8 100644 --- a/tests/mcp/remote-endpoint.spec.ts +++ b/tests/mcp/remote-endpoint.spec.ts @@ -59,3 +59,23 @@ test('remoteEndpoint accepts ConnectOptions object with headers', async ({ start page: expect.stringContaining('Page Title: Title'), }); }); + +test('back-compat: remoteHeaders config still 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, + }, + } as any, + }); + + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + expect(response).toHaveResponse({ + page: expect.stringContaining('Page Title: Title'), + }); +});