local-mcp 3.0.372 → 3.0.374
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 +3 -6
- package/download.js +130 -8
- package/index.js +193 -45
- package/package.json +3 -2
- package/setup.js +244 -88
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Local MCP
|
|
2
2
|
|
|
3
|
-
> **The only Mac MCP you can use from ChatGPT & Claude.ai on the web** — plus
|
|
3
|
+
> **The only Mac MCP you can use from ChatGPT & Claude.ai on the web** — plus 188+ 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. 100% local, no API keys, free — your data never leaves your machine.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/local-mcp)
|
|
6
6
|
[](https://local-mcp.com?ref=npm)
|
|
@@ -64,8 +64,7 @@ LMCP runs a native MCP server that bridges your Mac apps to any AI client:
|
|
|
64
64
|
| **Stocks** | Real-time quotes, historical charts, symbol search |
|
|
65
65
|
| **Weather** | Current conditions + forecast for any city (Open-Meteo, no key) |
|
|
66
66
|
| **Finder** | Search files via Spotlight, list directory contents |
|
|
67
|
-
| **
|
|
68
|
-
| **Chrome** | Open tabs, navigate, read pages, click/type/fill forms, run JavaScript |
|
|
67
|
+
| **Web** | Navigate, read, click/type/fill forms, run JavaScript and scrape structured data in LMCP's own browser sessions — you sign in once and the login persists |
|
|
69
68
|
| **Microsoft 365** | Read/send email, manage calendar, search org directory — device code login, works with Outlook.com and work accounts |
|
|
70
69
|
| **ServiceNow** | Search/create incidents, get details, search Knowledge Base — Basic auth |
|
|
71
70
|
| **NordVPN** | Status, server recommendations, diagnostics |
|
|
@@ -120,9 +119,7 @@ Weather (1): `get_weather`
|
|
|
120
119
|
|
|
121
120
|
Finder (5): `finder_search` `finder_list` `fs_list` `fs_read` `fs_search`
|
|
122
121
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
Chrome (12): `chrome_list_tabs` `chrome_read_tab` `chrome_search_tabs` `chrome_navigate` `chrome_go_back` `chrome_setup_check` `chrome_evaluate_js` `chrome_click` `chrome_type` `chrome_fill_form` `chrome_wait_for` `chrome_query_selector_all`
|
|
122
|
+
Web (13): `web_login` `web_navigate` `web_read` `web_find` `web_click` `web_type` `web_extract` `web_wait_for` `web_screenshot` `web_show` `web_eval` `web_session_list` `web_session_close`
|
|
126
123
|
|
|
127
124
|
Microsoft 365 (16): `connect_m365_account` `disconnect_m365_account` `m365_list_emails` `m365_read_email` `m365_send_email` `m365_reply_email` `m365_search_emails` `m365_list_events` `m365_create_event` `m365_delete_event` `search_m365_directory` `get_m365_person` `list_m365_people_insights` `m365_list_contacts` `m365_search_contacts` `m365_get_contact`
|
|
128
125
|
|
package/download.js
CHANGED
|
@@ -52,7 +52,106 @@ const TRAY_APP = process.platform === 'darwin'
|
|
|
52
52
|
? path.join(os.homedir(), 'Applications', 'LocalMCPTray.app')
|
|
53
53
|
: null
|
|
54
54
|
|
|
55
|
+
// Mirror of go-server's CacheDir()/.machine-id (internal/machineid/machineid.go).
|
|
56
|
+
// Only the win32 ladder reads/writes it: it's the side of the probe that can
|
|
57
|
+
// come up empty on a real machine (wmic gone, PowerShell blocked by policy),
|
|
58
|
+
// and consulting the same file the go-server already wrote/reads is what lets
|
|
59
|
+
// the two sides converge on one id instead of the hostname-hash fallback.
|
|
60
|
+
// cacheDir is a parameter (not read from the module-level CACHE_DIR directly)
|
|
61
|
+
// for the same reason setup.js's _cloudTokenPath/_readCachedCloudToken/
|
|
62
|
+
// _writeCloudToken take it explicitly: it lets tests point at a temp dir
|
|
63
|
+
// instead of writing into the real user's cache.
|
|
64
|
+
const WIN_MACHINE_ID_CACHE_NAME = '.machine-id'
|
|
65
|
+
|
|
66
|
+
function _readWinMachineIdCache(cacheDir) {
|
|
67
|
+
try {
|
|
68
|
+
return fs.readFileSync(path.join(cacheDir, WIN_MACHINE_ID_CACHE_NAME), 'utf8').trim()
|
|
69
|
+
} catch {
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _writeWinMachineIdCache(cacheDir, id) {
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
77
|
+
const dest = path.join(cacheDir, WIN_MACHINE_ID_CACHE_NAME)
|
|
78
|
+
// Short-circuit if the on-disk value is already correct — mirrors
|
|
79
|
+
// go-server's writeCache() (machineid.go:117-119): avoids an unnecessary
|
|
80
|
+
// rewrite on every call.
|
|
81
|
+
try {
|
|
82
|
+
if (fs.readFileSync(dest, 'utf8').trim() === id) return
|
|
83
|
+
} catch {}
|
|
84
|
+
// Write-then-rename rather than writeFileSync straight onto dest.
|
|
85
|
+
// writeFileSync truncates in place, so a write interrupted by a crash or
|
|
86
|
+
// a killed `npx` leaves a SHORT id on disk — and a short id is not an
|
|
87
|
+
// empty one: _readWinMachineIdCache (and the go-server's own reader,
|
|
88
|
+
// same TrimSpace+non-empty check) would hand it back as a perfectly
|
|
89
|
+
// valid identity, splitting this machine's telemetry in two (#1704).
|
|
90
|
+
// Same fix as go-server/internal/machineid/machineid.go:120-124
|
|
91
|
+
// (CreateTemp + Rename): rename is atomic on the same volume, so a
|
|
92
|
+
// reader always sees either the old value or the complete new one.
|
|
93
|
+
const tmp = path.join(cacheDir, `${WIN_MACHINE_ID_CACHE_NAME}.tmp${process.pid}`)
|
|
94
|
+
fs.writeFileSync(tmp, id)
|
|
95
|
+
fs.renameSync(tmp, dest)
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Memoizes only the hardware probe (PowerShell/wmic spawn), not the whole
|
|
100
|
+
// ladder — same split as go-server's cachedHardware(). _getMachineId() runs on
|
|
101
|
+
// every ensureBinary()/ensureTray()/... call, and setup.js calls it several
|
|
102
|
+
// times per `npx … setup` run (once per tracked step); without memoizing,
|
|
103
|
+
// each of those would pay a fresh PowerShell spawn. A failed probe is
|
|
104
|
+
// memoized too, on purpose: a run where PowerShell is blocked shouldn't retry
|
|
105
|
+
// it on every subsequent call in the same process.
|
|
106
|
+
let _winHardwareIdCache
|
|
107
|
+
// execImpl is injectable (defaults to the real execSync) so tests can pin the
|
|
108
|
+
// PowerShell-then-wmic ORDER without a Windows machine — see
|
|
109
|
+
// npm/test/machine-id-windows-order.test.js.
|
|
110
|
+
function _probeWinHardwareId(execImpl = require('child_process').execSync) {
|
|
111
|
+
if (_winHardwareIdCache !== undefined) return _winHardwareIdCache
|
|
112
|
+
// Same order as go-server's probeHardware (probe_windows.go): PowerShell's
|
|
113
|
+
// WMI UUID first, wmic only as fallback. wmic is deprecated and absent from
|
|
114
|
+
// modern Windows images, which is why trying it first split machine_id
|
|
115
|
+
// between npm and the go-server and reintroduced #1544 (0 of 9 Windows
|
|
116
|
+
// installs joined their heartbeat because the two sides disagreed on id).
|
|
117
|
+
try {
|
|
118
|
+
const ps = execImpl(
|
|
119
|
+
'powershell -NoProfile -Command "(Get-WmiObject Win32_ComputerSystemProduct).UUID"',
|
|
120
|
+
{ stdio: 'pipe' }
|
|
121
|
+
).toString().trim()
|
|
122
|
+
// PowerShell prints the literal string "nil", not an empty line, when WMI
|
|
123
|
+
// has no UUID to give — an emptiness check alone would accept it as an id.
|
|
124
|
+
if (ps && ps !== 'nil') {
|
|
125
|
+
_winHardwareIdCache = ps
|
|
126
|
+
return _winHardwareIdCache
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
try {
|
|
130
|
+
const wmic = execImpl('wmic csproduct get UUID /value', { stdio: 'pipe' }).toString()
|
|
131
|
+
const match = wmic.match(/UUID=(.+)/)
|
|
132
|
+
if (match) {
|
|
133
|
+
_winHardwareIdCache = match[1].trim()
|
|
134
|
+
return _winHardwareIdCache
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
_winHardwareIdCache = ''
|
|
138
|
+
return _winHardwareIdCache
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Test-only: the hardware probe is memoized process-wide (see above), so a
|
|
142
|
+
// test that wants to exercise the ladder more than once with different
|
|
143
|
+
// canned execImpl behavior needs to clear it between cases.
|
|
144
|
+
function _resetWinHardwareIdCacheForTests() {
|
|
145
|
+
_winHardwareIdCache = undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
55
148
|
function _getMachineId() {
|
|
149
|
+
// QA override, checked before any platform probe — same rank as go-server's
|
|
150
|
+
// Get() — so a smoke-* run stays a smoke-* run on every code path that
|
|
151
|
+
// reports machine_id, not just the ones that already remembered to check it.
|
|
152
|
+
const envId = (process.env.LMCP_MACHINE_ID || '').trim()
|
|
153
|
+
if (envId) return envId
|
|
154
|
+
|
|
56
155
|
const { execSync } = require('child_process')
|
|
57
156
|
if (process.platform === 'darwin') {
|
|
58
157
|
try {
|
|
@@ -66,11 +165,17 @@ function _getMachineId() {
|
|
|
66
165
|
if (match) return match[1]
|
|
67
166
|
} catch {}
|
|
68
167
|
} else if (process.platform === 'win32') {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
168
|
+
const hw = _probeWinHardwareId()
|
|
169
|
+
if (hw) {
|
|
170
|
+
_writeWinMachineIdCache(CACHE_DIR, hw)
|
|
171
|
+
return hw
|
|
172
|
+
}
|
|
173
|
+
// Both probes failed or are blocked — fall back to whatever the go-server
|
|
174
|
+
// already cached rather than diverging to the hostname hash (the poisoned-
|
|
175
|
+
// cache case go-server's own Get() guards against does not apply here:
|
|
176
|
+
// this file is only ever written with a hardware-derived id, by either side).
|
|
177
|
+
const cached = _readWinMachineIdCache(CACHE_DIR)
|
|
178
|
+
if (cached) return cached
|
|
74
179
|
} else {
|
|
75
180
|
try {
|
|
76
181
|
const mid = fs.readFileSync('/etc/machine-id', 'utf8').trim()
|
|
@@ -349,7 +454,13 @@ async function ensureRuntime() {
|
|
|
349
454
|
* El binary se instala en ~/.local/share/local-mcp/bin/teams-proxy
|
|
350
455
|
* @returns {Promise<string|null>} Ruta al binary, o null si falla
|
|
351
456
|
*/
|
|
352
|
-
async function ensureTeamsProxy() {
|
|
457
|
+
async function ensureTeamsProxy(platform = process.platform) {
|
|
458
|
+
// Only published for darwin (darwin-arm64/darwin-x64 artifacts) — mirrors the
|
|
459
|
+
// guard ensureTray() already has. Without it, every Windows/Linux install fired
|
|
460
|
+
// a doomed download attempt and printed a confusing "not available" error (#1390).
|
|
461
|
+
// platform is a parameter (default process.platform) so the guard is testable
|
|
462
|
+
// without a network mock — same pattern as _writeLaunchScript's isWin.
|
|
463
|
+
if (platform !== 'darwin') return null
|
|
353
464
|
const info = await getLatestBinary()
|
|
354
465
|
const version = info.version
|
|
355
466
|
const arch = process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64'
|
|
@@ -536,7 +647,13 @@ function _writeTrayLaunchAgent(trayApp) {
|
|
|
536
647
|
* Binary is installed at ~/.local/share/local-mcp/bin/slack-proxy
|
|
537
648
|
* @returns {Promise<string|null>} Path to the binary, or null if it fails
|
|
538
649
|
*/
|
|
539
|
-
async function ensureSlackProxy() {
|
|
650
|
+
async function ensureSlackProxy(platform = process.platform) {
|
|
651
|
+
// Only published for darwin (darwin-arm64/darwin-x64 artifacts) — mirrors the
|
|
652
|
+
// guard ensureTray() already has. Without it, every Windows/Linux install fired
|
|
653
|
+
// a doomed download attempt and printed a confusing "not available" error (#1390).
|
|
654
|
+
// platform is a parameter (default process.platform) so the guard is testable
|
|
655
|
+
// without a network mock — same pattern as _writeLaunchScript's isWin.
|
|
656
|
+
if (platform !== 'darwin') return null
|
|
540
657
|
const info = await getLatestBinary()
|
|
541
658
|
const version = info.version
|
|
542
659
|
const arch = process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64'
|
|
@@ -581,4 +698,9 @@ async function ensureSlackProxy() {
|
|
|
581
698
|
}
|
|
582
699
|
}
|
|
583
700
|
|
|
584
|
-
module.exports = {
|
|
701
|
+
module.exports = {
|
|
702
|
+
ensureBinary, ensureRuntime, ensureTray, ensureTeamsProxy, ensureSlackProxy, versionFromArtifactUrl,
|
|
703
|
+
CACHE_DIR, TRAY_DIR, _getMachineId,
|
|
704
|
+
_probeWinHardwareId, _resetWinHardwareIdCacheForTests,
|
|
705
|
+
_readWinMachineIdCache, _writeWinMachineIdCache, WIN_MACHINE_ID_CACHE_NAME,
|
|
706
|
+
}
|
package/index.js
CHANGED
|
@@ -18,6 +18,161 @@ const os = require('os')
|
|
|
18
18
|
const fs = require('fs')
|
|
19
19
|
const https = require('https')
|
|
20
20
|
|
|
21
|
+
// ── Version + liveness helpers (module scope so they are testable) ────────────
|
|
22
|
+
|
|
23
|
+
// PIDs of the server processes THIS process spawned. The liveness guard asks about
|
|
24
|
+
// OTHER instances; counting our own child made it answer "servers running" forever.
|
|
25
|
+
const _spawnedServerPids = []
|
|
26
|
+
function spawnedServerPids() { return _spawnedServerPids.slice() }
|
|
27
|
+
function noteSpawnedServer(pid) { if (typeof pid === 'number') _spawnedServerPids.push(pid) }
|
|
28
|
+
|
|
29
|
+
/** Semver comparison: returns true if version a >= b (e.g. "3.0.72" >= "3.0.70") */
|
|
30
|
+
function semverGte(a, b) {
|
|
31
|
+
const av = (a || '').split('.').map(Number)
|
|
32
|
+
const bv = (b || '').split('.').map(Number)
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
const ai = av[i] || 0, bi = bv[i] || 0
|
|
35
|
+
if (ai > bi) return true
|
|
36
|
+
if (ai < bi) return false
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether the cached binary is fresh enough to exec directly (the fast path).
|
|
43
|
+
*
|
|
44
|
+
* #1394 §C: the gate used to be `semverGte(cachedVersion, pkg.version)` on every
|
|
45
|
+
* platform. On Windows/Linux those two numbers are DIFFERENT SERIES by construction
|
|
46
|
+
* — `.server-version` holds the go-server artifact version while pkg.version is the
|
|
47
|
+
* npm/Mac release, and download.js documents the divergence it already has to work
|
|
48
|
+
* around ("version=3.0.369 next to a 3.0.258 windows artifact"). The comparison is
|
|
49
|
+
* therefore false on every machine in the fleet: every single spawn paid a
|
|
50
|
+
* /runtime/latest round-trip and exit(1) when offline, holding a perfectly good
|
|
51
|
+
* cached binary. And the slow path rewrites `.server-version` with the artifact
|
|
52
|
+
* version again, so it can never heal itself.
|
|
53
|
+
*
|
|
54
|
+
* On darwin the two ARE the same series, so the comparison is meaningful and stays:
|
|
55
|
+
* it is what stops a stale npx cache from downgrading a binary the tray already
|
|
56
|
+
* upgraded (LMC-375). Off darwin, "there is a cached binary and we know its version"
|
|
57
|
+
* is the whole requirement; the 24h background check below handles staleness.
|
|
58
|
+
*/
|
|
59
|
+
function fastPathVersionOK(platform, cachedVersion, pkgVersion) {
|
|
60
|
+
if (!cachedVersion) return false // nothing cached → must download
|
|
61
|
+
if (platform === 'darwin') return semverGte(cachedVersion, pkgVersion)
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* PIDs of the lmcp/local-mcp servers running right now, or null when the answer
|
|
67
|
+
* cannot be determined.
|
|
68
|
+
*
|
|
69
|
+
* #1394 §C: this was `pgrep -fc local-mcp-server`, which returned 0 on every
|
|
70
|
+
* Windows/Linux machine for two reasons — Windows has no `pgrep` (the `|| echo 0`
|
|
71
|
+
* swallowed it) and off macOS the binary is called `lmcp-server`. So the LMCA-1044
|
|
72
|
+
* guard was permanently disabled exactly where the updater renames the file.
|
|
73
|
+
*
|
|
74
|
+
* The first fix for that inverted the defect, and the review of #1565 caught it:
|
|
75
|
+
* counting was correct but the count was never zero, so nothing ever updated again.
|
|
76
|
+
* Two independent causes, both handled here:
|
|
77
|
+
*
|
|
78
|
+
* 1. THIS process has already spawned its own server as a child (launchMcpStdio
|
|
79
|
+
* runs ~10s before this timer fires), so the count always included it. We are
|
|
80
|
+
* asking about OTHER instances — replacing the binary is what SIGTERMs sibling
|
|
81
|
+
* proxies — so known PIDs are excluded by the caller.
|
|
82
|
+
*
|
|
83
|
+
* 2. `execSync` runs through `/bin/sh -c`, whose own cmdline contains the pattern,
|
|
84
|
+
* so `pgrep -f 'lmcp-server'` matched the shell running the pgrep. Verified in
|
|
85
|
+
* Docker: it returns 2 with nothing running. The `[l]` bracket makes the regex
|
|
86
|
+
* match `lmcp-server` while the literal pattern text in the shell's cmdline
|
|
87
|
+
* (with the brackets) does not match itself.
|
|
88
|
+
*/
|
|
89
|
+
function listRunningServerPids(exec, platform = process.platform) {
|
|
90
|
+
try {
|
|
91
|
+
if (platform === 'win32') {
|
|
92
|
+
// tasklist ships on every Windows SKU. CSV so the PID column is unambiguous.
|
|
93
|
+
const pids = []
|
|
94
|
+
for (const name of ['lmcp-server.exe', 'local-mcp-server.exe']) {
|
|
95
|
+
const out = exec(`tasklist /FI "IMAGENAME eq ${name}" /NH /FO CSV`)
|
|
96
|
+
if (/INFO:/i.test(out)) continue
|
|
97
|
+
for (const line of String(out).split(/\r?\n/)) {
|
|
98
|
+
const m = line.match(/^"[^"]+","(\d+)"/)
|
|
99
|
+
if (m) pids.push(Number(m[1]))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return pids
|
|
103
|
+
}
|
|
104
|
+
// See (2) above for the brackets. One alternation per name: `lmcp-server` is not
|
|
105
|
+
// a substring of `local-mcp-server`, so both are needed.
|
|
106
|
+
//
|
|
107
|
+
// NO `|| true`, and that is the whole point of this branch. execSync throws on any
|
|
108
|
+
// non-zero exit, and pgrep overloads the code: 1 means it ran and matched nothing
|
|
109
|
+
// — a real answer — while 127 means the shell could not find pgrep at all (a
|
|
110
|
+
// container with no procps) and 2/3 are usage/fatal. `|| true` collapsed every one
|
|
111
|
+
// of them into empty stdout, so a MISSING pgrep read as "no servers running" and
|
|
112
|
+
// the guard authorised the swap with a server live. Measured in Docker without
|
|
113
|
+
// procps. Windows already honoured the contract below; the asymmetry was an
|
|
114
|
+
// accident of the shell idiom, not a decision.
|
|
115
|
+
let out
|
|
116
|
+
try {
|
|
117
|
+
out = exec(`pgrep -f '[l]mcp-server|[l]ocal-mcp-server'`)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err && err.status === 1) return [] // ran fine, nothing matched
|
|
120
|
+
throw err // could not look → null, via the catch below
|
|
121
|
+
}
|
|
122
|
+
return String(out).trim().split(/\s+/).filter(Boolean).map(Number).filter(n => !Number.isNaN(n))
|
|
123
|
+
} catch {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How many server processes are running OTHER than the ones we already know about.
|
|
130
|
+
* Returns -1 when the count CANNOT be determined. Callers must treat that as
|
|
131
|
+
* "assume servers are running": a guard that cannot see must not say yes.
|
|
132
|
+
*/
|
|
133
|
+
function countRunningServers(exec, platform = process.platform, excludePids = []) {
|
|
134
|
+
const pids = listRunningServerPids(exec, platform)
|
|
135
|
+
if (pids === null) return -1
|
|
136
|
+
const skip = new Set([process.pid, ...excludePids].filter(p => typeof p === 'number'))
|
|
137
|
+
return pids.filter(p => !skip.has(p)).length
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** True when it is safe to replace the cached binary on disk. */
|
|
141
|
+
function safeToReplaceBinary(exec, platform = process.platform, excludePids = []) {
|
|
142
|
+
return countRunningServers(exec, platform, excludePids) === 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Replace the cached server binary with `srcPath`, refusing while any server is
|
|
147
|
+
* running (LMCA-1044: swapping it mid-session changes its mtime and used to get
|
|
148
|
+
* sibling stdio proxies SIGTERM'd → Cursor transport_closed).
|
|
149
|
+
*
|
|
150
|
+
* The check lives INSIDE this function on purpose. It used to sit at the call site,
|
|
151
|
+
* and a mutation run showed the consequence: deleting it there passed every test,
|
|
152
|
+
* because the only caller is a closure inside main()'s background update timer that
|
|
153
|
+
* no unit test can reach. Callers can no longer forget a guard they cannot see.
|
|
154
|
+
*
|
|
155
|
+
* Returns 'replaced' | 'servers-running' | 'failed'.
|
|
156
|
+
*/
|
|
157
|
+
function replaceCachedBinary(deps, srcPath, destPath, version, versionFilePath) {
|
|
158
|
+
const { exec, fs, execFileSync, platform = process.platform, excludePids = [] } = deps
|
|
159
|
+
if (!safeToReplaceBinary(exec, platform, excludePids)) return 'servers-running'
|
|
160
|
+
try {
|
|
161
|
+
const tmpPath = destPath + '.tmp'
|
|
162
|
+
fs.copyFileSync(srcPath, tmpPath)
|
|
163
|
+
fs.chmodSync(tmpPath, 0o755)
|
|
164
|
+
if (platform === 'darwin') {
|
|
165
|
+
try { execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', tmpPath], { stdio: 'pipe' }) } catch {}
|
|
166
|
+
}
|
|
167
|
+
fs.renameSync(tmpPath, destPath)
|
|
168
|
+
fs.writeFileSync(versionFilePath, version)
|
|
169
|
+
return 'replaced'
|
|
170
|
+
} catch {
|
|
171
|
+
return 'failed'
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
21
176
|
// ── Uninstall sentinels ───────────────────────────────────────────────────────
|
|
22
177
|
// A `.uninstalled` sentinel marks a machine the user removed, so no respawn (an
|
|
23
178
|
// AI client that still has `local-mcp` in its config re-spawning `npx local-mcp`)
|
|
@@ -232,19 +387,21 @@ async function main() {
|
|
|
232
387
|
: (process.platform === 'win32' ? 'lmcp-server.exe' : 'lmcp-server')
|
|
233
388
|
const stableBin = path.join(CACHE_DIR, binName)
|
|
234
389
|
const versionFile = path.join(CACHE_DIR, '.server-version')
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return
|
|
390
|
+
// On Windows/Linux the file download.js and the go-server updater keep current is
|
|
391
|
+
// `.go-server-version`; `.server-version` is only written by this script's slow
|
|
392
|
+
// path. Read both, newest-wins, so the fast path sees what the updater actually
|
|
393
|
+
// installed instead of a value only it ever writes (#1394 §C).
|
|
394
|
+
const goVersionFileRead = path.join(CACHE_DIR, '.go-server-version')
|
|
395
|
+
function readCachedVersion() {
|
|
396
|
+
const read = (p) => { try { return fs.readFileSync(p, 'utf8').trim() } catch { return '' } }
|
|
397
|
+
const own = read(versionFile)
|
|
398
|
+
if (process.platform === 'darwin') return own
|
|
399
|
+
const plat = read(goVersionFileRead)
|
|
400
|
+
if (!own) return plat
|
|
401
|
+
if (!plat) return own
|
|
402
|
+
return semverGte(plat, own) ? plat : own
|
|
247
403
|
}
|
|
404
|
+
const pkg = require('./package.json')
|
|
248
405
|
|
|
249
406
|
/** MCP hosts (Cursor, Claude) spawn us without a TTY — keep stdio bridge alive. */
|
|
250
407
|
const isMcpClientSpawn = !(process.stdin && process.stdin.isTTY)
|
|
@@ -310,6 +467,7 @@ async function main() {
|
|
|
310
467
|
let _pkgVer = ''
|
|
311
468
|
try { _pkgVer = require('./package.json').version || '' } catch {}
|
|
312
469
|
const child = spawn(binaryPath, spawnArgs, { stdio: 'inherit', env: { ...process.env, LMCP_NPM_VERSION: _pkgVer } })
|
|
470
|
+
noteSpawnedServer(child.pid)
|
|
313
471
|
child.on('error', (err) => {
|
|
314
472
|
process.stderr.write(`Error ejecutando LMCP: ${err.message}\n`)
|
|
315
473
|
process.exit(1)
|
|
@@ -378,10 +536,8 @@ async function main() {
|
|
|
378
536
|
// hard failure when offline despite a valid cached binary). Only require the
|
|
379
537
|
// exec bit off-Windows, where it's meaningful.
|
|
380
538
|
if (stat.isFile() && (process.platform === 'win32' || (stat.mode & 0o111))) {
|
|
381
|
-
const cachedVersion =
|
|
382
|
-
|
|
383
|
-
: ''
|
|
384
|
-
if (cachedVersion && semverGte(cachedVersion, pkg.version)) {
|
|
539
|
+
const cachedVersion = readCachedVersion()
|
|
540
|
+
if (fastPathVersionOK(process.platform, cachedVersion, pkg.version)) {
|
|
385
541
|
// Cached binary is same or newer — use fast path, no download needed
|
|
386
542
|
await ensureDarwinComponents()
|
|
387
543
|
launchMcpStdio(stableBin, cachedVersion || pkg.version)
|
|
@@ -406,34 +562,19 @@ async function main() {
|
|
|
406
562
|
// New version available — download for next restart only while no server is running.
|
|
407
563
|
// Replacing stableBin mid-session changes its mtime and used to cause sibling
|
|
408
564
|
// stdio proxies to be SIGTERM'd → Cursor transport_closed (LMCA-1044).
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
565
|
+
const _exec = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
566
|
+
// Our OWN server child does not count: launchMcpStdio spawned it
|
|
567
|
+
// ~10s ago and it is the session this update is for. Counting it
|
|
568
|
+
// made the guard permanently false and stopped every update.
|
|
569
|
+
const _deps = { exec: _exec, fs, execFileSync, excludePids: spawnedServerPids() }
|
|
570
|
+
// Cheap pre-check so we do not download for nothing; the binding
|
|
571
|
+
// guard is the one inside replaceCachedBinary, re-checked after
|
|
572
|
+
// the download (a server can start while it runs).
|
|
573
|
+
if (!safeToReplaceBinary(_exec, process.platform, spawnedServerPids())) return
|
|
417
574
|
|
|
418
575
|
const { ensureRuntime } = require('./download')
|
|
419
576
|
ensureRuntime().then(({ binPath, version: downloadedVersion }) => {
|
|
420
|
-
|
|
421
|
-
let stillActive = 0
|
|
422
|
-
try {
|
|
423
|
-
stillActive = parseInt(
|
|
424
|
-
execSync('pgrep -fc local-mcp-server 2>/dev/null || echo 0', { encoding: 'utf8' }).trim(),
|
|
425
|
-
10
|
|
426
|
-
) || 0
|
|
427
|
-
} catch {}
|
|
428
|
-
if (stillActive > 0) return
|
|
429
|
-
|
|
430
|
-
const tmpPath = stableBin + '.tmp'
|
|
431
|
-
fs.copyFileSync(binPath, tmpPath)
|
|
432
|
-
fs.chmodSync(tmpPath, 0o755)
|
|
433
|
-
if (process.platform === 'darwin') { try { execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', tmpPath], { stdio: 'pipe' }) } catch {} }
|
|
434
|
-
fs.renameSync(tmpPath, stableBin)
|
|
435
|
-
fs.writeFileSync(versionFile, downloadedVersion || latestVersion)
|
|
436
|
-
} catch {}
|
|
577
|
+
replaceCachedBinary(_deps, binPath, stableBin, downloadedVersion || latestVersion, versionFile)
|
|
437
578
|
}).catch(() => {})
|
|
438
579
|
}
|
|
439
580
|
} catch {}
|
|
@@ -493,7 +634,14 @@ async function main() {
|
|
|
493
634
|
launchMcpStdio(stableBin, downloadedVersion || pkg.version)
|
|
494
635
|
}
|
|
495
636
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
637
|
+
// Only auto-run as a program. Being requirable is what lets the version/liveness
|
|
638
|
+
// helpers above be unit-tested at all — they guard a binary swap and a network
|
|
639
|
+
// round-trip that no static check can see.
|
|
640
|
+
if (require.main === module) {
|
|
641
|
+
main().catch(err => {
|
|
642
|
+
process.stderr.write(`Error fatal: ${err.message}\n`)
|
|
643
|
+
process.exit(1)
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
module.exports = { semverGte, fastPathVersionOK, listRunningServerPids, countRunningServers, safeToReplaceBinary, replaceCachedBinary }
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-mcp",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.374",
|
|
4
4
|
"description": "Let ChatGPT, Claude, Cursor & any MCP client actually use your Mac — read & reply to email, manage your calendar, text over iMessage, find files, work with Teams, Slack & Office. On your Mac, no API keys, free.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"local-mcp": "index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"postinstall": "node postinstall.js"
|
|
10
|
+
"postinstall": "node postinstall.js",
|
|
11
|
+
"test": "node --test test/*.test.js"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"index.js",
|
package/setup.js
CHANGED
|
@@ -12,6 +12,7 @@ const fs = require('fs')
|
|
|
12
12
|
const path = require('path')
|
|
13
13
|
const os = require('os')
|
|
14
14
|
const { execSync, execFileSync } = require('child_process')
|
|
15
|
+
const download = require('./download')
|
|
15
16
|
|
|
16
17
|
const HOME = os.homedir()
|
|
17
18
|
const NPX_COMMAND = 'npx'
|
|
@@ -33,14 +34,22 @@ function _resolveNpxPath() {
|
|
|
33
34
|
// curl-based telemetry. This gives us visibility into failures that happen
|
|
34
35
|
// BEFORE Node loads our code (e.g. old nvm default in Claude Desktop's PATH).
|
|
35
36
|
// Returns the path to the launcher, or null if writing fails.
|
|
36
|
-
|
|
37
|
+
// isWin is a parameter, not a read of _IS_WIN, so the Windows branch can be
|
|
38
|
+
// exercised on any machine. Written as `if (_IS_WIN)` it could only be tested ON
|
|
39
|
+
// Windows — and nobody runs this suite there day to day, so a mutation that put the
|
|
40
|
+
// broken `%*` back stayed green everywhere it was checked (#1394 §C).
|
|
41
|
+
function _writeLaunchScript(npxAbsPath, cacheDir, isWin = _IS_WIN) {
|
|
37
42
|
// On Windows, don't write a bash launch script — use npx directly
|
|
38
|
-
if (
|
|
43
|
+
if (isWin) {
|
|
39
44
|
try {
|
|
40
45
|
// Write a .cmd wrapper that Claude Desktop can execute
|
|
41
46
|
fs.mkdirSync(cacheDir, { recursive: true })
|
|
42
47
|
const cmdPath = path.join(cacheDir, 'lmcp-launch.cmd')
|
|
43
|
-
|
|
48
|
+
// No %*: the arguments are already baked into the line above. Forwarding %*
|
|
49
|
+
// appended a SECOND copy of whatever the MCP host passed, so a client
|
|
50
|
+
// configured with args ended up running `npx -y local-mcp@latest -y
|
|
51
|
+
// local-mcp@latest` (#1394 §C).
|
|
52
|
+
const script = `@echo off\r\n"${npxAbsPath}" -y local-mcp@latest\r\n`
|
|
44
53
|
fs.writeFileSync(cmdPath, script)
|
|
45
54
|
return cmdPath
|
|
46
55
|
} catch {
|
|
@@ -136,6 +145,17 @@ function _rooClinePath() {
|
|
|
136
145
|
return path.join(HOME, '.config', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json')
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
// Where LMCP's own config.json (license_email, cloud_token, activation_checklist_pending)
|
|
149
|
+
// lives. Used to hardcode the macOS "Library/Application Support" path unconditionally, so
|
|
150
|
+
// on Windows it resolved to a nonsense C:\Users\X\Library\...\Local MCP directory that
|
|
151
|
+
// nothing reads or creates on its own (#1390). Parameterized so it's testable without
|
|
152
|
+
// being on each OS.
|
|
153
|
+
function _localMcpConfigDir(platform = process.platform, home = HOME, appData = _APPDATA) {
|
|
154
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Local MCP')
|
|
155
|
+
if (platform === 'win32') return path.join(appData, 'Local MCP')
|
|
156
|
+
return path.join(home, '.config', 'local-mcp')
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
const CLIENTS = [
|
|
140
160
|
{
|
|
141
161
|
id: 'claude-desktop',
|
|
@@ -268,32 +288,51 @@ function _atomicWriteConfig(filePath, data) {
|
|
|
268
288
|
return { ok: true, error: '' }
|
|
269
289
|
}
|
|
270
290
|
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
291
|
+
// _launcherSpawnSpec turns a launcher path into the {command, args} an MCP client
|
|
292
|
+
// config must carry so the host can actually SPAWN it.
|
|
293
|
+
//
|
|
294
|
+
// #1394 §C: on Windows the launcher is a `.cmd`, and it was written into the config
|
|
295
|
+
// as a bare `command` for every client except claude-desktop. Node has refused to
|
|
296
|
+
// spawn a `.cmd`/`.bat` without `shell: true` since the CVE-2024-27980 fix, so every
|
|
297
|
+
// Node-based MCP host — Cursor, VS Code, Windsurf, Cline, Roo — got `spawn EINVAL`
|
|
298
|
+
// and showed local-mcp as failed, on the very platform this release re-launches.
|
|
299
|
+
//
|
|
300
|
+
// The fix is to spawn the interpreter and pass the script as an ARGUMENT, which is
|
|
301
|
+
// exactly what the claude-desktop branch of injectMcpConfig already did. A batch file
|
|
302
|
+
// is never the executable; cmd.exe is.
|
|
303
|
+
//
|
|
304
|
+
// The invariant a test can check without knowing this function's shape: `command`
|
|
305
|
+
// must never be a batch file. That is the precise condition Node refuses, not a
|
|
306
|
+
// spelling of the fix.
|
|
307
|
+
function _launcherSpawnSpec(launcherPath, isWin = _IS_WIN) {
|
|
308
|
+
// Kept as its own function so the rule is testable in isolation, but callers get
|
|
309
|
+
// it through _writeLaunchSpec below, which returns the SPEC and never a bare path.
|
|
310
|
+
// A mutation run showed why: with the conversion left to the caller, a caller that
|
|
311
|
+
// skipped it passed every test — the only caller lives inside runSetup(), where no
|
|
312
|
+
// unit test can reach. There is now nothing to forget.
|
|
313
|
+
if (!launcherPath) return null
|
|
314
|
+
if (isWin && /\.(cmd|bat)$/i.test(launcherPath)) {
|
|
315
|
+
return { command: process.env.COMSPEC || 'cmd.exe', args: ['/c', launcherPath] }
|
|
286
316
|
}
|
|
317
|
+
return { command: launcherPath, args: [] }
|
|
318
|
+
}
|
|
287
319
|
|
|
288
|
-
|
|
320
|
+
// _writeLaunchSpec is what every caller uses: it writes the launcher AND returns the
|
|
321
|
+
// {command, args} an MCP client config can actually spawn. Returning a bare path is
|
|
322
|
+
// what let a `.cmd` land in the config as the executable (#1394 §C).
|
|
323
|
+
function _writeLaunchSpec(npxAbsPath, cacheDir, isWin = _IS_WIN) {
|
|
324
|
+
return _launcherSpawnSpec(_writeLaunchScript(npxAbsPath, cacheDir, isWin), isWin)
|
|
325
|
+
}
|
|
289
326
|
|
|
290
|
-
|
|
327
|
+
// Merges the local-mcp entry into a SINGLE config object, mutating it in place.
|
|
328
|
+
// Split out of injectMcpConfig so the same merge logic runs independently per path —
|
|
329
|
+
// see the comment on injectMcpConfig for why that independence matters.
|
|
330
|
+
function _mergeLocalMcpEntry(cfg, client, command, args) {
|
|
291
331
|
const existingServers = Object.keys(
|
|
292
332
|
cfg.mcpServers || cfg.servers || cfg.context_servers || {}
|
|
293
333
|
)
|
|
294
334
|
const otherServers = existingServers.filter(k => k !== 'local-mcp' && k !== 'office-mcp')
|
|
295
335
|
|
|
296
|
-
// 2. Merge — preserve everything, only add/replace local-mcp
|
|
297
336
|
if (client.zed) {
|
|
298
337
|
cfg.context_servers = cfg.context_servers || {}
|
|
299
338
|
cfg.context_servers['local-mcp'] = { command: { path: command, args: args ?? [] } }
|
|
@@ -308,23 +347,71 @@ function injectMcpConfig(client, command = NPX_COMMAND, args = NPX_ARGS) {
|
|
|
308
347
|
if (args !== undefined) entry.args = args
|
|
309
348
|
cfg.mcpServers['local-mcp'] = entry
|
|
310
349
|
}
|
|
350
|
+
return { existingCount: existingServers.length, otherServers }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Inyectar config MCP en un cliente ────────────────────────────────────────
|
|
354
|
+
// Returns { ok, stage, error, existingMcpCount, preservedServers, perPath }.
|
|
355
|
+
// Never overwrites a file whose JSON cannot be parsed.
|
|
356
|
+
//
|
|
357
|
+
// Windows Claude Desktop can have TWO install types (MSIX + .exe) reading from TWO
|
|
358
|
+
// different config files (_claudeDesktopAllPaths()). This used to read only the
|
|
359
|
+
// PREFERRED path, merge local-mcp into that single object, then write that SAME
|
|
360
|
+
// object to both paths — silently erasing any mcpServers that existed only in the
|
|
361
|
+
// non-preferred file, and reporting only the last path's write result because `write`
|
|
362
|
+
// was reassigned each loop iteration (#1390 finding 4). Each path is now read,
|
|
363
|
+
// merged, and written independently, and the JSON-parse-error backup guard in
|
|
364
|
+
// _safeReadConfig now protects every path, not just the preferred one.
|
|
365
|
+
//
|
|
366
|
+
// explicitPaths lets a test exercise the multi-path merge deterministically without
|
|
367
|
+
// running on Windows with two real Claude Desktop installs (default null preserves
|
|
368
|
+
// the real path-selection logic below).
|
|
369
|
+
function injectMcpConfig(client, command = NPX_COMMAND, args = NPX_ARGS, explicitPaths = null) {
|
|
370
|
+
// On Windows, Claude Desktop needs cmd /c wrapper to find npx
|
|
371
|
+
if (_IS_WIN && client.id === 'claude-desktop') {
|
|
372
|
+
command = 'cmd'
|
|
373
|
+
args = ['/c', 'npx', '-y', 'local-mcp@latest']
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const allPaths = explicitPaths ||
|
|
377
|
+
((client.id === 'claude-desktop' && _IS_WIN) ? _claudeDesktopAllPaths() : [client.cfgPath])
|
|
378
|
+
|
|
379
|
+
const perPath = []
|
|
380
|
+
let existingMcpCount = 0
|
|
381
|
+
const preservedSet = new Set()
|
|
311
382
|
|
|
312
|
-
// 3. Atomic write + verify
|
|
313
|
-
// On Windows, Claude Desktop MSIX reads from a different path than the .exe install.
|
|
314
|
-
// Write to all known paths so both install types work.
|
|
315
|
-
const allPaths = (client.id === 'claude-desktop' && _IS_WIN) ? _claudeDesktopAllPaths() : [client.cfgPath]
|
|
316
|
-
let write = { ok: false, error: 'no paths' }
|
|
317
383
|
for (const p of allPaths) {
|
|
384
|
+
const read = _safeReadConfig(p)
|
|
385
|
+
if (read.hadParseError) {
|
|
386
|
+
perPath.push({ path: p, ok: false, stage: 'config_read', error: read.error, hadParseError: true })
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
const cfg = read.data || {}
|
|
390
|
+
const { existingCount, otherServers } = _mergeLocalMcpEntry(cfg, client, command, args)
|
|
391
|
+
existingMcpCount = Math.max(existingMcpCount, existingCount)
|
|
392
|
+
otherServers.forEach((s) => preservedSet.add(s))
|
|
393
|
+
|
|
318
394
|
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
319
|
-
write = _atomicWriteConfig(p, cfg)
|
|
395
|
+
const write = _atomicWriteConfig(p, cfg)
|
|
396
|
+
perPath.push({ path: p, ok: write.ok, stage: write.ok ? 'config_write' : 'config_write_failed', error: write.error, hadParseError: false })
|
|
320
397
|
}
|
|
398
|
+
|
|
399
|
+
const anyOk = perPath.some((r) => r.ok)
|
|
400
|
+
const failed = perPath.filter((r) => !r.ok)
|
|
401
|
+
if (failed.length > 0) {
|
|
402
|
+
for (const f of failed) {
|
|
403
|
+
process.stderr.write(` ⚠ ${client.name}: could not update ${f.path} (${f.error}) — other MCP servers configured only there may be affected\n`)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
321
407
|
return {
|
|
322
|
-
ok:
|
|
323
|
-
stage:
|
|
324
|
-
error:
|
|
325
|
-
hadParseError:
|
|
326
|
-
existingMcpCount
|
|
327
|
-
preservedServers:
|
|
408
|
+
ok: anyOk,
|
|
409
|
+
stage: anyOk ? 'config_write' : (perPath[0] ? perPath[0].stage : 'config_read'),
|
|
410
|
+
error: failed.map((f) => `${f.path}: ${f.error}`).join('; ') || (perPath[0] ? perPath[0].error : 'no paths'),
|
|
411
|
+
hadParseError: !anyOk && perPath.some((r) => r.hadParseError),
|
|
412
|
+
existingMcpCount,
|
|
413
|
+
preservedServers: Array.from(preservedSet),
|
|
414
|
+
perPath,
|
|
328
415
|
}
|
|
329
416
|
}
|
|
330
417
|
|
|
@@ -403,30 +490,39 @@ async function runSetup(opts = {}) {
|
|
|
403
490
|
process.stderr.write('Downloading LMCP runtime...\n')
|
|
404
491
|
const { binPath, version: bv } = await ensureBinary()
|
|
405
492
|
binaryVersion = bv || ''
|
|
406
|
-
// Copy binary to stable path (npx fast-path will exec it directly)
|
|
407
|
-
const stablePath = path.join(CACHE_DIR, 'local-mcp-server')
|
|
408
493
|
try {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
494
|
+
// Copy binary to stable path (npx fast-path will exec it directly). darwin-only:
|
|
495
|
+
// off darwin, ensureBinary() already writes the go-server binary directly at its
|
|
496
|
+
// final name (lmcp-server / lmcp-server.exe) inside CACHE_DIR — index.js's fast
|
|
497
|
+
// path looks for exactly that. This block, unguarded, used to also run there and
|
|
498
|
+
// leave a dead second copy misnamed `local-mcp-server` (no .exe, never read by
|
|
499
|
+
// anything) plus a doomed `codesign` call and an EISDIR migration written for a
|
|
500
|
+
// curl-install bug that only ever existed on macOS's old Python installer (#1390).
|
|
501
|
+
if (_IS_MAC) {
|
|
502
|
+
const stablePath = path.join(CACHE_DIR, 'local-mcp-server')
|
|
503
|
+
const tmpPath = stablePath + '.tmp'
|
|
504
|
+
fs.copyFileSync(binPath, tmpPath)
|
|
505
|
+
fs.chmodSync(tmpPath, 0o755)
|
|
506
|
+
try { execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', tmpPath], { stdio: 'pipe' }) } catch {}
|
|
507
|
+
// curl route-3 installs the binary one level deeper:
|
|
508
|
+
// ~/.local/share/local-mcp/bin/local-mcp-server/local-mcp-server
|
|
509
|
+
// leaving stablePath itself as a DIRECTORY. fs.renameSync throws EISDIR
|
|
510
|
+
// silently, so the npm stable binary is never written and the old curl
|
|
511
|
+
// server stays running forever (LMC-158).
|
|
512
|
+
try {
|
|
513
|
+
const st = fs.statSync(stablePath)
|
|
514
|
+
if (st.isDirectory()) {
|
|
515
|
+
process.stderr.write(' Migrating from curl install: removing old versioned directory at stable path\n')
|
|
516
|
+
fs.rmSync(stablePath, { recursive: true, force: true })
|
|
517
|
+
}
|
|
518
|
+
} catch { /* stablePath doesn't exist — nothing to do */ }
|
|
519
|
+
fs.renameSync(tmpPath, stablePath)
|
|
520
|
+
}
|
|
426
521
|
// Write the actual binary version (from /runtime/latest), not the npm package
|
|
427
522
|
// version. npm and Swift binary versions can diverge (npm-only fixes bump npm
|
|
428
523
|
// without a Swift rebuild). Writing pkg.version here caused the tray to think
|
|
429
|
-
// a newer binary was on disk and restart-loop (LMC-TODO).
|
|
524
|
+
// a newer binary was on disk and restart-loop (LMC-TODO). Runs on every
|
|
525
|
+
// platform: index.js's readCachedVersion() reads .server-version off darwin too.
|
|
430
526
|
fs.writeFileSync(path.join(CACHE_DIR, '.server-version'), binaryVersion || '')
|
|
431
527
|
const settingsSrc = path.join(path.dirname(binPath), 'settings.html')
|
|
432
528
|
const settingsDst = path.join(CACHE_DIR, 'settings.html')
|
|
@@ -438,16 +534,16 @@ async function runSetup(opts = {}) {
|
|
|
438
534
|
// The launcher uses curl (no Node dependency) to send telemetry if Node is
|
|
439
535
|
// too old, then exec's npx. Claude Desktop runs this script instead of npx
|
|
440
536
|
// directly, so failures are visible in MCP logs AND in our backend events.
|
|
441
|
-
const
|
|
442
|
-
if (
|
|
537
|
+
const spec = _writeLaunchSpec(npxAbsPath, CACHE_DIR)
|
|
538
|
+
if (spec) { stableCommand = spec.command; stableArgs = spec.args }
|
|
443
539
|
} catch (err) {
|
|
444
540
|
process.stderr.write(` (Runtime download failed, will download on first run: ${err.message})\n\n`)
|
|
445
541
|
// Still try to write a launcher even if binary download failed — the version
|
|
446
542
|
// check + telemetry path is independent of the binary.
|
|
447
543
|
try {
|
|
448
544
|
const { CACHE_DIR } = require('./download')
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
545
|
+
const spec = _writeLaunchSpec(npxAbsPath, CACHE_DIR)
|
|
546
|
+
if (spec) { stableCommand = spec.command; stableArgs = spec.args }
|
|
451
547
|
} catch {}
|
|
452
548
|
}
|
|
453
549
|
// stableCommand is now the shell launcher script path. It wraps npx with a
|
|
@@ -476,7 +572,7 @@ async function runSetup(opts = {}) {
|
|
|
476
572
|
console.log('╚══════════════════════════════════════════════════════════╝\n')
|
|
477
573
|
console.log(' To get started, add LMCP to your AI client config:\n')
|
|
478
574
|
console.log(' ── Claude Desktop ────────────────────────────────────────')
|
|
479
|
-
console.log(
|
|
575
|
+
console.log(` File: ${_claudeDesktopPath()}\n`)
|
|
480
576
|
console.log(snippet)
|
|
481
577
|
console.log('\n ── Cursor ────────────────────────────────────────────────')
|
|
482
578
|
console.log(' File: ~/.cursor/mcp.json\n')
|
|
@@ -522,7 +618,7 @@ async function runSetup(opts = {}) {
|
|
|
522
618
|
}
|
|
523
619
|
}
|
|
524
620
|
|
|
525
|
-
const cfgDir =
|
|
621
|
+
const cfgDir = _localMcpConfigDir()
|
|
526
622
|
const cfgFile = path.join(cfgDir, 'config.json')
|
|
527
623
|
|
|
528
624
|
// Escribir email al config si viene por env
|
|
@@ -571,7 +667,7 @@ async function runSetup(opts = {}) {
|
|
|
571
667
|
if (configured.length > 0) {
|
|
572
668
|
if (healthOk) {
|
|
573
669
|
console.log(`✅ LMCP configured for: ${configured.join(', ')}\n`)
|
|
574
|
-
console.log(' ✓ Server binary verified —
|
|
670
|
+
console.log(' ✓ Server binary verified — 188+ tools ready\n')
|
|
575
671
|
} else {
|
|
576
672
|
console.log(`⚠ LMCP configured for: ${configured.join(', ')}`)
|
|
577
673
|
console.log(' Server binary could not be verified — it may still work after restart.\n')
|
|
@@ -661,16 +757,26 @@ async function runSetup(opts = {}) {
|
|
|
661
757
|
// Both fail silently: setup finishes, nothing is printed, the relay simply never comes up.
|
|
662
758
|
{
|
|
663
759
|
try {
|
|
760
|
+
// On darwin the token lives in config.json, read by the Swift tray. Off darwin
|
|
761
|
+
// the go-server reads ONLY CACHE_DIR/.cloud-token (#1390 finding 2) — check and
|
|
762
|
+
// write there instead, so this doesn't mint a second, unredeemable token under
|
|
763
|
+
// the same (now-correct, per finding 1) machine_id the go-server would otherwise
|
|
764
|
+
// register for itself on first run.
|
|
664
765
|
const cfg = (_safeReadConfig(cfgFile)).data || {}
|
|
665
|
-
const
|
|
766
|
+
const cfgToken = (cfg.cloud_token && typeof cfg.cloud_token === 'string'
|
|
666
767
|
&& cfg.cloud_token.startsWith('lmcp-')) ? cfg.cloud_token : ''
|
|
768
|
+
const existing = _IS_MAC ? cfgToken : (cfgToken || _readCachedCloudToken(download.CACHE_DIR))
|
|
667
769
|
let token = existing
|
|
668
770
|
if (!token) {
|
|
669
771
|
token = await _registerAnonToken()
|
|
670
772
|
if (token) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
773
|
+
if (_IS_MAC) {
|
|
774
|
+
const cfg2 = (_safeReadConfig(cfgFile)).data || {}
|
|
775
|
+
cfg2.cloud_token = token
|
|
776
|
+
_atomicWriteConfig(cfgFile, cfg2)
|
|
777
|
+
} else {
|
|
778
|
+
_writeCloudToken(download.CACHE_DIR, token)
|
|
779
|
+
}
|
|
674
780
|
console.log(' ✓ Cloud relay activated (anonymous)')
|
|
675
781
|
}
|
|
676
782
|
}
|
|
@@ -730,21 +836,43 @@ async function _installTray() {
|
|
|
730
836
|
}
|
|
731
837
|
}
|
|
732
838
|
|
|
839
|
+
// Resolves what to exec to prove the installed binary launches and responds. Pulled out
|
|
840
|
+
// as a pure function (platform/home/cacheDir all parameters) so it's testable without
|
|
841
|
+
// being on each OS. darwin uses the Swift binary's --export-tools (unchanged, tool-count
|
|
842
|
+
// check); win32/linux use the go-server binary, which only supports --version/--daemon
|
|
843
|
+
// (go-server/cmd/lmcp-server/main.go:430-433) — the old code called --export-tools there
|
|
844
|
+
// too, which would have fallen through to stdio mode and hung until the timeout (#1390
|
|
845
|
+
// finding 3), on top of checking a path+name (local-mcp-server, no .exe) the go-server
|
|
846
|
+
// binary never has.
|
|
847
|
+
function _healthCheckSpec(platform = process.platform, home = HOME, cacheDir = download.CACHE_DIR) {
|
|
848
|
+
if (platform === 'darwin') {
|
|
849
|
+
return { binPath: path.join(home, '.local', 'share', 'local-mcp', 'bin', 'local-mcp-server'), args: ['--export-tools'] }
|
|
850
|
+
}
|
|
851
|
+
const binName = platform === 'win32' ? 'lmcp-server.exe' : 'lmcp-server'
|
|
852
|
+
return { binPath: path.join(cacheDir, binName), args: ['--version'] }
|
|
853
|
+
}
|
|
854
|
+
|
|
733
855
|
function _runHealthCheck() {
|
|
734
856
|
try {
|
|
735
|
-
const binPath
|
|
857
|
+
const { binPath, args } = _healthCheckSpec()
|
|
736
858
|
if (!fs.existsSync(binPath)) return false
|
|
737
|
-
|
|
738
|
-
const result = execFileSync(binPath, ['--export-tools'], {
|
|
859
|
+
const result = execFileSync(binPath, args, {
|
|
739
860
|
timeout: 10000,
|
|
740
861
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
741
862
|
env: { ...process.env, HOME },
|
|
742
863
|
})
|
|
743
864
|
const output = result.toString().trim()
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
865
|
+
if (_IS_MAC) {
|
|
866
|
+
// Should return JSON array of tools
|
|
867
|
+
const tools = JSON.parse(output)
|
|
868
|
+
if (Array.isArray(tools) && tools.length > 50) {
|
|
869
|
+
process.stderr.write(` Health check: ${tools.length} tools verified\n`)
|
|
870
|
+
return true
|
|
871
|
+
}
|
|
872
|
+
return false
|
|
873
|
+
}
|
|
874
|
+
if (output) {
|
|
875
|
+
process.stderr.write(` Health check: server binary responds (v${output})\n`)
|
|
748
876
|
return true
|
|
749
877
|
}
|
|
750
878
|
return false
|
|
@@ -759,24 +887,23 @@ function _getRef() {
|
|
|
759
887
|
return process.env.LMCP_REF || 'npm'
|
|
760
888
|
}
|
|
761
889
|
|
|
890
|
+
// Delegates to download.js's cross-platform implementation (darwin Keychain/ioreg +
|
|
891
|
+
// win32 wmic UUID + linux /etc/machine-id + hashed-hostname fallback) instead of the
|
|
892
|
+
// darwin-only Keychain/ioreg pair this function used to hardcode — that duplicate is
|
|
893
|
+
// why machine_id traveled empty through the ENTIRE Windows/Linux setup funnel (#1390).
|
|
894
|
+
//
|
|
895
|
+
// The one thing download.js's version doesn't do is seed the macOS Keychain with the
|
|
896
|
+
// ioreg-derived id so the Swift tray reads the same value setup computed. That side
|
|
897
|
+
// effect stays here, gated to darwin, instead of moving into download.js: download's
|
|
898
|
+
// _getMachineId runs on every ensureBinary()/ensureTray()/... call (i.e. on every
|
|
899
|
+
// npx spawn), and a Keychain write there would be needless overhead on a hot path
|
|
900
|
+
// that setup.js — which only runs once at install time — doesn't share.
|
|
762
901
|
function _getMachineId() {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const r = execSync('security find-generic-password -s com.local-mcp.machine-id -a machine-id -w 2>/dev/null', { stdio: 'pipe' })
|
|
766
|
-
const id = r.toString().trim()
|
|
767
|
-
if (id) return id
|
|
768
|
-
} catch {}
|
|
769
|
-
try {
|
|
770
|
-
// Generate from hardware UUID (full UUID, same format as Swift server)
|
|
771
|
-
const ioreg = execSync('ioreg -rd1 -c IOPlatformExpertDevice', { stdio: 'pipe' }).toString()
|
|
772
|
-
const match = ioreg.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)
|
|
773
|
-
if (!match) return '' // No hostname fallback — avoid PII leak
|
|
774
|
-
const id = match[1]
|
|
775
|
-
// Save to Keychain for Swift server to read
|
|
902
|
+
const id = download._getMachineId()
|
|
903
|
+
if (_IS_MAC && id) {
|
|
776
904
|
try { execSync(`security add-generic-password -s com.local-mcp.machine-id -a machine-id -w "${id}" -U 2>/dev/null`, { stdio: 'pipe' }) } catch {}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
return ''
|
|
905
|
+
}
|
|
906
|
+
return id
|
|
780
907
|
}
|
|
781
908
|
|
|
782
909
|
/// Associate an email with this machine's tunnel token, presenting the token as proof of
|
|
@@ -821,6 +948,30 @@ async function _associateEmail(email, token) {
|
|
|
821
948
|
})
|
|
822
949
|
}
|
|
823
950
|
|
|
951
|
+
// On darwin the anon cloud_token is persisted in config.json (cfgFile), which the Swift
|
|
952
|
+
// tray already reads. Off darwin, the go-server reads it EXCLUSIVELY from
|
|
953
|
+
// getCacheDir()/.cloud-token as a plain trimmed string (tunnel.go:454,513-523) — it never
|
|
954
|
+
// looks at config.json — so writing the token to cfgFile there left it registered under a
|
|
955
|
+
// key nothing could ever redeem (#1390 finding 2). getCacheDir() in the go-server resolves
|
|
956
|
+
// to the exact same directory as CACHE_DIR here (tunnel.go:1107-1113 vs. download.js:39-41).
|
|
957
|
+
function _cloudTokenPath(cacheDir) {
|
|
958
|
+
return path.join(cacheDir, '.cloud-token')
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function _readCachedCloudToken(cacheDir) {
|
|
962
|
+
try {
|
|
963
|
+
const tok = fs.readFileSync(_cloudTokenPath(cacheDir), 'utf8').trim()
|
|
964
|
+
return tok.startsWith('lmcp-') ? tok : ''
|
|
965
|
+
} catch { return '' }
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function _writeCloudToken(cacheDir, token) {
|
|
969
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
970
|
+
// 0o600 matches tokenFileMode in tunnel.go — best-effort on Windows, where POSIX bits
|
|
971
|
+
// are largely ignored, but real restriction on Linux.
|
|
972
|
+
fs.writeFileSync(_cloudTokenPath(cacheDir), token, { mode: 0o600 })
|
|
973
|
+
}
|
|
974
|
+
|
|
824
975
|
/// Claim an anonymous tunnel token via /tunnel/register-anon so cloud relay
|
|
825
976
|
/// activates without asking for an email (2026-04-19 anon-first rollout). The
|
|
826
977
|
/// token is keyed on the Mac's machine_id (IOPlatformUUID, shared via Keychain
|
|
@@ -1098,4 +1249,9 @@ function _autoLaunchClient(configured) {
|
|
|
1098
1249
|
|
|
1099
1250
|
}
|
|
1100
1251
|
|
|
1101
|
-
module.exports = {
|
|
1252
|
+
module.exports = {
|
|
1253
|
+
runSetup, injectMcpConfig, CLIENTS,
|
|
1254
|
+
_launcherSpawnSpec, _writeLaunchScript, _writeLaunchSpec,
|
|
1255
|
+
_getMachineId, _healthCheckSpec, _localMcpConfigDir,
|
|
1256
|
+
_cloudTokenPath, _readCachedCloudToken, _writeCloudToken, _registerAnonToken,
|
|
1257
|
+
}
|