@pandanpc/mcp-server 0.1.45 → 0.1.47

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
@@ -70,12 +70,12 @@ Account operations require `note_login` when not already authenticated. Reading
70
70
 
71
71
  ## Automatic runtime updates
72
72
 
73
- The npm launcher starts the last verified runtime immediately and checks for a newer stable release in a separate background process (at most once every six hours; failed checks back off for 15 minutes). It downloads the platform binary into the user's cache, verifies SHA-256, and runs a bounded startup check before atomically selecting it for the next launch. Current MCP sessions are never restarted or replaced. Offline or failed updates keep the existing runtime usable.
73
+ The npm launcher starts the last verified runtime immediately and checks for a newer stable release in a separate background process (about once an hour with random jitter; failed checks back off exponentially from 15 minutes up to 6 hours). It downloads the platform binary into the user's cache, verifies SHA-256 and runs a bounded startup check, which stages it as a candidate. The launcher stays between the MCP client and the runtime, so a running session switches to the candidate without a restart: it completes a real MCP handshake with the client's own initialize request, waits for in-flight requests to finish (new requests are queued in order), then closes the old runtime. A new runtime becomes the default for future sessions only after it has served a tool call or run for a minute; one that fails to start, times out or crashes before that is skipped with a growing backoff, and a session whose new runtime crashes, before or after that point, goes back to the previous runtime automatically (requests that were in flight get an error and are not replayed). The crashed version is not retried in that session and is skipped with the same backoff; if it had already become the default, later sessions start from the runtime it replaced. If the new version changes the tool list, Claude Code sessions switch and receive `notifications/tools/list_changed`; other clients keep the current runtime and use the new one from their next session. A change to the server instructions always waits for the next session, because clients cannot refresh instructions mid-session; a switch is also postponed while the client is not reading the launcher's output. The launcher always advertises `tools.listChanged` to the client, even when the runtime does not declare it (runtimes 0.1.45 and earlier never do), so a later switch can still refresh the tool list. Offline or failed updates keep the existing runtime usable. Sessions that were started by an older launcher (0.1.45 or earlier) cannot switch in place: after upgrading the npm package, reconnect the MCP server (or restart the session) once; later releases then apply to running sessions automatically.
74
74
 
75
75
  The cache defaults to `~/.pandanpc-mcp/runtime/<platform>-<arch>` and does not require write access to a global npm installation. Each version uses its own file, including on Windows. Older cached executables are retained because another session may still use them; remove the cache only after closing those sessions if you want to reclaim disk space.
76
76
 
77
77
  - `pandanpc-mcp --version` shows the selected runtime and npm launcher versions.
78
- - `pandanpc-mcp --update` checks/downloads now; reconnect afterwards to use the result. Status messages go to stderr, never the MCP protocol stream.
78
+ - `pandanpc-mcp --update` checks/downloads now; running sessions pick up the result within a minute when compatible. Status messages go to stderr, never the MCP protocol stream.
79
79
  - Set `PANDANPC_MCP_AUTO_UPDATE=0` in the MCP client's environment to disable checks and pin the runtime bundled with the installed npm version.
80
80
  - `PANDANPC_MCP_CACHE_DIR` optionally selects a different user-writable cache directory.
81
81
 
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * bin entry: spawn the platform-specific pandanpc-mcp binary,
4
- * forwarding all arguments and stdio streams.
3
+ * bin entry: run the platform-specific pandanpc-mcp binary behind a stdio proxy that can swap in
4
+ * a newer verified runtime without restarting the MCP session (see proxy.js).
5
5
  */
6
6
 
7
7
  const path = require('path')
@@ -9,36 +9,83 @@ const { spawn } = require('child_process')
9
9
  const fs = require('fs')
10
10
  const { binaryName } = require('./binary-name')
11
11
  const pkg = require('./package.json')
12
- const { selectRuntime, startBackgroundUpdate, checkForUpdates } = require('./updater')
12
+ const updater = require('./updater')
13
+ const { McpProxy } = require('./proxy')
14
+ const { selectRuntime, startBackgroundUpdate, checkForUpdates } = updater
13
15
 
14
16
  const binName = binaryName()
15
17
  const bundledPath = path.join(__dirname, 'bin', binName)
16
18
  const runtime = selectRuntime()
19
+ const autoUpdate = process.env.PANDANPC_MCP_AUTO_UPDATE !== '0'
20
+ const args = process.argv.slice(2)
17
21
  let child
18
22
 
19
- if (process.argv[2] === '--version') {
23
+ if (args[0] === '--version') {
20
24
  console.log(`pandanpc-mcp ${runtime.version} (launcher ${pkg.version})`)
21
25
  process.exit(0)
22
26
  }
23
- if (process.argv[2] === '--update') {
27
+ if (args[0] === '--update') {
24
28
  checkForUpdates({ force: true }).then(result => {
25
29
  console.error(`[pandanpc-mcp] update: ${result.status}${result.version ? ` ${result.version}` : ''}${result.error ? ` (${result.error})` : ''}`)
26
30
  if (result.status === 'manual-update-required') console.error('Update the launcher: npm install -g @pandanpc/mcp-server@latest')
31
+ if (result.status === 'ready') console.error('Running sessions switch to it automatically when compatible')
27
32
  process.exit(result.status === 'failed' ? 1 : 0)
28
33
  }).catch(error => { console.error(error.message); process.exit(1) })
29
- } else {
34
+ } else if (args.includes('--http') || args.some(a => a.startsWith('--engine-agent'))) {
35
+ // Not an stdio MCP session: nothing to proxy.
30
36
  launch(runtime)
37
+ } else {
38
+ proxy()
31
39
  startBackgroundUpdate()
32
40
  // Keep long-lived sessions aware of releases without holding the process open.
33
41
  setInterval(startBackgroundUpdate, 15 * 60 * 1000).unref()
34
42
  }
35
43
 
44
+ function proxy() {
45
+ const bundled = { file: bundledPath, version: pkg.version }
46
+ let first = runtime, fallback = null
47
+ const candidate = autoUpdate ? updater.candidateRuntime() : null
48
+ if (candidate) { first = candidate; fallback = runtime }
49
+ if (!fs.existsSync(first.file)) {
50
+ console.error(`[pandanpc-mcp] binary not found at ${first.file}; reinstall @pandanpc/mcp-server`)
51
+ process.exit(1)
52
+ }
53
+ if (!fallback && runtime.file !== bundledPath && fs.existsSync(bundledPath)) fallback = bundled
54
+ const instance = new McpProxy({
55
+ input: process.stdin, output: process.stdout, args,
56
+ log: message => console.error(`[pandanpc-mcp] ${message}`),
57
+ promote: selected => { try { updater.promoteCandidate(selected) } catch {} },
58
+ recordFailure: (version, reason) => updater.recordFailure(updater.cacheRoot(), version, reason),
59
+ onExit: ({ code, signal, error }) => {
60
+ if (error && !signal) console.error(`[pandanpc-mcp] failed to spawn: ${error.message}`)
61
+ if (signal) {
62
+ process.removeAllListeners(signal)
63
+ process.kill(process.pid, signal)
64
+ }
65
+ process.exitCode = code ?? (error ? 1 : 0)
66
+ process.stdout.write('', () => process.exit(process.exitCode))
67
+ },
68
+ })
69
+ instance.start(first, fallback)
70
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
71
+ try { process.on(signal, () => instance.shutdown(signal)) } catch {}
72
+ }
73
+ if (!autoUpdate) return
74
+ // Pick up a runtime the background updater downloaded while this session is running.
75
+ setInterval(() => {
76
+ const version = updater.peekCandidateVersion()
77
+ if (!version || !instance.ready || instance.attempted.has(version) || version === instance.version) return
78
+ const next = updater.candidateRuntime()
79
+ if (next) instance.hotSwap(next)
80
+ }, 60 * 1000).unref()
81
+ }
82
+
36
83
  function launch(selected) {
37
84
  if (!fs.existsSync(selected.file)) {
38
85
  console.error(`[pandanpc-mcp] binary not found at ${selected.file}; reinstall @pandanpc/mcp-server`)
39
86
  process.exit(1)
40
87
  }
41
- child = spawn(selected.file, process.argv.slice(2), {
88
+ child = spawn(selected.file, args, {
42
89
  stdio: 'inherit', windowsHide: true,
43
90
  env: { ...process.env, PANDANPC_MCP_RUNTIME_VERSION: selected.version },
44
91
  })
@@ -55,6 +102,6 @@ function launch(selected) {
55
102
  process.exit(1)
56
103
  }
57
104
  })
105
+ process.on('SIGINT', () => { if (child) child.kill('SIGINT') })
106
+ process.on('SIGTERM', () => { if (child) child.kill('SIGTERM') })
58
107
  }
59
- process.on('SIGINT', () => { if (child) child.kill('SIGINT') })
60
- process.on('SIGTERM', () => { if (child) child.kill('SIGTERM') })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pandanpc/mcp-server",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "PandaNpc MCP Server — PandaNote and Agent connection management for Codex, Claude Code, and other MCP clients",
5
5
  "keywords": [
6
6
  "mcp",
@@ -20,6 +20,7 @@
20
20
  "pandanpc-mcp": "index.js"
21
21
  },
22
22
  "files": [
23
+ "proxy.js",
23
24
  "binary-name.js",
24
25
  "index.js",
25
26
  "install.js",
package/proxy.js ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Long-lived stdio proxy between the MCP client and the Rust runtime.
3
+ *
4
+ * It lets a newer verified runtime take over a live session: the candidate is started beside the
5
+ * current one, handshaken with the client's own initialize request, and only swapped in once the
6
+ * session is idle and every in-flight request (both directions) has completed. Client traffic that
7
+ * arrives meanwhile is queued in order. The old runtime is closed through stdin EOF so it releases
8
+ * its memory sync lease. A new runtime stays on probation until it has served a tool call or run
9
+ * for a while; only then is it promoted to the default, and if it crashes first the proxy goes
10
+ * back to the runtime it replaced within the same session.
11
+ */
12
+ const { spawn, spawnSync } = require('child_process')
13
+
14
+ const CLAUDE_CODE = /^claude[-_ ]?code/i
15
+ const INTERNAL = 'pandanpc-proxy:'
16
+
17
+ /** Splits a byte stream into JSON-RPC lines; tolerates split chunks, CRLF and split UTF-8 sequences. */
18
+ class LineReader {
19
+ constructor(onLine) { this.parts = []; this.onLine = onLine }
20
+ push(chunk) {
21
+ let start = 0
22
+ for (let i = chunk.indexOf(10); i !== -1; i = chunk.indexOf(10, start)) {
23
+ const piece = chunk.subarray(start, i)
24
+ const line = this.parts.length ? Buffer.concat([...this.parts, piece]) : piece
25
+ this.parts = []
26
+ start = i + 1
27
+ let end = line.length
28
+ if (end && line[end - 1] === 13) end--
29
+ const text = line.toString('utf8', 0, end)
30
+ if (text.trim()) this.onLine(text)
31
+ }
32
+ if (start < chunk.length) this.parts.push(chunk.subarray(start))
33
+ }
34
+ }
35
+
36
+ const idKey = id => JSON.stringify(id)
37
+ const isRequest = m => m && typeof m === 'object' && !Array.isArray(m) && typeof m.method === 'string' && m.id !== undefined && m.id !== null
38
+ const isNotification = m => m && typeof m === 'object' && !Array.isArray(m) && typeof m.method === 'string' && m.id === undefined
39
+ const isResponse = m => m && typeof m === 'object' && !Array.isArray(m) && m.method === undefined && m.id !== undefined
40
+ const progressToken = m => m?.params?._meta?.progressToken
41
+ function canonical(value) {
42
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
43
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`
44
+ return JSON.stringify(value)
45
+ }
46
+ const toolsFingerprint = tools => canonical([...tools].sort((a, b) => String(a.name).localeCompare(String(b.name))))
47
+ const timeout = (ms, message) => { let timer; const p = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms) }); p.catch(() => {}); p.cancel = () => clearTimeout(timer); return p }
48
+
49
+ function spawnProcess(runtime, args) {
50
+ return spawn(runtime.file, args, {
51
+ stdio: ['pipe', 'pipe', 'inherit'], windowsHide: true,
52
+ env: { ...process.env, PANDANPC_MCP_RUNTIME_VERSION: runtime.version },
53
+ })
54
+ }
55
+ /**
56
+ * Force-stops a runtime that ignored stdin EOF. On Windows the npm-cache binary re-launches itself
57
+ * from a private copy and waits for it, so the server may sit one level below; kill those two
58
+ * levels only. Engine agent workers are meant to outlive the MCP server and are never killed.
59
+ */
60
+ function killTree(proc) {
61
+ if (!proc.pid) return
62
+ if (process.platform === 'win32') {
63
+ const query = `Get-CimInstance Win32_Process -Filter "ParentProcessId=${Number(proc.pid)}" | Where-Object { $_.CommandLine -notmatch '--engine-agent-worker' } | ForEach-Object { $_.ProcessId }`
64
+ const found = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', query], { windowsHide: true, encoding: 'utf8', timeout: 10000 })
65
+ const pids = String(found.stdout || '').split(/\s+/).filter(p => /^\d+$/.test(p))
66
+ for (const pid of [proc.pid, ...pids]) spawnSync('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true, stdio: 'ignore', timeout: 10000 })
67
+ } else {
68
+ try { proc.kill('SIGKILL') } catch {}
69
+ }
70
+ }
71
+
72
+ class McpProxy {
73
+ constructor(options) {
74
+ this.input = options.input
75
+ this.output = options.output
76
+ this.args = options.args || []
77
+ this.spawnProcess = options.spawnProcess || spawnProcess
78
+ this.killTree = options.killTree || killTree
79
+ this.log = options.log || (() => {})
80
+ this.onExit = options.onExit || (() => {})
81
+ this.promote = options.promote || (() => {})
82
+ this.recordFailure = options.recordFailure || (() => {})
83
+ this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 15000
84
+ this.idleWaitMs = options.idleWaitMs ?? 30000
85
+ this.drainTimeoutMs = options.drainTimeoutMs ?? 5000
86
+ this.exitTimeoutMs = options.exitTimeoutMs ?? 10000
87
+ this.probationMs = options.probationMs ?? 60000
88
+ this.busyBackoffMs = options.busyBackoffMs ?? 2 * 60 * 1000
89
+ this.now = options.now || Date.now
90
+ this.generation = 0
91
+ this.nextInternal = 0
92
+ this.clientPending = new Map() // client -> runtime request id -> { rt, method }
93
+ this.serverPending = new Map() // runtime -> client request id -> { rt }
94
+ this.progressOwners = new Map() // progress token of a runtime -> client request -> runtime
95
+ this.queue = []
96
+ this.frozen = false
97
+ this.switching = false
98
+ this.attempted = new Set()
99
+ this.busyCount = 0
100
+ this.retryAt = 0
101
+ this.clientInit = null
102
+ this.clientInitialized = null
103
+ this.serverInit = null
104
+ this.exiting = false
105
+ this.drainWaiters = []
106
+ this.closing = new Set()
107
+ }
108
+
109
+ start(runtime, fallback) {
110
+ this.fallback = fallback || null
111
+ this.current = this.startRuntime(runtime)
112
+ this.current.trial = !!fallback
113
+ this.clientReader = new LineReader(line => this.fromClient(line))
114
+ this.input.on('data', chunk => this.clientReader.push(chunk))
115
+ this.input.on('end', () => this.shutdown())
116
+ this.input.on('error', () => this.shutdown())
117
+ this.output.on('error', () => this.shutdown())
118
+ }
119
+ get version() { return this.current?.runtime.version }
120
+ get ready() { return !!(this.serverInit && this.clientInitialized && !this.exiting && !this.restarting) }
121
+ isClaudeCode() { return CLAUDE_CODE.test(String(this.clientInit?.params?.clientInfo?.name || '')) }
122
+
123
+ startRuntime(runtime) {
124
+ const rt = { runtime, generation: ++this.generation, internal: new Map(), closing: false, alive: true, startedAt: this.now() }
125
+ rt.proc = this.spawnProcess(runtime, this.args)
126
+ const reader = new LineReader(line => this.fromRuntime(rt, line))
127
+ rt.proc.stdout.on('data', chunk => reader.push(chunk))
128
+ rt.proc.stdin.on('error', () => {})
129
+ rt.exited = new Promise(resolve => {
130
+ let done = false
131
+ const finish = (code, signal) => {
132
+ if (done) return
133
+ done = true; rt.alive = false; rt.code = code; rt.signal = signal
134
+ clearTimeout(rt.probationTimer)
135
+ for (const waiter of rt.internal.values()) waiter.reject(new Error('runtime exited'))
136
+ rt.internal.clear()
137
+ resolve()
138
+ this.runtimeExited(rt)
139
+ }
140
+ rt.proc.on('close', finish)
141
+ rt.proc.on('error', error => { rt.spawnError = error; finish(null, null) })
142
+ })
143
+ return rt
144
+ }
145
+
146
+ send(rt, message) {
147
+ if (!rt.alive) return false
148
+ try { rt.proc.stdin.write(typeof message === 'string' ? `${message}\n` : `${JSON.stringify(message)}\n`); return true } catch { return false }
149
+ }
150
+ reply(message) {
151
+ try { this.output.write(`${typeof message === 'string' ? message : JSON.stringify(message)}\n`) } catch {}
152
+ }
153
+ /** Resolves true once everything already written to the client has been handed to the OS, false on timeout. */
154
+ outputFlushed(ms = this.drainTimeoutMs) {
155
+ const limit = timeout(ms, 'output flush timed out')
156
+ const written = new Promise(resolve => { try { this.output.write('', () => resolve()) } catch { resolve() } })
157
+ return Promise.race([written, limit]).then(() => true, () => false).finally(() => limit.cancel())
158
+ }
159
+ request(rt, method, params, ms = this.handshakeTimeoutMs) {
160
+ const id = `${INTERNAL}${rt.generation}:${++this.nextInternal}`
161
+ const limit = timeout(ms, `${method} timed out`)
162
+ const answer = new Promise((resolve, reject) => {
163
+ rt.internal.set(id, { resolve, reject })
164
+ if (!this.send(rt, { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) })) reject(new Error('runtime is not running'))
165
+ })
166
+ return Promise.race([answer, limit]).finally(() => { limit.cancel(); rt.internal.delete(id) })
167
+ }
168
+ /** Replays the client's initialize on another runtime; its response never reaches the client. */
169
+ async handshake(rt) {
170
+ const exited = rt.exited.then(() => { throw new Error(`exited during startup (${rt.spawnError?.message || rt.signal || rt.code})`) })
171
+ exited.catch(() => {})
172
+ return Promise.race([this.request(rt, 'initialize', this.clientInit.params), exited])
173
+ }
174
+
175
+ // ── client -> runtime ─────────────────────────────────────────────────
176
+ fromClient(line) {
177
+ let message
178
+ try { message = JSON.parse(line) } catch { message = undefined }
179
+ if (isRequest(message) && message.method === 'initialize' && !this.clientInit) {
180
+ this.clientInit = message
181
+ this.frozen = true // nothing else goes out until the runtime answers initialize
182
+ this.initKey = idKey(message.id)
183
+ this.forwardRequest(this.current, message)
184
+ if (this.current.trial) this.watchTrial(this.current)
185
+ return
186
+ }
187
+ // Before initialize the runtime only accepts initialize: it exits on anything else. Clients may
188
+ // probe first (Claude Code sends `server/discover`), so answer such requests here and drop the rest.
189
+ if (!this.clientInit && this.initKey == null) {
190
+ if (isRequest(message)) this.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } })
191
+ return
192
+ }
193
+ if (isResponse(message)) return this.routeResponse(message)
194
+ if (isNotification(message) && message.method === 'notifications/cancelled') return this.routeCancel(message, line)
195
+ if (isNotification(message) && message.method === 'notifications/progress') {
196
+ // Client progress only refers to a runtime's own requests: it goes to that runtime or nowhere,
197
+ // never to a successor.
198
+ const owner = this.progressOwners.get(idKey(message.params?.progressToken))
199
+ if (owner?.alive && !owner.closing) this.send(owner, line)
200
+ return
201
+ }
202
+ if (this.frozen) return this.queue.push({ line, message })
203
+ this.dispatch(line, message)
204
+ }
205
+ dispatch(line, message) {
206
+ if (isNotification(message) && message.method === 'notifications/initialized') this.clientInitialized = message
207
+ if (isRequest(message)) return this.forwardRequest(this.current, message, line)
208
+ this.send(this.current, line)
209
+ }
210
+ forwardRequest(rt, message, line) {
211
+ this.clientPending.set(idKey(message.id), { rt, method: message.method })
212
+ if (!this.send(rt, line || message)) this.failRequest(message.id, 'PandaNpc MCP runtime is not running')
213
+ }
214
+ routeResponse(message) {
215
+ const key = idKey(message.id)
216
+ const entry = this.serverPending.get(key)
217
+ this.serverPending.delete(key)
218
+ if (entry) {
219
+ if (entry.rt.alive) this.send(entry.rt, message)
220
+ }
221
+ this.checkDrained()
222
+ }
223
+ routeCancel(message, line) {
224
+ const key = idKey(message.params?.requestId)
225
+ const entry = this.clientPending.get(key)
226
+ if (entry) return void this.send(entry.rt, line)
227
+ // A cancel for a queued request stays in order behind it; one for a finished or
228
+ // previous-runtime request has nothing left to cancel.
229
+ if (this.queue.some(q => isRequest(q.message) && idKey(q.message.id) === key)) this.queue.push({ line, message })
230
+ }
231
+ flush() {
232
+ this.frozen = false
233
+ const queued = this.queue
234
+ this.queue = []
235
+ for (const { line, message } of queued) {
236
+ if (this.frozen) { this.queue.push({ line, message }); continue }
237
+ this.dispatch(line, message)
238
+ }
239
+ }
240
+ failRequest(id, text) {
241
+ this.clientPending.delete(idKey(id))
242
+ this.reply({ jsonrpc: '2.0', id, error: { code: -32603, message: text } })
243
+ }
244
+
245
+ // ── runtime -> client ─────────────────────────────────────────────────
246
+ fromRuntime(rt, line) {
247
+ let message
248
+ try { message = JSON.parse(line) } catch { message = undefined }
249
+ if (isResponse(message) && typeof message.id === 'string' && rt.internal.has(message.id)) {
250
+ const waiter = rt.internal.get(message.id)
251
+ return message.error ? waiter.reject(new Error(message.error.message || 'request failed')) : waiter.resolve(message.result)
252
+ }
253
+ if (rt !== this.current) {
254
+ // A candidate being handshaken may not talk to the client; answer its requests itself.
255
+ if (isRequest(message)) this.send(rt, { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Not available during runtime handover' } })
256
+ return
257
+ }
258
+ if (isResponse(message)) {
259
+ const key = idKey(message.id)
260
+ const entry = this.clientPending.get(key)
261
+ this.clientPending.delete(key)
262
+ if (key === this.initKey && !this.serverInit) {
263
+ if (message.result) return this.initialized(rt, message)
264
+ if (rt.trial) return void this.abandonTrial(rt, 'initialize returned an error')
265
+ this.clientInit = null // let the client retry initialize
266
+ this.reply(line)
267
+ return this.flush()
268
+ }
269
+ if (entry?.method === 'tools/call' && message.result && !message.result.isError) this.confirm(rt)
270
+ this.reply(line)
271
+ return this.checkDrained()
272
+ }
273
+ if (isRequest(message)) {
274
+ const token = progressToken(message)
275
+ this.serverPending.set(idKey(message.id), { rt })
276
+ if (token !== undefined) {
277
+ // Late progress may follow the response, so owners are kept (bounded) rather than removed.
278
+ this.progressOwners.set(idKey(token), rt)
279
+ if (this.progressOwners.size > 1000) this.progressOwners.delete(this.progressOwners.keys().next().value)
280
+ }
281
+ }
282
+ this.reply(line)
283
+ }
284
+ initialized(rt, message) {
285
+ this.serverInit = message.result
286
+ // Advertise tools.listChanged so the client refreshes after a swap that changed the tool list.
287
+ const result = { ...message.result, capabilities: { ...message.result.capabilities } }
288
+ result.capabilities.tools = { ...(result.capabilities.tools || {}), listChanged: true }
289
+ if (rt.trial) {
290
+ rt.trial = false
291
+ rt.cancelTrial?.()
292
+ this.probation(rt, this.fallback)
293
+ this.fallback = null
294
+ } else if (this.fallback) {
295
+ rt.fallback = this.fallback
296
+ this.fallback = null
297
+ }
298
+ this.reply({ ...message, result })
299
+ this.flush()
300
+ }
301
+ /** A new runtime becomes the default only after it proved itself in this session. */
302
+ probation(rt, fallback) {
303
+ rt.fallback = fallback || null
304
+ rt.probation = true
305
+ clearTimeout(rt.probationTimer)
306
+ rt.probationTimer = setTimeout(() => this.confirm(rt), this.probationMs)
307
+ rt.probationTimer.unref?.()
308
+ }
309
+ confirm(rt) {
310
+ if (!rt.probation || !rt.alive || rt !== this.current) return
311
+ rt.probation = false
312
+ clearTimeout(rt.probationTimer)
313
+ this.promote(rt.runtime)
314
+ }
315
+ /** A startup candidate that dies or stalls before answering initialize is replaced by the fallback. */
316
+ watchTrial(rt) {
317
+ const limit = timeout(this.handshakeTimeoutMs, 'initialize timed out')
318
+ limit.catch(() => { if (rt.trial && rt === this.current) this.abandonTrial(rt, 'initialize timed out') })
319
+ rt.exited.then(() => limit.cancel())
320
+ rt.cancelTrial = limit.cancel
321
+ }
322
+ abandonTrial(rt, reason) {
323
+ if (!rt.trial || !this.fallback || this.exiting) return false
324
+ rt.trial = false
325
+ rt.cancelTrial?.()
326
+ this.log(`New runtime ${rt.runtime.version} failed to start (${reason}); using ${this.fallback.version}`)
327
+ this.recordFailure(rt.runtime.version, reason)
328
+ this.attempted.add(rt.runtime.version)
329
+ this.closeInBackground(rt)
330
+ this.clientPending.delete(this.initKey)
331
+ const next = this.startRuntime(this.fallback)
332
+ this.fallback = null
333
+ this.current = next
334
+ if (this.clientInit) this.forwardRequest(next, this.clientInit)
335
+ return true
336
+ }
337
+
338
+ // ── lifecycle ─────────────────────────────────────────────────────────
339
+ runtimeExited(rt) {
340
+ if (rt.closing || this.exiting) return
341
+ if (rt !== this.current) return
342
+ const reason = rt.spawnError ? rt.spawnError.message : `exit ${rt.signal || rt.code}`
343
+ if (rt.trial && this.abandonTrial(rt, reason)) return
344
+ // Never replay requests on another runtime: they may not be idempotent.
345
+ for (const [key, entry] of [...this.clientPending]) {
346
+ if (entry.rt === rt) this.failRequest(JSON.parse(key), 'PandaNpc MCP runtime exited before responding')
347
+ }
348
+ for (const [key, entry] of [...this.serverPending]) {
349
+ if (entry.rt === rt) this.serverPending.delete(key)
350
+ }
351
+ const fallback = rt.fallback && this.serverInit && this.clientInitialized
352
+ // Going back to the previous runtime always retires the crashed one, even after it was confirmed:
353
+ // otherwise the watcher would switch to it again and a reproducible crash would loop.
354
+ if (rt.probation || fallback) {
355
+ this.attempted.add(rt.runtime.version)
356
+ this.recordFailure(rt.runtime.version, `${rt.probation ? 'stopped before it was confirmed' : 'stopped after it was confirmed'} (${reason})`)
357
+ }
358
+ this.log(`PandaNpc MCP runtime ${rt.runtime.version} stopped (${reason})`)
359
+ if (fallback) return void this.restart(rt, rt.fallback)
360
+ this.finish(rt.code, rt.signal, rt.spawnError)
361
+ }
362
+ /** Brings back the runtime a crashed one replaced, re-using the cached handshake. */
363
+ async restart(crashed, runtime) {
364
+ this.restarting = true
365
+ this.frozen = true
366
+ let next
367
+ try {
368
+ next = this.startRuntime(runtime)
369
+ const limit = timeout(this.handshakeTimeoutMs, 'initialize timed out')
370
+ try { await Promise.race([this.handshake(next), limit]) } finally { limit.cancel() }
371
+ this.send(next, this.clientInitialized)
372
+ if (this.exiting) return
373
+ this.current = next
374
+ this.log(`PandaNpc MCP went back to ${runtime.version} after ${crashed.runtime.version} stopped`)
375
+ this.flush()
376
+ if (this.isClaudeCode()) this.reply({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' })
377
+ } catch (error) {
378
+ this.log(`PandaNpc MCP could not restart ${runtime.version} (${error.message})`)
379
+ if (next) this.closeInBackground(next)
380
+ this.finish(crashed.code || 1, null)
381
+ } finally { this.restarting = false }
382
+ }
383
+ async closeRuntime(rt, signal) {
384
+ rt.closing = true
385
+ clearTimeout(rt.probationTimer)
386
+ if (!rt.alive) return
387
+ try { rt.proc.stdin.end() } catch {}
388
+ if (signal && process.platform !== 'win32') { try { rt.proc.kill(signal) } catch {} }
389
+ const limit = timeout(this.exitTimeoutMs, 'exit timed out')
390
+ try { await Promise.race([rt.exited, limit]) } catch {
391
+ this.log(`PandaNpc MCP runtime ${rt.runtime.version} did not exit; terminating it`)
392
+ this.killTree(rt.proc)
393
+ const last = timeout(5000, 'kill timed out')
394
+ await Promise.race([rt.exited, last]).catch(() => {})
395
+ last.cancel()
396
+ } finally { limit.cancel() }
397
+ }
398
+ closeInBackground(rt) {
399
+ const closing = this.closeRuntime(rt).finally(() => this.closing.delete(closing))
400
+ this.closing.add(closing)
401
+ }
402
+ async shutdown(signal) {
403
+ if (this.exiting) return
404
+ this.exiting = true
405
+ const rt = this.current
406
+ await Promise.all([this.candidate && this.closeRuntime(this.candidate), rt && this.closeRuntime(rt, signal), ...this.closing])
407
+ this.finish(rt?.code, rt?.signal)
408
+ }
409
+ finish(code, signal, error) {
410
+ if (this.finished) return
411
+ this.finished = true
412
+ this.exiting = true
413
+ this.onExit({ code, signal, error })
414
+ }
415
+
416
+ // ── hot swap ──────────────────────────────────────────────────────────
417
+ pendingFor(rt) {
418
+ for (const entry of this.clientPending.values()) if (entry.rt === rt) return true
419
+ for (const entry of this.serverPending.values()) if (entry.rt === rt) return true
420
+ return rt.internal.size > 0
421
+ }
422
+ checkDrained() {
423
+ const waiters = this.drainWaiters
424
+ this.drainWaiters = []
425
+ for (const w of waiters) { if (!this.pendingFor(w.rt)) w.resolve(true); else this.drainWaiters.push(w) }
426
+ }
427
+ /** Resolves true once `rt` has nothing in flight, false after `ms`. */
428
+ idle(rt, ms) {
429
+ if (!this.pendingFor(rt)) return Promise.resolve(true)
430
+ return new Promise(resolve => {
431
+ const waiter = { rt, resolve: value => { clearTimeout(timer); resolve(value) } }
432
+ const timer = setTimeout(() => { this.drainWaiters = this.drainWaiters.filter(w => w !== waiter); resolve(false) }, ms)
433
+ this.drainWaiters.push(waiter)
434
+ })
435
+ }
436
+ async listTools(rt) {
437
+ const tools = []
438
+ let cursor
439
+ for (let page = 0; page < 50; page++) {
440
+ const result = await this.request(rt, 'tools/list', cursor ? { cursor } : {})
441
+ tools.push(...(result?.tools || []))
442
+ cursor = result?.nextCursor
443
+ if (!cursor) break
444
+ }
445
+ return tools
446
+ }
447
+ compatible(result) {
448
+ if (!result || result.protocolVersion !== this.serverInit.protocolVersion) return 'protocol version differs'
449
+ // tools.listChanged is ignored: the proxy always advertises it to the client (see initialized()),
450
+ // so a runtime that starts or stops declaring it does not change what the client sees.
451
+ const strip = caps => { const copy = JSON.parse(JSON.stringify(caps || {})); if (copy.tools) delete copy.tools.listChanged; return canonical(copy) }
452
+ return strip(result.capabilities) === strip(this.serverInit.capabilities) ? null : 'capabilities differ'
453
+ }
454
+ busy() {
455
+ this.busyCount++
456
+ this.retryAt = this.now() + Math.min(this.busyBackoffMs * 2 ** Math.min(this.busyCount - 1, 30), 60 * 60 * 1000)
457
+ return 'busy'
458
+ }
459
+ /**
460
+ * Returns 'switched', 'deferred' (kept for a later session), 'busy' (retry later), 'failed' or
461
+ * 'skipped'. Never loses or reorders client traffic; the old runtime stays in place on failure.
462
+ */
463
+ async hotSwap(runtime) {
464
+ if (!this.ready || this.switching || this.attempted.has(runtime.version) || runtime.version === this.version) return 'skipped'
465
+ if (this.now() < this.retryAt) return 'skipped'
466
+ this.switching = true
467
+ let next
468
+ try {
469
+ next = this.candidate = this.startRuntime(runtime)
470
+ const start = timeout(this.handshakeTimeoutMs, 'initialize timed out')
471
+ let init
472
+ try { init = await Promise.race([this.handshake(next), start]) } finally { start.cancel() }
473
+ const problem = this.compatible(init)
474
+ if (problem) {
475
+ this.attempted.add(runtime.version)
476
+ this.log(`PandaNpc MCP ${runtime.version} is not compatible with this session (${problem}); it will be used by the next session`)
477
+ return 'deferred'
478
+ }
479
+ this.send(next, this.clientInitialized)
480
+ const [before, after] = await Promise.all([this.listTools(this.current), this.listTools(next)])
481
+ const toolsChanged = toolsFingerprint(before) !== toolsFingerprint(after)
482
+ const instructionsChanged = (init.instructions || '') !== (this.serverInit.instructions || '')
483
+ if (instructionsChanged) this.log(`PandaNpc MCP ${runtime.version} changes the server instructions`)
484
+ // No client can refresh instructions mid-session, so an instructions change always waits for the next session.
485
+ if (instructionsChanged || (toolsChanged && !this.isClaudeCode())) {
486
+ this.attempted.add(runtime.version)
487
+ this.log(`PandaNpc MCP ${runtime.version} changes the ${instructionsChanged ? 'instructions' : 'tool list'}; it will be used by the next session`)
488
+ return 'deferred'
489
+ }
490
+ const old = this.current
491
+ // Freeze only at an idle moment, and only briefly: a busy session is retried later.
492
+ if (!await this.idle(old, this.idleWaitMs)) return this.busy()
493
+ this.frozen = true
494
+ if (!await this.idle(old, this.drainTimeoutMs)) { this.flush(); return this.busy() }
495
+ // A client that is not reading stdout keeps the old runtime; unfreeze and retry later.
496
+ if (!await this.outputFlushed()) { this.flush(); return this.busy() }
497
+ if (!next.alive || this.exiting) throw new Error('new runtime exited before handover')
498
+ if (old !== this.current) throw new Error('runtime changed during handover')
499
+ this.current = next
500
+ this.candidate = null
501
+ this.serverInit = { ...this.serverInit, ...init }
502
+ this.probation(next, old.runtime)
503
+ this.closeInBackground(old) // stdin EOF: the old runtime stops sync and releases its lease
504
+ this.flush()
505
+ if (toolsChanged) this.reply({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' })
506
+ this.busyCount = 0
507
+ this.log(`PandaNpc MCP switched to ${runtime.version} without restarting the session`)
508
+ return 'switched'
509
+ } catch (error) {
510
+ this.attempted.add(runtime.version)
511
+ this.recordFailure(runtime.version, error.message)
512
+ this.log(`PandaNpc MCP ${runtime.version} could not take over (${error.message}); keeping ${this.version}`)
513
+ if (this.frozen && !this.exiting && this.current.alive && !this.restarting) this.flush()
514
+ return 'failed'
515
+ } finally {
516
+ if (this.candidate) { const c = this.candidate; this.candidate = null; await this.closeRuntime(c) }
517
+ this.switching = false
518
+ }
519
+ }
520
+ }
521
+
522
+ module.exports = { McpProxy, LineReader, toolsFingerprint, killTree }
package/updater.js CHANGED
@@ -9,8 +9,13 @@ const { fetchText } = require('./network')
9
9
  const pkg = require('./package.json')
10
10
 
11
11
  const PROTOCOL = 1
12
- const INTERVAL = 6 * 60 * 60 * 1000
12
+ const INTERVAL = 60 * 60 * 1000
13
+ const JITTER = 10 * 60 * 1000
13
14
  const RETRY_INTERVAL = 15 * 60 * 1000
15
+ const MAX_RETRY_INTERVAL = 6 * 60 * 60 * 1000
16
+ // A candidate that fails a real handshake is not retried for 1h, 4h, 16h... capped at a week.
17
+ const FAILURE_BACKOFF = 60 * 60 * 1000
18
+ const MAX_FAILURE_BACKOFF = 7 * 24 * 60 * 60 * 1000
14
19
  const REGISTRY = 'https://registry.npmjs.org/@pandanpc%2fmcp-server/latest'
15
20
  const quiet = { log() {}, warn() {} }
16
21
 
@@ -34,27 +39,82 @@ function writeJson(file, value) {
34
39
  try { fs.writeFileSync(temp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(temp, file) }
35
40
  finally { try { fs.unlinkSync(temp) } catch {} }
36
41
  }
37
- function activeRuntime(root, bundledVersion = pkg.version, platform = process.platform) {
42
+ function jitter(random = Math.random) { return Math.floor(random() * JITTER) }
43
+ function successDelay(random) { return INTERVAL + jitter(random) }
44
+ function failureDelay(failures, random) { return Math.min(RETRY_INTERVAL * 2 ** Math.max(0, failures - 1), MAX_RETRY_INTERVAL) + jitter(random) }
45
+ // Launchers re-check the candidate every minute; hash a given file version only once.
46
+ const hashes = new Map()
47
+ function hashOf(file, stat) {
48
+ const key = `${file}|${stat.mtimeMs}|${stat.size}|${stat.ino}`
49
+ if (!hashes.has(key)) hashes.set(key, crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'))
50
+ return hashes.get(key)
51
+ }
52
+ // Verifies a pointer (active.json / candidate.json entry) and returns the runnable runtime or null.
53
+ function verifyEntry(root, entry, bundledVersion, platform) {
38
54
  try {
39
- const active = readJson(path.join(root, 'active.json'))
55
+ const active = entry
40
56
  if (!active || !versionParts(active.version) || compareVersions(active.version, bundledVersion) <= 0) return null
41
57
  if (!/^[a-zA-Z0-9.-]+$/.test(active.directory) || !active.directory.startsWith(active.version + '-')) return null
42
58
  if (!/^[a-f0-9]{64}$/.test(active.checksum)) return null
43
59
  const file = path.join(root, active.directory, binaryName(platform, active.version))
44
60
  const stat = fs.lstatSync(file)
45
61
  if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024 * 1024) return null
46
- if (crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== active.checksum) return null
62
+ if (hashOf(file, stat) !== active.checksum) return null
47
63
  if (platform !== 'win32') fs.accessSync(file, fs.constants.X_OK)
48
- return { file, version: active.version }
64
+ return { file, version: active.version, directory: active.directory, checksum: active.checksum }
49
65
  } catch { return null }
50
66
  }
67
+ function failures(root) { return readJson(path.join(root, 'failed.json')) || {} }
68
+ function inBackoff(root, version, now = Date.now()) {
69
+ const entry = failures(root)[version]
70
+ return !!entry && Number.isFinite(entry.until) && entry.until > now
71
+ }
72
+ // A downloaded version that failed a real start/handshake; it is skipped until its backoff ends.
73
+ function recordFailure(root, version, reason, now = Date.now()) {
74
+ try {
75
+ const all = failures(root)
76
+ const count = (all[version]?.count || 0) + 1
77
+ all[version] = { count, until: now + Math.min(FAILURE_BACKOFF * 4 ** (count - 1), MAX_FAILURE_BACKOFF), reason: String(reason || '').slice(0, 200) }
78
+ writeJson(path.join(root, 'failed.json'), all)
79
+ } catch {}
80
+ }
81
+ function activeRuntime(root, bundledVersion = pkg.version, platform = process.platform, now = Date.now()) {
82
+ const active = readJson(path.join(root, 'active.json'))
83
+ if (!active) return null
84
+ if (!inBackoff(root, active.version, now)) {
85
+ const runtime = verifyEntry(root, active, bundledVersion, platform)
86
+ if (runtime) return runtime
87
+ }
88
+ // The promoted version crashed after taking over: fall back to the one it replaced.
89
+ return active.previous && !inBackoff(root, active.previous.version, now) ? verifyEntry(root, active.previous, bundledVersion, platform) : null
90
+ }
91
+ // Downloaded and probed, but not yet proven by a real MCP handshake.
92
+ function candidateRuntime({ root = cacheRoot(), bundledVersion = pkg.version, platform = process.platform, now = Date.now() } = {}) {
93
+ const candidate = readJson(path.join(root, 'candidate.json'))
94
+ if (!candidate || inBackoff(root, candidate.version, now)) return null
95
+ const current = activeRuntime(root, bundledVersion, platform, now)
96
+ if (!versionParts(candidate.version) || (current && compareVersions(candidate.version, current.version) <= 0)) return null
97
+ return verifyEntry(root, candidate, bundledVersion, platform)
98
+ }
99
+ function peekCandidateVersion(root = cacheRoot()) {
100
+ const candidate = readJson(path.join(root, 'candidate.json'))
101
+ return candidate && versionParts(candidate.version) ? candidate.version : null
102
+ }
103
+ // Called only after the candidate completed a real initialize handshake.
104
+ function promoteCandidate(runtime, { root = cacheRoot(), bundledVersion = pkg.version, platform = process.platform } = {}) {
105
+ const current = activeRuntime(root, bundledVersion, platform)
106
+ if (current && compareVersions(current.version, runtime.version) >= 0) return
107
+ const previous = current ? { version: current.version, directory: current.directory, checksum: current.checksum } : undefined
108
+ writeJson(path.join(root, 'active.json'), { version: runtime.version, directory: runtime.directory, checksum: runtime.checksum, ...(previous ? { previous } : {}) })
109
+ if (readJson(path.join(root, 'candidate.json'))?.version === runtime.version) { try { fs.unlinkSync(path.join(root, 'candidate.json')) } catch {} }
110
+ }
51
111
  function selectRuntime({ root = cacheRoot(), bundledVersion = pkg.version, platform = process.platform, disabled = process.env.PANDANPC_MCP_AUTO_UPDATE === '0' } = {}) {
52
112
  const bundled = { file: path.join(__dirname, 'bin', binaryName(platform, bundledVersion)), version: bundledVersion }
53
113
  return (!disabled && activeRuntime(root, bundledVersion, platform)) || bundled
54
114
  }
55
115
  function due(root, now = Date.now()) {
56
116
  const state = readJson(path.join(root, 'check.json'))
57
- return !state || !Number.isFinite(state.nextCheck) || state.nextCheck <= now || state.nextCheck > now + INTERVAL
117
+ return !state || !Number.isFinite(state.nextCheck) || state.nextCheck <= now || state.nextCheck > now + MAX_RETRY_INTERVAL + JITTER
58
118
  }
59
119
  function acquireLock(root, now) {
60
120
  const file = path.join(root, 'update.lock')
@@ -90,6 +150,7 @@ async function checkForUpdates(options = {}) {
90
150
  const platformInfo = options.platformInfo || detectPlatform()
91
151
  const fetchTextFn = options.fetchTextFn || fetchText
92
152
  const now = (options.now || Date.now)()
153
+ const random = options.random || Math.random
93
154
  fs.mkdirSync(root, { recursive: true, mode: 0o700 })
94
155
  if (!options.force && !due(root, now)) return { status: 'throttled' }
95
156
  const unlock = acquireLock(root, now)
@@ -97,17 +158,23 @@ async function checkForUpdates(options = {}) {
97
158
  let directory
98
159
  try {
99
160
  if (!options.force && !due(root, now)) return { status: 'throttled' }
100
- writeJson(path.join(root, 'check.json'), { nextCheck: now + RETRY_INTERVAL })
161
+ const failed = (readJson(path.join(root, 'check.json'))?.failures || 0) + 1
162
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + failureDelay(failed, random), failures: failed })
101
163
  const metadata = JSON.parse(await fetchTextFn(REGISTRY))
102
164
  if (!versionParts(metadata.version)) throw new Error('Invalid release version')
103
- const current = activeRuntime(root, bundledVersion, platform)
104
- const currentVersion = current?.version || bundledVersion
165
+ const current = activeRuntime(root, bundledVersion, platform, now)
166
+ const pending = candidateRuntime({ root, bundledVersion, platform, now })
167
+ const currentVersion = pending?.version || current?.version || bundledVersion
105
168
  if (compareVersions(metadata.version, currentVersion) <= 0) {
106
- writeJson(path.join(root, 'check.json'), { nextCheck: now + INTERVAL })
169
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + successDelay(random) })
107
170
  return { status: 'current', version: currentVersion }
108
171
  }
172
+ if (inBackoff(root, metadata.version, now)) {
173
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + successDelay(random) })
174
+ return { status: 'backoff', version: metadata.version }
175
+ }
109
176
  if (metadata.pandanpcUpdaterProtocol !== PROTOCOL) {
110
- writeJson(path.join(root, 'check.json'), { nextCheck: now + INTERVAL, manualUpdateRequired: metadata.version })
177
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + successDelay(random), manualUpdateRequired: metadata.version })
111
178
  return { status: 'manual-update-required', version: metadata.version }
112
179
  }
113
180
  const version = metadata.version
@@ -119,11 +186,12 @@ async function checkForUpdates(options = {}) {
119
186
  baseUrl: `https://cos.pandanpc.com/mcp-server/v${version}`, fetchTextFn,
120
187
  ...(options.downloadFn ? { downloadFn: options.downloadFn } : {}), logger: quiet })
121
188
  await (options.probeFn || probeBinary)(file)
122
- const newer = activeRuntime(root, bundledVersion, platform)
189
+ const newer = candidateRuntime({ root, bundledVersion, platform, now }) || activeRuntime(root, bundledVersion, platform, now)
123
190
  if (newer && compareVersions(newer.version, version) >= 0) return { status: 'current', version: newer.version }
124
- writeJson(path.join(root, 'active.json'), { version, directory: name, checksum: result.checksum })
191
+ // Only a candidate: a launcher promotes it to active.json after a real MCP handshake succeeds.
192
+ writeJson(path.join(root, 'candidate.json'), { version, directory: name, checksum: result.checksum })
125
193
  directory = null // published versions may be in use; never delete them automatically
126
- writeJson(path.join(root, 'check.json'), { nextCheck: now + INTERVAL })
194
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + successDelay(random) })
127
195
  return { status: 'ready', version }
128
196
  } catch (error) {
129
197
  return { status: 'failed', error: error.message }
@@ -142,4 +210,4 @@ function startBackgroundUpdate() {
142
210
  } catch {} // updates must never prevent an MCP session from starting
143
211
  }
144
212
  if (require.main === module) checkForUpdates().catch(() => {})
145
- module.exports = { compareVersions, selectRuntime, checkForUpdates, startBackgroundUpdate }
213
+ module.exports = { compareVersions, selectRuntime, checkForUpdates, startBackgroundUpdate, candidateRuntime, peekCandidateVersion, promoteCandidate, recordFailure, inBackoff, cacheRoot }