@pandanpc/mcp-server 0.1.30 → 0.1.31

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
@@ -68,6 +68,19 @@ command = "pandanpc-mcp"
68
68
 
69
69
  Account operations require `note_login` when not already authenticated. Reading guides does not require a note login.
70
70
 
71
+ ## Automatic runtime updates
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.
74
+
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
+
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.
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
+ - `PANDANPC_MCP_CACHE_DIR` optionally selects a different user-writable cache directory.
81
+
82
+ Existing users must first upgrade to a launcher containing this feature with `npm install -g @pandanpc/mcp-server@latest`, then reconnect. Automatic updates cover the executable and its embedded guides, not the npm launcher code or separately copied Skill files. A future release requiring a different updater protocol must be installed through npm; `--update` reports that condition. Directly launching a downloaded Rust binary bypasses the npm updater.
83
+
71
84
  ## Optional Agent Skill
72
85
 
73
86
  The package includes `skills/pandanpc/SKILL.md` and its references. These are the same guide files embedded in the MCP binary, so examples stay aligned with the installed version. MCP-only clients can call `get_guide`; they do not need filesystem or Skill support.
package/index.js CHANGED
@@ -8,30 +8,53 @@ const path = require('path')
8
8
  const { spawn } = require('child_process')
9
9
  const fs = require('fs')
10
10
  const { binaryName } = require('./binary-name')
11
+ const pkg = require('./package.json')
12
+ const { selectRuntime, startBackgroundUpdate, checkForUpdates } = require('./updater')
11
13
 
12
14
  const binName = binaryName()
13
- const binPath = path.join(__dirname, 'bin', binName)
15
+ const bundledPath = path.join(__dirname, 'bin', binName)
16
+ const runtime = selectRuntime()
17
+ let child
14
18
 
15
- if (!fs.existsSync(binPath)) {
16
- console.error(`[pandanpc-mcp] binary not found at ${binPath}`)
17
- console.error(`[pandanpc-mcp] try reinstalling: npm install -g @pandanpc/mcp-server`)
18
- process.exit(1)
19
+ if (process.argv[2] === '--version') {
20
+ console.log(`pandanpc-mcp ${runtime.version} (launcher ${pkg.version})`)
21
+ process.exit(0)
22
+ }
23
+ if (process.argv[2] === '--update') {
24
+ checkForUpdates({ force: true }).then(result => {
25
+ console.error(`[pandanpc-mcp] update: ${result.status}${result.version ? ` ${result.version}` : ''}${result.error ? ` (${result.error})` : ''}`)
26
+ if (result.status === 'manual-update-required') console.error('Update the launcher: npm install -g @pandanpc/mcp-server@latest')
27
+ process.exit(result.status === 'failed' ? 1 : 0)
28
+ }).catch(error => { console.error(error.message); process.exit(1) })
29
+ } else {
30
+ launch(runtime)
31
+ startBackgroundUpdate()
32
+ // Keep long-lived sessions aware of releases without holding the process open.
33
+ setInterval(startBackgroundUpdate, 15 * 60 * 1000).unref()
19
34
  }
20
35
 
21
- const child = spawn(binPath, process.argv.slice(2), {
22
- stdio: 'inherit',
23
- windowsHide: true,
24
- })
25
-
26
- child.on('exit', (code, signal) => {
27
- if (signal) process.kill(process.pid, signal)
28
- else process.exit(code ?? 0)
29
- })
30
-
31
- child.on('error', (err) => {
32
- console.error(`[pandanpc-mcp] failed to spawn: ${err.message}`)
33
- process.exit(1)
34
- })
35
-
36
- process.on('SIGINT', () => child.kill('SIGINT'))
37
- process.on('SIGTERM', () => child.kill('SIGTERM'))
36
+ function launch(selected) {
37
+ if (!fs.existsSync(selected.file)) {
38
+ console.error(`[pandanpc-mcp] binary not found at ${selected.file}; reinstall @pandanpc/mcp-server`)
39
+ process.exit(1)
40
+ }
41
+ child = spawn(selected.file, process.argv.slice(2), {
42
+ stdio: 'inherit', windowsHide: true,
43
+ env: { ...process.env, PANDANPC_MCP_RUNTIME_VERSION: selected.version },
44
+ })
45
+ child.on('exit', (code, signal) => {
46
+ if (signal) process.kill(process.pid, signal)
47
+ else process.exit(code ?? 0)
48
+ })
49
+ child.on('error', err => {
50
+ if (selected.file !== bundledPath) {
51
+ console.error('[pandanpc-mcp] cached runtime could not start; using bundled version')
52
+ launch({ file: bundledPath, version: pkg.version })
53
+ } else {
54
+ console.error(`[pandanpc-mcp] failed to spawn: ${err.message}`)
55
+ process.exit(1)
56
+ }
57
+ })
58
+ }
59
+ process.on('SIGINT', () => { if (child) child.kill('SIGINT') })
60
+ process.on('SIGTERM', () => { if (child) child.kill('SIGTERM') })
package/install.js CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs')
10
10
  const path = require('path')
11
- const https = require('https')
11
+ const { download, fetchText } = require('./network')
12
12
  const crypto = require('crypto')
13
13
  const { binaryName } = require('./binary-name')
14
14
 
@@ -32,31 +32,6 @@ function detectPlatform() {
32
32
  return { key, name }
33
33
  }
34
34
 
35
- function download(url, dest) {
36
- return new Promise((resolve, reject) => {
37
- const file = fs.createWriteStream(dest)
38
- const req = https.get(url, (res) => {
39
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
40
- file.close()
41
- fs.unlinkSync(dest)
42
- return download(res.headers.location, dest).then(resolve, reject)
43
- }
44
- if (res.statusCode !== 200) {
45
- file.close()
46
- fs.unlinkSync(dest)
47
- return reject(new Error(`HTTP ${res.statusCode} for ${url}`))
48
- }
49
- res.pipe(file)
50
- file.on('finish', () => file.close(resolve))
51
- })
52
- req.on('error', (err) => {
53
- file.close()
54
- try { fs.unlinkSync(dest) } catch {}
55
- reject(err)
56
- })
57
- })
58
- }
59
-
60
35
  function sha256(file) {
61
36
  return new Promise((resolve, reject) => {
62
37
  const hash = crypto.createHash('sha256')
@@ -79,20 +54,6 @@ function checksumFor(sumsText, artifactName) {
79
54
  return checksum
80
55
  }
81
56
 
82
- async function fetchText(url) {
83
- return new Promise((resolve, reject) => {
84
- https.get(url, (res) => {
85
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
86
- return fetchText(res.headers.location).then(resolve, reject)
87
- }
88
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`))
89
- let body = ''
90
- res.on('data', (c) => (body += c))
91
- res.on('end', () => resolve(body))
92
- }).on('error', reject)
93
- })
94
- }
95
-
96
57
  function removeTemporaryFile(file) {
97
58
  try { fs.unlinkSync(file) } catch (error) {
98
59
  if (error?.code !== 'ENOENT') throw error
@@ -162,6 +123,7 @@ if (require.main === module) {
162
123
  }
163
124
 
164
125
  module.exports = {
126
+ detectPlatform,
165
127
  checksumFor,
166
128
  ensureBinary,
167
129
  }
package/network.js ADDED
@@ -0,0 +1,45 @@
1
+ const https = require('https')
2
+ const fs = require('fs')
3
+ const { pipeline } = require('stream')
4
+
5
+ // Bound both idle time and total transfer time; never follow a downgrade to HTTP.
6
+ function transfer(url, destination, limit, timeout, redirects = 0) {
7
+ return new Promise((resolve, reject) => {
8
+ const parsed = new URL(url)
9
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return reject(new Error('HTTPS URL required'))
10
+ let timer, piping = false
11
+ const finish = (error, value) => { clearTimeout(timer); error ? reject(error) : resolve(value) }
12
+ const req = https.get(parsed, (res) => {
13
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
14
+ res.destroy()
15
+ clearTimeout(timer)
16
+ if (redirects >= 3) return reject(new Error('Too many redirects'))
17
+ return transfer(new URL(res.headers.location, parsed).href, destination, limit, timeout, redirects + 1).then(resolve, reject)
18
+ }
19
+ if (res.statusCode !== 200) { res.destroy(); return finish(new Error(`HTTP ${res.statusCode}`)) }
20
+ let bytes = 0
21
+ res.on('data', chunk => {
22
+ bytes += chunk.length
23
+ if (bytes > limit) req.destroy(new Error('Response too large'))
24
+ })
25
+ if (destination) {
26
+ piping = true
27
+ pipeline(res, fs.createWriteStream(destination, { flags: 'wx', mode: 0o600 }), error => finish(error || (bytes > limit || !res.complete ? new Error('Incomplete or oversized response') : null)))
28
+ } else {
29
+ const chunks = []
30
+ res.on('data', chunk => chunks.push(chunk))
31
+ res.on('error', error => finish(error))
32
+ res.on('aborted', () => finish(new Error('Response interrupted')))
33
+ res.on('end', () => finish(bytes > limit || !res.complete ? new Error('Incomplete or oversized response') : null, Buffer.concat(chunks).toString('utf8')))
34
+ }
35
+ })
36
+ req.on('error', error => { if (!piping) finish(error) })
37
+ req.setTimeout(15000, () => req.destroy(new Error('Network idle timeout')))
38
+ timer = setTimeout(() => req.destroy(new Error('Network deadline exceeded')), timeout)
39
+ })
40
+ }
41
+
42
+ module.exports = {
43
+ fetchText: (url, { limit = 1024 * 1024, timeout = 20000 } = {}) => transfer(url, null, limit, timeout),
44
+ download: (url, destination, { limit = 64 * 1024 * 1024, timeout = 90000 } = {}) => transfer(url, destination, limit, timeout),
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pandanpc/mcp-server",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
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",
@@ -24,7 +24,9 @@
24
24
  "index.js",
25
25
  "install.js",
26
26
  "README.md",
27
- "skills"
27
+ "skills",
28
+ "network.js",
29
+ "updater.js"
28
30
  ],
29
31
  "scripts": {
30
32
  "postinstall": "node install.js"
@@ -47,5 +49,6 @@
47
49
  "bugs": {
48
50
  "url": "https://github.com/pandanpc-inc/mcp-server/issues"
49
51
  },
50
- "mcpName": "com.pandanpc/mcp-server"
52
+ "mcpName": "com.pandanpc/mcp-server",
53
+ "pandanpcUpdaterProtocol": 1
51
54
  }
package/updater.js ADDED
@@ -0,0 +1,145 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const os = require('os')
4
+ const crypto = require('crypto')
5
+ const { spawn, spawnSync } = require('child_process')
6
+ const { binaryName } = require('./binary-name')
7
+ const { ensureBinary, detectPlatform } = require('./install')
8
+ const { fetchText } = require('./network')
9
+ const pkg = require('./package.json')
10
+
11
+ const PROTOCOL = 1
12
+ const INTERVAL = 6 * 60 * 60 * 1000
13
+ const RETRY_INTERVAL = 15 * 60 * 1000
14
+ const REGISTRY = 'https://registry.npmjs.org/@pandanpc%2fmcp-server/latest'
15
+ const quiet = { log() {}, warn() {} }
16
+
17
+ function versionParts(v) {
18
+ if (typeof v !== 'string' || !/^\d+\.\d+\.\d+$/.test(v)) return null
19
+ const parts = v.split('.').map(Number)
20
+ return parts.every(Number.isSafeInteger) ? parts : null
21
+ }
22
+ function compareVersions(a, b) {
23
+ const x = versionParts(a), y = versionParts(b)
24
+ if (!x || !y) throw new Error('Invalid stable version')
25
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1
26
+ return 0
27
+ }
28
+ function cacheRoot() {
29
+ return path.join(process.env.PANDANPC_MCP_CACHE_DIR || path.join(os.homedir(), '.pandanpc-mcp', 'runtime'), `${process.platform}-${process.arch}`)
30
+ }
31
+ function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null } }
32
+ function writeJson(file, value) {
33
+ const temp = `${file}.${crypto.randomUUID()}.tmp`
34
+ try { fs.writeFileSync(temp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(temp, file) }
35
+ finally { try { fs.unlinkSync(temp) } catch {} }
36
+ }
37
+ function activeRuntime(root, bundledVersion = pkg.version, platform = process.platform) {
38
+ try {
39
+ const active = readJson(path.join(root, 'active.json'))
40
+ if (!active || !versionParts(active.version) || compareVersions(active.version, bundledVersion) <= 0) return null
41
+ if (!/^[a-zA-Z0-9.-]+$/.test(active.directory) || !active.directory.startsWith(active.version + '-')) return null
42
+ if (!/^[a-f0-9]{64}$/.test(active.checksum)) return null
43
+ const file = path.join(root, active.directory, binaryName(platform, active.version))
44
+ const stat = fs.lstatSync(file)
45
+ 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
47
+ if (platform !== 'win32') fs.accessSync(file, fs.constants.X_OK)
48
+ return { file, version: active.version }
49
+ } catch { return null }
50
+ }
51
+ function selectRuntime({ root = cacheRoot(), bundledVersion = pkg.version, platform = process.platform, disabled = process.env.PANDANPC_MCP_AUTO_UPDATE === '0' } = {}) {
52
+ const bundled = { file: path.join(__dirname, 'bin', binaryName(platform, bundledVersion)), version: bundledVersion }
53
+ return (!disabled && activeRuntime(root, bundledVersion, platform)) || bundled
54
+ }
55
+ function due(root, now = Date.now()) {
56
+ const state = readJson(path.join(root, 'check.json'))
57
+ return !state || !Number.isFinite(state.nextCheck) || state.nextCheck <= now || state.nextCheck > now + INTERVAL
58
+ }
59
+ function acquireLock(root, now) {
60
+ const file = path.join(root, 'update.lock')
61
+ const nonce = crypto.randomUUID()
62
+ try {
63
+ const fd = fs.openSync(file, 'wx', 0o600)
64
+ try { fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, nonce })) } finally { fs.closeSync(fd) }
65
+ return () => { if (readJson(file)?.nonce === nonce) { try { fs.unlinkSync(file) } catch {} } }
66
+ } catch (error) {
67
+ if (error.code !== 'EEXIST') throw error
68
+ // Reap only a dead owner, never an updater that is still running.
69
+ const first = fs.statSync(file)
70
+ const owner = readJson(file)
71
+ if (now - first.mtimeMs < 5 * 60 * 1000) return null
72
+ if (owner && Number.isInteger(owner.pid) && owner.pid > 0) {
73
+ try { process.kill(owner.pid, 0); return null } catch (e) { if (e.code !== 'ESRCH') return null }
74
+ }
75
+ // A concurrent reaper must not unlink a replacement lock.
76
+ const again = fs.statSync(file)
77
+ if (first.ino !== again.ino || first.mtimeMs !== again.mtimeMs) return null
78
+ fs.unlinkSync(file)
79
+ return null // next launch retries; don't race the replacement owner
80
+ }
81
+ }
82
+ function probeBinary(file) {
83
+ const r = spawnSync(file, ['--help'], { timeout: 5000, killSignal: 'SIGKILL', windowsHide: true, encoding: 'utf8', maxBuffer: 1024 * 1024 })
84
+ if (r.error || r.status !== 0) throw new Error('Downloaded runtime failed its startup check')
85
+ }
86
+ async function checkForUpdates(options = {}) {
87
+ const root = options.root || cacheRoot()
88
+ const bundledVersion = options.bundledVersion || pkg.version
89
+ const platform = options.platform || process.platform
90
+ const platformInfo = options.platformInfo || detectPlatform()
91
+ const fetchTextFn = options.fetchTextFn || fetchText
92
+ const now = (options.now || Date.now)()
93
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 })
94
+ if (!options.force && !due(root, now)) return { status: 'throttled' }
95
+ const unlock = acquireLock(root, now)
96
+ if (!unlock) return { status: 'busy' }
97
+ let directory
98
+ try {
99
+ if (!options.force && !due(root, now)) return { status: 'throttled' }
100
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + RETRY_INTERVAL })
101
+ const metadata = JSON.parse(await fetchTextFn(REGISTRY))
102
+ if (!versionParts(metadata.version)) throw new Error('Invalid release version')
103
+ const current = activeRuntime(root, bundledVersion, platform)
104
+ const currentVersion = current?.version || bundledVersion
105
+ if (compareVersions(metadata.version, currentVersion) <= 0) {
106
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + INTERVAL })
107
+ return { status: 'current', version: currentVersion }
108
+ }
109
+ if (metadata.pandanpcUpdaterProtocol !== PROTOCOL) {
110
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + INTERVAL, manualUpdateRequired: metadata.version })
111
+ return { status: 'manual-update-required', version: metadata.version }
112
+ }
113
+ const version = metadata.version
114
+ const name = `${version}-${crypto.randomUUID()}`
115
+ directory = path.join(root, name)
116
+ fs.mkdirSync(directory, { mode: 0o700 })
117
+ const file = path.join(directory, binaryName(platform, version))
118
+ const result = await ensureBinary({ key: platformInfo.key, artifactName: platformInfo.name, localBin: file, platform,
119
+ baseUrl: `https://cos.pandanpc.com/mcp-server/v${version}`, fetchTextFn,
120
+ ...(options.downloadFn ? { downloadFn: options.downloadFn } : {}), logger: quiet })
121
+ await (options.probeFn || probeBinary)(file)
122
+ const newer = activeRuntime(root, bundledVersion, platform)
123
+ 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 })
125
+ directory = null // published versions may be in use; never delete them automatically
126
+ writeJson(path.join(root, 'check.json'), { nextCheck: now + INTERVAL })
127
+ return { status: 'ready', version }
128
+ } catch (error) {
129
+ return { status: 'failed', error: error.message }
130
+ } finally {
131
+ if (directory) fs.rmSync(directory, { recursive: true, force: true })
132
+ unlock()
133
+ }
134
+ }
135
+ function startBackgroundUpdate() {
136
+ if (process.env.PANDANPC_MCP_AUTO_UPDATE === '0') return
137
+ try {
138
+ if (!due(cacheRoot())) return
139
+ const child = spawn(process.execPath, [__filename], { detached: true, windowsHide: true, stdio: 'ignore' })
140
+ child.on('error', () => {})
141
+ child.unref()
142
+ } catch {} // updates must never prevent an MCP session from starting
143
+ }
144
+ if (require.main === module) checkForUpdates().catch(() => {})
145
+ module.exports = { compareVersions, selectRuntime, checkForUpdates, startBackgroundUpdate }