local-mcp 3.0.419 → 3.0.421

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
@@ -1,14 +1,14 @@
1
1
  # Local MCP
2
2
 
3
- > **Use your Mac from ChatGPT & Claude.ai on the web** — plus 192+ local tools for Claude Desktop, Cursor, Windsurf, VS Code & Zed. Connect any AI to Mail, Calendar, Contacts, iMessage, Teams, Slack, WhatsApp, Signal, OneDrive, Google Drive, Microsoft 365, Notes, Reminders, OmniFocus, Word/Excel/PowerPoint and more. Runs on your computer, no API keys, free — local tools read your data on your machine, and your AI provider receives the content needed for each request.
3
+ > **Use your Mac or Windows PC from ChatGPT & Claude.ai on the web** — plus local tools for Claude Desktop, Cursor, Windsurf, VS Code & Zed. Connect any AI to Mail, Calendar, Contacts, iMessage, Teams, Slack, WhatsApp, Signal, OneDrive, Google Drive, Notion, Microsoft 365, Notes, Reminders, OmniFocus, Word/Excel/PowerPoint and more. Runs on your computer, no API keys — local tools read your data on your machine, and your AI provider receives the content needed for each request.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/local-mcp?style=flat-square&label=npm)](https://www.npmjs.com/package/local-mcp)
6
- [![macOS](https://img.shields.io/badge/macOS-13%2B-111111?style=flat-square)](https://local-mcp.com?ref=npm)
6
+ [![Platform](https://img.shields.io/badge/platform-macOS%2013%2B%20%7C%20Windows%2010%2B-111111?style=flat-square)](https://local-mcp.com?ref=npm)
7
7
  [![Signed](https://img.shields.io/badge/Developer%20ID-notarized-111111?style=flat-square)](https://local-mcp.com?ref=npm)
8
8
  [![Privacy](https://img.shields.io/badge/tools-run%20locally-2e7d32?style=flat-square)](https://local-mcp.com/en/privacy)
9
9
  [![Glama](https://glama.ai/mcp/servers/colibird-ai/local-mcp-releases/badge)](https://glama.ai/mcp/servers/colibird-ai/local-mcp-releases?ref=npm)
10
10
 
11
- **Runs on your computer. No tokens. No API keys. Free.**
11
+ **Runs on your computer. No tokens. No API keys.**
12
12
 
13
13
   
14
14
 
@@ -41,7 +41,7 @@ Auto-detects and configures Claude Desktop, Claude Code, Cursor, Windsurf, VS Co
41
41
 
42
42
  ## What it does
43
43
 
44
- LMCP runs a native MCP server that bridges your Mac apps to any AI client:
44
+ LMCP runs a native MCP server that bridges the apps on your Mac or Windows PC to any AI client. Availability varies by platform:
45
45
 
46
46
  | Service | Capabilities |
47
47
  |---------|-------------|
@@ -137,8 +137,6 @@ System (12): `lmcp_state` `get_config` `get_datetime` `daily_brief` `run_diagnos
137
137
 
138
138
  Shortcuts (3): `shortcuts_list` `shortcuts_run` `shortcuts_view`
139
139
 
140
- _Coming soon (not included in the 162 count above): **Evernote** (support is being rebuilt)._
141
-
142
140
   
143
141
 
144
142
  ## Safety
@@ -165,7 +163,7 @@ Full policy: **[local-mcp.com/en/privacy](https://local-mcp.com/en/privacy)**.
165
163
 
166
164
  ## Requirements
167
165
 
168
- - macOS 13 Ventura or later (Apple Silicon and Intel)
166
+ - macOS 13 Ventura or later (Apple Silicon and Intel), or Windows 10 or later (64-bit)
169
167
  - One of: Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, Zed
170
168
 
171
169
  ### Config file locations
package/download.js CHANGED
@@ -5,8 +5,7 @@
5
5
  * Cache: ~/.local/share/local-mcp/bin/{version}/
6
6
  */
7
7
 
8
- const https = require('https')
9
- const http = require('http')
8
+ const { https, http } = require('./user-agent')
10
9
  const fs = require('fs')
11
10
  const path = require('path')
12
11
  const os = require('os')
@@ -332,7 +331,7 @@ function downloadOnce(url, destPath) {
332
331
  }
333
332
  if (res.statusCode !== 200) {
334
333
  file.close()
335
- fs.unlinkSync(destPath)
334
+ fs.rmSync(destPath, { force: true })
336
335
  return reject(new Error(`HTTP ${res.statusCode} descargando binario`))
337
336
  }
338
337
 
@@ -353,7 +352,7 @@ function downloadOnce(url, destPath) {
353
352
 
354
353
  res.pipe(file)
355
354
  file.on('finish', () => { file.close(); process.stderr.write('\n'); resolve() })
356
- file.on('error', (err) => { fs.unlinkSync(destPath); reject(err) })
355
+ file.on('error', (err) => { fs.rmSync(destPath, { force: true }); reject(err) })
357
356
  }).on('error', reject)
358
357
  }
359
358
 
@@ -543,20 +542,37 @@ async function ensureRuntime() {
543
542
  return { pythonBin: binPath, serverPath: binPath, runtimeDir: versionDir, binPath, version }
544
543
  }
545
544
 
545
+ /**
546
+ * The version a co-process is published at (lmcp-mac#382). `/runtime/latest` names it apart from
547
+ * the server's (`coprocess_version`, #3598); the Mac updater installs exactly that one and writes
548
+ * the same `.<name>-version` file. Asking for `version` instead made the two writers disagree
549
+ * whenever the two differ, and each re-downloaded what the other had just written.
550
+ */
551
+ function coprocessVersion(info) {
552
+ const cp = info && info.coprocess_version
553
+ return typeof cp === 'string' && /^[0-9][0-9A-Za-z.+-]*$/.test(cp) ? cp : info.version
554
+ }
555
+
546
556
  /**
547
557
  * Descarga el co-proceso teams-proxy si no está instalado o es una versión diferente.
548
558
  * El binary se instala en ~/.local/share/local-mcp/bin/teams-proxy
559
+ *
560
+ * lmcp-mac#382: not when the tray app is installed. The tray's updater owns teams-proxy (it
561
+ * verifies its digest and Developer ID; this does not), and trays 3.0.418-3.0.419 delete it every
562
+ * update cycle: npx put it back on every spawn — 140 addresses downloaded it 5-49 times a day.
549
563
  * @returns {Promise<string|null>} Ruta al binary, o null si falla
550
564
  */
551
- async function ensureTeamsProxy(platform = process.platform) {
565
+ async function ensureTeamsProxy(platform = process.platform, deps = {}) {
566
+ const { trayApps = [TRAY_APP, SYSTEM_TRAY_APP] } = deps
552
567
  // Only published for darwin (darwin-arm64/darwin-x64 artifacts) — mirrors the
553
568
  // guard ensureTray() already has. Without it, every Windows/Linux install fired
554
569
  // a doomed download attempt and printed a confusing "not available" error (#1390).
555
570
  // platform is a parameter (default process.platform) so the guard is testable
556
571
  // without a network mock — same pattern as _writeLaunchScript's isWin.
557
572
  if (platform !== 'darwin') return null
573
+ if (trayApps.some(app => app && _isUsableTrayBundle(app))) return null
558
574
  const info = await getLatestBinary()
559
- const version = info.version
575
+ const version = coprocessVersion(info)
560
576
  const arch = process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64'
561
577
  const binPath = path.join(CACHE_DIR, 'teams-proxy')
562
578
  const verFile = path.join(CACHE_DIR, '.teams-proxy-version')
@@ -874,7 +890,7 @@ async function ensureSlackProxy(platform = process.platform) {
874
890
  // without a network mock — same pattern as _writeLaunchScript's isWin.
875
891
  if (platform !== 'darwin') return null
876
892
  const info = await getLatestBinary()
877
- const version = info.version
893
+ const version = coprocessVersion(info) // lmcp-mac#382: the same version the tray installs
878
894
  const arch = process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64'
879
895
  const binPath = path.join(CACHE_DIR, 'slack-proxy')
880
896
  const verFile = path.join(CACHE_DIR, '.slack-proxy-version')
@@ -918,7 +934,7 @@ async function ensureSlackProxy(platform = process.platform) {
918
934
  }
919
935
 
920
936
  module.exports = {
921
- ensureBinary, ensureRuntime, ensureTray, ensureTeamsProxy, ensureSlackProxy, versionFromArtifactUrl,
937
+ ensureBinary, ensureRuntime, ensureTray, ensureTeamsProxy, ensureSlackProxy, versionFromArtifactUrl, coprocessVersion,
922
938
  latestVersionForPlatform, getArch,
923
939
  CACHE_DIR, TRAY_DIR, _getMachineId, LINUX_NO_SOPORTADO,
924
940
  _probeWinHardwareId, _resetWinHardwareIdCacheForTests, UUIDS_DE_HARDWARE_BASURA, _esIdDeHardwareValido,
package/index.js CHANGED
@@ -16,7 +16,7 @@ const { spawn, execFileSync, execSync } = require('child_process')
16
16
  const path = require('path')
17
17
  const os = require('os')
18
18
  const fs = require('fs')
19
- const https = require('https')
19
+ const { https } = require('./user-agent')
20
20
  const evento = require('./launch-event')
21
21
 
22
22
  // ── Version + liveness helpers (module scope so they are testable) ────────────
@@ -268,11 +268,22 @@ function reportarLanzamiento(outcome, opciones) {
268
268
  if (ES_LANZAMIENTO) evento.reportar(outcome, evento.claseDeArgumento(cmd), opciones)
269
269
  }
270
270
  // Antes que nada: un `spawned` que en Windows esperaba su id sale ahora, por el proceso suelto (#21).
271
- if (ES_LANZAMIENTO) process.on('exit', () => { evento.soltarPendiente(); reportarLanzamiento('other') })
271
+ // La red dice además por qué (#82), como clase: una excepción que nadie atrapó, o no se sabe.
272
+ let razonDeSalida = 'unknown'
273
+ if (ES_LANZAMIENTO) {
274
+ process.on('uncaughtExceptionMonitor', () => { razonDeSalida = 'uncaught-exception' })
275
+ process.on('exit', () => { evento.soltarPendiente(); reportarLanzamiento('other', { reason: razonDeSalida }) })
276
+ }
277
+ // #82: un server que sale con error dentro de esta ventana no arrancó. El `spawned` espera a que
278
+ // pase; el server y el cliente no esperan nada: sólo se demora el evento.
279
+ const VENTANA_ARRANQUE_MS = 2000
272
280
 
273
281
  // Platform support: macOS (Swift tray + embedded server) and Windows (go-server standalone
274
282
  // binary downloaded from R2). Linux passes this guard so that `--help`, `status`, `update` and
275
283
  // `uninstall` answer, but it is NOT supported: the server path and `setup` say so (#9).
284
+ // Since local-mcp-releases#61 row 5 npm refuses the install on Linux (`os` in package.json), so
285
+ // this branch is no longer reached through `npm install`/`npx`. It stays for whoever gets past
286
+ // that: `node index.js` on a checkout, `npm install --force`, or a tool that ignores `os`.
276
287
  const SUPPORTED_PLATFORMS = ['darwin', 'win32', 'linux']
277
288
  if (!SUPPORTED_PLATFORMS.includes(process.platform)) {
278
289
  console.error(`LMCP supports macOS and Windows. Your platform (${process.platform}) is not supported yet — see https://local-mcp.com`)
@@ -344,7 +355,15 @@ async function main() {
344
355
  // sólo buscaba ayuda de CLI. Un comando de consulta no instala software.
345
356
  //
346
357
  // Sin argumentos sigue arrancando el server, que es como lo invocan los clientes de IA.
347
- if (cmd !== undefined && !COMANDOS.includes(cmd) && !SINONIMOS_DEL_SERVER.includes(cmd)) {
358
+ //
359
+ // #88: un cliente de IA (stdin sin TTY) que manda un argumento desconocido quiere el server, y
360
+ // con exit 2 reintentaba en bucle. Ahí se ignora el argumento (una línea por stderr, nunca
361
+ // stdout: es JSON-RPC) y se sigue por el camino del server, que reporta `spawned` con la clase.
362
+ // En una terminal (TTY) y ante un pedido de ayuda sigue la ayuda: una consulta no descarga ni instala.
363
+ const desconocido = cmd !== undefined && !COMANDOS.includes(cmd) && !SINONIMOS_DEL_SERVER.includes(cmd)
364
+ if (desconocido && !PEDIDOS_DE_AYUDA.includes(cmd) && !(process.stdin && process.stdin.isTTY)) {
365
+ process.stderr.write(`[LMCP] ignoring unknown argument ${JSON.stringify(String(cmd).slice(0, 40))}; starting the MCP server\n`)
366
+ } else if (desconocido) {
348
367
  const pedidoDeAyuda = PEDIDOS_DE_AYUDA.includes(cmd)
349
368
  mostrarAyuda()
350
369
  if (!pedidoDeAyuda) {
@@ -405,6 +424,25 @@ async function main() {
405
424
  // copy and the durable App Support copy (see uninstallSentinelPaths).
406
425
  writeUninstallSentinels()
407
426
 
427
+ // #48 (2): quitar la entrada de los clientes de IA que se alcanzan (mismo catálogo que el setup);
428
+ // lo que no se puede tocar se dice. Sin catálogo (sin red) se dice también, no se adivina.
429
+ try {
430
+ const { unconfigureClients } = require('./unconfigure')
431
+ const catalogFile = process.env.LMCP_CLIENTS_CATALOG_FILE
432
+ const catalog = catalogFile
433
+ ? JSON.parse(fs.readFileSync(catalogFile, 'utf8'))
434
+ : await require('./setup')._fetchClientsCatalog()
435
+ if (!catalog) {
436
+ console.log('No se pudo leer el catálogo de clientes: las entradas "local-mcp" de tus clientes de IA quedan; quitalas a mano.')
437
+ } else {
438
+ const r = unconfigureClients(catalog)
439
+ for (const x of r.removed) console.log(`Entrada local-mcp eliminada de ${x.client}: ${x.file}`)
440
+ for (const x of r.left) console.log(`Dejada en ${x.client}${x.file ? ' (' + x.file + ')' : ''}: ${x.reason}; quitá "local-mcp" a mano.`)
441
+ }
442
+ } catch (e) {
443
+ console.log('No se pudieron quitar las entradas de los clientes de IA: ' + e.message)
444
+ }
445
+
408
446
  if (fs.existsSync(CACHE_DIR)) {
409
447
  fs.rmSync(CACHE_DIR, { recursive: true, force: true })
410
448
  console.log(`Runtime eliminado de ${CACHE_DIR}`)
@@ -584,14 +622,28 @@ async function main() {
584
622
  try { _pkgVer = require('./package.json').version || '' } catch {}
585
623
  const child = spawn(binaryPath, spawnArgs, { stdio: 'inherit', env: { ...process.env, LMCP_NPM_VERSION: _pkgVer } })
586
624
  noteSpawnedServer(child.pid)
587
- // El server ya arrancó y este proceso queda vivo toda la sesión: el evento sale desde acá.
588
- child.on('spawn', () => reportarLanzamiento('spawned', { enProceso: true }))
625
+ // El server arrancó y sigue vivo pasada la ventana: este proceso queda toda la sesión y el
626
+ // evento sale desde acá.
627
+ let arranque = null
628
+ child.on('spawn', () => {
629
+ arranque = setTimeout(() => { arranque = null; reportarLanzamiento('spawned', { enProceso: true }) }, VENTANA_ARRANQUE_MS)
630
+ arranque.unref() // el evento nunca retiene al proceso ni demora una salida
631
+ })
589
632
  child.on('error', (err) => {
590
- reportarLanzamiento('spawn-failed')
633
+ reportarLanzamiento('spawn-failed', { reason: evento.razonDeError(err) })
591
634
  process.stderr.write(`Error ejecutando LMCP: ${err.message}\n`)
592
635
  process.exit(1)
593
636
  })
594
- child.on('exit', (code) => process.exit(code ?? 0))
637
+ child.on('exit', (code, signal) => {
638
+ // Dentro de la ventana (#82): salió con error = no arrancó (`spawn-failed`), nunca `spawned`.
639
+ // El código de salida pasa al cliente igual que siempre.
640
+ if (arranque) {
641
+ clearTimeout(arranque)
642
+ if (code === 0) reportarLanzamiento('spawned')
643
+ else reportarLanzamiento('spawn-failed', { reason: signal ? 'signal' : 'early-exit' })
644
+ }
645
+ process.exit(code ?? 0)
646
+ })
595
647
  }
596
648
 
597
649
  // On Windows: apply pending update before launching (same as Mac's UpdateManager)
@@ -728,7 +780,7 @@ async function main() {
728
780
  try {
729
781
  runtime = await ensureRuntime()
730
782
  } catch (err) {
731
- reportarLanzamiento('download-failed')
783
+ reportarLanzamiento('download-failed', { reason: evento.razonDeError(err) })
732
784
  process.stderr.write(`\nError al preparar el runtime de LMCP:\n${err.message}\n`)
733
785
  process.stderr.write(`Intentá reinstalar: npx local-mcp update\n\n`)
734
786
  process.exit(1)
@@ -764,6 +816,7 @@ async function main() {
764
816
  // round-trip that no static check can see.
765
817
  if (require.main === module) {
766
818
  main().catch(err => {
819
+ razonDeSalida = 'uncaught-exception'
767
820
  process.stderr.write(`Error fatal: ${err.message}\n`)
768
821
  process.exit(1)
769
822
  })
package/launch-event.js CHANGED
@@ -12,23 +12,49 @@
12
12
  // · escribir en stdout — es el protocolo;
13
13
  // · demorar el arranque del server o una salida — sin reintentos, timeout corto, todo error
14
14
  // tragado;
15
- // · mandar algo que no sea uno de los cinco campos: ni el argumento, ni rutas, ni entorno.
16
- const https = require('https')
15
+ // · mandar algo que no sea uno de los campos del contrato, con valores de conjunto cerrado: ni
16
+ // el argumento, ni rutas, ni entorno, ni el mensaje de un error (#82: de un error sale su clase).
17
+ const { https } = require('./user-agent')
17
18
  const cp = require('child_process')
18
19
  const DESTINO = { hostname: 'office-mcp-production.up.railway.app', path: '/launch-event' }
19
20
  const PLATAFORMAS = ['darwin', 'win32', 'linux', 'other']
20
- const CLASES = ['none', 'stdio', 'unknown']
21
+ const CLASES = ['none', 'stdio', 'unknown', 'start', 'serve', 'server', 'run', 'mcp', '--stdio', 'other-word', 'other-flag']
21
22
  const OUTCOMES = ['spawned', 'rejected-argument', 'unsupported-node', 'unsupported-platform',
22
23
  'download-failed', 'spawn-failed', 'uninstalled', 'other']
24
+ // #82: por qué falló, como clase. Sólo acompaña a un outcome de falla, y sólo donde hay algo que
25
+ // clasificar (`download-failed`, `spawn-failed`, `other`); `checksum-mismatch` e `init-failed`
26
+ // son del contrato y este wrapper no los produce.
27
+ const RAZONES = ['network', 'timeout', 'http-status', 'checksum-mismatch', 'disk-write',
28
+ 'permission-denied', 'binary-missing', 'early-exit', 'signal', 'uncaught-exception', 'init-failed', 'unknown']
29
+ const CAMINO = 'npm-wrapper' // launch_path: quién arrancó el server
23
30
  const RE_VERSION = /^\d{1,3}\.\d{1,3}\.\d{1,5}$/
24
31
  const RE_MAQUINA = /^[A-Za-z0-9._:{}-]{1,64}$/
25
32
  const [TIMEOUT_MS, TOPE_MS] = [3000, 4000] // el pedido · la vida del proceso suelto
26
33
  const TOPE_ID_MS = 2000 // Windows: cuánto puede esperar el `spawned` al id de hardware (#21)
27
34
 
28
- // Del argumento sólo viaja su clase. `stdio` es el único que se nombra: es el del incidente.
35
+ // Del argumento sólo viaja su clase (#88): `stdio` y las formas que los clientes inventan se
36
+ // nombran (conjunto fijo); cualquier otra cosa es `other-flag` si empieza con `-` y `other-word`
37
+ // si no. El texto crudo no sale nunca. `unknown` queda en el conjunto por compatibilidad: ya no se emite.
38
+ const NOMBRADOS = ['stdio', 'start', 'serve', 'server', 'run', 'mcp', '--stdio']
29
39
  function claseDeArgumento (cmd) {
30
40
  if (cmd === undefined) return 'none'
31
- return cmd === 'stdio' ? 'stdio' : 'unknown'
41
+ if (NOMBRADOS.includes(cmd)) return cmd
42
+ return typeof cmd === 'string' && cmd.startsWith('-') ? 'other-flag' : 'other-word'
43
+ }
44
+
45
+ // De un error sólo viaja su clase (#82). El mensaje se LEE acá —trae rutas y URLs— y no sale: lo
46
+ // que devuelve es siempre un valor de RAZONES. El orden importa: «EACCES … open <ruta>» es de
47
+ // permisos aunque la ruta diga otra cosa.
48
+ function razonDeError (err) {
49
+ let t = ''
50
+ try { t = `${(err && err.code) || ''} ${(err && err.message) || ''}` } catch {}
51
+ if (/\b(EACCES|EPERM)\b/.test(t)) return 'permission-denied'
52
+ if (/timeout|ETIMEDOUT/i.test(t)) return 'timeout'
53
+ if (/HTTP \d{3}|not yet available/.test(t)) return 'http-status'
54
+ if (/\b(ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ENETUNREACH|EHOSTUNREACH|EPIPE)\b|socket hang up|CERT|ERR_TLS/.test(t)) return 'network'
55
+ if (/\b(ENOSPC|EROFS|EIO|EBUSY|EDQUOT)\b/.test(t)) return 'disk-write'
56
+ if (/\bENOENT\b|not found after extraction|no encontrado después de extraer|tar\.exe not found/.test(t)) return 'binary-missing'
57
+ return 'unknown'
32
58
  }
33
59
 
34
60
  // El heartbeat no tiene compuerta de consentimiento (ver #11). Este evento respeta además el
@@ -55,8 +81,11 @@ function cuerpo (outcome, clase, deps = {}) {
55
81
  package_version: version,
56
82
  platform: PLATAFORMAS.includes(plataforma) ? plataforma : 'other',
57
83
  arg_class: clase,
58
- outcome
84
+ outcome,
85
+ launch_path: CAMINO
59
86
  }
87
+ // Una razón fuera del conjunto no viaja, y un `spawned` no lleva razón; el evento sale igual.
88
+ if (outcome !== 'spawned' && RAZONES.includes(deps.reason)) c.reason = deps.reason
60
89
  const id = 'machineId' in deps ? deps.machineId : idDeMaquina()
61
90
  if (typeof id === 'string' && RE_MAQUINA.test(id)) c.machine_id = id
62
91
  return c
@@ -140,7 +169,7 @@ function idDeHardwareWindows (listo) {
140
169
  // Una persona en una terminal (stdin TTY) no es un cliente de IA lanzando el server: no reporta.
141
170
  let reportado = false
142
171
  let pendiente = null
143
- function reportar (outcome, clase, { enProceso = false } = {}) {
172
+ function reportar (outcome, clase, { enProceso = false, reason } = {}) {
144
173
  if (reportado) return
145
174
  reportado = true
146
175
  try {
@@ -162,7 +191,7 @@ function reportar (outcome, clase, { enProceso = false } = {}) {
162
191
  if (c) enviar(c, { soltar: true })
163
192
  return
164
193
  }
165
- lanzarSuelto(outcome, clase)
194
+ lanzarSuelto(outcome, clase, reason)
166
195
  } catch {}
167
196
  }
168
197
 
@@ -170,8 +199,9 @@ function reportar (outcome, clase, { enProceso = false } = {}) {
170
199
  // como reportado para que la red del `exit` tampoco lo mande como `other`.
171
200
  function silenciar () { reportado = true }
172
201
 
173
- function lanzarSuelto (outcome, clase) {
174
- const hijo = cp.spawn(process.execPath, [__filename, outcome, clase],
202
+ function lanzarSuelto (outcome, clase, reason) {
203
+ // La razón viaja como argumento sólo si es del conjunto: al proceso suelto no le llega texto libre.
204
+ const hijo = cp.spawn(process.execPath, [__filename, outcome, clase, ...(RAZONES.includes(reason) ? [reason] : [])],
175
205
  { detached: true, stdio: 'ignore', windowsHide: true })
176
206
  hijo.on('error', () => {})
177
207
  hijo.unref()
@@ -187,12 +217,12 @@ function soltarPendiente () {
187
217
  try { lanzarSuelto(outcome, clase) } catch {}
188
218
  }
189
219
 
190
- // Como programa es el proceso suelto: `node launch-event.js <outcome> <clase>`.
220
+ // Como programa es el proceso suelto: `node launch-event.js <outcome> <clase> [reason]`.
191
221
  if (require.main === module) {
192
222
  setTimeout(() => process.exit(0), TOPE_MS).unref()
193
- const c = apagado() ? null : cuerpo(process.argv[2], process.argv[3])
223
+ const c = apagado() ? null : cuerpo(process.argv[2], process.argv[3], { reason: process.argv[4] })
194
224
  if (c) enviar(c)
195
225
  }
196
226
 
197
- module.exports = { PLATAFORMAS, CLASES, OUTCOMES, RE_VERSION, RE_MAQUINA, TOPE_ID_MS, claseDeArgumento, apagado, cuerpo, reportar, silenciar, soltarPendiente,
227
+ module.exports = { PLATAFORMAS, CLASES, OUTCOMES, RAZONES, CAMINO, razonDeError, RE_VERSION, RE_MAQUINA, TOPE_ID_MS, claseDeArgumento, apagado, cuerpo, reportar, silenciar, soltarPendiente,
198
228
  _idDeHardwareWindows: idDeHardwareWindows } // costura de test
package/package.json CHANGED
@@ -1,20 +1,22 @@
1
1
  {
2
2
  "name": "local-mcp",
3
- "version": "3.0.419",
4
- "description": "Let ChatGPT, Claude, Cursor & any MCP client actually use your Mac or Windows PC — read & reply to email, manage your calendar, text over iMessage, find files, work with Teams, Slack & Office. Runs locally, no API keys, free.",
3
+ "version": "3.0.421",
4
+ "description": "Let ChatGPT, Claude, Cursor & any MCP client actually use your Mac or Windows PC — read & reply to email, manage your calendar, text over iMessage, find files, work with Teams, Slack, Notion & Office. Runs locally, no API keys.",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "local-mcp": "index.js"
8
8
  },
9
9
  "scripts": {
10
10
  "postinstall": "node postinstall.js",
11
- "test": "node --test test/*.test.js"
11
+ "test": "node --require ./test/aislado.preload.js --test test/*.test.js"
12
12
  },
13
13
  "files": [
14
14
  "index.js",
15
15
  "download.js",
16
16
  "setup.js",
17
17
  "launch-event.js",
18
+ "user-agent.js",
19
+ "unconfigure.js",
18
20
  "postinstall.js",
19
21
  "README.md"
20
22
  ],
@@ -23,7 +25,6 @@
23
25
  },
24
26
  "os": [
25
27
  "darwin",
26
- "linux",
27
28
  "win32"
28
29
  ],
29
30
  "keywords": [
package/setup.js CHANGED
@@ -67,7 +67,7 @@ function _writeLaunchScript(npxAbsPath, cacheDir, isWin = _IS_WIN) {
67
67
  'NODE_MAJOR=$(echo "$NODE_VER" | sed \'s/v//\' | cut -d. -f1)',
68
68
  'if [ "${NODE_MAJOR:-0}" -lt 16 ] 2>/dev/null; then',
69
69
  ' # curl telemetry — no Node dependency, fire-and-forget',
70
- ` curl -sf --max-time 3 -X POST "https://${BACKEND_HOST}/install-event" \\`,
70
+ ` curl -sf --max-time 3 -A "LMCP-npm (launcher)" -X POST "https://${BACKEND_HOST}/install-event" \\`,
71
71
  ' -H "Content-Type: application/json" \\',
72
72
  ' -d "{\\\"stage\\\":\\\"node_too_old\\\",\\\"version\\\":\\\"$NODE_VER\\\"}" &>/dev/null &',
73
73
  ' 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',
@@ -328,7 +328,7 @@ async function runSetup(opts = {}) {
328
328
  const _preToken = process.env.LMCP_PRE_TOKEN || ''
329
329
  if (_preToken.startsWith('lmcp-pre-')) {
330
330
  try {
331
- const https = require('https')
331
+ const { https } = require('./user-agent')
332
332
  const req = https.request({
333
333
  hostname: BACKEND_HOST,
334
334
  path: `/install/started/${_preToken}`,
@@ -797,10 +797,12 @@ function _getRef() {
797
797
  // _getMachineId runs on every ensureBinary()/ensureTray()/... call (i.e. on every
798
798
  // npx spawn), and a Keychain write there would be needless overhead on a hot path
799
799
  // that setup.js — which only runs once at install time — doesn't share.
800
- function _getMachineId() {
801
- const id = download._getMachineId()
802
- if (_IS_MAC && id) {
803
- try { execSync(`security add-generic-password -s com.local-mcp.machine-id -a machine-id -w "${id}" -U 2>/dev/null`, { stdio: 'pipe' }) } catch {}
800
+ // `deps` es la costura de test (#84): sin ella un test pedía al Keychain REAL de quien lo corre
801
+ // que creara o pisara la identidad de la máquina. Producto: nadie la pasa, nada cambia.
802
+ function _getMachineId(deps = {}) {
803
+ const id = download._getMachineId(deps)
804
+ if ((deps.platform ? deps.platform === 'darwin' : _IS_MAC) && id) {
805
+ try { (deps.execImpl || execSync)(`security add-generic-password -s com.local-mcp.machine-id -a machine-id -w "${id}" -U 2>/dev/null`, { stdio: 'pipe' }) } catch {}
804
806
  }
805
807
  return id
806
808
  }
@@ -819,7 +821,7 @@ function _getMachineId() {
819
821
  async function _associateEmail(email, token) {
820
822
  return new Promise((resolve) => {
821
823
  try {
822
- const https = require('https')
824
+ const { https } = require('./user-agent')
823
825
  const payload = JSON.stringify({
824
826
  stage: 'email_prompt',
825
827
  email_prompt_result: 'submitted',
@@ -892,7 +894,7 @@ function _writeCloudToken(cacheDir, token) {
892
894
  /// treat failure as non-fatal and fall through to the email-based path.
893
895
  async function _registerAnonToken() {
894
896
  return new Promise((resolve) => {
895
- const https = require('https')
897
+ const { https } = require('./user-agent')
896
898
  const machineId = _getMachineId()
897
899
  const payload = JSON.stringify({ machine_id: machineId })
898
900
  const req = https.request({
@@ -951,7 +953,7 @@ function _migrateCurlLaunchAgent() {
951
953
 
952
954
  function _trackSetupStep(step, status, client, detail) {
953
955
  try {
954
- const https = require('https')
956
+ const { https } = require('./user-agent')
955
957
  const payload = {
956
958
  machine_id: _getMachineId(),
957
959
  install_id: process.env.INSTALL_ID || '',
@@ -989,7 +991,7 @@ function _trackSetupStep(step, status, client, detail) {
989
991
 
990
992
  function _trackConfigWritten(clientId, clientName) {
991
993
  try {
992
- const https = require('https')
994
+ const { https } = require('./user-agent')
993
995
  const machineId = _getMachineId()
994
996
  const data = JSON.stringify({
995
997
  stage: 'config_written',
@@ -1015,7 +1017,7 @@ function _trackConfigWritten(clientId, clientName) {
1015
1017
 
1016
1018
  function _trackRestartPrompted(clientName) {
1017
1019
  try {
1018
- const https = require('https')
1020
+ const { https } = require('./user-agent')
1019
1021
  const machineId = _getMachineId()
1020
1022
  const payload = {
1021
1023
  stage: 'restart_prompted',
@@ -1040,7 +1042,7 @@ function _trackRestartPrompted(clientName) {
1040
1042
 
1041
1043
  function _pingInstall(clients, method, email = '', binaryVersion = '') {
1042
1044
  try {
1043
- const https = require('https')
1045
+ const { https } = require('./user-agent')
1044
1046
  const pkg = require('./package.json')
1045
1047
  const machineId = _getMachineId()
1046
1048
  // Persist the attribution ref so the server reports it on every heartbeat —
@@ -1133,7 +1135,7 @@ function _pingInstall(clients, method, email = '', binaryVersion = '') {
1133
1135
  // callers treat that as "don't launch anything," never as an error.
1134
1136
  function _fetchClientsCatalog(totalMs = 4000) {
1135
1137
  return new Promise((resolve) => {
1136
- const https = require('https')
1138
+ const { https } = require('./user-agent')
1137
1139
  const req = https.request({
1138
1140
  hostname: BACKEND_HOST, path: '/clients/catalog', method: 'GET', timeout: totalMs,
1139
1141
  }, (res) => {
package/unconfigure.js ADDED
@@ -0,0 +1,101 @@
1
+ 'use strict'
2
+ // #48 (2): `npx local-mcp uninstall` quita la entrada `local-mcp` de las configs de los clientes de IA
3
+ // que puede alcanzar, con el MISMO catálogo que usa el setup (nada de lista de clientes aquí). Sin esto
4
+ // el cliente sigue lanzando el wrapper en cada arranque contra una máquina ya desinstalada.
5
+ //
6
+ // Sólo toca lo que puede deshacer sin riesgo: JSON estándar (cfgSchema mcpServers / servers /
7
+ // context_servers) y sólo la clave `local-mcp` dentro de ese objeto. Lo demás (TOML/YAML, esquemas
8
+ // propios, JSON con comentarios o inválido, comodines sin carpeta) no se reescribe: se devuelve en
9
+ // `left` con el motivo, para que el comando diga qué dejó.
10
+ const fs = require('fs')
11
+ const os = require('os')
12
+ const path = require('path')
13
+
14
+ const ENTRY = 'local-mcp'
15
+ const KEY_BY_SCHEMA = { mcpServers: 'mcpServers', servers: 'servers', context_servers: 'context_servers' }
16
+
17
+ function _expand(p, env) {
18
+ return p
19
+ .replace(/^~(?=[\\/]|$)/, env.home)
20
+ .replace(/%APPDATA%/g, env.appData)
21
+ .replace(/%LOCALAPPDATA%/g, env.localAppData)
22
+ }
23
+
24
+ // Un `*` sólo vale dentro de UN segmento; se resuelve contra el disco.
25
+ function _glob(p) {
26
+ if (!p.includes('*')) return [p]
27
+ const parts = p.split(/[\\/]/)
28
+ let bases = [parts[0] === '' ? path.sep : parts[0]]
29
+ for (const seg of parts.slice(1)) {
30
+ const next = []
31
+ for (const b of bases) {
32
+ if (!seg.includes('*')) { next.push(path.join(b, seg)); continue }
33
+ const re = new RegExp('^' + seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$')
34
+ let names = []
35
+ try { names = fs.readdirSync(b) } catch {}
36
+ for (const n of names) if (re.test(n)) next.push(path.join(b, n))
37
+ }
38
+ bases = next
39
+ }
40
+ return bases
41
+ }
42
+
43
+ function _paths(client, plat, env) {
44
+ const platform = (client.platforms || {})[plat]
45
+ if (!platform || !Array.isArray(platform.cfgPaths)) return []
46
+ const hosts = Array.isArray(platform.hosts) && platform.hosts.length ? platform.hosts : [null]
47
+ const out = []
48
+ for (const raw of platform.cfgPaths) {
49
+ for (const h of hosts) {
50
+ const withHost = h === null ? raw : raw.replace(/\{host\}/g, h)
51
+ if (withHost.includes('{host}')) continue
52
+ for (const f of _glob(_expand(withHost, env))) if (!out.includes(f)) out.push(f)
53
+ }
54
+ }
55
+ return out
56
+ }
57
+
58
+ function _writeAtomic(file, data) {
59
+ const tmp = file + '.lmcp-tmp'
60
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8')
61
+ fs.renameSync(tmp, file)
62
+ }
63
+
64
+ /**
65
+ * @param {object} catalog el catálogo servido (`clients[]`).
66
+ * @param {object} [opts] platform, home, appData, localAppData (para probar sin tocar el disco real).
67
+ * @returns {{removed: {client:string,file:string}[], left: {client:string,file?:string,reason:string}[]}}
68
+ */
69
+ function unconfigureClients(catalog, opts = {}) {
70
+ const home = opts.home || os.homedir()
71
+ const platform = opts.platform || process.platform
72
+ const env = {
73
+ home,
74
+ appData: opts.appData || process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
75
+ localAppData: opts.localAppData || process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'),
76
+ }
77
+ const plat = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : null
78
+ const removed = []
79
+ const left = []
80
+ if (!plat || !catalog || !Array.isArray(catalog.clients)) return { removed, left }
81
+ for (const c of catalog.clients) {
82
+ const files = _paths(c, plat, env).filter((f) => { try { return fs.statSync(f).isFile() } catch { return false } })
83
+ for (const file of files) {
84
+ const key = KEY_BY_SCHEMA[c.cfgSchema]
85
+ if (!key) { left.push({ client: c.id, file, reason: 'schema ' + c.cfgSchema }); continue }
86
+ let data
87
+ try { data = JSON.parse(fs.readFileSync(file, 'utf8')) } catch {
88
+ left.push({ client: c.id, file, reason: 'not plain JSON' }); continue
89
+ }
90
+ const block = data && typeof data === 'object' ? data[key] : null
91
+ if (!block || typeof block !== 'object' || !(ENTRY in block)) continue
92
+ delete block[ENTRY]
93
+ try { _writeAtomic(file, data); removed.push({ client: c.id, file }) } catch (e) {
94
+ left.push({ client: c.id, file, reason: e.message })
95
+ }
96
+ }
97
+ }
98
+ return { removed, left }
99
+ }
100
+
101
+ module.exports = { unconfigureClients }
package/user-agent.js ADDED
@@ -0,0 +1,40 @@
1
+ 'use strict'
2
+ // Every outbound HTTP request from npx carries `User-Agent: LMCP-npm/<version> (<platform>)`.
3
+ // Node sends none by default, and an empty-UA download of a binary from a fresh process looks
4
+ // like malware to a security gateway. Drop-in `https`/`http` wrappers: same signatures,
5
+ // the header is added unless the caller already set one. Resolve `request`/`get` at call time.
6
+ const https = require('https')
7
+ const http = require('http')
8
+
9
+ let VERSION = '0'
10
+ try { VERSION = require('./package.json').version || '0' } catch {}
11
+
12
+ function userAgent (platform = process.platform) {
13
+ return `LMCP-npm/${VERSION} (${platform})`
14
+ }
15
+
16
+ function conUA (opts) {
17
+ const o = { ...(opts || {}) }
18
+ const h = { ...(o.headers || {}) }
19
+ if (!Object.keys(h).some((k) => k.toLowerCase() === 'user-agent')) h['User-Agent'] = userAgent()
20
+ o.headers = h
21
+ return o
22
+ }
23
+
24
+ function envolver (mod) {
25
+ return {
26
+ request (opts, cb) {
27
+ if (typeof opts === 'string' || opts instanceof URL) return mod.request(opts, conUA({}), cb)
28
+ return mod.request(conUA(opts), cb)
29
+ },
30
+ get (url, opts, cb) {
31
+ if (typeof url === 'string' || url instanceof URL) {
32
+ if (typeof opts === 'function') return mod.get(url, conUA({}), opts)
33
+ return mod.get(url, conUA(opts), cb)
34
+ }
35
+ return mod.get(conUA(url), typeof opts === 'function' ? opts : cb)
36
+ }
37
+ }
38
+ }
39
+
40
+ module.exports = { userAgent, conUA, https: envolver(https), http: envolver(http) }