Skip to content

Commit d6ba837

Browse files
committed
fix(extensions): show connected extension popup pages
Allow connected-browser sessions to include extension targets so popup pages show up in list_pages and can be selected like other pages. Keep extension management tools behind launched-browser mode only. That fixes popup visibility without exposing install/reload/trigger tools for browsers we only attach to. Also add CLI coverage for the flag combination and a regression test that launches a browser over remote debugging, opens an extension popup, and verifies the page is listed and snapshottable.
1 parent 728d902 commit d6ba837

4 files changed

Lines changed: 331 additions & 6 deletions

File tree

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const cliOptions = {
1212
type: 'boolean',
1313
description:
1414
'If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remote debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.',
15-
conflicts: ['isolated', 'executablePath', 'categoryExtensions'],
15+
conflicts: ['isolated', 'executablePath'],
1616
default: false,
1717
coerce: (value: boolean | undefined) => {
1818
if (!value) {
@@ -26,7 +26,7 @@ export const cliOptions = {
2626
description:
2727
'Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.',
2828
alias: 'u',
29-
conflicts: ['wsEndpoint', 'categoryExtensions'],
29+
conflicts: ['wsEndpoint'],
3030
coerce: (url: string | undefined) => {
3131
if (!url) {
3232
return;
@@ -44,7 +44,7 @@ export const cliOptions = {
4444
description:
4545
'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.',
4646
alias: 'w',
47-
conflicts: ['browserUrl', 'categoryExtensions'],
47+
conflicts: ['browserUrl'],
4848
coerce: (url: string | undefined) => {
4949
if (!url) {
5050
return;
@@ -213,9 +213,8 @@ export const cliOptions = {
213213
categoryExtensions: {
214214
type: 'boolean',
215215
hidden: true,
216-
conflicts: ['browserUrl', 'autoConnect', 'wsEndpoint'],
217216
describe:
218-
'Set to true to include tools related to extensions. Note: This feature is only supported with a pipe connection. autoConnect is not supported.',
217+
'Set to true to include extension pages and service workers. Extension management tools are only exposed when the browser is launched by chrome-devtools-mcp.',
219218
},
220219
categoryInPageTools: {
221220
type: 'boolean',

src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export async function createMcpServer(
8686
: undefined,
8787
userDataDir: serverArgs.userDataDir,
8888
devtools,
89+
enableExtensions: serverArgs.categoryExtensions,
8990
})
9091
: await ensureBrowserLaunched({
9192
headless: serverArgs.headless,
@@ -114,6 +115,11 @@ export async function createMcpServer(
114115
}
115116

116117
const toolMutex = new Mutex();
118+
const extensionToolsEnabled =
119+
serverArgs.categoryExtensions &&
120+
!serverArgs.browserUrl &&
121+
!serverArgs.wsEndpoint &&
122+
!serverArgs.autoConnect;
117123

118124
function registerTool(tool: ToolDefinition | DefinedPageTool): void {
119125
if (
@@ -136,7 +142,7 @@ export async function createMcpServer(
136142
}
137143
if (
138144
tool.annotations.category === ToolCategory.EXTENSIONS &&
139-
!serverArgs.categoryExtensions
145+
!extensionToolsEnabled
140146
) {
141147
return;
142148
}

tests/cli.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,47 @@ describe('cli args parsing', () => {
251251
});
252252
});
253253

254+
it('parses browserUrl with categoryExtensions', async () => {
255+
const args = parseArguments('1.0.0', [
256+
'node',
257+
'main.js',
258+
'--browserUrl',
259+
'http://localhost:3000',
260+
'--category-extensions',
261+
]);
262+
assert.deepStrictEqual(args, {
263+
...defaultArgs,
264+
_: [],
265+
headless: false,
266+
$0: 'npx chrome-devtools-mcp@latest',
267+
'browser-url': 'http://localhost:3000',
268+
browserUrl: 'http://localhost:3000',
269+
u: 'http://localhost:3000',
270+
'category-extensions': true,
271+
categoryExtensions: true,
272+
});
273+
});
274+
275+
it('parses auto-connect with categoryExtensions', async () => {
276+
const args = parseArguments('1.0.0', [
277+
'node',
278+
'main.js',
279+
'--auto-connect',
280+
'--category-extensions',
281+
]);
282+
assert.deepStrictEqual(args, {
283+
...defaultArgs,
284+
_: [],
285+
headless: false,
286+
$0: 'npx chrome-devtools-mcp@latest',
287+
channel: 'stable',
288+
'auto-connect': true,
289+
autoConnect: true,
290+
'category-extensions': true,
291+
categoryExtensions: true,
292+
});
293+
});
294+
254295
it('parses usage statistics flag', async () => {
255296
// Test default (should be true).
256297
const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']);
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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 {spawn} from 'node:child_process';
9+
import {once} from 'node:events';
10+
import {mkdtemp, rm} from 'node:fs/promises';
11+
import net from 'node:net';
12+
import os from 'node:os';
13+
import path from 'node:path';
14+
import {describe, it} from 'node:test';
15+
import {setTimeout as delay} from 'node:timers/promises';
16+
17+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
18+
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
19+
20+
const EXTENSION_SW_PATH = path.join(
21+
import.meta.dirname,
22+
'../../tests/tools/fixtures/extension-sw',
23+
);
24+
25+
function escapeRegex(value: string): string {
26+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27+
}
28+
29+
function getText(result: unknown): string {
30+
if (!result || typeof result !== 'object' || !('content' in result)) {
31+
return '';
32+
}
33+
const {content} = result as {
34+
content?: Array<{type: string; text?: string}>;
35+
};
36+
return (content ?? [])
37+
.filter((item: {type: string}) => item.type === 'text')
38+
.map((item: {text?: string}) => item.text ?? '')
39+
.join('\n');
40+
}
41+
42+
async function getFreePort(): Promise<number> {
43+
return await new Promise((resolve, reject) => {
44+
const server = net.createServer();
45+
server.listen(0, '127.0.0.1', () => {
46+
const address = server.address();
47+
if (!address || typeof address === 'string') {
48+
reject(new Error('Could not determine free port'));
49+
return;
50+
}
51+
const {port} = address;
52+
server.close(error => {
53+
if (error) {
54+
reject(error);
55+
return;
56+
}
57+
resolve(port);
58+
});
59+
});
60+
server.on('error', reject);
61+
});
62+
}
63+
64+
async function waitFor<T>(
65+
fn: () => Promise<T | null>,
66+
timeoutMs = 15000,
67+
): Promise<T> {
68+
const endTime = Date.now() + timeoutMs;
69+
let lastError: unknown;
70+
while (Date.now() < endTime) {
71+
try {
72+
const result = await fn();
73+
if (result !== null) {
74+
return result;
75+
}
76+
} catch (error) {
77+
lastError = error;
78+
}
79+
await delay(100);
80+
}
81+
throw new Error(
82+
`Timed out waiting for condition${lastError ? `: ${String(lastError)}` : ''}`,
83+
);
84+
}
85+
86+
async function fetchJson(url: string): Promise<unknown> {
87+
const response = await fetch(url);
88+
if (!response.ok) {
89+
throw new Error(`Failed to fetch ${url}: ${response.status}`);
90+
}
91+
return await response.json();
92+
}
93+
94+
async function createPopupTarget(
95+
port: number,
96+
extensionId: string,
97+
): Promise<void> {
98+
const version = (await fetchJson(
99+
`http://127.0.0.1:${port}/json/version`,
100+
)) as {
101+
webSocketDebuggerUrl: string;
102+
};
103+
await new Promise<void>((resolve, reject) => {
104+
const ws = new WebSocket(version.webSocketDebuggerUrl);
105+
ws.onopen = () => {
106+
ws.send(
107+
JSON.stringify({
108+
id: 1,
109+
method: 'Target.createTarget',
110+
params: {
111+
url: `chrome-extension://${extensionId}/popup.html`,
112+
newWindow: true,
113+
width: 400,
114+
height: 600,
115+
},
116+
}),
117+
);
118+
};
119+
ws.onmessage = event => {
120+
const message = JSON.parse(String(event.data)) as {
121+
id?: number;
122+
error?: {message: string};
123+
};
124+
if (message.id !== 1) {
125+
return;
126+
}
127+
ws.close();
128+
if (message.error) {
129+
reject(new Error(message.error.message));
130+
return;
131+
}
132+
resolve();
133+
};
134+
ws.onerror = event => {
135+
reject(new Error(`WebSocket error: ${String(event.type)}`));
136+
};
137+
});
138+
}
139+
140+
async function withConnectedClient(
141+
cb: (client: Client, extensionId: string) => Promise<void>,
142+
): Promise<void> {
143+
const port = await getFreePort();
144+
const userDataDir = await mkdtemp(
145+
path.join(os.tmpdir(), 'cdmcp-connected-extensions-'),
146+
);
147+
const chromePath = process.env.CHROME_M146_EXECUTABLE_PATH;
148+
assert.ok(chromePath, 'CHROME_M146_EXECUTABLE_PATH must be set');
149+
150+
const browserProcess = spawn(
151+
chromePath,
152+
[
153+
'--headless=new',
154+
`--remote-debugging-port=${port}`,
155+
`--user-data-dir=${userDataDir}`,
156+
'--no-first-run',
157+
'--no-default-browser-check',
158+
'--enable-unsafe-extension-debugging',
159+
`--disable-extensions-except=${EXTENSION_SW_PATH}`,
160+
`--load-extension=${EXTENSION_SW_PATH}`,
161+
],
162+
{
163+
stdio: ['ignore', 'ignore', 'pipe'],
164+
detached: true,
165+
},
166+
);
167+
168+
const transport = new StdioClientTransport({
169+
command: process.execPath,
170+
args: [
171+
'build/src/bin/chrome-devtools-mcp.js',
172+
'--browserUrl',
173+
`http://127.0.0.1:${port}`,
174+
'--categoryExtensions',
175+
'--no-usage-statistics',
176+
],
177+
});
178+
const client = new Client(
179+
{
180+
name: 'connected-browser-extensions-test',
181+
version: '1.0.0',
182+
},
183+
{
184+
capabilities: {},
185+
},
186+
);
187+
188+
try {
189+
await waitFor(async () => {
190+
return (await fetchJson(
191+
`http://127.0.0.1:${port}/json/version`,
192+
)) as Record<string, unknown>;
193+
});
194+
const serviceWorker = await waitFor(async () => {
195+
const targets = (await fetchJson(
196+
`http://127.0.0.1:${port}/json/list`,
197+
)) as Array<{type: string; url: string}>;
198+
return (
199+
targets.find(
200+
target =>
201+
target.type === 'service_worker' &&
202+
target.url.startsWith('chrome-extension://') &&
203+
target.url.endsWith('/sw.js'),
204+
) ?? null
205+
);
206+
});
207+
const extensionId = new URL(serviceWorker.url).host;
208+
209+
await createPopupTarget(port, extensionId);
210+
await waitFor(async () => {
211+
const targets = (await fetchJson(
212+
`http://127.0.0.1:${port}/json/list`,
213+
)) as Array<{type: string; url: string}>;
214+
return (
215+
targets.find(
216+
target =>
217+
target.type === 'page' &&
218+
target.url === `chrome-extension://${extensionId}/popup.html`,
219+
) ?? null
220+
);
221+
});
222+
223+
await client.connect(transport);
224+
await cb(client, extensionId);
225+
} finally {
226+
await client.close().catch(() => undefined);
227+
try {
228+
process.kill(-browserProcess.pid!, 'SIGKILL');
229+
} catch {
230+
browserProcess.kill('SIGKILL');
231+
}
232+
await Promise.race([once(browserProcess, 'exit'), delay(3000)]).catch(
233+
() => undefined,
234+
);
235+
await rm(userDataDir, {recursive: true, force: true, maxRetries: 10});
236+
}
237+
}
238+
239+
describe('connected browser extension pages', () => {
240+
it('lists extension popup pages without exposing extension management tools', async () => {
241+
await withConnectedClient(async (client, extensionId) => {
242+
const {tools} = await client.listTools();
243+
assert.ok(tools.find(tool => tool.name === 'list_pages'));
244+
assert.ok(!tools.find(tool => tool.name === 'install_extension'));
245+
assert.ok(!tools.find(tool => tool.name === 'trigger_extension_action'));
246+
247+
const listPagesResult = await client.callTool({
248+
name: 'list_pages',
249+
arguments: {},
250+
});
251+
const listPagesText = getText(listPagesResult);
252+
assert.match(listPagesText, /## Extension Pages/);
253+
assert.match(
254+
listPagesText,
255+
new RegExp(
256+
`(\\d+): chrome-extension://${escapeRegex(extensionId)}/popup\\.html(?: \\[selected\\])?`,
257+
),
258+
);
259+
260+
const popupPageMatch = listPagesText.match(
261+
new RegExp(
262+
`(\\d+): chrome-extension://${escapeRegex(extensionId)}/popup\\.html`,
263+
),
264+
);
265+
assert.ok(popupPageMatch, 'Popup page should be listed');
266+
267+
await client.callTool({
268+
name: 'select_page',
269+
arguments: {pageId: Number(popupPageMatch[1])},
270+
});
271+
const snapshotResult = await client.callTool({
272+
name: 'take_snapshot',
273+
arguments: {},
274+
});
275+
const snapshotText = getText(snapshotResult);
276+
assert.match(snapshotText, /Extension With Service Worker/);
277+
});
278+
});
279+
});

0 commit comments

Comments
 (0)