chrome-devtools-mcp-docker 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KiritoMiao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # chrome-devtools-mcp-docker
2
+
3
+ Lazy Docker-backed Chrome for Chrome DevTools MCP, with a noVNC web interface for watching or controlling the same browser session.
4
+
5
+ When the MCP server starts, it:
6
+
7
+ 1. Starts a Selenium standalone Chrome container in Docker.
8
+ 2. Generates a temporary noVNC password.
9
+ 3. Prints web-control URLs with the password embedded.
10
+ 4. Creates a Chrome session and connects the bundled `chrome-devtools-mcp` server to its CDP endpoint.
11
+ 5. Stops the container when the MCP server exits, unless configured otherwise.
12
+
13
+ ## Requirements
14
+
15
+ - Node.js 20.19 or newer.
16
+ - Docker Engine.
17
+ - Permission to run Docker as the current user, or passwordless `sudo docker`.
18
+
19
+ ## Codex Config
20
+
21
+ From GitHub:
22
+
23
+ ```toml
24
+ [mcp_servers.chrome-devtools]
25
+ command = "npx"
26
+ args = [
27
+ "-y",
28
+ "git+ssh://git@github.com/KiritoMiao/chrome-devtools-mcp-docker.git",
29
+ "--web-host",
30
+ "127.0.0.1",
31
+ "--web-port",
32
+ "8080"
33
+ ]
34
+ startup_timeout_ms = 60_000
35
+ ```
36
+
37
+ From npm, after publishing:
38
+
39
+ ```toml
40
+ [mcp_servers.chrome-devtools]
41
+ command = "npx"
42
+ args = ["-y", "chrome-devtools-mcp-docker"]
43
+ startup_timeout_ms = 60_000
44
+ ```
45
+
46
+ ## Web Interface
47
+
48
+ The temporary URL is printed to MCP stderr on startup:
49
+
50
+ ```text
51
+ [chrome-devtools-mcp-docker] browser web control URL (configured): http://127.0.0.1:8080/?autoconnect=1&resize=scale&password=<temporary-password>
52
+ [chrome-devtools-mcp-docker] browser web control URL (cloudflare public IP <detected-ip>): http://<detected-ip>:8080/?autoconnect=1&resize=scale&password=<temporary-password>
53
+ [chrome-devtools-mcp-docker] browser web control URL (interface IP <detected-ip>): http://<detected-ip>:8080/?autoconnect=1&resize=scale&password=<temporary-password>
54
+ ```
55
+
56
+ The Cloudflare line is detected from `https://www.cloudflare.com/cdn-cgi/trace`. The interface line is detected from the host network interfaces. If a detector cannot find an address, that line is omitted.
57
+
58
+ Useful options:
59
+
60
+ ```sh
61
+ npx -y chrome-devtools-mcp-docker --web-host 127.0.0.1 --web-port 8080
62
+ npx -y chrome-devtools-mcp-docker --web-listen 0.0.0.0:18080 --web-url http://YOUR_HOST:18080
63
+ ```
64
+
65
+ `--web-host` controls the listen IP address for the browser web interface. `--web-port` controls the browser web interface host port. Selenium/CDP stays bound to `127.0.0.1:4444` by default.
66
+
67
+ If exposing the web interface beyond localhost, set `--web-url` to the URL users should open.
68
+
69
+ ## CLI
70
+
71
+ ```text
72
+ chrome-devtools-mcp-docker [options] [-- chrome-devtools-mcp args]
73
+ ```
74
+
75
+ Options:
76
+
77
+ - `--web-host <ip>`: IP address for the browser web UI to listen on. Default: `127.0.0.1`.
78
+ - `--web-port <port>`: Host port for the browser web UI. Default: `8080`.
79
+ - `--web-listen <ip:port>`: Shorthand for `--web-host` and `--web-port`.
80
+ - `--web-url <url>`: URL printed for users. Default: `http://127.0.0.1:<web-port>`.
81
+ - `--selenium-port <port>`: Host port for the Selenium/CDP proxy. Default: `4444`.
82
+ - `--selenium-session-timeout <seconds>`: Selenium browser session timeout. Default: `86400`.
83
+ - `--devtools-host <ip>`: IP address for the Selenium/CDP host port. Default: `127.0.0.1`.
84
+ - `--image <image>`: Docker image. Default: `selenium/standalone-chrome:latest`.
85
+ - `--container <name>`: Docker container name. Default: `chrome-devtools-mcp-docker`.
86
+ - `--no-stop-on-exit`: Leave the browser container running after MCP exits.
87
+ - `--status`: Show container status without starting it.
88
+ - `--stop-container`: Stop the container.
89
+
90
+ Environment variables use the `CHROME_DEVTOOLS_MCP_DOCKER_` prefix, for example:
91
+
92
+ - `CHROME_DEVTOOLS_MCP_DOCKER_WEB_HOST`
93
+ - `CHROME_DEVTOOLS_MCP_DOCKER_WEB_PORT`
94
+ - `CHROME_DEVTOOLS_MCP_DOCKER_WEB_URL`
95
+ - `CHROME_DEVTOOLS_MCP_DOCKER_SELENIUM_PORT`
96
+ - `CHROME_DEVTOOLS_MCP_DOCKER_SELENIUM_SESSION_TIMEOUT`
97
+
98
+ ## Development
99
+
100
+ ```sh
101
+ npm install
102
+ npm test
103
+ npm pack --dry-run
104
+ ```
105
+
106
+ ## Publishing
107
+
108
+ Publishing is handled by GitHub Actions through npm trusted publishing. Configure npm before the first release:
109
+
110
+ 1. Create or claim the `chrome-devtools-mcp-docker` package on npm.
111
+ 2. In npm package settings, add a trusted publisher:
112
+ - Provider: GitHub Actions
113
+ - Organization/user: `KiritoMiao`
114
+ - Repository: `chrome-devtools-mcp-docker`
115
+ - Workflow filename: `publish.yml`
116
+ - Environment: leave empty
117
+ 3. Push a version bump to `main`.
118
+ 4. Create a GitHub release for that version.
119
+
120
+ The workflow runs tests and `npm pack --dry-run`, then publishes to npm with provenance. Published prereleases use the `next` dist-tag by default; normal releases use `latest`. You can also run the workflow manually and choose `latest` or `next`.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { helpText, loadConfig } from '../src/config.js';
7
+ import { runServer } from '../src/runtime.js';
8
+
9
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
10
+
11
+ try {
12
+ const config = loadConfig();
13
+
14
+ if (config.help) {
15
+ process.stdout.write(helpText());
16
+ process.exit(0);
17
+ }
18
+
19
+ if (config.version) {
20
+ const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
21
+ process.stdout.write(`${packageJson.version}\n`);
22
+ process.exit(0);
23
+ }
24
+
25
+ const exitCode = await runServer(config);
26
+ process.exit(exitCode);
27
+ } catch (error) {
28
+ process.stderr.write(`[chrome-devtools-mcp-docker] error: ${error.message}\n`);
29
+ process.exit(1);
30
+ }
@@ -0,0 +1,11 @@
1
+ [mcp_servers.chrome-devtools]
2
+ command = "npx"
3
+ args = [
4
+ "-y",
5
+ "git+ssh://git@github.com/KiritoMiao/chrome-devtools-mcp-docker.git",
6
+ "--web-host",
7
+ "127.0.0.1",
8
+ "--web-port",
9
+ "8080"
10
+ ]
11
+ startup_timeout_ms = 60_000
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "chrome-devtools-mcp-docker",
3
+ "version": "0.1.5",
4
+ "description": "Lazy Docker-backed Chrome DevTools MCP server with a noVNC web UI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "chrome-devtools-mcp-docker": "bin/chrome-devtools-mcp-docker.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "start": "node ./bin/chrome-devtools-mcp-docker.js"
12
+ },
13
+ "dependencies": {
14
+ "chrome-devtools-mcp": "0.25.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=20.19.0"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/KiritoMiao/chrome-devtools-mcp-docker.git"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "chrome-devtools",
26
+ "selenium",
27
+ "novnc",
28
+ "docker",
29
+ "chromium"
30
+ ],
31
+ "license": "MIT",
32
+ "files": [
33
+ "bin/",
34
+ "src/",
35
+ "examples/",
36
+ "README.md",
37
+ "LICENSE"
38
+ ]
39
+ }
package/src/config.js ADDED
@@ -0,0 +1,281 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULTS = Object.freeze({
5
+ containerName: 'chrome-devtools-mcp-docker',
6
+ image: undefined,
7
+ webHost: '127.0.0.1',
8
+ webPort: 8080,
9
+ devtoolsHost: '127.0.0.1',
10
+ seleniumPort: 4444,
11
+ seleniumContainerPort: 4444,
12
+ seleniumSessionTimeout: 86400,
13
+ shmSize: '2g',
14
+ stopOnExit: true,
15
+ waitAttempts: 80
16
+ });
17
+
18
+ const ENV_MAP = Object.freeze({
19
+ containerName: 'CHROME_DEVTOOLS_MCP_DOCKER_CONTAINER',
20
+ image: 'CHROME_DEVTOOLS_MCP_DOCKER_IMAGE',
21
+ webHost: 'CHROME_DEVTOOLS_MCP_DOCKER_WEB_HOST',
22
+ webPort: 'CHROME_DEVTOOLS_MCP_DOCKER_WEB_PORT',
23
+ webUrl: 'CHROME_DEVTOOLS_MCP_DOCKER_WEB_URL',
24
+ devtoolsHost: 'CHROME_DEVTOOLS_MCP_DOCKER_DEVTOOLS_HOST',
25
+ seleniumPort: 'CHROME_DEVTOOLS_MCP_DOCKER_SELENIUM_PORT',
26
+ seleniumContainerPort: 'CHROME_DEVTOOLS_MCP_DOCKER_SELENIUM_CONTAINER_PORT',
27
+ seleniumSessionTimeout: 'CHROME_DEVTOOLS_MCP_DOCKER_SELENIUM_SESSION_TIMEOUT',
28
+ shmSize: 'CHROME_DEVTOOLS_MCP_DOCKER_SHM_SIZE',
29
+ currentUrlFile: 'CHROME_DEVTOOLS_MCP_DOCKER_CURRENT_URL_FILE',
30
+ stopOnExit: 'CHROME_DEVTOOLS_MCP_DOCKER_STOP_CONTAINER_ON_EXIT',
31
+ waitAttempts: 'CHROME_DEVTOOLS_MCP_DOCKER_WAIT_ATTEMPTS'
32
+ });
33
+
34
+ const FLAG_MAP = Object.freeze({
35
+ '--container': 'containerName',
36
+ '--container-name': 'containerName',
37
+ '--image': 'image',
38
+ '--web-host': 'webHost',
39
+ '--web-port': 'webPort',
40
+ '--web-url': 'webUrl',
41
+ '--devtools-host': 'devtoolsHost',
42
+ '--selenium-port': 'seleniumPort',
43
+ '--selenium-container-port': 'seleniumContainerPort',
44
+ '--selenium-session-timeout': 'seleniumSessionTimeout',
45
+ '--shm-size': 'shmSize',
46
+ '--current-url-file': 'currentUrlFile',
47
+ '--wait-attempts': 'waitAttempts'
48
+ });
49
+
50
+ const REMOVED_FLAGS = new Set([
51
+ '--backend',
52
+ '--devtools-port',
53
+ '--devtools-container-port',
54
+ '--webrtc-port',
55
+ '--webrtc-nat-ip',
56
+ '--screen-width',
57
+ '--screen-height',
58
+ '--screen-rate'
59
+ ]);
60
+
61
+ const NUMBER_KEYS = new Set([
62
+ 'webPort',
63
+ 'seleniumPort',
64
+ 'seleniumContainerPort',
65
+ 'seleniumSessionTimeout',
66
+ 'waitAttempts'
67
+ ]);
68
+
69
+ export function loadConfig({ argv = process.argv.slice(2), env = process.env } = {}) {
70
+ const options = { ...DEFAULTS };
71
+ options.currentUrlFile = defaultCurrentUrlFile(env);
72
+
73
+ for (const [key, envName] of Object.entries(ENV_MAP)) {
74
+ if (env[envName] !== undefined && env[envName] !== '') {
75
+ options[key] = coerceValue(key, env[envName]);
76
+ }
77
+ }
78
+
79
+ const passthroughArgs = [];
80
+ for (let index = 0; index < argv.length; index += 1) {
81
+ const arg = argv[index];
82
+
83
+ if (arg === '--') {
84
+ passthroughArgs.push(...argv.slice(index + 1));
85
+ break;
86
+ }
87
+
88
+ if (arg === '--help' || arg === '-h') {
89
+ options.help = true;
90
+ continue;
91
+ }
92
+
93
+ if (arg === '--version' || arg === '-v') {
94
+ options.version = true;
95
+ continue;
96
+ }
97
+
98
+ if (arg === '--status' || arg === '--stop-container') {
99
+ options.command = arg.slice(2);
100
+ continue;
101
+ }
102
+
103
+ if (arg === '--no-stop-on-exit') {
104
+ options.stopOnExit = false;
105
+ continue;
106
+ }
107
+
108
+ if (arg === '--stop-on-exit') {
109
+ options.stopOnExit = true;
110
+ continue;
111
+ }
112
+
113
+ if (arg === '--web-listen') {
114
+ const value = argv[++index];
115
+ if (!value) {
116
+ throw new Error('--web-listen requires host:port');
117
+ }
118
+ const parsed = parseHostPort(value, '--web-listen');
119
+ options.webHost = parsed.host;
120
+ options.webPort = parsed.port;
121
+ continue;
122
+ }
123
+
124
+ const inline = arg.match(/^(--[^=]+)=(.*)$/);
125
+ const flag = inline ? inline[1] : arg;
126
+ if (REMOVED_FLAGS.has(flag)) {
127
+ throw new Error(`Unsupported option: ${flag}`);
128
+ }
129
+ if (FLAG_MAP[flag]) {
130
+ const key = FLAG_MAP[flag];
131
+ const value = inline ? inline[2] : argv[++index];
132
+ if (value === undefined) {
133
+ throw new Error(`${flag} requires a value`);
134
+ }
135
+ options[key] = coerceValue(key, value);
136
+ continue;
137
+ }
138
+
139
+ passthroughArgs.push(arg);
140
+ }
141
+
142
+ validateConfig(options);
143
+
144
+ if (!options.image) {
145
+ options.image = 'selenium/standalone-chrome:latest';
146
+ }
147
+
148
+ const webUrl = options.webUrl ?? `http://${displayHost(options.webHost)}:${options.webPort}`;
149
+ const seleniumUrl = `http://${options.devtoolsHost}:${options.seleniumPort}`;
150
+ const seleniumWsUrl = `ws://${options.devtoolsHost}:${options.seleniumPort}`;
151
+
152
+ return Object.freeze({
153
+ ...options,
154
+ webUrl,
155
+ seleniumUrl,
156
+ seleniumWsUrl,
157
+ passthroughArgs
158
+ });
159
+ }
160
+
161
+ export function buildDockerRunArgs(config, { adminPassword }) {
162
+ return [
163
+ 'run',
164
+ '-d',
165
+ '--rm',
166
+ '--name',
167
+ config.containerName,
168
+ '--shm-size',
169
+ config.shmSize,
170
+ '-p',
171
+ `${config.webHost}:${config.webPort}:7900/tcp`,
172
+ '-p',
173
+ `${config.devtoolsHost}:${config.seleniumPort}:${config.seleniumContainerPort}/tcp`,
174
+ '-e',
175
+ `SE_VNC_PASSWORD=${adminPassword}`,
176
+ '-e',
177
+ 'SE_NODE_MAX_SESSIONS=1',
178
+ '-e',
179
+ `SE_NODE_SESSION_TIMEOUT=${config.seleniumSessionTimeout}`,
180
+ config.image
181
+ ];
182
+ }
183
+
184
+ export function helpText() {
185
+ return `chrome-devtools-mcp-docker
186
+
187
+ Starts a Docker-backed browser with a web UI and proxies chrome-devtools-mcp to it.
188
+
189
+ Usage:
190
+ chrome-devtools-mcp-docker [options] [-- chrome-devtools-mcp args]
191
+
192
+ Options:
193
+ --web-host <ip> IP address for the browser web UI to listen on. Default: 127.0.0.1
194
+ --web-port <port> Host port for the browser web UI. Default: 8080
195
+ --web-listen <ip:port> Shorthand for --web-host and --web-port
196
+ --web-url <url> URL printed for users. Default: http://127.0.0.1:<web-port>
197
+ --selenium-port <port> Host port for Selenium/CDP proxy. Default: 4444
198
+ --selenium-session-timeout <seconds>
199
+ Selenium browser session timeout. Default: 86400
200
+ --devtools-host <ip> IP address for DevTools host port. Default: 127.0.0.1
201
+ --image <image> Docker image. Default: selenium/standalone-chrome:latest
202
+ --container <name> Docker container name. Default: chrome-devtools-mcp-docker
203
+ --no-stop-on-exit Leave the browser container running after MCP exits
204
+ --status Show the browser container status
205
+ --stop-container Stop the browser container
206
+ --help Show this help
207
+
208
+ Environment:
209
+ CHROME_DEVTOOLS_MCP_DOCKER_WEB_HOST
210
+ CHROME_DEVTOOLS_MCP_DOCKER_WEB_PORT
211
+ CHROME_DEVTOOLS_MCP_DOCKER_WEB_URL
212
+ CHROME_DEVTOOLS_MCP_DOCKER_SELENIUM_PORT
213
+ CHROME_DEVTOOLS_MCP_DOCKER_SELENIUM_SESSION_TIMEOUT
214
+ `;
215
+ }
216
+
217
+ function defaultCurrentUrlFile(env) {
218
+ const base =
219
+ env.XDG_RUNTIME_DIR ||
220
+ env.TMPDIR ||
221
+ os.tmpdir();
222
+ return path.join(base, 'chrome-devtools-mcp-docker-current-url');
223
+ }
224
+
225
+ function coerceValue(key, value) {
226
+ if (NUMBER_KEYS.has(key)) {
227
+ return parsePortishNumber(key, value);
228
+ }
229
+
230
+ if (key === 'stopOnExit') {
231
+ return !['0', 'false', 'no', 'off'].includes(String(value).toLowerCase());
232
+ }
233
+
234
+ return value;
235
+ }
236
+
237
+ function parseHostPort(value, label) {
238
+ const lastColon = value.lastIndexOf(':');
239
+ if (lastColon <= 0 || lastColon === value.length - 1) {
240
+ throw new Error(`${label} requires host:port`);
241
+ }
242
+
243
+ return {
244
+ host: value.slice(0, lastColon),
245
+ port: parsePort(value.slice(lastColon + 1))
246
+ };
247
+ }
248
+
249
+ function parsePortishNumber(key, value) {
250
+ const number = Number(value);
251
+ if (!Number.isInteger(number)) {
252
+ throw new Error(`Invalid number for ${key}: ${value}`);
253
+ }
254
+ if (key.toLowerCase().includes('port')) {
255
+ return parsePort(value);
256
+ }
257
+ if (number <= 0) {
258
+ throw new Error(`Invalid positive number for ${key}: ${value}`);
259
+ }
260
+ return number;
261
+ }
262
+
263
+ function parsePort(value) {
264
+ const port = Number(value);
265
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
266
+ throw new Error(`Invalid port: ${value}`);
267
+ }
268
+ return port;
269
+ }
270
+
271
+ function validateConfig(config) {
272
+ for (const key of ['webHost', 'devtoolsHost']) {
273
+ if (!config[key] || typeof config[key] !== 'string') {
274
+ throw new Error(`${key} is required`);
275
+ }
276
+ }
277
+ }
278
+
279
+ function displayHost(host) {
280
+ return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
281
+ }
package/src/docker.js ADDED
@@ -0,0 +1,72 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+
3
+ export function resolveDockerCommand() {
4
+ if (commandSucceeds(['docker', 'info'])) {
5
+ return ['docker'];
6
+ }
7
+ if (commandSucceeds(['sudo', '-n', 'docker', 'info'])) {
8
+ return ['sudo', 'docker'];
9
+ }
10
+ throw new Error('Docker is not available. Re-login for docker group membership or configure passwordless sudo for docker.');
11
+ }
12
+
13
+ export function runDocker(dockerCommand, args, options = {}) {
14
+ const result = spawnSync(dockerCommand[0], [...dockerCommand.slice(1), ...args], {
15
+ encoding: 'utf8',
16
+ ...options
17
+ });
18
+
19
+ if (result.error) {
20
+ throw result.error;
21
+ }
22
+
23
+ if (result.status !== 0) {
24
+ const stderr = result.stderr?.trim();
25
+ const stdout = result.stdout?.trim();
26
+ throw new Error(stderr || stdout || `docker ${args.join(' ')} failed with status ${result.status}`);
27
+ }
28
+
29
+ return result.stdout ?? '';
30
+ }
31
+
32
+ export function spawnDocker(dockerCommand, args, options = {}) {
33
+ return spawn(dockerCommand[0], [...dockerCommand.slice(1), ...args], {
34
+ stdio: 'inherit',
35
+ ...options
36
+ });
37
+ }
38
+
39
+ export function containerRunning(dockerCommand, name) {
40
+ const result = spawnSync(dockerCommand[0], [
41
+ ...dockerCommand.slice(1),
42
+ 'inspect',
43
+ '-f',
44
+ '{{.State.Running}}',
45
+ name
46
+ ], {
47
+ encoding: 'utf8'
48
+ });
49
+
50
+ return result.status === 0 && result.stdout.trim() === 'true';
51
+ }
52
+
53
+ export function containerExists(dockerCommand, name) {
54
+ const result = spawnSync(dockerCommand[0], [
55
+ ...dockerCommand.slice(1),
56
+ 'inspect',
57
+ name
58
+ ], {
59
+ encoding: 'utf8',
60
+ stdio: ['ignore', 'ignore', 'ignore']
61
+ });
62
+
63
+ return result.status === 0;
64
+ }
65
+
66
+ function commandSucceeds(command) {
67
+ const result = spawnSync(command[0], command.slice(1), {
68
+ stdio: ['ignore', 'ignore', 'ignore']
69
+ });
70
+
71
+ return result.status === 0;
72
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,433 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import http from 'node:http';
4
+ import https from 'node:https';
5
+ import net from 'node:net';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+ import { spawn } from 'node:child_process';
10
+
11
+ import { buildDockerRunArgs } from './config.js';
12
+ import { containerExists, containerRunning, resolveDockerCommand, runDocker } from './docker.js';
13
+
14
+ const require = createRequire(import.meta.url);
15
+
16
+ export async function runServer(config, streams = process) {
17
+ const dockerCommand = resolveDockerCommand();
18
+
19
+ if (config.command === 'status') {
20
+ streams.stderr.write(runDocker(dockerCommand, ['ps', '-a', '--filter', `name=^/${config.containerName}$`]));
21
+ await printCurrentUrl(config, streams);
22
+ return 0;
23
+ }
24
+
25
+ if (config.command === 'stop-container') {
26
+ if (containerExists(dockerCommand, config.containerName)) {
27
+ runDocker(dockerCommand, ['stop', config.containerName], { stdio: 'ignore' });
28
+ }
29
+ await removeCurrentUrl(config);
30
+ return 0;
31
+ }
32
+
33
+ let startedByWrapper = false;
34
+ let mcpProcess;
35
+ let seleniumSession;
36
+
37
+ const cleanup = async () => {
38
+ if (mcpProcess && !mcpProcess.killed) {
39
+ mcpProcess.kill('SIGTERM');
40
+ }
41
+
42
+ if (seleniumSession) {
43
+ try {
44
+ await deleteSeleniumSession(config, seleniumSession.id);
45
+ } catch {
46
+ // Best-effort cleanup.
47
+ }
48
+ seleniumSession = undefined;
49
+ }
50
+
51
+ if (startedByWrapper && config.stopOnExit) {
52
+ log(streams, `stopping container: ${config.containerName}`);
53
+ try {
54
+ runDocker(dockerCommand, ['stop', '-t', '10', config.containerName], { stdio: 'ignore' });
55
+ } catch {
56
+ // Best-effort cleanup.
57
+ }
58
+ }
59
+
60
+ await removeCurrentUrl(config);
61
+ };
62
+
63
+ const handleSignal = async (signal) => {
64
+ await cleanup();
65
+ process.kill(process.pid, signal);
66
+ };
67
+
68
+ process.once('SIGINT', handleSignal);
69
+ process.once('SIGTERM', handleSignal);
70
+
71
+ try {
72
+ if (containerRunning(dockerCommand, config.containerName)) {
73
+ log(streams, `container already running: ${config.containerName}`);
74
+ } else {
75
+ if (containerExists(dockerCommand, config.containerName)) {
76
+ log(streams, `removing stale container: ${config.containerName}`);
77
+ runDocker(dockerCommand, ['rm', '-f', config.containerName], { stdio: 'ignore' });
78
+ }
79
+
80
+ const adminPassword = generatePassword();
81
+
82
+ log(streams, `creating and starting Chrome container: ${config.containerName}`);
83
+ runDocker(dockerCommand, buildDockerRunArgs(config, {
84
+ adminPassword
85
+ }), { stdio: 'ignore' });
86
+ startedByWrapper = true;
87
+
88
+ const controlUrlEntries = await resolveWebControlUrlEntries(config, adminPassword);
89
+ await fs.writeFile(config.currentUrlFile, serializeWebControlUrlEntries(controlUrlEntries), { mode: 0o600 });
90
+ logWebControlUrlEntries(streams, controlUrlEntries);
91
+ log(streams, `temporary browser web password: ${adminPassword}`);
92
+ }
93
+
94
+ await waitForReady(config, dockerCommand, streams);
95
+ await printCurrentUrl(config, streams);
96
+
97
+ seleniumSession = await createSeleniumSession(config, streams);
98
+
99
+ mcpProcess = spawnChromeDevtoolsMcp(config, seleniumSession.cdpWsUrl);
100
+ return await waitForChild(mcpProcess);
101
+ } finally {
102
+ process.off('SIGINT', handleSignal);
103
+ process.off('SIGTERM', handleSignal);
104
+ await cleanup();
105
+ }
106
+ }
107
+
108
+ export async function waitForReady(config, dockerCommand, streams = process) {
109
+ const statusUrl = `${config.seleniumUrl.replace(/\/$/, '')}/status`;
110
+ if (!(await waitForHttp(statusUrl, config.waitAttempts))) {
111
+ log(streams, `Selenium did not become ready at ${statusUrl}`);
112
+ dumpDockerLogs(dockerCommand, config, streams);
113
+ throw new Error('Selenium failed to become ready');
114
+ }
115
+ }
116
+
117
+ function spawnChromeDevtoolsMcp(config, cdpWsUrl) {
118
+ const packageJsonPath = require.resolve('chrome-devtools-mcp/package.json');
119
+ const binPath = path.join(path.dirname(packageJsonPath), 'build/src/bin/chrome-devtools-mcp.js');
120
+
121
+ return spawn(process.execPath, [
122
+ binPath,
123
+ '--wsEndpoint',
124
+ cdpWsUrl,
125
+ '--no-usage-statistics',
126
+ '--no-performance-crux',
127
+ ...config.passthroughArgs
128
+ ], {
129
+ stdio: 'inherit',
130
+ env: {
131
+ ...process.env,
132
+ CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: '1',
133
+ CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: '1'
134
+ }
135
+ });
136
+ }
137
+
138
+ async function createSeleniumSession(config, streams) {
139
+ const response = await requestJson(`${config.seleniumUrl.replace(/\/$/, '')}/session`, {
140
+ method: 'POST',
141
+ body: {
142
+ capabilities: {
143
+ alwaysMatch: {
144
+ browserName: 'chrome',
145
+ 'goog:chromeOptions': {
146
+ args: ['--remote-allow-origins=*']
147
+ }
148
+ }
149
+ }
150
+ }
151
+ });
152
+ const sessionId = response.value?.sessionId;
153
+ if (!sessionId) {
154
+ throw new Error('Selenium did not return a session id');
155
+ }
156
+
157
+ const cdpWsUrl = `${config.seleniumWsUrl.replace(/\/$/, '')}/session/${sessionId}/se/cdp`;
158
+ log(streams, `created Selenium Chrome session: ${sessionId}`);
159
+ return { id: sessionId, cdpWsUrl };
160
+ }
161
+
162
+ async function deleteSeleniumSession(config, sessionId) {
163
+ await requestJson(`${config.seleniumUrl.replace(/\/$/, '')}/session/${sessionId}`, {
164
+ method: 'DELETE',
165
+ allowEmpty: true
166
+ });
167
+ }
168
+
169
+ function waitForChild(child) {
170
+ return new Promise((resolve, reject) => {
171
+ child.once('error', reject);
172
+ child.once('exit', (code, signal) => {
173
+ if (signal) {
174
+ resolve(128);
175
+ } else {
176
+ resolve(code ?? 0);
177
+ }
178
+ });
179
+ });
180
+ }
181
+
182
+ function waitForHttp(url, attempts) {
183
+ return new Promise((resolve) => {
184
+ let remaining = attempts;
185
+
186
+ const attempt = () => {
187
+ const request = http.get(url, (response) => {
188
+ response.resume();
189
+ if (response.statusCode && response.statusCode >= 200 && response.statusCode < 500) {
190
+ resolve(true);
191
+ } else {
192
+ retry();
193
+ }
194
+ });
195
+
196
+ request.setTimeout(2000, () => {
197
+ request.destroy();
198
+ retry();
199
+ });
200
+
201
+ request.once('error', retry);
202
+ };
203
+
204
+ const retry = () => {
205
+ remaining -= 1;
206
+ if (remaining <= 0) {
207
+ resolve(false);
208
+ return;
209
+ }
210
+ setTimeout(attempt, 500);
211
+ };
212
+
213
+ attempt();
214
+ });
215
+ }
216
+
217
+ function requestJson(url, { method = 'GET', body, allowEmpty = false } = {}) {
218
+ return new Promise((resolve, reject) => {
219
+ const payload = body === undefined ? undefined : JSON.stringify(body);
220
+ const request = http.request(url, {
221
+ method,
222
+ headers: payload
223
+ ? {
224
+ 'content-type': 'application/json',
225
+ 'content-length': Buffer.byteLength(payload)
226
+ }
227
+ : undefined
228
+ }, (response) => {
229
+ const chunks = [];
230
+ response.on('data', (chunk) => chunks.push(chunk));
231
+ response.on('end', () => {
232
+ const text = Buffer.concat(chunks).toString('utf8');
233
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
234
+ reject(new Error(text || `HTTP ${response.statusCode} from ${url}`));
235
+ return;
236
+ }
237
+ if (!text && allowEmpty) {
238
+ resolve({});
239
+ return;
240
+ }
241
+ try {
242
+ resolve(JSON.parse(text));
243
+ } catch (error) {
244
+ reject(error);
245
+ }
246
+ });
247
+ });
248
+
249
+ request.once('error', reject);
250
+ if (payload) {
251
+ request.write(payload);
252
+ }
253
+ request.end();
254
+ });
255
+ }
256
+
257
+ function dumpDockerLogs(dockerCommand, config, streams) {
258
+ try {
259
+ streams.stderr.write(runDocker(dockerCommand, ['logs', '--tail=160', config.containerName]));
260
+ } catch {
261
+ // Ignore log collection errors.
262
+ }
263
+ }
264
+
265
+ async function printCurrentUrl(config, streams) {
266
+ try {
267
+ const text = (await fs.readFile(config.currentUrlFile, 'utf8')).trim();
268
+ if (!text) {
269
+ return;
270
+ }
271
+ logWebControlUrlEntries(streams, parseSerializedWebControlUrlEntries(text));
272
+ } catch {
273
+ // No current URL yet.
274
+ }
275
+ }
276
+
277
+ export function buildWebControlUrlEntries(config, password, { cloudflarePublicIp, interfacePublicIp } = {}) {
278
+ const entries = [
279
+ {
280
+ label: 'configured',
281
+ url: webControlUrl(config, password)
282
+ }
283
+ ];
284
+
285
+ if (cloudflarePublicIp) {
286
+ entries.push({
287
+ label: `cloudflare public IP ${cloudflarePublicIp}`,
288
+ url: webControlUrlForHost(config, password, cloudflarePublicIp)
289
+ });
290
+ }
291
+
292
+ if (interfacePublicIp) {
293
+ entries.push({
294
+ label: `interface IP ${interfacePublicIp}`,
295
+ url: webControlUrlForHost(config, password, interfacePublicIp)
296
+ });
297
+ }
298
+
299
+ return entries;
300
+ }
301
+
302
+ export function serializeWebControlUrlEntries(entries) {
303
+ if (entries.length === 0) {
304
+ return '';
305
+ }
306
+ const [primary, ...rest] = entries;
307
+ return [
308
+ primary.url,
309
+ ...rest.map((entry) => `${entry.label}=${entry.url}`)
310
+ ].join('\n') + '\n';
311
+ }
312
+
313
+ export function parseCloudflareTrace(text) {
314
+ for (const line of text.split(/\r?\n/)) {
315
+ const [key, value] = line.split('=', 2);
316
+ if (key === 'ip' && value && net.isIP(value)) {
317
+ return value;
318
+ }
319
+ }
320
+ return undefined;
321
+ }
322
+
323
+ export function findInterfacePublicIp(networkInterfaces = os.networkInterfaces()) {
324
+ for (const addresses of Object.values(networkInterfaces)) {
325
+ for (const address of addresses ?? []) {
326
+ const family = typeof address.family === 'string' ? address.family : `IPv${address.family}`;
327
+ if (family !== 'IPv4' || address.internal || !net.isIPv4(address.address)) {
328
+ continue;
329
+ }
330
+ if (!isPrivateOrLocalIPv4(address.address)) {
331
+ return address.address;
332
+ }
333
+ }
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ async function resolveWebControlUrlEntries(config, password) {
339
+ const [cloudflarePublicIp, interfacePublicIp] = await Promise.all([
340
+ fetchCloudflarePublicIp().catch(() => undefined),
341
+ Promise.resolve(findInterfacePublicIp())
342
+ ]);
343
+
344
+ return buildWebControlUrlEntries(config, password, {
345
+ cloudflarePublicIp,
346
+ interfacePublicIp
347
+ });
348
+ }
349
+
350
+ function webControlUrl(config, password) {
351
+ const baseUrl = config.webUrl.replace(/\/$/, '');
352
+ return webControlUrlFromBase(config, password, baseUrl);
353
+ }
354
+
355
+ function webControlUrlForHost(config, password, host) {
356
+ const base = new URL(config.webUrl);
357
+ return webControlUrlFromBase(config, password, `${base.protocol}//${urlHost(host)}:${config.webPort}`);
358
+ }
359
+
360
+ function webControlUrlFromBase(config, password, baseUrl) {
361
+ return `${baseUrl}/?autoconnect=1&resize=scale&password=${encodeURIComponent(password)}`;
362
+ }
363
+
364
+ function parseSerializedWebControlUrlEntries(text) {
365
+ return text.split(/\r?\n/)
366
+ .filter(Boolean)
367
+ .map((line, index) => {
368
+ if (index === 0) {
369
+ return { label: 'configured', url: line };
370
+ }
371
+ const separator = line.indexOf('=');
372
+ if (separator === -1) {
373
+ return { label: `url ${index + 1}`, url: line };
374
+ }
375
+ return {
376
+ label: line.slice(0, separator),
377
+ url: line.slice(separator + 1)
378
+ };
379
+ });
380
+ }
381
+
382
+ function logWebControlUrlEntries(streams, entries) {
383
+ for (const entry of entries) {
384
+ log(streams, `browser web control URL (${entry.label}): ${entry.url}`);
385
+ }
386
+ }
387
+
388
+ function fetchCloudflarePublicIp() {
389
+ return new Promise((resolve, reject) => {
390
+ const request = https.get('https://www.cloudflare.com/cdn-cgi/trace', (response) => {
391
+ const chunks = [];
392
+ response.on('data', (chunk) => chunks.push(chunk));
393
+ response.on('end', () => {
394
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
395
+ reject(new Error(`HTTP ${response.statusCode} from Cloudflare trace`));
396
+ return;
397
+ }
398
+ resolve(parseCloudflareTrace(Buffer.concat(chunks).toString('utf8')));
399
+ });
400
+ });
401
+
402
+ request.setTimeout(3000, () => {
403
+ request.destroy(new Error('Cloudflare trace timed out'));
404
+ });
405
+ request.once('error', reject);
406
+ });
407
+ }
408
+
409
+ function isPrivateOrLocalIPv4(address) {
410
+ const [a, b] = address.split('.').map(Number);
411
+ return a === 0 ||
412
+ a === 10 ||
413
+ a === 127 ||
414
+ (a === 169 && b === 254) ||
415
+ (a === 172 && b >= 16 && b <= 31) ||
416
+ (a === 192 && b === 168);
417
+ }
418
+
419
+ function urlHost(host) {
420
+ return net.isIPv6(host) ? `[${host}]` : host;
421
+ }
422
+
423
+ async function removeCurrentUrl(config) {
424
+ await fs.rm(config.currentUrlFile, { force: true });
425
+ }
426
+
427
+ function generatePassword() {
428
+ return crypto.randomBytes(18).toString('hex');
429
+ }
430
+
431
+ function log(streams, message) {
432
+ streams.stderr.write(`[chrome-devtools-mcp-docker] ${message}\n`);
433
+ }