diff --git a/packages/playwright/src/mcp/browser/context.ts b/packages/playwright/src/mcp/browser/context.ts index 80eadfda1c280..e741522c6ec40 100644 --- a/packages/playwright/src/mcp/browser/context.ts +++ b/packages/playwright/src/mcp/browser/context.ts @@ -51,6 +51,7 @@ export class Context { private _tabs: Tab[] = []; private _currentTab: Tab | undefined; private _clientInfo: ClientInfo; + private _extraHTTPHeaders: Record | undefined; private static _allContexts: Set = new Set(); private _closeBrowserContextPromise: Promise | undefined; @@ -210,6 +211,12 @@ export class Context { return browserContext; } + async setExtraHTTPHeaders(headers: Record) { + this._extraHTTPHeaders = { ...headers }; + const { browserContext } = await this._ensureBrowserContext(); + await browserContext.setExtraHTTPHeaders(this._extraHTTPHeaders); + } + private _ensureBrowserContext() { if (!this._browserContextPromise) { this._browserContextPromise = this._setupBrowserContext(); @@ -227,6 +234,8 @@ export class Context { const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName); const { browserContext } = result; await this._setupRequestInterception(browserContext); + if (this._extraHTTPHeaders) + await browserContext.setExtraHTTPHeaders(this._extraHTTPHeaders); if (this.sessionLog) await InputRecorder.create(this, browserContext); for (const page of browserContext.pages()) diff --git a/packages/playwright/src/mcp/browser/tools.ts b/packages/playwright/src/mcp/browser/tools.ts index e5927c2888f81..80feed565acc3 100644 --- a/packages/playwright/src/mcp/browser/tools.ts +++ b/packages/playwright/src/mcp/browser/tools.ts @@ -26,6 +26,7 @@ import mouse from './tools/mouse'; import navigate from './tools/navigate'; import network from './tools/network'; import pdf from './tools/pdf'; +import headers from './tools/headers'; import snapshot from './tools/snapshot'; import screenshot from './tools/screenshot'; import tabs from './tools/tabs'; @@ -47,6 +48,7 @@ export const browserTools: Tool[] = [ ...keyboard, ...navigate, ...network, + ...headers, ...mouse, ...pdf, ...screenshot, diff --git a/packages/playwright/src/mcp/browser/tools/headers.ts b/packages/playwright/src/mcp/browser/tools/headers.ts new file mode 100644 index 0000000000000..a5464e07c16a5 --- /dev/null +++ b/packages/playwright/src/mcp/browser/tools/headers.ts @@ -0,0 +1,56 @@ +/** + * 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 { z } from '../../sdk/bundle'; +import { defineTool } from './tool'; + +const setHeaders = defineTool({ + capability: 'headers', + + schema: { + name: 'browser_set_headers', + title: 'Set extra HTTP headers', + description: 'Persistently set custom HTTP headers on the active browser context.', + inputSchema: z.object({ + headers: z.record(z.string(), z.string()).describe('Header names mapped to the values that should be sent with every request.'), + }), + type: 'destructive', + }, + + handle: async (context, params, response) => { + const entries = Object.entries(params.headers); + if (!entries.length) { + response.addError('Please provide at least one header to set.'); + return; + } + + const invalidHeader = entries.find(([name]) => !name.trim()); + if (invalidHeader) { + response.addError('Header names must be non-empty strings.'); + return; + } + + await context.setExtraHTTPHeaders(params.headers); + + const count = entries.length; + response.addResult(`Configured ${count} ${count === 1 ? 'header' : 'headers'} for this session.`); + response.addCode(`await context.setExtraHTTPHeaders(${JSON.stringify(params.headers, null, 2)});`); + }, +}); + +export default [ + setHeaders, +]; diff --git a/packages/playwright/src/mcp/config.d.ts b/packages/playwright/src/mcp/config.d.ts index dd373b9883f03..49409629823e0 100644 --- a/packages/playwright/src/mcp/config.d.ts +++ b/packages/playwright/src/mcp/config.d.ts @@ -16,7 +16,7 @@ import type * as playwright from 'playwright-core'; -export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'testing' | 'tracing'; +export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'testing' | 'tracing' | 'headers'; export type Config = { /** @@ -99,6 +99,7 @@ export type Config = { * - 'core': Core browser automation features. * - 'pdf': PDF generation and manipulation. * - 'vision': Coordinate-based interactions. + * - 'headers': Manage persistent custom HTTP headers. */ capabilities?: ToolCapability[]; @@ -166,4 +167,3 @@ export type Config = { */ imageResponses?: 'allow' | 'omit'; }; - diff --git a/packages/playwright/src/mcp/program.ts b/packages/playwright/src/mcp/program.ts index baad3ab5b4b6c..04d6715201a47 100644 --- a/packages/playwright/src/mcp/program.ts +++ b/packages/playwright/src/mcp/program.ts @@ -34,7 +34,7 @@ export function decorateCommand(command: Command, version: string) { .option('--blocked-origins ', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList) .option('--block-service-workers', 'block service workers') .option('--browser ', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') - .option('--caps ', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList) + .option('--caps ', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf, headers.', commaSeparatedList) .option('--cdp-endpoint ', 'CDP endpoint to connect to.') .option('--cdp-header ', 'CDP headers to send with the connect request, multiple can be specified.', headerParser) .option('--config ', 'path to the configuration file.') diff --git a/tests/mcp/headers.spec.ts b/tests/mcp/headers.spec.ts new file mode 100644 index 0000000000000..0c722eb1fdf97 --- /dev/null +++ b/tests/mcp/headers.spec.ts @@ -0,0 +1,126 @@ +/** + * 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('headers tool requires capability', async ({ client, startClient }) => { + const { tools } = await client.listTools(); + expect(tools.map(tool => tool.name)).not.toContain('browser_set_headers'); + + const { client: headersClient } = await startClient({ args: ['--caps=headers'] }); + const headersToolList = await headersClient.listTools(); + expect(headersToolList.tools.map(tool => tool.name)).toContain('browser_set_headers'); +}); + +test('browser_set_headers rejects empty input', async ({ startClient }) => { + const { client } = await startClient({ args: ['--caps=headers'] }); + + const response = await client.callTool({ + name: 'browser_set_headers', + arguments: { headers: {} }, + }); + + expect(response).toHaveResponse({ + isError: true, + result: 'Please provide at least one header to set.', + }); +}); + +test('browser_set_headers rejects header names without characters', async ({ startClient }) => { + const { client } = await startClient({ args: ['--caps=headers'] }); + + const response = await client.callTool({ + name: 'browser_set_headers', + arguments: { headers: { ' ': 'value' } }, + }); + + expect(response).toHaveResponse({ + isError: true, + result: 'Header names must be non-empty strings.', + }); +}); + +test('browser_set_headers persists headers across navigations', async ({ startClient, server }) => { + server.setContent('/first', 'First', 'text/html'); + server.setContent('/second', 'Second', 'text/html'); + + const { client } = await startClient({ args: ['--caps=headers'] }); + + expect(await client.callTool({ + name: 'browser_set_headers', + arguments: { + headers: { 'X-Tenant-ID': 'tenant-123' }, + }, + })).toHaveResponse({ + result: 'Configured 1 header for this session.', + }); + + const firstRequestPromise = server.waitForRequest('/first'); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}/first` }, + }); + const firstRequest = await firstRequestPromise; + expect(firstRequest.headers['x-tenant-id']).toBe('tenant-123'); + + const secondRequestPromise = server.waitForRequest('/second'); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}/second` }, + }); + const secondRequest = await secondRequestPromise; + expect(secondRequest.headers['x-tenant-id']).toBe('tenant-123'); +}); + +test('browser_set_headers applies to all requests from the context', async ({ startClient, server }) => { + server.setRoute('/page', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(``); + }); + server.setRoute('/api/data', (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + + const { client } = await startClient({ args: ['--caps=headers'] }); + + expect(await client.callTool({ + name: 'browser_set_headers', + arguments: { + headers: { + 'X-Tenant-ID': 'tenant-456', + 'Authorization': 'Bearer token456', + }, + }, + })).toHaveResponse({ + result: 'Configured 2 headers for this session.', + }); + + const pageRequestPromise = server.waitForRequest('/page'); + const apiRequestPromise = server.waitForRequest('/api/data'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}/page` }, + }); + + const [pageRequest, apiRequest] = await Promise.all([pageRequestPromise, apiRequestPromise]); + + expect(pageRequest.headers['x-tenant-id']).toBe('tenant-456'); + expect(pageRequest.headers['authorization']).toBe('Bearer token456'); + expect(apiRequest.headers['x-tenant-id']).toBe('tenant-456'); + expect(apiRequest.headers['authorization']).toBe('Bearer token456'); +});