Skip to content
Closed
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
11 changes: 11 additions & 0 deletions src/bin/chrome-devtools-mcp-cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ export const cliOptions = {
describe: 'Whether to enable interoperability tools',
hidden: true,
},
experimentalWebauthn: {
type: 'boolean',
describe:
'Whether to enable experimental WebAuthn virtual authenticator tools.',
hidden: false,
default: false,
},
experimentalScreencast: {
type: 'boolean',
describe:
Expand Down Expand Up @@ -343,6 +350,10 @@ export function parseArguments(version: string, argv = process.argv) {
'$0 --slim',
'Only 3 tools: navigation, JavaScript execution and screenshot',
],
[
'$0 --experimental-webauthn',
'Enable experimental WebAuthn virtual authenticator tooling.',
],
]);

return yargsInstance
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ export async function createMcpServer(
) {
return;
}
if (
tool.annotations.conditions?.includes('experimentalWebauthn') &&
!serverArgs.experimentalWebauthn
) {
return;
}
if (
tool.annotations.conditions?.includes('screencast') &&
!serverArgs.experimentalScreencast
Expand Down
2 changes: 2 additions & 0 deletions src/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as screenshotTools from './screenshot.js';
import * as scriptTools from './script.js';
import * as slimTools from './slim/tools.js';
import * as snapshotTools from './snapshot.js';
import * as webauthnTools from './webauthn.js';
import type {ToolDefinition} from './ToolDefinition.js';

export const createTools = (args: ParsedArguments) => {
Expand All @@ -41,6 +42,7 @@ export const createTools = (args: ParsedArguments) => {
...Object.values(screenshotTools),
...Object.values(scriptTools),
...Object.values(snapshotTools),
...Object.values(webauthnTools),
];

const tools = [];
Expand Down
183 changes: 183 additions & 0 deletions src/tools/webauthn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {zod} from '../third_party/index.js';

import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';

const ACTIONS = [
'status',
'enable',
'disable',
'addAuthenticator',
'removeAuthenticator',
'setUserVerified',
] as const;

type WebauthnAction = (typeof ACTIONS)[number];

type CdpClient = {
send(method: string, params?: unknown): Promise<unknown>;
};

function getCdpClient(page: {pptrPage: unknown}): CdpClient {
// Puppeteer does not expose this via a stable public API yet.
// @ts-expect-error internal API
const client = page.pptrPage._client?.();
if (!client || typeof client.send !== 'function') {
throw new Error('Unable to access CDP session for the selected page.');
}
return client as CdpClient;
}

async function getStatus(client: CdpClient) {
try {
const result = (await client.send(
'WebAuthn.getCredentials',
{},
)) as {credentials?: unknown[]};
return {
enabled: true,
authenticators: [] as Array<Record<string, unknown>>,
credentials: result.credentials ?? [],
};
} catch {
return {
enabled: false,
authenticators: [] as Array<Record<string, unknown>>,
credentials: [] as unknown[],
};
}
}

async function handleAction(
action: WebauthnAction,
params: {
authenticatorId?: string;
userVerified?: boolean;
protocol?: 'ctap2' | 'u2f';
transport?: 'usb' | 'nfc' | 'ble' | 'internal';
hasResidentKey?: boolean;
hasUserVerification?: boolean;
automaticPresenceSimulation?: boolean;
isUserVerified?: boolean;
},
client: CdpClient,
) {
switch (action) {
case 'status':
return {action, result: 'ok'};
case 'enable':
await client.send('WebAuthn.enable');
return {action, result: 'enabled'};
case 'disable':
await client.send('WebAuthn.disable');
return {action, result: 'disabled'};
case 'addAuthenticator': {
const addResult = (await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: params.protocol ?? 'ctap2',
transport: params.transport ?? 'internal',
hasResidentKey: params.hasResidentKey ?? true,
hasUserVerification: params.hasUserVerification ?? true,
automaticPresenceSimulation:
params.automaticPresenceSimulation ?? true,
isUserVerified: params.isUserVerified ?? true,
},
})) as {authenticatorId?: string};
return {
action,
result: 'addedAuthenticator',
authenticatorId: addResult.authenticatorId,
};
}
case 'removeAuthenticator': {
if (!params.authenticatorId) {
throw new Error('authenticatorId is required for removeAuthenticator');
}
await client.send('WebAuthn.removeVirtualAuthenticator', {
authenticatorId: params.authenticatorId,
});
return {action, result: 'removedAuthenticator'};
}
case 'setUserVerified': {
if (!params.authenticatorId) {
throw new Error('authenticatorId is required for setUserVerified');
}
await client.send('WebAuthn.setUserVerified', {
authenticatorId: params.authenticatorId,
isUserVerified: params.userVerified ?? true,
});
return {action, result: 'setUserVerified'};
}
default:
throw new Error(`Unsupported action: ${action as string}`);
}
}

export const configureWebauthn = definePageTool({
name: 'configure_webauthn',
description:
'Configure experimental WebAuthn virtual authenticator state. Always returns status in the response.',
annotations: {
category: ToolCategory.DEBUGGING,
readOnlyHint: false,
conditions: ['experimentalWebauthn'],
},
schema: {
action: zod
.enum(ACTIONS)
.default('status')
.describe('Action to apply to WebAuthn virtual authenticator state.'),
authenticatorId: zod
.string()
.optional()
.describe('Virtual authenticator ID for targeted actions.'),
userVerified: zod
.boolean()
.optional()
.describe('User verification state for setUserVerified action.'),
protocol: zod
.enum(['ctap2', 'u2f'])
.optional()
.describe('Authenticator protocol for addAuthenticator.'),
transport: zod
.enum(['usb', 'nfc', 'ble', 'internal'])
.optional()
.describe('Authenticator transport for addAuthenticator.'),
hasResidentKey: zod
.boolean()
.optional()
.describe('Whether resident keys are supported for addAuthenticator.'),
hasUserVerification: zod
.boolean()
.optional()
.describe('Whether user verification is supported for addAuthenticator.'),
automaticPresenceSimulation: zod
.boolean()
.optional()
.describe('Whether presence simulation is enabled for addAuthenticator.'),
isUserVerified: zod
.boolean()
.optional()
.describe('Initial user verification value for addAuthenticator.'),
},
handler: async ({params, page}, response) => {
const client = getCdpClient(page);
const actionResult = await handleAction(params.action, params, client);
const status = await getStatus(client);

response.appendResponseLine('WebAuthn status:');
response.appendResponseLine(`- enabled: ${status.enabled}`);
response.appendResponseLine(
`- authenticators: ${status.authenticators.length}`,
);
response.appendResponseLine(`- credentials: ${status.credentials.length}`);
response.appendResponseLine(`Action result: ${JSON.stringify(actionResult)}`);
},
});

2 changes: 2 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ describe('cli args parsing', () => {
performanceCrux: true,
'usage-statistics': true,
usageStatistics: true,
'experimental-webauthn': false,
experimentalWebauthn: false,
};

it('parses with default args', async () => {
Expand Down
13 changes: 13 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,17 @@ describe('e2e', () => {
['--experimental-interop-tools'],
);
});

it('has experimental webauthn tools', async () => {
await withClient(
async client => {
const {tools} = await client.listTools();
const configureWebauthn = tools.find(
t => t.name === 'configure_webauthn',
);
assert.ok(configureWebauthn);
},
['--experimental-webauthn'],
);
});
});
86 changes: 86 additions & 0 deletions tests/tools/webauthn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';
import {describe, it} from 'node:test';

import sinon from 'sinon';

import {configureWebauthn} from '../../src/tools/webauthn.js';
import {withMcpContext} from '../utils.js';

describe('webauthn', () => {
it('reports status', async () => {
await withMcpContext(async (response, context) => {
const selectedPage = context.getSelectedPptrPage();
const send = sinon.stub().resolves({credentials: []});
sinon
.stub(selectedPage as unknown as Record<string, unknown>, '_client')
.returns({send} as never);

await configureWebauthn.handler(
{
params: {action: 'status'},
page: context.getSelectedMcpPage(),
},
response,
context,
);

assert.ok(response.responseLines.join('\n').includes('WebAuthn status:'));
sinon.assert.calledWith(send, 'WebAuthn.getCredentials', {});
});
});

it('enables WebAuthn and includes action result', async () => {
await withMcpContext(async (response, context) => {
const selectedPage = context.getSelectedPptrPage();
const send = sinon
.stub()
.onFirstCall()
.resolves(undefined)
.onSecondCall()
.resolves({credentials: []});
sinon
.stub(selectedPage as unknown as Record<string, unknown>, '_client')
.returns({send} as never);

await configureWebauthn.handler(
{
params: {action: 'enable'},
page: context.getSelectedMcpPage(),
},
response,
context,
);

sinon.assert.calledWith(send.firstCall, 'WebAuthn.enable');
assert.ok(response.responseLines.join('\n').includes('"result":"enabled"'));
});
});

it('throws if removeAuthenticator is missing authenticatorId', async () => {
await withMcpContext(async (response, context) => {
const selectedPage = context.getSelectedPptrPage();
const send = sinon.stub().resolves({credentials: []});
sinon
.stub(selectedPage as unknown as Record<string, unknown>, '_client')
.returns({send} as never);

await assert.rejects(
configureWebauthn.handler(
{
params: {action: 'removeAuthenticator'},
page: context.getSelectedMcpPage(),
},
response,
context,
),
/authenticatorId is required/,
);
});
});
});