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 +5 -7
- package/download.js +24 -8
- package/index.js +61 -8
- package/launch-event.js +43 -13
- package/package.json +5 -4
- package/setup.js +15 -13
- package/unconfigure.js +101 -0
- package/user-agent.js +40 -0
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
|
|
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
|
[](https://www.npmjs.com/package/local-mcp)
|
|
6
|
-
[](https://local-mcp.com?ref=npm)
|
|
7
7
|
[](https://local-mcp.com?ref=npm)
|
|
8
8
|
[](https://local-mcp.com/en/privacy)
|
|
9
9
|
[](https://glama.ai/mcp/servers/colibird-ai/local-mcp-releases?ref=npm)
|
|
10
10
|
|
|
11
|
-
**Runs on your computer. No tokens. No API keys
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
588
|
-
|
|
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) =>
|
|
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
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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('
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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) }
|