#!/bin/bash # LMCP — Installer # Usage: curl -fsSL https://local-mcp.com/install | bash # # Routes (in order): # 1. Node >= 18 available → npx -y local-mcp@latest setup (best: auto-updates, no perms) # 2. Homebrew available → brew install node → npx (installs Node first) # 3. Neither → download PyInstaller binary from R2 directly (no Node needed) 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; } 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 & } # ── Uninstall mode ───────────────────────────────────────────────────────────── if [ "${1:-}" = "--uninstall" ]; then echo "" echo -e "${BOLD}LMCP — Uninstaller${NC}" echo "" # 1. Stop and remove LaunchAgent (prevents auto-start on login) TRAY_PLIST_PATH="$HOME/Library/LaunchAgents/com.local-mcp.tray.plist" if [ -f "$TRAY_PLIST_PATH" ]; then launchctl unload "$TRAY_PLIST_PATH" 2>/dev/null || true rm -f "$TRAY_PLIST_PATH" ok "Removed LaunchAgent (auto-start disabled)" fi # 2. Kill running processes pkill -f LocalMCPTray 2>/dev/null || true pkill -f local-mcp-server 2>/dev/null || true ok "Stopped LMCP processes" # 3. Remove app from /Applications if [ -d "/Applications/LocalMCPTray.app" ]; then rm -rf "/Applications/LocalMCPTray.app" ok "Removed /Applications/LocalMCPTray.app" fi # 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]; try { var d=JSON.parse(fs.readFileSync(p,'utf8')); var s=d.mcpServers||{}; var rm=Object.keys(s).filter(function(k){return /lmcp|local.mcp/i.test(k);}); rm.forEach(function(k){delete s[k];}); if(rm.length) 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/settings.json" _remove_from_config "$HOME/.vscode/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" # Track uninstall (fire-and-forget — read version + machine_id from config if present) _UNINSTALL_VERSION="" _UNINSTALL_MACHINE="" _UNINSTALL_REF="${LMCP_REF:-}" _CONFIG_PATH="$APP_SUPPORT/config.json" if [ -f "$_CONFIG_PATH" ]; then _UNINSTALL_VERSION=$(grep '"version"' "$_CONFIG_PATH" 2>/dev/null | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1 || true) _UNINSTALL_MACHINE=$(grep '"machine_id"' "$_CONFIG_PATH" 2>/dev/null | sed 's/.*"machine_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1 || true) fi 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:-}" 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 # ── macOS check ─────────────────────────────────────────────────────────────── if [[ "$(uname)" != "Darwin" ]]; then echo " LMCP is macOS-only." echo "" echo " On macOS, run:" echo " npx -y local-mcp@latest setup" echo "" echo " More info: https://local-mcp.com" echo "" exit 1 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. if [ -z "$LMCP_EMAIL" ]; then mkdir -p "$APP_SUPPORT" _CONFIG_FILE="$APP_SUPPORT/config.json" [ -f "$_CONFIG_FILE" ] || echo '{}' > "$_CONFIG_FILE" _EXISTING_CT="" if command -v jq &>/dev/null; then _EXISTING_CT="$(jq -r '.cloud_token // empty' "$_CONFIG_FILE" 2>/dev/null)" fi 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" fi ok "Cloud relay activated (anonymous)" fi fi fi fi # ── Route 1: Node >= 18 available — use npx (best experience) ───────────────── 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 # ── Route 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 # ── Route 3: No Node, no Homebrew — install via DMG ────────────────────────── _t "route" "ok" "dmg" info "Node.js not found — installing LMCP via app bundle..." echo "" ARCH=$(uname -m) if [[ "$ARCH" == "arm64" ]]; then ARCH_TAG="darwin-arm64" elif [[ "$ARCH" == "x86_64" ]]; then ARCH_TAG="darwin-x64" else fail "Unsupported architecture: $ARCH. Visit https://local-mcp.com for help." fi # ── Download DMG ─────────────────────────────────────────────────────────────── DMG_URL="https://download.local-mcp.com/LocalMCP-latest.dmg" DMG_TMP="$(mktemp /tmp/LocalMCP-XXXXXX.dmg)" info "Downloading LMCP..." curl -fsSL --progress-bar "$DMG_URL" -o "$DMG_TMP" \ || fail "Download failed. Try again or visit https://local-mcp.com" _t "download_dmg" "ok" "" # ── Mount + copy ─────────────────────────────────────────────────────────────── info "Installing..." MOUNT_POINT="$(hdiutil attach "$DMG_TMP" -nobrowse -quiet 2>/dev/null | awk 'END {print $NF}')" [ -n "$MOUNT_POINT" ] || fail "Could not mount installer. Download manually at https://local-mcp.com" TRAY_SRC="$(find "$MOUNT_POINT" -maxdepth 1 -name "*.app" | head -1)" [ -n "$TRAY_SRC" ] || { hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null; fail "App not found in DMG."; } 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 hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null || true rm -f "$DMG_TMP" 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" "" # ── 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 PLIST_EOF launchctl unload "$TRAY_PLIST" 2>/dev/null || true pkill -f LocalMCPTray 2>/dev/null || true sleep 1 launchctl load "$TRAY_PLIST" 2>/dev/null || true ok "LMCP set to auto-start on login" # ── Detect installed AI clients (telemetry only — Tray handles config) ──────── CLAUDE_CFG="$HOME/Library/Application Support/Claude/claude_desktop_config.json" CURSOR_CFG="$HOME/.cursor/mcp.json" _DETECTED_CLIENTS="" _check_client() { local label="$1"; shift; for p in "$@"; do [ -d "$p" ] || [ -f "$p" ] && { _DETECTED_CLIENTS="${_DETECTED_CLIENTS}${label} "; return; }; done; } _check_client "claude-desktop" "$HOME/Library/Application Support/Claude" "$CLAUDE_CFG" _check_client "cursor" "$HOME/.cursor" "/Applications/Cursor.app" _check_client "windsurf" "$HOME/.codeium/windsurf" "/Applications/Windsurf.app" _check_client "claude-code" "$HOME/.claude" _check_client "vscode" "$HOME/.vscode" "/Applications/Visual Studio Code.app" _check_client "zed" "$HOME/.config/zed" "/Applications/Zed.app" # ── 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" 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 _t "complete" "ok" "dmg" echo "" echo -e "${GREEN}${BOLD}✅ LMCP installed successfully!${NC}" echo "" echo -e " ${BOLD}LMCP is running${NC} — look for the icon in your menu bar." echo -e " ${GREEN}Your AI clients are being configured automatically.${NC}" echo -e " ${YELLOW}Restart Claude Desktop, Cursor, or Windsurf to connect.${NC}" if [ -n "$_DETECTED_CLIENTS" ]; then echo -e " ${BLUE}Detected: ${_DETECTED_CLIENTS}${NC}" 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}" exit 0 # ── (Route 3 legacy: standalone binary — kept below for reference, unreachable) ── LEGACY_ROUTE3_UNREACHABLE=true # Fetch latest binary version LMCP_INSTALL_STEP="fetch_version" info "Checking latest version..." VERSION=$(curl -sf "${BACKEND_URL}/runtime/latest" \ | grep -o '"version":"[^"]*"' | cut -d'"' -f4) \ || fail "Could not fetch version info. Check your internet connection." [ -n "$VERSION" ] || fail "Could not parse version info. Check your internet connection." ok "Latest version: v${VERSION}" _t "version_check" "ok" "$VERSION" BINARY_URL="https://download.local-mcp.com/local-mcp-server-${VERSION}-${ARCH_TAG}.tar.gz" TMP_TAR="$(mktemp /tmp/local-mcp-XXXXXX.tar.gz)" _LMCP_TMP_TAR="$TMP_TAR" LMCP_INSTALL_STEP="download_binary" info "Downloading LMCP v${VERSION} for ${ARCH_TAG}..." curl -fsSL --progress-bar "$BINARY_URL" -o "$TMP_TAR" \ || fail "Download failed. Try again or visit https://local-mcp.com/download" ok "Downloaded" _t "download_server" "ok" "$VERSION" # Extract binary LMCP_INSTALL_STEP="extract_binary" info "Installing..." mkdir -p "$BIN_DIR" tar -xzf "$TMP_TAR" -C "$BIN_DIR" BINARY="$BIN_DIR/local-mcp-server/local-mcp-server" if [ ! -f "$BINARY" ]; then fail "Binary not found after extraction. Please report this at https://github.com/lanchuske/local-mcp/issues" fi chmod +x "$BINARY" # Remove quarantine attribute + ad-hoc sign (avoids Gatekeeper block) xattr -d com.apple.quarantine "$BINARY" 2>/dev/null || true codesign --force --sign - --identifier "com.local-mcp.server" "$BINARY" 2>/dev/null || true ok "Installed to ${BINARY}" _t "extract_binary" "ok" "$ARCH_TAG" # ── Download wacli (WhatsApp co-process) — no Homebrew required ─────────────── WACLI_BIN="$BIN_DIR/wacli" LMCP_INSTALL_STEP="download_wacli" info "Downloading wacli (WhatsApp co-process)..." WACLI_TMP="$(mktemp /tmp/wacli-XXXXXX.tar.gz)" # Use GitHub's latest-release redirect — pure curl, no jq or Homebrew if curl -fsSL "https://github.com/steipete/wacli/releases/latest/download/wacli-macos-universal.tar.gz" -o "$WACLI_TMP" 2>/dev/null; then tar -xzf "$WACLI_TMP" -C "$BIN_DIR" wacli 2>/dev/null rm -f "$WACLI_TMP" if [ -f "$WACLI_BIN" ]; then chmod +x "$WACLI_BIN" xattr -d com.apple.quarantine "$WACLI_BIN" 2>/dev/null || true codesign --force --sign - --identifier "com.local-mcp.wacli" "$WACLI_BIN" 2>/dev/null || true LMCP_WACLI_OK="true" ok "wacli installed to ${WACLI_BIN}" _t "download_wacli" "ok" "" else rm -f "$WACLI_TMP" 2>/dev/null || true warn "wacli could not be extracted — WhatsApp tools will be limited until reinstalled" fi else rm -f "$WACLI_TMP" 2>/dev/null || true warn "wacli download failed — WhatsApp tools will be limited until reinstalled" fi LMCP_INSTALL_STEP="download_coprocesses" # ── Download co-processes (Teams/Slack proxy, Helper, JXA Runner) ───────────── # These are needed for full functionality. Each is a stable binary that rarely # changes — download only if not already present (like npm/download.js does). _download_coprocess() { local name="$1" bin_name="$2" local bin_path="$BIN_DIR/$bin_name" local ver_file="$BIN_DIR/.${bin_name}-version" [ -f "$bin_path" ] && [ -f "$ver_file" ] && [ "$(cat "$ver_file" 2>/dev/null)" = "$VERSION" ] && return 0 local arch_tag="$ARCH_TAG" local url="https://download.local-mcp.com/${bin_name}-${VERSION}-${arch_tag}.tar.gz" info "Downloading ${name} v${VERSION}..." local tmp="$(mktemp /tmp/${bin_name}-XXXXXX.tar.gz)" if curl -fsSL "$url" -o "$tmp" 2>/dev/null; then tar -xzf "$tmp" -C "$BIN_DIR" 2>/dev/null rm -f "$tmp" if [ -f "$bin_path" ]; then chmod +x "$bin_path" xattr -d com.apple.quarantine "$bin_path" 2>/dev/null || true codesign --force --sign - --identifier "com.local-mcp.${bin_name}" "$bin_path" 2>/dev/null || true echo "$VERSION" > "$ver_file" ok "${name} installed" else warn "${name} extraction failed" fi else rm -f "$tmp" 2>/dev/null || true warn "${name} download failed (non-critical)" fi } _download_coprocess "teams-proxy" "teams-proxy" _download_coprocess "slack-proxy" "slack-proxy" # Helper and JXA Runner: only download if not present (never overwrite — TCC cdhash) if [ ! -f "$BIN_DIR/local-mcp-helper" ]; then _download_coprocess "local-mcp-helper" "local-mcp-helper" fi if [ ! -f "$BIN_DIR/local-mcp-jxa-runner" ]; then _download_coprocess "local-mcp-jxa-runner" "local-mcp-jxa-runner" fi LMCP_INSTALL_STEP="configure_clients" # ── Download tray (menu bar app) — universal binary (arm64 + Intel) ────────── TRAY_META="$HOME/.local/share/local-mcp/tray" TRAY_APP="/Applications/LocalMCPTray.app" TRAY_URL="https://download.local-mcp.com/local-mcp-tray-${VERSION}-darwin-universal.tar.gz" TMP_TRAY="$(mktemp /tmp/local-mcp-tray-XXXXXX.tar.gz)" _LMCP_TMP_TRAY="$TMP_TRAY" # Migrar instalación vieja de ~/.local/share si existe rm -rf "$TRAY_META/LocalMCPTray.app" 2>/dev/null || true LMCP_INSTALL_STEP="download_tray" info "Downloading tray (menu bar app)..." # Retry up to 3 times — transient network hiccups on first install have left # machines in Mode B (no tray), which means no supervisor, no auto-repair, and # no recovery path when the server gets stuck on an old version (LMC-445). TRAY_DOWNLOAD_OK=false for _tray_attempt in 1 2 3; do if curl -fsSL --progress-bar "$TRAY_URL" -o "$TMP_TRAY" 2>/dev/null; then TRAY_DOWNLOAD_OK=true break fi if [ "$_tray_attempt" -lt 3 ]; then warn "Tray download attempt $_tray_attempt failed — retrying in 3s..." sleep 3 fi done if $TRAY_DOWNLOAD_OK; then mkdir -p "$TRAY_META" rm -rf "$TRAY_APP" 2>/dev/null || true if ! tar -xzf "$TMP_TRAY" -C "/Applications" 2>/dev/null; then warn "Could not extract tray to /Applications — trying with permissions fix..." chmod 755 /Applications 2>/dev/null || true tar -xzf "$TMP_TRAY" -C "/Applications" 2>/dev/null || true fi if [ -d "$TRAY_APP" ]; then xattr -rd com.apple.quarantine "$TRAY_APP" 2>/dev/null || true codesign --force --sign - "$TRAY_APP/Contents/MacOS/LocalMCPTray" 2>/dev/null || true echo "$VERSION" > "$TRAY_META/.version" LMCP_TRAY_OK="true" ok "Tray installed to /Applications (menu bar icon)" _t "download_tray" "ok" "" # LaunchAgent — auto-start en cada inicio de sesión TRAY_PLIST="$HOME/Library/LaunchAgents/com.local-mcp.tray.plist" TRAY_BIN="$TRAY_APP/Contents/MacOS/LocalMCPTray" mkdir -p "$HOME/Library/LaunchAgents" cat > "$TRAY_PLIST" << PLIST_EOF Label com.local-mcp.tray ProgramArguments ${TRAY_BIN} RunAtLoad KeepAlive PLIST_EOF launchctl unload "$TRAY_PLIST" 2>/dev/null || true # Kill any running tray process before loading the new one (LMC-514: single-instance guard) pkill -f LocalMCPTray 2>/dev/null || true sleep 1 launchctl load "$TRAY_PLIST" 2>/dev/null || true ok "Tray set to auto-start on login" fi else warn "Tray download failed after 3 attempts — MCP server works without it, but auto-updates require the tray app. Re-run the installer when your network is stable." fi # ── Configure AI clients ─────────────────────────────────────────────────────── CONFIGURED_CLIENTS="" # _merge_mcp_config FILE CLIENT_ID CMD [ARGS [SCHEMA]] # SCHEMA: "mcpServers" (default, Claude Desktop/Cursor/Windsurf/Roo-Cline) # "vscode" (VS Code native 1.99+: uses "servers" key + type:"stdio") # Safely merges local-mcp into an existing MCP config using jq (if available) # or osascript JXA (always present on macOS). Never overwrites a file with # invalid JSON — backs it up and returns 2 so the caller can emit a parse_error # event. Returns 0 on success, 1 on write/verify error, 2 on parse error. _merge_mcp_config() { local cfg_file="$1" local client_id="$2" local cmd="$3" local _args="${4:--y local-mcp@latest}" local schema="${5:-mcpServers}" # "vscode" → servers+type:stdio; else mcpServers local _desc='111 tools for Mail, Calendar, Teams, OneDrive, Notes, Safari, and more. Try: Read my emails, What is on my calendar, Summarize Teams messages' local tmp="${cfg_file}.lmcp-tmp" mkdir -p "$(dirname "$cfg_file")" # Build args JSON array (split on spaces) local args_json args_json=$(printf '%s' "$_args" | awk '{ printf "[" for(i=1;i<=NF;i++){printf "%s\"%s\"",(i>1?",":""),$i} printf "]" }') # --- jq path (preferred: preserves all existing MCPs) --- if command -v jq &>/dev/null; then # Validate existing JSON first if [ -f "$cfg_file" ] && [ -s "$cfg_file" ]; then if ! jq empty "$cfg_file" 2>/tmp/lmcp_jq_err; then cp "$cfg_file" "${cfg_file}.lmcp-backup" cat /tmp/lmcp_jq_err >&2 return 2 fi fi local base='{}' [ -f "$cfg_file" ] && [ -s "$cfg_file" ] && base=$(cat "$cfg_file") local merged others verify_path if [ "$schema" = "vscode" ]; then # VS Code native MCP: "servers" key, entry has type:"stdio" merged=$(echo "$base" | jq \ --arg cmd "$cmd" \ --argjson args "$args_json" \ '.servers["local-mcp"] = {"type":"stdio","command":$cmd,"args":$args}' \ 2>/tmp/lmcp_jq_err) || { cat /tmp/lmcp_jq_err >&2; rm -f "$tmp"; return 1; } verify_path='.servers["local-mcp"]' others=$(echo "$merged" | jq -r '[.servers // {} | keys[] | select(. != "local-mcp")] | join(",")' 2>/dev/null || true) else # Standard mcpServers format (Claude Desktop, Cursor, Windsurf, Roo-Cline, etc.) merged=$(echo "$base" | jq \ --arg cmd "$cmd" \ --argjson args "$args_json" \ --arg desc "$_desc" \ 'del(.mcpServers["office-mcp"]) | .mcpServers["local-mcp"] = {"command":$cmd,"args":$args,"description":$desc}' \ 2>/tmp/lmcp_jq_err) || { cat /tmp/lmcp_jq_err >&2; rm -f "$tmp"; return 1; } verify_path='.mcpServers["local-mcp"]' others=$(echo "$merged" | jq -r '[.mcpServers // {} | keys[] | select(. != "local-mcp")] | join(",")' 2>/dev/null || true) fi echo "$merged" > "$tmp" && mv "$tmp" "$cfg_file" if ! jq -e "$verify_path" "$cfg_file" &>/dev/null; then echo "VERIFY_ERROR: local-mcp missing after write" >&2 return 1 fi echo "OK:${others}" return 0 fi # --- osascript JXA fallback (no jq — always available on macOS) --- osascript -l JavaScript - "$cfg_file" "$cmd" "$_args" "$_desc" "$schema" <<'JSEOF' 2>/tmp/lmcp_jq_err function run(argv) { var cfgFile = argv[0], cmd = argv[1], argsStr = argv[2], desc = argv[3], schema = argv[4]; var args = argsStr ? argsStr.split(' ') : []; var fm = $.NSFileManager.defaultManager; ObjC.import('Foundation'); var cfg = {}; if (fm.fileExistsAtPath($(cfgFile))) { var raw = ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError( $(cfgFile), $.NSUTF8StringEncoding, null)); raw = raw ? raw.trim() : ''; if (raw && raw !== '{}') { try { cfg = JSON.parse(raw); } catch(e) { fm.copyItemAtPathToPathError($(cfgFile), $(cfgFile + '.lmcp-backup'), null); $.NSFileManager.defaultManager; // flush throw new Error('PARSE_ERROR:' + e.message); } } } var others; if (schema === 'vscode') { if (!cfg.servers) cfg.servers = {}; cfg.servers['local-mcp'] = {type: 'stdio', command: cmd, args: args}; others = Object.keys(cfg.servers).filter(function(k){return k!=='local-mcp';}); } else { if (!cfg.mcpServers) cfg.mcpServers = {}; delete cfg.mcpServers['office-mcp']; cfg.mcpServers['local-mcp'] = {command: cmd, args: args, description: desc}; others = Object.keys(cfg.mcpServers).filter(function(k){return k!=='local-mcp';}); } var json = JSON.stringify(cfg, null, 2) + '\n'; var tmp = cfgFile + '.lmcp-tmp'; var ok = $(json).writeToFileAtomically($(tmp), true); if (!ok) throw new Error('WRITE_ERROR: could not write ' + tmp); fm.moveItemAtPathToPathError($(tmp), $(cfgFile), null); return 'OK:' + others.join(','); } JSEOF local jsa_exit=$? if [ $jsa_exit -ne 0 ]; then local jsa_err jsa_err=$(cat /tmp/lmcp_jq_err 2>/dev/null) echo "$jsa_err" >&2 echo "$jsa_err" | grep -q "PARSE_ERROR" && return 2 return 1 fi } # _setup_client CLIENT_ID CLIENT_NAME CFG_FILE CMD [SCHEMA] # SCHEMA: "mcpServers" (default) or "vscode" (VS Code native 1.99+) # Writes config and emits setup-step telemetry for the result. _setup_client() { local client_id="$1" local client_name="$2" local cfg_file="$3" local cmd="$4" local schema="${5:-mcpServers}" # Determine command and args local _cmd _args if command -v npx &>/dev/null; then _cmd="npx"; _args="-y local-mcp@latest" else _cmd="$cmd"; _args="stdio" fi local py_out py_err exit_code py_out=$(_merge_mcp_config "$cfg_file" "$client_id" "$_cmd" "$_args" "$schema" 2>/tmp/lmcp_merge_err); exit_code=$? py_err=$(cat /tmp/lmcp_merge_err 2>/dev/null) if [ "$exit_code" -eq 0 ]; then ok "Configured ${client_name}" CONFIGURED_CLIENTS="${CONFIGURED_CLIENTS}${client_name} " _track_setup_step "config_write" "ok" "$client_id" "" "$py_out" elif [ "$exit_code" -eq 2 ]; then warn "Could not configure ${client_name}: existing config has invalid JSON (backed up to ${cfg_file}.lmcp-backup)" warn " Error: ${py_err}" _track_setup_step "config_write" "parse_error" "$client_id" "$py_err" "" else warn "Could not configure ${client_name}: ${py_err}" _track_setup_step "config_write" "write_error" "$client_id" "$py_err" "" fi } # _track_setup_step STEP STATUS CLIENT ERROR DETAIL _track_setup_step() { local step="$1" status="$2" client="$3" error_msg="$4" detail="$5" # Extract machine_id from config.json using grep (no scripting runtime needed) local machine_id="" if [ -f "$APP_SUPPORT/config.json" ]; then machine_id=$(grep -o '"machine_id":"[^"]*"' "$APP_SUPPORT/config.json" 2>/dev/null \ | head -1 | sed 's/"machine_id":"//;s/"//' || true) fi # Truncate error to 300 chars and escape for JSON local err_short err_short=$(printf '%s' "$error_msg" | head -c 300 | sed 's/\\/\\\\/g;s/"/\\"/g;s/ / /g') local preserved="" if printf '%s' "$detail" | grep -q '^OK:.\+'; then preserved=$(printf '%s' "$detail" | sed 's/^OK://') fi # Build JSON payload via printf (no scripting runtime needed) local payload payload=$(printf '{"machine_id":"%s","install_id":"%s","method":"curl","step":"%s","status":"%s","client":"%s","error":"%s","preserved_servers":"%s"}' \ "${machine_id}" "${INSTALL_ID:-}" "$step" "$status" "$client" "$err_short" "$preserved") curl -s -X POST "https://office-mcp-production.up.railway.app/setup-step" \ -H "Content-Type: application/json" \ -d "$payload" \ --max-time 5 &>/dev/null & } CLAUDE_CFG="$HOME/Library/Application Support/Claude/claude_desktop_config.json" CURSOR_CFG="$HOME/.cursor/mcp.json" WINDSURF_CFG="$HOME/.codeium/windsurf/mcp_config.json" CLAUDE_CODE_CFG="$HOME/.claude/settings.json" VSCODE_CFG="$HOME/.vscode/mcp.json" ROO_CLINE_CFG="$HOME/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/mcp_settings.json" ZED_CFG="$HOME/.config/zed/settings.json" # Emit detect step — which clients are present _DETECTED_CLIENTS="" _NOT_DETECTED="" _check_client() { local label="$1"; shift; for p in "$@"; do [ -d "$p" ] || [ -f "$p" ] && { _DETECTED_CLIENTS="${_DETECTED_CLIENTS}${label} "; return; }; done; _NOT_DETECTED="${_NOT_DETECTED}${label} "; } _check_client "claude-desktop" "$HOME/Library/Application Support/Claude" "$CLAUDE_CFG" _check_client "cursor" "$HOME/.cursor" "/Applications/Cursor.app" _check_client "windsurf" "$HOME/.codeium/windsurf" "/Applications/Windsurf.app" _check_client "claude-code" "$HOME/.claude" _check_client "vscode" "$HOME/.vscode" "/Applications/Visual Studio Code.app" _check_client "roo-cline" "$HOME/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline" _check_client "zed" "$HOME/.config/zed" "/Applications/Zed.app" _track_setup_step "detect" "ok" "" "" "clients_found:${_DETECTED_CLIENTS% },clients_not_found:${_NOT_DETECTED% }" if [ -d "$HOME/Library/Application Support/Claude" ] || [ -f "$CLAUDE_CFG" ]; then _setup_client "claude-desktop" "Claude Desktop" "$CLAUDE_CFG" "$BINARY" fi if [ -d "$HOME/.cursor" ] || command -v cursor &>/dev/null 2>/dev/null; then _setup_client "cursor" "Cursor" "$CURSOR_CFG" "$BINARY" fi if [ -d "$HOME/.codeium/windsurf" ] || [ -d "/Applications/Windsurf.app" ]; then _setup_client "windsurf" "Windsurf" "$WINDSURF_CFG" "$BINARY" fi if [ -d "$HOME/.claude" ] || command -v claude &>/dev/null 2>/dev/null; then _setup_client "claude-code" "Claude Code" "$CLAUDE_CODE_CFG" "$BINARY" fi if [ -d "$HOME/.vscode" ] || [ -d "/Applications/Visual Studio Code.app" ]; then _setup_client "vscode" "VS Code" "$VSCODE_CFG" "$BINARY" "vscode" fi if [ -d "$HOME/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline" ]; then _setup_client "roo-cline" "Roo-Cline" "$ROO_CLINE_CFG" "$BINARY" fi if [ -d "$HOME/.config/zed" ] || [ -d "/Applications/Zed.app" ]; then _setup_client "zed" "Zed" "$ZED_CFG" "$BINARY" fi _t "configure_clients" "ok" "$CONFIGURED_CLIENTS" # ── Save pre-token / email to config ────────────────────────────────────────── 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" 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" else # Pure bash fallback: build minimal JSON local _cfg="{" local _sep="" if [ -n "$LMCP_PRE_TOKEN" ]; then _cfg="${_cfg}${_sep}\"pre_token\": \"${LMCP_PRE_TOKEN}\"" _sep=", " fi if [ -n "$LMCP_EMAIL" ]; then _cfg="${_cfg}${_sep}\"license_email\": \"${LMCP_EMAIL}\"" fi _cfg="${_cfg}}" echo "$_cfg" > "$CONFIG_FILE" 2>/dev/null || true fi fi # ── Track install ────────────────────────────────────────────────────────────── OS_VER="$(sw_vers -productVersion 2>/dev/null || true)" REF="${LMCP_REF:-}" curl -sf -X POST "${BACKEND_URL}/install/npm" \ -H "Content-Type: application/json" \ -d "{\"version\":\"${VERSION}\",\"os_version\":\"${OS_VER}\",\"method\":\"curl-binary\",\"arch\":\"${ARCH}\",\"ref\":\"${REF}\"}" \ 2>/dev/null || true # Claim pre-token if present (cloud connector → install attribution) if [ -n "${LMCP_PRE_TOKEN:-}" ]; then curl -sf -X POST "${BACKEND_URL}/install/ping" \ -H "Content-Type: application/json" \ -d "{\"pre_token\":\"${LMCP_PRE_TOKEN}\",\"version\":\"${VERSION}\",\"os_version\":\"${OS_VER}\",\"machine_id\":\"${LMCP_MACHINE_ID}\"}" \ 2>/dev/null || true fi # Emit binary_health and setup_complete telemetry if [ -f "$BINARY" ] && "$BINARY" --version &>/dev/null 2>&1; then _track_setup_step "binary_health" "ok" "" "" "" else _track_setup_step "binary_health" "failed" "" "binary not executable" "" fi _complete_status="ok" [ -z "$CONFIGURED_CLIENTS" ] && _complete_status="no_clients_configured" _track_setup_step "setup_complete" "$_complete_status" "" "" "clients_found:${CONFIGURED_CLIENTS% }" LMCP_INSTALL_STEP="done" # ── Auto-launch primary AI client ───────────────────────────────────────────── LAUNCH_APP="" if [ -n "$CONFIGURED_CLIENTS" ]; then if echo "$CONFIGURED_CLIENTS" | grep -q "Claude Desktop"; then LAUNCH_APP="Claude" elif echo "$CONFIGURED_CLIENTS" | grep -q "Cursor"; then LAUNCH_APP="Cursor" elif echo "$CONFIGURED_CLIENTS" | grep -q "Windsurf"; then LAUNCH_APP="Windsurf" fi if [ -n "$LAUNCH_APP" ]; then open -a "$LAUNCH_APP" 2>/dev/null || true curl -sf -X POST "$BACKEND_URL/install-event" \ -H "Content-Type: application/json" \ -d "{\"stage\":\"install_auto_launch\",\"client\":\"${LAUNCH_APP}\",\"install_id\":\"${INSTALL_ID}\"}" \ &>/dev/null & fi fi # ── Done ────────────────────────────────────────────────────────────────────── _t "complete" "ok" "${VERSION}" echo "" echo -e "${GREEN}${BOLD}✅ LMCP v${VERSION} installed${NC}" echo "" if [ -n "$CONFIGURED_CLIENTS" ]; then echo -e "${GREEN}AI clients configured: ${CONFIGURED_CLIENTS}${NC}" if [ -n "$LAUNCH_APP" ]; then echo -e " ${GREEN}${LAUNCH_APP} is opening now — LMCP tools will be available in a moment.${NC}" else echo -e " ${YELLOW}Restart your AI client to activate LMCP.${NC}" fi if echo "$CONFIGURED_CLIENTS" | grep -q "Cursor"; then echo -e " ${BOLD}⚡ Cursor users: Switch to \"Agent\" mode in the chat panel for LMCP tools to appear.${NC}" echo -e " ${BLUE}(Click the mode dropdown at the bottom of the Cursor chat → select \"Agent\")${NC}" fi else echo -e "${BOLD}Add this to your AI client config (claude_desktop_config.json):${NC}" echo "" echo -e " ${BLUE}{ \"mcpServers\": { \"local-mcp\": { \"command\": \"${BINARY}\", \"args\": [\"stdio\"] } } }${NC}" echo "" fi echo "" echo -e " ${BOLD}💬 Try asking Claude:${NC}" echo -e " ${BLUE}\"Read my last 3 emails and summarize them\"${NC}" echo "" if [ -n "$LMCP_EMAIL" ]; then echo -e "${GREEN}✓ Account linked — cloud relay will auto-connect.${NC}" fi # ── Optional email upgrade (no prompt — removed 2026-04-19) ─────────────────── # The interactive email prompt was deleted to eliminate TTY-dependent friction # (it always skipped under `curl | bash` anyway). Cloud relay now activates # anonymously above via /tunnel/register-anon. Users can attach an email later # from the tray or https://local-mcp.com/settings to enable cross-Mac sync and # paid-license features. if [ -z "$LMCP_EMAIL" ] && [ -n "$LMCP_MACHINE_ID" ]; then echo -e " ${BOLD}📧 Get update notifications:${NC} ${BLUE}https://local-mcp.com/settings?m=${LMCP_MACHINE_ID}${NC}" echo "" fi echo "" echo -e " Docs & settings: ${BLUE}https://local-mcp.com${NC}" echo ""