#!/bin/bash # LMCP — Installer # Usage: curl -fsSL https://local-mcp.com/install | bash # # Routes (in order — ADR-0004 phase 3b: the notarized .app bundle is primary): # 1. DMG → download LocalMCP-latest.dmg, mount, copy LocalMCPTray.app # (embeds the stdio server → Automation TCC stays cert-based) # 2. npx fallback → Node >= 18 available → npx -y local-mcp@latest setup # 3. npx fallback → Homebrew available → brew install node → npx # (the legacy standalone-binary path below stays deprecated/unreachable) set -e RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m' ok() { echo -e "${GREEN}✓${NC} $*"; } info() { echo -e "${BLUE}→${NC} $*"; } warn() { echo -e "${YELLOW}⚠${NC} $*"; } fail() { echo -e "${RED}✗${NC} $*"; exit 1; } # GUI notification — fills the SILENT window on PKG installs: macOS shows "install # succeeded" the instant the tiny bootstrap .pkg lands, but the ~8 MB tray tarball then # downloads in the background here (install.sh runs in the user's Aqua session via # `launchctl asuser`), so nothing is visible until the tray + wizard appear seconds later. # Fire-and-forget, never blocks. Harmless (no-op) on the curl|bash terminal path. _notify() { osascript -e "display notification \"$2\" with title \"$1\"" >/dev/null 2>&1 || true; } BACKEND_URL="https://office-mcp-production.up.railway.app" APP_SUPPORT="$HOME/Library/Application Support/Local MCP" BIN_DIR="$HOME/.local/share/local-mcp/bin" _FP="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo anon-$$)" LMCP_REF="${LMCP_REF:-}" # Get machine_id early for telemetry (same as heartbeat — IOPlatformUUID) _MID="$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformUUID/{print $4}')" # Fire-and-forget step telemetry — never blocks, never fails _t() { curl -sf -X POST "$BACKEND_URL/install/step" -H "Content-Type: application/json" -d "{\"step\":\"$1\",\"status\":\"$2\",\"platform\":\"darwin\",\"fingerprint\":\"$_FP\",\"detail\":\"$3\",\"ref\":\"$LMCP_REF\",\"machine_id\":\"$_MID\"}" &>/dev/null & } # ── #204: what may run on this Mac — BEGIN verificación ─────────────────────────── # Everything downloaded from download.local-mcp.com is checked before it is unpacked into place # or run: its sha256 against the one /runtime/latest publishes (the API, another trust domain than # the download bucket), then the Developer ID signature of our team. A curl download carries no # quarantine flag, so Gatekeeper would not check it. Any refusal stops the install: no fallback. _LMCP_TEAM_ID="F7S4WH844B" _LMCP_SIGN_REQ="anchor apple generic and certificate leaf[subject.OU] = \"$_LMCP_TEAM_ID\"" # The sha256 /runtime/latest publishes under hashes., lowercased; "" when the key is absent; # "unparseable" when the key is there but its value is not a sha256, which then refuses. _lmcp_hash_of() { local v j # JSON allows spaces around the colon (O2', #215 review): both reads take them, so a pretty- # printed answer is neither refused nor — worse — read as "no hash". Tabs and newlines count. j="$(printf '%s' "$1" | tr '\t\r\n' ' ')" v="$(printf '%s' "$j" | grep -o "\"$2\" *: *\"[^\"]*\"" | head -1 | cut -d'"' -f4 | tr 'A-F' 'a-f')" # Absent, or null (the API's "not built yet"): no hash to enforce — the signature still is. # Any other value (a number, an empty string) is present and unreadable: it refuses. if [ -z "$v" ]; then printf '%s' "$j" | grep -q "\"$2\" *:" || return 0 printf '%s' "$j" | grep -q "\"$2\" *: *null" && return 0 fi printf '%s' "$v" | grep -Eq '^[0-9a-f]{64}$' && printf '%s' "$v" || printf 'unparseable' } # _lmcp_verified : 0 only on a match; otherwise removes the file. _lmcp_verified() { local f="$1" want="$2" what="$3" got if ! printf '%s' "$want" | grep -Eq '^[0-9a-f]{64}$'; then echo "✗ There is no published checksum for $what, so it was not installed (an unverified download never runs)." >&2 rm -f "$f"; return 1 fi got="$(shasum -a 256 "$f" 2>/dev/null | awk '{print $1}')" if [ "$got" != "$want" ]; then echo "✗ $what did not match its published checksum, so nothing was installed." >&2 rm -f "$f"; return 1 fi } # _lmcp_signed_by_us : 0 only if signed with our Developer ID (and, for an .app, accepted # by Gatekeeper). An unsigned, ad-hoc or other-team signature is refused. _lmcp_signed_by_us() { codesign --verify --strict --deep -R="$_LMCP_SIGN_REQ" "$1" >/dev/null 2>&1 || return 1 case "$1" in *.app) spctl --assess --type execute "$1" >/dev/null 2>&1 || return 1 ;; esac } # ── END verificación ───────────────────────────────────────────────────────────── # ── #82: BEGIN carpeta de la app ───────────────────────────────────────────────── # The .pkg installs either for all users (/Applications) or for this user only (~/Applications) # and tells this script which with LMCP_APP_DIR. Harness: scripts/test-pkg-dominio-mutantes.sh. _LMCP_SYS_APPS="/Applications" # The folder the .pkg payload used. Only the two folders a .pkg can install into are accepted: # ~/Applications when LMCP_APP_DIR names exactly that; anything else (unset included) is # /Applications, the contract before #82. _lmcp_preinstalled_dir() { if [ -n "${LMCP_APP_DIR:-}" ] && [ "$LMCP_APP_DIR" = "$HOME/Applications" ]; then printf '%s' "$HOME/Applications" else printf '%s' "$_LMCP_SYS_APPS" fi } # _lmcp_retire_copy : remove a LocalMCPTray.app only when this user may remove it from its # folder. rm -rf of a bundle the user owns, inside a folder the user cannot write (an # administrator's /Applications), empties the bundle and leaves a husk behind; that copy is left # whole instead. Returns 1 when a copy stays. _lmcp_retire_copy() { [ -e "$1" ] || return 0 [ -w "$(dirname "$1")" ] || return 1 chflags -R nouchg "$1" 2>/dev/null || true rm -rf "$1" 2>/dev/null [ ! -e "$1" ] } # ── END carpeta de la app ──────────────────────────────────────────────────────── # ── Uninstall mode ───────────────────────────────────────────────────────────── if [ "${1:-}" = "--uninstall" ]; then echo "" echo -e "${BOLD}LMCP — Uninstaller${NC}" echo "" # 1. Stop and remove LaunchAgents (tray + legacy server — prevents auto-start) for _plist in com.local-mcp.tray.plist com.local-mcp.server.plist; do _PP="$HOME/Library/LaunchAgents/$_plist" if [ -f "$_PP" ]; then launchctl unload "$_PP" 2>/dev/null || true rm -f "$_PP" fi done ok "Removed LaunchAgents (auto-start disabled)" # 2. Kill running processes (tray, server, and all co-processes) for _p in LocalMCPTray local-mcp-server teams-proxy slack-proxy wacli; do pkill -f "$_p" 2>/dev/null || true done ok "Stopped LMCP processes" # 2b. Reset TCC permissions BEFORE the bundle is removed: tccutil resolves the id through # the installed app, and after the delete answers "No such bundle identifier" for every id # (QA r5 N1). Every LMCP identity, all services (#3696). Kept in sync with # scripts/tcc_identidades.py by scripts/test-tcc-identidades.py — edit that module. # TCC-IDS:BEGIN _TCC_IDS=( com.local-mcp.tray com.local-mcp.tray.dev com.local-mcp.server com.local-mcp.helper com.local-mcp.imagehelper com.local-mcp.teams-proxy com.local-mcp.slack-proxy com.local-mcp.signal-proxy com.local-mcp.telegram-proxy com.local-mcp.sqlcipher com.local-mcp.wacli com.local-mcp.zalocli ) # TCC-IDS:END _TCC_OK=0; _TCC_FALLOS=(); _TCC_PRINCIPAL="" for _tid in "${_TCC_IDS[@]}"; do _rc=0; _out="$(tccutil reset All "$_tid" 2>&1)" || _rc=$? # set -e: exit 64 is a normal answer case "$_out" in *"Successfully reset"*) _TCC_OK=$((_TCC_OK+1)); [ "$_tid" = com.local-mcp.tray ] && _TCC_PRINCIPAL=ok ;; *"No such bundle identifier"*) : ;; *) if [ "$_rc" -eq 0 ]; then _TCC_OK=$((_TCC_OK+1)); [ "$_tid" = com.local-mcp.tray ] && _TCC_PRINCIPAL=ok else _TCC_FALLOS+=("$_tid"); fi ;; esac done if [ "$_TCC_PRINCIPAL" = ok ] && [ "${#_TCC_FALLOS[@]}" -eq 0 ]; then ok "Permissions reset (${_TCC_OK} LMCP identities)" else warn "Some Local MCP permissions could not be reset. Open System Settings → Privacy & Security" warn "and remove any remaining 'Local MCP' or 'LocalMCPTray' entry." fi # 2c. Client configs through the catalog the tray uses (QA r5 N6); the hand list in step 6 # stays as the fallback. stdin closed + 20 s cap: an older binary starts a stdio server. for _srv in "$HOME/Applications/LocalMCPTray.app/Contents/MacOS/local-mcp-server" \ "/Applications/LocalMCPTray.app/Contents/MacOS/local-mcp-server" \ "$HOME/.local/share/local-mcp/bin/local-mcp-server"; do [ -x "$_srv" ] || continue if perl -e 'alarm 20; exec @ARGV' "$_srv" --unconfigure-clients /dev/null \ | grep -q '^UNCONFIGURE-CLIENTS OK'; then ok "AI client configs cleaned (catalog)" break fi done # 3. Remove the app bundle from BOTH locations. npm installs to ~/Applications # (to avoid the App Management TCC prompt); the DMG installs to /Applications # or falls back to ~/Applications. Deleting only /Applications left the real # bundle + embedded server on disk for most installs. # #82: a copy in a folder this user cannot write (an administrator's /Applications) is # left whole and named, instead of emptied into a husk and reported as removed. for _app in "$HOME/Applications/LocalMCPTray.app" "/Applications/LocalMCPTray.app"; do if [ -d "$_app" ]; then if _lmcp_retire_copy "$_app"; then ok "Removed $_app" else warn "Could not remove $_app (it may belong to an administrator). Ask an administrator to delete it." fi fi done # 4. Remove binaries and data if [ -d "$HOME/.local/share/local-mcp" ]; then rm -rf "$HOME/.local/share/local-mcp" ok "Removed ~/.local/share/local-mcp" fi # 5. Remove app support (config, logs) if [ -d "$APP_SUPPORT" ]; then rm -rf "$APP_SUPPORT" ok "Removed ~/Library/Application Support/Local MCP" fi # 6. Remove from AI client configs # Uses node (available on most systems) to safely edit JSON; falls back to # printing manual instructions if node is absent. _remove_from_config() { local cfg="$1" [ -f "$cfg" ] || return 0 if command -v node &>/dev/null; then node -e " var fs=require('fs'), p=process.argv[1]; var OURS=['local-mcp','lmcp']; // #2852: exact names — the old /lmcp|local.mcp/i // matched mysqlmcp/gmailmcp/postgresqlmcp (substring) and localXmcp (the . wildcard), // so uninstalling LMCP deleted the user's other MCP servers. var isOurs=function(k){return OURS.indexOf(String(k).toLowerCase())!==-1;}; function strip(m){ if(!m) return false; var c=false; Object.keys(m).filter(isOurs).forEach(function(k){delete m[k]; c=true;}); return c; } try { var d=JSON.parse(fs.readFileSync(p,'utf8')), changed=false; // user scope + other schemas changed = strip(d.mcpServers) || changed; changed = strip(d.servers) || changed; // VS Code changed = strip(d.context_servers) || changed; // Zed // project scope (Claude Code ~/.claude.json) if (d.projects) Object.keys(d.projects).forEach(function(k){ if (d.projects[k]) changed = strip(d.projects[k].mcpServers) || changed; }); if (changed) fs.writeFileSync(p, JSON.stringify(d, null, 2)); } catch(e){} " "$cfg" 2>/dev/null && ok "Cleaned $cfg" || true else warn "node not found — to finish cleanup, remove the 'lmcp' entry from $cfg" fi } _remove_from_config "$HOME/Library/Application Support/Claude/claude_desktop_config.json" _remove_from_config "$HOME/.cursor/mcp.json" _remove_from_config "$HOME/.codeium/windsurf/mcp_config.json" _remove_from_config "$HOME/.claude.json" _remove_from_config "$HOME/.vscode/mcp.json" _remove_from_config "$HOME/Library/Application Support/Code/User/mcp.json" _remove_from_config "$HOME/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/mcp_settings.json" _remove_from_config "$HOME/.config/zed/settings.json" _remove_from_config "$HOME/.lmstudio/mcp.json" _remove_from_config "$HOME/.warp/.mcp.json" _remove_from_config "$HOME/.gemini/antigravity/mcp_config.json" _remove_from_config "$HOME/Library/Application Support/Jan/data/mcp_config.json" # QA r5 N6: the WhatsApp link store, the Preferences file and per-bundle caches/cookies. defaults delete com.local-mcp.tray 2>/dev/null || true rm -rf "$HOME/.wacli" "$HOME/Library/Preferences/com.local-mcp.tray.plist" \ "$HOME"/Library/Caches/com.local-mcp.* "$HOME"/Library/HTTPStorages/com.local-mcp.* \ "$HOME"/Library/WebKit/com.local-mcp.* 2>/dev/null || true # Track uninstall (fire-and-forget). Derive machine_id the SAME way the client does # (npm/download.js): keychain first, then config.json, then ioreg — so the uninstall # event matches the machine_id in heartbeats/events. Reading only config.json left it # empty (machine_id lives in the keychain), so every uninstall event was unattributable. _UNINSTALL_VERSION="" _UNINSTALL_MACHINE="" _UNINSTALL_REF="${LMCP_REF:-}" _CONFIG_PATH="$APP_SUPPORT/config.json" _UNINSTALL_MACHINE=$(security find-generic-password -s com.local-mcp.machine-id -a machine-id -w 2>/dev/null || true) if [ -f "$_CONFIG_PATH" ]; then _UNINSTALL_VERSION=$(grep '"version"' "$_CONFIG_PATH" 2>/dev/null | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1 || true) [ -z "$_UNINSTALL_MACHINE" ] && _UNINSTALL_MACHINE=$(grep '"machine_id"' "$_CONFIG_PATH" 2>/dev/null | sed 's/.*"machine_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1 || true) fi [ -z "$_UNINSTALL_MACHINE" ] && _UNINSTALL_MACHINE=$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformUUID/{print $4}' | head -1 || true) curl -sf -X POST "$BACKEND_URL/uninstall-event" \ -H "Content-Type: application/json" \ -d "{\"version\":\"${_UNINSTALL_VERSION}\",\"machine_id\":\"${_UNINSTALL_MACHINE}\",\"ref\":\"${_UNINSTALL_REF}\"}" \ &>/dev/null & # Telemetry curl -sf -X POST "$BACKEND_URL/install/step" -H "Content-Type: application/json" \ -d "{\"step\":\"uninstall\",\"status\":\"ok\",\"platform\":\"darwin\",\"fingerprint\":\"anon-uninstall\",\"detail\":\"${_UNINSTALL_VERSION}\"}" &>/dev/null & echo "" echo -e "${GREEN}LMCP has been uninstalled.${NC}" echo "" echo "Your emails, calendar, contacts, and other data were never stored by LMCP" echo "and remain untouched on your Mac." echo "" echo "To reinstall later: curl -fsSL 'https://local-mcp.com/install' | bash" echo "" wait # let background telemetry finish exit 0 fi LMCP_PRE_TOKEN="${LMCP_PRE_TOKEN:-}" LMCP_EMAIL="${LMCP_EMAIL:-}" echo "" echo -e "${BOLD}╔══════════════════════════════════════╗${NC}" echo -e "${BOLD}║ LMCP — Installer ║${NC}" echo -e "${BOLD}╚══════════════════════════════════════╝${NC}" echo "" _t "start" "ok" "" # ── Install tracking (fire-and-forget, zero dependencies — curl always exists on macOS) ── INSTALL_ID="$(date +%s)-$$" LMCP_REF="${LMCP_REF:-}" LMCP_SOURCE="${LMCP_SOURCE:-}" # Persist the attribution ref so the server reports it on every heartbeat — that # puts machine_id + ref in one event (machine-level attribution, deduped, and it # closes the install events that fire before machine_id exists). Best-effort. if [ -n "$LMCP_REF" ]; then mkdir -p "$HOME/.local/share/local-mcp" 2>/dev/null printf '%s' "$LMCP_REF" > "$HOME/.local/share/local-mcp/install-ref" 2>/dev/null || true fi LMCP_MACHINE_ID="$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformUUID/{print $4; exit}')" LMCP_INSTALL_STEP="start" # updated before each major step for error telemetry LMCP_WACLI_OK="false" # set to "true" when wacli installs successfully LMCP_TRAY_OK="false" # set to "true" when tray installs successfully LMCP_CLIENTS="" # comma-separated list of configured AI clients curl -sf -X POST "$BACKEND_URL/install-event" \ -H "Content-Type: application/json" \ -d "{\"stage\":\"start\",\"ref\":\"${LMCP_REF}\",\"source\":\"${LMCP_SOURCE}\",\"install_id\":\"${INSTALL_ID}\",\"has_node\":\"$(command -v node &>/dev/null && echo yes || echo no)\",\"has_brew\":\"$(command -v brew &>/dev/null && echo yes || echo no)\"}" \ &>/dev/null & # Mark cloud→local pre-token as "started" if present if [ -n "${LMCP_PRE_TOKEN:-}" ]; then curl -sf -X POST "$BACKEND_URL/install/started/${LMCP_PRE_TOKEN}" &>/dev/null & fi # On exit (success or failure), send completion event including which step failed. # Defined as a function so later trap overrides (temp file cleanup) can call it explicitly. _lmcp_on_exit() { local _EXIT=$? rm -f "${_LMCP_TMP_TAR:-}" "${_LMCP_TMP_TRAY:-}" 2>/dev/null || true local _stage _stage="$( [ $_EXIT -eq 0 ] && echo complete || echo failed )" local _clients _clients="$(echo "${CONFIGURED_CLIENTS:-}" | xargs)" # trim whitespace if [ "$_stage" = "failed" ]; then # Synchronous on failure so the event isn't lost when the shell exits curl -sf -X POST "$BACKEND_URL/install-event" \ -H "Content-Type: application/json" \ -d "{\"stage\":\"${_stage}\",\"failed_step\":\"${LMCP_INSTALL_STEP}\",\"ref\":\"${LMCP_REF}\",\"install_id\":\"${INSTALL_ID}\",\"wacli_ok\":${LMCP_WACLI_OK},\"tray_ok\":${LMCP_TRAY_OK},\"clients\":\"${_clients}\"}" \ --max-time 3 2>/dev/null || true else curl -sf -X POST "$BACKEND_URL/install-event" \ -H "Content-Type: application/json" \ -d "{\"stage\":\"${_stage}\",\"failed_step\":\"${LMCP_INSTALL_STEP}\",\"ref\":\"${LMCP_REF}\",\"install_id\":\"${INSTALL_ID}\",\"wacli_ok\":${LMCP_WACLI_OK},\"tray_ok\":${LMCP_TRAY_OK},\"clients\":\"${_clients}\"}" \ &>/dev/null & fi } trap '_lmcp_on_exit' EXIT # ── platform check ───────────────────────────────────────────────────────────── # This is the macOS installer (DMG/pkg app bundle). Windows has its own published # artifacts + installer, so hand those users off to the right path instead of the old # "macOS-only" dead end (Win/Linux public relaunch, 2026-08-14). # # Linux says "not supported" HERE and does not forward anywhere (#1710, #1686). It used # to print `curl -fsSL https://local-mcp.com/install-linux | bash`, which stopped being a # real path when the Linux offer was retired on 2026-08-20 — sending someone to a URL that # answers "not supported" is a worse dead end than saying it directly, and it cost the user # a second command to find out. _uname="$(uname 2>/dev/null)" if [[ "$_uname" != "Darwin" ]]; then if [[ "$_uname" == "Linux" ]]; then echo " LMCP does not support Linux at the moment." elif [[ "$_uname" == MINGW* || "$_uname" == MSYS* || "$_uname" == CYGWIN* ]]; then echo " For Windows, run in PowerShell:" echo " irm https://www.local-mcp.com/install-windows | iex" else echo " LMCP runs on macOS and Windows — see https://local-mcp.com" fi echo "" echo " More info: https://local-mcp.com" echo "" exit 1 fi # ── macOS version check (2026-07-25) ─────────────────────────────────────────── # LMCP requires macOS 13+ (min_os_version in the .well-known/mcp.json server-card). # Analysis of curl-dmg installs that NEVER produced a matching tray-direct/heartbeat # found a small (~1%) but 100%-fail bucket on macOS < 13 (Mojave/Monterey): the DMG # downloads fine, but the app can't launch on an unsupported OS, and that failure # was previously invisible (silent — script reported "complete: ok" regardless). # Fail fast with a clear message instead of a silent no-op tray. _OS_MAJOR="$(sw_vers -productVersion 2>/dev/null | cut -d. -f1)" if [ -n "$_OS_MAJOR" ] && [ "$_OS_MAJOR" -lt 13 ] 2>/dev/null; then LMCP_INSTALL_STEP="os_check" _t "os_check" "fail" "macos_$(sw_vers -productVersion 2>/dev/null)" fail "LMCP requires macOS 13 (Ventura) or later. You're on $(sw_vers -productVersion 2>/dev/null). Please update macOS to use LMCP." fi # ── Anon cloud relay registration (2026-04-19) ──────────────────────────────── # Claim an anonymous tunnel token keyed on the Mac's IOPlatformUUID so cloud # relay auto-connects without asking for an email. The token is stored in # config.json as `cloud_token`; downstream (npx setup + standalone binary) # preserve the field on subsequent config writes. Failure is non-fatal — the # install proceeds without cloud relay if the backend is unreachable or the # LMCP_ANON_INSTALL env flag is off on the server. # Provisioning is ALWAYS anonymous now, with or without LMCP_EMAIL. It used to be skipped when an # email was supplied, which left the install with NO cloud_token and made the machine depend on # /tunnel/token minting one for that address on first contact — the very TOFU path being removed # (a stranger could pre-register someone else's address and end up sharing the token). With this # branch gone, that endpoint can stop issuing without stranding anyone. Measured 2026-08-08: the # email path was 11% of new provisioning (15 of 142 in 7 days) and it fails SILENTLY — the install # completes, no error is shown, the relay simply never comes up. mkdir -p "$APP_SUPPORT" _CONFIG_FILE="$APP_SUPPORT/config.json" [ -f "$_CONFIG_FILE" ] || echo '{}' > "$_CONFIG_FILE" chmod 600 "$_CONFIG_FILE" 2>/dev/null || true # Reading the existing token needs a no-jq path too: macOS ships without jq, and without this # the installer fails to see a token it already has, re-calls register-anon, gets the 409 that # #1063 introduced, and ends up with _CT empty — so the email is never associated either. _EXISTING_CT="" if command -v jq &>/dev/null; then _EXISTING_CT="$(jq -r '.cloud_token // empty' "$_CONFIG_FILE" 2>/dev/null)" else _EXISTING_CT="$(sed -n 's/.*"cloud_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_CONFIG_FILE" 2>/dev/null | head -1)" fi _CT="$_EXISTING_CT" if [ -z "$_EXISTING_CT" ]; then _ANON_RESP="$(curl -sf --max-time 8 -X POST "$BACKEND_URL/tunnel/register-anon" \ -H 'Content-Type: application/json' \ -d "{\"machine_id\":\"${LMCP_MACHINE_ID}\"}" 2>/dev/null || true)" if [ -n "$_ANON_RESP" ]; then if command -v jq &>/dev/null; then _CT="$(echo "$_ANON_RESP" | jq -r '.token // empty' 2>/dev/null)" else _CT="$(echo "$_ANON_RESP" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" fi if [[ -n "$_CT" && "$_CT" == lmcp-* ]]; then if command -v jq &>/dev/null; then _tmp="$(mktemp)" jq --arg t "$_CT" '.cloud_token = $t' "$_CONFIG_FILE" > "$_tmp" 2>/dev/null \ && mv "$_tmp" "$_CONFIG_FILE" || rm -f "$_tmp" else echo "{\"cloud_token\": \"${_CT}\"}" > "$_CONFIG_FILE" chmod 600 "$_CONFIG_FILE" 2>/dev/null || true fi ok "Cloud relay activated (anonymous)" fi fi fi # Associate the supplied email with this machine's token — with the token just minted OR the one # it already had. This lives OUTSIDE the "no existing token" branch on purpose: a machine that # re-runs the installer (reinstall, upgrade, a second attempt) already has a cloud_token, and # nesting the association inside meant its email was dropped without a sound. That does not hurt # while /tunnel/token still mints and associates, but the moment it stops, those users would end # up with no association and no fallback. # # The cloud_token travels as proof of possession: /install-event has no auth, so that UPDATE # refuses to touch a row unless the caller holds that machine's token. Calling it when the email # is already set is harmless — the backend also guards on (email IS NULL OR email = ''), so # rowcount 0 is the normal case here, not an error. # # The body needs escaping: an email carrying a quote or a backslash would break hand-interpolated # JSON, curl -sf would swallow the 4xx and `|| true` would hide it — the same silent failure this # whole change exists to remove. jq does it properly WHEN PRESENT, but macOS ships without jq and # this whole script is written to work without it (every other use has a sed/echo fallback), so # requiring it here would kill the association for exactly the users least likely to have it. # Escaping \ and " by hand covers every realistic address, and sending with basic escaping beats # not sending at all. if [ -n "$LMCP_EMAIL" ] && [ -n "$_CT" ]; then if command -v jq &>/dev/null; then _EVT="$(jq -n --arg em "$LMCP_EMAIL" --arg mid "$LMCP_MACHINE_ID" --arg tok "$_CT" \ '{stage:"email_prompt", email_prompt_result:"submitted", email:$em, machine_id:$mid, cloud_token:$tok, source:"install"}')" else _EM_ESC="$(printf '%s' "$LMCP_EMAIL" | sed 's/\\/\\\\/g; s/"/\\"/g')" _MID_ESC="$(printf '%s' "$LMCP_MACHINE_ID" | sed 's/\\/\\\\/g; s/"/\\"/g')" _TOK_ESC="$(printf '%s' "$_CT" | sed 's/\\/\\\\/g; s/"/\\"/g')" _EVT="{\"stage\":\"email_prompt\",\"email_prompt_result\":\"submitted\",\"email\":\"${_EM_ESC}\",\"machine_id\":\"${_MID_ESC}\",\"cloud_token\":\"${_TOK_ESC}\",\"source\":\"install\"}" fi curl -sf --max-time 5 -X POST "$BACKEND_URL/install-event" \ -H 'Content-Type: application/json' -d "$_EVT" &>/dev/null || true fi # ── ADR-0004: the notarized .app bundle (single artifact, embeds the stdio server) # is the primary macOS install — delivered as a tarball (the SAME artifact the # in-app auto-updater consumes; no DMG/hdiutil). npx / Homebrew remain as # automatic fallbacks if it fails. Telemetry step names (download_dmg/mount_dmg/ # install_dmg) are kept verbatim so the admin install-funnel stays continuous. ── _dmg_cleanup() { [ -n "${EXTRACT_DIR:-}" ] && rm -rf "$EXTRACT_DIR" 2>/dev/null || true [ -n "${TAR_TMP:-}" ] && rm -f "$TAR_TMP" 2>/dev/null || true } _try_dmg() { # Primary macOS install: the notarized LocalMCPTray.app bundle (embeds the # stdio server → Automation TCC stays cert-based across updates). Returns non-zero ONLY before # anything is downloaded (unsupported arch, version unknown), so the caller can fall back to npx. # #204: once the bucket download starts, every failure stops the install with `fail` — falling # through to npx after a failed or tampered download would install code nothing verified. _t "route" "ok" "dmg" info "Installing LMCP (app bundle)..." echo "" # #1303: snapshot user-authored data (saved recipes + run history) BEFORE the install # mutates anything. An upgrade-window event deleted both dirs unrecoverably on # 2026-08-13 with the deleter unidentified — so the backup is unconditional here, # mirroring UserDataBackup.backupBeforeUpgrade in the auto-update path. Keep last 5. _LMCP_DATA="$HOME/.local/share/local-mcp" if [ -n "$(ls -A "$_LMCP_DATA/recipes" 2>/dev/null)" ] || [ -n "$(ls -A "$_LMCP_DATA/runs" 2>/dev/null)" ]; then _BK="$_LMCP_DATA/backups/$(date -u +%Y%m%dT%H%M%S)-installsh" mkdir -p "$_BK" [ -d "$_LMCP_DATA/recipes" ] && cp -R "$_LMCP_DATA/recipes" "$_BK/" 2>/dev/null [ -d "$_LMCP_DATA/runs" ] && cp -R "$_LMCP_DATA/runs" "$_BK/" 2>/dev/null ls -dt "$_LMCP_DATA/backups"/*/ 2>/dev/null | tail -n +6 | while read -r _old; do rm -rf "$_old"; done ok "Backed up saved recipes + run history → backups/$(basename "$_BK")" fi ARCH=$(uname -m) if [[ "$ARCH" == "arm64" ]]; then ARCH_TAG="darwin-arm64" elif [[ "$ARCH" == "x86_64" ]]; then ARCH_TAG="darwin-x64" else { warn "Unsupported architecture: $ARCH. Visit https://local-mcp.com for help."; _dmg_cleanup; return 1; } fi # ── Install the tray app ────────────────────────────────────────────────────── # Self-contained .pkg fast path: the GUI installer copies LocalMCPTray.app into # /Applications — or ~/Applications for «Install for me only» (#82, LMCP_APP_DIR) — ITSELF # (payload) and runs this script with LMCP_APP_PREINSTALLED=1 — so # skip the download/extract/copy and adopt the app it already placed. No download at # install time → no "invisible window" gap, works offline, atomic + notarized. Every other # channel (curl|bash, DMG-run) leaves the flag unset → the normal tarball path in `else`. if [ "${LMCP_APP_PREINSTALLED:-}" = "1" ] && [ -d "$(_lmcp_preinstalled_dir)/LocalMCPTray.app" ]; then TRAY_DEST="$(_lmcp_preinstalled_dir)/LocalMCPTray.app" TRAY_BIN="$TRAY_DEST/Contents/MacOS/LocalMCPTray" # #204: the app the installer placed is checked like a downloaded one before anything runs. _lmcp_signed_by_us "$TRAY_DEST" \ || { _t "install_dmg" "fail" "signature"; fail "Install stopped: the app is not signed by LMCP's developer. Download the app at https://local-mcp.com"; } xattr -rd com.apple.quarantine "$TRAY_DEST" 2>/dev/null || true # Clear stale update caches so the fresh app re-detects the real latest (as the else path does). rm -f "$HOME/.local/share/local-mcp/bin/.latest-version" \ "$HOME/.local/share/local-mcp/bin/.latest-download-url" \ "$HOME/.local/share/local-mcp/bin/.latest-notes" \ "$HOME/.local/share/local-mcp/.uninstalled" \ "$APP_SUPPORT/.uninstalled" 2>/dev/null || true # #82, the latest install wins: the copy this install placed is the one the LaunchAgent # launches, and a copy from an earlier install in the other folder is removed — unless this # user cannot remove it (an administrator's /Applications under «Install for me only»), # in which case it stays whole and unused; the updater skips it (#179). for _other in "$_LMCP_SYS_APPS/LocalMCPTray.app" "$HOME/Applications/LocalMCPTray.app"; do [ "$_other" = "$TRAY_DEST" ] && continue # An administrator choosing «me only» can write /Applications, but a copy there that is not # theirs serves every other account on this Mac: it stays (review of #82). if [ "$_other" = "$_LMCP_SYS_APPS/LocalMCPTray.app" ] && [ -e "$_other" ] \ && [ "$(stat -f '%Su' "$_other" 2>/dev/null)" != "$(id -un)" ]; then info "Left the copy at $_other in place (another account's install); LMCP runs from $TRAY_DEST" continue fi if _lmcp_retire_copy "$_other"; then :; else info "Left the copy at $_other in place (this user cannot remove it); LMCP runs from $TRAY_DEST" fi done # #356: the server marker must follow the app the .pkg just placed. An upgrade left the old # bin/.server-version, so lmcp_state reported disk_version of the PREVIOUS release (3.0.419 # after installing 3.0.420). The tray embeds the server: its stamped version is the truth. # >>> server-version-stamp _lmcp_app_ver="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$TRAY_DEST/Contents/Info.plist" 2>/dev/null)" if [ -n "$_lmcp_app_ver" ] && [ "$_lmcp_app_ver" != "1.0" ]; then mkdir -p "$HOME/.local/share/local-mcp/bin" 2>/dev/null \ && printf '%s' "$_lmcp_app_ver" > "$HOME/.local/share/local-mcp/bin/.server-version" 2>/dev/null || true fi # <<< server-version-stamp ok "Using the app the installer placed at $TRAY_DEST" _t "install_dmg" "ok" "preinstalled" else # ── Download tray tarball ──────────────────────────────────────────────────────── # The notarized LocalMCPTray.app ships as a VERSION-PINNED tarball on R2 — the SAME # artifact the in-app auto-updater consumes (no DMG/hdiutil). Resolve the real latest # version and pull the pinned tarball. If the version probe fails (API down / offline) # return non-zero so the caller falls back to npx. There is no mutable "-latest" # tarball alias to go stale, so the old stale-alias class of bug is gone with the DMG. # #204: ONE read gives the version and its checksum, so they cannot come from two different answers. _LMCP_RUNTIME="$(curl -sf --max-time 20 "${BACKEND_URL}/runtime/latest")" TRAY_VERSION=$(printf '%s' "$_LMCP_RUNTIME" | grep -o '"version":"[^"]*"' | head -1 | cut -d'"' -f4) # The Mac tray tarball's own key (lmcp-api#133). NOT `tray`: that one is the Windows tray's hash. TRAY_SHA256="$(_lmcp_hash_of "$_LMCP_RUNTIME" tray-darwin-universal)" [ -n "$TRAY_VERSION" ] || { warn "Could not resolve the latest version. Visit https://local-mcp.com"; _t "download_dmg" "fail" "no_version"; return 1; } TARBALL_URL="https://download.local-mcp.com/local-mcp-tray-${TRAY_VERSION}-darwin-universal.tar.gz" TAR_TMP="$(mktemp /tmp/LocalMCPTray-XXXXXX.tar.gz)" EXTRACT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lmcp-tray-XXXXXX")" info "Downloading LMCP v${TRAY_VERSION}..." _notify "Setting up Local MCP…" "Downloading the app — this takes a few seconds. The menu-bar icon will appear when it's ready." curl -fsSL --progress-bar "$TARBALL_URL" -o "$TAR_TMP" \ || { _t "download_dmg" "fail" "curl_failed"; _dmg_cleanup; fail "Download failed. Try again or download the app at https://local-mcp.com"; } # #204: refused = the install stops here (fail exits): a download that does not verify must not # fall through to another route either. # The checksum is enforced as soon as the API publishes it; until then the Developer ID check below # (always required) is the trust anchor — the same one the in-app updater uses for the tray. if [ -n "$TRAY_SHA256" ]; then _lmcp_verified "$TAR_TMP" "$TRAY_SHA256" "the LMCP app download" \ || { _t "download_dmg" "fail" "checksum"; _dmg_cleanup; fail "Install stopped: the download could not be verified. Try again later or download the app at https://local-mcp.com"; } fi _t "download_dmg" "ok" "" # ── Extract + copy ─────────────────────────────────────────────────────────────── info "Installing..." tar -xzf "$TAR_TMP" -C "$EXTRACT_DIR" 2>/dev/null \ || { _t "mount_dmg" "fail" "tar_failed"; _dmg_cleanup; fail "Could not extract the app. Download it at https://local-mcp.com"; } TRAY_SRC="$(find "$EXTRACT_DIR" -maxdepth 2 -name "*.app" | head -1)" [ -n "$TRAY_SRC" ] || { _t "mount_dmg" "fail" "no_app_in_tar"; _dmg_cleanup; fail "The app was not found in the download. Download it at https://local-mcp.com"; } _lmcp_signed_by_us "$TRAY_SRC" \ || { _t "mount_dmg" "fail" "signature"; _dmg_cleanup; fail "Install stopped: the app is not signed by LMCP's developer. Download the app at https://local-mcp.com"; } # ── Self-heal: clean up a previous/broken install before copying ─────────────── # Makes reinstall fix a broken state instead of leaving cruft. Removes any existing # or ORPHANED LocalMCPTray.app from BOTH locations (old buggy uninstalls left the # ~/Applications copy behind; also avoids cp-into-existing-dir nesting), stops a # running tray, and clears the stale update cache. The poisoned `.latest-version` # is what left old trays "stuck thinking they were current" (no update, no banner, # no forced prompt) — clearing it lets the fresh tray re-detect the real latest. pkill -x LocalMCPTray 2>/dev/null || true rm -rf "$HOME/Applications/LocalMCPTray.app" "/Applications/LocalMCPTray.app" 2>/dev/null || true rm -f "$HOME/.local/share/local-mcp/bin/.latest-version" \ "$HOME/.local/share/local-mcp/bin/.latest-download-url" \ "$HOME/.local/share/local-mcp/bin/.latest-notes" \ "$HOME/.local/share/local-mcp/.uninstalled" \ "$APP_SUPPORT/.uninstalled" 2>/dev/null || true TRAY_DEST="" if cp -R "$TRAY_SRC" "/Applications/" 2>/dev/null; then TRAY_DEST="/Applications/$(basename "$TRAY_SRC")" else mkdir -p "$HOME/Applications" cp -R "$TRAY_SRC" "$HOME/Applications/" TRAY_DEST="$HOME/Applications/$(basename "$TRAY_SRC")" fi rm -rf "$TAR_TMP" "$EXTRACT_DIR" 2>/dev/null || true xattr -rd com.apple.quarantine "$TRAY_DEST" 2>/dev/null || true TRAY_BIN="$TRAY_DEST/Contents/MacOS/LocalMCPTray" ok "Installed to $TRAY_DEST" _t "install_dmg" "ok" "" fi # Telemetry: the DMG bundle IS the tray, so tray_ok is true here. wacli is NOT part of # the DMG — UpdateManager fetches it post-launch — so report it as null (→ wacli_unknown) # instead of the default "false", which otherwise made install-component-stats show a # bogus ~84% "tray_failed/wacli_failed" for the primary install path. LMCP_TRAY_OK="true" LMCP_WACLI_OK="null" # ── LaunchAgent — auto-start on login ───────────────────────────────────────── TRAY_PLIST="$HOME/Library/LaunchAgents/com.local-mcp.tray.plist" mkdir -p "$HOME/Library/LaunchAgents" cat > "$TRAY_PLIST" << PLIST_EOF Label com.local-mcp.tray ProgramArguments ${TRAY_BIN} RunAtLoad KeepAlive AssociatedBundleIdentifiers com.local-mcp.tray PLIST_EOF # Reload the job by LABEL in the gui domain (modern API). Legacy unload/load by plist # file does NOT reliably replace an already-loaded job whose program path changed (e.g. # the app moved /Applications <-> ~/Applications between installs) — the stale job keeps # the old path and spawn-fails (exit 78 EX_CONFIG), leaving the tray dead / version-stuck. _GUI="gui/$(id -u)" _TRAY_LABEL="com.local-mcp.tray" _TRAY_SOCK="$HOME/.local/share/local-mcp/mcp.sock" launchctl bootout "$_GUI/$_TRAY_LABEL" 2>/dev/null || true launchctl unload "$TRAY_PLIST" 2>/dev/null || true # legacy fallback (older macOS) pkill -f LocalMCPTray 2>/dev/null || true sleep 1 # Clear a launchd "disabled" override for OUR label only. Turning LMCP off in Login Items # writes one, and it outlives uninstall: bootstrap then fails with EIO (silenced below) and a # reinstall came up with no tray (m1, 2026-09-30). Idempotent: a no-op when not disabled. # Harness: scripts/test-install-rehabilita-tray.sh. launchctl enable "$_GUI/$_TRAY_LABEL" 2>/dev/null || true _LAUNCH_MARK="$(mktemp "${TMPDIR:-/tmp}/lmcp-launch-XXXXXX" 2>/dev/null || true)" launchctl bootstrap "$_GUI" "$TRAY_PLIST" 2>/dev/null || launchctl load "$TRAY_PLIST" 2>/dev/null || true launchctl kickstart -k "$_GUI/$_TRAY_LABEL" 2>/dev/null || true ok "LMCP set to auto-start on login" # ── Verify the tray actually started ────────────────────────────────────────────── # Every launchctl call above silences its own errors, so a spawn failure (a disable override, # no Aqua session over SSH, a crash at launch) is only visible here. "LMCP is running" is printed # ONLY on positive evidence within 20 s: the tray process, or a socket created after this launch # (a stale one from a previous install doesn't count). Otherwise the closing block prints the # recovery command and exits 1. The .pkg postinstall runs this in the background and ignores the # exit code; the GUI installer shows it. _tray_up() { pgrep -x LocalMCPTray >/dev/null 2>&1 && return 0 [ -S "$_TRAY_SOCK" ] && [ -n "$_LAUNCH_MARK" ] && [ "$_TRAY_SOCK" -nt "$_LAUNCH_MARK" ] } _TRAY_UP=0 _w=0 while [ "$_w" -lt 20 ]; do if _tray_up; then _TRAY_UP=1; break; fi sleep 1 _w=$((_w + 1)) done if [ "$_TRAY_UP" = 0 ] && _tray_up; then _TRAY_UP=1; fi if [ -n "$_LAUNCH_MARK" ]; then rm -f "$_LAUNCH_MARK"; fi if [ "$_TRAY_UP" = 1 ]; then _t "launch_verify" "ok" "" _notify "Local MCP is ready 🎉" "Look for the menu-bar icon. Restart your AI client (Claude, Cursor, VS Code…) to see the tools." else _t "launch_verify" "fail" "not_running_20s" _notify "Local MCP didn't start" "Open LocalMCPTray from your Applications folder to finish setup." fi # ── Configure the AI apps (#3212) ───────────────────────────────────────────── # Dario, 2026-09-11: el instalador configura por defecto las apps de IA instaladas, para todos, # con el mismo modelo, escritor y lock que el tray. Una app de la que la persona sacó LMCP queda # como está (skipped_seen). El reporte es la última línea de stdout con LMCP_REPORT=json. _CC_REPORT="" _CC_BIN="$TRAY_DEST/Contents/MacOS/local-mcp-server" if [ -x "$_CC_BIN" ]; then _CC_REPORT="$(perl -e 'alarm shift; exec @ARGV' 90 "$_CC_BIN" --configure-clients --json /dev/null | tail -n 1 || true)" fi _cc_pick() { # $1 = expresión JS sobre `r`, el reporte; vacío si no hay un reporte que entender [ -n "$_CC_REPORT" ] || return 0 osascript -l JavaScript -e "function run(a){try{var r=JSON.parse(a[0]);if(r.schema!==1)return '';return $1}catch(e){return ''}}" "$_CC_REPORT" 2>/dev/null || true } _CC_CONFIGURED="$(_cc_pick 'r.clients.filter(function(c){return c.status==="configured"}).map(function(c){return c.name}).join(", ")')" _DETECTED_CLIENTS="$(_cc_pick 'r.clients.filter(function(c){return c.status!=="not_installed"}).map(function(c){return c.name}).join(", ")')" _CC_NEEDS="$(_cc_pick 'r.clients.filter(function(c){return ["not_editable","shadowed","manual","needs_newer","failed"].indexOf(c.status)>=0}).map(function(c){return c.name+": "+c.status.replace(/_/g," ")+(c.reason?" - "+c.reason:"")}).join("\n")')" if [ -n "$(_cc_pick '"ok"')" ]; then _t "configure_clients" "ok" ""; else _t "configure_clients" "fail" "no_report"; fi # ── Save pre-token / email to config if present ─────────────────────────────── OS_VER="$(sw_vers -productVersion 2>/dev/null || true)" if [ -n "$LMCP_PRE_TOKEN" ] || [ -n "$LMCP_EMAIL" ]; then mkdir -p "$APP_SUPPORT" CONFIG_FILE="$APP_SUPPORT/config.json" [ -f "$CONFIG_FILE" ] || echo '{}' > "$CONFIG_FILE" chmod 600 "$CONFIG_FILE" 2>/dev/null || true if command -v jq &>/dev/null; then _tmp="$(mktemp)" jq --arg pt "${LMCP_PRE_TOKEN:-}" --arg em "${LMCP_EMAIL:-}" ' if ($pt != "") then .pre_token = $pt else . end | if ($em != "" and (.license_email == null or .license_email == "")) then .license_email = $em else . end ' "$CONFIG_FILE" > "$_tmp" 2>/dev/null && mv "$_tmp" "$CONFIG_FILE" || rm -f "$_tmp" fi fi # ── Track install ────────────────────────────────────────────────────────────── curl -sf -X POST "${BACKEND_URL}/install/npm" \ -H "Content-Type: application/json" \ -d "{\"version\":\"dmg\",\"os_version\":\"${OS_VER}\",\"method\":\"curl-dmg\",\"arch\":\"${ARCH}\",\"ref\":\"${LMCP_REF:-}\"}" \ 2>/dev/null || true if [ "$_TRAY_UP" = 1 ]; then _t "complete" "ok" "dmg"; else _t "complete" "fail" "tray_not_running"; fi echo "" if [ "$_TRAY_UP" = 1 ]; then echo -e "${GREEN}${BOLD}✅ LMCP installed successfully!${NC}" echo "" echo -e " ${BOLD}LMCP is running${NC} — look for the icon in your menu bar." else echo -e "${RED}${BOLD}✗ LMCP was installed, but it is NOT running${NC} (it didn't start within 20 seconds)." echo "" echo -e " Start it with:" echo -e " ${BOLD}launchctl enable $_GUI/$_TRAY_LABEL && launchctl bootstrap $_GUI \"$TRAY_PLIST\"${NC}" echo -e " or open LocalMCPTray from your Applications folder. Over SSH, run it at the Mac itself." fi if [ -n "$_CC_CONFIGURED" ]; then echo -e " ${GREEN}Configured: ${_CC_CONFIGURED}${NC}" echo -e " ${YELLOW}Restart those apps (or open a new session) to connect.${NC}" elif [ -n "$_DETECTED_CLIENTS" ]; then echo -e " ${BLUE}Your AI apps: ${_DETECTED_CLIENTS}${NC}" else echo -e " ${YELLOW}No AI app found yet — when you install one, LMCP connects it on its next launch.${NC}" fi if [ -n "$_CC_NEEDS" ]; then echo -e " ${YELLOW}Needs you:${NC}" printf '%s\n' "$_CC_NEEDS" | sed 's/^/ · /' fi echo "" echo -e " ${BOLD}💬 Try asking Claude:${NC}" echo -e " ${BLUE}\"Read my last 3 emails and summarize them\"${NC}" echo "" echo -e " Docs & settings: ${BLUE}https://local-mcp.com${NC}" if [ "${LMCP_REPORT:-}" = "json" ] && [ -n "$_CC_REPORT" ]; then printf '%s\n' "$_CC_REPORT"; fi if [ "$_TRAY_UP" = 1 ]; then exit 0; fi exit 1 } # Try the app bundle first; _try_dmg exits 0 on success, returns here only on failure. _try_dmg || warn "App-bundle install unavailable — falling back to npx..." # ── Fallback 1: Node >= 18 available — use npx ─────────────────────────────── if command -v node &>/dev/null && command -v npx &>/dev/null; then NODE_MAJOR=$(node --version 2>/dev/null | sed 's/v//' | cut -d. -f1) if [ "${NODE_MAJOR:-0}" -ge 18 ] 2>/dev/null; then info "Node.js v${NODE_MAJOR} detected — using npx (auto-updates, no permissions needed)" _t "route" "ok" "npx" echo "" LMCP_EMAIL="$LMCP_EMAIL" LMCP_PRE_TOKEN="$LMCP_PRE_TOKEN" LMCP_METHOD="curl" npx -y local-mcp@latest setup exit $? else warn "Node.js v${NODE_MAJOR} is too old (need >= 18). Trying Homebrew..." fi fi # ── Fallback 2: Homebrew available — install Node first ────────────────────── if command -v brew &>/dev/null; then info "Node.js not found — installing via Homebrew (this takes ~1 min)..." _t "route" "ok" "homebrew" brew install node --quiet 2>&1 | tail -3 ok "Node.js installed" echo "" LMCP_EMAIL="$LMCP_EMAIL" LMCP_PRE_TOKEN="$LMCP_PRE_TOKEN" LMCP_METHOD="curl" npx -y local-mcp@latest setup exit $? fi # All install routes exhausted (DMG failed AND no Node/Homebrew to fall back to). # Fail cleanly here (ADR-0004 phase 3b: the notarized .app bundle is the only artifact). The old # standalone route that sat below this line was unreachable and installed unverified downloads; # it was removed (#204). fail "Couldn't install LMCP automatically. Download the app at https://local-mcp.com"