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/CHANGELOG.md +85 -27
- package/CONTRIBUTING.md +123 -47
- package/README.md +117 -62
- package/SECURITY.md +34 -24
- package/bin/mcp-bridge.mjs +91 -42
- package/bin/pooling-batch-scheduler.mjs +129 -0
- package/bin/pooling-batches.mjs +270 -0
- package/bin/pooling-config.mjs +413 -389
- package/bin/pooling-editor.mjs +128 -0
- package/bin/pooling-errors.mjs +23 -0
- package/bin/pooling-execution.mjs +19 -6
- package/bin/pooling-files.mjs +541 -0
- package/bin/pooling-trace.mjs +218 -0
- package/bin/pooling-writer.mjs +87 -14
- package/bin/prewarm.mjs +87 -6
- package/bin/windows/PoolingSecurityHelper.build.json +7 -4
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/PoolingNativeFiles.cs +678 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +18 -5
- package/package.json +2 -2
- package/tools/windows-security-helper/README.md +146 -3
- package/tools/windows-security-helper/build.ps1 +2 -2
- package/ui/dist/assets/index-CaLPg0c-.css +1 -0
- package/ui/dist/assets/index-CgPruWYi.js +9 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -19
- package/ui/dist/assets/index-CIy7uxfd.css +0 -1
- package/ui/dist/assets/index-CtaaQsB9.js +0 -8
package/README.md
CHANGED
|
@@ -26,58 +26,42 @@ is separate planned work.
|
|
|
26
26
|
|
|
27
27
|
## Quick start
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
See
|
|
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
|
-
|
|
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.
|
|
50
|
-
JSON host configuration containing server definitions, or
|
|
51
|
-
[`~/.mcp-pacemaker/servers.json`](#serversjson).
|
|
52
|
-
`claude` (Claude Desktop), `copilot-cli` and `gemini`.
|
|
53
|
-
destinations, not
|
|
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
|
|
44
|
+
Then run interactive setup:
|
|
56
45
|
|
|
57
46
|
```bash
|
|
58
47
|
mcp-pacemaker init
|
|
59
48
|
```
|
|
60
49
|
|
|
61
|
-
`init`
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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`
|
|
76
|
-
`mcp-pacemaker init --from vscode --client codex` imports VS Code's
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
Use an exact version for a repeatable installation:
|
|
173
157
|
|
|
174
158
|
```bash
|
|
175
|
-
npm install -g mcp-pacemaker@2.0.
|
|
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.
|
|
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.
|
|
201
|
-
|
|
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]` |
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
can finish after
|
|
330
|
-
|
|
331
|
-
|
|
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;
|
|
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 |
|
|
448
|
-
| `/admin/servers/<name>/pooling` | POST |
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- **
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
- **
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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,
|
package/bin/mcp-bridge.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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 = {
|
|
189
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 =
|
|
403
|
-
|
|
404
|
-
|
|
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, {
|
|
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(
|
|
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
|
|
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
|
|
1438
|
-
|
|
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,
|
|
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
|
|
1443
|
-
if (!d ||
|
|
1444
|
-
|
|
1445
|
-
|
|
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(() => {
|
|
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 */ }
|