@pandanpc/mcp-server 0.1.45 → 0.1.46
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 +2 -2
- package/index.js +56 -9
- package/package.json +2 -1
- package/proxy.js +516 -0
- package/updater.js +83 -15
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 (
|
|
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;
|
|
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:
|
|
4
|
-
*
|
|
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
|
|
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 (
|
|
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 (
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.1.46",
|
|
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,516 @@
|
|
|
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
|
+
if (isResponse(message)) return this.routeResponse(message)
|
|
188
|
+
if (isNotification(message) && message.method === 'notifications/cancelled') return this.routeCancel(message, line)
|
|
189
|
+
if (isNotification(message) && message.method === 'notifications/progress') {
|
|
190
|
+
// Client progress only refers to a runtime's own requests: it goes to that runtime or nowhere,
|
|
191
|
+
// never to a successor.
|
|
192
|
+
const owner = this.progressOwners.get(idKey(message.params?.progressToken))
|
|
193
|
+
if (owner?.alive && !owner.closing) this.send(owner, line)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
if (this.frozen) return this.queue.push({ line, message })
|
|
197
|
+
this.dispatch(line, message)
|
|
198
|
+
}
|
|
199
|
+
dispatch(line, message) {
|
|
200
|
+
if (isNotification(message) && message.method === 'notifications/initialized') this.clientInitialized = message
|
|
201
|
+
if (isRequest(message)) return this.forwardRequest(this.current, message, line)
|
|
202
|
+
this.send(this.current, line)
|
|
203
|
+
}
|
|
204
|
+
forwardRequest(rt, message, line) {
|
|
205
|
+
this.clientPending.set(idKey(message.id), { rt, method: message.method })
|
|
206
|
+
if (!this.send(rt, line || message)) this.failRequest(message.id, 'PandaNpc MCP runtime is not running')
|
|
207
|
+
}
|
|
208
|
+
routeResponse(message) {
|
|
209
|
+
const key = idKey(message.id)
|
|
210
|
+
const entry = this.serverPending.get(key)
|
|
211
|
+
this.serverPending.delete(key)
|
|
212
|
+
if (entry) {
|
|
213
|
+
if (entry.rt.alive) this.send(entry.rt, message)
|
|
214
|
+
}
|
|
215
|
+
this.checkDrained()
|
|
216
|
+
}
|
|
217
|
+
routeCancel(message, line) {
|
|
218
|
+
const key = idKey(message.params?.requestId)
|
|
219
|
+
const entry = this.clientPending.get(key)
|
|
220
|
+
if (entry) return void this.send(entry.rt, line)
|
|
221
|
+
// A cancel for a queued request stays in order behind it; one for a finished or
|
|
222
|
+
// previous-runtime request has nothing left to cancel.
|
|
223
|
+
if (this.queue.some(q => isRequest(q.message) && idKey(q.message.id) === key)) this.queue.push({ line, message })
|
|
224
|
+
}
|
|
225
|
+
flush() {
|
|
226
|
+
this.frozen = false
|
|
227
|
+
const queued = this.queue
|
|
228
|
+
this.queue = []
|
|
229
|
+
for (const { line, message } of queued) {
|
|
230
|
+
if (this.frozen) { this.queue.push({ line, message }); continue }
|
|
231
|
+
this.dispatch(line, message)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
failRequest(id, text) {
|
|
235
|
+
this.clientPending.delete(idKey(id))
|
|
236
|
+
this.reply({ jsonrpc: '2.0', id, error: { code: -32603, message: text } })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── runtime -> client ─────────────────────────────────────────────────
|
|
240
|
+
fromRuntime(rt, line) {
|
|
241
|
+
let message
|
|
242
|
+
try { message = JSON.parse(line) } catch { message = undefined }
|
|
243
|
+
if (isResponse(message) && typeof message.id === 'string' && rt.internal.has(message.id)) {
|
|
244
|
+
const waiter = rt.internal.get(message.id)
|
|
245
|
+
return message.error ? waiter.reject(new Error(message.error.message || 'request failed')) : waiter.resolve(message.result)
|
|
246
|
+
}
|
|
247
|
+
if (rt !== this.current) {
|
|
248
|
+
// A candidate being handshaken may not talk to the client; answer its requests itself.
|
|
249
|
+
if (isRequest(message)) this.send(rt, { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Not available during runtime handover' } })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (isResponse(message)) {
|
|
253
|
+
const key = idKey(message.id)
|
|
254
|
+
const entry = this.clientPending.get(key)
|
|
255
|
+
this.clientPending.delete(key)
|
|
256
|
+
if (key === this.initKey && !this.serverInit) {
|
|
257
|
+
if (message.result) return this.initialized(rt, message)
|
|
258
|
+
if (rt.trial) return void this.abandonTrial(rt, 'initialize returned an error')
|
|
259
|
+
this.clientInit = null // let the client retry initialize
|
|
260
|
+
this.reply(line)
|
|
261
|
+
return this.flush()
|
|
262
|
+
}
|
|
263
|
+
if (entry?.method === 'tools/call' && message.result && !message.result.isError) this.confirm(rt)
|
|
264
|
+
this.reply(line)
|
|
265
|
+
return this.checkDrained()
|
|
266
|
+
}
|
|
267
|
+
if (isRequest(message)) {
|
|
268
|
+
const token = progressToken(message)
|
|
269
|
+
this.serverPending.set(idKey(message.id), { rt })
|
|
270
|
+
if (token !== undefined) {
|
|
271
|
+
// Late progress may follow the response, so owners are kept (bounded) rather than removed.
|
|
272
|
+
this.progressOwners.set(idKey(token), rt)
|
|
273
|
+
if (this.progressOwners.size > 1000) this.progressOwners.delete(this.progressOwners.keys().next().value)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.reply(line)
|
|
277
|
+
}
|
|
278
|
+
initialized(rt, message) {
|
|
279
|
+
this.serverInit = message.result
|
|
280
|
+
// Advertise tools.listChanged so the client refreshes after a swap that changed the tool list.
|
|
281
|
+
const result = { ...message.result, capabilities: { ...message.result.capabilities } }
|
|
282
|
+
result.capabilities.tools = { ...(result.capabilities.tools || {}), listChanged: true }
|
|
283
|
+
if (rt.trial) {
|
|
284
|
+
rt.trial = false
|
|
285
|
+
rt.cancelTrial?.()
|
|
286
|
+
this.probation(rt, this.fallback)
|
|
287
|
+
this.fallback = null
|
|
288
|
+
} else if (this.fallback) {
|
|
289
|
+
rt.fallback = this.fallback
|
|
290
|
+
this.fallback = null
|
|
291
|
+
}
|
|
292
|
+
this.reply({ ...message, result })
|
|
293
|
+
this.flush()
|
|
294
|
+
}
|
|
295
|
+
/** A new runtime becomes the default only after it proved itself in this session. */
|
|
296
|
+
probation(rt, fallback) {
|
|
297
|
+
rt.fallback = fallback || null
|
|
298
|
+
rt.probation = true
|
|
299
|
+
clearTimeout(rt.probationTimer)
|
|
300
|
+
rt.probationTimer = setTimeout(() => this.confirm(rt), this.probationMs)
|
|
301
|
+
rt.probationTimer.unref?.()
|
|
302
|
+
}
|
|
303
|
+
confirm(rt) {
|
|
304
|
+
if (!rt.probation || !rt.alive || rt !== this.current) return
|
|
305
|
+
rt.probation = false
|
|
306
|
+
clearTimeout(rt.probationTimer)
|
|
307
|
+
this.promote(rt.runtime)
|
|
308
|
+
}
|
|
309
|
+
/** A startup candidate that dies or stalls before answering initialize is replaced by the fallback. */
|
|
310
|
+
watchTrial(rt) {
|
|
311
|
+
const limit = timeout(this.handshakeTimeoutMs, 'initialize timed out')
|
|
312
|
+
limit.catch(() => { if (rt.trial && rt === this.current) this.abandonTrial(rt, 'initialize timed out') })
|
|
313
|
+
rt.exited.then(() => limit.cancel())
|
|
314
|
+
rt.cancelTrial = limit.cancel
|
|
315
|
+
}
|
|
316
|
+
abandonTrial(rt, reason) {
|
|
317
|
+
if (!rt.trial || !this.fallback || this.exiting) return false
|
|
318
|
+
rt.trial = false
|
|
319
|
+
rt.cancelTrial?.()
|
|
320
|
+
this.log(`New runtime ${rt.runtime.version} failed to start (${reason}); using ${this.fallback.version}`)
|
|
321
|
+
this.recordFailure(rt.runtime.version, reason)
|
|
322
|
+
this.attempted.add(rt.runtime.version)
|
|
323
|
+
this.closeInBackground(rt)
|
|
324
|
+
this.clientPending.delete(this.initKey)
|
|
325
|
+
const next = this.startRuntime(this.fallback)
|
|
326
|
+
this.fallback = null
|
|
327
|
+
this.current = next
|
|
328
|
+
if (this.clientInit) this.forwardRequest(next, this.clientInit)
|
|
329
|
+
return true
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── lifecycle ─────────────────────────────────────────────────────────
|
|
333
|
+
runtimeExited(rt) {
|
|
334
|
+
if (rt.closing || this.exiting) return
|
|
335
|
+
if (rt !== this.current) return
|
|
336
|
+
const reason = rt.spawnError ? rt.spawnError.message : `exit ${rt.signal || rt.code}`
|
|
337
|
+
if (rt.trial && this.abandonTrial(rt, reason)) return
|
|
338
|
+
// Never replay requests on another runtime: they may not be idempotent.
|
|
339
|
+
for (const [key, entry] of [...this.clientPending]) {
|
|
340
|
+
if (entry.rt === rt) this.failRequest(JSON.parse(key), 'PandaNpc MCP runtime exited before responding')
|
|
341
|
+
}
|
|
342
|
+
for (const [key, entry] of [...this.serverPending]) {
|
|
343
|
+
if (entry.rt === rt) this.serverPending.delete(key)
|
|
344
|
+
}
|
|
345
|
+
const fallback = rt.fallback && this.serverInit && this.clientInitialized
|
|
346
|
+
// Going back to the previous runtime always retires the crashed one, even after it was confirmed:
|
|
347
|
+
// otherwise the watcher would switch to it again and a reproducible crash would loop.
|
|
348
|
+
if (rt.probation || fallback) {
|
|
349
|
+
this.attempted.add(rt.runtime.version)
|
|
350
|
+
this.recordFailure(rt.runtime.version, `${rt.probation ? 'stopped before it was confirmed' : 'stopped after it was confirmed'} (${reason})`)
|
|
351
|
+
}
|
|
352
|
+
this.log(`PandaNpc MCP runtime ${rt.runtime.version} stopped (${reason})`)
|
|
353
|
+
if (fallback) return void this.restart(rt, rt.fallback)
|
|
354
|
+
this.finish(rt.code, rt.signal, rt.spawnError)
|
|
355
|
+
}
|
|
356
|
+
/** Brings back the runtime a crashed one replaced, re-using the cached handshake. */
|
|
357
|
+
async restart(crashed, runtime) {
|
|
358
|
+
this.restarting = true
|
|
359
|
+
this.frozen = true
|
|
360
|
+
let next
|
|
361
|
+
try {
|
|
362
|
+
next = this.startRuntime(runtime)
|
|
363
|
+
const limit = timeout(this.handshakeTimeoutMs, 'initialize timed out')
|
|
364
|
+
try { await Promise.race([this.handshake(next), limit]) } finally { limit.cancel() }
|
|
365
|
+
this.send(next, this.clientInitialized)
|
|
366
|
+
if (this.exiting) return
|
|
367
|
+
this.current = next
|
|
368
|
+
this.log(`PandaNpc MCP went back to ${runtime.version} after ${crashed.runtime.version} stopped`)
|
|
369
|
+
this.flush()
|
|
370
|
+
if (this.isClaudeCode()) this.reply({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' })
|
|
371
|
+
} catch (error) {
|
|
372
|
+
this.log(`PandaNpc MCP could not restart ${runtime.version} (${error.message})`)
|
|
373
|
+
if (next) this.closeInBackground(next)
|
|
374
|
+
this.finish(crashed.code || 1, null)
|
|
375
|
+
} finally { this.restarting = false }
|
|
376
|
+
}
|
|
377
|
+
async closeRuntime(rt, signal) {
|
|
378
|
+
rt.closing = true
|
|
379
|
+
clearTimeout(rt.probationTimer)
|
|
380
|
+
if (!rt.alive) return
|
|
381
|
+
try { rt.proc.stdin.end() } catch {}
|
|
382
|
+
if (signal && process.platform !== 'win32') { try { rt.proc.kill(signal) } catch {} }
|
|
383
|
+
const limit = timeout(this.exitTimeoutMs, 'exit timed out')
|
|
384
|
+
try { await Promise.race([rt.exited, limit]) } catch {
|
|
385
|
+
this.log(`PandaNpc MCP runtime ${rt.runtime.version} did not exit; terminating it`)
|
|
386
|
+
this.killTree(rt.proc)
|
|
387
|
+
const last = timeout(5000, 'kill timed out')
|
|
388
|
+
await Promise.race([rt.exited, last]).catch(() => {})
|
|
389
|
+
last.cancel()
|
|
390
|
+
} finally { limit.cancel() }
|
|
391
|
+
}
|
|
392
|
+
closeInBackground(rt) {
|
|
393
|
+
const closing = this.closeRuntime(rt).finally(() => this.closing.delete(closing))
|
|
394
|
+
this.closing.add(closing)
|
|
395
|
+
}
|
|
396
|
+
async shutdown(signal) {
|
|
397
|
+
if (this.exiting) return
|
|
398
|
+
this.exiting = true
|
|
399
|
+
const rt = this.current
|
|
400
|
+
await Promise.all([this.candidate && this.closeRuntime(this.candidate), rt && this.closeRuntime(rt, signal), ...this.closing])
|
|
401
|
+
this.finish(rt?.code, rt?.signal)
|
|
402
|
+
}
|
|
403
|
+
finish(code, signal, error) {
|
|
404
|
+
if (this.finished) return
|
|
405
|
+
this.finished = true
|
|
406
|
+
this.exiting = true
|
|
407
|
+
this.onExit({ code, signal, error })
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── hot swap ──────────────────────────────────────────────────────────
|
|
411
|
+
pendingFor(rt) {
|
|
412
|
+
for (const entry of this.clientPending.values()) if (entry.rt === rt) return true
|
|
413
|
+
for (const entry of this.serverPending.values()) if (entry.rt === rt) return true
|
|
414
|
+
return rt.internal.size > 0
|
|
415
|
+
}
|
|
416
|
+
checkDrained() {
|
|
417
|
+
const waiters = this.drainWaiters
|
|
418
|
+
this.drainWaiters = []
|
|
419
|
+
for (const w of waiters) { if (!this.pendingFor(w.rt)) w.resolve(true); else this.drainWaiters.push(w) }
|
|
420
|
+
}
|
|
421
|
+
/** Resolves true once `rt` has nothing in flight, false after `ms`. */
|
|
422
|
+
idle(rt, ms) {
|
|
423
|
+
if (!this.pendingFor(rt)) return Promise.resolve(true)
|
|
424
|
+
return new Promise(resolve => {
|
|
425
|
+
const waiter = { rt, resolve: value => { clearTimeout(timer); resolve(value) } }
|
|
426
|
+
const timer = setTimeout(() => { this.drainWaiters = this.drainWaiters.filter(w => w !== waiter); resolve(false) }, ms)
|
|
427
|
+
this.drainWaiters.push(waiter)
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
async listTools(rt) {
|
|
431
|
+
const tools = []
|
|
432
|
+
let cursor
|
|
433
|
+
for (let page = 0; page < 50; page++) {
|
|
434
|
+
const result = await this.request(rt, 'tools/list', cursor ? { cursor } : {})
|
|
435
|
+
tools.push(...(result?.tools || []))
|
|
436
|
+
cursor = result?.nextCursor
|
|
437
|
+
if (!cursor) break
|
|
438
|
+
}
|
|
439
|
+
return tools
|
|
440
|
+
}
|
|
441
|
+
compatible(result) {
|
|
442
|
+
if (!result || result.protocolVersion !== this.serverInit.protocolVersion) return 'protocol version differs'
|
|
443
|
+
// tools.listChanged is ignored: the proxy always advertises it to the client (see initialized()),
|
|
444
|
+
// so a runtime that starts or stops declaring it does not change what the client sees.
|
|
445
|
+
const strip = caps => { const copy = JSON.parse(JSON.stringify(caps || {})); if (copy.tools) delete copy.tools.listChanged; return canonical(copy) }
|
|
446
|
+
return strip(result.capabilities) === strip(this.serverInit.capabilities) ? null : 'capabilities differ'
|
|
447
|
+
}
|
|
448
|
+
busy() {
|
|
449
|
+
this.busyCount++
|
|
450
|
+
this.retryAt = this.now() + Math.min(this.busyBackoffMs * 2 ** Math.min(this.busyCount - 1, 30), 60 * 60 * 1000)
|
|
451
|
+
return 'busy'
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Returns 'switched', 'deferred' (kept for a later session), 'busy' (retry later), 'failed' or
|
|
455
|
+
* 'skipped'. Never loses or reorders client traffic; the old runtime stays in place on failure.
|
|
456
|
+
*/
|
|
457
|
+
async hotSwap(runtime) {
|
|
458
|
+
if (!this.ready || this.switching || this.attempted.has(runtime.version) || runtime.version === this.version) return 'skipped'
|
|
459
|
+
if (this.now() < this.retryAt) return 'skipped'
|
|
460
|
+
this.switching = true
|
|
461
|
+
let next
|
|
462
|
+
try {
|
|
463
|
+
next = this.candidate = this.startRuntime(runtime)
|
|
464
|
+
const start = timeout(this.handshakeTimeoutMs, 'initialize timed out')
|
|
465
|
+
let init
|
|
466
|
+
try { init = await Promise.race([this.handshake(next), start]) } finally { start.cancel() }
|
|
467
|
+
const problem = this.compatible(init)
|
|
468
|
+
if (problem) {
|
|
469
|
+
this.attempted.add(runtime.version)
|
|
470
|
+
this.log(`PandaNpc MCP ${runtime.version} is not compatible with this session (${problem}); it will be used by the next session`)
|
|
471
|
+
return 'deferred'
|
|
472
|
+
}
|
|
473
|
+
this.send(next, this.clientInitialized)
|
|
474
|
+
const [before, after] = await Promise.all([this.listTools(this.current), this.listTools(next)])
|
|
475
|
+
const toolsChanged = toolsFingerprint(before) !== toolsFingerprint(after)
|
|
476
|
+
const instructionsChanged = (init.instructions || '') !== (this.serverInit.instructions || '')
|
|
477
|
+
if (instructionsChanged) this.log(`PandaNpc MCP ${runtime.version} changes the server instructions`)
|
|
478
|
+
// No client can refresh instructions mid-session, so an instructions change always waits for the next session.
|
|
479
|
+
if (instructionsChanged || (toolsChanged && !this.isClaudeCode())) {
|
|
480
|
+
this.attempted.add(runtime.version)
|
|
481
|
+
this.log(`PandaNpc MCP ${runtime.version} changes the ${instructionsChanged ? 'instructions' : 'tool list'}; it will be used by the next session`)
|
|
482
|
+
return 'deferred'
|
|
483
|
+
}
|
|
484
|
+
const old = this.current
|
|
485
|
+
// Freeze only at an idle moment, and only briefly: a busy session is retried later.
|
|
486
|
+
if (!await this.idle(old, this.idleWaitMs)) return this.busy()
|
|
487
|
+
this.frozen = true
|
|
488
|
+
if (!await this.idle(old, this.drainTimeoutMs)) { this.flush(); return this.busy() }
|
|
489
|
+
// A client that is not reading stdout keeps the old runtime; unfreeze and retry later.
|
|
490
|
+
if (!await this.outputFlushed()) { this.flush(); return this.busy() }
|
|
491
|
+
if (!next.alive || this.exiting) throw new Error('new runtime exited before handover')
|
|
492
|
+
if (old !== this.current) throw new Error('runtime changed during handover')
|
|
493
|
+
this.current = next
|
|
494
|
+
this.candidate = null
|
|
495
|
+
this.serverInit = { ...this.serverInit, ...init }
|
|
496
|
+
this.probation(next, old.runtime)
|
|
497
|
+
this.closeInBackground(old) // stdin EOF: the old runtime stops sync and releases its lease
|
|
498
|
+
this.flush()
|
|
499
|
+
if (toolsChanged) this.reply({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' })
|
|
500
|
+
this.busyCount = 0
|
|
501
|
+
this.log(`PandaNpc MCP switched to ${runtime.version} without restarting the session`)
|
|
502
|
+
return 'switched'
|
|
503
|
+
} catch (error) {
|
|
504
|
+
this.attempted.add(runtime.version)
|
|
505
|
+
this.recordFailure(runtime.version, error.message)
|
|
506
|
+
this.log(`PandaNpc MCP ${runtime.version} could not take over (${error.message}); keeping ${this.version}`)
|
|
507
|
+
if (this.frozen && !this.exiting && this.current.alive && !this.restarting) this.flush()
|
|
508
|
+
return 'failed'
|
|
509
|
+
} finally {
|
|
510
|
+
if (this.candidate) { const c = this.candidate; this.candidate = null; await this.closeRuntime(c) }
|
|
511
|
+
this.switching = false
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
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 =
|
|
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
|
|
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 =
|
|
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 (
|
|
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 +
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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 +
|
|
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
|
-
|
|
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 +
|
|
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 }
|