Skip to content

Make serve --mcp-stdio a transportless daemon client (fixes WhatsApp/Signal MCP fratricide)#149

Merged
MaxGhenis merged 3 commits into
mainfrom
claude/serene-driscoll-59987f
Jul 21, 2026
Merged

Make serve --mcp-stdio a transportless daemon client (fixes WhatsApp/Signal MCP fratricide)#149
MaxGhenis merged 3 commits into
mainfrom
claude/serene-driscoll-59987f

Conversation

@MaxGhenis

Copy link
Copy Markdown
Owner

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 device by 20:41:07). This also explains the 2026-07-13 WhatsApp logout and Signal's needs_reauth death. instance.lock never applied — only backup/migrate honor 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:

  • Zero transport supervisors, zero v2 dispatcher/ingest, zero sync loops, zero legacy scheduler, zero telemetry. All of those are daemon-owned (the scheduler alone would double-send every due message from a second process).
  • Reads stay local and WAL-safe. Startup probes /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 like openmessage read (all CLI fail-fast diagnostics preserved). An unset OPENMESSAGES_DATA_DIR adopts the daemon's reported dir, killing the two-data-dirs trap for bare spawns.
  • Sends, reactions, and get_status route through the daemon's local HTTP API via the new internal/localapi client: /api/v1/outbox on 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/react on legacy daemons. App closed → actionable "start the OpenMessage app" error; the client never falls back to opening its own connections.
  • cmd/send_outbox.go now shares internal/localapi (CLI behavior unchanged — routing-cell tests pass verbatim).
  • Escape hatches: --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-stdio without --web leaves zero transport state (no whatsmeow store, no signal-cli config, no v2 dir), with a --transports contrast 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.
  • Daemon-routed tool tests: confirmed/deterministic-rejection/ambiguous/legacy-daemon/daemon-down cells, group-send refusal, react routing, status daemon-truth + app-closed reports.
  • internal/localapi unit tests: reachability semantics, token attach, streaming multipart, delivery polling, error classification.
  • Existing suites pass unchanged (go test ./... and -race on 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_conversations serving daemon truth and current v2 messages through the actual protocol.

Docs

docs/agent-runbook.md gains an "MCP serving — exactly one process may own live transports" section (failure mode, client-mode semantics, safe ~/.mcp.json recipe); CLAUDE.md summarizes the serving modes.

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits July 20, 2026 21:37
…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
MaxGhenis merged commit acbd527 into main Jul 21, 2026
5 checks passed
@MaxGhenis
MaxGhenis deleted the claude/serene-driscoll-59987f branch July 21, 2026 08:46
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]>
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