From 0aec262464e0425ac2024eb4e553217ceecb66fd Mon Sep 17 00:00:00 2001 From: Carey Janecka Date: Wed, 8 Apr 2026 19:19:06 -0700 Subject: [PATCH] feat(webauthn): add flag-gated configure_webauthn tool --- src/bin/chrome-devtools-mcp-cli-options.ts | 11 ++ src/index.ts | 6 + src/tools/tools.ts | 2 + src/tools/webauthn.ts | 183 +++++++++++++++++++++ tests/cli.test.ts | 2 + tests/index.test.ts | 13 ++ tests/tools/webauthn.test.ts | 86 ++++++++++ 7 files changed, 303 insertions(+) create mode 100644 src/tools/webauthn.ts create mode 100644 tests/tools/webauthn.test.ts diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 3cfbacac0..6a37a266e 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -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: @@ -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 diff --git a/src/index.ts b/src/index.ts index 362f2348a..12a1e582c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 3c74115c3..e44d242d4 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -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) => { @@ -41,6 +42,7 @@ export const createTools = (args: ParsedArguments) => { ...Object.values(screenshotTools), ...Object.values(scriptTools), ...Object.values(snapshotTools), + ...Object.values(webauthnTools), ]; const tools = []; diff --git a/src/tools/webauthn.ts b/src/tools/webauthn.ts new file mode 100644 index 000000000..d2eec6132 --- /dev/null +++ b/src/tools/webauthn.ts @@ -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; +}; + +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>, + credentials: result.credentials ?? [], + }; + } catch { + return { + enabled: false, + authenticators: [] as Array>, + 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)}`); + }, +}); + diff --git a/tests/cli.test.ts b/tests/cli.test.ts index b18a4532f..1f7df22bb 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -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 () => { diff --git a/tests/index.test.ts b/tests/index.test.ts index f08350c08..b7f7b987d 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -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'], + ); + }); }); diff --git a/tests/tools/webauthn.test.ts b/tests/tools/webauthn.test.ts new file mode 100644 index 000000000..f1a79ec17 --- /dev/null +++ b/tests/tools/webauthn.test.ts @@ -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, '_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, '_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, '_client') + .returns({send} as never); + + await assert.rejects( + configureWebauthn.handler( + { + params: {action: 'removeAuthenticator'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ), + /authenticatorId is required/, + ); + }); + }); +});