local-mcp 3.0.136 → 3.0.138

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 (3) hide show
  1. package/index.js +14 -0
  2. package/package.json +1 -1
  3. package/setup.js +273 -37
package/index.js CHANGED
@@ -24,6 +24,20 @@ if (process.platform !== 'darwin') {
24
24
  process.exit(1)
25
25
  }
26
26
 
27
+ // Node version guard — Claude Desktop can silently launch LMCP with an old
28
+ // nvm default (e.g. v11) that makes `npx -y` fail before this file loads.
29
+ // If this code IS running on an old Node, surface the error clearly so the
30
+ // user sees it in Claude Desktop's MCP logs instead of a cryptic spawn error.
31
+ if (parseInt(process.version.slice(1)) < 16) {
32
+ const msg = `LMCP requires Node 16+. Running ${process.version}.\n` +
33
+ 'If you use nvm, run: nvm alias default 22\n' +
34
+ 'Then restart Claude Desktop / Cursor.'
35
+ process.stderr.write(msg + '\n')
36
+ // Exit with a non-zero code so the MCP host marks the server as failed
37
+ // (vs hanging forever with no output).
38
+ process.exit(1)
39
+ }
40
+
27
41
  const cmd = process.argv[2]
28
42
 
29
43
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-mcp",
3
- "version": "3.0.136",
3
+ "version": "3.0.138",
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": {
package/setup.js CHANGED
@@ -16,6 +16,49 @@ 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 — then restart Claude Desktop.\\n\' "$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
 
@@ -41,10 +84,17 @@ const CLIENTS = [
41
84
  detect: () => fs.existsSync(path.join(HOME, '.codeium', 'windsurf')) || _appExists('Windsurf'),
42
85
  },
43
86
  {
44
- id: 'vscode-cline',
45
- name: 'VS Code (Cline)',
87
+ id: 'vscode',
88
+ name: 'VS Code',
46
89
  cfgPath: path.join(HOME, '.vscode', 'mcp.json'),
47
- detect: () => _cmdExists('code') || fs.existsSync(path.join(HOME, '.vscode')),
90
+ detect: () => _cmdExists('code') || _appExists('Visual Studio Code') || fs.existsSync(path.join(HOME, '.vscode')),
91
+ vscode: true, // native MCP uses "servers" key + type:"stdio"
92
+ },
93
+ {
94
+ id: 'roo-cline',
95
+ name: 'Roo-Cline',
96
+ cfgPath: path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json'),
97
+ detect: () => fs.existsSync(path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline')),
48
98
  },
49
99
  {
50
100
  id: 'zed',
@@ -72,42 +122,110 @@ function _cmdExists(cmd) {
72
122
  try { execSync(`which ${cmd}`, { stdio: 'pipe' }); return true } catch { return false }
73
123
  }
74
124
 
75
- function _readJson(filePath) {
125
+ // ── Safe config read ─────────────────────────────────────────────────────────
126
+ // Returns { data, wasEmpty, hadParseError, error }.
127
+ // If the file exists but JSON.parse fails we BACK IT UP and return hadParseError=true
128
+ // so callers can skip the write instead of silently wiping the user's other MCPs.
129
+
130
+ function _safeReadConfig(filePath) {
131
+ if (!fs.existsSync(filePath)) {
132
+ return { data: {}, wasEmpty: true, hadParseError: false, error: '' }
133
+ }
134
+ let raw
135
+ try { raw = fs.readFileSync(filePath, 'utf8').trim() } catch (e) {
136
+ return { data: {}, wasEmpty: true, hadParseError: false, error: e.message }
137
+ }
138
+ if (!raw || raw === '{}') {
139
+ return { data: {}, wasEmpty: true, hadParseError: false, error: '' }
140
+ }
76
141
  try {
77
- return JSON.parse(fs.readFileSync(filePath, 'utf8'))
78
- } catch {
79
- return {}
142
+ return { data: JSON.parse(raw), wasEmpty: false, hadParseError: false, error: '' }
143
+ } catch (e) {
144
+ // Existing config is not valid JSON — back it up, never overwrite
145
+ try { fs.copyFileSync(filePath, filePath + '.lmcp-backup') } catch {}
146
+ return { data: null, wasEmpty: false, hadParseError: true, error: e.message }
80
147
  }
81
148
  }
82
149
 
83
- function _writeJson(filePath, data) {
84
- fs.mkdirSync(path.dirname(filePath), { recursive: true })
85
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8')
150
+ // ── Atomic write ──────────────────────────────────────────────────────────────
151
+ // Writes to a .tmp file then renames (atomic on POSIX). Verifies the written
152
+ // file is valid JSON and contains the local-mcp entry before returning ok=true.
153
+
154
+ function _atomicWriteConfig(filePath, data) {
155
+ const json = JSON.stringify(data, null, 2) + '\n'
156
+ try { JSON.parse(json) } catch (e) {
157
+ return { ok: false, error: 'merge produced invalid JSON: ' + e.message }
158
+ }
159
+ const tmpPath = filePath + '.lmcp-tmp'
160
+ try {
161
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
162
+ fs.writeFileSync(tmpPath, json, 'utf8')
163
+ fs.renameSync(tmpPath, filePath)
164
+ } catch (e) {
165
+ try { fs.unlinkSync(tmpPath) } catch {}
166
+ return { ok: false, error: e.message }
167
+ }
168
+ // Post-write verify
169
+ try {
170
+ const v = JSON.parse(fs.readFileSync(filePath, 'utf8'))
171
+ const hasEntry = !!(
172
+ (v.mcpServers && v.mcpServers['local-mcp']) ||
173
+ (v.servers && v.servers['local-mcp']) ||
174
+ (v.context_servers && v.context_servers['local-mcp'])
175
+ )
176
+ if (!hasEntry) return { ok: false, error: 'verify failed: local-mcp not found after write' }
177
+ } catch (e) {
178
+ return { ok: false, error: 'verify read failed: ' + e.message }
179
+ }
180
+ return { ok: true, error: '' }
86
181
  }
87
182
 
88
- // ── Inyectar config MCP en un cliente estándar ────────────────────────────────
183
+ // ── Inyectar config MCP en un cliente ────────────────────────────────────────
184
+ // Returns { ok, stage, error, existingMcpCount, preservedServers }.
185
+ // Never overwrites a file whose JSON cannot be parsed.
89
186
 
90
187
  function injectMcpConfig(client, command = NPX_COMMAND, args = NPX_ARGS) {
91
- const cfg = _readJson(client.cfgPath)
188
+ // 1. Read existing config safely
189
+ const read = _safeReadConfig(client.cfgPath)
190
+ if (read.hadParseError) {
191
+ return { ok: false, stage: 'config_read', error: read.error, hadParseError: true,
192
+ existingMcpCount: 0, preservedServers: [] }
193
+ }
194
+
195
+ const cfg = read.data || {}
92
196
 
197
+ // Count other MCP servers before merge (for telemetry)
198
+ const existingServers = Object.keys(
199
+ cfg.mcpServers || cfg.servers || cfg.context_servers || {}
200
+ )
201
+ const otherServers = existingServers.filter(k => k !== 'local-mcp' && k !== 'office-mcp')
202
+
203
+ // 2. Merge — preserve everything, only add/replace local-mcp
93
204
  if (client.zed) {
94
- // Zed usa { "context_servers": { "local-mcp": { "command": {...} } } }
95
205
  cfg.context_servers = cfg.context_servers || {}
96
- cfg.context_servers['local-mcp'] = {
97
- command: { path: command, args: args ?? [] },
98
- }
206
+ cfg.context_servers['local-mcp'] = { command: { path: command, args: args ?? [] } }
207
+ } else if (client.vscode) {
208
+ // VS Code native MCP (1.99+) uses "servers" key with type:"stdio"
209
+ cfg.servers = cfg.servers || {}
210
+ cfg.servers['local-mcp'] = { type: 'stdio', command, args: args ?? [] }
99
211
  } else {
100
- // Formato estándar MCP: { "mcpServers": { "local-mcp": { "command": "...", "args": [...] } } }
101
212
  cfg.mcpServers = cfg.mcpServers || {}
102
- // Limpiar entradas legacy
103
213
  delete cfg.mcpServers['office-mcp']
104
214
  const entry = { command }
105
215
  if (args !== undefined) entry.args = args
106
216
  cfg.mcpServers['local-mcp'] = entry
107
217
  }
108
218
 
109
- _writeJson(client.cfgPath, cfg)
110
- return true
219
+ // 3. Atomic write + verify
220
+ const write = _atomicWriteConfig(client.cfgPath, cfg)
221
+ return {
222
+ ok: write.ok,
223
+ stage: write.ok ? 'config_write' : 'config_write_failed',
224
+ error: write.error,
225
+ hadParseError: false,
226
+ existingMcpCount: existingServers.length,
227
+ preservedServers: otherServers,
228
+ }
111
229
  }
112
230
 
113
231
  // ── Main ──────────────────────────────────────────────────────────────────────
@@ -137,9 +255,21 @@ async function runSetup(opts = {}) {
137
255
  console.log('║ LMCP — Setup Wizard ║')
138
256
  console.log('╚══════════════════════════════════════╝\n')
139
257
 
258
+ // Warn if running on an old Node version. Claude Desktop resolves 'npx' from
259
+ // its own PATH (not the user's shell), so an old nvm default (v11/v12) causes
260
+ // a silent "You must supply a command" failure on first launch.
261
+ const nodeMajor = parseInt(process.version.slice(1))
262
+ if (nodeMajor < 16) {
263
+ console.error(`\n⚠️ Warning: you're running Node ${process.version}.`)
264
+ console.error(' Claude Desktop may fail to start LMCP because it finds an old npx on its PATH.')
265
+ console.error(' Fix: nvm alias default 22 (then relaunch Claude Desktop)\n')
266
+ }
267
+
140
268
  // Pre-download binary so first launch is instant
141
- // All clients use npx as launcher (self-healing if binary is missing/broken)
142
- let stableCommand = NPX_COMMAND
269
+ // All clients use a shell launcher that wraps npx (self-healing if binary is
270
+ // missing/broken, and surfaces telemetry + clear errors if Node is too old).
271
+ let npxAbsPath = _resolveNpxPath() // baked into the launcher script
272
+ let stableCommand = NPX_COMMAND // fallback; replaced below once CACHE_DIR is known
143
273
  let stableArgs = NPX_ARGS
144
274
  let binaryVersion = ''
145
275
  try {
@@ -177,14 +307,33 @@ async function runSetup(opts = {}) {
177
307
  if (fs.existsSync(settingsSrc)) fs.copyFileSync(settingsSrc, settingsDst)
178
308
  } catch {}
179
309
  process.stderr.write('✓ Runtime ready\n\n')
310
+ // Write the launcher script now that we have CACHE_DIR.
311
+ // The launcher uses curl (no Node dependency) to send telemetry if Node is
312
+ // too old, then exec's npx. Claude Desktop runs this script instead of npx
313
+ // directly, so failures are visible in MCP logs AND in our backend events.
314
+ const launcherPath = _writeLaunchScript(npxAbsPath, CACHE_DIR)
315
+ if (launcherPath) stableCommand = launcherPath
180
316
  } catch (err) {
181
317
  process.stderr.write(` (Runtime download failed, will download on first run: ${err.message})\n\n`)
318
+ // Still try to write a launcher even if binary download failed — the version
319
+ // check + telemetry path is independent of the binary.
320
+ try {
321
+ const { CACHE_DIR } = require('./download')
322
+ const launcherPath = _writeLaunchScript(npxAbsPath, CACHE_DIR)
323
+ if (launcherPath) stableCommand = launcherPath
324
+ } catch {}
182
325
  }
183
- // Always use npx as the command it has a fast-path that execs the binary directly
184
- // but can self-heal if the binary is missing or broken
326
+ // stableCommand is now the shell launcher script path. It wraps npx with a
327
+ // Node version check + curl telemetry before exec'ing. If launcher creation
328
+ // failed it falls back to plain 'npx'.
185
329
 
186
330
  // Detectar clientes
187
331
  const detected = CLIENTS.filter(c => c.detect())
332
+ const notDetected = CLIENTS.filter(c => !c.detect())
333
+ _trackSetupStep('detect', detected.length > 0 ? 'ok' : 'no_clients', '', {
334
+ clientsFound: detected.map(c => c.id),
335
+ clientsNotFound: notDetected.map(c => c.id),
336
+ })
188
337
 
189
338
  if (detected.length === 0) {
190
339
  const entry = stableArgs !== undefined
@@ -222,14 +371,25 @@ async function runSetup(opts = {}) {
222
371
  const failed = []
223
372
 
224
373
  for (const client of detected) {
225
- try {
226
- injectMcpConfig(client, stableCommand, stableArgs)
374
+ const result = injectMcpConfig(client, stableCommand, stableArgs)
375
+ if (result.ok) {
376
+ _trackSetupStep('config_write', 'ok', client.id, {
377
+ existingMcpCount: result.existingMcpCount,
378
+ preservedServers: result.preservedServers,
379
+ })
227
380
  _trackConfigWritten(client.id || client.name, client.name)
228
381
  configured.push(client.name)
229
382
  console.log(`✓ ${client.name} configured`)
230
- } catch (err) {
231
- failed.push(client.name)
232
- console.error(`✗ ${client.name}: ${err.message}`)
383
+ } else {
384
+ const status = result.hadParseError ? 'parse_error' : 'write_error'
385
+ _trackSetupStep('config_write', status, client.id, { error: result.error })
386
+ failed.push({ name: client.name, reason: status, error: result.error })
387
+ if (result.hadParseError) {
388
+ console.error(`✗ ${client.name}: existing config has invalid JSON — backed up to ${client.cfgPath}.lmcp-backup`)
389
+ console.error(` Edit or remove the backup to fix: ${result.error}`)
390
+ } else {
391
+ console.error(`✗ ${client.name}: ${result.error}`)
392
+ }
233
393
  }
234
394
  }
235
395
 
@@ -240,10 +400,11 @@ async function runSetup(opts = {}) {
240
400
  let email = process.env.LMCP_EMAIL || ''
241
401
  if (email) {
242
402
  try {
243
- const cfg = _readJson(cfgFile)
403
+ const r = _safeReadConfig(cfgFile)
404
+ const cfg = r.data || {}
244
405
  if (!cfg.license_email) {
245
406
  cfg.license_email = email
246
- _writeJson(cfgFile, cfg)
407
+ _atomicWriteConfig(cfgFile, cfg)
247
408
  console.log(`✓ Email saved: ${email}`)
248
409
  }
249
410
  } catch { /* config not created yet — will be created on first run */ }
@@ -293,8 +454,9 @@ async function runSetup(opts = {}) {
293
454
  email = ans
294
455
  emailPromptResult = 'submitted'
295
456
  try {
296
- const cfg = _readJson(cfgFile)
297
- if (!cfg.license_email) { cfg.license_email = email; _writeJson(cfgFile, cfg) }
457
+ const r = _safeReadConfig(cfgFile)
458
+ const cfg = r.data || {}
459
+ if (!cfg.license_email) { cfg.license_email = email; _atomicWriteConfig(cfgFile, cfg) }
298
460
  } catch {}
299
461
  console.log(' ✓ Email saved')
300
462
  } else {
@@ -308,6 +470,13 @@ async function runSetup(opts = {}) {
308
470
 
309
471
  // Health check — verify binary works before showing success
310
472
  const healthOk = _runHealthCheck()
473
+ _trackSetupStep('binary_health', healthOk ? 'ok' : 'failed', '', {})
474
+
475
+ // setup_complete summary
476
+ _trackSetupStep('setup_complete', configured.length > 0 ? 'ok' : 'failed', '', {
477
+ clientsFound: configured,
478
+ clientsNotFound: failed.map(f => f.name || f),
479
+ })
311
480
 
312
481
  const hasCursor = configured.includes('Cursor')
313
482
 
@@ -340,17 +509,19 @@ async function runSetup(opts = {}) {
340
509
  } else {
341
510
  const primaryClient = configured[0]
342
511
  console.log('┌─────────────────────────────────────────────────────┐')
343
- console.log('│ NEXT STEP — This is important: │')
512
+ console.log('│ NEXT STEP — restart to activate: │')
344
513
  console.log('│ │')
345
- console.log(`│ Quit and reopen ${primaryClient.padEnd(35)}│`)
346
- console.log('│ LMCP will appear automatically. │')
514
+ console.log(`│ 1. Quit ${primaryClient} completely (Cmd+Q)${''.padEnd(Math.max(0, 30 - primaryClient.length))}│`)
515
+ console.log(`│ 2. Reopen ${primaryClient.padEnd(41)}│`)
347
516
  console.log('│ │')
348
517
  console.log('│ Then try: "Summarize my unread emails" │')
349
518
  console.log('└─────────────────────────────────────────────────────┘\n')
519
+ _trackRestartPrompted(primaryClient)
350
520
  }
351
521
  }
352
522
  if (failed.length > 0) {
353
- console.log(`⚠ Could not configure: ${failed.join(', ')} — check permissions and try again.\n`)
523
+ const failNames = failed.map(f => (typeof f === 'string' ? f : f.name)).join(', ')
524
+ console.log(`⚠ Could not configure: ${failNames} — check permissions and try again.\n`)
354
525
  }
355
526
  console.log(' Setup guide & troubleshooting: https://local-mcp.com/setup\n')
356
527
 
@@ -540,6 +711,46 @@ function _migrateCurlLaunchAgent() {
540
711
  } catch { /* non-fatal — don't block install on unexpected plist content */ }
541
712
  }
542
713
 
714
+ // ── Setup funnel telemetry ────────────────────────────────────────────────────
715
+ // Fire-and-forget. Sends one event per step to /setup-step so we can see exactly
716
+ // where each machine's install funnel breaks without waiting for a heartbeat.
717
+
718
+ function _trackSetupStep(step, status, client, detail) {
719
+ try {
720
+ const https = require('https')
721
+ const payload = {
722
+ machine_id: _getMachineId(),
723
+ install_id: process.env.INSTALL_ID || '',
724
+ method: process.env.LMCP_METHOD || 'npm',
725
+ step,
726
+ status,
727
+ client: client || '',
728
+ error: (detail && detail.error) ? String(detail.error).slice(0, 300) : '',
729
+ }
730
+ if (detail) {
731
+ if (detail.existingMcpCount >= 0) payload.existing_mcp_count = detail.existingMcpCount
732
+ if (detail.preservedServers && detail.preservedServers.length)
733
+ payload.preserved_servers = detail.preservedServers.join(',')
734
+ if (detail.clientsFound) payload.clients_found = detail.clientsFound.join(',')
735
+ if (detail.clientsNotFound) payload.clients_not_found = detail.clientsNotFound.join(',')
736
+ if (detail.toolCount >= 0) payload.tool_count = detail.toolCount
737
+ if (detail.binaryVersion) payload.binary_version = detail.binaryVersion
738
+ }
739
+ const data = JSON.stringify(payload)
740
+ const req = https.request({
741
+ hostname: BACKEND_HOST,
742
+ path: '/setup-step',
743
+ method: 'POST',
744
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
745
+ timeout: 5000,
746
+ })
747
+ req.on('response', (res) => res.resume())
748
+ req.on('error', () => {})
749
+ req.write(data)
750
+ req.end()
751
+ } catch { /* non-fatal */ }
752
+ }
753
+
543
754
  function _trackConfigWritten(clientId, clientName) {
544
755
  try {
545
756
  const https = require('https')
@@ -594,6 +805,31 @@ function _trackEmailPrompt(result, email, errorMsg) {
594
805
  } catch { /* non-fatal */ }
595
806
  }
596
807
 
808
+ function _trackRestartPrompted(clientName) {
809
+ try {
810
+ const https = require('https')
811
+ const machineId = _getMachineId()
812
+ const payload = {
813
+ stage: 'restart_prompted',
814
+ client_name: clientName,
815
+ machine_id: machineId,
816
+ install_id: process.env.INSTALL_ID || '',
817
+ }
818
+ const data = JSON.stringify(payload)
819
+ const req = https.request({
820
+ hostname: BACKEND_HOST,
821
+ path: '/install-event',
822
+ method: 'POST',
823
+ headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
824
+ timeout: 5000,
825
+ })
826
+ req.on('response', (res) => res.resume())
827
+ req.on('error', () => {})
828
+ req.write(data)
829
+ req.end()
830
+ } catch { /* non-fatal */ }
831
+ }
832
+
597
833
  function _pingInstall(clients, method, email = '', binaryVersion = '') {
598
834
  try {
599
835
  const https = require('https')