dsh-windows-mcp 0.1.0 → 0.2.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/dependency.js CHANGED
@@ -80,10 +80,15 @@ export function windowsMcpExecutable() {
80
80
  return null;
81
81
  }
82
82
 
83
- /** Version of the installed tool, read from its dist-info directory. */
84
- export function windowsMcpVersion(executable = windowsMcpExecutable()) {
85
- if (executable === null) return null;
86
- const directory = windowsMcpToolDirectory();
83
+ /**
84
+ * Version of a `uv tool` installation, read from the dist-info directory inside
85
+ * the tool's environment. Works for the global tool root and for the managed
86
+ * per-version directories this plugin keeps (see `installs.js`).
87
+ * @param toolDirectory - the directory that contains `Scripts/` and `Lib/`,
88
+ * i.e. `…/tools/windows-mcp` or `…/tools/<version>/windows-mcp`.
89
+ */
90
+ export function toolVersionIn(toolDirectory) {
91
+ if (typeof toolDirectory !== "string" || toolDirectory === "") return null;
87
92
  const packages = [];
88
93
  const collect = (sitePackages) => {
89
94
  try {
@@ -94,10 +99,10 @@ export function windowsMcpVersion(executable = windowsMcpExecutable()) {
94
99
  /* layout absent */
95
100
  }
96
101
  };
97
- collect(join(directory, "Lib", "site-packages"));
102
+ collect(join(toolDirectory, "Lib", "site-packages"));
98
103
  try {
99
- for (const entry of readdirSync(join(directory, "lib"), { withFileTypes: true })) {
100
- if (entry.isDirectory() && /^python\d/u.test(entry.name)) collect(join(directory, "lib", entry.name, "site-packages"));
104
+ for (const entry of readdirSync(join(toolDirectory, "lib"), { withFileTypes: true })) {
105
+ if (entry.isDirectory() && /^python\d/u.test(entry.name)) collect(join(toolDirectory, "lib", entry.name, "site-packages"));
101
106
  }
102
107
  } catch {
103
108
  /* POSIX layout absent */
@@ -109,9 +114,17 @@ export function windowsMcpVersion(executable = windowsMcpExecutable()) {
109
114
  return null;
110
115
  }
111
116
 
117
+ /** Version of the globally installed tool, read from its dist-info directory. */
118
+ export function windowsMcpVersion(executable = windowsMcpExecutable()) {
119
+ if (executable === null) return null;
120
+ return toolVersionIn(windowsMcpToolDirectory());
121
+ }
122
+
112
123
  /** The pinned upstream version our patch mounts, e.g. `0.8.8`, or null. */
113
124
  export function windowsMcpVersionPin(patchText) {
114
- const match = /windows-mcp@([0-9][^\s,'"\]]*)/u.exec(String(patchText ?? ""));
125
+ // Stop at anything that ends a version in YAML or prose: whitespace, quotes,
126
+ // commas, closing brackets, and the backtick/paren used in comments.
127
+ const match = /windows-mcp@([0-9][^\s,'"\]`);]*)/u.exec(String(patchText ?? ""));
115
128
  return match === null ? null : match[1];
116
129
  }
117
130
 
@@ -143,11 +156,67 @@ export function dependencySnapshot(resolver, pinned, env = process.env) {
143
156
  * install
144
157
  * ------------------------------------------------------------------ */
145
158
 
159
+ /**
160
+ * Environment for `uv tool install`.
161
+ *
162
+ * `UV_CONCURRENT_DOWNLOADS` is deliberately below uv's default of 8: the
163
+ * mainland mirrors rate-limit aggressive parallelism (measured: USTC answered
164
+ * HTTP 429 while unzipping a dependency when uv fetched 8 files at once), and a
165
+ * slow-but-successful install beats a fast-but-failing one.
166
+ *
167
+ * @param toolDirectory - when set, the install is redirected into that tool
168
+ * root (`UV_TOOL_DIR`) instead of the global one, which is how this plugin
169
+ * keeps several upstream versions side by side.
170
+ */
171
+ export function uvInstallEnvironment(toolDirectory = null) {
172
+ const environment = {
173
+ ...process.env,
174
+ UV_HTTP_TIMEOUT: "600",
175
+ UV_CONCURRENT_DOWNLOADS: "4",
176
+ UV_HTTP_RETRIES: "3",
177
+ };
178
+ if (typeof toolDirectory === "string" && toolDirectory !== "") environment.UV_TOOL_DIR = toolDirectory;
179
+ return environment;
180
+ }
181
+
182
+ /**
183
+ * Start one `uv tool install` and stream its output to `onLine`.
184
+ * @returns `{ child, args }` — the caller owns the child's lifecycle.
185
+ */
186
+ export function spawnUvInstall(options = {}) {
187
+ const uv = options.uv;
188
+ const spec = options.spec;
189
+ const indexUrl = typeof options.indexUrl === "string" && options.indexUrl !== "" ? options.indexUrl : null;
190
+ const toolDirectory = typeof options.toolDirectory === "string" && options.toolDirectory !== "" ? options.toolDirectory : null;
191
+ const onLine = typeof options.onLine === "function" ? options.onLine : () => {};
192
+ const args = ["tool", "install", "--force"];
193
+ if (indexUrl !== null) args.push("--default-index", indexUrl);
194
+ args.push(spec);
195
+ const child = spawn(uv, args, {
196
+ cwd: typeof options.cwd === "string" ? options.cwd : homedir(),
197
+ env: { ...uvInstallEnvironment(toolDirectory), ...(options.env ?? {}) },
198
+ windowsHide: true,
199
+ stdio: ["ignore", "pipe", "pipe"],
200
+ });
201
+ try {
202
+ child.stdout?.on("data", onLine);
203
+ child.stderr?.on("data", onLine);
204
+ } catch {
205
+ /* a child without pipes still closes */
206
+ }
207
+ return { child, args };
208
+ }
209
+
146
210
  /**
147
211
  * One `uv tool install` at a time, with a bounded log the page can follow.
148
212
  * @param options.warn - warning sink.
149
213
  * @param options.resolver - PATH lookup used to find `uv`.
150
214
  * @param options.pinned - upstream version pin.
215
+ * @param options.indexUrl - PyPI index to install from (a mirror), or null.
216
+ * @param options.toolDirectory - `UV_TOOL_DIR` for an isolated install, or null
217
+ * for the global tool root.
218
+ * @param options.resolveExecutable - where to look for the installed executable
219
+ * afterwards; defaults to the global `uv tool` installation.
151
220
  * @param options.afterInstall - called with the installed executable, returns
152
221
  * the bridge-write outcome that `snapshot()` then reports.
153
222
  */
@@ -202,22 +271,19 @@ export function createPrepare(options = {}) {
202
271
  return { started: false, reason: state.error, state: this.snapshot() };
203
272
  }
204
273
  const spec = options.pinned === null || options.pinned === undefined ? WINDOWS_MCP_TOOL : `${WINDOWS_MCP_TOOL}@${options.pinned}`;
274
+ const indexUrl = typeof options.indexUrl === "string" && options.indexUrl !== "" ? options.indexUrl : null;
275
+ const toolDirectory = typeof options.toolDirectory === "string" && options.toolDirectory !== "" ? options.toolDirectory : null;
276
+ const resolveExecutable = typeof options.resolveExecutable === "function" ? options.resolveExecutable : windowsMcpExecutable;
205
277
  state.phase = "running";
206
278
  state.startedAt = new Date().toISOString();
207
279
  state.finishedAt = null;
208
280
  state.error = null;
209
281
  state.bridge = null;
210
- state.log = [`$ ${uv} tool install --force ${spec}`];
282
+ const command = [uv, "tool", "install", "--force", ...(indexUrl === null ? [] : ["--default-index", indexUrl]), spec].join(" ");
283
+ state.log = [`$ ${command}${toolDirectory === null ? "" : ` (UV_TOOL_DIR=${toolDirectory})`}`];
211
284
  try {
212
- const running = spawn(uv, ["tool", "install", "--force", spec], {
213
- cwd: homedir(),
214
- env: { ...process.env, UV_HTTP_TIMEOUT: "600", UV_CONCURRENT_DOWNLOADS: "8" },
215
- windowsHide: true,
216
- stdio: ["ignore", "pipe", "pipe"],
217
- });
285
+ const { child: running } = spawnUvInstall({ uv, spec, indexUrl, toolDirectory, onLine: append });
218
286
  child = running;
219
- running.stdout?.on("data", append);
220
- running.stderr?.on("data", append);
221
287
  running.on("error", (error) => settle("failed", String(error)));
222
288
  running.on("close", (code) => {
223
289
  if (state.phase === "cancelled") return;
@@ -225,7 +291,7 @@ export function createPrepare(options = {}) {
225
291
  settle("failed", `uv exited with code ${code}`);
226
292
  return;
227
293
  }
228
- const executable = windowsMcpExecutable();
294
+ const executable = resolveExecutable();
229
295
  if (executable === null) {
230
296
  settle("failed", "uv reported success, but no windows-mcp executable appeared in the tool directory.");
231
297
  return;
@@ -289,10 +355,63 @@ export function yamlScalar(value) {
289
355
  return `'${String(value).replace(/'/gu, "''")}'`;
290
356
  }
291
357
 
292
- /** The complete bridge row, pointing at an installed executable. */
358
+ /**
359
+ * The bridge row values this package ships. The settings page may change them;
360
+ * `lib/settings.js` normalises user input against these same defaults.
361
+ */
362
+ export const BRIDGE_DEFAULTS = Object.freeze({
363
+ toolCallTimeoutMs: 120000,
364
+ excludeTools: Object.freeze(["App", "PowerShell", "FileSystem"]),
365
+ screenshotScale: 0.75,
366
+ screenshotBackend: "auto",
367
+ disableFlash: true,
368
+ });
369
+
370
+ /** Render `args:` — `--exclude-tools` takes one comma-separated argv. */
371
+ function bridgeArgsLine(excludeTools) {
372
+ const names = Array.from(excludeTools, (name) => String(name).trim()).filter((name) => name !== "");
373
+ if (names.length === 0) return " args: [serve]";
374
+ return ` args: [serve, --exclude-tools, ${JSON.stringify(names.join(","))}]`;
375
+ }
376
+
377
+ /** Render `env:` — only the keys that are set, in a stable order. */
378
+ function bridgeEnvLine(settings) {
379
+ const pairs = ['ANONYMIZED_TELEMETRY: "false"'];
380
+ if (settings.disableFlash !== false) pairs.push('WINDOWS_MCP_DISABLE_FLASH: "1"');
381
+ pairs.push(`WINDOWS_MCP_SCREENSHOT_SCALE: ${JSON.stringify(String(settings.screenshotScale))}`);
382
+ if (typeof settings.screenshotBackend === "string" && settings.screenshotBackend !== "auto") {
383
+ pairs.push(`WINDOWS_MCP_SCREENSHOT_BACKEND: ${JSON.stringify(settings.screenshotBackend)}`);
384
+ }
385
+ return ` env: { ${pairs.join(", ")} }`;
386
+ }
387
+
388
+ /** Fold user settings (or their absence) into one complete bridge value set. */
389
+ function bridgeValues(meta) {
390
+ const source = meta.settings !== null && typeof meta.settings === "object" ? meta.settings : null;
391
+ const merged = { ...BRIDGE_DEFAULTS };
392
+ if (source === null) return merged;
393
+ if (typeof source.toolCallTimeoutMs === "number" && Number.isFinite(source.toolCallTimeoutMs)) {
394
+ merged.toolCallTimeoutMs = Math.round(source.toolCallTimeoutMs);
395
+ }
396
+ if (Array.isArray(source.excludeTools)) merged.excludeTools = source.excludeTools.slice();
397
+ if (typeof source.screenshotScale === "number" && Number.isFinite(source.screenshotScale)) {
398
+ merged.screenshotScale = source.screenshotScale;
399
+ }
400
+ if (typeof source.screenshotBackend === "string" && source.screenshotBackend !== "") {
401
+ merged.screenshotBackend = source.screenshotBackend;
402
+ }
403
+ if (typeof source.disableFlash === "boolean") merged.disableFlash = source.disableFlash;
404
+ return merged;
405
+ }
406
+
407
+ /**
408
+ * The complete bridge row, pointing at an installed executable.
409
+ * `meta.settings` is the optional `bridge` half of the user settings.
410
+ */
293
411
  export function bridgeOverrideBlock(executable, meta = {}) {
294
412
  const stamp = typeof meta.stamp === "string" ? meta.stamp : new Date().toISOString();
295
413
  const version = typeof meta.version === "string" ? meta.version : "unknown";
414
+ const values = bridgeValues(meta);
296
415
  return [
297
416
  PROFILE_BLOCK_BEGIN,
298
417
  `# Written by ${PACKAGE_ID} ${version} at ${stamp}.`,
@@ -302,11 +421,11 @@ export function bridgeOverrideBlock(executable, meta = {}) {
302
421
  " serverName: windows",
303
422
  " transport: stdio",
304
423
  ` command: ${yamlScalar(executable)}`,
305
- ' args: [serve, --exclude-tools, "App,PowerShell,FileSystem"]',
306
- " toolCallTimeoutMs: 120000",
424
+ bridgeArgsLine(values.excludeTools),
425
+ ` toolCallTimeoutMs: ${values.toolCallTimeoutMs}`,
307
426
  " failOnStartupError: false",
308
427
  " reconnect: { enabled: true, initialDelayMs: 500, maxDelayMs: 30000, maxAttempts: 10 }",
309
- ' env: { ANONYMIZED_TELEMETRY: "false", WINDOWS_MCP_DISABLE_FLASH: "1", WINDOWS_MCP_SCREENSHOT_SCALE: "0.75" }',
428
+ bridgeEnvLine(values),
310
429
  PROFILE_BLOCK_END,
311
430
  ].join("\n");
312
431
  }
@@ -460,7 +579,15 @@ export function writeBridgeOverride(executable, options = {}) {
460
579
  backup = null;
461
580
  }
462
581
  }
463
- writeFileSync(file, withBridgeOverride(current, bridgeOverrideBlock(executable, { stamp: new Date().toISOString(), version }), eol), "utf8");
582
+ writeFileSync(
583
+ file,
584
+ withBridgeOverride(
585
+ current,
586
+ bridgeOverrideBlock(executable, { stamp: new Date().toISOString(), version, settings: options.settings }),
587
+ eol,
588
+ ),
589
+ "utf8",
590
+ );
464
591
  return { ok: true, path: file, backup, executable, restartRequired: true };
465
592
  } catch (error) {
466
593
  warn(`could not point the bridge at the installed Windows-MCP: ${String(error)}`);
package/lib/index.js CHANGED
@@ -33,12 +33,21 @@ import {
33
33
  BRIDGE_ROUTE,
34
34
  PREPARE_CANCEL_ROUTE,
35
35
  PREPARE_ROUTE,
36
- createPrepare,
37
36
  dependencySnapshot,
38
37
  windowsMcpExecutable,
39
38
  windowsMcpVersionPin,
40
39
  writeBridgeOverride,
41
40
  } from "./dependency.js";
41
+ import { UPDATE_ACTIONS, UPDATE_ROUTE, createUpdater } from "./updater.js";
42
+ import {
43
+ SETTINGS_ROUTE,
44
+ bridgeSettingsMatch,
45
+ bridgeSettingsOf,
46
+ normalizeSettings,
47
+ readBridgeSettings,
48
+ readSettings,
49
+ writeSettings,
50
+ } from "./settings.js";
42
51
 
43
52
  const PACKAGE_ID = "dsh-windows-mcp";
44
53
  const BRIDGE_ROW_ID = "windows-mcp-bridge";
@@ -62,13 +71,17 @@ const stateDirectory = (() => {
62
71
  const statePath = join(stateDirectory, "dsh-windows-mcp.state.json");
63
72
  const STATE_ROUTE = "/dsh-windows-mcp/state";
64
73
  const MAX_STATE_BODY_BYTES = 1024;
74
+ const MAX_SETTINGS_BODY_BYTES = 8192;
65
75
 
66
76
  /** Mirrors the persisted flag; read once at boot, updated by the state route.
67
77
  * Desktop control is opt-in: a fresh install starts OFF. */
68
78
  const computerUse = { enabled: false };
69
79
 
70
- /** The `uv tool install` driver, created in apply() once the logger exists. */
71
- let dependencyManager = null;
80
+ /** User settings; read once at boot, replaced by the settings route. */
81
+ let settings = null;
82
+
83
+ /** The Windows-MCP version manager, created in apply() once the logger exists. */
84
+ let updater = null;
72
85
 
73
86
  /* ------------------------------------------------------------------ *
74
87
  * small, throw-proof helpers
@@ -311,6 +324,25 @@ function buildReport(tools) {
311
324
  }`,
312
325
  );
313
326
 
327
+ const update = updater === null ? null : updater.view();
328
+ if (update !== null) {
329
+ const managed = update.versions;
330
+ lines.push(
331
+ `managed install : ${
332
+ managed.active === null
333
+ ? "none yet — the global `uv tool` installation is used (installing or updating creates one)"
334
+ : `v${managed.active} under ${managed.toolchainRoot}`
335
+ }`,
336
+ );
337
+ if (managed.previous !== null) lines.push(` rollback to : v${managed.previous}`);
338
+ if (managed.available.length > 0) lines.push(` installed : ${managed.available.map((version) => `v${version}`).join(", ")}`);
339
+ if (update.check.latest === null) {
340
+ lines.push(" latest : not checked yet — press Check for updates in Settings → Plugins");
341
+ } else {
342
+ lines.push(` latest : v${update.check.latest}${managed.updateAvailable ? " — an update is available" : " — up to date"}`);
343
+ }
344
+ }
345
+
314
346
  if (uv === null && uvx === null) {
315
347
  advice.push(
316
348
  "Install uv (https://docs.astral.sh/uv/) and make sure `uvx` is on the PATH of the process that runs DSH — without it the bridge cannot start Windows-MCP.",
@@ -490,13 +522,52 @@ function registerStateRoute(ctx, warn) {
490
522
  }
491
523
  };
492
524
 
493
- const snapshot = () => ({
494
- enabled: computerUse.enabled,
495
- bridged: bridgedToolCount(scoped.get?.("tools")),
496
- serverName: "windows",
497
- dependency: dependencySnapshot(which, windowsMcpVersionPin(readText(patchPath))),
498
- prepare: dependencyManager === null ? null : dependencyManager.snapshot(),
499
- });
525
+ /** Map the version manager's install progress onto the prepare shape the page polls. */
526
+ const prepareSnapshot = () => {
527
+ if (updater === null) return null;
528
+ const install = updater.view().install;
529
+ return {
530
+ phase: install.phase === "installing" ? "running" : install.phase,
531
+ startedAt: install.startedAt,
532
+ finishedAt: install.finishedAt,
533
+ error: install.error,
534
+ log: install.log,
535
+ bridge: install.bridge,
536
+ version: install.version,
537
+ mirror: install.mirror,
538
+ };
539
+ };
540
+
541
+ /**
542
+ * Settings plus what the profile patch actually says: the two only
543
+ * match after a restart, because the MCP client reads its row at boot.
544
+ */
545
+ const settingsView = () => {
546
+ const current = settings === null ? normalizeSettings(null).settings : settings;
547
+ const patchText = readText(patchPath);
548
+ const applied = bridgeSettingsMatch(current, patchText);
549
+ return {
550
+ settings: current,
551
+ bridge: readBridgeSettings(patchText),
552
+ applied,
553
+ restartRequired: !applied,
554
+ pluginVersion: selfVersion(),
555
+ };
556
+ };
557
+
558
+ const snapshot = () => {
559
+ const legacy = dependencySnapshot(which, windowsMcpVersionPin(readText(patchPath)));
560
+ const update = updater === null ? null : updater.view();
561
+ return {
562
+ enabled: computerUse.enabled,
563
+ bridged: bridgedToolCount(scoped.get?.("tools")),
564
+ serverName: "windows",
565
+ dependency: updater === null ? legacy : updater.dependencyView(legacy),
566
+ prepare: prepareSnapshot(),
567
+ update,
568
+ settings: settingsView(),
569
+ };
570
+ };
500
571
 
501
572
  const handler = (request, response) => {
502
573
  if (request.method === "GET") {
@@ -536,7 +607,7 @@ function registerStateRoute(ctx, warn) {
536
607
  request.on("error", () => respond(response, 400, { error: "request failed" }));
537
608
  };
538
609
 
539
- /** Start the dependency install the Plugins page shows a button for. */
610
+ /** Start installing the pinned (or newest known) Windows-MCP into its own tool root. */
540
611
  const prepareHandler = (request, response) => {
541
612
  if (request.method === "GET") {
542
613
  respond(response, 200, snapshot());
@@ -546,17 +617,22 @@ function registerStateRoute(ctx, warn) {
546
617
  respond(response, 405, { error: "method not allowed" });
547
618
  return;
548
619
  }
549
- if (dependencyManager === null) {
550
- respond(response, 503, { error: "the dependency manager is not ready" });
620
+ if (updater === null) {
621
+ respond(response, 503, { error: "the version manager is not ready" });
551
622
  return;
552
623
  }
553
- const started = dependencyManager.start();
554
- respond(response, started.started ? 202 : 409, {
555
- started: started.started,
556
- reason: started.reason ?? null,
557
- spec: started.spec ?? null,
558
- prepare: dependencyManager.snapshot(),
559
- });
624
+ const view = updater.view();
625
+ const target = view.versions.pinned ?? view.check.latest;
626
+ if (target === null) {
627
+ respond(response, 409, { started: false, reason: "no Windows-MCP version is known yet", prepare: prepareSnapshot() });
628
+ return;
629
+ }
630
+ if (view.install.phase === "installing") {
631
+ respond(response, 409, { started: false, reason: "an install is already running", prepare: prepareSnapshot() });
632
+ return;
633
+ }
634
+ void updater.installVersion(target);
635
+ respond(response, 202, { started: true, spec: `windows-mcp@${target}`, prepare: prepareSnapshot() });
560
636
  };
561
637
 
562
638
  /** Stop a running install. */
@@ -565,31 +641,177 @@ function registerStateRoute(ctx, warn) {
565
641
  respond(response, 405, { error: "method not allowed" });
566
642
  return;
567
643
  }
568
- const cancelled = dependencyManager !== null && dependencyManager.cancel();
569
- respond(response, 200, { cancelled, prepare: dependencyManager === null ? null : dependencyManager.snapshot() });
644
+ const result = updater === null ? { cancelled: false } : updater.cancel();
645
+ respond(response, 200, { cancelled: result.cancelled === true, prepare: prepareSnapshot() });
570
646
  };
571
647
 
572
- /** Point the bridge at the installed executable (also done after install). */
648
+ /** Point the bridge at the active executable (managed install first). */
573
649
  const bridgeHandler = (request, response) => {
574
650
  if (request.method !== "POST") {
575
651
  respond(response, 405, { error: "method not allowed" });
576
652
  return;
577
653
  }
578
- const executable = windowsMcpExecutable();
654
+ const view = updater === null ? null : updater.view();
655
+ const executable = view?.versions?.activeExecutable ?? windowsMcpExecutable();
579
656
  if (executable === null) {
580
657
  respond(response, 409, {
581
658
  ok: false,
582
- error: "Windows-MCP is not installed through `uv tool` yet, so there is nothing to point the bridge at.",
659
+ error: "Windows-MCP is not installed yet, so there is nothing to point the bridge at.",
583
660
  });
584
661
  return;
585
662
  }
586
663
  respond(response, 200, writeBridgeOverride(executable, { warn, version: selfVersion() }));
587
664
  };
588
665
 
666
+ /** Check for, install, activate and roll back Windows-MCP versions. */
667
+ const updateHandler = (request, response) => {
668
+ if (request.method === "GET") {
669
+ respond(response, 200, updater === null ? { error: "the version manager is not ready" } : updater.view());
670
+ return;
671
+ }
672
+ if (request.method !== "POST") {
673
+ respond(response, 405, { error: "method not allowed" });
674
+ return;
675
+ }
676
+ if (updater === null) {
677
+ respond(response, 503, { error: "the version manager is not ready" });
678
+ return;
679
+ }
680
+ const chunks = [];
681
+ let size = 0;
682
+ request.on("data", (chunk) => {
683
+ size += chunk.length;
684
+ if (size > MAX_STATE_BODY_BYTES) {
685
+ request.destroy();
686
+ return;
687
+ }
688
+ chunks.push(chunk);
689
+ });
690
+ request.on("end", () => {
691
+ let body;
692
+ try {
693
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
694
+ } catch {
695
+ respond(response, 400, { error: "invalid JSON body" });
696
+ return;
697
+ }
698
+ const action = typeof body?.action === "string" ? body.action : "";
699
+ const version = typeof body?.version === "string" && body.version !== "" ? body.version : null;
700
+ const allowed = [...UPDATE_ACTIONS, "cancel"];
701
+ if (!allowed.includes(action)) {
702
+ respond(response, 400, { error: `expected { action } in ${allowed.join(", ")}` });
703
+ return;
704
+ }
705
+ if (action === "check") {
706
+ void updater.check({ force: true });
707
+ respond(response, 202, { started: true, action });
708
+ return;
709
+ }
710
+ if (action === "install") {
711
+ const view = updater.view();
712
+ const target = version ?? view.check.latest ?? view.versions.pinned;
713
+ if (target === null) {
714
+ respond(response, 409, { ok: false, error: "no Windows-MCP version is known yet — check for updates first" });
715
+ return;
716
+ }
717
+ void updater.installVersion(target);
718
+ respond(response, 202, { started: true, action, version: target });
719
+ return;
720
+ }
721
+ if (action === "cancel") {
722
+ respond(response, 200, updater.cancel());
723
+ return;
724
+ }
725
+ if (action === "activate") {
726
+ if (version === null) {
727
+ respond(response, 400, { error: "activate needs { version }" });
728
+ return;
729
+ }
730
+ respond(response, 200, updater.activateInstalled(version));
731
+ return;
732
+ }
733
+ respond(response, 200, updater.rollback());
734
+ });
735
+ request.on("error", () => respond(response, 400, { error: "request failed" }));
736
+ };
737
+
738
+ /**
739
+ * Read and update the user settings. Plugin settings apply at once;
740
+ * bridge settings are written into the profile patch row and only take
741
+ * effect after DSH restarts.
742
+ */
743
+ const settingsHandler = (request, response) => {
744
+ if (request.method === "GET") {
745
+ respond(response, 200, settingsView());
746
+ return;
747
+ }
748
+ if (request.method !== "POST") {
749
+ respond(response, 405, { error: "method not allowed" });
750
+ return;
751
+ }
752
+ const chunks = [];
753
+ let size = 0;
754
+ request.on("data", (chunk) => {
755
+ size += chunk.length;
756
+ if (size > MAX_SETTINGS_BODY_BYTES) {
757
+ request.destroy();
758
+ return;
759
+ }
760
+ chunks.push(chunk);
761
+ });
762
+ request.on("end", () => {
763
+ let body;
764
+ try {
765
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
766
+ } catch {
767
+ respond(response, 400, { error: "invalid JSON body" });
768
+ return;
769
+ }
770
+ const incoming = body !== null && typeof body === "object" && body.settings !== null && typeof body.settings === "object" ? body.settings : body;
771
+ const normalized = normalizeSettings(incoming);
772
+ const stored = writeSettings(normalized.settings, stateDirectory);
773
+ settings = stored.settings;
774
+ const wanted = bridgeSettingsOf(settings);
775
+ if (bridgeSettingsMatch(settings, readText(patchPath))) {
776
+ respond(response, 200, {
777
+ ok: true,
778
+ saved: stored.written,
779
+ problems: normalized.problems,
780
+ write: null,
781
+ ...settingsView(),
782
+ });
783
+ return;
784
+ }
785
+ const view = updater === null ? null : updater.view();
786
+ const executable = view?.versions?.activeExecutable ?? windowsMcpExecutable();
787
+ if (executable === null) {
788
+ respond(response, 200, {
789
+ ok: true,
790
+ saved: stored.written,
791
+ problems: normalized.problems,
792
+ write: { ok: false, error: "Windows-MCP is not installed yet, so the bridge row was left untouched" },
793
+ ...settingsView(),
794
+ });
795
+ return;
796
+ }
797
+ const written = writeBridgeOverride(executable, { warn, version: selfVersion(), settings: wanted });
798
+ respond(response, 200, {
799
+ ok: written.ok === true,
800
+ saved: stored.written,
801
+ problems: normalized.problems,
802
+ write: written,
803
+ ...settingsView(),
804
+ });
805
+ });
806
+ request.on("error", () => respond(response, 400, { error: "request failed" }));
807
+ };
808
+
589
809
  scoped.webServer.register({ kind: "exact", path: STATE_ROUTE, handler });
590
810
  scoped.webServer.register({ kind: "exact", path: PREPARE_ROUTE, handler: prepareHandler });
591
811
  scoped.webServer.register({ kind: "exact", path: PREPARE_CANCEL_ROUTE, handler: cancelHandler });
592
812
  scoped.webServer.register({ kind: "exact", path: BRIDGE_ROUTE, handler: bridgeHandler });
813
+ scoped.webServer.register({ kind: "exact", path: UPDATE_ROUTE, handler: updateHandler });
814
+ scoped.webServer.register({ kind: "exact", path: SETTINGS_ROUTE, handler: settingsHandler });
593
815
  } catch (error) {
594
816
  warn(`could not register the Computer Use state route: ${String(error)}`);
595
817
  }
@@ -607,16 +829,34 @@ export function apply(ctx) {
607
829
  try {
608
830
  const warn = makeLogger(ctx);
609
831
  computerUse.enabled = readComputerUse().enabled;
610
- dependencyManager = createPrepare({
832
+ settings = readSettings(stateDirectory);
833
+ updater = createUpdater({
611
834
  warn,
612
835
  resolver: which,
613
836
  pinned: windowsMcpVersionPin(readText(patchPath)),
614
- afterInstall: (executable) => writeBridgeOverride(executable, { warn, version: selfVersion() }),
837
+ preferredMirror: () => (settings === null ? "auto" : settings.plugin.preferredMirror),
838
+ writeBridge: (executable) =>
839
+ writeBridgeOverride(executable, {
840
+ warn,
841
+ version: selfVersion(),
842
+ settings: settings === null ? null : bridgeSettingsOf(settings),
843
+ }),
615
844
  });
616
845
  auditStartup(warn);
617
846
  registerDoctor(ctx, warn);
618
847
  registerStateRoute(ctx, warn);
619
848
  registerToolGate(ctx, warn);
849
+ // Check for a newer Windows-MCP build in the background (cheap: a ranged
850
+ // read per mirror, cached for hours). The page shows the result; the boot
851
+ // never waits for it, and a failure only costs the last known answer. The
852
+ // settings page can turn this off.
853
+ if (settings === null || settings.plugin.autoCheckUpdates === true) {
854
+ try {
855
+ void updater.check({ force: false }).catch(() => {});
856
+ } catch {
857
+ /* a failed check is not a boot problem */
858
+ }
859
+ }
620
860
  } catch {
621
861
  /* apply() must not throw, whatever happens */
622
862
  }