local-mcp 3.0.137 → 3.0.139

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.
Files changed (4) hide show
  1. package/download.js +97 -31
  2. package/index.js +36 -16
  3. package/package.json +4 -2
  4. package/setup.js +117 -12
package/download.js CHANGED
@@ -13,34 +13,61 @@ const os = require('os')
13
13
  const { execFileSync } = require('child_process')
14
14
 
15
15
  const BACKEND_URL = 'https://office-mcp-production.up.railway.app'
16
- const CACHE_DIR = path.join(os.homedir(), '.local', 'share', 'local-mcp', 'bin')
17
- const TRAY_DIR = path.join(os.homedir(), '.local', 'share', 'local-mcp', 'tray') // metadata dir
16
+
17
+ // Platform-aware cache directory:
18
+ // macOS: ~/.local/share/local-mcp/bin
19
+ // Windows: %LOCALAPPDATA%/local-mcp/bin
20
+ // Linux: ~/.local/share/local-mcp/bin
21
+ const CACHE_DIR = process.platform === 'win32'
22
+ ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'local-mcp', 'bin')
23
+ : path.join(os.homedir(), '.local', 'share', 'local-mcp', 'bin')
24
+
25
+ const TRAY_DIR = process.platform === 'win32'
26
+ ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'local-mcp', 'tray')
27
+ : path.join(os.homedir(), '.local', 'share', 'local-mcp', 'tray')
28
+
18
29
  // Install to ~/Applications (user-owned) to avoid macOS App Management TCC prompt.
19
30
  // /Applications requires kTCCServiceAppManagement permission which shows "node would like
20
31
  // to access data from other apps" on every tray update — confusing and unnecessary.
21
- const TRAY_APP = path.join(os.homedir(), 'Applications', 'LocalMCPTray.app')
32
+ // On Windows/Linux there is no tray app yet.
33
+ const TRAY_APP = process.platform === 'darwin'
34
+ ? path.join(os.homedir(), 'Applications', 'LocalMCPTray.app')
35
+ : null
22
36
 
23
37
  function _getMachineId() {
24
38
  const { execSync } = require('child_process')
25
- try {
26
- const r = execSync('security find-generic-password -s com.local-mcp.machine-id -a machine-id -w 2>/dev/null', { stdio: 'pipe' })
27
- const id = r.toString().trim()
28
- if (id) return id
29
- } catch {}
30
- try {
31
- // Full hardware UUID (same format as Swift server)
32
- const ioreg = execSync('ioreg -rd1 -c IOPlatformExpertDevice', { stdio: 'pipe' }).toString()
33
- const match = ioreg.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)
34
- return match ? match[1] : os.hostname()
35
- } catch {}
36
- return ''
39
+ if (process.platform === 'darwin') {
40
+ try {
41
+ const r = execSync('security find-generic-password -s com.local-mcp.machine-id -a machine-id -w 2>/dev/null', { stdio: 'pipe' })
42
+ const id = r.toString().trim()
43
+ if (id) return id
44
+ } catch {}
45
+ try {
46
+ const ioreg = execSync('ioreg -rd1 -c IOPlatformExpertDevice', { stdio: 'pipe' }).toString()
47
+ const match = ioreg.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)
48
+ return match ? match[1] : os.hostname()
49
+ } catch {}
50
+ } else if (process.platform === 'win32') {
51
+ try {
52
+ const wmic = execSync('wmic csproduct get UUID /value', { stdio: 'pipe' }).toString()
53
+ const match = wmic.match(/UUID=(.+)/)
54
+ if (match) return match[1].trim()
55
+ } catch {}
56
+ } else {
57
+ try {
58
+ const mid = fs.readFileSync('/etc/machine-id', 'utf8').trim()
59
+ if (mid) return mid
60
+ } catch {}
61
+ }
62
+ return os.hostname()
37
63
  }
38
64
 
39
65
  function getArch() {
66
+ const plat = process.platform === 'win32' ? 'windows' : process.platform === 'linux' ? 'linux' : 'darwin'
40
67
  const arch = process.arch
41
- if (arch === 'arm64') return 'darwin-arm64'
42
- if (arch === 'x64') return 'darwin-x64'
43
- throw new Error(`Arquitectura no soportada: ${arch}. LMCP requiere macOS arm64 o x64.`)
68
+ if (arch === 'arm64') return `${plat}-arm64`
69
+ if (arch === 'x64') return `${plat}-amd64`
70
+ throw new Error(`Unsupported architecture: ${plat}/${arch}. LMCP requires arm64 or x64.`)
44
71
  }
45
72
 
46
73
  /**
@@ -121,7 +148,55 @@ async function ensureBinary() {
121
148
  const arch = getArch()
122
149
  const info = await getLatestBinary()
123
150
  const version = info.version
124
- // URL del binario PyInstaller (nueva nomenclatura)
151
+
152
+ // On non-darwin, use the Go server binary (cross-platform).
153
+ // The Go binary is a single file, not a nested tarball.
154
+ if (process.platform !== 'darwin') {
155
+ const binName = process.platform === 'win32' ? 'lmcp-server.exe' : 'lmcp-server'
156
+ const binPath = path.join(CACHE_DIR, binName)
157
+ const versionFile = path.join(CACHE_DIR, '.go-server-version')
158
+
159
+ // Check if already cached at the right version
160
+ if (fs.existsSync(binPath)) {
161
+ try {
162
+ const cached = fs.readFileSync(versionFile, 'utf8').trim()
163
+ if (cached === version) return { binPath, versionDir: CACHE_DIR, version }
164
+ } catch {}
165
+ }
166
+
167
+ // Download the Go binary tarball from R2
168
+ const url = `https://download.local-mcp.com/lmcp-server-${version}-${arch}.tar.gz`
169
+ process.stderr.write(`\nLMCP v${version} (${arch}) not found in cache.\n`)
170
+ process.stderr.write(`Downloading from ${url}\n`)
171
+
172
+ fs.mkdirSync(CACHE_DIR, { recursive: true })
173
+ const tarPath = path.join(CACHE_DIR, `go-server-${version}.tar.gz`)
174
+
175
+ try {
176
+ await downloadFile(url, tarPath)
177
+ process.stderr.write(` Extracting...\n`)
178
+ execFileSync('tar', ['-xzf', tarPath, '-C', CACHE_DIR], { stdio: 'pipe' })
179
+ fs.unlinkSync(tarPath)
180
+ } catch (err) {
181
+ // Go binary not yet published for this version — fall back to message
182
+ try { fs.unlinkSync(tarPath) } catch {}
183
+ throw new Error(
184
+ `LMCP ${version} is not yet available for ${arch}.\n` +
185
+ `The Windows/Linux Go server is in preview — check https://local-mcp.com for updates.`
186
+ )
187
+ }
188
+
189
+ if (!fs.existsSync(binPath)) {
190
+ throw new Error(`Binary not found after extraction: ${binPath}`)
191
+ }
192
+ if (process.platform !== 'win32') fs.chmodSync(binPath, 0o755)
193
+ fs.writeFileSync(versionFile, version)
194
+
195
+ process.stderr.write(` Ready at ${CACHE_DIR}\n`)
196
+ return { binPath, versionDir: CACHE_DIR, version }
197
+ }
198
+
199
+ // macOS: Swift binary in a nested tarball
125
200
  const url = `https://download.local-mcp.com/local-mcp-server-${version}-${arch}.tar.gz`
126
201
 
127
202
  const versionDir = path.join(CACHE_DIR, version)
@@ -148,28 +223,19 @@ async function ensureBinary() {
148
223
  throw new Error(`Binario no encontrado después de extraer: ${binPath}`)
149
224
  }
150
225
 
151
- // Asegurar que sea ejecutable
152
226
  fs.chmodSync(binPath, 0o755)
153
227
 
154
- // Clear quarantine separately — Node.js https downloads don't set it, but tar extraction
155
- // on some macOS versions can inherit it from the archive. Non-critical.
156
- try { execFileSync('xattr', ['-d', 'com.apple.quarantine', binPath], { stdio: 'pipe' }) } catch { /* no quarantine, fine */ }
228
+ try { execFileSync('xattr', ['-d', 'com.apple.quarantine', binPath], { stdio: 'pipe' }) } catch {}
157
229
 
158
- // Preserve the Developer ID signature from the build pipeline.
159
- // On macOS 26+ ad-hoc signatures (--sign -) don't create a stable TCC identity:
160
- // each new CDHash is treated as a new app, triggering "node would like to access
161
- // data from other apps" on every JXA call. Only fall back to ad-hoc if the binary
162
- // has no valid signature at all (e.g. a dev build that was never properly signed).
163
- // See: FB-74E468, FB-017B8E — permission prompt loop on macOS 26 beta.
164
230
  let hasSig = false
165
231
  try {
166
232
  execFileSync('codesign', ['--verify', binPath], { stdio: 'pipe' })
167
233
  hasSig = true
168
- } catch { /* no valid signature — apply ad-hoc fallback */ }
234
+ } catch {}
169
235
  if (!hasSig) {
170
236
  try {
171
237
  execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', binPath], { stdio: 'pipe' })
172
- } catch { /* non-critical */ }
238
+ } catch {}
173
239
  }
174
240
 
175
241
  process.stderr.write(` Listo en ${versionDir}\n`)
package/index.js CHANGED
@@ -18,9 +18,24 @@ const fs = require('fs')
18
18
  const https = require('https')
19
19
 
20
20
 
21
- // Solo macOS
22
- if (process.platform !== 'darwin') {
23
- console.error('LMCP solo está disponible para macOS.')
21
+ // Platform support: macOS (full), Windows (Go server), Linux (Go server)
22
+ const SUPPORTED_PLATFORMS = ['darwin', 'win32', 'linux']
23
+ if (!SUPPORTED_PLATFORMS.includes(process.platform)) {
24
+ console.error(`LMCP is not available for ${process.platform}. Supported: macOS, Windows, Linux.`)
25
+ process.exit(1)
26
+ }
27
+
28
+ // Node version guard — Claude Desktop can silently launch LMCP with an old
29
+ // nvm default (e.g. v11) that makes `npx -y` fail before this file loads.
30
+ // If this code IS running on an old Node, surface the error clearly so the
31
+ // user sees it in Claude Desktop's MCP logs instead of a cryptic spawn error.
32
+ if (parseInt(process.version.slice(1)) < 16) {
33
+ const msg = `LMCP requires Node 16+. Running ${process.version}.\n` +
34
+ `If you use nvm, run: nvm alias default 22 && nvm uninstall ${process.version}\n` +
35
+ 'Then restart Claude Desktop / Cursor.'
36
+ process.stderr.write(msg + '\n')
37
+ // Exit with a non-zero code so the MCP host marks the server as failed
38
+ // (vs hanging forever with no output).
24
39
  process.exit(1)
25
40
  }
26
41
 
@@ -75,7 +90,8 @@ async function main() {
75
90
 
76
91
  // ── Modo default: stdio MCP server ──────────────────────────────────────────
77
92
  const { CACHE_DIR } = require('./download')
78
- const stableBin = path.join(CACHE_DIR, 'local-mcp-server')
93
+ const binName = process.platform === 'win32' ? 'local-mcp-server.exe' : 'local-mcp-server'
94
+ const stableBin = path.join(CACHE_DIR, binName)
79
95
  const versionFile = path.join(CACHE_DIR, '.server-version')
80
96
  const pkg = require('./package.json')
81
97
 
@@ -103,11 +119,13 @@ async function main() {
103
119
  : ''
104
120
  if (cachedVersion && semverGte(cachedVersion, pkg.version)) {
105
121
  // Cached binary is same or newer — use fast path, no download needed
106
- const { ensureTray, ensureTeamsProxy, ensureHelper, ensureJXARunner } = require('./download')
107
- ensureTray().catch(() => {})
108
- ensureTeamsProxy().catch(() => {})
109
- ensureHelper().catch(() => {})
110
- ensureJXARunner().catch(() => {})
122
+ if (process.platform === 'darwin') {
123
+ const { ensureTray, ensureTeamsProxy, ensureHelper, ensureJXARunner } = require('./download')
124
+ ensureTray().catch(() => {})
125
+ ensureTeamsProxy().catch(() => {})
126
+ ensureHelper().catch(() => {})
127
+ ensureJXARunner().catch(() => {})
128
+ }
111
129
  const child = spawn(stableBin, [], { stdio: 'inherit', env: process.env })
112
130
  child.on('error', () => process.exit(1))
113
131
  child.on('exit', (code) => process.exit(code ?? 0))
@@ -136,7 +154,7 @@ async function main() {
136
154
  const tmpPath = stableBin + '.tmp'
137
155
  fs.copyFileSync(binPath, tmpPath)
138
156
  fs.chmodSync(tmpPath, 0o755)
139
- try { execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', tmpPath], { stdio: 'pipe' }) } catch {}
157
+ if (process.platform === 'darwin') { try { execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', tmpPath], { stdio: 'pipe' }) } catch {} }
140
158
  fs.renameSync(tmpPath, stableBin)
141
159
  fs.writeFileSync(versionFile, downloadedVersion || latestVersion)
142
160
  } catch {}
@@ -165,11 +183,13 @@ async function main() {
165
183
  // Slow path: download/repair binary
166
184
  const { ensureRuntime, ensureTray, ensureTeamsProxy, ensureHelper, ensureJXARunner } = require('./download')
167
185
 
168
- // Update tray + proxies in background. Helper + jxa-runner downloaded only on first install.
169
- ensureTray().catch(() => {})
170
- ensureTeamsProxy().catch(() => {})
171
- ensureHelper().catch(() => {})
172
- ensureJXARunner().catch(() => {})
186
+ // Update tray + proxies in background (macOS only).
187
+ if (process.platform === 'darwin') {
188
+ ensureTray().catch(() => {})
189
+ ensureTeamsProxy().catch(() => {})
190
+ ensureHelper().catch(() => {})
191
+ ensureJXARunner().catch(() => {})
192
+ }
173
193
 
174
194
  let runtime
175
195
  try {
@@ -187,7 +207,7 @@ async function main() {
187
207
  const tmpPath = stableBin + '.tmp'
188
208
  fs.copyFileSync(binPath, tmpPath)
189
209
  fs.chmodSync(tmpPath, 0o755)
190
- try { execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', tmpPath], { stdio: 'pipe' }) } catch {}
210
+ if (process.platform === 'darwin') { try { execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', tmpPath], { stdio: 'pipe' }) } catch {} }
191
211
  fs.renameSync(tmpPath, stableBin)
192
212
  // Write the ACTUAL downloaded binary version (not pkg.version) so the fast path
193
213
  // knows what version is on disk. Using pkg.version here caused LMC-375: machines
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-mcp",
3
- "version": "3.0.137",
3
+ "version": "3.0.139",
4
4
  "description": "LMCP — connect Claude Desktop, Cursor, Windsurf to Mail, Calendar, Contacts, Teams, OneDrive on macOS. Privacy-first: all data stays on your Mac.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -20,7 +20,9 @@
20
20
  "node": ">=18"
21
21
  },
22
22
  "os": [
23
- "darwin"
23
+ "darwin",
24
+ "win32",
25
+ "linux"
24
26
  ],
25
27
  "keywords": [
26
28
  "mcp",
package/setup.js CHANGED
@@ -16,17 +16,83 @@ const { execSync, execFileSync } = require('child_process')
16
16
  const HOME = os.homedir()
17
17
  const NPX_COMMAND = 'npx'
18
18
  const NPX_ARGS = ['-y', 'local-mcp@latest']
19
+
20
+ // Resolve absolute path to npx from the current Node binary so Claude Desktop
21
+ // doesn't pick up an old npx from its own PATH (common with nvm users whose
22
+ // nvm default points to Node v11/v12 — those npx versions don't support -y).
23
+ // Falls back to plain 'npx' if resolution fails (non-nvm setups, symlinks, etc.)
24
+ function _resolveNpxPath() {
25
+ try {
26
+ const candidate = path.join(path.dirname(process.execPath), 'npx')
27
+ if (fs.existsSync(candidate)) return candidate
28
+ } catch {}
29
+ return NPX_COMMAND
30
+ }
31
+
32
+ // Write a shell launcher script that wraps npx with a Node version check and
33
+ // curl-based telemetry. This gives us visibility into failures that happen
34
+ // BEFORE Node loads our code (e.g. old nvm default in Claude Desktop's PATH).
35
+ // Returns the path to the launcher, or null if writing fails.
36
+ function _writeLaunchScript(npxAbsPath, cacheDir) {
37
+ try {
38
+ fs.mkdirSync(cacheDir, { recursive: true })
39
+ const launcherPath = path.join(cacheDir, 'lmcp-launch.sh')
40
+ const script = [
41
+ '#!/bin/bash',
42
+ '# LMCP launcher — generated by npx local-mcp setup, do not edit.',
43
+ '# Checks Node version before launching npx; sends telemetry if too old.',
44
+ 'NODE_VER=$(node --version 2>/dev/null || echo "none")',
45
+ 'NODE_MAJOR=$(echo "$NODE_VER" | sed \'s/v//\' | cut -d. -f1)',
46
+ 'if [ "${NODE_MAJOR:-0}" -lt 16 ] 2>/dev/null; then',
47
+ ' # curl telemetry — no Node dependency, fire-and-forget',
48
+ ` curl -sf --max-time 3 -X POST "https://${BACKEND_HOST}/install-event" \\`,
49
+ ' -H "Content-Type: application/json" \\',
50
+ ' -d "{\\\"stage\\\":\\\"node_too_old\\\",\\\"version\\\":\\\"$NODE_VER\\\"}" &>/dev/null &',
51
+ ' printf \'LMCP requires Node 16+. Current: %s\\nFix: nvm alias default 22 && nvm uninstall %s — then restart Claude Desktop.\\n\' "$NODE_VER" "$NODE_VER" >&2',
52
+ ' exit 1',
53
+ 'fi',
54
+ `exec "${npxAbsPath}" -y local-mcp@latest "$@"`,
55
+ ].join('\n') + '\n'
56
+ fs.writeFileSync(launcherPath, script, { mode: 0o755 })
57
+ return launcherPath
58
+ } catch {
59
+ return null
60
+ }
61
+ }
19
62
  const STABLE_LINK = path.join(os.homedir(), '.local', 'share', 'local-mcp', 'bin', 'local-mcp-server')
20
63
  const BACKEND_HOST = 'office-mcp-production.up.railway.app'
21
64
 
22
- // ── Rutas de config de cada cliente ──────────────────────────────────────────
65
+ // ── Platform-aware config paths for AI clients ─────────────────────────────
66
+ // macOS: ~/Library/Application Support/...
67
+ // Windows: %APPDATA%/... (most Electron apps use APPDATA)
68
+ // Linux: ~/.config/... (XDG standard)
69
+
70
+ const _IS_WIN = process.platform === 'win32'
71
+ const _IS_MAC = process.platform === 'darwin'
72
+ const _APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming')
73
+
74
+ function _claudeDesktopPath() {
75
+ if (_IS_WIN) return path.join(_APPDATA, 'Claude', 'claude_desktop_config.json')
76
+ if (_IS_MAC) return path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
77
+ return path.join(HOME, '.config', 'Claude', 'claude_desktop_config.json')
78
+ }
79
+
80
+ function _claudeDesktopDetect() {
81
+ return fs.existsSync(path.dirname(_claudeDesktopPath()))
82
+ }
83
+
84
+ function _rooClinePath() {
85
+ if (_IS_WIN) return path.join(_APPDATA, 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json')
86
+ if (_IS_MAC) return path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json')
87
+ return path.join(HOME, '.config', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json')
88
+ }
23
89
 
24
90
  const CLIENTS = [
25
91
  {
26
92
  id: 'claude-desktop',
27
93
  name: 'Claude Desktop',
28
- cfgPath: path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
29
- detect: () => fs.existsSync(path.join(HOME, 'Library', 'Application Support', 'Claude')),
94
+ cfgPath: _claudeDesktopPath(),
95
+ detect: _claudeDesktopDetect,
30
96
  },
31
97
  {
32
98
  id: 'cursor',
@@ -50,8 +116,8 @@ const CLIENTS = [
50
116
  {
51
117
  id: 'roo-cline',
52
118
  name: 'Roo-Cline',
53
- cfgPath: path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json'),
54
- detect: () => fs.existsSync(path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline')),
119
+ cfgPath: _rooClinePath(),
120
+ detect: () => fs.existsSync(path.dirname(_rooClinePath())),
55
121
  },
56
122
  {
57
123
  id: 'zed',
@@ -71,12 +137,25 @@ const CLIENTS = [
71
137
  // ── Helpers ───────────────────────────────────────────────────────────────────
72
138
 
73
139
  function _appExists(name) {
74
- return fs.existsSync(`/Applications/${name}.app`) ||
75
- fs.existsSync(path.join(HOME, `Applications/${name}.app`))
140
+ if (_IS_MAC) {
141
+ return fs.existsSync(`/Applications/${name}.app`) ||
142
+ fs.existsSync(path.join(HOME, `Applications/${name}.app`))
143
+ }
144
+ if (_IS_WIN) {
145
+ // Check common Windows install locations
146
+ const pf = process.env.ProgramFiles || 'C:\\Program Files'
147
+ const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
148
+ const localApp = process.env.LOCALAPPDATA || path.join(HOME, 'AppData', 'Local')
149
+ return fs.existsSync(path.join(pf, name)) ||
150
+ fs.existsSync(path.join(pf86, name)) ||
151
+ fs.existsSync(path.join(localApp, name))
152
+ }
153
+ return false // Linux: rely on _cmdExists or config path detection
76
154
  }
77
155
 
78
156
  function _cmdExists(cmd) {
79
- try { execSync(`which ${cmd}`, { stdio: 'pipe' }); return true } catch { return false }
157
+ const which = _IS_WIN ? 'where' : 'which'
158
+ try { execSync(`${which} ${cmd}`, { stdio: 'pipe' }); return true } catch { return false }
80
159
  }
81
160
 
82
161
  // ── Safe config read ─────────────────────────────────────────────────────────
@@ -212,9 +291,21 @@ async function runSetup(opts = {}) {
212
291
  console.log('║ LMCP — Setup Wizard ║')
213
292
  console.log('╚══════════════════════════════════════╝\n')
214
293
 
294
+ // Warn if running on an old Node version. Claude Desktop resolves 'npx' from
295
+ // its own PATH (not the user's shell), so an old nvm default (v11/v12) causes
296
+ // a silent "You must supply a command" failure on first launch.
297
+ const nodeMajor = parseInt(process.version.slice(1))
298
+ if (nodeMajor < 16) {
299
+ console.error(`\n⚠️ Warning: you're running Node ${process.version}.`)
300
+ console.error(' Claude Desktop may fail to start LMCP because it finds an old npx on its PATH.')
301
+ console.error(` Fix: nvm alias default 22 && nvm uninstall ${process.version} (then relaunch Claude Desktop)\n`)
302
+ }
303
+
215
304
  // Pre-download binary so first launch is instant
216
- // All clients use npx as launcher (self-healing if binary is missing/broken)
217
- let stableCommand = NPX_COMMAND
305
+ // All clients use a shell launcher that wraps npx (self-healing if binary is
306
+ // missing/broken, and surfaces telemetry + clear errors if Node is too old).
307
+ let npxAbsPath = _resolveNpxPath() // baked into the launcher script
308
+ let stableCommand = NPX_COMMAND // fallback; replaced below once CACHE_DIR is known
218
309
  let stableArgs = NPX_ARGS
219
310
  let binaryVersion = ''
220
311
  try {
@@ -252,11 +343,25 @@ async function runSetup(opts = {}) {
252
343
  if (fs.existsSync(settingsSrc)) fs.copyFileSync(settingsSrc, settingsDst)
253
344
  } catch {}
254
345
  process.stderr.write('✓ Runtime ready\n\n')
346
+ // Write the launcher script now that we have CACHE_DIR.
347
+ // The launcher uses curl (no Node dependency) to send telemetry if Node is
348
+ // too old, then exec's npx. Claude Desktop runs this script instead of npx
349
+ // directly, so failures are visible in MCP logs AND in our backend events.
350
+ const launcherPath = _writeLaunchScript(npxAbsPath, CACHE_DIR)
351
+ if (launcherPath) stableCommand = launcherPath
255
352
  } catch (err) {
256
353
  process.stderr.write(` (Runtime download failed, will download on first run: ${err.message})\n\n`)
354
+ // Still try to write a launcher even if binary download failed — the version
355
+ // check + telemetry path is independent of the binary.
356
+ try {
357
+ const { CACHE_DIR } = require('./download')
358
+ const launcherPath = _writeLaunchScript(npxAbsPath, CACHE_DIR)
359
+ if (launcherPath) stableCommand = launcherPath
360
+ } catch {}
257
361
  }
258
- // Always use npx as the command it has a fast-path that execs the binary directly
259
- // but can self-heal if the binary is missing or broken
362
+ // stableCommand is now the shell launcher script path. It wraps npx with a
363
+ // Node version check + curl telemetry before exec'ing. If launcher creation
364
+ // failed it falls back to plain 'npx'.
260
365
 
261
366
  // Detectar clientes
262
367
  const detected = CLIENTS.filter(c => c.detect())