@comecaramelos/dsh-chrome-mcp 0.0.1
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.md +21 -0
- package/README.md +115 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +585 -0
- package/lib/index.js +647 -0
- package/package.json +40 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @comecaramelos/dsh-chrome-mcp — host half.
|
|
3
|
+
*
|
|
4
|
+
* Connects the Chrome DevTools MCP server (`chrome-devtools-mcp`, launched as
|
|
5
|
+
* `npx -y chrome-devtools-mcp@latest [extraFlags]`) as an MCP server through
|
|
6
|
+
* the stock @deepseek-ai/dsh-mcp-client bridge, and exposes the user-facing
|
|
7
|
+
* settings — extra flags, re-check trigger, and error reporting — under the
|
|
8
|
+
* `chrome-mcp` settings namespace: the Web GUI's Plugins settings page
|
|
9
|
+
* renders a card (see ./client.js) keyed by that namespace. Editing the
|
|
10
|
+
* extra flags re-applies the nested mcp-client plugin in place
|
|
11
|
+
* (fiber.update), which restarts the bridge with the new arguments.
|
|
12
|
+
*
|
|
13
|
+
* Persistence boundary: only the user-authored fields land in
|
|
14
|
+
* `$DSH_HOME/settings.yaml` — `extraFlags` (the last UI edit, used to launch
|
|
15
|
+
* on (re)start) and `recheckNonce` (the client→host recheck trigger; a module
|
|
16
|
+
* plugin has no other host channel, `harness.handle` is reserved for
|
|
17
|
+
* code-string halves). Status state (`lastError`, `checkRevision`,
|
|
18
|
+
* `chromeVersion`, `chromeMissing`) rides the composition base layer — served to the client
|
|
19
|
+
* inside the resolved value, held in memory, never persisted.
|
|
20
|
+
*
|
|
21
|
+
* One plugin instance per dsh host: the settings namespace is fixed.
|
|
22
|
+
*
|
|
23
|
+
* Config (cordis row):
|
|
24
|
+
* serverName tool namespace prefix (default "chrome")
|
|
25
|
+
* command launcher executable (default "npx")
|
|
26
|
+
* package npm spec to npx (default "chrome-devtools-mcp@latest")
|
|
27
|
+
* extraFlags flags appended after the spec
|
|
28
|
+
* (default ["--no-usage-statistics", "--no-performance-crux"])
|
|
29
|
+
* chromePath Chrome executable path override appended as
|
|
30
|
+
* `--executablePath=<path>`; "" = check discovery
|
|
31
|
+
* (see discoverChrome; a non-empty path is probed)
|
|
32
|
+
* chromePaths discovery candidate list when chromePath is ""
|
|
33
|
+
* (default defaultChromePaths(), mirroring upstream's
|
|
34
|
+
* stable-channel locations)
|
|
35
|
+
* env / cwd / toolCallTimeoutMs / failOnStartupError / reconnect
|
|
36
|
+
* passed through to dsh-mcp-client
|
|
37
|
+
*/
|
|
38
|
+
import { spawn } from "node:child_process";
|
|
39
|
+
import z from "@deepseek-ai/schemastery";
|
|
40
|
+
import * as McpClient from "@deepseek-ai/dsh-mcp-client";
|
|
41
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
42
|
+
import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
|
|
43
|
+
|
|
44
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
45
|
+
const name = "chrome-devtools-mcp";
|
|
46
|
+
|
|
47
|
+
/** No host services of our own: the nested bridge declares `tools`. */
|
|
48
|
+
const inject = [];
|
|
49
|
+
|
|
50
|
+
/** Fixed settings namespace; also the client card's slot key. */
|
|
51
|
+
export const SETTINGS_NAMESPACE = "chrome-mcp";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Flag entries are appended verbatim to the bridge argv and reach Chrome via
|
|
55
|
+
* a direct execve (no shell), so shell metacharacters cannot execute here.
|
|
56
|
+
* What must never reach argv is control characters (newlines, NULs) — they
|
|
57
|
+
* corrupt the argument framing, so entries may not contain any.
|
|
58
|
+
*/
|
|
59
|
+
const CONTROL_PATTERN = /[\x00-\x1f\x7f]/u;
|
|
60
|
+
|
|
61
|
+
/** Flags every launch carries unless the user overrides them. */
|
|
62
|
+
export const DEFAULT_EXTRA_FLAGS = ["--no-usage-statistics", "--no-performance-crux"];
|
|
63
|
+
|
|
64
|
+
/** Upstream configuration docs surfaced by the client card link. */
|
|
65
|
+
export const CONFIGURATION_DOCS_URL =
|
|
66
|
+
"https://github.com/ChromeDevTools/chrome-devtools-mcp/blob/main/docs/configuration.md";
|
|
67
|
+
|
|
68
|
+
/** Reconnect policy passthrough; mirrors dsh-mcp-client's schema. */
|
|
69
|
+
const Reconnect = z.object({
|
|
70
|
+
enabled: z.boolean().default(true),
|
|
71
|
+
initialDelayMs: z.number().min(1).max(600000).default(500),
|
|
72
|
+
maxDelayMs: z.number().min(1).max(600000).default(30000),
|
|
73
|
+
maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(10)
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Google Chrome stable-channel locations probed by discovery when
|
|
78
|
+
* `chromePath` is empty — mirrors the upstream (puppeteer) system-channel
|
|
79
|
+
* resolution. WSL Windows-side locations (`/mnt/c/...`) are deliberately
|
|
80
|
+
* absent: the interop launch cannot work (see .probe), and reporting a found
|
|
81
|
+
* Windows binary as healthy would be a false negative for the known
|
|
82
|
+
* executable-missing error. Unknown platforms probe nothing.
|
|
83
|
+
*/
|
|
84
|
+
export const DEFAULT_CHROME_PATHS = {
|
|
85
|
+
linux: [
|
|
86
|
+
"/opt/google/chrome/chrome",
|
|
87
|
+
"/usr/bin/google-chrome",
|
|
88
|
+
"/usr/bin/google-chrome-stable",
|
|
89
|
+
"/usr/local/bin/google-chrome",
|
|
90
|
+
"google-chrome"
|
|
91
|
+
],
|
|
92
|
+
darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
|
93
|
+
win32: [
|
|
94
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
95
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
96
|
+
]
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The default discovery candidate list for one platform; [] probes nothing. */
|
|
100
|
+
export function defaultChromePaths(platform = process.platform) {
|
|
101
|
+
const candidates = DEFAULT_CHROME_PATHS[platform];
|
|
102
|
+
return Array.isArray(candidates) ? [...candidates] : [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const Config = z.object({
|
|
106
|
+
serverName: z.string().pattern(/^[A-Za-z0-9_-]{1,32}$/).default("chrome"),
|
|
107
|
+
command: z.string().default("npx"),
|
|
108
|
+
package: z.string().min(1).default("chrome-devtools-mcp@latest"),
|
|
109
|
+
extraFlags: z.array(String).default(["--no-usage-statistics", "--no-performance-crux"]),
|
|
110
|
+
chromePath: z.string().default(""),
|
|
111
|
+
chromePaths: z.array(String).default(defaultChromePaths()),
|
|
112
|
+
env: z.dict(String).default({}),
|
|
113
|
+
cwd: z.string().default(""),
|
|
114
|
+
toolCallTimeoutMs: z.number().default(60000),
|
|
115
|
+
failOnStartupError: z.boolean().default(false),
|
|
116
|
+
reconnect: Reconnect
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* User-settings section served to the Web GUI card.
|
|
121
|
+
*
|
|
122
|
+
* Persisted (user layer in `$DSH_HOME/settings.yaml`): `extraFlags` and
|
|
123
|
+
* `recheckNonce`. Served but never persisted (composition base layer held in
|
|
124
|
+
* memory by the host): `lastError`, `checkRevision`, `chromeVersion`, `chromeMissing` — the
|
|
125
|
+
* client's recheck poll stops when the served `checkRevision` advances past
|
|
126
|
+
* its click-time value.
|
|
127
|
+
*/
|
|
128
|
+
export const SettingsSchema = z.object({
|
|
129
|
+
/** Flags appended after `npx -y <package>`; persisted last UI edit. */
|
|
130
|
+
extraFlags: z.array(String).default(["--no-usage-statistics", "--no-performance-crux"]),
|
|
131
|
+
/** Written by the UI re-check button; the host reacts to its change. */
|
|
132
|
+
recheckNonce: z.number().default(0),
|
|
133
|
+
/** Last check/connection failure, human-readable; empty when healthy. */
|
|
134
|
+
lastError: z.string().default(""),
|
|
135
|
+
/** Known error: the last failure was "no Chrome executable detected". */
|
|
136
|
+
chromeMissing: z.boolean().default(false),
|
|
137
|
+
/** Check run counter (success or failure); completion signal for the client. */
|
|
138
|
+
checkRevision: z.number().default(0),
|
|
139
|
+
/** Chrome executable version discovered by the last check; "" when unknown. */
|
|
140
|
+
chromeVersion: z.string().default("")
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** Timeout for one check subprocess (chrome executable probe). */
|
|
144
|
+
const CHECK_TIMEOUT_MS = 15000;
|
|
145
|
+
|
|
146
|
+
/** Max length of a reported error line. */
|
|
147
|
+
const ERROR_CAP = 240;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate a user-authored extraFlags list: a plain array of flag strings
|
|
151
|
+
* carrying no control characters (argv is execve'd without a shell). Throws
|
|
152
|
+
* with the offending entry so the settings layer can surface it.
|
|
153
|
+
* @param value - candidate flags array.
|
|
154
|
+
* @returns true when valid.
|
|
155
|
+
*/
|
|
156
|
+
export function validateExtraFlags(value) {
|
|
157
|
+
if (!Array.isArray(value)) {
|
|
158
|
+
throw new Error("extraFlags must be an array of strings");
|
|
159
|
+
}
|
|
160
|
+
for (const flag of value) {
|
|
161
|
+
if (typeof flag !== "string" || flag === "") {
|
|
162
|
+
throw new Error("extraFlags entries must be non-empty strings");
|
|
163
|
+
}
|
|
164
|
+
if (CONTROL_PATTERN.test(flag)) {
|
|
165
|
+
throw new Error(`extraFlags entry "${flag}" contains control characters`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when a flag already selects the Chrome executable (upstream flags:
|
|
172
|
+
* `--executablePath` / `--executable-path` / `-e`, with or without `=`). */
|
|
173
|
+
function carriesExecutablePath(flag) {
|
|
174
|
+
return /^(?:--executablePath|--executable-path|-e)(?:=|(?=\s)|$)/u.test(flag);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build the stdio argv tail for one flags list:
|
|
179
|
+
* `-y <package> [extraFlags]`, spawned with `command` (default `npx`), so the
|
|
180
|
+
* final invocation is `npx -y chrome-devtools-mcp@latest [extraFlags]`. When
|
|
181
|
+
* `chromePath` is set and the flags do not already carry an executable-path
|
|
182
|
+
* option, `--executablePath=<path>` is appended (upstream option:
|
|
183
|
+
* https://github.com/ChromeDevTools/chrome-devtools-mcp/blob/main/docs/configuration.md).
|
|
184
|
+
*/
|
|
185
|
+
export function buildServerArgs(entry, extraFlags) {
|
|
186
|
+
const flags = Array.isArray(extraFlags) ? extraFlags.filter((flag) => typeof flag === "string") : [];
|
|
187
|
+
const args = ["-y", entry.package, ...flags];
|
|
188
|
+
const executable = typeof entry.chromePath === "string" ? entry.chromePath.trim() : "";
|
|
189
|
+
if (executable !== "" && !flags.some((flag) => carriesExecutablePath(flag))) {
|
|
190
|
+
args.push(`--executablePath=${executable}`);
|
|
191
|
+
}
|
|
192
|
+
return args;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** True when two flags lists are element-wise equal (flags are arrays). */
|
|
196
|
+
export function sameFlags(a, b) {
|
|
197
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
198
|
+
return a.every((flag, index) => flag === b[index]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** stdio config for the nested mcp-client bridge for one flags list. */
|
|
202
|
+
function bridgeConfig(entry, extraFlags) {
|
|
203
|
+
return {
|
|
204
|
+
serverName: entry.serverName,
|
|
205
|
+
transport: "stdio",
|
|
206
|
+
command: entry.command,
|
|
207
|
+
args: buildServerArgs(entry, extraFlags),
|
|
208
|
+
env: entry.env,
|
|
209
|
+
cwd: entry.cwd,
|
|
210
|
+
toolCallTimeoutMs: entry.toolCallTimeoutMs,
|
|
211
|
+
failOnStartupError: entry.failOnStartupError,
|
|
212
|
+
reconnect: entry.reconnect
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** First non-empty line of a text block, capped. */
|
|
217
|
+
function firstLine(text, cap = ERROR_CAP) {
|
|
218
|
+
const line = String(text ?? "")
|
|
219
|
+
.split(/\r?\n/u)
|
|
220
|
+
.find((candidate) => candidate.trim() !== "") ?? "";
|
|
221
|
+
return line.trim().slice(0, cap);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
/** True when one extraFlag declares a connect mode (no local executable). */
|
|
226
|
+
function carriesConnectionMode(flag) {
|
|
227
|
+
return /^--(?:browserUrl|wsEndpoint)(?:=|(?=\s)|$)/iu.test(flag);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Spawn one `chromePath --version` probe (scrubbed parent env, bounded
|
|
232
|
+
* timeout). Resolves the first stdout line (the version); ENOENT rejects with
|
|
233
|
+
* "Chrome executable not found: <path>" carrying code "ENOENT"; a non-zero
|
|
234
|
+
* exit rejects with the first non-empty stderr/stdout line.
|
|
235
|
+
*/
|
|
236
|
+
function probeExecutable(target) {
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
let child;
|
|
239
|
+
try {
|
|
240
|
+
child = spawn(target, ["--version"], {
|
|
241
|
+
env: scrubbedParentEnv(),
|
|
242
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
243
|
+
timeout: CHECK_TIMEOUT_MS,
|
|
244
|
+
killSignal: "SIGKILL"
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
reject(new Error(`could not run "${target} --version": ${String(error)}`));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
let stdout = "";
|
|
251
|
+
let stderr = "";
|
|
252
|
+
child.stdout.on("data", (chunk) => (stdout += chunk));
|
|
253
|
+
child.stderr.on("data", (chunk) => (stderr += chunk));
|
|
254
|
+
child.on("error", (error) => {
|
|
255
|
+
const code = error && typeof error.code === "string" ? error.code : "";
|
|
256
|
+
if (code === "ENOENT") {
|
|
257
|
+
const missing = new Error(`Chrome executable not found: ${target}`);
|
|
258
|
+
missing.code = "ENOENT";
|
|
259
|
+
reject(missing);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
reject(new Error(`could not run "${target} --version": ${String(error)}`));
|
|
263
|
+
});
|
|
264
|
+
child.on("close", (code) => {
|
|
265
|
+
if (code === null) {
|
|
266
|
+
reject(new Error(`"${target} --version" timed out after ${String(CHECK_TIMEOUT_MS)}ms`));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (code !== 0) {
|
|
270
|
+
const detail =
|
|
271
|
+
firstLine(stderr) !== "" ? firstLine(stderr) : firstLine(stdout) !== "" ? firstLine(stdout) : `exit code ${String(code)}`;
|
|
272
|
+
reject(new Error(`"${target} --version" failed: ${detail}`));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
resolve(firstLine(stdout));
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Discover a usable Google Chrome executable when `chromePath` is empty:
|
|
282
|
+
* spawn `--version` for every candidate in order. The first runnable
|
|
283
|
+
* candidate resolves its version line. A candidate that exists but is not
|
|
284
|
+
* usable (non-ENOENT failure) reports that detail — the binary is there and
|
|
285
|
+
* broken. All candidates ENOENT reject with the known executable-not-found
|
|
286
|
+
* error listing the checked paths. An empty candidate list resolves "" —
|
|
287
|
+
* nothing probed (unknown platform).
|
|
288
|
+
* @param candidatePaths - candidate executable paths (default: platform list).
|
|
289
|
+
* @returns the discovered executable version line.
|
|
290
|
+
*/
|
|
291
|
+
export async function discoverChrome(candidatePaths) {
|
|
292
|
+
const candidates = (Array.isArray(candidatePaths) ? candidatePaths : defaultChromePaths())
|
|
293
|
+
.map((candidate) => String(candidate ?? "").trim())
|
|
294
|
+
.filter((candidate) => candidate !== "");
|
|
295
|
+
if (candidates.length === 0) return "";
|
|
296
|
+
let presentFailure = "";
|
|
297
|
+
for (const candidate of candidates) {
|
|
298
|
+
try {
|
|
299
|
+
return await probeExecutable(candidate);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const code = error && typeof error.code === "string" ? error.code : "";
|
|
302
|
+
if (code === "ENOENT") continue;
|
|
303
|
+
if (presentFailure === "") presentFailure = error instanceof Error ? error.message : String(error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (presentFailure !== "") throw new Error(presentFailure);
|
|
307
|
+
throw new Error(`Chrome executable not found: no executable detected (checked: ${candidates.join(", ")})`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Probe one check run:
|
|
312
|
+
*
|
|
313
|
+
* - `chromePath` non-empty → probe that executable (ENOENT → "Chrome
|
|
314
|
+
* executable not found: <path>"; non-zero → first stderr/stdout line;
|
|
315
|
+
* success → the version line).
|
|
316
|
+
* - `chromePath` empty → run {@link discoverChrome} over the candidate list
|
|
317
|
+
* (row-config `chromePaths`, default {@link defaultChromePaths}). Nothing
|
|
318
|
+
* usable → the known executable-missing error; a found executable resolves
|
|
319
|
+
* its version line.
|
|
320
|
+
* - the extra flags already pin an executable (`--executablePath`, …) or a
|
|
321
|
+
* connect mode (`--browserUrl` / `--wsEndpoint`) → resolve "": the bridge
|
|
322
|
+
* resolves the executable (or needs none) itself; nothing left to check.
|
|
323
|
+
*
|
|
324
|
+
* NOTE: from WSL the executable must be a Linux binary. Launching the
|
|
325
|
+
* Windows chrome.exe through the WSL interop layer fails: chrome-devtools-mcp
|
|
326
|
+
* connects with `pipe: true` (inherited fd 3/4), and Windows-side processes
|
|
327
|
+
* cannot receive those pipes from WSL; the WebSocket fallback hard-codes
|
|
328
|
+
* 127.0.0.1 in the DevToolsActivePort file, unreachable across the WSL2 NAT
|
|
329
|
+
* bridge. Verified empirically 2025-09-19; do not "fix" discovery by pointing
|
|
330
|
+
* candidates or chromePath at a /mnt/c executable.
|
|
331
|
+
* @param entry - validated {@link Config}.
|
|
332
|
+
* @param extraFlags - the flags list the bridge currently launches with; only
|
|
333
|
+
* inspected for pinning/connect-mode options.
|
|
334
|
+
* @returns the executable version line, or "" when nothing is probed.
|
|
335
|
+
*/
|
|
336
|
+
export function checkChrome(entry, extraFlags) {
|
|
337
|
+
const target = typeof entry.chromePath === "string" ? entry.chromePath.trim() : "";
|
|
338
|
+
if (target !== "") return probeExecutable(target);
|
|
339
|
+
const flags = Array.isArray(extraFlags)
|
|
340
|
+
? extraFlags
|
|
341
|
+
: Array.isArray(entry.extraFlags)
|
|
342
|
+
? entry.extraFlags
|
|
343
|
+
: [];
|
|
344
|
+
if (flags.some((flag) => typeof flag === "string" && (carriesExecutablePath(flag) || carriesConnectionMode(flag)))) {
|
|
345
|
+
return Promise.resolve("");
|
|
346
|
+
}
|
|
347
|
+
return discoverChrome(entry.chromePaths);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Recognizes the known, developer-actionable error "no Chrome executable was
|
|
352
|
+
* detected": our own ENOENT/discovery lines, the upstream executable-discovery
|
|
353
|
+
* failures ("Could not find Google Chrome executable for channel 'stable'
|
|
354
|
+
* at:", puppeteer's configured-path miss), and the umbrella launch failure
|
|
355
|
+
* chrome-devtools-mcp surfaces when it cannot start a browser binary. Upstream
|
|
356
|
+
* starts the browser lazily (on the first tool call), so the check probe is
|
|
357
|
+
* what surfaces the state without any tool call. Anything else is a generic
|
|
358
|
+
* check or connection error.
|
|
359
|
+
* @param text - an error message (or first line thereof).
|
|
360
|
+
* @returns true when the failure is the known executable-missing state.
|
|
361
|
+
*/
|
|
362
|
+
export function isChromeMissing(text) {
|
|
363
|
+
const message = String(text ?? "");
|
|
364
|
+
return (
|
|
365
|
+
/chrome executable not found/iu.test(message) ||
|
|
366
|
+
/could not find (?:google )?chrome(?: executable)?/iu.test(message) ||
|
|
367
|
+
/browser was not found at the configured executablePath/iu.test(message) ||
|
|
368
|
+
/failed to launch the browser process/iu.test(message)
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Summarize one chrome-tool call result for the card's status line. Failed
|
|
375
|
+
* calls return "<tool> failed: <first text line>" — tool-level errors
|
|
376
|
+
* (browser unreachable, "Target closed", …) are returned to the calling
|
|
377
|
+
* agent and never route through the bridge logger, so this is the only
|
|
378
|
+
* capture point. Successes return null: the caller clears the stored error.
|
|
379
|
+
* @param toolName - fully qualified registered name (`mcp__<server>__<tool>`).
|
|
380
|
+
* @param result - settled tools-service execution result.
|
|
381
|
+
* @returns the note, or null when the call did not fail.
|
|
382
|
+
*/
|
|
383
|
+
export function toolResultNote(toolName, result) {
|
|
384
|
+
if (!result || result.isError !== true) return null;
|
|
385
|
+
const blocks = Array.isArray(result.content) ? result.content : [];
|
|
386
|
+
let text = blocks
|
|
387
|
+
.filter((block) => block && block.type === "text" && typeof block.text === "string")
|
|
388
|
+
.map((block) => block.text)
|
|
389
|
+
.join(" ");
|
|
390
|
+
if (text === "" && result.error && typeof result.error.message === "string") text = result.error.message;
|
|
391
|
+
if (text === "") text = result.error === void 0 ? "unknown error" : String(result.error);
|
|
392
|
+
return firstLine(`${toolName.split("__").pop()} failed: ${text}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Environment variable that seeds the launch flags for one dsh run: a
|
|
397
|
+
* whitespace-separated extraFlags list, e.g.
|
|
398
|
+
* `DSH_CHROME_MCP_FLAGS="--headless --isolated"`.
|
|
399
|
+
*/
|
|
400
|
+
export const FLAGS_ENV_VAR = "DSH_CHROME_MCP_FLAGS";
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Parse a whitespace-separated flags list from the environment.
|
|
404
|
+
* @param configured - the row config `extraFlags` value.
|
|
405
|
+
* @param envValue - `DSH_CHROME_MCP_FLAGS` as seen in the launch env.
|
|
406
|
+
* @returns the env flags when the value is a non-empty string whose every
|
|
407
|
+
* entry is valid, else `configured` (the caller decides on warnings).
|
|
408
|
+
*/
|
|
409
|
+
export function resolveEnvFlags(configured, envValue) {
|
|
410
|
+
if (typeof envValue !== "string" || envValue.trim() === "") return configured;
|
|
411
|
+
const flags = envValue.split(/\s+/u).filter((flag) => flag !== "");
|
|
412
|
+
try {
|
|
413
|
+
validateExtraFlags(flags);
|
|
414
|
+
} catch {
|
|
415
|
+
return configured;
|
|
416
|
+
}
|
|
417
|
+
return flags;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Read the last UI-selected extraFlags list from the persisted user layer
|
|
422
|
+
* when the settings service is already up and the stored value is valid.
|
|
423
|
+
* The host consults this before the first bridge spawn so the very first
|
|
424
|
+
* connection launches with the persisted flags — and every in-process
|
|
425
|
+
* reconnect, which reuses the bridge config, comes back on them as well.
|
|
426
|
+
* @param ctx - plugin context.
|
|
427
|
+
* @returns the persisted flags array, or undefined when absent, invalid, or
|
|
428
|
+
* the settings service is not available yet (the installSection initial
|
|
429
|
+
* onChange then applies the persisted selection in place).
|
|
430
|
+
*/
|
|
431
|
+
export function readPersistedFlags(ctx) {
|
|
432
|
+
try {
|
|
433
|
+
const settings = ctx.get("settings");
|
|
434
|
+
const section = settings?.document?.[SETTINGS_NAMESPACE];
|
|
435
|
+
if (section === null || typeof section !== "object" || Array.isArray(section)) return void 0;
|
|
436
|
+
const value = section.extraFlags;
|
|
437
|
+
if (Array.isArray(value) && value.every((flag) => typeof flag === "string" && flag !== "")) {
|
|
438
|
+
return [...value];
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
// settings service not available yet: row config defaults apply.
|
|
442
|
+
}
|
|
443
|
+
return void 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Wrap a logger callable so a callback observes every level call before the
|
|
448
|
+
* original runs. Used to capture the nested bridge's own log lines (the only
|
|
449
|
+
* surface dsh-mcp-client exposes for connection failures — reconnect
|
|
450
|
+
* backoff warnings, spawn failures, give-ups) into the settings base layer.
|
|
451
|
+
* The wrapper shares the underlying LoggerService (exporters/buffer included)
|
|
452
|
+
* and only shadows the level methods.
|
|
453
|
+
*/
|
|
454
|
+
export function captureLogger(base, note) {
|
|
455
|
+
const levels = ["trace", "debug", "info", "warn", "error", "fatal"];
|
|
456
|
+
const wrapper = (...args) => base(...args);
|
|
457
|
+
if (base && typeof base === "object") Object.setPrototypeOf(wrapper, Object.getPrototypeOf(base));
|
|
458
|
+
Object.assign(wrapper, base);
|
|
459
|
+
for (const level of levels) {
|
|
460
|
+
wrapper[level] = (...args) => {
|
|
461
|
+
note(level, args);
|
|
462
|
+
return base?.[level]?.(...args);
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
return wrapper;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Log lines the bridge emits after a lost connection recovered — clear
|
|
469
|
+
* the captured connection error when one shows up. */
|
|
470
|
+
const BRIDGE_RECONNECTED = /reconnected and re-synced tools/u;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Mount the Chrome DevTools MCP server bridge and its settings-backed card.
|
|
474
|
+
* Flags precedence: settings user layer (UI edit) > FLAGS_ENV_VAR (launch
|
|
475
|
+
* env) > row config `extraFlags` > schema default.
|
|
476
|
+
* @param ctx - plugin context.
|
|
477
|
+
* @param config - validated {@link Config}.
|
|
478
|
+
*/
|
|
479
|
+
function apply(ctx, config) {
|
|
480
|
+
const ns = SETTINGS_NAMESPACE;
|
|
481
|
+
const envValue = launchEnvironmentOf(ctx).get(FLAGS_ENV_VAR)?.value;
|
|
482
|
+
const baseFlags = resolveEnvFlags(config.extraFlags, envValue);
|
|
483
|
+
if (typeof envValue === "string" && envValue.trim() !== "" && baseFlags === config.extraFlags) {
|
|
484
|
+
ctx.logger.warn(`chrome-devtools-mcp(${config.serverName}): ignoring ${FLAGS_ENV_VAR} — invalid flags list; using row config`);
|
|
485
|
+
}
|
|
486
|
+
// The persisted UI selection wins over env/row config: seed the first
|
|
487
|
+
// bridge spawn with it. When the settings service is not up yet this
|
|
488
|
+
// yields the base flags, and the installSection initial onChange below
|
|
489
|
+
// switches the bridge to the persisted selection in place right after.
|
|
490
|
+
const initialFlags = readPersistedFlags(ctx) ?? baseFlags;
|
|
491
|
+
|
|
492
|
+
// Composition base layer: part of the resolved value served to the client
|
|
493
|
+
// but never persisted. Check/connection status lives here, in memory.
|
|
494
|
+
const entry = {
|
|
495
|
+
extraFlags: initialFlags,
|
|
496
|
+
recheckNonce: 0,
|
|
497
|
+
lastError: "",
|
|
498
|
+
chromeMissing: false,
|
|
499
|
+
checkRevision: 0,
|
|
500
|
+
chromeVersion: ""
|
|
501
|
+
};
|
|
502
|
+
/** Live resolved settings value; swapped by installSection hooks. */
|
|
503
|
+
let source = () => entry;
|
|
504
|
+
/** Flags the nested bridge currently launches with. */
|
|
505
|
+
let runningFlags = initialFlags;
|
|
506
|
+
/** Last recheckNonce observed; undefined until the first settings commit. */
|
|
507
|
+
let lastNonce = void 0;
|
|
508
|
+
/** Serializes check runs so rapid rechecks never interleave. */
|
|
509
|
+
let checks = Promise.resolve();
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* One executable check; the result lands in the base layer (in memory).
|
|
513
|
+
* The client reads the base layer fresh on every describe read, and polls
|
|
514
|
+
* the shared describe mirror for the advance — via the re-check button and
|
|
515
|
+
* via a one-time startup catch-up poll, since the registration-time check
|
|
516
|
+
* lands after the card's first read.
|
|
517
|
+
*/
|
|
518
|
+
const runCheck = () => {
|
|
519
|
+
checks = checks
|
|
520
|
+
.then(async () => {
|
|
521
|
+
try {
|
|
522
|
+
const live = source();
|
|
523
|
+
const version = await checkChrome({
|
|
524
|
+
...config,
|
|
525
|
+
extraFlags: Array.isArray(live.extraFlags) ? live.extraFlags : config.extraFlags
|
|
526
|
+
});
|
|
527
|
+
entry.chromeVersion = version;
|
|
528
|
+
// Nothing-to-check ("") must not clear captured bridge/tool
|
|
529
|
+
// status (the known "executable missing" error included);
|
|
530
|
+
// only a probed/discovered success proves health.
|
|
531
|
+
if (version !== "") {
|
|
532
|
+
entry.lastError = "";
|
|
533
|
+
entry.chromeMissing = false;
|
|
534
|
+
}
|
|
535
|
+
} catch (error) {
|
|
536
|
+
entry.lastError = firstLine(error instanceof Error ? error.message : String(error));
|
|
537
|
+
entry.chromeMissing = isChromeMissing(entry.lastError);
|
|
538
|
+
ctx.logger.warn(`chrome-devtools-mcp(${config.serverName}): check failed: ${entry.lastError}`);
|
|
539
|
+
}
|
|
540
|
+
entry.checkRevision += 1;
|
|
541
|
+
})
|
|
542
|
+
.catch(() => {});
|
|
543
|
+
return checks;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Mirror bridge log lines into the base layer. dsh-mcp-client reports
|
|
548
|
+
* connection problems only through its logger (reconnect warnings with
|
|
549
|
+
* "connection failed/lost", give-up errors, spawn failures), and a
|
|
550
|
+
* module plugin has no push channel to clients — so the newest failure
|
|
551
|
+
* line becomes `lastError`, and the "reconnected and re-synced tools"
|
|
552
|
+
* success line clears it. The wrapper lives only on the bridge scope;
|
|
553
|
+
* our own logs bypass it, and our own warns never masquerade as
|
|
554
|
+
* connection errors.
|
|
555
|
+
*/
|
|
556
|
+
const noteBridgeLog = (level, args) => {
|
|
557
|
+
const message = args
|
|
558
|
+
.map((arg) => (arg instanceof Error ? (arg.stack ?? arg.message ?? String(arg)) : String(arg)))
|
|
559
|
+
.join(" ");
|
|
560
|
+
if (level === "error" || level === "warn") {
|
|
561
|
+
entry.lastError = firstLine(message);
|
|
562
|
+
// Classify the captured line: an executable-discovery or launch
|
|
563
|
+
// failure is the known "Chrome not found" state (chromeMissing).
|
|
564
|
+
// It is UI-surfaced exactly like any other error — the card's red
|
|
565
|
+
// status dot with the full message as its title (no badge).
|
|
566
|
+
entry.chromeMissing = isChromeMissing(message);
|
|
567
|
+
} else if (level === "info" && BRIDGE_RECONNECTED.test(message)) {
|
|
568
|
+
entry.lastError = "";
|
|
569
|
+
entry.chromeMissing = false;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// Tool-level status: chrome MCP tool failures (browser unreachable,
|
|
574
|
+
// "Target closed", …) are returned to the calling agent and never reach
|
|
575
|
+
// the bridge logger, so without this hook the card would show a clean
|
|
576
|
+
// status while tools fail. Failed calls store the note; any successful
|
|
577
|
+
// chrome-tool call clears it — the card tracks current health. Registered
|
|
578
|
+
// plainly on this ctx: untagged listeners pass the tools-scoped waterfall
|
|
579
|
+
// filter globally, for every agent (prevalent production pattern:
|
|
580
|
+
// dsh-hooks-codex / dsh-tool-fs-search).
|
|
581
|
+
const toolPrefix = `mcp__${config.serverName}__`;
|
|
582
|
+
ctx.on("tools/post-execute", async (exec, result, next) => {
|
|
583
|
+
try {
|
|
584
|
+
if (typeof exec.name === "string" && exec.name.startsWith(toolPrefix)) {
|
|
585
|
+
const note = toolResultNote(exec.name, result);
|
|
586
|
+
entry.lastError = note ?? "";
|
|
587
|
+
entry.chromeMissing = note === null ? false : isChromeMissing(note);
|
|
588
|
+
}
|
|
589
|
+
} catch (error) {
|
|
590
|
+
ctx.logger.warn(`chrome-devtools-mcp(${config.serverName}): tool status capture failed: ${String(error)}`);
|
|
591
|
+
}
|
|
592
|
+
return next();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Initial connection with the resolved flags. The bridge scope shadows
|
|
596
|
+
// `logger` (cordis `extend` semantics: own props shadow inherited ones,
|
|
597
|
+
// parent untouched) so only the bridge's own log lines route through
|
|
598
|
+
// noteBridgeLog; our own logs stay on the original logger.
|
|
599
|
+
const bridgeScope = ctx.extend({ logger: captureLogger(ctx.logger, noteBridgeLog) });
|
|
600
|
+
const bridge = bridgeScope.plugin(McpClient, bridgeConfig(config, initialFlags));
|
|
601
|
+
|
|
602
|
+
/** Re-apply the nested bridge with one flags list; failures logged, never thrown. */
|
|
603
|
+
const applyFlags = (flags) => {
|
|
604
|
+
runningFlags = flags;
|
|
605
|
+
ctx.logger.info(`chrome-devtools-mcp(${config.serverName}): relaunching bridge with flags [${flags.join(" ")}]`);
|
|
606
|
+
Promise.resolve(bridge.update(bridgeConfig(config, flags)))
|
|
607
|
+
.then(() => {
|
|
608
|
+
entry.lastError = "";
|
|
609
|
+
// Intentionally NOT clearing `chromeMissing`: a successful
|
|
610
|
+
// bridge update proves the flags took (clears the captured
|
|
611
|
+
// connection error) but says nothing about the executable —
|
|
612
|
+
// the known not-found flag clears only on a probed success or
|
|
613
|
+
// the reconnect line, and the client dot keeps it visible.
|
|
614
|
+
})
|
|
615
|
+
.catch((error) => ctx.logger.error(`chrome-devtools-mcp(${config.serverName}): flags update failed: ${String(error)}`));
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
619
|
+
const settings = settingsCtx.settings;
|
|
620
|
+
settings.installSection(ctx, ns, SettingsSchema, entry, {
|
|
621
|
+
setSource: (next) => {
|
|
622
|
+
source = next;
|
|
623
|
+
},
|
|
624
|
+
validate: (value) => {
|
|
625
|
+
try {
|
|
626
|
+
validateExtraFlags(value.extraFlags);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
throw new Error(`settings "${ns}": ${String(error.message ?? error)}`);
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
onChange: () => {
|
|
632
|
+
try {
|
|
633
|
+
const current = source();
|
|
634
|
+
if (!sameFlags(current.extraFlags, runningFlags)) applyFlags(current.extraFlags);
|
|
635
|
+
if (current.recheckNonce !== lastNonce) {
|
|
636
|
+
lastNonce = current.recheckNonce;
|
|
637
|
+
runCheck();
|
|
638
|
+
}
|
|
639
|
+
} catch (error) {
|
|
640
|
+
ctx.logger.error(`chrome-devtools-mcp(${config.serverName}): ${String(error)}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export { apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@comecaramelos/dsh-chrome-mcp",
|
|
3
|
+
"description": "DSH plugin: Chrome DevTools MCP bridge (`npx -y chrome-devtools-mcp@latest`) integration (wraps @deepseek-ai/dsh-mcp-client)",
|
|
4
|
+
"author": "Robe ComeCaramelos <hello@comecaramelos.com>",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "lib/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./lib/index.js",
|
|
11
|
+
"./client": "./lib/client.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"cordis.patch.yml"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node test/parse.test.mjs && node test/apply.test.mjs && node test/client.test.mjs"
|
|
20
|
+
},
|
|
21
|
+
"dsh": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
},
|
|
25
|
+
"client": {
|
|
26
|
+
"platform": "web",
|
|
27
|
+
"inject": [
|
|
28
|
+
"@deepseek-ai/dsh-client-locale",
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
35
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.2-rc.1",
|
|
36
|
+
"@deepseek-ai/dsh-mcp-client": "^0.1.2-rc.1",
|
|
37
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.2-rc.1",
|
|
38
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
39
|
+
}
|
|
40
|
+
}
|