From e051af1c768df99528ed9b4054ba7a28c27f72ac Mon Sep 17 00:00:00 2001
From: Yury Semikhatsky <yurys@chromium.org>
Date: Wed, 13 May 2026 14:50:17 -0700
Subject: [PATCH 1/2] fix(mcp): support remoteHeaders for remote browser
 endpoint

Allows the MCP/CLI client to forward custom headers (e.g.
`x-playwright-browser`) when connecting to a Playwright `run-server`
endpoint, which previously failed because no browser type was
specified.

Fixes: https://github.com/microsoft/playwright/issues/40814
---
 .../src/tools/mcp/browserFactory.ts           |  5 +-
 .../playwright-core/src/tools/mcp/config.d.ts |  5 ++
 .../playwright-core/src/tools/mcp/config.ts   |  3 +
 .../playwright-core/src/tools/mcp/program.ts  |  1 +
 tests/mcp/cli-remote.spec.ts                  | 38 ++++++++++++
 tests/mcp/fixtures.ts                         |  9 +++
 tests/mcp/remote-endpoint.spec.ts             | 59 +++++++++++++++++++
 7 files changed, 119 insertions(+), 1 deletion(-)
 create mode 100644 tests/mcp/cli-remote.spec.ts
 create mode 100644 tests/mcp/remote-endpoint.spec.ts

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<BrowserWithInfo>
   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<string, string>;
+
     /**
      * 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<string, string>;
   saveSession?: boolean;
   secrets?: Record<string, string>;
   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>', 'port to listen on for SSE transport.')
       .option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
       .option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
+      .option('--remote-header <headers...>', '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>', '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..264f8d09bec97
--- /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' && mcpBrowser !== 'chrome', 'Tests only run with the chromium browser.');
+
+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<TestFixtures & TestOptions, WorkerFixtures
     await browserServer.close();
   },
 
+  runServerEndpoint: async ({ childProcess }, use) => {
+    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..c7071f10aaca2
--- /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' && mcpBrowser !== 'chrome', 'Tests only run with the chromium browser.');
+
+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'`),
+  });
+});

From 5c6918238af0386058369c7efbb94d4b19f6e483 Mon Sep 17 00:00:00 2001
From: Yury Semikhatsky <yurys@chromium.org>
Date: Wed, 13 May 2026 14:53:01 -0700
Subject: [PATCH 2/2] test(mcp): skip remote-endpoint tests on non-chromium
 projects

Remote server connection is browser-agnostic, so running the same test
against every mcpBrowser project just burns CI time.
---
 tests/mcp/cli-remote.spec.ts      | 2 +-
 tests/mcp/remote-endpoint.spec.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/mcp/cli-remote.spec.ts b/tests/mcp/cli-remote.spec.ts
index 264f8d09bec97..40fb22b236b1c 100644
--- a/tests/mcp/cli-remote.spec.ts
+++ b/tests/mcp/cli-remote.spec.ts
@@ -17,7 +17,7 @@
 import fs from 'fs';
 import { test, expect } from './cli-fixtures';
 
-test.skip(({ mcpBrowser }) => mcpBrowser !== 'chromium' && mcpBrowser !== 'chrome', 'Tests only run with the chromium browser.');
+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');
diff --git a/tests/mcp/remote-endpoint.spec.ts b/tests/mcp/remote-endpoint.spec.ts
index c7071f10aaca2..6aba3e9df20a2 100644
--- a/tests/mcp/remote-endpoint.spec.ts
+++ b/tests/mcp/remote-endpoint.spec.ts
@@ -16,7 +16,7 @@
 
 import { test, expect } from './fixtures';
 
-test.skip(({ mcpBrowser }) => mcpBrowser !== 'chromium' && mcpBrowser !== 'chrome', 'Tests only run with the chromium browser.');
+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({
