mcp-pacemaker 1.3.1 → 2.0.2

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/README.md CHANGED
@@ -26,58 +26,42 @@ is separate planned work.
26
26
 
27
27
  ## Quick start
28
28
 
29
- This README describes **1.3.x legacy behavior**. New installations should use the current
30
- [2.0.x documentation](https://github.com/girishkvs/mcp-pacemaker/blob/v2.0.1/README.md).
31
- See the [support policy](SECURITY.md#supported-versions) for the legacy maintenance window.
32
-
33
- Use a maintained **Node.js 22 or 24** release; see [installation requirements](#install)
34
- before setup. `2.0.1` is available from the official npm package linked above.
35
-
36
- The compatibility checks cover **1.3.0 / 2.0.0** and the **1.3.1 / 2.0.1** patch pair.
37
- This describes tested version pairs, not npm publication status.
38
- A 2.x client handles legacy immediate saves. A 1.x client cannot write pooling settings to a
39
- 2.x bridge: update the CLI and refresh old dashboard tabs. Before downgrading, settle pending
40
- or interrupted transactions with 2.x. See the [real-version gates](CONTRIBUTING.md#real-version-compatibility-gates)
41
- for exact pairs; other minor versions are not covered.
29
+ Install **mcp-pacemaker 2.0.2** from npm. Use a maintained **Node.js 22 or 24**
30
+ release; Windows also needs PowerShell 7 and Windows Script Host.
31
+ See [installation requirements](#install) before setup.
42
32
 
43
33
  ```bash
44
- # Current line for new installations:
45
- npm install -g mcp-pacemaker@2.0.1
34
+ npm install -g mcp-pacemaker@2.0.2
46
35
  mcp-pacemaker --version
47
36
  ```
48
37
 
49
- The version check prints `2.0.1` and does not run setup. Before setup, have a readable
50
- JSON host configuration containing server definitions, or prepare
51
- [`~/.mcp-pacemaker/servers.json`](#serversjson). Import sources are `vscode`, `cursor`,
52
- `claude` (Claude Desktop), `copilot-cli` and `gemini`. Codex and Claude Code are wiring
53
- destinations, not supported import sources.
38
+ The version check prints `2.0.2` without running setup.
39
+ Before setup, have a readable JSON host configuration containing server definitions, or
40
+ prepare [`~/.mcp-pacemaker/servers.json`](#serversjson). Supported import sources are
41
+ `vscode`, `cursor`, `claude` (Claude Desktop), `copilot-cli` and `gemini`.
42
+ Codex and Claude Code are wiring destinations, not import sources.
54
43
 
55
- Then start interactive setup:
44
+ Then run interactive setup:
56
45
 
57
46
  ```bash
58
47
  mcp-pacemaker init
59
48
  ```
60
49
 
61
- `init` does the setup:
62
-
63
- 1. **detects** every MCP host you have installed — editors and CLIs,
64
- 2. **imports one source host** into `~/.mcp-pacemaker/servers.json`, or reuses that file if it already exists,
65
- 3. asks for confirmation, then **wires** the selected hosts to the bridge,
66
- 4. **registers** OS auto-start so the bridge returns after a reboot,
67
- 5. **starts** the bridge and prints what it wired.
50
+ `init` detects supported hosts, imports **one source host** (or reuses `servers.json`),
51
+ asks for confirmation before wiring the selected hosts, registers OS auto-start and
52
+ starts the bridge. Reload or restart the selected hosts afterward.
68
53
 
69
54
  **Setup changes local configuration and auto-start.** Importing can create or update
70
- `~/.mcp-pacemaker/servers.json` before the wiring confirmation. To inspect host changes,
71
- use the manual import/plan steps below before running `install`. Existing JSON/TOML host
72
- files get a `.bak` before replacement; Claude Code is configured through its native CLI.
73
- Keep your own protected configuration backup, especially before repeating setup.
55
+ `servers.json` before the wiring confirmation. Existing JSON/TOML host files get a `.bak`
56
+ before replacement; native Claude Code registrations do not. Keep your own protected
57
+ configuration backup, especially before repeating setup.
74
58
 
75
- `--from` selects the import source; `--client` selects wiring destinations. For example,
76
- `mcp-pacemaker init --from vscode --client codex` imports VS Code's server definitions and
77
- wires Codex. Without `--from`, setup prefers the first detected JSON host when importing;
78
- it does not merge every detected host. Omit `--yes` when you want the confirmation prompt.
59
+ `--from` chooses the import source; `--client` chooses wiring destinations. For example,
60
+ `mcp-pacemaker init --from vscode --client codex` imports VS Code's definitions and wires
61
+ Codex. Without `--from`, setup prefers the first detected JSON host; it does not merge
62
+ every detected host. Omit `--yes` to keep the confirmation prompt.
79
63
 
80
- After setup, bare `mcp-pacemaker` reports status:
64
+ Check the running bridge:
81
65
 
82
66
  ```bash
83
67
  mcp-pacemaker status # status without entering first-run setup
@@ -169,16 +153,15 @@ the setup CLI and dashboard have their own dependencies.
169
153
  Install the released package from [npm](https://www.npmjs.com/package/mcp-pacemaker).
170
154
  Its repository link should point to
171
155
  [`girishkvs/mcp-pacemaker`](https://github.com/girishkvs/mcp-pacemaker).
172
- For new installations, use the current line rather than this branch's legacy save behavior:
156
+ Use an exact version for a repeatable installation:
173
157
 
174
158
  ```bash
175
- npm install -g mcp-pacemaker@2.0.1
159
+ npm install -g mcp-pacemaker@2.0.2
176
160
  mcp-pacemaker --version
177
161
  ```
178
162
 
179
163
  The current line uses the `latest` npm channel; the maintenance line uses `legacy` when that
180
- channel has been published. Before deliberately choosing legacy behavior, check that channel
181
- and choose its exact published version:
164
+ channel has been published. Check the actual registry values before choosing a channel:
182
165
 
183
166
  ```bash
184
167
  npm view mcp-pacemaker dist-tags --json
@@ -197,14 +180,28 @@ wizard; use `--version` for a read-only installation check.
197
180
  Windows setup also needs PowerShell 7 (`pwsh`) and Windows Script Host for its launcher.
198
181
  Automatic configuration edits need .NET Framework 4.6.2 or newer. The bundled helper is
199
182
  unsigned; npm provenance is not Authenticode signing or an application-control allowlist.
200
- Windows ARM64 is not validated. Legacy automatic edits still fail closed when the caller
201
- cannot read the audit policy; this patch does not adopt 2.x Windows save behavior.
183
+ Windows ARM64 is not validated.
184
+
185
+ ### Version compatibility
186
+
187
+ This README describes **2.0.x**. For 1.3.x immediate-save behavior, see the
188
+ [legacy documentation](https://github.com/girishkvs/mcp-pacemaker/blob/v1.3.1/README.md)
189
+ and [support policy](SECURITY.md#supported-versions). Those documents do not establish
190
+ that a legacy version is available on npm.
191
+
192
+ The historical baselines remain **1.3.0 / 2.0.0**. The earlier **1.3.1 / 2.0.1**
193
+ patch-pair evidence is not transferable to a new release. The selected **1.3.1 / 2.0.2**
194
+ pair requires fresh exact-byte compatibility and service-replacement gates.
195
+ A 2.x client handles legacy immediate saves. A 1.x client cannot write pooling settings to a
196
+ 2.x bridge: update the CLI and refresh old dashboard tabs. Before downgrading, settle pending
197
+ or interrupted transactions with 2.x. See the [real-version gates](CONTRIBUTING.md#real-version-compatibility-gates)
198
+ for exact pairs; only the exact executed pairs are covered.
202
199
 
203
200
  ### Command reference
204
201
 
205
202
  | Command | What it does |
206
203
  |---|---|
207
- | `init [--client a,b] [--yes]` | **The one you want.** Detect hosts, import, wire, auto-start, launch |
204
+ | `init [--from source] [--client a,b] [--yes]` | Interactive setup: import one source, wire selected hosts, auto-start, launch |
208
205
  | `status` | Bridges, wired hosts, servers, and any server currently failing |
209
206
  | `reload` | Re-read `servers.json` into the running bridge without restarting it |
210
207
  | `doctor` | Diagnose config, bridge reachability, host wiring |
@@ -295,7 +292,18 @@ HTTP proxies are excluded because the bridge does not launch their processes.
295
292
  Choose **Enable pre-warming (N)** on a slow-server card or in the table. The button shows
296
293
  the exact warm count and the extra resident-process cost before you click. Nothing enables
297
294
  it automatically. **Disable pre-warming** removes idle warm children without killing active
298
- sessions; **Undo** restores the prior config only if no intervening edit has occurred.
295
+ sessions after the change is applied.
296
+
297
+ Changes are batched in a **complete pending copy** of `servers.json`. Every accepted change
298
+ resets a five-second countdown. When editing stops, reload validates the pending copy, retains
299
+ the original as `servers.previous.json`, flips the pending file into place, and activates the
300
+ new settings. The dashboard shows **Pending** or **Applying** until that succeeds; active
301
+ mode and warm counts are not changed early. **Reload now** flushes the same batch immediately.
302
+
303
+ **Cancel batch** discards a pending batch without changing active settings. **Undo batch**
304
+ stages the full previous configuration for another five-second batch, only if no intervening
305
+ active-file edit has occurred. These actions affect the entire batch; the dashboard names all
306
+ affected servers. An unrelated pending batch must be cancelled or applied before Undo.
299
307
 
300
308
  The same controls are available without a browser:
301
309
 
@@ -303,20 +311,27 @@ The same controls are available without a browser:
303
311
  mcp-pacemaker prewarm --port 8791
304
312
  mcp-pacemaker prewarm --port 8791 --enable filesystem --count 2
305
313
  mcp-pacemaker prewarm --port 8791 --disable filesystem
306
- # An applied change prints its own --server / --undo command.
314
+ # Changes are queued; the printed command cancels a pending batch or undoes it after application.
307
315
  ```
308
316
 
309
317
  Use `--config <path>` when the bridge's config and admin nonce are outside the default
310
318
  directory. `--json` returns the measured snapshot. The CLI and UI use the same server
311
319
  eligibility and recommendation data; a missing latency sample is not reported as a zero-cost
312
- startup. Config writes preserve unrelated JSON and permissions, create `.bak`, and require
313
- a matching content revision. Conflicts are shown rather than retried over someone else's edit.
314
- Config remains strict JSON, not JSONC.
315
-
316
- On Windows, automatic edits require readable audit policy as well as ordinary file access.
317
- If security cannot be verified or preserved, the action is refused before config data is written;
318
- the bridge does not elevate itself. Existing sessions keep running. Configuration writes run in
319
- a serialized worker so permission inspection does not block MCP traffic.
320
+ startup. The CLI returns after staging, so consecutive commands can join the same batch.
321
+ `prewarm` shows pending settings separately from active values. Config remains strict JSON,
322
+ not JSONC; the pending copy preserves unrelated JSON and requires a matching active revision.
323
+ Conflicts are reported rather than overwriting someone else's edit.
324
+
325
+ On Windows, saves run with the normal bridge account and use **directory-inherited auditing**.
326
+ The dashboard gives a non-blocking notice: custom per-file audit rules may not carry forward
327
+ to the new active copy. The retained original keeps its original metadata. No audit-read
328
+ privilege, UAC prompt, or permanently elevated bridge is required.
329
+
330
+ This does not permit broader access to configuration data. Candidates must have appropriate
331
+ access restrictions before any configuration bytes are written, and supported integrity
332
+ protections must be retained. Actual source write denial or unsupported protection remains
333
+ an error. Configuration operations run in a serialized worker so file inspection does not
334
+ block MCP traffic.
320
335
 
321
336
  Windows security inspection uses the bundled, own-source .NET Framework helper rather than
322
337
  starting PowerShell or compiling code on each write. Automatic edits require .NET Framework
@@ -324,11 +339,36 @@ starting PowerShell or compiling code on each write. Automatic edits require .NE
324
339
  integrity metadata are [included in the package](tools/windows-security-helper/README.md).
325
340
  Windows ARM64 execution is not yet validated.
326
341
 
327
- Pooling writes have a nine-second budget, including queueing and permission inspection.
328
- Expired or cancelled work cannot start a file replacement. A replacement already in progress
329
- can finish after the deadline: the action reports an unknown or late-committed outcome, and
330
- the bridge reloads the saved configuration. It never automatically retries or claims that a
331
- timed-out write was rolled back. Reread the settings before taking another action.
342
+ Staging requests and file commits each have a bounded execution budget; the acknowledged
343
+ five-second batching delay does not keep the original HTTP request open. A commit already in
344
+ progress can finish after its deadline. The result distinguishes a pre-commit failure from
345
+ a committed or unknown outcome; it never automatically retries or claims a timed-out write
346
+ was rolled back.
347
+
348
+ The two file moves are not an atomic exchange. Reload coordinates them with validated recovery
349
+ state and non-replacing destination placement. Existing MCP traffic continues using the
350
+ previous in-memory configuration during the flip; an external reader can briefly find the
351
+ active path absent. Recovery must not overwrite a file created by an external editor.
352
+ Reread the settings after an uncertain result.
353
+
354
+ ### Upgrading from 1.3.0
355
+
356
+ Version 2.0.0 changes the configuration-save protocol; it does not require a new `servers.json`
357
+ format. Update the bridge and CLI together, then reload any open dashboard pages. A 1.3.0
358
+ client with a current nonce gets HTTP 409 before staging, with an instruction to update the
359
+ client. An already-open tab retains the old nonce after a bridge restart and gets HTTP 401;
360
+ refresh it to load the new dashboard and admin session.
361
+
362
+ Custom API clients must send `x-mcp-pooling-batch: 1`. Treat HTTP 202 as acceptance, not
363
+ completion, and follow the returned batch ID in `prewarm.batches` until it is applied,
364
+ cancelled or failed. CLI scripts must likewise wait for application before relying on new
365
+ settings. Undo now restores the complete batch and may affect several servers.
366
+ Updated clients still understand the immediate HTTP 200 response from a 1.3.0 bridge.
367
+
368
+ Before downgrading, cancel or apply pending changes with 2.0 and resolve any interrupted
369
+ transaction using 2.0 recovery. Stop that bridge before starting 1.3.0 with the active config.
370
+ Do not use a 1.3.0 binary to recover 2.0 transaction files or delete those files to bypass a
371
+ recovery error. Keep a protected backup of the configuration before changing versions.
332
372
  If the writer worker exits, further automatic edits are refused until the bridge restarts;
333
373
  existing MCP sessions continue.
334
374
 
@@ -434,7 +474,7 @@ than the request it is meant to pre-empt.
434
474
  ## HTTP API
435
475
 
436
476
  Everything the dashboards show is plain HTTP on the same loopback port, so you can script it.
437
- Read endpoints need no auth; the one state-changing endpoint requires a nonce.
477
+ Read endpoints need no auth; administrative writes require a nonce.
438
478
 
439
479
  | Endpoint | Method | Returns |
440
480
  |---|---|---|
@@ -444,12 +484,21 @@ Read endpoints need no auth; the one state-changing endpoint requires a nonce.
444
484
  | `/api/events` | GET (SSE) | The same snapshot pushed every 2s — what the dashboard consumes |
445
485
  | `/api/logs` | GET (SSE) | Bridge log: replays the last 100 lines, then streams. For anything older, read `bridge.log` next to your config |
446
486
  | `/admin/recycle/<name>` | POST | Restart a server's processes. Omit `<name>` to recycle everything |
447
- | `/admin/reload` | POST | Re-read `servers.json`. Only servers whose definition changed are restarted |
448
- | `/admin/servers/<name>/pooling` | POST | Explicit enable/disable/undo with admin nonce, bounded input and a matching config revision |
487
+ | `/admin/reload` | POST | Flush a pending configuration batch, or re-read an externally edited `servers.json`. Only changed definitions are reconfigured |
488
+ | `/admin/servers/<name>/pooling` | POST | Stage enable/disable or batch cancel/undo with admin nonce, bounded input and a matching config revision |
449
489
  | `/<name>/mcp` | POST/GET/DELETE | Streamable HTTP transport for that server |
450
490
  | `/<name>/sse` | GET | HTTP+SSE transport for that server |
451
491
  | `/.well-known/oauth-protected-resource/<name>` | GET | OAuth discovery relayed from the upstream, for HTTP servers where the client authenticates |
452
492
 
493
+ Pooling mutations require `x-mcp-pooling-batch: 1`. A staged change returns **HTTP 202** with
494
+ `pending: true`, a batch ID, a private cancel/undo receipt, and the public snapshot. Acceptance
495
+ is not activation. `prewarm.batches` in the snapshot reports bounded pending, applying and
496
+ completed outcomes; it does not expose receipt tokens or full configurations.
497
+ Clients without the batch header are rejected before a change is queued, rather than letting
498
+ an older client report a queued save as already enabled.
499
+ Rich snapshots carry an instance-scoped `snapshotVersion` so a delayed HTTP response or older
500
+ SSE frame cannot replace newer active settings or prematurely expire a batch receipt.
501
+
453
502
  ```bash
454
503
  # is a server actually running, and who is using it?
455
504
  curl -s http://127.0.0.1:8850/api/status | jq '.servers[] | {name, sessions, pids, clients}'
@@ -609,6 +658,12 @@ It is not scoped to one port; review [replacement and rollback](#replacing-or-ro
609
658
  first if another instance must remain running. Do not fetch a different CLI with `npx`
610
659
  to remove an existing installation.
611
660
 
661
+ After the installed service has been removed successfully, remove the global npm package:
662
+
663
+ ```bash
664
+ npm uninstall -g mcp-pacemaker
665
+ ```
666
+
612
667
  ## Project
613
668
 
614
669
  - [CHANGELOG.md](CHANGELOG.md) — release notes
package/SECURITY.md CHANGED
@@ -44,30 +44,40 @@ so a few properties matter more than usual:
44
44
  buttons and the equivalent CLI actions require the admin nonce. Browser writes also require
45
45
  a matching Origin when provided. Only `sharing` and `minWarm` can be changed; commands,
46
46
  environment and auth settings are not accepted. Requests are bounded to 4 KiB.
47
- Edits require a content revision, preserve unrelated JSON, keep a backup and replace the file
48
- atomically. Undo restores the previous bytes only while that revision is still current.
49
- These checks detect stale clients and observed editor changes; a non-cooperating external
50
- writer can still race the final filesystem check/rename because portable filesystem CAS
51
- is unavailable. Do not edit the file concurrently with an admin action.
52
- - **Timeout is not proof of rollback.** Pooling operations have a nine-second budget and
53
- shared cancellation before file-replacement ownership. Once replacement has started, it
54
- can finish after cancellation or timeout. Uncertain and late-committed outcomes are reported
55
- explicitly; saved state is reloaded, including after worker failure with config watching off.
56
- Reconciliation failures are logged, not treated as success. Operations are never replayed,
57
- and a failed worker refuses subsequent writes until the bridge restarts.
58
- - **Config backups and Undo contain config data.** `servers.json.bak` has the previous config
59
- and is protected like the source file, including its Windows DACL. Temporary files are given
60
- the same permissions before data is written. Bounded in-memory Undo records can include
61
- credential-bearing configuration; they are not returned to the browser or written elsewhere.
62
- - **Windows timestamp changes are not permission changes by themselves.** Automatic writes
63
- capture native non-audit security sections, audit policy and file attributes. A changed
64
- timestamp is accepted only when that verified state, content and file identity still match.
65
- Unreadable audit policy is refused before staging; security that cannot be preserved on both
66
- replacement and backup files is also refused. No automatic elevation or security-policy
67
- changes are performed. The built-in audit-read API uses only rights assigned to the account.
68
- Inspection uses the bundled own-source .NET Framework helper, with no runtime compilation,
69
- download or PowerShell fallback. Build metadata checks source/binary consistency; it is not
70
- code signing or protection against replacement of the entire package.
47
+ Edits require a content revision and accumulate in a complete pending copy, preserving
48
+ unrelated JSON. Five seconds after the last accepted change, reload validates and flips the
49
+ files. HTTP 202 means staged, not applied. A batch-capable client header is required so an
50
+ older client cannot mistake acceptance for activation. Cancel and Undo affect a whole batch;
51
+ stale receipts cannot overwrite intervening active-file edits.
52
+ - **File flipping is recoverable, not an atomic two-file exchange.** The original is retained
53
+ as the previous file. Destination placement must not overwrite an unexpected contender.
54
+ Recovery uses validated transaction state and object/content checks, not blind renames or
55
+ deletion of unknown files. There can be a brief absent-active-path interval between moves.
56
+ These checks are not a universal filesystem compare-and-swap guarantee; avoid concurrent
57
+ manual edits during activation.
58
+ - **Timeout is not proof of rollback.** Staging and commit operations have bounded execution
59
+ budgets; the acknowledged debounce wait is separate. Once a commit starts, it can finish
60
+ after cancellation or timeout. Failed outcomes distinguish not-committed, committed and
61
+ unknown states. Reconciliation failures are surfaced, not treated as success, and uncertain
62
+ writes are never replayed automatically.
63
+ - **Pending files, previous files and Undo contain config data.** Complete copies must not
64
+ broaden access to credential-bearing bytes. The retained original keeps its original
65
+ security metadata. Bounded Undo records are internal; public batch summaries contain only
66
+ status and requested pooling settings. Receipt tokens are delivered only in authorized
67
+ responses, not broadcast in snapshots.
68
+ - **Windows saves do not require audit-read privilege.** New copies use directory-inherited
69
+ auditing. Explicit per-file audit rules are not guaranteed to carry forward to the new active
70
+ copy, and the UI displays this as a non-blocking notice. This is not a claim that custom rules
71
+ were detected or that the complete original security descriptor was cloned.
72
+ Ordinary data-access restrictions and supported integrity protections must be established
73
+ before writing candidate contents. Source write denial cannot be bypassed using parent
74
+ directory replacement rights. Unsupported protections are refused rather than weakened.
75
+ A source owner matching the caller's user or token default owner is retained. Other owners
76
+ require the existing checked transfer to the caller's user; source write access is still
77
+ required in either case.
78
+ The bundled own-source helper does not elevate the bridge, change account privileges, compile
79
+ at runtime, download code or use a PowerShell fallback. Build metadata checks consistency,
80
+ not code signing or protection against replacement of the entire package.
71
81
  - **Shared mode is not a user-security boundary.** It is opt-in for stateless tools with one
72
82
  common credential context. Only a tools capability derived from the real upstream is exposed;
73
83
  unsupported client capabilities and protocol methods fail explicitly. IDs, progress,
@@ -34,9 +34,10 @@ import { homedir } from 'node:os';
34
34
  import { createInterface } from 'node:readline';
35
35
  import { checkServerPaths, checkReservedName } from './config-checks.mjs';
36
36
  import { ServerMetrics } from './server-metrics.mjs';
37
- import { MAX_MIN_WARM } from './pooling-config.mjs';
38
- import { PoolingConfigWriter } from './pooling-writer.mjs';
37
+ import { MAX_MIN_WARM, hasPoolingTransaction, recoverPoolingConfig } from './pooling-config.mjs';
38
+ import { PoolingBatches } from './pooling-batches.mjs';
39
39
  import { POOLING_BUDGET_MS } from './pooling-execution.mjs';
40
+ import { poolingTrace } from './pooling-trace.mjs';
40
41
  import { SharedSessionManager, SHARED_DEFAULTS } from './shared-sessions.mjs';
41
42
 
42
43
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -47,7 +48,8 @@ const getArg = (name, def) => {
47
48
  };
48
49
 
49
50
  const HOME = resolve(homedir(), '.mcp-pacemaker');
50
- const defaultConfig = existsSync(resolve(process.cwd(), 'servers.json'))
51
+ const localConfig = resolve(process.cwd(), 'servers.json');
52
+ const defaultConfig = existsSync(localConfig) || hasPoolingTransaction(localConfig)
51
53
  ? resolve(process.cwd(), 'servers.json')
52
54
  : resolve(HOME, 'servers.json');
53
55
  const CONFIG = resolve(process.cwd(), getArg('--config', defaultConfig));
@@ -58,12 +60,21 @@ const VERSION = (() => { try { return JSON.parse(readFileSync(resolve(__dirname,
58
60
  const BASE_CWD = resolve(getArg('--cwd', dirname(CONFIG)));
59
61
 
60
62
  /** @type {Record<string, any>} */
63
+ if (hasPoolingTransaction(CONFIG)) recoverPoolingConfig(CONFIG);
61
64
  const servers = JSON.parse(readFileSync(CONFIG, 'utf8'));
62
- const poolingConfig = new PoolingConfigWriter(CONFIG);
65
+ const poolingConfig = new PoolingBatches({
66
+ configPath: CONFIG,
67
+ reload: reloadConfig,
68
+ revision: () => createHash('sha256').update(lastConfigText ?? '').digest('hex'),
69
+ notify: publishSnapshot,
70
+ captureSnapshot: richSnapshot,
71
+ onError: (error) => log(`pooling reload failed (${error.code || 'IO_ERROR'}); reread batch status`),
72
+ });
63
73
 
64
74
  /* ----------------------------- dashboard backbone --------------------------- */
65
75
  const startedAt = Date.now();
66
76
  const instanceId = randomUUID();
77
+ let snapshotVersion = 0;
67
78
  const serverMetrics = new ServerMetrics();
68
79
  const ADMIN_NONCE = randomUUID();
69
80
  const NONCE_FILE = resolve(dirname(CONFIG), 'admin.nonce');
@@ -185,8 +196,20 @@ function richSnapshot() {
185
196
  // breached. Report the number the cap actually counts alongside it.
186
197
  for (const s of sessions.values()) { const b = byName[s.name]; if (b) { b.sessions++; if (s.child?.pid) b.pids.push(s.child.pid); } }
187
198
  for (const s of httpSessions.values()) { const b = byName[s.name]; if (b) { b.sessions++; b.cappedSessions++; if (s.child?.pid && !b.pids.includes(s.child.pid)) b.pids.push(s.child.pid); if (s.clientInfo && s.clientInfo.name && !b.clients.includes(s.clientInfo.name)) b.clients.push(s.clientInfo.name); } }
188
- const prewarm = { revision: createHash('sha256').update(lastConfigText ?? '').digest('hex'), maxWarm: MAX_MIN_WARM };
189
- return { ok: true, service: 'mcp-pacemaker', version: VERSION, port: PORT, instanceId, startedAt: new Date(startedAt).toISOString(), prewarm, uptimeSec: Math.round((Date.now() - startedAt) / 1000), sessions: sessions.size + httpSessions.size, restart: restartStatus(), servers: Object.values(byName) };
199
+ const prewarm = {
200
+ revision: createHash('sha256').update(lastConfigText ?? '').digest('hex'),
201
+ maxWarm: MAX_MIN_WARM, ...poolingConfig.snapshot(),
202
+ };
203
+ snapshotVersion++;
204
+ return { ok: true, service: 'mcp-pacemaker', version: VERSION, port: PORT, instanceId, snapshotVersion, startedAt: new Date(startedAt).toISOString(), prewarm, uptimeSec: Math.round((Date.now() - startedAt) / 1000), sessions: sessions.size + httpSessions.size, restart: restartStatus(), servers: Object.values(byName) };
205
+ }
206
+
207
+ function publishSnapshot() {
208
+ if (!snapClients.size) return;
209
+ const frame = `data: ${JSON.stringify(richSnapshot())}\n\n`;
210
+ for (const client of snapClients) {
211
+ try { client.write(frame); } catch { /* disconnected */ }
212
+ }
190
213
  }
191
214
 
192
215
  function prewarmingState(name) {
@@ -298,9 +321,20 @@ function handleAdmin(url, req, res) {
298
321
  return;
299
322
  }
300
323
  if (req.method === 'POST' && parts[1] === 'reload') {
301
- const r = reloadConfig('admin request');
302
- res.writeHead(r.ok ? 200 : 400, { 'Content-Type': 'application/json' });
303
- res.end(JSON.stringify(r));
324
+ poolingConfig.reloadNow().then((result) => {
325
+ if (res.destroyed) return;
326
+ res.writeHead(200, { 'Content-Type': 'application/json' });
327
+ res.end(JSON.stringify({ ...result, snapshot: richSnapshot() }));
328
+ }).catch((error) => {
329
+ if (res.destroyed) return;
330
+ res.writeHead(error.statusCode ?? 500, { 'Content-Type': 'application/json' });
331
+ res.end(JSON.stringify({
332
+ ok: false, error: error.statusCode ? error.message : 'Unable to reload the saved config.',
333
+ code: error.statusCode ? error.code : 'RELOAD_FAILED',
334
+ ...(error.commitState ? { commitState: error.commitState } : {}),
335
+ snapshot: richSnapshot(),
336
+ }));
337
+ });
304
338
  return;
305
339
  }
306
340
  if (req.method === 'POST' && parts[1] === 'recycle') {
@@ -314,9 +348,11 @@ function handleAdmin(url, req, res) {
314
348
  }
315
349
 
316
350
  function handlePoolingChange(encodedName, req, res) {
351
+ let trace;
317
352
  const reply = (status, body) => {
318
353
  if (res.destroyed ||
319
354
  res.writableEnded) return;
355
+ trace?.record('response', { status });
320
356
  res.writeHead(status, { 'Content-Type': 'application/json' });
321
357
  res.end(JSON.stringify(body));
322
358
  };
@@ -332,6 +368,11 @@ function handlePoolingChange(encodedName, req, res) {
332
368
  req.resume();
333
369
  return;
334
370
  }
371
+ if (req.headers['x-mcp-pooling-batch'] !== '1') {
372
+ reply(409, { error: 'Reload the dashboard or update the CLI before changing pooling settings.' });
373
+ req.resume();
374
+ return;
375
+ }
335
376
  const chunks = [];
336
377
  let bytes = 0;
337
378
  let rejected = false;
@@ -339,18 +380,10 @@ function handlePoolingChange(encodedName, req, res) {
339
380
  const execution = {
340
381
  signal: controller.signal,
341
382
  deadline: process.hrtime.bigint() + BigInt(POOLING_BUDGET_MS) * 1000000n,
342
- onLateSettlement: ({ error, committed, commitStarted }) => {
343
- if (error &&
344
- error.code !== 'WRITER_CANCELLED' &&
345
- error.code !== 'WRITER_DEADLINE') {
346
- log(`pooling operation settled without a normal result (${error.code}); reread the config`);
347
- }
348
- if (!committed &&
349
- !commitStarted) return;
350
- const reloaded = reloadConfig('pooling commit settled without a normal result');
351
- if (!reloaded.ok) log('pooling commit settled but reload failed; reread the config');
352
- },
353
383
  };
384
+ trace = poolingTrace.operation(execution.deadline);
385
+ execution.trace = trace;
386
+ trace?.record('request-start');
354
387
  const bodyTimer = setTimeout(() => {
355
388
  rejected = true;
356
389
  chunks.length = 0;
@@ -380,6 +413,7 @@ function handlePoolingChange(encodedName, req, res) {
380
413
  chunks.push(chunk);
381
414
  });
382
415
  req.on('end', async () => {
416
+ trace?.record('body-end', { bytes });
383
417
  clearTimeout(bodyTimer);
384
418
  if (rejected) return;
385
419
  let body;
@@ -399,21 +433,16 @@ function handlePoolingChange(encodedName, req, res) {
399
433
  return;
400
434
  }
401
435
  try {
402
- const result = Object.hasOwn(body, 'undoId')
403
- ? await poolingConfig.undo({ ...body, name }, execution)
404
- : await poolingConfig.apply({ ...body, name }, execution);
405
- const reloaded = reloadConfig('pooling action');
406
- if (!reloaded.ok) {
407
- log(`[${name}] pooling settings saved but reload failed`);
408
- reply(500, { error: 'Settings were saved, but could not be activated. Reread the config before retrying.', revision: result.revision });
409
- return;
410
- }
411
- log(`[${name}] pooling ${Object.hasOwn(body, 'undoId') ? 'undo' : result.mode} applied`);
412
- reply(200, { ...result, snapshot: richSnapshot() });
436
+ const result = await poolingConfig.stage({ ...body, name }, execution);
437
+ log(`[${name}] pooling ${result.pending ? 'batch staged' : result.cancelled ? 'batch cancelled' : 'unchanged'}`);
438
+ reply(result.pending ? 202 : 200, result);
413
439
  } catch (error) {
414
440
  const status = Number.isInteger(error.statusCode) ? error.statusCode : 500;
415
441
  log(`pooling action rejected (${error.code || 'IO_ERROR'})`);
416
- reply(status, { error: error.statusCode ? error.message : 'Unable to apply pooling settings.' });
442
+ reply(status, {
443
+ error: error.statusCode ? error.message : 'Unable to stage pooling settings.',
444
+ ...(error.commitState ? { commitState: error.commitState } : {}),
445
+ });
417
446
  }
418
447
  });
419
448
  }
@@ -435,7 +464,7 @@ function handleUi(pathname, res) {
435
464
  }
436
465
 
437
466
  // Push snapshots to /api/events subscribers on an interval.
438
- setInterval(() => { if (!snapClients.size) return; const p = `data: ${JSON.stringify(richSnapshot())}\n\n`; for (const c of snapClients) { try { c.write(p); } catch { /* gone */ } } }, 2000).unref();
467
+ setInterval(publishSnapshot, 2000).unref();
439
468
 
440
469
  /* ------------------------------ pluggable auth ------------------------------ */
441
470
  const tokenCache = new Map(); // key -> { value, exp }
@@ -619,6 +648,8 @@ function killTree(child, sync = false) {
619
648
  child.detach();
620
649
  return;
621
650
  }
651
+ if (child.exitCode != null ||
652
+ child.signalCode != null) return;
622
653
  // Teardown we initiated. taskkill /F reports exit code 1, so without this every recycle,
623
654
  // idle reap and session close would be recorded as a crash.
624
655
  child.__bridgeKilled = true;
@@ -1427,22 +1458,36 @@ let lastConfigText = (() => { try { return readFileSync(CONFIG, 'utf8'); } catch
1427
1458
  function reloadConfig(reason) {
1428
1459
  let text;
1429
1460
  try { text = readFileSync(CONFIG, 'utf8'); }
1430
- catch (e) { return { ok: false, error: `cannot read ${CONFIG}: ${e.message}` }; }
1461
+ catch {
1462
+ return { ok: false, statusCode: 500, code: 'CONFIG_READ_FAILED',
1463
+ publicError: 'Unable to read the saved configuration.' };
1464
+ }
1431
1465
  if (text === lastConfigText) return { ok: true, unchanged: true, added: [], removed: [], changed: [], restarted: 0 };
1432
1466
 
1433
1467
  // An invalid file must never take working servers down — a half-written save from an editor
1434
1468
  // looks exactly like this. Keep serving what is already in memory and report the problem.
1435
1469
  let next;
1436
1470
  try { next = JSON.parse(text); }
1437
- catch (e) { log(`config reload rejected: invalid JSON (${e.message})`); return { ok: false, error: `invalid JSON: ${e.message}` }; }
1438
- if (!next || typeof next !== 'object' || Array.isArray(next)) {
1471
+ catch {
1472
+ log('config reload rejected: invalid JSON');
1473
+ return { ok: false, statusCode: 400, code: 'INVALID_CONFIG_JSON',
1474
+ publicError: 'Saved config contains invalid JSON.' };
1475
+ }
1476
+ if (!next ||
1477
+ typeof next !== 'object' ||
1478
+ Array.isArray(next)) {
1439
1479
  log('config reload rejected: top level must be an object of server definitions');
1440
- return { ok: false, error: 'top level must be an object of server definitions' };
1480
+ return { ok: false, statusCode: 400, code: 'INVALID_CONFIG_ROOT',
1481
+ publicError: 'Config top level must be an object of server definitions.' };
1441
1482
  }
1442
- for (const [n, d] of Object.entries(next)) {
1443
- if (!d || typeof d !== 'object' || (!d.command && !d.url)) {
1444
- log(`config reload rejected: "${n}" has neither "command" nor "url"`);
1445
- return { ok: false, error: `server "${n}" has neither "command" nor "url"` };
1483
+ for (const d of Object.values(next)) {
1484
+ if (!d ||
1485
+ typeof d !== 'object' ||
1486
+ (!d.command &&
1487
+ !d.url)) {
1488
+ log('config reload rejected: a server has neither "command" nor "url"');
1489
+ return { ok: false, statusCode: 400, code: 'INVALID_SERVER_DEFINITION',
1490
+ publicError: 'A server definition has neither "command" nor "url".' };
1446
1491
  }
1447
1492
  }
1448
1493
 
@@ -1469,7 +1514,11 @@ if (process.env.MCP_CONFIG_WATCH !== '0') {
1469
1514
  try {
1470
1515
  watcher = watch(CONFIG, () => {
1471
1516
  clearTimeout(timer);
1472
- timer = setTimeout(() => { reloadConfig('file changed'); rearm(); }, 300);
1517
+ timer = setTimeout(() => {
1518
+ poolingConfig.reloadSaved('file changed')
1519
+ .catch(() => log('config watcher could not reload the saved configuration'))
1520
+ .finally(rearm);
1521
+ }, 300);
1473
1522
  });
1474
1523
  watcher.unref?.();
1475
1524
  } catch { /* watching is best effort; /admin/reload still works */ }