feat: add extraHttpHeaders emulation to emulate tool (#1176)

## Summary

Extend the existing `emulate` tool with an `extraHTTPHeaders` parameter
that calls Puppeteer's `page.setExtraHTTPHeaders()` (which uses CDP
`Network.setExtraHTTPHeaders` under the hood).

Closes #1175

## Approach

Per [feedback from
@natorion](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1175#issuecomment-4097587153),
this integrates into the existing `emulate` tool rather than adding a
standalone tool. The `emulate` tool is already the central hub for
page-level state modifications (userAgent, viewport, networkConditions,
geolocation, colorScheme), and custom HTTP headers fit naturally
alongside them. This also avoids increasing the MCP tool count and LLM
token overhead.

## Changes

- **`src/types.ts`** — Added `extraHTTPHeaders?: Record<string, string>`
to `EmulationSettings`
- **`src/tools/emulation.ts`** — Added `extraHTTPHeaders` as an optional
zod parameter on the `emulate` tool
- **`src/McpContext.ts`** — Added handler logic in the `emulate()`
method:
- Calls `page.setExtraHTTPHeaders()` when `extraHTTPHeaders` is provided
  - Clears from settings when an empty `{}` is passed
- Preserves existing headers when the param is **omitted** (unlike other
emulation settings that reset when omitted) — prevents
`emulate({colorScheme: "dark"})` from accidentally clearing
previously-set headers
- **`tests/tools/emulation.test.ts`** — Added 5 test cases:
  1. Sets extra headers on requests
  2. Clears headers with `{}`
  3. Headers persist across navigations
  4. Does not affect other emulation settings
  5. Reports correctly per-page (new page has no headers)

## Use Case

This enables setting custom HTTP headers on **all** requests — including
the initial document navigation and `<script>` tag loads — which
`initScript` cannot do since it runs after the document is already
fetched.

## Usage

```js
// Set headers
emulate({ extraHTTPHeaders: { "X-Custom": "value", "Authorization": "Bearer token" } })

// Clear headers
emulate({ extraHTTPHeaders: {} })

// Combine with other emulation settings
emulate({ extraHTTPHeaders: { "X-Branch": "feature-1" }, userAgent: "MyBot/1.0" })
```

---------

Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
Co-authored-by: Nicholas Roscino <nroscino@google.com>
This commit is contained in:
Pedro Durek
2026-05-20 03:37:27 -06:00
committed by GitHub
parent 9f47df3684
commit 6992106d1c
7 changed files with 222 additions and 0 deletions
+1
View File
@@ -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 (`&lt;latitude&gt;,&lt;longitude&gt;`) 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.
+9
View File
@@ -301,6 +301,7 @@ export class McpContext implements Context {
userAgent?: string;
colorScheme?: 'dark' | 'light' | 'auto';
viewport?: Viewport;
extraHttpHeaders?: Record<string, string> | undefined;
},
targetPage?: Page,
): Promise<void> {
@@ -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
: {};
+7
View File
@@ -167,6 +167,13 @@ export const commands: Commands = {
"Emulate device viewports '<width>x<height>x<devicePixelRatio>[,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: {
+4
View File
@@ -85,6 +85,10 @@
{
"name": "viewport_length",
"argType": "number"
},
{
"name": "extra_http_headers_length",
"argType": "number"
}
]
},
+33
View File
@@ -14,6 +14,32 @@ import {
viewportTransform,
} from './ToolDefinition.js';
function headerStringTransform(
value: string | undefined,
): Record<string, string> | 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<string, string>;
} 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 '<width>x<height>x<devicePixelRatio>[,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) => {
+1
View File
@@ -31,4 +31,5 @@ export interface EmulationSettings {
userAgent?: string;
colorScheme?: 'dark' | 'light';
viewport?: Viewport;
extraHttpHeaders?: Record<string, string>;
}
+167
View File
@@ -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('<main>Headers Test</main>');
});
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('<main>Headers Clear</main>');
});
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('<main>Page One</main>');
});
server.addRoute('/persist-two', async (req, res) => {
receivedHeaders.push({...req.headers});
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('<main>Page Two</main>');
});
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) => {