@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/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
+ }