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.
Files changed (5) hide show
  1. package/README.md +3 -6
  2. package/download.js +130 -8
  3. package/index.js +193 -45
  4. package/package.json +3 -2
  5. 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 215+ 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, Safari, Chrome, Word/Excel/PowerPoint and more. 100% local, no API keys, free — your data never leaves your machine.
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
  [![npm](https://img.shields.io/npm/v/local-mcp?style=flat-square&label=npm)](https://www.npmjs.com/package/local-mcp)
6
6
  [![macOS](https://img.shields.io/badge/macOS-13%2B-111111?style=flat-square)](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
- | **Safari** | Bookmarks, open tabs, click/type/fill forms, run JavaScript |
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
- Safari (13): `list_safari_bookmarks` `safari_list_tabs` `safari_read_tab` `safari_search_tabs` `safari_navigate` `safari_go_back` `safari_setup_check` `safari_evaluate_js` `safari_click` `safari_type` `safari_fill_form` `safari_wait_for` `safari_query_selector_all`
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
- try {
70
- const wmic = execSync('wmic csproduct get UUID /value', { stdio: 'pipe' }).toString()
71
- const match = wmic.match(/UUID=(.+)/)
72
- if (match) return match[1].trim()
73
- } catch {}
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 = { ensureBinary, ensureRuntime, ensureTray, ensureTeamsProxy, ensureSlackProxy, versionFromArtifactUrl, CACHE_DIR, TRAY_DIR }
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
- const pkg = require('./package.json')
236
-
237
- // Semver comparison: returns true if version a >= b (e.g. "3.0.72" >= "3.0.70")
238
- function semverGte(a, b) {
239
- const av = (a || '').split('.').map(Number)
240
- const bv = (b || '').split('.').map(Number)
241
- for (let i = 0; i < 3; i++) {
242
- const ai = av[i] || 0, bi = bv[i] || 0
243
- if (ai > bi) return true
244
- if (ai < bi) return false
245
- }
246
- return true
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 = fs.existsSync(versionFile)
382
- ? fs.readFileSync(versionFile, 'utf8').trim()
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
- let activeServers = 0
410
- try {
411
- activeServers = parseInt(
412
- execSync('pgrep -fc local-mcp-server 2>/dev/null || echo 0', { encoding: 'utf8' }).trim(),
413
- 10
414
- ) || 0
415
- } catch {}
416
- if (activeServers > 0) return
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
- try {
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
- main().catch(err => {
497
- process.stderr.write(`Error fatal: ${err.message}\n`)
498
- process.exit(1)
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.372",
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
- function _writeLaunchScript(npxAbsPath, cacheDir) {
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 (_IS_WIN) {
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
- const script = `@echo off\r\n"${npxAbsPath}" -y local-mcp@latest %*\r\n`
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
- // ── Inyectar config MCP en un cliente ────────────────────────────────────────
272
- // Returns { ok, stage, error, existingMcpCount, preservedServers }.
273
- // Never overwrites a file whose JSON cannot be parsed.
274
-
275
- function injectMcpConfig(client, command = NPX_COMMAND, args = NPX_ARGS) {
276
- // On Windows, Claude Desktop needs cmd /c wrapper to find npx
277
- if (_IS_WIN && client.id === 'claude-desktop') {
278
- command = 'cmd'
279
- args = ['/c', 'npx', '-y', 'local-mcp@latest']
280
- }
281
- // 1. Read existing config safely
282
- const read = _safeReadConfig(client.cfgPath)
283
- if (read.hadParseError) {
284
- return { ok: false, stage: 'config_read', error: read.error, hadParseError: true,
285
- existingMcpCount: 0, preservedServers: [] }
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
- const cfg = read.data || {}
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
- // Count other MCP servers before merge (for telemetry)
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: write.ok,
323
- stage: write.ok ? 'config_write' : 'config_write_failed',
324
- error: write.error,
325
- hadParseError: false,
326
- existingMcpCount: existingServers.length,
327
- preservedServers: otherServers,
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
- const tmpPath = stablePath + '.tmp'
410
- fs.copyFileSync(binPath, tmpPath)
411
- fs.chmodSync(tmpPath, 0o755)
412
- try { execFileSync('codesign', ['--force', '--sign', '-', '--identifier', 'com.local-mcp.server', tmpPath], { stdio: 'pipe' }) } catch {}
413
- // curl route-3 installs the binary one level deeper:
414
- // ~/.local/share/local-mcp/bin/local-mcp-server/local-mcp-server
415
- // leaving stablePath itself as a DIRECTORY. fs.renameSync throws EISDIR
416
- // silently, so the npm stable binary is never written and the old curl
417
- // server stays running forever (LMC-158).
418
- try {
419
- const st = fs.statSync(stablePath)
420
- if (st.isDirectory()) {
421
- process.stderr.write(' Migrating from curl install: removing old versioned directory at stable path\n')
422
- fs.rmSync(stablePath, { recursive: true, force: true })
423
- }
424
- } catch { /* stablePath doesn't exist nothing to do */ }
425
- fs.renameSync(tmpPath, stablePath)
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 launcherPath = _writeLaunchScript(npxAbsPath, CACHE_DIR)
442
- if (launcherPath) stableCommand = launcherPath
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 launcherPath = _writeLaunchScript(npxAbsPath, CACHE_DIR)
450
- if (launcherPath) stableCommand = launcherPath
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(' File: ~/Library/Application Support/Claude/claude_desktop_config.json\n')
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 = path.join(HOME, 'Library', 'Application Support', 'Local MCP')
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 — 215+ tools ready\n')
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 existing = (cfg.cloud_token && typeof cfg.cloud_token === 'string'
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
- const cfg2 = (_safeReadConfig(cfgFile)).data || {}
672
- cfg2.cloud_token = token
673
- _atomicWriteConfig(cfgFile, cfg2)
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 = path.join(HOME, '.local', 'share', 'local-mcp', 'bin', 'local-mcp-server')
857
+ const { binPath, args } = _healthCheckSpec()
736
858
  if (!fs.existsSync(binPath)) return false
737
- // Run with --export-tools to verify binary launches and responds
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
- // Should return JSON array of tools
745
- const tools = JSON.parse(output)
746
- if (Array.isArray(tools) && tools.length > 50) {
747
- process.stderr.write(` Health check: ${tools.length} tools verified\n`)
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
- try {
764
- // Try reading from macOS Keychain (shared with Swift server)
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
- return id
778
- } catch {}
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 = { runSetup, injectMcpConfig, CLIENTS }
1252
+ module.exports = {
1253
+ runSetup, injectMcpConfig, CLIENTS,
1254
+ _launcherSpawnSpec, _writeLaunchScript, _writeLaunchSpec,
1255
+ _getMachineId, _healthCheckSpec, _localMcpConfigDir,
1256
+ _cloudTokenPath, _readCachedCloudToken, _writeCloudToken, _registerAnonToken,
1257
+ }