Make serve --mcp-stdio a transportless daemon client (fixes WhatsApp/Signal MCP fratricide)#149
Merged
Merged
Conversation
…gnal fratricide) serve --mcp-stdio (the shape MCP hosts spawn per Claude session) used to start the full Google/WhatsApp/Signal transport stack gated only on !isDemo. Every MCP process connected with the same WhatsApp device credentials and signal-cli account as the running app daemon; WhatsApp treats that as a second device login and kills the session (empirically: pairing at 20:40:41 dead with "401: logged out from another device" by 20:41:07 after two MCP spawns on 2026-07-20), and concurrent signal-cli pollers corrupt/deauth Signal. The MCP-stdio-only shape now runs as a transportless client: - Zero transport supervisors, v2 dispatcher/ingest, sync loops, legacy scheduler, or telemetry — the app daemon owns all of those. - Reads stay local. Startup probes the daemon (daemon truth, like the PR #140 CLI send path): a daemon reporting v2-primary for the same data dir routes reads at the v2 store; otherwise OPENMESSAGES_V2_* env decides, preserving the CLI fail-fast diagnostics. An unset OPENMESSAGES_DATA_DIR adopts the daemon's reported data dir. - Sends, reactions, and get_status route through the daemon's local HTTP API via the new internal/localapi client (v1 outbox on v2 daemons with the durable idempotency contract, /api/send + /api/react on legacy daemons). With the app closed, send tools return an actionable error instead of ever opening their own connections. - cmd/send_outbox.go now shares the same localapi client (CLI behavior unchanged; routing-cell tests pass verbatim). - --transports restores the standalone full-stack stdio behavior for installs where the MCP process is the only OpenMessage process; --no-transports strips daemon subsystems from any other shape. Regression coverage: TestRunServeMCPStdioStartsZeroTransportSupervisors (with a --transports contrast case proving the assertion bites), TestBuiltBinaryMCPStdioClientShapeStartsNoTransports at the compiled- binary level, daemon-truth read-mode adoption, daemon-routed tool tests (confirmed/rejected/ambiguous/legacy/down), and localapi unit tests. Verified live: a real MCP handshake against the running app's data dir held for 20+ seconds with WhatsApp staying connected, and v2-primary reads served current messages. docs/agent-runbook.md documents the failure mode and the safe ~/.mcp.json recipe. Co-Authored-By: Claude Fable 5 <[email protected]>
…gits findDirectSMSConversation matched the target's 10-digit suffix as a substring of the digits of the whole participants JSON, so a sequence straddling two different numbers could resolve — and route a send — to the wrong conversation. Parse the participants and compare each number individually by digit suffix, with a regression test whose fixture reproduces the straddle against the old algorithm. Co-Authored-By: Claude Fable 5 <[email protected]>
The review verdict was approve-with-nits (no blocking bugs). Three items were worth fixing before merge: - agent-runbook.md overpromised --no-transports: on a v2-primary install a --web --no-transports process refuses to start (the v2 read path needs the dispatcher stack). Document the fail-closed behavior instead. - get_status in MCP client mode reported any status failure as "app NOT RUNNING", including an answering-but-erroring daemon (e.g. rejected control token). Distinguish reachable failures so agents aren't told the app is closed when it isn't. - The daemon data-dir adoption branch (env-less spawn adopts the running app's data dir) had no direct test; add one. Co-Authored-By: Claude Fable 5 <[email protected]>
MaxGhenis
added a commit
that referenced
this pull request
Jul 21, 2026
MCP hosts spawn one serve --mcp-stdio process per Claude session, and PR #149's runServeMCPClient opened the store via app.New — which runs the full startup repair-sweep suite (RepairLegacyArtifacts, RepairContentlessRecency, RepairTapbacks, RepairEmptyStubMessages, and the WhatsApp media-placeholder repair) on every open. With dozens of sessions open (25 observed 2026-07-21), the daemon's live messages.db absorbed that many concurrent repair-sweep write bursts at every session start. The adversarial review of #149 flagged this as a follow-up. app.New now delegates to newApp(logger, true) and keeps its behavior byte-for-byte (sweeps then demo seed, same error handling); the sweep block moves verbatim into repairStartupArtifacts. app.NewClient is the repair-free twin: same data-dir resolution, permission hardening, and store open (schema migration included), zero repair writes. Used by: - runServeMCPClient (the per-session MCP client shape) - openCommandReadSource's legacy branch, so the advertised read-only CLI commands (read, status) stop writing to the shared live store Tests: - TestNewClientPerformsNoStoreWrites (internal/app) seeds one canonical trigger row per sweep, snapshots every messages/conversations row plus all other table counts over a read-only connection, and asserts NewClient leaves the store identical; a control leg then proves app.New repairs every seeded row, so the assertion cannot pass vacuously (mutation-tested: NewClient wired to run sweeps fails). - TestRunServeMCPClientDoesNotRepairStore (cmd) runs the real RunServe --mcp-stdio shape to stdin EOF and asserts the trigger row survives, with an app.New contrast leg (called directly — a full daemon RunServe keeps goroutines holding the store briefly past return, racing the verification open). - TestOpenCommandReadSourceLegacyDoesNotRepairStore (cmd) pins the read-only CLI contract. GOWORK=off go test ./cmd ./internal/app ./internal/tools -count=1 green; new tests also green under -race -count=3. Co-Authored-By: Claude Fable 5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
openmessage serve --mcp-stdio— the exact process MCP hosts spawn per Claude session — ran the full transport stack: the Google, WhatsApp, and Signal bridge supervisors auto-started gated only on!isDemo, regardless of serve mode. Each spawned MCP process connected with the same WhatsApp device credentials and signal-cli account as the live macOS app daemon. WhatsApp treats that as a second device login and kills the session; concurrent signal-cli pollers corrupt/deauth Signal.Empirically confirmed 2026-07-20: MCP processes spawned at 20:40:57/20:41:03 killed a WhatsApp pairing that was 16 seconds old (paired 20:40:41,
401: logged out from another deviceby 20:41:07). This also explains the 2026-07-13 WhatsApp logout and Signal'sneeds_reauthdeath.instance.locknever applied — onlybackup/migratehonor it.The fix: MCP client mode (daemon truth)
The MCP-stdio-only shape now runs as a transportless client of the running app, extending PR #140's daemon-truth pattern from the CLI to the whole MCP surface:
/api/status: a daemon reporting v2-primary for the same data dir routes reads at the v2 store; with the daemon down or serving another dir,OPENMESSAGES_V2_*env decides exactly likeopenmessage read(all CLI fail-fast diagnostics preserved). An unsetOPENMESSAGES_DATA_DIRadopts the daemon's reported dir, killing the two-data-dirs trap for bare spawns.get_statusroute through the daemon's local HTTP API via the newinternal/localapiclient:/api/v1/outboxon v2 daemons (same durable do-not-resend idempotency contract as the in-process v2 tools, including non-error ambiguous-outcome results with a replay key),/api/send+/api/reacton legacy daemons. App closed → actionable "start the OpenMessage app" error; the client never falls back to opening its own connections.cmd/send_outbox.gonow sharesinternal/localapi(CLI behavior unchanged — routing-cell tests pass verbatim).--transports(standalone stdio deployments that are the only OpenMessage process) and--no-transports(strip daemon subsystems from any shape).Regression coverage
TestRunServeMCPStdioStartsZeroTransportSupervisors— the requested regression test:serve --mcp-stdiowithout--webleaves zero transport state (no whatsmeow store, no signal-cli config, no v2 dir), with a--transportscontrast case proving the assertion isn't vacuous.TestBuiltBinaryMCPStdioClientShapeStartsNoTransports— same assertion against the compiled binary.TestRunServeMCPClientAdoptsDaemonTruth— v2-primary read adoption from a fake daemon, no dispatcher state provisioned.internal/localapiunit tests: reachability semantics, token attach, streaming multipart, delivery polling, error classification.go test ./...and-raceon changed packages). Existing tests that intentionally pinned the old behavior were updated: the corrupt-store refusal now pins both shapes, and the v2-ingest binary test uses--transports(the client shape must never provision the stack).Verified live
Against the running macOS app: a real MCP handshake (initialize → tools/list → tool calls) on the app's data dir held for 20+ seconds — the old code killed WhatsApp in ~25s — with the daemon's WhatsApp staying connected (0 days behind) and
get_status/list_conversationsserving daemon truth and current v2 messages through the actual protocol.Docs
docs/agent-runbook.mdgains an "MCP serving — exactly one process may own live transports" section (failure mode, client-mode semantics, safe~/.mcp.jsonrecipe); CLAUDE.md summarizes the serving modes.🤖 Generated with Claude Code