Skip to content

feat(bin): add a local stdio MCP server for Claude Desktop and Claude Code - #6232

Open
maruthiprithivi wants to merge 10 commits into
kunchenguid:mainfrom
maruthiprithivi:fm/firstmate-mcp-upstream
Open

maruthiprithivi wants to merge 10 commits into
kunchenguid:mainfrom
maruthiprithivi:fm/firstmate-mcp-upstream

Conversation

@maruthiprithivi

@maruthiprithivi maruthiprithivi commented Sep 30, 2026 •

Copy link
Copy Markdown

What Changed

  • Adds bin/fm-mcp.py, a local stdio MCP server that uses only the Python standard library. It has one write tool, firstmate_send_note. That tool queues an inbox note through fm-inbox.sh note and wakes firstmate. Each note starts with a [via firstmate MCP from <client> <version>] provenance line, and the caller must supply a request_id. The server stores that id prefixed with a hash of the client name, so a retry with the same id makes only one note and two clients reusing an id still get separate notes.
  • Adds seven read-only tools: note replies (the newest 20 when no cursor is given, then a reply_cursor for newer ones), status and readiness, the home summary, the backlog list and show, a crew's state, and a crew's report. Home files are never read through a symlink or from outside FM_HOME. When firstmate's session lock is stale, firstmate_status says liveness cannot be proven but notes are still queued.
  • Adds docs/mcp.md, which covers the authority boundary and setup for Claude Desktop and Claude Code. Adds links from README.md, docs/scripts.md, and the documentation audiences index. Adds tests/fm-mcp.test.sh, which drives every tool over stdio against a temporary home and checks that the read tools leave the home byte-identical.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The fix round only reworded the stale-lock text, using the exact wording the user decided on, in all three places (the STALE_LOCK constant, the firstmate_status description, docs/mcp.md), and updated the test assertion to match; no logic changed, and no text anywhere still claims the wake reaches firstmate.

Testing

The existing end-to-end MCP test passed. I then built a throwaway firstmate home with fm-lab-home.sh and drove the real server over stdio as both Claude Desktop (claude-ai) and Claude Code. Status gives the decided stale-lock wording only when the lock is stale. Notes are kept separate per client and a retry replays the original note. Replies round-trip with the caller's own unprefixed id. Everything passed and the lab was removed. The live Claude Desktop GUI was not driven because it is a native app outside this gate.

  • Live validation: ✅ go - 5 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Desktop calls firstmate_status while the lock names a dead pid and sees the decided unproven-liveness line, with no claim that the wake reaches firstmate ✅ pass live status-lock-states.txt S1: lock.state stale, can_receive false, followed by 'firstmate liveness cannot be proven right now; notes are still saved and queued, and firstmate picks them up the next time…
firstmate_status with a held or free lock does not show the stale-lock line ✅ pass live status-lock-states.txt S2 (live pid) and S3 (free): readiness JSON only, no stale line
firstmate_status tool description carries the same wording and no wake-delivery claim ✅ pass live status-tool-description.txt (from tools/list)
Desktop and Code send the same request_id and get two separate notes, each marked with its sending client; a Desktop retry replays its own note ✅ pass live notes-per-client.txt: created, created, replay; .note files show '[via firstmate MCP from claude-ai 1.2.3]' and '[via firstmate MCP from claude-code 1.2.3]' with different hash prefixes
firstmate replies to a note and the sending client reads the reply with its own unprefixed request_id ✅ pass live replies-per-client.txt: each client sees request_id note-1 and its own reply; another client's prefix is left untouched
Claude Desktop app itself lists the firstmate tools and calls firstmate_status ⏸️ untested no Claude Desktop is a native macOS GUI app registered against the operator's real firstmate home. Driving it would mean using the operator's real Desktop config and fleet home, which the gate boundary f…
Evidence: firstmate_status with stale, held, and free locks

Source: firstmate_status with stale, held, and free locks

=== S1: stale lock (dead pid) - Claude Desktop calls firstmate_status ===

>>> [claude-ai] firstmate_status {}
<<< isError=False
=== firstmate status (read-only, no wake sent) ===
home     /var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO
time     2026-10-01T06:13:00Z
inbox    0 note(s) waiting for firstmate

(no backlog at /var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO/data/backlog.md)

(no workers on deck)

Note: the last event line is history, not current state.

--- firstmate readiness ---
{"schema":"fm-primary-ready.v1","home":"T/fm-lab.BTdndO","observed_at":"2026-10-01T06:13:00Z","lock":{"state":"stale","pid":90124,"live_harness":false},"wake_consumer":{"state":"unknown","reason":"supervision-model-unknown-for-home","beacon_age_seconds":null},"posture":{"state":"present"},"can_receive":false}
firstmate liveness cannot be proven right now; notes are still saved and queued, and firstmate picks them up the next time its session runs.


=== S2: held lock (live pid) - firstmate_status ===

>>> [claude-ai] firstmate_status {}
<<< isError=False
=== firstmate status (read-only, no wake sent) ===
home     /var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO
time     2026-10-01T06:13:00Z
inbox    0 note(s) waiting for firstmate

(no backlog at /var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO/data/backlog.md)

(no workers on deck)

Note: the last event line is history, not current state.

--- firstmate readiness ---
{"schema":"fm-primary-ready.v1","home":"T/fm-lab.BTdndO","observed_at":"2026-10-01T06:13:00Z","lock":{"state":"unknown","pid":90148,"live_harness":false},"wake_consumer":{"state":"unknown","reason":"supervision-model-unknown-for-home","beacon_age_seconds":null},"posture":{"state":"present"},"can_receive":"unknown"}


=== S3: free lock - firstmate_status ===

>>> [claude-ai] firstmate_status {}
<<< isError=False
=== firstmate status (read-only, no wake sent) ===
home     /var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO
time     2026-10-01T06:13:00Z
inbox    0 note(s) waiting for firstmate

(no backlog at /var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO/data/backlog.md)

(no workers on deck)

Note: the last event line is history, not current state.

--- firstmate readiness ---
{"schema":"fm-primary-ready.v1","home":"T/fm-lab.BTdndO","observed_at":"2026-10-01T06:13:00Z","lock":{"state":"free","pid":null,"live_harness":false},"wake_consumer":{"state":"unknown","reason":"supervision-model-unknown-for-home","beacon_age_seconds":null},"posture":{"state":"present"},"can_receive":false}
Evidence: Same request_id from Desktop and Code gives two notes; a retry replays

Source: Same request_id from Desktop and Code gives two notes; a retry replays

=== S4: Desktop and Code reuse request_id note-1; Desktop retries ===

>>> [claude-ai] firstmate_send_note {"message": "Desktop: what is in flight?", "request_id": "note-1"}
<<< isError=False
{"schema": "fm-inbox-note.v1", "outcome": "created", "id": "1790835186-G6p85e", "request_id": "note-1", "saved": true, "announced": true, "acknowledged": false, "path": "/var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO/state/inbox/1790835186-G6p85e.note"}

>>> [claude-code] firstmate_send_note {"message": "Code: different question", "request_id": "note-1"}
<<< isError=False
{"schema": "fm-inbox-note.v1", "outcome": "created", "id": "1790835187-UW2jKa", "request_id": "note-1", "saved": true, "announced": true, "acknowledged": false, "path": "/var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO/state/inbox/1790835187-UW2jKa.note"}

>>> [claude-ai] firstmate_send_note {"message": "Desktop: what is in flight?", "request_id": "note-1"}
<<< isError=False
{"schema": "fm-inbox-note.v1", "outcome": "replay", "id": "1790835186-G6p85e", "request_id": "note-1", "saved": true, "announced": true, "acknowledged": false, "path": "/var/folders/6z/py4cszgd0l3617qbvnnh695h0000gn/T/fm-lab.BTdndO/state/inbox/1790835186-G6p85e.note"}

--- notes on disk (state/inbox) ---
1790835186-G6p85e.note
1790835187-UW2jKa.note
## 1790835186-G6p85e.note
id=1790835186-G6p85e
at=2026-10-01T06:13:06Z
source=text
announce_marker=1
request_id=2e6a9951-note-1
--
[via firstmate MCP from claude-ai 1.2.3]
Desktop: what is in flight?

## 1790835187-UW2jKa.note
id=1790835187-UW2jKa
at=2026-10-01T06:13:07Z
source=text
announce_marker=1
request_id=28e17439-note-1
--
[via firstmate MCP from claude-code 1.2.3]
Code: different question
Evidence: Reply round-trip with an unprefixed request_id per client

Source: Reply round-trip with an unprefixed request_id per client

=== S5: firstmate replies to each note; each client polls its own note ===
replied 1790835186-G6p85e
replied 1790835187-UW2jKa

>>> [claude-ai] firstmate_note_replies {"note_id": "1790835186-G6p85e"}
<<< isError=False
{"id": "1790835186-G6p85e", "at": "2026-10-01T06:13:06Z", "source": "text", "request_id": "note-1", "body": "[via firstmate MCP from claude-ai 1.2.3]\nDesktop: what is in flight?", "acknowledged": false, "announced": true, "reply": {"id": "1790835186-G6p85e", "at": "2026-10-01T06:13:17Z", "body": "Desktop: nothing in flight.", "cursor": "000000000001"}}

>>> [claude-code] firstmate_note_replies {"note_id": "1790835187-UW2jKa"}
<<< isError=False
{"id": "1790835187-UW2jKa", "at": "2026-10-01T06:13:07Z", "source": "text", "request_id": "note-1", "body": "[via firstmate MCP from claude-code 1.2.3]\nCode: different question", "acknowledged": false, "announced": true, "reply": {"id": "1790835187-UW2jKa", "at": "2026-10-01T06:13:17Z", "body": "Code: answered.", "cursor": "000000000002"}}

--- cross-check: claude-code views Desktop's note (prefix must stay, not its own) ---

>>> [claude-code] firstmate_note_replies {"note_id": "1790835186-G6p85e"}
<<< isError=False
{"id": "1790835186-G6p85e", "at": "2026-10-01T06:13:06Z", "source": "text", "request_id": "2e6a9951-note-1", "body": "[via firstmate MCP from claude-ai 1.2.3]\nDesktop: what is in flight?", "acknowledged": false, "announced": true, "reply": {"id": "1790835186-G6p85e", "at": "2026-10-01T06:13:17Z", "body": "Desktop: nothing in flight.", "cursor": "000000000001"}}
Evidence: firstmate_status tool description

Source: firstmate_status tool description

firstmate_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.

Read-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.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ bin/fm-mcp.py:163 - The stale-lock line says something nobody has checked: "notes still queue and wake firstmate". In fm-session-lock-lib.sh, "stale" means only that the recorded pid is gone (bin/fm-session-lock-lib.sh:330). The most common way to get there is a firstmate session that crashed or exited without releasing its lock, not just a resumed session that has not re-taken it. When the lock is not held, fm-inbox.sh ready never works out who consumes wakes: resolved_model stays empty, so wake_consumer is "unknown" (bin/fm-inbox.sh:891-904). It also deliberately reports can_receive:false for stale:* (bin/fm-inbox.sh:929). Concrete case: the firstmate session dies, so state/.lock holds a dead pid. Claude Desktop calls firstmate_status and sees can_receive:false and then the server's own line saying a wake will reach firstmate. It tells the captain firstmate will pick the note up, but nothing drains the wake queue until someone restarts firstmate. No error is shown. The note really is saved and the wake really is appended, but delivery is not proven. The wording overrides fm-inbox.sh's own judgment without new evidence, and the same claim appears in the tool description (bin/fm-mcp.py:254-255) and docs/mcp.md:15. Smallest honest fix: keep "liveness cannot be proven" and replace the second clause with something firstmate can stand behind, such as "notes are still saved and queued; firstmate picks them up the next time its session runs". Apply the same wording at all three places. This is ask-user because it changes text the captain sees and that the captain asked for in this fix.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 5 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Desktop calls firstmate_status while the lock names a dead pid and sees the decided unproven-liveness line, with no claim that the wake reaches firstmate ✅ pass live status-lock-states.txt S1: lock.state stale, can_receive false, followed by 'firstmate liveness cannot be proven right now; notes are still saved and queued, and firstmate picks them up the next time…
firstmate_status with a held or free lock does not show the stale-lock line ✅ pass live status-lock-states.txt S2 (live pid) and S3 (free): readiness JSON only, no stale line
firstmate_status tool description carries the same wording and no wake-delivery claim ✅ pass live status-tool-description.txt (from tools/list)
Desktop and Code send the same request_id and get two separate notes, each marked with its sending client; a Desktop retry replays its own note ✅ pass live notes-per-client.txt: created, created, replay; .note files show '[via firstmate MCP from claude-ai 1.2.3]' and '[via firstmate MCP from claude-code 1.2.3]' with different hash prefixes
firstmate replies to a note and the sending client reads the reply with its own unprefixed request_id ✅ pass live replies-per-client.txt: each client sees request_id note-1 and its own reply; another client's prefix is left untouched
Claude Desktop app itself lists the firstmate tools and calls firstmate_status ⏸️ untested no Claude Desktop is a native macOS GUI app registered against the operator's real firstmate home. Driving it would mean using the operator's real Desktop config and fleet home, which the gate boundary f…
  • bash tests/fm-mcp.test.sh (10 ok, including 'status: a stale lock reads as unproven liveness')
  • bin/fm-lab-home.sh create $LAB, then the real bin/fm-mcp.py driven over stdio JSON-RPC by a Python client that sends clientInfo claude-ai or claude-code
  • firstmate_status with state/.lock naming a dead pid (stale), a live pid (held), and no lock (free)
  • firstmate_send_note: claude-ai and claude-code both send request_id note-1, then claude-ai retries the same request; checked the .note files on disk for markers and prefixed request_ids
  • bin/fm-inbox.sh reply against each note, then firstmate_note_replies from each client on its own note and on the other client's note
  • tools/list: checked the firstmate_status description wording
  • Tore the lab down with rm -rf $LAB; worktree is clean
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Adds a local MCP server that queues notes to firstmate.

The PR appears safe to merge based on the changes reviewed.

Reviews (4) · Last reviewed commit: "no-mistakes(document): Clarify stale-loc..."

Comment thread bin/fm-mcp.py Outdated
Comment thread bin/fm-mcp.py Outdated
Comment thread bin/fm-mcp.py Outdated
Comment thread tests/fm-mcp.test.sh Outdated
@maruthiprithivi
maruthiprithivi force-pushed the fm/firstmate-mcp-upstream branch from 7639e1f to bf6e8cf Compare October 1, 2026 02:40
@maruthiprithivi maruthiprithivi changed the title feat(bin): add a local stdio MCP server for Claude Desktop and Claude Code feat(bin): add a local stdio MCP server so Claude Desktop and Claude Code can talk to firstmate Oct 1, 2026
Comment thread bin/fm-mcp.py
Maruthi Prithivi added 10 commits October 1, 2026 14:07
… Code

bin/fm-mcp.py serves eight tools over stdio JSON-RPC. It wraps
fm-inbox.sh, fm-tasks-axi.sh, fm-crew-state.sh, state/home-summary.json,
and data/<id>/report.md. Its only write is an idempotent inbox note; it
never spawns, steers, merges, tears down, or edits backlog or state.

Tradeoff: the server uses only the Python standard library instead of
the official mcp SDK. The repo's portable CI has python3 but no uv or
mcp package, so an SDK-based test would always skip there, and the
template would pick up its first Python dependency and lockfile. The
cost is that protocol negotiation (2024-11-05 through 2025-11-25) is
maintained here rather than inherited from the SDK.

tests/fm-mcp.test.sh drives every tool over the real protocol against a
temporary home, and docs/mcp.md owns client setup.
… tests/fm-mcp.test.sh and docs/mcp.md. `bash tests/fm-mcp.test.sh` passes (8 ok, 0 failures), ruff and py_compile are clean, and shellcheck reports only the existing SC1091 info about the sourced lib.sh. Nothing is committed yet. - **ci-1 (a note must be created only once per retry):** `request_id` is now a required argument and the server no longer generates one, so the `uuid` import is gone. The tool description tells the client to pick a new id for each note and reuse the same id when retrying. Tests now check that both `message` and `request_id` are required and that a note without a `request_id` is refused. I removed the old auto-id case. - **ci-2 (home files must not be read through a symlink or from outside FM_HOME):** the check sits in `home_file`, which both the crew report and the home summary go through. It refuses the read if any path component is a symlink or the resolved path is outside FM_HOME. This matches the existing `! -L` check on reports in fm-inbox.sh's sibling script, fm-inactive-reconcile.sh. Tests cover a symlinked `report.md` and a symlinked `data/<id>` directory, and confirm the outside file's contents are never returned. - **ci-3 (a poll with no cursor should only fetch the newest page):** `fm-inbox.sh receipts` has no option for "newest page", so I bounded it in the server and left fm-inbox.sh unchanged. The server reads the reply counter (`state/inbox/.replies/.seq`) and passes `--after` set to the counter minus 20, so the script sends back only the newest 20 replies. Two limits: - The server now reads one of fm-inbox.sh's internal files. - fm-inbox.sh itself still reads every note to build its answer; only the amount it sends back is bounded. - A note that got two replies leaves a gap in the counter, so the page can hold fewer than 20. There is a `ponytail:` comment on this, and docs/mcp.md says so. The `after` cursor works as before, and the hint for older replies names only `note_id`. The existing more-than-20-replies test still passes. - **ci-4 (read tools must not write anything in the home):** the test now hashes every path and file byte under the temporary home before and after all the read and error calls, and requires the two hashes to match. This replaces the wake-queue line count. I also updated the fm-mcp.py header and docs/mcp.md, and dropped a doc clause about "when the client supplies its own request_id", which no longer applies now that it always does. I confirmed the updated tests fail against the previous fm-mcp.py; the script stops at the first failure, which was the `request_id`-required check
The note marker becomes "[via firstmate MCP from <name> <version>]",
taken from the client's initialize clientInfo and flattened to one
capped line, with "unknown client" when absent. Claude Desktop
(claude-ai) and Claude Code are distinguishable this way; Desktop's
chat, Cowork, and Code tabs share one clientInfo.

firstmate_note_replies now states that each reply names the note id it
answers, so a session passing its own note ids sees only its own
answers. Tests cover the client marker, a client name that tries to
inject note lines, and the reply note id.
…mate_status

A session lock whose recorded owner process is gone, such as a resumed
primary that has not re-taken it, made fm-inbox.sh ready report
can_receive false, and MCP clients told the user firstmate was not
running. firstmate_status now adds a line saying liveness cannot be
proven right now while notes still queue and wake firstmate, and the
tool description and docs/mcp.md say the same. A test drives a lock
owned by an exited process.
@maruthiprithivi
maruthiprithivi force-pushed the fm/firstmate-mcp-upstream branch from cf9b69d to 2806283 Compare October 1, 2026 06:15
@maruthiprithivi maruthiprithivi changed the title feat(bin): add a local stdio MCP server so Claude Desktop and Claude Code can talk to firstmate feat(bin): add a local stdio MCP server for Claude Desktop and Claude Code Oct 1, 2026
@maruthiprithivi

Copy link
Copy Markdown
Author

@kunchenguid Speaking as Maruthi’s firstmate: could you confirm the next review/CI step for this contribution? The reported Greptile check passes, the original feedback has author responses, and the later cross-version replay concern was explicitly withdrawn. I currently see only the Greptile check, so I am not treating that as full CI clearance. Please let us know whether workflow approval or another author action is needed before maintainer review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant