Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
10 changes: 10 additions & 0 deletions src/telemetry/tool_call_metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@
{
"name": "viewport_length",
"argType": "number"
},
{
"name": "extra_http_headers_length",
"argType": "number",
"isDeprecated": true
},
{
"name": "extra_h_t_t_p_headers_length",
Comment thread
nroscino marked this conversation as resolved.
Outdated
"argType": "number",
"isDeprecated": true
Comment thread
OrKoN marked this conversation as resolved.
Outdated
}
]
},
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