diff --git a/README.md b/README.md index 85e9afbc1fd..3d02eb94395 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/remote-secondmates.md](docs/remote-secondmates.md) - current setup, routing, transfer, recovery, and safety behavior for whole-home remote second mates. - [docs/calm.md](docs/calm.md) - current `/calm` behavior on Pi and Claude Code and its supported presentation limits. - [docs/voice-relay.md](docs/voice-relay.md) - the optional spoken interface: setup on both machines, measured round-trip cost, what a spoken answer may read, and what this build does not do yet. +- [docs/mcp.md](docs/mcp.md) - the local MCP server that lets Claude Desktop and Claude Code read fleet state and send firstmate notes. - [docs/fleet-ledger.md](docs/fleet-ledger.md) - the opt-in activity ledger outside tools can read to follow a home's tasks, and its record contract. - [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for an away-mode escalation delivery that gets stuck. - [docs/tmux-backend.md](docs/tmux-backend.md) - current setup and limits for the tmux reference backend. diff --git a/bin/fm-mcp.py b/bin/fm-mcp.py new file mode 100755 index 00000000000..214498980ee --- /dev/null +++ b/bin/fm-mcp.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""fm-mcp.py - a local stdio MCP server that lets an MCP client talk to firstmate. + +Claude Desktop (chat, Cowork, and Code) and Claude Code launch this as a local +stdio server. It wraps firstmate's own scripts and published files and nothing +else: no network listener, no credentials, and no direct writes to firstmate's +files. It never spawns, steers, merges, tears down, or edits backlog or state. +Every action request becomes an inbox note (fm-inbox.sh note), and firstmate's +own rules decide what happens next. Each such note's body starts with the line +"[via firstmate MCP from ]", naming the client from its +initialize handshake (or "unknown client"), so firstmate can tell it from a note +the captain typed and tell Claude Desktop from Claude Code. The client's +request_id is stored prefixed with a short hash of the client name (not its +version), so two clients reusing one request_id get separate notes; the caller +sees its own id, from both firstmate_send_note and firstmate_note_replies. + +Usage: + fm-mcp.py serve MCP over stdin/stdout (newline-delimited JSON-RPC) + +Tools: + firstmate_send_note fm-inbox.sh note --request-id - --json - (the only write) + firstmate_note_replies fm-inbox.sh receipts [--after ] + firstmate_status fm-inbox.sh status + fm-inbox.sh ready + firstmate_home_summary state/home-summary.json + firstmate_backlog_list fm-tasks-axi.sh list + firstmate_backlog_show fm-tasks-axi.sh show --full + firstmate_crew_state fm-crew-state.sh + firstmate_crew_report data//report.md (never through a symlink) + +Environment: + FM_HOME firstmate's operational home (default: this script's repo root, + the same default every bin/ script uses). Scripts always come from + this script's own bin/ directory. + PATH must reach what those scripts need (tasks-axi, python3, git, and the + runtime backend's CLI); a desktop app does not inherit a shell PATH. + +Only the standard library is used, so any python3 a firstmate home already +needs can run it. docs/mcp.md owns client setup. +""" + +import hashlib +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +BIN = Path(__file__).resolve().parent +PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") +TASK_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") +REQUEST_ID = re.compile(r"[A-Za-z0-9._:-]{1,119}") +TIMEOUT = 60 +MARKER = "[via firstmate MCP from {client}]" +# The client named in initialize's clientInfo, so firstmate can tell Claude Desktop +# ("claude-ai") from Claude Code. Client-supplied, so it is flattened and capped. +CLIENT = "unknown client" +PREFIX = hashlib.sha256(b"").hexdigest()[:8] + "-" +REPLIES = 20 + +INSTRUCTIONS = ( + "Talk to firstmate, the supervising agent that runs the captain's fleet of coding agents." + " firstmate_send_note is the only way to ask for work or action; it wakes firstmate, whose" + " own rules decide what to do. Replies arrive asynchronously: check them with" + " firstmate_note_replies. Every other tool is a read-only view of fleet state." +) +NOTE_ONLY = ( + "\n\nRead-only: this tool never spawns, steers, merges, tears down, or edits backlog or state;" + " to ask for any of that, send firstmate a note with firstmate_send_note." +) + + +class ToolError(Exception): + pass + + +class InvalidParams(Exception): + pass + + +def home(): + path = Path(os.environ.get("FM_HOME") or BIN.parent).expanduser() + if not path.is_dir(): + raise ToolError(f"FM_HOME is not a directory: {path}") + return path + + +def run(script, *args, stdin=None): + fm_home = home() + try: + proc = subprocess.run( + [str(BIN / script), *args], + input=stdin, + capture_output=True, + text=True, + cwd=fm_home, + env={**os.environ, "FM_HOME": str(fm_home)}, + timeout=TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ToolError(f"{script} did not run: {exc}") from exc + if proc.returncode != 0: + raise ToolError(f"{script} {args[0] if args else ''} exited {proc.returncode}\n{proc.stdout}{proc.stderr}".strip()) + return proc.stdout + + +def valid_id(value): + if not TASK_ID.fullmatch(value) or ".." in value: + raise ToolError(f"invalid task id: {value!r}") + return value + + +def send_note(message, request_id): + if not message.strip(): + raise ToolError("message is empty") + if not REQUEST_ID.fullmatch(request_id): + raise ToolError(f"invalid request_id: {request_id!r} (1-119 of A-Za-z0-9._:-)") + note = json.loads(run("fm-inbox.sh", "note", "--request-id", PREFIX + request_id, "--json", "-", + stdin=MARKER.format(client=CLIENT) + "\n" + message)) + note["request_id"] = request_id + return json.dumps(note) + + +def note_replies(note_id=None, after=None): + if note_id is None: + # No cursor: start REPLIES below the durable reply sequence, so the + # script serializes only the newest page instead of the whole history. + # ponytail: a note replied to twice leaves a sequence gap, so this page + # can hold fewer than REPLIES; the older ones stay reachable by note_id. + cursor = after or newest_page_cursor() + receipts = json.loads(run("fm-inbox.sh", "receipts", *(["--after", cursor] if cursor else []))) + for entry in receipts["omitted"]: + if entry["reveal"] == "pass --all-replies": + entry["reveal"] = "call again with after set to reply_cursor" + elif entry["reveal"].startswith("pass --all-"): + entry["reveal"] = "pass note_id to read one note" + if cursor and not after: + receipts["omitted"].append({"surface": "older replies omitted", + "reveal": "pass note_id to read one note's reply"}) + return json.dumps(own_request_ids(receipts)) + receipts = own_request_ids(json.loads(run("fm-inbox.sh", "receipts", "--all-pending", "--all-handled"))) + for note in receipts["pending"] + receipts["handled"]: + if note["id"] == note_id: + return json.dumps(note) + raise ToolError(f"no note with id {note_id!r}") + + +def own_request_ids(receipts): + for note in receipts["pending"] + receipts["handled"]: + if (note.get("request_id") or "").startswith(PREFIX): + note["request_id"] = note["request_id"][len(PREFIX):] + return receipts + + +def newest_page_cursor(): + try: + seq = int((home() / "state" / "inbox" / ".replies" / ".seq").read_text().strip()) + except (OSError, ValueError): + return None + return "%012d" % (seq - REPLIES) if seq > REPLIES else None + + +STALE_LOCK = "firstmate liveness cannot be proven right now; notes are still saved and queued, and firstmate picks them up the next time its session runs." + + +def status(): + ready = run("fm-inbox.sh", "ready") + try: + stale = json.loads(ready)["lock"]["state"] == "stale" + except (ValueError, KeyError, TypeError): + stale = False + # A stale lock only means the recorded owner process is gone (firstmate + # exited, or a resumed session has not re-taken it), so liveness is unproven. + return run("fm-inbox.sh", "status") + "\n--- firstmate readiness ---\n" + ready + (STALE_LOCK + "\n" if stale else "") + + +def home_file(*parts, missing): + root = home() + path = root.joinpath(*parts) + links = [root.joinpath(*parts[:i]) for i in range(1, len(parts) + 1)] + if any(p.is_symlink() for p in links) or not path.resolve().is_relative_to(root.resolve()): + raise ToolError(f"refusing a symlinked or out-of-home path: {'/'.join(parts)}") + if not path.is_file(): + raise ToolError(missing) + return path.read_text() + + +def home_summary(): + return home_file("state", "home-summary.json", missing="no home summary published at state/home-summary.json") + + +def backlog_list(): + return run("fm-tasks-axi.sh", "list") + + +def backlog_show(task_id): + return run("fm-tasks-axi.sh", "show", valid_id(task_id), "--full") + + +def crew_state(task_id): + return run("fm-crew-state.sh", valid_id(task_id)) + + +def crew_report(task_id): + return home_file("data", valid_id(task_id), "report.md", missing=f"no report yet for {task_id}") + + +def param(description): + return {"type": "string", "description": description} + + +TASK_PARAM = {"task_id": param("Backlog task / crew id")} + +# name -> (handler, description, properties, required, annotations) +TOOLS = { + "firstmate_send_note": ( + send_note, + "Send firstmate a message and wake it (fm-inbox.sh note).\n\n" + f"The queued note's body always starts with the line \"{MARKER.format(client=' ')}\"," + " naming the MCP client from its initialize handshake, so firstmate can tell it came through" + " this server, and from which app, rather than from the captain directly.\n" + "Use this for everything you want firstmate to do or know: questions, new work," + " approvals, steering a crew, merges, cancellations. The note is only a request;" + " this tool itself never spawns, steers, merges, tears down, or edits backlog or state -" + " firstmate reads the note and its own rules decide what happens.\n" + "Returns JSON with the note id and request_id. Use a fresh UUID as the request_id for each new" + " note (other sessions of the same app share this client's request_id space);" + " if a call fails or times out, retry with the SAME request_id: a repeat returns the" + " original note instead of a duplicate." + " Check for firstmate's answer later with firstmate_note_replies(note_id).", + {"message": param("The note for firstmate"), + "request_id": param("Idempotency key: a fresh UUID per note (1-119 of A-Za-z0-9._:-); reuse it when retrying the same note")}, + ["message", "request_id"], + {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}, + ), + "firstmate_note_replies": ( + note_replies, + "Read firstmate's replies to notes and whether each note is still pending (fm-inbox.sh receipts).\n\n" + "With note_id: returns that note with acknowledged (firstmate has taken it) and" + " reply (firstmate's answer, or null while none is recorded yet), so a session" + " that passes its own note ids sees only the answers to its own notes." + " Without: returns recent pending and handled notes plus the newest replies (oldest" + " first); pass the previous reply_cursor as `after` to see only newer replies, paged" + " oldest first. Each reply names the note id it answers." + NOTE_ONLY, + {"note_id": param("Note id returned by firstmate_send_note"), + "after": param("reply_cursor from a previous call")}, + [], + None, + ), + "firstmate_status": ( + status, + "What is happening now: notes waiting, in-flight backlog items, each crew's last event" + " (fm-inbox.sh status), and whether firstmate itself is live to receive notes (fm-inbox.sh ready)." + " When the session lock is stale, it says: firstmate liveness cannot be proven right now; notes are still saved and queued, and firstmate picks them up the next time its session runs." + " Sends no wake and never interrupts firstmate." + NOTE_ONLY, + {}, [], None, + ), + "firstmate_home_summary": ( + home_summary, + "firstmate's structured fleet summary (state/home-summary.json): active crews, open" + " decisions, holds, queue, and whether the summary is currently valid." + NOTE_ONLY, + {}, [], None, + ), + "firstmate_backlog_list": ( + backlog_list, + "List firstmate's backlog tasks with id, state, kind, repo, and title (fm-tasks-axi.sh list)." + NOTE_ONLY, + {}, [], None, + ), + "firstmate_backlog_show": ( + backlog_show, + "Show one backlog task in full, including its notes (fm-tasks-axi.sh show --full)." + NOTE_ONLY, + TASK_PARAM, ["task_id"], None, + ), + "firstmate_crew_state": ( + crew_state, + "A crew's CURRENT state, reconciled from its pipeline run, pane, and status log" + " (fm-crew-state.sh ): working, parked, done, blocked, paused, failed, or unknown." + NOTE_ONLY, + TASK_PARAM, ["task_id"], None, + ), + "firstmate_crew_report": ( + crew_report, + "A crew's written report (data//report.md), usually present once the task is done." + NOTE_ONLY, + TASK_PARAM, ["task_id"], None, + ), +} +READ_ONLY = {"readOnlyHint": True, "openWorldHint": False} + + +def tool_list(): + return [ + { + "name": name, + "description": desc, + "inputSchema": {"type": "object", "properties": props, "required": required, "additionalProperties": False}, + "annotations": ann or READ_ONLY, + } + for name, (_, desc, props, required, ann) in TOOLS.items() + ] + + +def call_tool(params): + name, args = params.get("name"), params.get("arguments") or {} + if name not in TOOLS: + raise InvalidParams(f"unknown tool: {name!r}") + handler, _, props, required, _ = TOOLS[name] + try: + if not isinstance(args, dict) or set(args) - set(props): + raise ToolError(f"arguments must be an object with only: {', '.join(props) or 'none'}") + if any(k not in args for k in required) or not all(isinstance(v, str) for v in args.values()): + raise ToolError(f"required string arguments: {', '.join(required) or 'none'}") + text, is_error = handler(**args), False + except ToolError as exc: + text, is_error = str(exc), True + return {"content": [{"type": "text", "text": text}], "isError": is_error} + + +def handle(msg): + global CLIENT, PREFIX + method, params = msg.get("method"), msg.get("params") or {} + if method == "initialize": + info = params.get("clientInfo") if isinstance(params.get("clientInfo"), dict) else {} + label = " ".join(str(info[k]) for k in ("name", "version") if info.get(k)) + CLIENT = re.sub(r"[^\w .:@/+-]", "", label)[:80].strip() or "unknown client" + PREFIX = hashlib.sha256(str(info.get("name") or "").encode()).hexdigest()[:8] + "-" + asked = params.get("protocolVersion") + return { + "protocolVersion": asked if asked in PROTOCOLS else PROTOCOLS[0], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "firstmate", "version": "1"}, + "instructions": INSTRUCTIONS, + } + if method == "ping": + return {} + if method == "tools/list": + return {"tools": tool_list()} + if method == "tools/call": + return call_tool(params) + raise NotImplementedError(f"method not found: {method}") + + +def reply(payload): + sys.stdout.write(json.dumps(payload) + "\n") + sys.stdout.flush() + + +def main(): + for line in sys.stdin: + if not line.strip(): + continue + try: + msg = json.loads(line) + except ValueError: + reply({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}) + continue + if not isinstance(msg, dict) or "id" not in msg: + continue # notifications (initialized, cancelled) need no answer + out = {"jsonrpc": "2.0", "id": msg["id"]} + try: + out["result"] = handle(msg) + except NotImplementedError as exc: + out["error"] = {"code": -32601, "message": str(exc)} + except InvalidParams as exc: + out["error"] = {"code": -32602, "message": str(exc)} + except Exception as exc: # keep serving; one bad request must not drop the session + out["error"] = {"code": -32603, "message": f"{type(exc).__name__}: {exc}"} + reply(out) + + +if __name__ == "__main__": + main() diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index a064b1c51a4..c4860f1acdb 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -388,6 +388,10 @@ "path": "docs/jev-guards.md", "audience": "maintainer-architecture" }, + { + "path": "docs/mcp.md", + "audience": "operator-current" + }, { "path": "docs/orca-backend.md", "audience": "operator-current" diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000000..84617fefd30 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,55 @@ +# MCP server + +`bin/fm-mcp.py` is a local stdio [MCP](https://modelcontextprotocol.io) server that lets Claude Desktop or Claude Code talk to a running firstmate. +It wraps firstmate's own scripts and published files and nothing else: no network listener, no credentials, and no direct writes to firstmate's files. +It uses only the Python standard library, so the `python3` a firstmate home already needs can run it. + +## Authority boundary + +The server never spawns, steers, merges, tears down, or edits backlog or state. +`firstmate_send_note` is its only write: it queues an inbox note through `fm-inbox.sh note` and wakes firstmate, and firstmate's own rules decide what happens next. +Every note it queues starts with the first line `[via firstmate MCP from ]`, naming the client from its MCP initialize handshake (or `unknown client`), so firstmate can tell an MCP note from one the captain typed. +Claude Desktop identifies itself as `claude-ai` and Claude Code under its own name, so the two are distinguishable, but Desktop's chat, Cowork, and Code tabs share one client identity. +Each reply names the note id it answers, so a session that passes its own note id to `firstmate_note_replies` sees only the answer to its own note. +Every other tool is a read of status, readiness, receipts, the home summary, the backlog, a crew's current state, or a crew's report. +When firstmate's session lock is stale, meaning only that its recorded owner process is gone (firstmate exited, or a resumed session has not re-taken the lock yet), `firstmate_status` adds: "firstmate liveness cannot be proven right now; notes are still saved and queued, and firstmate picks them up the next time its session runs". +A home file is never read through a symlink or from outside `FM_HOME`, so a symlinked `data//report.md` is refused. +The script header lists each tool and the command or file it wraps. + +`request_id` is required and chosen by the client, one per note, so a client retrying a failed or timed-out call with the same id still produces exactly one note. +The server stores it prefixed with a short hash of the client name (not its version, so a retry after the client updates still replays), so Claude Desktop and Claude Code reusing the same id still get separate notes; `firstmate_send_note` and `firstmate_note_replies` both return the caller's own id unchanged. +Sessions of the same app share one client name, so the tool asks for a fresh UUID per note. +Replies arrive asynchronously through `fm-inbox.sh reply` and are read back with `firstmate_note_replies`; without a `note_id` or `after` cursor it asks `fm-inbox.sh receipts` for only the newest 20 replies (fewer when a re-answered note left a gap in the reply sequence), and each call's `reply_cursor` passed back as `after` returns only newer ones. + +## Setup + +The server reads `FM_HOME` like every other `bin/` script and defaults to its own repository root. +Desktop apps do not inherit a shell `PATH`, so pass one that reaches `tasks-axi`, `python3`, `git`, and the runtime backend's CLI. + +Claude Desktop, under `mcpServers` in `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS), then fully quit and relaunch the app: + +```json +"firstmate": { + "command": "/usr/bin/python3", + "args": ["/path/to/firstmate/bin/fm-mcp.py"], + "env": { + "FM_HOME": "/path/to/firstmate/home", + "PATH": "/path/to/node/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + } +} +``` + +Claude Code: + +```sh +claude mcp add --scope user firstmate \ + -e FM_HOME=/path/to/firstmate/home \ + -e PATH="$PATH" \ + -- python3 /path/to/firstmate/bin/fm-mcp.py +``` + +Desktop logs each connection to `~/Library/Logs/Claude/mcp-server-firstmate.log`; `claude mcp list` shows whether Claude Code connected. + +## Verification + +`tests/fm-mcp.test.sh` drives every tool over the real stdio protocol against a temporary home and firstmate's real scripts, and asserts that the note is the only write: the read tools leave the whole home byte-identical. diff --git a/docs/scripts.md b/docs/scripts.md index 8d656ec203e..f21f696e817 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -159,6 +159,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-public-followup-emit.sh` | Validate and report one typed terminal work result into its owning home, or stage it when that home is remote | | `fm-public-followup-collect.sh` | Read and retire the typed terminal results a remote work home staged for the home that owes the public reply | | `fm-inbox.sh` | The captain's out-of-band capture surface: queue a note (optionally idempotent by request id), announce or repair its wake, record a durable primary reply, and emit bounded receipts and primary-readiness JSON | +| `fm-mcp.py` | Local stdio MCP server for Claude Desktop and Claude Code: reads fleet state and queues inbox notes, never acts itself ([mcp.md](mcp.md)) | | `fm-mail.sh` | General-purpose mail plane: read unseen IMAP mail, send one SMTP message, or surface new mail as a `check` wake via `poll` (configuration in the home's gitignored `.env`) | | `fm-mail.py` | The IMAP/SMTP engine behind `fm-mail.sh` | | `fm-mail-check.sh` | Standing received-mail poll: `arm` registers a watcher check that runs `fm-mail.sh poll` on the watcher cadence (new mail still wakes via the poll; the check's own line also wakes unless the poll is a proven no-op), `disarm` removes it | diff --git a/tests/fm-mcp.test.sh b/tests/fm-mcp.test.sh new file mode 100755 index 00000000000..ac0176f0abd --- /dev/null +++ b/tests/fm-mcp.test.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# tests/fm-mcp.test.sh - the MCP server (bin/fm-mcp.py) over its real stdio protocol. +# +# Every case speaks newline-delimited JSON-RPC to the server exactly as Claude +# Desktop or Claude Code would, against a temporary fake FM_HOME, and the server +# runs firstmate's real scripts. The property worth protecting is the authority +# boundary: firstmate_send_note is the only tool that writes, and it writes only +# an inbox note that wakes firstmate. Every other tool is a read. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found"; exit 0; } +command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found"; exit 0; } + +TMP_ROOT=$(fm_test_tmproot fm-mcp) +H="$TMP_ROOT/home" +mkdir -p "$H/state" "$H/data/demo" +cat >"$H/data/backlog.md" <<'EOF' +# Backlog + +## In flight +- [ ] demo - Demo task for the MCP tests (repo: demo) (kind: ship) (since 2026-09-30) + Demo notes body. + +## Queued +## Done +EOF +printf '# demo report\nall good\n' >"$H/data/demo/report.md" +printf 'kind=ship\nmode=local-only\nbranch=fm/demo\n' >"$H/state/demo.meta" +printf 'done [at=1790767000]: ready in branch fm/demo\n' >"$H/state/demo.status" +printf '{"schema":"fm-secondmate-home-summary.v1","active_children":[]}\n' >"$H/state/home-summary.json" +export FM_HOME="$H" FM_CREW_STATE_NO_FORGE=1 + +# rpc ... : one server session, one response per line on stdout. +rpc() { + printf '%s\n' "$@" | python3 "$ROOT/bin/fm-mcp.py" +} + +# jq_py : evaluate against the single JSON value on stdin. +jq_py() { + python3 -c 'import json,sys; r=json.load(sys.stdin); v=eval(sys.argv[1]); print(v if isinstance(v,str) else json.dumps(v))' "$1" +} + +# call [arguments-json] : prints isError on line 1, the text after it. +call() { + rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":${2:-"{}"}}}" | + jq_py '"%s\n%s" % (json.dumps(r["result"]["isError"]), r["result"]["content"][0]["text"])' +} + +ok_text() { #