Skip to content

fix(mcp): send threaded replies via WebSocket to prevent self-mention echo#115

Merged
tlongwell-block merged 3 commits intomainfrom
fix/self-mention-echo
Mar 19, 2026
Merged

fix(mcp): send threaded replies via WebSocket to prevent self-mention echo#115
tlongwell-block merged 3 commits intomainfrom
fix/self-mention-echo

Conversation

@tlongwell-block
Copy link
Collaborator

@tlongwell-block tlongwell-block commented Mar 19, 2026

Problem

When a bot replies in a thread, it receives its own reply back as a new @mention, creating an echo loop.

Root cause chain:

Bot replies in thread
    │
    ▼
sprout-mcp: send_message(parent_event_id=...)
    │
    ▼  REST path (threaded replies went through REST)
POST /api/channels/{ch}/messages
    │
    ▼
sprout-relay: messages.rs
  ├─ sign_with_keys(relay_keypair)  →  event.pubkey = RELAY_PUBKEY
  └─ tags: ["p", BOT_PUBKEY]       →  attribution tag (always added)
    │
    ▼
fan_out → delivers to bot's #p=[bot_pubkey] subscription
    │
    ▼
sprout-acp: ignore_self check
  event.pubkey (RELAY) != bot_pubkey  →  check FAILS
    │
    ▼
Bot processes its own reply as a new mention  →  ECHO 🔁

Top-level messages didn't echo because MCP sent them via WebSocket, signed by the bot's own key, so ignore_self caught them.

Fix

Unify send_message to use WebSocket for all messages — top-level and replies. The bot signs every event with its own key, so ignore_self works universally.

Before:  send_message → top-level? WS (bot signs) : REST (relay signs) → echo
After:   send_message → always WS (bot signs) → ignore_self works ✓

What changed (crates/sprout-mcp/src/server.rs)

  1. Renamed build_top_level_message_tagsbuild_message_tags — same logic, now used for all messages
  2. Added find_root_from_tags — pure function that parses a parent event's NIP-10 tags to find the thread root. Returns root.or(reply) (prefers explicit "root" marker, falls back to "reply" target which IS the root for direct replies)
  3. Added build_reply_tags — async method that fetches the parent event via GET /api/events/{id}, parses its tags, and constructs NIP-10 e tags:
    • Direct reply (parent is top-level): ["e", parent, "", "reply"]
    • Nested reply (parent is a reply): ["e", root, "", "root"] + ["e", parent, "", "reply"]
  4. Removed the REST branch from send_message — all messages go through WebSocket
  5. Added ["broadcast", "1"] tag when broadcast_to_channel=true on replies
  6. 16 new unit tests (87 total pass)

What this keeps

  • All existing validation (UUID, content size, parent_event_id format, mention_pubkeys format)
  • normalize_mention_pubkeys (self-mention filtering for explicit mentions)
  • mention_pubkeys and kind override support on replies
  • Thread metadata resolution (now handled by the relay's WS EVENT handler, which already does this)

What this removes

  • The POST /api/channels/{ch}/messages call from send_message
  • The if p.parent_event_id.is_none() branch split
  • The JSON body construction for REST replies

Not in scope

  • send_diff_message (kind:40008) — always uses REST, even for top-level diffs. Separate echo vector, separate fix.
  • FilterContext::from_event in filter.rsauthor field uses raw event.pubkey, wrong for relay-signed events. Lower priority.

Testing

Automated

  • 87 unit tests pass (16 new) — cargo test -p sprout-mcp
  • 51 REST API integration tests pass — e2e_rest_api
  • 27 WebSocket relay integration tests pass — e2e_relay
  • cargo clippy clean, cargo fmt clean

Live E2E with ACP harnesses

Three agents (Alice, Bob, Charlie) running via sprout-acp harness against a live relay, exercising the actual echo-fix path:

Agent Thread reply pubkey Bot-signed? NIP-10 tags Echo?
Alice b848ceb6... ✅ Alice's own key ["e", root, "", "reply"] No — 9 log lines (startup only)
Bob 1f821603... ✅ Bob's own key ["e", root, "", "reply"] No — 9 log lines (startup only)
Charlie 25623e53... ✅ Charlie's own key ["e", root, "", "reply"] No — 9 log lines (startup only)

Thread metadata: 3 replies, 3 participants, correct ancestry. Zero echo across all agents.

Crossfire review

Reviewer Score Verdict
Codex (GPT-5.4) 9/10 APPROVE
Opus 9/10 APPROVE
Gemini 3.1 Pro 7/10 → fixed Found real nested-reply bug (now fixed)

Additional fixes (in this PR)

crates/sprout-test-client/tests/e2e_mcp.rs

  • spawn_mcp_server now sets SPROUT_TOOLSETS=all (was missing → only 25/42 tools available)
  • spawn_mcp_server now removes SPROUT_API_TOKEN from subprocess env (stale token from .env caused NIP-42 auth failures against fresh DBs)
  • Tool count assertion updated 43 → 42 (matches ALL_TOOLS)

crates/sprout-test-client/src/bin/mention.rs

  • Added rustls::crypto::ring::default_provider().install_default()tokio-tungstenite pulls in rustls but no CryptoProvider was installed, causing a panic even for plain ws:// connections

… echo

Unify send_message to use WebSocket for all messages. Previously, threaded
replies went through REST, causing the relay to sign them with its own key.
This made ignore_self fail, and the bot would receive its own reply as a new
@mention.
…ear stale API token, correct tool count

spawn_mcp_server now:
- Sets SPROUT_TOOLSETS=all so all 42 tools are available
- Removes SPROUT_API_TOKEN to prevent stale tokens from .env
  causing NIP-42 auth failures against a fresh database
- Tool count assertion updated from 43 to 42 (matches ALL_TOOLS)
tokio-tungstenite pulls in rustls but no CryptoProvider is installed
at process startup, causing a panic even for plain ws:// connections.
Add rustls as a direct dependency and call install_default() in main.
@tlongwell-block tlongwell-block merged commit efed530 into main Mar 19, 2026
8 checks passed
@tlongwell-block tlongwell-block deleted the fix/self-mention-echo branch March 19, 2026 13:51
tlongwell-block added a commit that referenced this pull request Mar 19, 2026
…postgres

* origin/main:
  fix(mcp): send threaded replies via WebSocket to prevent self-mention echo (#115)
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