Skip to content

Commit 0aec262

Browse files
committed
feat(webauthn): add flag-gated configure_webauthn tool
1 parent fb6e857 commit 0aec262

7 files changed

Lines changed: 303 additions & 0 deletions

File tree

src/bin/chrome-devtools-mcp-cli-options.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ export const cliOptions = {
180180
describe: 'Whether to enable interoperability tools',
181181
hidden: true,
182182
},
183+
experimentalWebauthn: {
184+
type: 'boolean',
185+
describe:
186+
'Whether to enable experimental WebAuthn virtual authenticator tools.',
187+
hidden: false,
188+
default: false,
189+
},
183190
experimentalScreencast: {
184191
type: 'boolean',
185192
describe:
@@ -343,6 +350,10 @@ export function parseArguments(version: string, argv = process.argv) {
343350
'$0 --slim',
344351
'Only 3 tools: navigation, JavaScript execution and screenshot',
345352
],
353+
[
354+
'$0 --experimental-webauthn',
355+
'Enable experimental WebAuthn virtual authenticator tooling.',
356+
],
346357
]);
347358

348359
return yargsInstance

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ export async function createMcpServer(
158158
) {
159159
return;
160160
}
161+
if (
162+
tool.annotations.conditions?.includes('experimentalWebauthn') &&
163+
!serverArgs.experimentalWebauthn
164+
) {
165+
return;
166+
}
161167
if (
162168
tool.annotations.conditions?.includes('screencast') &&
163169
!serverArgs.experimentalScreencast

src/tools/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import * as screenshotTools from './screenshot.js';
2121
import * as scriptTools from './script.js';
2222
import * as slimTools from './slim/tools.js';
2323
import * as snapshotTools from './snapshot.js';
24+
import * as webauthnTools from './webauthn.js';
2425
import type {ToolDefinition} from './ToolDefinition.js';
2526

2627
export const createTools = (args: ParsedArguments) => {
@@ -41,6 +42,7 @@ export const createTools = (args: ParsedArguments) => {
4142
...Object.values(screenshotTools),
4243
...Object.values(scriptTools),
4344
...Object.values(snapshotTools),
45+
...Object.values(webauthnTools),
4446
];
4547

4648
const tools = [];

src/tools/webauthn.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {zod} from '../third_party/index.js';
8+
9+
import {ToolCategory} from './categories.js';
10+
import {definePageTool} from './ToolDefinition.js';
11+
12+
const ACTIONS = [
13+
'status',
14+
'enable',
15+
'disable',
16+
'addAuthenticator',
17+
'removeAuthenticator',
18+
'setUserVerified',
19+
] as const;
20+
21+
type WebauthnAction = (typeof ACTIONS)[number];
22+
23+
type CdpClient = {
24+
send(method: string, params?: unknown): Promise<unknown>;
25+
};
26+
27+
function getCdpClient(page: {pptrPage: unknown}): CdpClient {
28+
// Puppeteer does not expose this via a stable public API yet.
29+
// @ts-expect-error internal API
30+
const client = page.pptrPage._client?.();
31+
if (!client || typeof client.send !== 'function') {
32+
throw new Error('Unable to access CDP session for the selected page.');
33+
}
34+
return client as CdpClient;
35+
}
36+
37+
async function getStatus(client: CdpClient) {
38+
try {
39+
const result = (await client.send(
40+
'WebAuthn.getCredentials',
41+
{},
42+
)) as {credentials?: unknown[]};
43+
return {
44+
enabled: true,
45+
authenticators: [] as Array<Record<string, unknown>>,
46+
credentials: result.credentials ?? [],
47+
};
48+
} catch {
49+
return {
50+
enabled: false,
51+
authenticators: [] as Array<Record<string, unknown>>,
52+
credentials: [] as unknown[],
53+
};
54+
}
55+
}
56+
57+
async function handleAction(
58+
action: WebauthnAction,
59+
params: {
60+
authenticatorId?: string;
61+
userVerified?: boolean;
62+
protocol?: 'ctap2' | 'u2f';
63+
transport?: 'usb' | 'nfc' | 'ble' | 'internal';
64+
hasResidentKey?: boolean;
65+
hasUserVerification?: boolean;
66+
automaticPresenceSimulation?: boolean;
67+
isUserVerified?: boolean;
68+
},
69+
client: CdpClient,
70+
) {
71+
switch (action) {
72+
case 'status':
73+
return {action, result: 'ok'};
74+
case 'enable':
75+
await client.send('WebAuthn.enable');
76+
return {action, result: 'enabled'};
77+
case 'disable':
78+
await client.send('WebAuthn.disable');
79+
return {action, result: 'disabled'};
80+
case 'addAuthenticator': {
81+
const addResult = (await client.send('WebAuthn.addVirtualAuthenticator', {
82+
options: {
83+
protocol: params.protocol ?? 'ctap2',
84+
transport: params.transport ?? 'internal',
85+
hasResidentKey: params.hasResidentKey ?? true,
86+
hasUserVerification: params.hasUserVerification ?? true,
87+
automaticPresenceSimulation:
88+
params.automaticPresenceSimulation ?? true,
89+
isUserVerified: params.isUserVerified ?? true,
90+
},
91+
})) as {authenticatorId?: string};
92+
return {
93+
action,
94+
result: 'addedAuthenticator',
95+
authenticatorId: addResult.authenticatorId,
96+
};
97+
}
98+
case 'removeAuthenticator': {
99+
if (!params.authenticatorId) {
100+
throw new Error('authenticatorId is required for removeAuthenticator');
101+
}
102+
await client.send('WebAuthn.removeVirtualAuthenticator', {
103+
authenticatorId: params.authenticatorId,
104+
});
105+
return {action, result: 'removedAuthenticator'};
106+
}
107+
case 'setUserVerified': {
108+
if (!params.authenticatorId) {
109+
throw new Error('authenticatorId is required for setUserVerified');
110+
}
111+
await client.send('WebAuthn.setUserVerified', {
112+
authenticatorId: params.authenticatorId,
113+
isUserVerified: params.userVerified ?? true,
114+
});
115+
return {action, result: 'setUserVerified'};
116+
}
117+
default:
118+
throw new Error(`Unsupported action: ${action as string}`);
119+
}
120+
}
121+
122+
export const configureWebauthn = definePageTool({
123+
name: 'configure_webauthn',
124+
description:
125+
'Configure experimental WebAuthn virtual authenticator state. Always returns status in the response.',
126+
annotations: {
127+
category: ToolCategory.DEBUGGING,
128+
readOnlyHint: false,
129+
conditions: ['experimentalWebauthn'],
130+
},
131+
schema: {
132+
action: zod
133+
.enum(ACTIONS)
134+
.default('status')
135+
.describe('Action to apply to WebAuthn virtual authenticator state.'),
136+
authenticatorId: zod
137+
.string()
138+
.optional()
139+
.describe('Virtual authenticator ID for targeted actions.'),
140+
userVerified: zod
141+
.boolean()
142+
.optional()
143+
.describe('User verification state for setUserVerified action.'),
144+
protocol: zod
145+
.enum(['ctap2', 'u2f'])
146+
.optional()
147+
.describe('Authenticator protocol for addAuthenticator.'),
148+
transport: zod
149+
.enum(['usb', 'nfc', 'ble', 'internal'])
150+
.optional()
151+
.describe('Authenticator transport for addAuthenticator.'),
152+
hasResidentKey: zod
153+
.boolean()
154+
.optional()
155+
.describe('Whether resident keys are supported for addAuthenticator.'),
156+
hasUserVerification: zod
157+
.boolean()
158+
.optional()
159+
.describe('Whether user verification is supported for addAuthenticator.'),
160+
automaticPresenceSimulation: zod
161+
.boolean()
162+
.optional()
163+
.describe('Whether presence simulation is enabled for addAuthenticator.'),
164+
isUserVerified: zod
165+
.boolean()
166+
.optional()
167+
.describe('Initial user verification value for addAuthenticator.'),
168+
},
169+
handler: async ({params, page}, response) => {
170+
const client = getCdpClient(page);
171+
const actionResult = await handleAction(params.action, params, client);
172+
const status = await getStatus(client);
173+
174+
response.appendResponseLine('WebAuthn status:');
175+
response.appendResponseLine(`- enabled: ${status.enabled}`);
176+
response.appendResponseLine(
177+
`- authenticators: ${status.authenticators.length}`,
178+
);
179+
response.appendResponseLine(`- credentials: ${status.credentials.length}`);
180+
response.appendResponseLine(`Action result: ${JSON.stringify(actionResult)}`);
181+
},
182+
});
183+

tests/cli.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ describe('cli args parsing', () => {
2323
performanceCrux: true,
2424
'usage-statistics': true,
2525
usageStatistics: true,
26+
'experimental-webauthn': false,
27+
experimentalWebauthn: false,
2628
};
2729

2830
it('parses with default args', async () => {

tests/index.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,17 @@ describe('e2e', () => {
162162
['--experimental-interop-tools'],
163163
);
164164
});
165+
166+
it('has experimental webauthn tools', async () => {
167+
await withClient(
168+
async client => {
169+
const {tools} = await client.listTools();
170+
const configureWebauthn = tools.find(
171+
t => t.name === 'configure_webauthn',
172+
);
173+
assert.ok(configureWebauthn);
174+
},
175+
['--experimental-webauthn'],
176+
);
177+
});
165178
});

tests/tools/webauthn.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import {describe, it} from 'node:test';
9+
10+
import sinon from 'sinon';
11+
12+
import {configureWebauthn} from '../../src/tools/webauthn.js';
13+
import {withMcpContext} from '../utils.js';
14+
15+
describe('webauthn', () => {
16+
it('reports status', async () => {
17+
await withMcpContext(async (response, context) => {
18+
const selectedPage = context.getSelectedPptrPage();
19+
const send = sinon.stub().resolves({credentials: []});
20+
sinon
21+
.stub(selectedPage as unknown as Record<string, unknown>, '_client')
22+
.returns({send} as never);
23+
24+
await configureWebauthn.handler(
25+
{
26+
params: {action: 'status'},
27+
page: context.getSelectedMcpPage(),
28+
},
29+
response,
30+
context,
31+
);
32+
33+
assert.ok(response.responseLines.join('\n').includes('WebAuthn status:'));
34+
sinon.assert.calledWith(send, 'WebAuthn.getCredentials', {});
35+
});
36+
});
37+
38+
it('enables WebAuthn and includes action result', async () => {
39+
await withMcpContext(async (response, context) => {
40+
const selectedPage = context.getSelectedPptrPage();
41+
const send = sinon
42+
.stub()
43+
.onFirstCall()
44+
.resolves(undefined)
45+
.onSecondCall()
46+
.resolves({credentials: []});
47+
sinon
48+
.stub(selectedPage as unknown as Record<string, unknown>, '_client')
49+
.returns({send} as never);
50+
51+
await configureWebauthn.handler(
52+
{
53+
params: {action: 'enable'},
54+
page: context.getSelectedMcpPage(),
55+
},
56+
response,
57+
context,
58+
);
59+
60+
sinon.assert.calledWith(send.firstCall, 'WebAuthn.enable');
61+
assert.ok(response.responseLines.join('\n').includes('"result":"enabled"'));
62+
});
63+
});
64+
65+
it('throws if removeAuthenticator is missing authenticatorId', async () => {
66+
await withMcpContext(async (response, context) => {
67+
const selectedPage = context.getSelectedPptrPage();
68+
const send = sinon.stub().resolves({credentials: []});
69+
sinon
70+
.stub(selectedPage as unknown as Record<string, unknown>, '_client')
71+
.returns({send} as never);
72+
73+
await assert.rejects(
74+
configureWebauthn.handler(
75+
{
76+
params: {action: 'removeAuthenticator'},
77+
page: context.getSelectedMcpPage(),
78+
},
79+
response,
80+
context,
81+
),
82+
/authenticatorId is required/,
83+
);
84+
});
85+
});
86+
});

0 commit comments

Comments
 (0)