diff --git a/artery/emacs-mcp.mjs b/artery/emacs-mcp.mjs --- a/artery/emacs-mcp.mjs +++ b/artery/emacs-mcp.mjs @@ -11,7 +11,7 @@ * This would give visual feedback when Copilot is monitoring logs. */ import { spawn } from "child_process"; -import * as readline from "readline"; +import { httpPort, serveHttp, serveStdio } from "../toolchain/mcp/http-front.mjs"; const EMACSCLIENT = process.env.EMACSCLIENT || "/usr/sbin/emacsclient"; @@ -340,84 +340,10 @@ }; } } -// Main — stdio by default (Claude spawns one process per session), or a -// shared daemon with `--http [port]` so any number of parallel sessions -// reuse ONE resident process (wired up by toolchain/mcp/install-daemons.sh -// + a local-scope http entry in ~/.claude.json). handleMessage is already -// stateless per call, so the streamable-HTTP front is just: POST a JSON-RPC -// message, get the JSON reply — notifications (no id) get a bare 202. -const httpFlag = process.argv.indexOf("--http"); -if (httpFlag !== -1) { - const { createServer } = await import("node:http"); - const port = Number(process.argv[httpFlag + 1]) || 7766; - createServer(async (req, res) => { - if (req.method !== "POST") { - res.writeHead(405, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - jsonrpc: "2.0", - id: null, - error: { code: -32000, message: "stateless server: POST only" }, - })); - return; - } - let body = ""; - for await (const chunk of req) body += chunk; - try { - const message = JSON.parse(body); - // Notifications carry no id and must not get a reply — without this - // guard "notifications/initialized" would fall through to the - // method-not-found error and every session would log a warning. - const answerable = (m) => m.id !== undefined && m.id !== null; - const response = Array.isArray(message) - ? (await Promise.all(message.filter(answerable).map(handleMessage))).filter(Boolean) - : answerable(message) ? await handleMessage(message) : null; - const empty = !response || (Array.isArray(response) && !response.length); - if (empty) { - res.writeHead(202); - res.end(); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(response)); - } catch (e) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - jsonrpc: "2.0", - id: null, - error: { code: -32700, message: `Parse error: ${e.message}` }, - })); - } - }).listen(port, "127.0.0.1", () => { - console.error(`🧠 emacs-mcp shared daemon on http://127.0.0.1:${port}`); - }); -} else { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false, - }); - - rl.on("line", async (line) => { - try { - const message = JSON.parse(line); - const response = await handleMessage(message); - if (response) { - console.log(JSON.stringify(response)); - } - } catch (e) { - console.error( - JSON.stringify({ - jsonrpc: "2.0", - id: null, - error: { - code: -32700, - message: `Parse error: ${e.message}`, - }, - }), - ); - } - }); - - // Log to stderr for debugging (won't interfere with JSON-RPC on stdout) - console.error("🧠 Emacs MCP Server started"); -} +// stdio by default (Claude spawns one process per session), or `--http [port]` +// for one resident daemon every session shares — installed by +// toolchain/mcp/install-daemons.sh, which also points Claude at it with a +// local-scope http entry. handleMessage is stateless per call. +const port = httpPort(process.argv, 7766); +if (port) serveHttp({ handleMessage, port, banner: "🧠 emacs-mcp shared daemon" }); +else serveStdio({ handleMessage, banner: "🧠 Emacs MCP Server started" }); diff --git a/slab/bin/frame-mcp.mjs b/slab/bin/frame-mcp.mjs --- a/slab/bin/frame-mcp.mjs +++ b/slab/bin/frame-mcp.mjs @@ -18,7 +18,7 @@ import { readFile, unlink } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; -import * as readline from "node:readline"; +import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const FRAME = join(HERE, "frame.mjs"); @@ -204,14 +204,10 @@ return { jsonrpc: "2.0", id, error: { code: -32000, message: String(error.message || error) } }; } } -const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); -rl.on("line", async (line) => { - if (!line.trim()) return; - try { - const response = await handleMessage(JSON.parse(line)); - if (response) console.log(JSON.stringify(response)); - } catch (e) { - console.error(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: `Parse error: ${e.message}` } })); - } -}); -console.error("🖼 frame-mcp server started (observe-only: frame, frame_list, frame_doctor, frame_setup)"); +// stdio by default (Claude spawns one process per session), or `--http [port]` +// for one resident daemon every session shares — installed by +// toolchain/mcp/install-daemons.sh. Each capture shells out fresh, so there is +// no per-session state to keep. +const port = httpPort(process.argv, 7767); +if (port) serveHttp({ handleMessage, port, banner: "🖼 frame-mcp shared daemon" }); +else serveStdio({ handleMessage, banner: "🖼 frame-mcp server started (observe-only: frame, frame_list, frame_doctor, frame_setup)" }); diff --git a/slab/bin/puppet-mcp.mjs b/slab/bin/puppet-mcp.mjs --- a/slab/bin/puppet-mcp.mjs +++ b/slab/bin/puppet-mcp.mjs @@ -18,7 +18,7 @@ import { existsSync, readFileSync } from "node:fs"; import net from "node:net"; import { homedir } from "node:os"; import { join } from "node:path"; -import * as readline from "node:readline"; +import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs"; import { termList, typeText, sendKeys } from "./macos.mjs"; const HOME = homedir(); @@ -190,14 +190,10 @@ return { jsonrpc: "2.0", id, error: { code: -32000, message: String(error.message || error) } }; } } -const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); -rl.on("line", async (line) => { - if (!line.trim()) return; - try { - const response = await handleMessage(JSON.parse(line)); - if (response) console.log(JSON.stringify(response)); - } catch (e) { - console.error(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: `Parse error: ${e.message}` } })); - } -}); -console.error("🎭 puppet-mcp server started (act: nav/reload/stroke/gesture/key/cursor/type/keys + eval/shot/list/term)"); +// stdio by default (Claude spawns one process per session), or `--http [port]` +// for one resident daemon every session shares — installed by +// toolchain/mcp/install-daemons.sh. handleMessage is stateless per call, and +// the real state lives in the puppet daemon behind the unix socket anyway. +const port = httpPort(process.argv, 7769); // 7768 is Spotify's +if (port) serveHttp({ handleMessage, port, banner: "🎭 puppet-mcp shared daemon" }); +else serveStdio({ handleMessage, banner: "🎭 puppet-mcp server started (act: nav/reload/stroke/gesture/key/cursor/type/keys + eval/shot/list/term)" }); diff --git a/toolchain/mcp/http-front.mjs b/toolchain/mcp/http-front.mjs new file mode 100644 --- /dev/null +++ b/toolchain/mcp/http-front.mjs @@ -0,0 +1,71 @@ +// http-front.mjs — let every Claude session share ONE resident MCP server. +// +// Claude Code spawns a fresh stdio child for each MCP server in each session, +// so N parallel sessions cost N copies of every server. On an 8 GB box (neo) +// that adds up: three sessions once carried ~570 MB of duplicate MCP processes. +// +// These servers answer each JSON-RPC call from scratch — `handleMessage` holds +// no per-connection state — so a plain POST front is enough for any number of +// sessions to share one process. Streamable HTTP minus the streaming: POST a +// message, get the reply. A notification (no `id`) must get a bare 202 and no +// body, or `notifications/initialized` falls through to method-not-found and +// every session logs a warning. +// +// Bind loopback only. ants/mail-mcp keeps its own SDK transport because it also +// binds off-loopback (jasellite) behind bearer auth — a different problem. + +import { createServer } from "node:http"; +import * as readline from "node:readline"; + +const answerable = (m) => m.id !== undefined && m.id !== null; + +/** Port from `--http [port]` in argv, or null when the flag is absent. */ +export function httpPort(argv, fallback) { + const i = argv.indexOf("--http"); + if (i === -1) return null; + return Number(argv[i + 1]) || fallback; +} + +/** One resident process, many sessions. */ +export function serveHttp({ handleMessage, port, host = "127.0.0.1", banner }) { + createServer(async (req, res) => { + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "stateless server: POST only" } })); + return; + } + let body = ""; + for await (const chunk of req) body += chunk; + try { + const message = JSON.parse(body); + const response = Array.isArray(message) + ? (await Promise.all(message.filter(answerable).map(handleMessage))).filter(Boolean) + : answerable(message) ? await handleMessage(message) : null; + if (!response || (Array.isArray(response) && !response.length)) { + res.writeHead(202); + res.end(); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(response)); + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: `Parse error: ${e.message}` } })); + } + }).listen(port, host, () => console.error(`${banner} on http://${host}:${port}`)); +} + +/** One process per session — what Claude Code does when it spawns us directly. */ +export function serveStdio({ handleMessage, banner }) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); + rl.on("line", async (line) => { + if (!line.trim()) return; + try { + const response = await handleMessage(JSON.parse(line)); + if (response) console.log(JSON.stringify(response)); + } catch (e) { + console.error(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: `Parse error: ${e.message}` } })); + } + }); + console.error(banner); +} diff --git a/toolchain/mcp/install-daemons.sh b/toolchain/mcp/install-daemons.sh --- a/toolchain/mcp/install-daemons.sh +++ b/toolchain/mcp/install-daemons.sh @@ -1,32 +1,66 @@ #!/usr/bin/env bash -# install-daemons.sh — run mail-mcp + emacs-mcp as ONE shared HTTP daemon -# each (launchd), instead of one stdio process per Claude session. +# 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. The daemons listen on -# localhost and every session connects over streamable HTTP instead: +# 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) # -# After installing, point Claude at them with same-name local-scope -# overrides (local scope shadows the stdio entries in .mcp.json): -# claude mcp add --transport http --scope local mail http://127.0.0.1:7765/mcp -# claude mcp add --transport http --scope local emacs http://127.0.0.1:7766/mcp +# This script also POINTS Claude at the daemons, with same-name local-scope +# entries that shadow the stdio ones in .mcp.json. That step used to live in a +# comment here and was never run on neo, so the daemons sat idle while every +# session still spawned its own stdio copy. A half-applied fix is worse than +# none: it looks installed. Now the script does both halves, and verifies. # -# Idempotent: re-running rewrites the plists and restarts both daemons. +# 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" -install_one() { - local label="$1" script="$2" port="$3" - local plist="$AGENTS/$label.plist" - cat > "$plist" <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" < @@ -45,8 +79,8 @@ HOME $HOME PATH - /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin - + $(dirname "$NODE"):/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +$(extra_env "$name") RunAtLoad KeepAlive @@ -61,9 +95,51 @@ EOF launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true - launchctl bootstrap "gui/$(id -u)" "$plist" - echo "✓ $label → 127.0.0.1:$port ($script)" + launchctl bootstrap "gui/$(id -u)" "$AGENTS/$label.plist" } -install_one computer.aesthetic.mail-mcp "$REPO/ants/mail-mcp/server.mjs" 7765 -install_one computer.aesthetic.emacs-mcp "$REPO/artery/emacs-mcp.mjs" 7766 +HAVE_CLAUDE=0 +command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=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 +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 + +# 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