#!/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 ""