Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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
: {};
Expand Down
7 changes: 7 additions & 0 deletions src/bin/chrome-devtools-cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions src/telemetry/tool_call_metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
{
"name": "viewport_length",
"argType": "number"
},
{
"name": "extra_http_headers_length",
"argType": "number"
}
]
},
Expand Down
33 changes: 33 additions & 0 deletions src/tools/emulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export interface EmulationSettings {
userAgent?: string;
colorScheme?: 'dark' | 'light';
viewport?: Viewport;
extraHttpHeaders?: Record<string, string>;
}
167 changes: 167 additions & 0 deletions tests/tools/emulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
Loading