local-mcp 3.0.136 → 3.0.137

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 (2) hide show
  1. package/package.json +1 -1
  2. package/setup.js +200 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-mcp",
3
- "version": "3.0.136",
3
+ "version": "3.0.137",
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
@@ -41,10 +41,17 @@ const CLIENTS = [
41
41
  detect: () => fs.existsSync(path.join(HOME, '.codeium', 'windsurf')) || _appExists('Windsurf'),
42
42
  },
43
43
  {
44
- id: 'vscode-cline',
45
- name: 'VS Code (Cline)',
44
+ id: 'vscode',
45
+ name: 'VS Code',
46
46
  cfgPath: path.join(HOME, '.vscode', 'mcp.json'),
47
- detect: () => _cmdExists('code') || fs.existsSync(path.join(HOME, '.vscode')),
47
+ detect: () => _cmdExists('code') || _appExists('Visual Studio Code') || fs.existsSync(path.join(HOME, '.vscode')),
48
+ vscode: true, // native MCP uses "servers" key + type:"stdio"
49
+ },
50
+ {
51
+ id: 'roo-cline',
52
+ 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')),
48
55
  },
49
56
  {
50
57
  id: 'zed',
@@ -72,42 +79,110 @@ function _cmdExists(cmd) {
72
79
  try { execSync(`which ${cmd}`, { stdio: 'pipe' }); return true } catch { return false }
73
80
  }
74
81
 
75
- function _readJson(filePath) {
82
+ // ── Safe config read ─────────────────────────────────────────────────────────
83
+ // Returns { data, wasEmpty, hadParseError, error }.
84
+ // If the file exists but JSON.parse fails we BACK IT UP and return hadParseError=true
85
+ // so callers can skip the write instead of silently wiping the user's other MCPs.
86
+
87
+ function _safeReadConfig(filePath) {
88
+ if (!fs.existsSync(filePath)) {
89
+ return { data: {}, wasEmpty: true, hadParseError: false, error: '' }
90
+ }
91
+ let raw
92
+ try { raw = fs.readFileSync(filePath, 'utf8').trim() } catch (e) {
93
+ return { data: {}, wasEmpty: true, hadParseError: false, error: e.message }
94
+ }
95
+ if (!raw || raw === '{}') {
96
+ return { data: {}, wasEmpty: true, hadParseError: false, error: '' }
97
+ }
76
98
  try {
77
- return JSON.parse(fs.readFileSync(filePath, 'utf8'))
78
- } catch {
79
- return {}
99
+ return { data: JSON.parse(raw), wasEmpty: false, hadParseError: false, error: '' }
100
+ } catch (e) {
101
+ // Existing config is not valid JSON — back it up, never overwrite
102
+ try { fs.copyFileSync(filePath, filePath + '.lmcp-backup') } catch {}
103
+ return { data: null, wasEmpty: false, hadParseError: true, error: e.message }
80
104
  }
81
105
  }
82
106
 
83
- function _writeJson(filePath, data) {
84
- fs.mkdirSync(path.dirname(filePath), { recursive: true })
85
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8')
107
+ // ── Atomic write ──────────────────────────────────────────────────────────────
108
+ // Writes to a .tmp file then renames (atomic on POSIX). Verifies the written
109
+ // file is valid JSON and contains the local-mcp entry before returning ok=true.
110
+
111
+ function _atomicWriteConfig(filePath, data) {
112
+ const json = JSON.stringify(data, null, 2) + '\n'
113
+ try { JSON.parse(json) } catch (e) {
114
+ return { ok: false, error: 'merge produced invalid JSON: ' + e.message }
115
+ }
116
+ const tmpPath = filePath + '.lmcp-tmp'
117
+ try {
118
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
119
+ fs.writeFileSync(tmpPath, json, 'utf8')
120
+ fs.renameSync(tmpPath, filePath)
121
+ } catch (e) {
122
+ try { fs.unlinkSync(tmpPath) } catch {}
123
+ return { ok: false, error: e.message }
124
+ }
125
+ // Post-write verify
126
+ try {
127
+ const v = JSON.parse(fs.readFileSync(filePath, 'utf8'))
128
+ const hasEntry = !!(
129
+ (v.mcpServers && v.mcpServers['local-mcp']) ||
130
+ (v.servers && v.servers['local-mcp']) ||
131
+ (v.context_servers && v.context_servers['local-mcp'])
132
+ )
133
+ if (!hasEntry) return { ok: false, error: 'verify failed: local-mcp not found after write' }
134
+ } catch (e) {
135
+ return { ok: false, error: 'verify read failed: ' + e.message }
136
+ }
137
+ return { ok: true, error: '' }
86
138
  }
87
139
 
88
- // ── Inyectar config MCP en un cliente estándar ────────────────────────────────
140
+ // ── Inyectar config MCP en un cliente ────────────────────────────────────────
141
+ // Returns { ok, stage, error, existingMcpCount, preservedServers }.
142
+ // Never overwrites a file whose JSON cannot be parsed.
89
143
 
90
144
  function injectMcpConfig(client, command = NPX_COMMAND, args = NPX_ARGS) {
91
- const cfg = _readJson(client.cfgPath)
145
+ // 1. Read existing config safely
146
+ const read = _safeReadConfig(client.cfgPath)
147
+ if (read.hadParseError) {
148
+ return { ok: false, stage: 'config_read', error: read.error, hadParseError: true,
149
+ existingMcpCount: 0, preservedServers: [] }
150
+ }
92
151
 
152
+ const cfg = read.data || {}
153
+
154
+ // Count other MCP servers before merge (for telemetry)
155
+ const existingServers = Object.keys(
156
+ cfg.mcpServers || cfg.servers || cfg.context_servers || {}
157
+ )
158
+ const otherServers = existingServers.filter(k => k !== 'local-mcp' && k !== 'office-mcp')
159
+
160
+ // 2. Merge — preserve everything, only add/replace local-mcp
93
161
  if (client.zed) {
94
- // Zed usa { "context_servers": { "local-mcp": { "command": {...} } } }
95
162
  cfg.context_servers = cfg.context_servers || {}
96
- cfg.context_servers['local-mcp'] = {
97
- command: { path: command, args: args ?? [] },
98
- }
163
+ cfg.context_servers['local-mcp'] = { command: { path: command, args: args ?? [] } }
164
+ } else if (client.vscode) {
165
+ // VS Code native MCP (1.99+) uses "servers" key with type:"stdio"
166
+ cfg.servers = cfg.servers || {}
167
+ cfg.servers['local-mcp'] = { type: 'stdio', command, args: args ?? [] }
99
168
  } else {
100
- // Formato estándar MCP: { "mcpServers": { "local-mcp": { "command": "...", "args": [...] } } }
101
169
  cfg.mcpServers = cfg.mcpServers || {}
102
- // Limpiar entradas legacy
103
170
  delete cfg.mcpServers['office-mcp']
104
171
  const entry = { command }
105
172
  if (args !== undefined) entry.args = args
106
173
  cfg.mcpServers['local-mcp'] = entry
107
174
  }
108
175
 
109
- _writeJson(client.cfgPath, cfg)
110
- return true
176
+ // 3. Atomic write + verify
177
+ const write = _atomicWriteConfig(client.cfgPath, cfg)
178
+ return {
179
+ ok: write.ok,
180
+ stage: write.ok ? 'config_write' : 'config_write_failed',
181
+ error: write.error,
182
+ hadParseError: false,
183
+ existingMcpCount: existingServers.length,
184
+ preservedServers: otherServers,
185
+ }
111
186
  }
112
187
 
113
188
  // ── Main ──────────────────────────────────────────────────────────────────────
@@ -185,6 +260,11 @@ async function runSetup(opts = {}) {
185
260
 
186
261
  // Detectar clientes
187
262
  const detected = CLIENTS.filter(c => c.detect())
263
+ const notDetected = CLIENTS.filter(c => !c.detect())
264
+ _trackSetupStep('detect', detected.length > 0 ? 'ok' : 'no_clients', '', {
265
+ clientsFound: detected.map(c => c.id),
266
+ clientsNotFound: notDetected.map(c => c.id),
267
+ })
188
268
 
189
269
  if (detected.length === 0) {
190
270
  const entry = stableArgs !== undefined
@@ -222,14 +302,25 @@ async function runSetup(opts = {}) {
222
302
  const failed = []
223
303
 
224
304
  for (const client of detected) {
225
- try {
226
- injectMcpConfig(client, stableCommand, stableArgs)
305
+ const result = injectMcpConfig(client, stableCommand, stableArgs)
306
+ if (result.ok) {
307
+ _trackSetupStep('config_write', 'ok', client.id, {
308
+ existingMcpCount: result.existingMcpCount,
309
+ preservedServers: result.preservedServers,
310
+ })
227
311
  _trackConfigWritten(client.id || client.name, client.name)
228
312
  configured.push(client.name)
229
313
  console.log(`✓ ${client.name} configured`)
230
- } catch (err) {
231
- failed.push(client.name)
232
- console.error(`✗ ${client.name}: ${err.message}`)
314
+ } else {
315
+ const status = result.hadParseError ? 'parse_error' : 'write_error'
316
+ _trackSetupStep('config_write', status, client.id, { error: result.error })
317
+ failed.push({ name: client.name, reason: status, error: result.error })
318
+ if (result.hadParseError) {
319
+ console.error(`✗ ${client.name}: existing config has invalid JSON — backed up to ${client.cfgPath}.lmcp-backup`)
320
+ console.error(` Edit or remove the backup to fix: ${result.error}`)
321
+ } else {
322
+ console.error(`✗ ${client.name}: ${result.error}`)
323
+ }
233
324
  }
234
325
  }
235
326
 
@@ -240,10 +331,11 @@ async function runSetup(opts = {}) {
240
331
  let email = process.env.LMCP_EMAIL || ''
241
332
  if (email) {
242
333
  try {
243
- const cfg = _readJson(cfgFile)
334
+ const r = _safeReadConfig(cfgFile)
335
+ const cfg = r.data || {}
244
336
  if (!cfg.license_email) {
245
337
  cfg.license_email = email
246
- _writeJson(cfgFile, cfg)
338
+ _atomicWriteConfig(cfgFile, cfg)
247
339
  console.log(`✓ Email saved: ${email}`)
248
340
  }
249
341
  } catch { /* config not created yet — will be created on first run */ }
@@ -293,8 +385,9 @@ async function runSetup(opts = {}) {
293
385
  email = ans
294
386
  emailPromptResult = 'submitted'
295
387
  try {
296
- const cfg = _readJson(cfgFile)
297
- if (!cfg.license_email) { cfg.license_email = email; _writeJson(cfgFile, cfg) }
388
+ const r = _safeReadConfig(cfgFile)
389
+ const cfg = r.data || {}
390
+ if (!cfg.license_email) { cfg.license_email = email; _atomicWriteConfig(cfgFile, cfg) }
298
391
  } catch {}
299
392
  console.log(' ✓ Email saved')
300
393
  } else {
@@ -308,6 +401,13 @@ async function runSetup(opts = {}) {
308
401
 
309
402
  // Health check — verify binary works before showing success
310
403
  const healthOk = _runHealthCheck()
404
+ _trackSetupStep('binary_health', healthOk ? 'ok' : 'failed', '', {})
405
+
406
+ // setup_complete summary
407
+ _trackSetupStep('setup_complete', configured.length > 0 ? 'ok' : 'failed', '', {
408
+ clientsFound: configured,
409
+ clientsNotFound: failed.map(f => f.name || f),
410
+ })
311
411
 
312
412
  const hasCursor = configured.includes('Cursor')
313
413
 
@@ -340,17 +440,19 @@ async function runSetup(opts = {}) {
340
440
  } else {
341
441
  const primaryClient = configured[0]
342
442
  console.log('┌─────────────────────────────────────────────────────┐')
343
- console.log('│ NEXT STEP — This is important: │')
443
+ console.log('│ NEXT STEP — restart to activate: │')
344
444
  console.log('│ │')
345
- console.log(`│ Quit and reopen ${primaryClient.padEnd(35)}│`)
346
- console.log('│ LMCP will appear automatically. │')
445
+ console.log(`│ 1. Quit ${primaryClient} completely (Cmd+Q)${''.padEnd(Math.max(0, 30 - primaryClient.length))}│`)
446
+ console.log(`│ 2. Reopen ${primaryClient.padEnd(41)}│`)
347
447
  console.log('│ │')
348
448
  console.log('│ Then try: "Summarize my unread emails" │')
349
449
  console.log('└─────────────────────────────────────────────────────┘\n')
450
+ _trackRestartPrompted(primaryClient)
350
451
  }
351
452
  }
352
453
  if (failed.length > 0) {
353
- console.log(`⚠ Could not configure: ${failed.join(', ')} — check permissions and try again.\n`)
454
+ const failNames = failed.map(f => (typeof f === 'string' ? f : f.name)).join(', ')
455
+ console.log(`⚠ Could not configure: ${failNames} — check permissions and try again.\n`)
354
456
  }
355
457
  console.log(' Setup guide & troubleshooting: https://local-mcp.com/setup\n')
356
458
 
@@ -540,6 +642,46 @@ function _migrateCurlLaunchAgent() {
540
642
  } catch { /* non-fatal — don't block install on unexpected plist content */ }
541
643
  }
542
644
 
645
+ // ── Setup funnel telemetry ────────────────────────────────────────────────────
646
+ // Fire-and-forget. Sends one event per step to /setup-step so we can see exactly
647
+ // where each machine's install funnel breaks without waiting for a heartbeat.
648
+
649
+ function _trackSetupStep(step, status, client, detail) {
650
+ try {
651
+ const https = require('https')
652
+ const payload = {
653
+ machine_id: _getMachineId(),
654
+ install_id: process.env.INSTALL_ID || '',
655
+ method: process.env.LMCP_METHOD || 'npm',
656
+ step,
657
+ status,
658
+ client: client || '',
659
+ error: (detail && detail.error) ? String(detail.error).slice(0, 300) : '',
660
+ }
661
+ if (detail) {
662
+ if (detail.existingMcpCount >= 0) payload.existing_mcp_count = detail.existingMcpCount
663
+ if (detail.preservedServers && detail.preservedServers.length)
664
+ payload.preserved_servers = detail.preservedServers.join(',')
665
+ if (detail.clientsFound) payload.clients_found = detail.clientsFound.join(',')
666
+ if (detail.clientsNotFound) payload.clients_not_found = detail.clientsNotFound.join(',')
667
+ if (detail.toolCount >= 0) payload.tool_count = detail.toolCount
668
+ if (detail.binaryVersion) payload.binary_version = detail.binaryVersion
669
+ }
670
+ const data = JSON.stringify(payload)
671
+ const req = https.request({
672
+ hostname: BACKEND_HOST,
673
+ path: '/setup-step',
674
+ method: 'POST',
675
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
676
+ timeout: 5000,
677
+ })
678
+ req.on('response', (res) => res.resume())
679
+ req.on('error', () => {})
680
+ req.write(data)
681
+ req.end()
682
+ } catch { /* non-fatal */ }
683
+ }
684
+
543
685
  function _trackConfigWritten(clientId, clientName) {
544
686
  try {
545
687
  const https = require('https')
@@ -594,6 +736,31 @@ function _trackEmailPrompt(result, email, errorMsg) {
594
736
  } catch { /* non-fatal */ }
595
737
  }
596
738
 
739
+ function _trackRestartPrompted(clientName) {
740
+ try {
741
+ const https = require('https')
742
+ const machineId = _getMachineId()
743
+ const payload = {
744
+ stage: 'restart_prompted',
745
+ client_name: clientName,
746
+ machine_id: machineId,
747
+ install_id: process.env.INSTALL_ID || '',
748
+ }
749
+ const data = JSON.stringify(payload)
750
+ const req = https.request({
751
+ hostname: BACKEND_HOST,
752
+ path: '/install-event',
753
+ method: 'POST',
754
+ headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
755
+ timeout: 5000,
756
+ })
757
+ req.on('response', (res) => res.resume())
758
+ req.on('error', () => {})
759
+ req.write(data)
760
+ req.end()
761
+ } catch { /* non-fatal */ }
762
+ }
763
+
597
764
  function _pingInstall(clients, method, email = '', binaryVersion = '') {
598
765
  try {
599
766
  const https = require('https')