diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 6e8a2ba8b..747193452 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -256,6 +256,7 @@ - **colorScheme** (enum: "dark", "light", "auto") _(optional)_: [`Emulate`](#emulate) the dark or the light mode. Set to "auto" to reset to the default. - **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling +- **extraHttpHeaders** (string) _(optional)_: Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers. - **geolocation** (string) _(optional)_: Geolocation (`<latitude>,<longitude>`) to [`emulate`](#emulate). Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override. - **networkConditions** (enum: "Offline", "Slow 3G", "Fast 3G", "Slow 4G", "Fast 4G") _(optional)_: Throttle network. Omit to disable throttling. - **userAgent** (string) _(optional)_: User agent to [`emulate`](#emulate). Set to empty string to clear the user agent override. diff --git a/src/McpContext.ts b/src/McpContext.ts index 88fa344c0..a77bc06bc 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -301,6 +301,7 @@ export class McpContext implements Context { userAgent?: string; colorScheme?: 'dark' | 'light' | 'auto'; viewport?: Viewport; + extraHttpHeaders?: Record | undefined; }, targetPage?: Page, ): Promise { @@ -379,6 +380,14 @@ export class McpContext implements Context { newSettings.viewport = viewport; } + if (options.extraHttpHeaders !== undefined) { + await page.setExtraHTTPHeaders(options.extraHttpHeaders); + newSettings.extraHttpHeaders = options.extraHttpHeaders; + if (Object.keys(options.extraHttpHeaders).length === 0) { + delete newSettings.extraHttpHeaders; + } + } + mcpPage.emulationSettings = Object.keys(newSettings).length ? newSettings : {}; diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index 221ad3ebe..af92dccd5 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -167,6 +167,13 @@ export const commands: Commands = { "Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.", required: false, }, + extraHttpHeaders: { + name: 'extraHttpHeaders', + type: 'string', + description: + 'Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.', + required: false, + }, }, }, evaluate_script: { diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 9453d60f4..597bfa7a3 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -85,6 +85,10 @@ { "name": "viewport_length", "argType": "number" + }, + { + "name": "extra_http_headers_length", + "argType": "number" } ] }, diff --git a/src/tools/emulation.ts b/src/tools/emulation.ts index 7eebe9200..519ad5a10 100644 --- a/src/tools/emulation.ts +++ b/src/tools/emulation.ts @@ -14,6 +14,32 @@ import { viewportTransform, } from './ToolDefinition.js'; +function headerStringTransform( + value: string | undefined, +): Record | undefined { + if (value === undefined) { + return undefined; + } + if (value === '') { + return {}; + } + try { + const parsed = JSON.parse(value); + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error('Headers must be a JSON object'); + } + return parsed as Record; + } catch (error) { + throw new Error( + `Invalid JSON for headers: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + const throttlingOptions: [string, ...string[]] = [ 'Offline', ...Object.keys(PredefinedNetworkConditions), @@ -65,6 +91,13 @@ export const emulate = definePageTool({ .describe( `Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.`, ), + extraHttpHeaders: zod + .string() + .optional() + .transform(headerStringTransform) + .describe( + 'Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.', + ), }, blockedByDialog: true, handler: async (request, response, context) => { diff --git a/src/types.ts b/src/types.ts index 13e64f58d..2107b558f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,4 +31,5 @@ export interface EmulationSettings { userAgent?: string; colorScheme?: 'dark' | 'light'; viewport?: Viewport; + extraHttpHeaders?: Record; } diff --git a/tests/tools/emulation.test.ts b/tests/tools/emulation.test.ts index b1a19a904..84a3b7828 100644 --- a/tests/tools/emulation.test.ts +++ b/tests/tools/emulation.test.ts @@ -5,6 +5,7 @@ */ import assert from 'node:assert'; +import type {IncomingHttpHeaders} from 'node:http'; import {beforeEach, describe, it} from 'node:test'; import {emulate} from '../../src/tools/emulation.js'; @@ -571,6 +572,172 @@ describe('emulation', () => { }); }); + describe('extraHttpHeaders', () => { + it('sets extra headers on requests', async () => { + let receivedHeaders: IncomingHttpHeaders = {}; + server.addRoute('/headers-test', async (req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('
Headers Test
'); + }); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-Custom-Header': 'test-value'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + await page.goto(server.getRoute('/headers-test')); + assert.strictEqual(receivedHeaders['x-custom-header'], 'test-value'); + }); + }); + + it('clears extra headers when null is passed', async () => { + let receivedHeaders: IncomingHttpHeaders = {}; + server.addRoute('/headers-clear', async (req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('
Headers Clear
'); + }); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + // Set headers first + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-To-Clear': 'value'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + // Clear headers + await emulate.handler( + { + params: { + extraHttpHeaders: {}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + await page.goto(server.getRoute('/headers-clear')); + assert.strictEqual(receivedHeaders['x-to-clear'], undefined); + assert.strictEqual( + context.getSelectedMcpPage().emulationSettings.extraHttpHeaders, + undefined, + ); + }); + }); + + it('headers persist across navigations', async () => { + const receivedHeaders: IncomingHttpHeaders[] = []; + server.addRoute('/persist-one', async (req, res) => { + receivedHeaders.push({...req.headers}); + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('
Page One
'); + }); + server.addRoute('/persist-two', async (req, res) => { + receivedHeaders.push({...req.headers}); + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('
Page Two
'); + }); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-Persist': 'yes'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + await page.goto(server.getRoute('/persist-one')); + await page.goto(server.getRoute('/persist-two')); + + assert.strictEqual(receivedHeaders[0]?.['x-persist'], 'yes'); + assert.strictEqual(receivedHeaders[1]?.['x-persist'], 'yes'); + }); + }); + + it('does not affect other emulation settings', async () => { + await withMcpContext(async (response, context) => { + // Set userAgent first + await emulate.handler( + { + params: { + userAgent: 'MyUA', + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + // Set extraHTTPHeaders separately + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-Test': 'value'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const settings = context.getSelectedMcpPage().emulationSettings; + assert.deepStrictEqual(settings.extraHttpHeaders, { + 'X-Test': 'value', + }); + }); + }); + + it('reports correctly for the currently selected page', async () => { + await withMcpContext(async (response, context) => { + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-Page': 'one'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + assert.deepStrictEqual( + context.getSelectedMcpPage().emulationSettings.extraHttpHeaders, + {'X-Page': 'one'}, + ); + + const page = await context.newPage(); + context.selectPage(page); + + assert.strictEqual( + context.getSelectedMcpPage().emulationSettings.extraHttpHeaders, + undefined, + ); + }); + }); + }); + describe('colorScheme', () => { it('emulates color scheme', async () => { await withMcpContext(async (response, context) => {