#!/usr/bin/env bash # install-daemons.sh — run our MCP servers as ONE shared HTTP daemon each # (launchd), instead of one stdio process per Claude session. # # Why: Claude Code spawns every stdio MCP server fresh per session, so N # parallel sessions cost N copies of each server. On neo (8 GB) three sessions # once carried ~570 MB of duplicate MCP processes, which contributed to a hang # so complete that launchd could no longer fork an sshd to let anyone in. # # mail-mcp → http://127.0.0.1:7765/mcp # emacs-mcp → http://127.0.0.1:7766/mcp # frame-mcp → http://127.0.0.1:7767/mcp # puppet-mcp → http://127.0.0.1:7769/mcp (7768 is Spotify's) # dm-mcp → http://127.0.0.1:7771/mcp # memory-mcp → http://127.0.0.1:7772/mcp # prox-mcp → http://127.0.0.1:7773/mcp # cal-mcp → http://127.0.0.1:7774/mcp # chat-mcp → http://127.0.0.1:7775/mcp # fleet-mcp → http://127.0.0.1:7776/mcp # # This script also POINTS Claude and Codex at the daemons. Claude gets same-name # local-scope entries that shadow the stdio ones in .mcp.json; Codex gets its # user-level MCP entries via `codex mcp add --url`. # # The stdio entries in .mcp.json stay as the fallback: a box without daemons # (or a session outside this repo) still works, just at one process per session. # # Idempotent: re-running rewrites the plists, restarts the daemons, and # re-points Claude at them. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Prefer fnm's stable alias path over a transient fnm_multishells one, which # dies with the shell that made it. frame-mcp also spawns `node` as a child, so # this directory goes on the daemon's PATH below — launchd inherits no shell. NODE="$HOME/.local/share/fnm/aliases/default/bin/node" [ -x "$NODE" ] || NODE="$(command -v node)" AGENTS="$HOME/Library/LaunchAgents" mkdir -p "$AGENTS" # "name port script" rows. macOS ships bash 3.2 — no associative arrays. Fed to # the loops below by here-doc, which (unlike a pipe) keeps them in this shell so # `fail` survives. servers() { cat <<'ROWS' mail 7765 ants/mail-mcp/server.mjs emacs 7766 artery/emacs-mcp.mjs frame 7767 slab/bin/frame-mcp.mjs puppet 7769 slab/bin/puppet-mcp.mjs dm 7771 slab/bin/dm-mcp.mjs memory 7772 memory/memory-mcp.mjs prox 7773 slab/bin/prox-mcp.mjs cal 7774 slab/bin/cal-mcp.mjs chat 7775 slab/bin/chat-mcp.mjs fleet 7776 toolchain/fleet/fleet-mcp.mjs ROWS } # Env the stdio entries in .mcp.json pass, which the daemons need too. Both # servers fall back to paths that are wrong here (emacs → /usr/sbin/emacsclient, # mail → a /workspaces devcontainer path), so a daemon without these is subtly # broken rather than obviously dead. extra_env() { case "$1" in emacs) printf ' EMACSCLIENT\n emacsclient\n' ;; mail) printf ' AC_EMAIL_STYLE_GUIDE\n %s/toolchain/email/style-guide.md\n' "$REPO" ;; esac } write_plist() { local name="$1" label="$2" script="$3" port="$4" cat > "$AGENTS/$label.plist" < Label $label ProgramArguments $NODE $script --http $port EnvironmentVariables HOME $HOME PATH $(dirname "$NODE"):/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin $(extra_env "$name") RunAtLoad KeepAlive ThrottleInterval 5 StandardOutPath /tmp/$label.out StandardErrorPath /tmp/$label.err EOF launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true launchctl bootstrap "gui/$(id -u)" "$AGENTS/$label.plist" } HAVE_CLAUDE=0 command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1 HAVE_CODEX=0 command -v codex >/dev/null 2>&1 && HAVE_CODEX=1 cd "$REPO" # `claude mcp --scope local` is per-project while read -r name port script; do [ -n "$name" ] || continue write_plist "$name" "computer.aesthetic.$name-mcp" "$REPO/$script" "$port" echo "✓ $name-mcp → 127.0.0.1:$port" if [ "$HAVE_CLAUDE" = 1 ]; then # Local scope shadows the stdio entry of the same name in .mcp.json. claude mcp remove "$name" --scope local >/dev/null 2>&1 || true claude mcp add --transport http --scope local "$name" "http://127.0.0.1:$port/mcp" >/dev/null echo " ↳ claude → http://127.0.0.1:$port/mcp (local scope)" fi if [ "$HAVE_CODEX" = 1 ]; then codex mcp remove "$name" >/dev/null 2>&1 || true codex mcp add "$name" --url "http://127.0.0.1:$port/mcp" >/dev/null echo " ↳ codex → http://127.0.0.1:$port/mcp" fi done <<<"$(servers)" if [ "$HAVE_CLAUDE" = 0 ]; then echo echo "⚠ 'claude' not on PATH — daemons installed, but sessions will still spawn" echo " stdio copies. Re-run from a shell where 'claude' resolves." fi if [ "$HAVE_CODEX" = 0 ]; then echo echo "⚠ 'codex' not on PATH — daemons installed, but Codex will not see them" echo " until you re-run from a shell where 'codex' resolves." fi # Verify. A daemon that never answers is precisely the silent half-apply this # script exists to prevent. Speak like a real MCP client: mail-mcp's SDK # transport 406s a request that won't accept text/event-stream, and a launchd # respawn after a port clash costs one ThrottleInterval, so allow ~10s. echo fail=0 while read -r name port script; do [ -n "$name" ] || continue i=0 while [ $i -lt 50 ]; do nc -z 127.0.0.1 "$port" 2>/dev/null && break; sleep 0.2; i=$((i+1)); done if curl -s -m 5 -X POST "http://127.0.0.1:$port/mcp" \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"install-daemons","version":"1"}}}' \ 2>/dev/null | grep -q '"serverInfo"'; then echo "✓ $name-mcp answering on $port" else echo "✗ $name-mcp NOT answering on $port — see /tmp/computer.aesthetic.$name-mcp.err" echo " (is another app holding $port? lsof -nP -iTCP:$port -sTCP:LISTEN)" fail=1 fi done <<<"$(servers)" exit $fail