mcp-pacemaker 1.3.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/CHANGELOG.md +424 -0
- package/CONTRIBUTING.md +156 -0
- package/LICENSE +21 -0
- package/README.md +620 -0
- package/SECURITY.md +110 -0
- package/THIRD_PARTY_NOTICES.txt +366 -0
- package/autostart/windows/launcher.vbs +11 -0
- package/autostart/windows/register-task.ps1 +63 -0
- package/bin/cli.mjs +880 -0
- package/bin/config-checks.mjs +62 -0
- package/bin/logs.mjs +465 -0
- package/bin/mcp-bridge.mjs +2113 -0
- package/bin/pooling-config.mjs +553 -0
- package/bin/pooling-execution.mjs +91 -0
- package/bin/pooling-writer.mjs +169 -0
- package/bin/prewarm.mjs +152 -0
- package/bin/server-metrics.mjs +69 -0
- package/bin/service-control.mjs +124 -0
- package/bin/shared-sessions.mjs +1083 -0
- package/bin/top.mjs +115 -0
- package/bin/update-check.mjs +153 -0
- package/bin/windows/PoolingSecurityHelper.build.json +39 -0
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/AssemblyInfo.cs +11 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +165 -0
- package/bin/windows/src/PoolingSecurityReader.cs +50 -0
- package/docs/shared-sessions.md +181 -0
- package/examples/servers.example.json +31 -0
- package/package.json +77 -0
- package/supervisor/bridge-child.mjs +14 -0
- package/supervisor/supervise.mjs +172 -0
- package/supervisor/supervise.ps1 +23 -0
- package/supervisor/supervise.sh +11 -0
- package/tools/windows-security-helper/README.md +95 -0
- package/tools/windows-security-helper/build.ps1 +262 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
- package/ui/dist/assets/index-CIy7uxfd.css +1 -0
- package/ui/dist/assets/index-CtaaQsB9.js +8 -0
- package/ui/dist/index.html +14 -0
- package/ui/dist/third-party-manifest.json +579 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Shared stdio sessions
|
|
2
|
+
|
|
3
|
+
Status: 1.3.0 implementation contract; not a claim that every server is compatible.
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
Add `"sharing": "shared"` to a stdio definition only after confirming the profile below.
|
|
8
|
+
Use `/<name>/mcp`: classic SSE endpoints explicitly reject shared mode.
|
|
9
|
+
Pooling buttons never enable shared mode or replace an existing shared policy.
|
|
10
|
+
|
|
11
|
+
| Setting | Default | Purpose |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `sharedLingerMs` | 1800000 | Keep the initialized child between compatible sessions. |
|
|
14
|
+
| `sharedDrainTimeoutMs` | 30000 | Maximum drain time before terminating unresolved work. |
|
|
15
|
+
| `sharedRetryDelayMs` | 1000 | Backoff after a failed generation. |
|
|
16
|
+
| `sharedMaxSessions` | 128 | Virtual members plus joining clients per group. |
|
|
17
|
+
| `sharedMaxInFlight` | 8 | Dispatched operations per child. |
|
|
18
|
+
| `sharedMaxQueued` | 32 | Unsent operations waiting for dispatch. |
|
|
19
|
+
| `sharedMaxIds` | 4096 | Retained request IDs per virtual session. |
|
|
20
|
+
| `sharedMaxCursors` | 256 | Retained tools-list cursors per virtual session. |
|
|
21
|
+
| `sharedMaxLineBytes` | 1048576 | Maximum JSON-line size, including translated fields. |
|
|
22
|
+
| `sharedMaxBufferBytes` | 4194304 | Maximum buffered protocol data per stream. |
|
|
23
|
+
|
|
24
|
+
All settings require bounded integers; invalid values fail explicitly. The bridge's existing
|
|
25
|
+
session cap and request/initialization timeouts also apply. A slot is reserved before waiting
|
|
26
|
+
for shared initialization or the spawn gate. Concurrent resumes of one external session ID
|
|
27
|
+
share one handshake.
|
|
28
|
+
Client and upstream JSON have a maximum depth of 64, counting the root as depth 0.
|
|
29
|
+
An upstream depth violation terminates its generation with explicit failure metadata,
|
|
30
|
+
not an uncaught serializer exception; no business call is replayed.
|
|
31
|
+
|
|
32
|
+
## Purpose and approval
|
|
33
|
+
|
|
34
|
+
`sharing: "shared"` reuses one initialized stdio child across compatible virtual HTTP sessions.
|
|
35
|
+
This differs from `pool`: a pooled child is uninitialized and belongs exclusively to its first
|
|
36
|
+
session. Both modes remain opt-in. No latency recommendation enables either mode automatically.
|
|
37
|
+
|
|
38
|
+
Shared mode is for a stateless-tools profile: tools operate on explicit inputs, the configured
|
|
39
|
+
credential context is common, and the server has no implicit per-client workspace, conversation,
|
|
40
|
+
account or subscription state. Enabling the mode asserts that profile. Matching capabilities
|
|
41
|
+
alone does not prove the implementation is stateless.
|
|
42
|
+
|
|
43
|
+
The default remains `isolated`. Unsupported clients receive a clear compatibility error before
|
|
44
|
+
their business request is dispatched. There is no silent sharing downgrade, guessed callback
|
|
45
|
+
recipient, or automatic replay on another process.
|
|
46
|
+
|
|
47
|
+
## Compatibility and initialization
|
|
48
|
+
|
|
49
|
+
One group per configured server and configuration generation is allowed. Its compatibility key
|
|
50
|
+
contains the complete, canonically ordered initialization parameters and configuration. Preserve
|
|
51
|
+
array order, absent properties and all client identity fields. The JSON-RPC request ID is not
|
|
52
|
+
part of the initialization parameters.
|
|
53
|
+
|
|
54
|
+
For the first profile:
|
|
55
|
+
|
|
56
|
+
- Support released protocol versions `2025-03-26`, `2025-06-18` and `2025-11-25`.
|
|
57
|
+
- Require empty client capabilities. Roots, sampling, elicitation, tasks and unknown extensions
|
|
58
|
+
cannot safely be routed from an arbitrary stdio callback to an individual client.
|
|
59
|
+
- Support `tools/list`, `tools/call` and `ping`.
|
|
60
|
+
- Reject stateful protocol methods, subscriptions, logging changes and task-augmented calls.
|
|
61
|
+
- Do not interpret arbitrary tool argument/result fields as request IDs or state handles.
|
|
62
|
+
- Bind tools-list cursors to their originating virtual session and child generation.
|
|
63
|
+
- Retain the actual upstream protocol version, instructions and implementation metadata.
|
|
64
|
+
The upstream may negotiate any of the three supported versions.
|
|
65
|
+
- Expose only the upstream's actual `tools` capability, without inventing flags. This
|
|
66
|
+
explicitly tools-only surface does not advertise optional prompts, resources, logging,
|
|
67
|
+
completions or tasks even if the upstream supports them. An empty `experimental` object
|
|
68
|
+
is harmless; unknown or nonempty experimental extensions are incompatible.
|
|
69
|
+
|
|
70
|
+
Concurrent compatible arrivals share a creation promise installed before awaiting process
|
|
71
|
+
creation. Exactly one upstream `initialize` and one `notifications/initialized` are sent.
|
|
72
|
+
Each virtual session receives the result with its own request ID and must complete its own
|
|
73
|
+
initialized notification before dispatching tools.
|
|
74
|
+
|
|
75
|
+
An incompatible client does not replace or disturb the established group. Failed initialization
|
|
76
|
+
fails all waiters, removes the failed creation promise, and terminates the candidate. A later
|
|
77
|
+
attempt may start a new group with bounded retry behavior.
|
|
78
|
+
|
|
79
|
+
## Routing
|
|
80
|
+
|
|
81
|
+
Use distinct typed keys: integer `1` and string `"1"` are different client IDs.
|
|
82
|
+
|
|
83
|
+
| Message | Required behavior |
|
|
84
|
+
|---|---|
|
|
85
|
+
| Client request | Translate to a generation-unique upstream ID and retain owner plus original ID. |
|
|
86
|
+
| Child response | Deliver only to its mapped owner, restoring the original ID. |
|
|
87
|
+
| Client progress token | Translate only `_meta.progressToken`; restore it on the owner's progress notifications. |
|
|
88
|
+
| Client cancellation | Translate only that session's request ID. Never cancel another client's request. |
|
|
89
|
+
| Child `ping` | Answer as the bridge peer. It is not a response even if its ID matches an operation. |
|
|
90
|
+
| Other child requests | Reject explicitly; never select the first/recent client or broadcast callbacks. |
|
|
91
|
+
| Tools-list changes | Fan out to members only when the common catalog contract permits it. |
|
|
92
|
+
| Other notifications | Drop unowned logs and unexposed catalog changes with diagnostics. Never broadcast their data; reject unknown extensions. |
|
|
93
|
+
|
|
94
|
+
Classify messages by category (`method`, `result`, `error`) before consulting ID maps. Unknown
|
|
95
|
+
or late responses must not become notifications. Progress associated with a POST must be
|
|
96
|
+
delivered on its originating POST stream; an unrelated GET stream is not a replacement.
|
|
97
|
+
|
|
98
|
+
Bound operations, queued work, input lines, retained IDs/cursors and stream buffers. Reject
|
|
99
|
+
overflow explicitly instead of allocating without limit or silently truncating protocol data.
|
|
100
|
+
|
|
101
|
+
## Virtual child adapter
|
|
102
|
+
|
|
103
|
+
`bin/shared-sessions.mjs` exports `SharedSessionManager`. It takes:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
{
|
|
107
|
+
spawn: async (name, definition) => actualChild,
|
|
108
|
+
kill: (actualChild) => {},
|
|
109
|
+
onFailure: (name, detail) => {},
|
|
110
|
+
onInitialized: (name, elapsedMs) => {},
|
|
111
|
+
onResponse: (name) => {},
|
|
112
|
+
log: (name, text) => {},
|
|
113
|
+
initTimeoutMs,
|
|
114
|
+
requestTimeoutMs
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`await manager.acquire(name, definition, initializeParams)` returns an EventEmitter-compatible
|
|
119
|
+
virtual child with `stdin` accepting newline-delimited JSON-RPC, readable `stdout`, the actual
|
|
120
|
+
group `pid`, `exitCode`, `signalCode`, `__spawnedAt`, `__sharedSession: true`, and `detach()`.
|
|
121
|
+
It plugs into the existing Streamable HTTP per-session response parser without spawning again.
|
|
122
|
+
One actual child owns one stdout parser; virtual stdout contains only that session's messages.
|
|
123
|
+
For already-parsed HTTP JSON, use `child.writeMessage(message)`. It applies the same input,
|
|
124
|
+
depth, ID and ownership guards before serialization. Calling it after detachment throws an
|
|
125
|
+
explicit `SHARED_DETACHED` admission error instead of leaving a request waiting indefinitely.
|
|
126
|
+
|
|
127
|
+
The bridge's process-kill helper must detect a virtual child and detach it instead of killing
|
|
128
|
+
its shared PID. Actual group creation goes through the existing spawn gate and B21 counter.
|
|
129
|
+
Virtual session attachment is not an OS spawn or a cold-initialization sample.
|
|
130
|
+
`onResponse` reports actual upstream responses, not locally generated rejections. Health must
|
|
131
|
+
not classify errors by a numeric JSON-RPC code that an upstream can also legitimately use.
|
|
132
|
+
|
|
133
|
+
Provide `recycle(name)`, `remove(name)`, `shutdown()` and `inspect(name)` methods. Inspection
|
|
134
|
+
exposes only lifecycle facts such as generation, state, members and unresolved operations.
|
|
135
|
+
|
|
136
|
+
## Lifetime, drain and credentials
|
|
137
|
+
|
|
138
|
+
Deleting one session forgets and tombstones only that session. Remove its unsent work and send
|
|
139
|
+
owner-scoped cancellation for dispatched work. Other clients and their operations remain alive.
|
|
140
|
+
The actual child lingers between compatible sessions, bounded by `sharedLingerMs` (default
|
|
141
|
+
30 minutes). Unresolved work prevents idle eviction.
|
|
142
|
+
|
|
143
|
+
Cancellation is not proof that execution stopped. Retain execution ownership until the
|
|
144
|
+
response arrives or a bounded failure/drain policy terminates the generation.
|
|
145
|
+
|
|
146
|
+
Recycling marks the generation draining and stops new dispatch to it. Existing work gets a
|
|
147
|
+
bounded opportunity to finish; once drained, terminate promptly rather than waiting for the
|
|
148
|
+
next full recycle interval. A drain deadline fails unresolved work explicitly as potentially
|
|
149
|
+
executed, terminates that generation, and permits a later fresh generation. Never replay those
|
|
150
|
+
business calls. Fence old exit handlers and timers so they cannot delete a replacement.
|
|
151
|
+
|
|
152
|
+
The server retains its existing credential mechanism. The bridge does not freeze a token in
|
|
153
|
+
the shared initialization, infer renewal from a generic cache, or substitute an auth provider.
|
|
154
|
+
Keep configured credential recycling. A synthetic short-expiry test must show: expired call
|
|
155
|
+
fails unchanged; draining/replacement creates a fresh generation; a new call succeeds; the
|
|
156
|
+
failed call was dispatched once. Real provider renewal still requires its own canary.
|
|
157
|
+
|
|
158
|
+
Resume reattaches only compatible stateless sessions. It is not restoration of arbitrary
|
|
159
|
+
application state and does not preserve TCP connections across a bridge restart; that is B14.
|
|
160
|
+
|
|
161
|
+
## Acceptance gates
|
|
162
|
+
|
|
163
|
+
1. More than fifty compatible initialize/DELETE cycles reuse one actual child and one upstream
|
|
164
|
+
initialization. Two simultaneously active sessions must also share that same generation.
|
|
165
|
+
2. Concurrent numeric/string ID collisions, out-of-order results, progress and cancellation
|
|
166
|
+
route to the correct owner. Forged or late messages cannot cross sessions.
|
|
167
|
+
3. Delete one client while another works, kill a child during a request, and recycle while busy.
|
|
168
|
+
Pending callers fail promptly and dispatched tool calls are never replayed.
|
|
169
|
+
4. Incompatible initialization and unsupported callbacks/methods fail explicitly. Isolation
|
|
170
|
+
defaults and existing HTTP proxy behavior remain unchanged.
|
|
171
|
+
5. Counter deltas for identical isolated and shared workloads show actual reduced launches,
|
|
172
|
+
not merely fewer latency samples or a lower instant process count.
|
|
173
|
+
6. Use an isolated config directory and spare port for real-server and browser/CLI canaries.
|
|
174
|
+
Do not alter another project's client or orchestration configuration.
|
|
175
|
+
|
|
176
|
+
Reference: released MCP
|
|
177
|
+
[lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle),
|
|
178
|
+
[transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports),
|
|
179
|
+
[progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress), and
|
|
180
|
+
[cancellation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation).
|
|
181
|
+
Draft transport changes are not applied to clients speaking released versions.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"filesystem": {
|
|
3
|
+
"command": "npx",
|
|
4
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
5
|
+
},
|
|
6
|
+
"github": {
|
|
7
|
+
"command": "npx",
|
|
8
|
+
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
9
|
+
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "REPLACE_WITH_YOUR_PAT" }
|
|
10
|
+
},
|
|
11
|
+
"example-http-static-token": {
|
|
12
|
+
"type": "http",
|
|
13
|
+
"url": "https://example.com/mcp",
|
|
14
|
+
"auth": { "type": "env", "var": "EXAMPLE_TOKEN" }
|
|
15
|
+
},
|
|
16
|
+
"example-http-cli-token": {
|
|
17
|
+
"type": "http",
|
|
18
|
+
"url": "https://your-service.example.com/mcp",
|
|
19
|
+
"auth": {
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "az account get-access-token --resource api://your-resource-id --query accessToken -o tsv",
|
|
22
|
+
"refreshMinutes": 50
|
|
23
|
+
},
|
|
24
|
+
"headers": { "x-example-header": "value" }
|
|
25
|
+
},
|
|
26
|
+
"example-http-no-auth": {
|
|
27
|
+
"type": "http",
|
|
28
|
+
"url": "https://open.example.com/mcp",
|
|
29
|
+
"auth": { "type": "none" }
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-pacemaker",
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Keeps your MCP servers alive across editor restarts, reboots, and token expiry — pluggable auth, cross-platform auto-start, one-command setup. Zero-dependency bridge; a small CLI for setup.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-pacemaker": "bin/cli.mjs",
|
|
8
|
+
"mcp-bridge": "bin/mcp-bridge.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"autostart/",
|
|
13
|
+
"supervisor/",
|
|
14
|
+
"tools/windows-security-helper/",
|
|
15
|
+
"examples/",
|
|
16
|
+
"ui/dist/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"SECURITY.md",
|
|
20
|
+
"docs/shared-sessions.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"THIRD_PARTY_NOTICES.txt",
|
|
23
|
+
"CONTRIBUTING.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Girish Konda",
|
|
30
|
+
"homepage": "https://github.com/girishkvs/mcp-pacemaker#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/girishkvs/mcp-pacemaker/issues"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/girishkvs/mcp-pacemaker.git"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"mcp",
|
|
40
|
+
"model-context-protocol",
|
|
41
|
+
"sse",
|
|
42
|
+
"bridge",
|
|
43
|
+
"proxy",
|
|
44
|
+
"vscode",
|
|
45
|
+
"cursor",
|
|
46
|
+
"claude-desktop",
|
|
47
|
+
"supervisor",
|
|
48
|
+
"keep-alive"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"bridge": "node bin/mcp-bridge.mjs",
|
|
52
|
+
"cli": "node bin/cli.mjs",
|
|
53
|
+
"consumer:check": "node tools/npm-consumer/check.mjs",
|
|
54
|
+
"compat:prepare": "node tools/compatibility/prepare.mjs",
|
|
55
|
+
"compat:prepare:baseline": "node tools/compatibility/prepare.mjs --historical",
|
|
56
|
+
"compat:clean": "node tools/compatibility/prepare.mjs --clean",
|
|
57
|
+
"test:compat": "node --test --test-concurrency=1 test/compat/gates.test.mjs test/compat/cli-api.test.mjs",
|
|
58
|
+
"test:compat:browser": "node --test --test-concurrency=1 test/compat/browser.test.mjs",
|
|
59
|
+
"test": "node --test test/bridge.test.mjs test/bridge-controls.test.mjs test/kill-tree.test.mjs test/keepalive.test.mjs test/auth-token.test.mjs test/cold-start.test.mjs test/crash-report.test.mjs test/resume.test.mjs test/config-checks.test.mjs test/config-reload.test.mjs test/health.test.mjs test/cli.test.mjs test/logs.test.mjs test/prewarm.test.mjs test/pooling-config.test.mjs test/pooling-writer.test.mjs test/pooling-api.test.mjs test/pooling-deadlines.test.mjs test/pooling-queue-budget.test.mjs test/windows-security-helper.test.mjs test/tui.test.mjs test/warm-pool.test.mjs test/server-metrics.test.mjs test/admission.test.mjs test/shared-sessions.test.mjs test/shared-http.test.mjs test/cross-host.test.mjs test/suite-integrity.test.mjs test/consumer-package.test.mjs test/notices.test.mjs test/notices-runtime.test.mjs test/npm-publication.test.mjs test/npm-publication-proof.test.mjs test/update-check.test.mjs test/service-lifecycle.test.mjs test/service-supervisor.test.mjs test/npm-publication-gates.test.mjs test/publication-scanners-advisories.test.mjs test/publication-scanners.test.mjs test/service-replacement.test.mjs test/consumer-environment.test.mjs test/npm-publication-external.test.mjs test/npm-publication-matrix.test.mjs test/npm-publication-peer.test.mjs test/publication-scanners-diagnostics.test.mjs test/publication-scanners-publication.test.mjs test/publication-scanners-paths.test.mjs test/npm-publication-local-enforcement.test.mjs test/publication-scanners-admission.test.mjs",
|
|
60
|
+
"notices:check": "node tools/third-party-notices/check.mjs",
|
|
61
|
+
"publication:source-gates": "node tools/npm-publication/source-gates.mjs",
|
|
62
|
+
"publication:artifact-gates": "node tools/npm-publication/artifact-gates.mjs",
|
|
63
|
+
"service:replacement:check": "node tools/service-replacement/check.mjs",
|
|
64
|
+
"publication:external-gates": "node tools/npm-publication/external-gates.mjs",
|
|
65
|
+
"publication:local-gate": "node tools/npm-publication/local-windows.mjs",
|
|
66
|
+
"publication:verify-local": "node tools/npm-publication/verify-local.mjs"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@clack/prompts": "^1.7.0",
|
|
70
|
+
"commander": "^15.0.0",
|
|
71
|
+
"ink": "^7.1.1",
|
|
72
|
+
"picocolors": "^1.1.1",
|
|
73
|
+
"react": "^19.2.7",
|
|
74
|
+
"semver": "^7.7.3",
|
|
75
|
+
"smol-toml": "^1.8.0"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// IPC reaches the bridge's existing graceful shutdown handler on Windows too.
|
|
2
|
+
let loaded = false;
|
|
3
|
+
let stopRequested = false;
|
|
4
|
+
function shutdown() {
|
|
5
|
+
stopRequested = true;
|
|
6
|
+
if (loaded) process.emit('SIGTERM');
|
|
7
|
+
}
|
|
8
|
+
process.on('message', (message) => {
|
|
9
|
+
if (message === 'stop') shutdown();
|
|
10
|
+
});
|
|
11
|
+
process.on('disconnect', shutdown);
|
|
12
|
+
await import('../bin/mcp-bridge.mjs');
|
|
13
|
+
loaded = true;
|
|
14
|
+
if (stopRequested) shutdown();
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, resolve, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { servicePaths, assertDurableRoot } from '../bin/service-control.mjs';
|
|
10
|
+
|
|
11
|
+
const { values } = parseArgs({
|
|
12
|
+
options: { port: { type: 'string', default: '8791' }, config: { type: 'string' } },
|
|
13
|
+
});
|
|
14
|
+
const port = Number(values.port);
|
|
15
|
+
const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
16
|
+
const config = resolve(values.config || join(homedir(), '.mcp-pacemaker', 'servers.json'));
|
|
17
|
+
const paths = servicePaths(config, port);
|
|
18
|
+
assertDurableRoot(root);
|
|
19
|
+
if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node.js >=20 is required.');
|
|
20
|
+
|
|
21
|
+
if (existsSync(paths.hold)) {
|
|
22
|
+
console.log(`Service :${port} is held stopped. Use the matching CLI start --port ${port} to resume deliberately.`);
|
|
23
|
+
} else {
|
|
24
|
+
await supervise();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function supervise() {
|
|
28
|
+
const identity = {
|
|
29
|
+
protocol: 1, root, config, port, socket: paths.socket, pid: process.pid, execPath: process.execPath,
|
|
30
|
+
id: randomUUID(), token: randomBytes(32).toString('hex'),
|
|
31
|
+
version: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version,
|
|
32
|
+
};
|
|
33
|
+
let child;
|
|
34
|
+
let childExit;
|
|
35
|
+
let retry;
|
|
36
|
+
let stopping;
|
|
37
|
+
let stopRequested = false;
|
|
38
|
+
let holdRequested = false;
|
|
39
|
+
const clients = new Set();
|
|
40
|
+
const server = createServer((socket) => {
|
|
41
|
+
clients.add(socket);
|
|
42
|
+
socket.on('close', () => clients.delete(socket));
|
|
43
|
+
socket.on('error', () => socket.destroy());
|
|
44
|
+
socket.setTimeout(15000, () => socket.destroy());
|
|
45
|
+
let data = '';
|
|
46
|
+
let handled = false;
|
|
47
|
+
socket.on('data', (chunk) => {
|
|
48
|
+
if (handled) return;
|
|
49
|
+
data += chunk;
|
|
50
|
+
if (data.length > 4096) { socket.destroy(); return; }
|
|
51
|
+
if (!data.includes('\n')) return;
|
|
52
|
+
handled = true;
|
|
53
|
+
let request;
|
|
54
|
+
try { request = JSON.parse(data.slice(0, data.indexOf('\n'))); }
|
|
55
|
+
catch { socket.destroy(); return; }
|
|
56
|
+
if (request.id !== identity.id ||
|
|
57
|
+
request.token !== identity.token ||
|
|
58
|
+
request.action !== 'stop') {
|
|
59
|
+
socket.destroy();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
stop().then(() => {
|
|
63
|
+
socket.end(JSON.stringify({ id: identity.id, stopped: true, autostartHeld: true }) + '\n');
|
|
64
|
+
}, (error) => {
|
|
65
|
+
console.error(error.message);
|
|
66
|
+
socket.end(JSON.stringify({ id: identity.id, stopped: false }) + '\n');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
mkdirSync(dirname(config), { recursive: true, mode: 0o700 });
|
|
72
|
+
await new Promise((resolveListen, reject) => {
|
|
73
|
+
server.once('error', reject);
|
|
74
|
+
server.listen(paths.socket, resolveListen);
|
|
75
|
+
});
|
|
76
|
+
server.on('error', (error) => { console.error(error.message); process.exitCode = 1; });
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(paths.record, JSON.stringify(identity) + '\n', { mode: 0o600 });
|
|
79
|
+
if (existsSync(paths.hold)) {
|
|
80
|
+
server.close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
launch();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
server.close();
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
process.on('SIGINT', onSignal);
|
|
89
|
+
process.on('SIGTERM', onSignal);
|
|
90
|
+
|
|
91
|
+
function launch() {
|
|
92
|
+
if (stopRequested ||
|
|
93
|
+
existsSync(paths.hold)) {
|
|
94
|
+
server.close();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
child = fork(join(root, 'supervisor', 'bridge-child.mjs'), ['--port', String(port), '--config', config], {
|
|
98
|
+
execPath: process.execPath, execArgv: [], stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true,
|
|
99
|
+
});
|
|
100
|
+
childExit = new Promise((resolveExit, reject) => {
|
|
101
|
+
child.once('error', reject);
|
|
102
|
+
child.once('exit', (code) => {
|
|
103
|
+
child = undefined;
|
|
104
|
+
resolveExit();
|
|
105
|
+
if (stopRequested) return;
|
|
106
|
+
if (code === 3) server.close();
|
|
107
|
+
else retry = setTimeout(launch, 2000);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
childExit.catch((error) => {
|
|
111
|
+
console.error(`Bridge launch failed: ${error.message}`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
server.close();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function stop(hold = true) {
|
|
118
|
+
// Persist before touching the child so logon/unlock and OS retries cannot restart it.
|
|
119
|
+
if (hold &&
|
|
120
|
+
!holdRequested) {
|
|
121
|
+
writeFileSync(paths.hold, JSON.stringify({ id: identity.id, root, config, port }) + '\n', { mode: 0o600 });
|
|
122
|
+
holdRequested = true;
|
|
123
|
+
}
|
|
124
|
+
if (stopping) return stopping;
|
|
125
|
+
stopRequested = true;
|
|
126
|
+
clearTimeout(retry);
|
|
127
|
+
stopping = (async () => {
|
|
128
|
+
if (child) {
|
|
129
|
+
if (child.connected) {
|
|
130
|
+
try {
|
|
131
|
+
await new Promise((resolveSend, reject) => child.send('stop', (error) => error ? reject(error) : resolveSend()));
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.code !== 'ERR_IPC_CHANNEL_CLOSED' &&
|
|
134
|
+
error.code !== 'EPIPE') throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let timeout;
|
|
138
|
+
try {
|
|
139
|
+
await Promise.race([
|
|
140
|
+
childExit,
|
|
141
|
+
new Promise((_, reject) => {
|
|
142
|
+
timeout = setTimeout(() => reject(new Error('Bridge did not exit; do not replace its root. No force-kill attempted.')), 10000);
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (holdRequested) {
|
|
150
|
+
writeFileSync(paths.hold, JSON.stringify({ id: identity.id, root, config, port, stopped: true }) + '\n', { mode: 0o600 });
|
|
151
|
+
}
|
|
152
|
+
server.close();
|
|
153
|
+
process.removeListener('SIGINT', onSignal);
|
|
154
|
+
process.removeListener('SIGTERM', onSignal);
|
|
155
|
+
})().catch((error) => {
|
|
156
|
+
// Keep restart suppressed, but let a later explicit stop verify the real child exit.
|
|
157
|
+
// A timeout must not write a completed receipt or permanently poison reconciliation.
|
|
158
|
+
stopping = undefined;
|
|
159
|
+
throw error;
|
|
160
|
+
});
|
|
161
|
+
return stopping;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function onSignal() {
|
|
165
|
+
stop(false).then(() => {
|
|
166
|
+
for (const socket of clients) socket.destroy();
|
|
167
|
+
}, (error) => {
|
|
168
|
+
console.error(error.message);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS Supervisor for the mcp-pacemaker bridge (Windows). Keeps it alive across crashes.
|
|
3
|
+
.DESCRIPTION
|
|
4
|
+
Runs the managed supervisor. A targeted CLI stop holds automatic restarts until an
|
|
5
|
+
explicit CLI start. Node.js 20 or newer must be on PATH.
|
|
6
|
+
.PARAMETER Port Port the bridge listens on (default 8791). Must match your client config.
|
|
7
|
+
.OUTPUTS
|
|
8
|
+
Supervisor diagnostics.
|
|
9
|
+
.EXAMPLE
|
|
10
|
+
.\supervise.ps1 -Port 8791
|
|
11
|
+
#>
|
|
12
|
+
[CmdletBinding()]
|
|
13
|
+
param([ValidateRange(1, 65535)][int]$Port = 8791)
|
|
14
|
+
|
|
15
|
+
$ErrorActionPreference = 'Stop'
|
|
16
|
+
$supervisor = Join-Path -Path $PSScriptRoot -ChildPath 'supervise.mjs'
|
|
17
|
+
|
|
18
|
+
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
|
19
|
+
throw "node is not on PATH. Install Node.js (>=20) and retry."
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
& node $supervisor --port $Port
|
|
23
|
+
exit $LASTEXITCODE
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Starts the managed supervisor. Arg 1 = port.
|
|
3
|
+
PORT="${1:-8791}"
|
|
4
|
+
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
5
|
+
|
|
6
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
7
|
+
echo "node is not on PATH. Install Node.js (>=20) and retry." >&2
|
|
8
|
+
exit 1
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
exec node "$DIR/supervisor/supervise.mjs" --port "$PORT"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Windows security helper
|
|
2
|
+
|
|
3
|
+
`bin/windows/PoolingSecurityHelper.exe` is an own-source, AnyCPU .NET Framework
|
|
4
|
+
console program. It contains both the native security reader and the operation
|
|
5
|
+
code; there is no companion reader DLL or third-party runtime dependency.
|
|
6
|
+
The `bin/windows/src/` source and build metadata ship alongside the executable.
|
|
7
|
+
|
|
8
|
+
## Runtime
|
|
9
|
+
|
|
10
|
+
Requires Windows with .NET Framework 4.6.2 or later. Windows 10 1607+, Windows 11,
|
|
11
|
+
and Windows Server 2016+ include that version or newer. Windows PowerShell 5.1
|
|
12
|
+
alone on older Windows does not establish this prerequisite.
|
|
13
|
+
PowerShell is **not** launched by the helper.
|
|
14
|
+
There is no runtime compilation, download, elevation, or policy change.
|
|
15
|
+
AnyCPU uses the installed Framework's process architecture; native calls use
|
|
16
|
+
pointer-sized handles. Windows ARM64 execution has not been validated.
|
|
17
|
+
|
|
18
|
+
The Node caller must launch the package-relative executable directly (no shell)
|
|
19
|
+
with exactly one argument, `inspect` or `copy`, and supply:
|
|
20
|
+
|
|
21
|
+
| Environment variable | Used by | Value |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `MCP_POOL_SOURCE` | Both | Source config path |
|
|
24
|
+
| `MCP_POOL_SECURITY` | Copy | Previously inspected fingerprint |
|
|
25
|
+
| `MCP_POOL_TEMP` | Copy | Existing empty replacement file |
|
|
26
|
+
| `MCP_POOL_BACKUP` | Copy | Existing empty backup file |
|
|
27
|
+
|
|
28
|
+
`inspect` writes one line: `F:<base64 SHA256>` for complete security or
|
|
29
|
+
`P:<base64 SHA256>` when audit read is denied. These are comparison values,
|
|
30
|
+
not log fields. The caller must refuse automatic writes for partial (`P:`)
|
|
31
|
+
state. Successful `copy` writes nothing to stdout.
|
|
32
|
+
|
|
33
|
+
| Exit code | Meaning |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| 0 | Inspection or copy/verification succeeded |
|
|
36
|
+
| 3 | Source fingerprint changed before or after copy; caller reports conflict |
|
|
37
|
+
| 1 | Invalid input, I/O failure, or security cannot be preserved |
|
|
38
|
+
|
|
39
|
+
On failure, stderr contains only `MCPERR type=<exception type>` and, for native
|
|
40
|
+
errors, `MCPERR nativeError=<integer>`. No path, descriptor, token, config bytes,
|
|
41
|
+
or exception message is logged. Caller owns process timeout and error mapping.
|
|
42
|
+
|
|
43
|
+
Fingerprint framing is unchanged: canonical native descriptor (mask `0x1f7`),
|
|
44
|
+
separate managed audit descriptor, and integer file attributes, written with
|
|
45
|
+
`BinaryWriter` length prefixes before SHA256. Copy preserves owner/group/DACL
|
|
46
|
+
through SDDL and verifies the entire fingerprint on both staging files, then
|
|
47
|
+
rechecks the source. It does not copy unsupported audit/integrity/attribute
|
|
48
|
+
state or weaken it to force success. It never writes config contents.
|
|
49
|
+
|
|
50
|
+
## Build and verify
|
|
51
|
+
|
|
52
|
+
Prerequisites, installed by the developer or CI image:
|
|
53
|
+
|
|
54
|
+
1. Visual Studio or Build Tools with MSBuild and its Roslyn `csc.exe`, supporting
|
|
55
|
+
`/deterministic` and `/pathmap`. `vswhere` discovers current releases, including
|
|
56
|
+
Visual Studio 2026; an explicit compiler path is also supported.
|
|
57
|
+
2. The .NET Framework **4.6.2 targeting pack** (reference assemblies, not a new
|
|
58
|
+
runtime). The script reports missing prerequisites; it installs nothing.
|
|
59
|
+
3. Windows PowerShell 5.1 or PowerShell 7 for the build script only.
|
|
60
|
+
|
|
61
|
+
From the repository root:
|
|
62
|
+
|
|
63
|
+
```powershell
|
|
64
|
+
# Update executable and metadata after source changes.
|
|
65
|
+
.\tools\windows-security-helper\build.ps1
|
|
66
|
+
|
|
67
|
+
# Read-only source/toolchain/hash check plus byte-for-byte rebuild.
|
|
68
|
+
.\tools\windows-security-helper\build.ps1 -Verify
|
|
69
|
+
|
|
70
|
+
# Select an already-installed compiler explicitly when reproducing recorded bytes.
|
|
71
|
+
.\tools\windows-security-helper\build.ps1 -Verify -CompilerPath 'C:\path\to\Roslyn\csc.exe'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The build uses `/noconfig /nostdlib+` with explicit `mscorlib.dll` and `System.dll`
|
|
75
|
+
references, fixed options, LF-normalized UTF-8 source, and mapped source paths.
|
|
76
|
+
It compiles twice under different temporary paths and refuses unequal bytes.
|
|
77
|
+
Temporary build files are removed by explicit path. No `dotnet build`, runtime
|
|
78
|
+
Framework compiler, NuGet restore, or external binary is involved.
|
|
79
|
+
|
|
80
|
+
`PoolingSecurityHelper.build.json` records compiler version/hash, reference
|
|
81
|
+
hashes, options, normalized source/build-script hashes, and executable SHA256.
|
|
82
|
+
`-Verify` checks all inputs and requires rebuilt bytes to match the packaged
|
|
83
|
+
binary. Verification never updates the binary or metadata.
|
|
84
|
+
|
|
85
|
+
Reproducibility is scoped to the **recorded compiler and references**. A newer
|
|
86
|
+
CI image/compiler may produce different bytes; do not treat a changed toolchain
|
|
87
|
+
as verification of the old binary. Use the recorded installed toolchain or
|
|
88
|
+
explicitly rebuild, review, and validate the updated assets. This is integrity
|
|
89
|
+
checking, not code signing or protection against someone replacing all assets.
|
|
90
|
+
|
|
91
|
+
The published executable must contain only the production sources above.
|
|
92
|
+
Audit-denial/delay test variants belong in disposable directories outside `bin/`
|
|
93
|
+
and must never be included in an npm tarball. Release validation must also run
|
|
94
|
+
the real package on supported Windows/Node versions and verify packaged file
|
|
95
|
+
inclusion. This build alone does not establish those platform results.
|