From cc2b3c10b2dbc5b051ed464613a585d25cdff0bc Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 4 Jun 2026 14:07:00 -0400
Subject: [PATCH 1/7] chore: remove sprout-mcp, add sprout-relay-client, fill
 CLI parity gaps
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

sprout-mcp has been fully replaced by sprout-cli for all three external
agents (Goose, Claude Code, Codex) — the desktop already set
SPROUT_ACP_MCP_COMMAND="" for those providers. This removes the dead
crate, cleans up every reference, and fills the remaining CLI parity
gaps so no MCP capability is lost.

- New `sprout-relay-client` crate extracts the WebSocket client from
  sprout-mcp so sprout-test-client no longer depends on the removed crate
- ACP default for `--mcp-command` changes from `"sprout-mcp-server"` to `""`
- `build_mcp_servers()` derives server name from the command basename
  instead of hardcoding `"sprout-mcp"`
- CLI gains: `dms hide`, `channels set-add-policy`, `--inputs` on
  workflow trigger, `--depth-limit` on messages thread, `--types` on
  feed get, `--before-id` on social notes
- All `"sprout-mcp-server"` fallbacks removed from desktop backend and
  frontend; `DEFAULT_MCP_COMMAND` constant removed
- sprout-mcp removed from Justfile, CI cross-compile, release builds,
  `bundle-sidecars.sh`, and `tauri.conf.json` externalBin
- `reconcile_mcp_commands_in_file` migration kept for upgrade compat
---
 .github/workflows/ci.yml                      |    2 -
 .github/workflows/release.yml                 |    4 +-
 AGENTS.md                                     |   11 +-
 ARCHITECTURE.md                               |   43 +-
 CONTRIBUTING.md                               |   58 +-
 Cargo.lock                                    |   48 +-
 Cargo.toml                                    |    6 +-
 README.md                                     |    2 +-
 TESTING.md                                    |   10 +-
 crates/git-sign-nostr/Cargo.toml              |    2 +-
 crates/sprout-acp/README.md                   |    4 +-
 crates/sprout-acp/src/acp.rs                  |    8 +-
 crates/sprout-acp/src/config.rs               |    8 +-
 crates/sprout-acp/src/lib.rs                  |   10 +-
 crates/sprout-cli/src/commands/messages.rs    |    3 +-
 crates/sprout-core/Cargo.toml                 |    2 -
 crates/sprout-core/src/presence.rs            |    1 -
 crates/sprout-dev-mcp/src/paths.rs            |    2 +-
 crates/sprout-mcp/Cargo.toml                  |   57 -
 crates/sprout-mcp/src/lib.rs                  |  103 -
 crates/sprout-mcp/src/main.rs                 |  161 -
 crates/sprout-mcp/src/server.rs               | 3319 -----------------
 crates/sprout-mcp/src/toolsets.rs             |  456 ---
 crates/sprout-mcp/src/upload.rs               |  421 ---
 crates/sprout-persona/src/resolve.rs          |    6 +-
 crates/sprout-relay-client/Cargo.toml         |   25 +
 .../src/lib.rs}                               |   11 +-
 crates/sprout-test-client/Cargo.toml          |    2 +-
 crates/sprout-test-client/src/lib.rs          |    8 +-
 crates/sprout-test-client/tests/e2e_mcp.rs    | 1264 -------
 .../src-tauri/src/commands/agent_discovery.rs |    4 +-
 .../src-tauri/src/commands/agent_models.rs    |    8 +-
 desktop/src-tauri/src/commands/agents.rs      |    3 +-
 .../src-tauri/src/managed_agents/discovery.rs |    4 +-
 .../src/managed_agents/relay_mesh.rs          |    2 +-
 .../src-tauri/src/managed_agents/runtime.rs   |    5 +-
 desktop/src-tauri/src/managed_agents/types.rs |    7 +-
 desktop/src-tauri/tauri.conf.json             |    1 -
 desktop/src/features/agents/channelAgents.ts  |    4 +-
 .../features/agents/ui/CreateAgentDialog.tsx  |    8 +-
 .../agents/ui/CreateAgentDialogSections.tsx   |    6 +-
 desktop/src/testing/e2eBridge.ts              |   11 +-
 justfile                                      |   11 +-
 scripts/bundle-sidecars.sh                    |    4 +-
 44 files changed, 127 insertions(+), 6008 deletions(-)
 delete mode 100644 crates/sprout-mcp/Cargo.toml
 delete mode 100644 crates/sprout-mcp/src/lib.rs
 delete mode 100644 crates/sprout-mcp/src/main.rs
 delete mode 100644 crates/sprout-mcp/src/server.rs
 delete mode 100644 crates/sprout-mcp/src/toolsets.rs
 delete mode 100644 crates/sprout-mcp/src/upload.rs
 create mode 100644 crates/sprout-relay-client/Cargo.toml
 rename crates/{sprout-mcp/src/relay_client.rs => sprout-relay-client/src/lib.rs} (99%)
 delete mode 100644 crates/sprout-test-client/tests/e2e_mcp.rs

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c8b4d65ad4..4078ef2d20 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -446,7 +446,6 @@ jobs:
           cross build --release --target "$TARGET" \
             -p sprout-relay \
             -p sprout-acp \
-            -p sprout-mcp \
             -p sprout-agent \
             -p sprout-dev-mcp \
             -p git-credential-nostr \
@@ -474,7 +473,6 @@ jobs:
           TARGET=$(rustc -vV | sed -n 's|host: ||p')
           mkdir -p desktop/src-tauri/binaries
           touch "desktop/src-tauri/binaries/sprout-acp-$TARGET"
-          touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET"
           touch "desktop/src-tauri/binaries/sprout-agent-$TARGET"
           touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET"
           touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3038699dcd..76cb7e6a9b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -72,7 +72,7 @@ jobs:
 
       - name: Build sidecars
         run: |
-          cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli
+          cargo build --release -p sprout-acp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli
           ./scripts/bundle-sidecars.sh
 
       # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
@@ -317,7 +317,7 @@ jobs:
 
       - name: Build sidecars
         run: |
-          cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli
+          cargo build --release -p sprout-acp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli
           ./scripts/bundle-sidecars.sh
 
       - name: Build Linux Tauri app
diff --git a/AGENTS.md b/AGENTS.md
index e67c85d8b6..a81f6382fc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,6 @@ crates/
   sprout-audit        # Hash-chain audit log
   sprout-media        # Blossom/S3 media storage
   # Agent surface
-  sprout-mcp          # MCP server providing AI agent tools (being phased out in favor of the CLI)
   sprout-acp          # ACP harness bridging Sprout events to AI agents
   sprout-agent        # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output)
   sprout-dev-mcp      # Developer MCP server — shell + file-edit tools
@@ -144,12 +143,7 @@ first, then implement handling in the relay.
 **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags.
 Filters and queries must scope to `h` tags when operating within a channel.
 
-**Agent-facing operations go in `sprout-cli`, not `sprout-mcp`**: `sprout-mcp`
-is being phased out. New agent-facing features belong in `sprout-cli` — add a
-subcommand there first, then wire the REST/WebSocket call in `client.rs`. Do
-not add new tools to `sprout-mcp` unless specifically required for backward
-compatibility. `sprout-dev-mcp` (shell + file tools for `sprout-agent`) is
-separate and not being phased out.
+**Agent-facing operations go in `sprout-cli`**: New agent-facing features belong in `sprout-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `sprout-dev-mcp` (shell + file tools for `sprout-agent`) is separate.
 
 **Workflow conditions**: `sprout-workflow` uses
 [evalexpr](https://docs.rs/evalexpr) for condition evaluation. Keep expressions
@@ -163,7 +157,7 @@ check existing reply handlers for the pattern.
 
 ## Agent CLI (`sprout-cli`)
 
-`sprout` is the agent-first CLI replacing `sprout-mcp`. Auth env vars
+`sprout` is the agent-first CLI. Auth env vars
 (`SPROUT_RELAY_URL`, `SPROUT_PRIVATE_KEY`, `SPROUT_AUTH_TAG`) are auto-injected
 by the ACP harness into managed agent subprocesses. In development, set
 `SPROUT_PRIVATE_KEY` and `SPROUT_RELAY_URL` in your environment manually.
@@ -211,7 +205,6 @@ just test         # full integration suite (requires Postgres + Redis)
 E2E tests live in `crates/sprout-test-client/tests/`:
 - `e2e_relay.rs` — WebSocket relay protocol
 - `e2e_rest_api.rs` — REST endpoint coverage
-- `e2e_mcp.rs` — MCP tool surface
 - `e2e_tokens.rs` — auth token flows
 - `e2e_workflows.rs` — workflow engine
 - `e2e_media.rs` — media upload/download (Blossom)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6c261fe163..00ee0804ed 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -16,7 +16,7 @@ Sprout is a Rust monorepo, licensed Apache 2.0 under Block, Inc.
 ┌─────────────────────────────────────────────────────────────────────┐
 │                           CLIENTS                                    │
 │                                                                      │
-│  Human (Nostr app, web, mobile)    Agent (MCP tools via sprout-mcp) │
+│  Human (Nostr app, web, mobile)    Agent (CLI tools via sprout-cli) │
 │           │                                    │                     │
 │           └──────────── WebSocket ─────────────┘                    │
 └─────────────────────────────────────────────────────────────────────┘
@@ -78,10 +78,9 @@ sprout-core  (zero I/O — types, verification, filter matching, kind registry)
          │
          └── sprout-relay       (ties everything together — the server)
 
-sprout-mcp          (agent API surface — stdio MCP server; depends on sprout-core and sprout-sdk)
 sprout-acp          (agent harness — bridges relay @mentions → AI agents via ACP/JSON-RPC)
 sprout-proxy        (NIP-28 compatibility proxy — translates standard Nostr clients ↔ Sprout relay)
-sprout-sdk          (typed Nostr event builders — used by sprout-mcp, sprout-acp, and sprout-cli)
+sprout-sdk          (typed Nostr event builders — used by sprout-acp and sprout-cli)
 sprout-media        (Blossom/S3 media storage)
 sprout-cli          (agent-first CLI)
 sprout-admin        (operator CLI: relay membership + key generation)
@@ -135,7 +134,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa
 
 `sprout-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Sprout uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
 
-Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `sprout-core/src/kind.rs` and imported by `sprout-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `sprout-core/src/kind.rs`; `sprout-mcp/src/server.rs` uses the constant via import.
+Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `sprout-core/src/kind.rs` and imported by `sprout-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `sprout-core/src/kind.rs`.
 
 ### Wire Protocol (NIP-01 messages)
 
@@ -679,40 +678,17 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
 
 ---
 
-### sprout-mcp — Agent API Surface
-
-stdio MCP server using the `rmcp` SDK. The interface through which AI agents interact with Sprout. Logs to stderr (stdout is the MCP JSON-RPC channel).
-
-The registered tool surface is grouped into toolsets, enabled per server via `SPROUT_TOOLSETS`. The authoritative list (and per-toolset grouping) is `ALL_TOOLS` in `crates/sprout-mcp/src/toolsets.rs` — the doc deliberately doesn't duplicate it, since a hand-copied list drifts. The default toolset covers messaging, threads, search, feed, reactions, channel basics, DMs, profiles/presence, and workflow triggers; opt-in toolsets add channel admin, canvas, workflow admin, forums, social, and media.
-
-**Key implementation details:**
-- Connects to relay via WebSocket (`tokio_tungstenite`). Handles NIP-42 auth automatically.
-- Ephemeral keypair generated if `SPROUT_PRIVATE_KEY` not set (printed to stderr).
-- Exponential backoff reconnection: 1s → 30s. Resubscribes all active subscriptions after reconnect.
-- REST calls use NIP-98 Schnorr-signed auth when `SPROUT_PRIVATE_KEY` is set; falls back to `X-Pubkey: <hex>` in dev mode.
-- `create_channel` sends a signed Nostr kind 9007 event (NIP-29 group creation, not a REST call).
-- `set_canvas` sends kind 40100 with `h` tag pointing to channel UUID.
-- UUID validation at tool boundary before any network call.
-- `MAX_CONTENT_BYTES = 65,536` enforced in `send_message`.
-- `get_channel_history` caps at 200 results; `get_workflow_runs` caps at 100; `get_feed` max 50 per category.
-
-**Does NOT:** persist state. Does NOT implement server-side logic — it's a thin client over the relay's WebSocket and REST APIs.
-
----
-
 ### sprout-acp — Agent Communication Protocol Harness
 
-Standalone binary that bridges Sprout relay events to AI agents via the [Agent Communication Protocol](https://agentclientprotocol.com/) (ACP). The active counterpart to `sprout-mcp`'s passive tool-serving role.
+Standalone binary that bridges Sprout relay events to AI agents via the [Agent Communication Protocol](https://agentclientprotocol.com/) (ACP).
 
 **Architecture:**
 
 ```
 Sprout Relay ──WS──→ sprout-acp ──stdio (ACP/JSON-RPC)──→ Agent (goose/codex/claude)
-                                                                  │
-                                                        sprout-mcp-server (subprocess)
 ```
 
-`sprout-acp` spawns AI agent subprocesses (1–32, default 1), connects to the relay via WebSocket with NIP-42 auth, discovers channels via REST API, and queues `@mention` events per channel. At most one prompt is in-flight per channel. Queued events are batched into a single prompt sent via `session/prompt` over ACP. The agent uses `sprout-mcp-server` tools (provided as a subprocess) to reply.
+`sprout-acp` spawns AI agent subprocesses (1–32, default 1), connects to the relay via WebSocket with NIP-42 auth, discovers channels via REST API, and queues `@mention` events per channel. At most one prompt is in-flight per channel. Queued events are batched into a single prompt sent via `session/prompt` over ACP.
 
 **Key modules:**
 
@@ -730,9 +706,9 @@ Sprout Relay ──WS──→ sprout-acp ──stdio (ACP/JSON-RPC)──→ Ag
 - Pool of 1–32 agent subprocesses with claim/return lifecycle.
 - Per-channel queuing: at most one prompt in-flight per channel; subsequent @mentions queue until the agent responds.
 - Crash recovery: agent subprocess crashes are detected and the agent is respawned.
-- Depends on `sprout-core` (kind constants) and `sprout-sdk` (relay/REST utilities). Does NOT depend on `sprout-mcp` at compile time.
+- Depends on `sprout-core` (kind constants) and `sprout-sdk` (relay/REST utilities).
 
-**Does NOT:** persist state. Does NOT implement the MCP tool surface — that's `sprout-mcp`'s job.
+**Does NOT:** persist state.
 
 ---
 
@@ -758,7 +734,6 @@ Subcommands:
 | File | Tests | Scope |
 |------|-------|-------|
 | `tests/e2e_relay.rs` | 27 | WebSocket protocol (auth, subscriptions, filters, limits, NIP-11) |
-| `tests/e2e_mcp.rs` | 14 | MCP tool integration (messaging, channels, canvas, feed) |
 | `tests/e2e_media.rs` | 7 | Media upload/download (Blossom) |
 | `tests/e2e_media_extended.rs` | 18 | Extended media scenarios |
 | `tests/e2e_nostr_interop.rs` | 15 | NIP-28 proxy interoperability |
@@ -766,11 +741,11 @@ Subcommands:
 | `tests/e2e_tokens.rs` | 20 | Token auth and scope enforcement |
 | `tests/e2e_workflows.rs` | 7 | Workflow CRUD, trigger, and execution |
 
-All e2e tests are `#[ignore]` — require a running relay. Total: **148 e2e tests**.
+All e2e tests are `#[ignore]` — require a running relay. Total: **134 e2e tests**.
 
 `src/main.rs` is a manual testing CLI (`sprout-test-cli`) with `--send`, `--subscribe`, `--channel`, `--url`, `--kind` flags.
 
-Re-exports `parse_relay_message`, `OkResponse`, `RelayMessage` from `sprout-mcp` to avoid duplicating the wire protocol parser.
+Re-exports `parse_relay_message`, `OkResponse`, `RelayMessage` from `sprout-relay-client`.
 
 ---
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 44ecd37cb5..c337683449 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -291,10 +291,9 @@ sprout-pubsub     ← Redis fan-out
 sprout-search     ← Typesense full-text search
 sprout-audit      ← Tamper-evident hash-chain audit log
 sprout-workflow   ← YAML-as-code workflow engine
-sprout-mcp        ← stdio MCP server (agent API surface)
 sprout-acp        ← ACP harness (bridges Sprout relay events to AI agents via stdio)
 sprout-proxy      ← Nostr client compatibility layer
-sprout-sdk        ← Typed Nostr event builders (used by sprout-mcp and sprout-cli)
+sprout-sdk        ← Typed Nostr event builders (used by sprout-cli)
 sprout-media      ← Blossom/S3 media storage
 sprout-cli        ← Agent-first CLI for interacting with the relay
 sprout-admin      ← Operator CLI
@@ -380,61 +379,6 @@ to existing clients.
 
 ---
 
-## How to Add a New MCP Tool
-
-MCP tools live in `crates/sprout-mcp/src/server.rs`. The `rmcp` crate
-provides the `#[tool]` and `#[tool_router]` macros.
-
-1. **Define a parameter struct:**
-
-   ```rust
-   #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-   pub struct MyToolParams {
-       /// UUID of the target channel.
-       pub channel_id: String,
-       /// Optional limit on results.
-       #[serde(default)]
-       pub limit: Option<u32>,
-   }
-   ```
-
-   Use doc comments (`///`) on fields — they become the tool's parameter
-   descriptions in the MCP schema.
-
-2. **Implement the handler method** on `SproutMcpServer`:
-
-   ```rust
-   #[tool(
-       name = "my_tool",
-       description = "One-sentence description of what this tool does"
-   )]
-   pub async fn my_tool(&self, Parameters(p): Parameters<MyToolParams>) -> String {
-       // Validate inputs at the boundary
-       if uuid::Uuid::parse_str(&p.channel_id).is_err() {
-           return format!("Error: channel_id '{}' is not a valid UUID", p.channel_id);
-       }
-       // Read tools call the relay REST API
-       match self.client.get(&format!("/api/channels/{}/my-resource", p.channel_id)).await {
-           Ok(body) => body,
-           Err(e) => format!("Error: {e}"),
-       }
-   }
-   ```
-
-   **Read vs. write tools:** Read tools use `self.client.get()` (REST).
-   Write tools build a signed Nostr event and call
-   `self.client.send_event(event)` — see `send_message` for the canonical
-   pattern.
-
-3. **The `#[tool_router]` macro** on the `impl SproutMcpServer` block
-   automatically discovers all `#[tool]`-annotated methods — no manual
-   registration or doc updates needed.
-
-4. **Write a test** — add an integration test in
-   `crates/sprout-test-client/tests/e2e_mcp.rs` that exercises the new tool end-to-end.
-
----
-
 ## How to Add a New API Endpoint
 
 REST endpoints live in `crates/sprout-relay/src/api/` — each resource has
diff --git a/Cargo.lock b/Cargo.lock
index 7fefc07021..4d5d53e2d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7574,7 +7574,6 @@ dependencies = [
  "nostr",
  "percent-encoding",
  "rand 0.10.1",
- "schemars",
  "serde",
  "serde_json",
  "sha2 0.11.0",
@@ -7629,34 +7628,6 @@ dependencies = [
  "zeroize",
 ]
 
-[[package]]
-name = "sprout-mcp"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "base64",
- "futures-util",
- "hex",
- "infer",
- "nostr",
- "reqwest 0.13.3",
- "rmcp",
- "rustls",
- "schemars",
- "serde",
- "serde_json",
- "sha2 0.11.0",
- "sprout-core",
- "sprout-sdk",
- "thiserror 2.0.18",
- "tokio",
- "tokio-tungstenite 0.29.0",
- "tracing",
- "tracing-subscriber",
- "url",
- "uuid",
-]
-
 [[package]]
 name = "sprout-media"
 version = "0.1.0"
@@ -7828,6 +7799,23 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "sprout-relay-client"
+version = "0.1.0"
+dependencies = [
+ "futures-util",
+ "nostr",
+ "reqwest 0.13.3",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-tungstenite 0.29.0",
+ "tracing",
+ "url",
+ "uuid",
+]
+
 [[package]]
 name = "sprout-sdk"
 version = "0.1.0"
@@ -7874,7 +7862,7 @@ dependencies = [
  "serde_json",
  "sha2 0.11.0",
  "sprout-core",
- "sprout-mcp",
+ "sprout-relay-client",
  "thiserror 2.0.18",
  "tokio",
  "tokio-tungstenite 0.29.0",
diff --git a/Cargo.toml b/Cargo.toml
index d77553bb6f..3ac02ce050 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ members = [
     "crates/sprout-auth",
     "crates/sprout-search",
     "crates/sprout-audit",
-    "crates/sprout-mcp",
+    "crates/sprout-relay-client",
     "crates/sprout-acp",
     "crates/sprout-agent",
     "crates/sprig",
@@ -102,7 +102,7 @@ futures-util = "0.3"
 tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
 url = "2"
 
-# MCP SDK
+# MCP SDK (used by sprout-dev-mcp and sprout-agent)
 rmcp = { version = "1.1.0", features = ["server", "transport-io", "macros"] }
 schemars = { version = "1", default-features = false }
 
@@ -113,7 +113,7 @@ sprout-auth = { path = "crates/sprout-auth" }
 sprout-pubsub = { path = "crates/sprout-pubsub" }
 sprout-search = { path = "crates/sprout-search" }
 sprout-audit = { path = "crates/sprout-audit" }
-sprout-mcp = { path = "crates/sprout-mcp" }
+sprout-relay-client = { path = "crates/sprout-relay-client" }
 sprout-proxy = { path = "crates/sprout-proxy" }
 sprout-workflow = { path = "crates/sprout-workflow" }
 sprout-media = { path = "crates/sprout-media" }
diff --git a/README.md b/README.md
index 4e7ae86151..27b548b226 100644
--- a/README.md
+++ b/README.md
@@ -135,7 +135,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH
 
 **Services** — `sprout-db` (Postgres) · `sprout-auth` (NIP-42/98 Schnorr auth, rate limiting) · `sprout-pubsub` (Redis, presence, typing) · `sprout-search` (Typesense) · `sprout-audit` (hash-chain log)
 
-**Agent surface** — `sprout-cli` (agent-first CLI, JSON in / JSON out) · `sprout-acp` (ACP harness for Goose/Codex/Claude Code) · `sprout-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `sprout-mcp` (stdio MCP — being phased out in favor of the CLI) · `sprout-dev-mcp` (shell + file-edit tools) · `sprout-workflow` (YAML automation) · `sprout-persona` (agent persona packs)
+**Agent surface** — `sprout-cli` (agent-first CLI, JSON in / JSON out) · `sprout-acp` (ACP harness for Goose/Codex/Claude Code) · `sprout-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `sprout-dev-mcp` (shell + file-edit tools) · `sprout-workflow` (YAML automation) · `sprout-persona` (agent persona packs)
 
 **Git & pairing** — `git-sign-nostr` / `git-credential-nostr` (nostr-signed git) · `sprout-pair-relay` / `sprout-pairing-cli` (relay pairing)
 
diff --git a/TESTING.md b/TESTING.md
index 96b1cc1c4a..d55f540a15 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -183,17 +183,13 @@ header fallback. There is no REST API for fetching message threads — use
 sprout-agent) to the relay. The harness listens for events, drives the
 agent over stdio, and the agent replies through MCP tools.
 
-> The `sprout-mcp` server is being deprecated in favour of direct CLI/relay
-> integration. Keep it in mind if you're poking at the ACP code, but new
-> tests should not depend on it.
-
 Minimum recipe — assumes the relay from step 3 is running and the channel
 `$CHANNEL` from step 4 still exists. The agent identity must be **different**
 from the sender identity (`SPROUT_ACP_RESPOND_TO=anyone` still skips events
 the agent signed itself).
 
 ```bash
-cargo build --release -p sprout-acp -p sprout-mcp
+cargo build --release -p sprout-acp
 export PATH="$PWD/target/release:$PATH"
 
 # 1. Save your sender identity from step 4 — you'll need it to @mention the agent
@@ -216,14 +212,12 @@ export SPROUT_PRIVATE_KEY="$AGENT_SK"
 export SPROUT_RELAY_URL=ws://localhost:3000   # match step 3 (e.g. ws://localhost:3030 if overridden)
 export SPROUT_ACP_RESPOND_TO=anyone           # default is owner-only; opens the gate for testing
 # NIP-AE core-memory prompt injection is on by default; set SPROUT_ACP_NO_MEMORY=true to opt out.
-export SPROUT_ACP_MCP_COMMAND="$PWD/target/release/sprout-mcp-server"  # explicit path beats $PATH
 export GOOSE_MODE=auto                        # must be 'auto' or goose hangs on prompts
 
 sprout-acp                                    # foreground; logs to stdout (run in a separate terminal)
 
 # Optional: turn on per-turn tracing if the default log is too quiet.
-# Both crates honour RUST_LOG via tracing_subscriber's EnvFilter.
-# RUST_LOG=sprout_acp=debug,sprout_mcp=debug sprout-acp
+# RUST_LOG=sprout_acp=debug sprout-acp
 ```
 
 > **Using a different ACP agent?** The default recipe assumes `goose` is on
diff --git a/crates/git-sign-nostr/Cargo.toml b/crates/git-sign-nostr/Cargo.toml
index 4d4b78b798..b3e9dcf8ba 100644
--- a/crates/git-sign-nostr/Cargo.toml
+++ b/crates/git-sign-nostr/Cargo.toml
@@ -20,7 +20,7 @@ path = "src/main.rs"
 [dependencies]
 # Base64 armor encoding/decoding for NIP-GS signature envelopes.
 # Not in workspace deps — each crate pins independently (same pattern as
-# sprout-relay, sprout-mcp, sprout-cli, git-credential-nostr).
+# sprout-relay, sprout-cli, git-credential-nostr).
 base64 = "0.22"
 
 # Hex encoding for BIP-340 signatures and public keys.
diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md
index fdc0b82107..9b5cf9f571 100644
--- a/crates/sprout-acp/README.md
+++ b/crates/sprout-acp/README.md
@@ -19,7 +19,7 @@ Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdi
 Build:
 
 ```bash
-cargo build --release -p sprout-acp -p sprout-mcp-server
+cargo build --release -p sprout-acp
 export PATH="$PWD/target/release:$PATH"
 ```
 
@@ -103,7 +103,7 @@ All configuration is via environment variables (or CLI flags — every env var h
 | `SPROUT_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. |
 | `SPROUT_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. |
 | `SPROUT_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). |
-| `SPROUT_ACP_MCP_COMMAND` | no | `sprout-mcp-server` | Path to the Sprout MCP server binary. |
+| `SPROUT_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. |
 | `SPROUT_ACP_IDLE_TIMEOUT` | no | `620` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. |
 | `SPROUT_ACP_MAX_TURN_DURATION` | no | `3600` | Absolute wall-clock cap per turn (safety valve). |
 | `SPROUT_API_TOKEN` | no | — | API token (required if relay enforces token auth). |
diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs
index 5b4dcd49b7..114af7bf1e 100644
--- a/crates/sprout-acp/src/acp.rs
+++ b/crates/sprout-acp/src/acp.rs
@@ -1349,8 +1349,8 @@ mod tests {
     fn session_new_mcp_server_has_required_fields() {
         // Schema requires name, command, args, env — all present, args/env may be empty.
         let server = McpServer {
-            name: "sprout-mcp".into(),
-            command: "/usr/local/bin/sprout-mcp-server".into(),
+            name: "test-mcp".into(),
+            command: "/usr/local/bin/test-mcp-server".into(),
             args: vec![],
             env: vec![
                 EnvVar {
@@ -1364,10 +1364,10 @@ mod tests {
             ],
         };
         let serialized = serde_json::to_value(&server).unwrap();
-        assert_eq!(serialized["name"].as_str(), Some("sprout-mcp"));
+        assert_eq!(serialized["name"].as_str(), Some("test-mcp"));
         assert_eq!(
             serialized["command"].as_str(),
-            Some("/usr/local/bin/sprout-mcp-server")
+            Some("/usr/local/bin/test-mcp-server")
         );
         assert!(serialized["args"].is_array());
         assert_eq!(serialized["args"].as_array().unwrap().len(), 0);
diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs
index 0cbe141c63..d9a6abbf16 100644
--- a/crates/sprout-acp/src/config.rs
+++ b/crates/sprout-acp/src/config.rs
@@ -209,11 +209,7 @@ pub struct CliArgs {
     )]
     pub agent_args: Vec<String>,
 
-    #[arg(
-        long,
-        env = "SPROUT_ACP_MCP_COMMAND",
-        default_value = "sprout-mcp-server"
-    )]
+    #[arg(long, env = "SPROUT_ACP_MCP_COMMAND", default_value = "")]
     pub mcp_command: String,
 
     /// Idle timeout: max seconds of silence before killing a turn.
@@ -1170,7 +1166,7 @@ mod tests {
             relay_url: "ws://localhost:3000".into(),
             agent_command: "goose".into(),
             agent_args: vec!["acp".into()],
-            mcp_command: "sprout-mcp-server".into(),
+            mcp_command: "".into(),
             idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
             max_turn_duration_secs: 3600,
             agents: 1,
diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs
index e1a68975cd..1edb8fbd65 100644
--- a/crates/sprout-acp/src/lib.rs
+++ b/crates/sprout-acp/src/lib.rs
@@ -2615,7 +2615,11 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
         return vec![];
     }
     vec![McpServer {
-        name: "sprout-mcp".to_string(),
+        name: std::path::Path::new(&config.mcp_command)
+            .file_stem()
+            .and_then(|s| s.to_str())
+            .unwrap_or("mcp")
+            .to_string(),
         command: config.mcp_command.clone(),
         args: vec![],
         env: {
@@ -2796,7 +2800,7 @@ mod build_mcp_servers_tests {
             relay_url: "ws://localhost:3000".into(),
             agent_command: "goose".into(),
             agent_args: vec!["acp".into()],
-            mcp_command: "sprout-mcp-server".into(),
+            mcp_command: "test-mcp-server".into(),
             idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS,
             max_turn_duration_secs: 3600,
             agents: 1,
@@ -2835,7 +2839,7 @@ mod build_mcp_servers_tests {
         let servers = build_mcp_servers(&config);
         assert_eq!(servers.len(), 1);
         let server = &servers[0];
-        assert_eq!(server.name, "sprout-mcp");
+        assert_eq!(server.name, "test-mcp-server");
 
         let names: Vec<&str> = server.env.iter().map(|e| e.name.as_str()).collect();
         assert!(
diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs
index 3489ddc77a..f535e284ab 100644
--- a/crates/sprout-cli/src/commands/messages.rs
+++ b/crates/sprout-cli/src/commands/messages.rs
@@ -58,8 +58,7 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
 /// - Direct reply (parent is top-level): `root == parent`.
 /// - Nested reply: `root` is the parent's own root marker; `parent` is unchanged.
 ///
-/// This matches the behavior of `sprout-mcp`'s `resolve_thread_ref` so that
-/// CLI-sent replies thread identically to MCP-sent replies.
+/// Ensures CLI-sent replies thread correctly using the same NIP-10 logic.
 async fn resolve_thread_ref(
     client: &SproutClient,
     parent_event_id: &str,
diff --git a/crates/sprout-core/Cargo.toml b/crates/sprout-core/Cargo.toml
index 5df15aedc5..60eea1c666 100644
--- a/crates/sprout-core/Cargo.toml
+++ b/crates/sprout-core/Cargo.toml
@@ -9,7 +9,6 @@ description = "Core types, event verification, and filter matching for Sprout"
 
 [features]
 test-utils = []
-mcp-schema = ["schemars"]
 
 [dependencies]
 nostr      = { workspace = true }
@@ -21,7 +20,6 @@ chrono     = { workspace = true }
 hex        = { workspace = true }
 hmac       = { workspace = true }
 sha2       = { workspace = true }
-schemars   = { workspace = true, optional = true }
 rand              = { workspace = true }
 subtle            = { workspace = true }
 zeroize           = { workspace = true }
diff --git a/crates/sprout-core/src/presence.rs b/crates/sprout-core/src/presence.rs
index 801446856f..db144c4bfc 100644
--- a/crates/sprout-core/src/presence.rs
+++ b/crates/sprout-core/src/presence.rs
@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
 /// The WebSocket path (kind:20001) accepts arbitrary status strings for
 /// forward-compatibility; this enum is the curated set for structured APIs.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
-#[cfg_attr(feature = "mcp-schema", derive(schemars::JsonSchema))]
 #[serde(rename_all = "lowercase")]
 pub enum PresenceStatus {
     /// User is actively online.
diff --git a/crates/sprout-dev-mcp/src/paths.rs b/crates/sprout-dev-mcp/src/paths.rs
index 3fb6050971..68cb50379d 100644
--- a/crates/sprout-dev-mcp/src/paths.rs
+++ b/crates/sprout-dev-mcp/src/paths.rs
@@ -48,7 +48,7 @@ mod tests {
         // Symlink targeting outside the dir should be rejected.
         #[cfg(unix)]
         {
-            let outside = std::env::temp_dir().join("sprout-mcp-paths-escape-target");
+            let outside = std::env::temp_dir().join("dev-mcp-paths-escape-target");
             let _ = fs::remove_file(&outside);
             fs::write(&outside, b"y").expect("write outside");
             let link = dir.path().join("link.txt");
diff --git a/crates/sprout-mcp/Cargo.toml b/crates/sprout-mcp/Cargo.toml
deleted file mode 100644
index 9db50f8ac5..0000000000
--- a/crates/sprout-mcp/Cargo.toml
+++ /dev/null
@@ -1,57 +0,0 @@
-[package]
-name = "sprout-mcp"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-repository.workspace = true
-description = "MCP server providing AI agent tools for Sprout"
-
-[[bin]]
-name = "sprout-mcp-server"
-path = "src/main.rs"
-
-[dependencies]
-# Sprout core types
-sprout-core = { workspace = true, features = ["mcp-schema"] }
-sprout-sdk = { workspace = true }
-
-# MCP SDK
-rmcp = { workspace = true }
-schemars = { workspace = true }
-
-# Nostr (for auth + event building)
-nostr = { workspace = true }
-
-# Async runtime
-tokio = { workspace = true }
-tokio-tungstenite = { workspace = true }
-futures-util = { workspace = true }
-
-# Serialization
-serde = { workspace = true }
-serde_json = { workspace = true }
-
-# HTTP client (for relay REST API calls)
-reqwest = { workspace = true }
-
-# TLS crypto provider (required for wss:// connections)
-rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
-
-# Crypto
-sha2 = { workspace = true }
-hex = { workspace = true }
-base64 = "0.22"
-infer = "0.19"
-
-# Utilities
-uuid = { workspace = true }
-tracing = { workspace = true }
-tracing-subscriber = { workspace = true }
-thiserror = { workspace = true }
-anyhow = { workspace = true }
-url = { workspace = true }
-
-[dev-dependencies]
-tokio = { workspace = true, features = ["test-util"] }
-tokio-tungstenite = { workspace = true }
diff --git a/crates/sprout-mcp/src/lib.rs b/crates/sprout-mcp/src/lib.rs
deleted file mode 100644
index 93ab79357a..0000000000
--- a/crates/sprout-mcp/src/lib.rs
+++ /dev/null
@@ -1,103 +0,0 @@
-#![deny(unsafe_code)]
-#![warn(missing_docs)]
-//! # sprout-mcp
-//!
-//! MCP (Model Context Protocol) server that exposes [Sprout] — a Nostr-based enterprise
-//! communications platform — as a set of tools consumable by AI agents.
-//!
-//! ## Overview
-//!
-//! `sprout-mcp` runs as a stdio MCP server. An agent host (e.g. Claude Desktop, Goose)
-//! launches it as a subprocess and communicates over JSON-RPC on stdin/stdout. The server
-//! maintains a persistent, authenticated WebSocket connection to a Sprout relay. All reads
-//! use Nostr REQ/EOSE queries; all writes publish signed Nostr events.
-//!
-//! ```text
-//!  ┌─────────────┐  JSON-RPC (stdio)  ┌──────────────┐  NIP-42 WebSocket  ┌───────────────┐
-//!  │  Agent Host │ ◄─────────────────► │  sprout-mcp  │ ◄─────────────────► │ Sprout Relay  │
-//!  └─────────────┘                     └──────────────┘  HTTP (media only)  └───────────────┘
-//! ```
-//!
-//! ## Connecting to the Relay
-//!
-//! On startup `sprout-mcp` reads three environment variables:
-//!
-//! | Variable             | Default                  | Description                                      |
-//! |----------------------|--------------------------|--------------------------------------------------|
-//! | `SPROUT_RELAY_URL`   | `ws://localhost:3000`    | WebSocket URL of the Sprout relay                |
-//! | `SPROUT_PRIVATE_KEY` | *(generated)*            | `nsec…` Nostr private key for the agent identity |
-//! | `SPROUT_API_TOKEN`   | *(none)*                 | Auth token embedded in NIP-42 handshake          |
-//!
-//! If `SPROUT_PRIVATE_KEY` is absent a fresh ephemeral keypair is generated and its public key
-//! is printed to stderr. In production you should supply a stable key so the agent has a
-//! consistent Nostr identity.
-//!
-//! Authentication follows [NIP-42]: the relay sends an `AUTH` challenge immediately after the
-//! WebSocket handshake; the client signs it and sends back an `AUTH` event. When
-//! `SPROUT_API_TOKEN` is set the token is embedded in the auth event tags so the relay can
-//! verify the agent's API permissions.
-//!
-//! ## WebSocket Connection Management
-//!
-//! [`relay_client::RelayClient`] uses a background tokio task that owns the WebSocket
-//! connection. The background task:
-//!
-//! - Responds to Ping frames immediately — preventing relay disconnects during long LLM turns
-//! - Handles mid-session NIP-42 AUTH challenges automatically
-//! - Reconnects with exponential backoff (1 s → 2 s → 4 s → … → 30 s cap) on any
-//!   connection loss, without any action required from the caller
-//! - Re-authenticates via NIP-42 after each reconnect
-//! - Replays all active subscriptions after reconnect
-//!
-//! ```text
-//! RelayClient (Clone)
-//!   ├── cmd_tx: mpsc::Sender<RelayCommand>   ← send_event / subscribe / close
-//!   └── bg_handle: JoinHandle<()>
-//!         └── run_background_task()
-//!               ├── ws.next()  → handle_ws_message()   // Ping→Pong, AUTH→respond, OK→resolve
-//!               ├── cmd_rx     → handle_command()       // SendEvent, Subscribe, Close
-//!               └── tick       → expire_timed_out()     // 10s timeouts
-//! ```
-//!
-//! ## Available Tools
-//!
-//! Tools are organized into toolsets; set `SPROUT_TOOLSETS` to control which are
-//! active. The authoritative list and per-toolset grouping is `ALL_TOOLS` in
-//! [`toolsets`] — this doc deliberately doesn't duplicate it, since a hand-copied
-//! list drifts. The default toolset covers messaging, threads, search, feed,
-//! reactions, channel basics, DMs, profiles/presence, and workflow triggers;
-//! opt-in toolsets add channel admin, canvas, workflow admin, forums, social,
-//! and media.
-//!
-//! ## Example Configuration (Claude Desktop)
-//!
-//! ```json
-//! {
-//!   "mcpServers": {
-//!     "sprout": {
-//!       "command": "/usr/local/bin/sprout-mcp-server",
-//!       "env": {
-//!         "SPROUT_RELAY_URL": "wss://relay.example.com",
-//!         "SPROUT_PRIVATE_KEY": "nsec1...",
-//!         "SPROUT_API_TOKEN": "your-api-token"
-//!       }
-//!     }
-//!   }
-//! }
-//! ```
-//!
-//! [Sprout]: https://github.com/block/sprout
-//! [NIP-42]: https://github.com/nostr-protocol/nips/blob/master/42.md
-
-// NOTE: `parse_relay_message`, `OkResponse`, and `RelayMessage` from `relay_client`
-// are re-exported by `sprout-test-client`. Changes to these types are a breaking
-// change for the test harness.
-
-/// WebSocket client for the Sprout relay (NIP-42 auth, subscriptions, reconnect).
-pub mod relay_client;
-/// MCP tool implementations backed by the relay client.
-pub mod server;
-/// Toolset definitions and configuration for organizing MCP tools.
-pub mod toolsets;
-/// File upload to the Sprout relay (Blossom protocol).
-pub mod upload;
diff --git a/crates/sprout-mcp/src/main.rs b/crates/sprout-mcp/src/main.rs
deleted file mode 100644
index c9b01bb6cb..0000000000
--- a/crates/sprout-mcp/src/main.rs
+++ /dev/null
@@ -1,161 +0,0 @@
-use anyhow::Result;
-use nostr::{Keys, Tag};
-use rmcp::{transport::stdio, ServiceExt};
-use tracing_subscriber::EnvFilter;
-
-use sprout_mcp::relay_client::RelayClient;
-use sprout_mcp::server::SproutMcpServer;
-use sprout_mcp::toolsets::ToolsetConfig;
-
-/// Parse and validate the NIP-OA auth tag from the environment.
-///
-/// Returns `Ok(Some(tag))` if a valid auth tag is configured,
-/// `Ok(None)` if no auth tag is set (or empty),
-/// `Err` if the tag is present but malformed, invalid, or non-Unicode.
-fn resolve_auth_tag(
-    env_value: Result<String, std::env::VarError>,
-    agent_pubkey: &nostr::PublicKey,
-) -> anyhow::Result<Option<Tag>> {
-    let tag_json = match env_value {
-        Ok(s) if !s.is_empty() => s,
-        Ok(_) => return Ok(None), // empty string — treat as not set
-        Err(std::env::VarError::NotPresent) => return Ok(None),
-        Err(std::env::VarError::NotUnicode(_)) => {
-            anyhow::bail!("SPROUT_AUTH_TAG contains non-Unicode data");
-        }
-    };
-
-    let tag = sprout_sdk::nip_oa::parse_auth_tag(&tag_json)
-        .map_err(|e| anyhow::anyhow!("SPROUT_AUTH_TAG is malformed: {e}"))?;
-
-    sprout_sdk::nip_oa::verify_auth_tag(&tag_json, agent_pubkey).map_err(|e| {
-        anyhow::anyhow!(
-            "SPROUT_AUTH_TAG signature verification failed for agent pubkey {}: {e}",
-            agent_pubkey.to_hex()
-        )
-    })?;
-
-    eprintln!(
-        "sprout-mcp: NIP-OA auth tag configured (owner: {})",
-        tag.as_slice().get(1).map(|s| &s[..8]).unwrap_or("?")
-    );
-
-    Ok(Some(tag))
-}
-
-#[tokio::main]
-async fn main() -> Result<()> {
-    // Install the ring crypto provider for rustls (required for wss:// connections).
-    let _ = rustls::crypto::ring::default_provider().install_default();
-    // Log to stderr — stdout is the MCP JSON-RPC channel.
-    tracing_subscriber::fmt()
-        .with_env_filter(
-            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("sprout_mcp=info")),
-        )
-        .with_writer(std::io::stderr)
-        .init();
-
-    let relay_url =
-        std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
-
-    let api_token = std::env::var("SPROUT_API_TOKEN").ok();
-
-    let keys = match std::env::var("SPROUT_PRIVATE_KEY") {
-        Ok(nsec) => Keys::parse(&nsec)?,
-        Err(_) => {
-            let keys = Keys::generate();
-            eprintln!(
-                "sprout-mcp: generated ephemeral keypair: {}",
-                keys.public_key().to_hex()
-            );
-            keys
-        }
-    };
-
-    let auth_tag = resolve_auth_tag(std::env::var("SPROUT_AUTH_TAG"), &keys.public_key())?;
-
-    if auth_tag.is_some() {
-        eprintln!("sprout-mcp: NIP-OA auth tag verified ✓");
-    }
-
-    let toolset_config = ToolsetConfig::from_env();
-    eprintln!("sprout-mcp: toolsets: {:?}", toolset_config);
-
-    eprintln!("sprout-mcp: connecting to relay at {relay_url}...");
-    let client = RelayClient::connect(&relay_url, &keys, api_token.as_deref(), auth_tag).await?;
-    eprintln!("sprout-mcp: connected and authenticated.");
-
-    let tools_to_remove = toolset_config.tools_to_remove();
-    let server = SproutMcpServer::new(client, Some(tools_to_remove));
-    let service = server.serve(stdio()).await?;
-    service.waiting().await?;
-
-    Ok(())
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String {
-        let owner_keys = nostr::Keys::generate();
-        sprout_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "").unwrap()
-    }
-
-    #[test]
-    fn resolve_auth_tag_valid() {
-        let agent_keys = nostr::Keys::generate();
-        let json = make_valid_auth_tag(&agent_keys);
-        let result = resolve_auth_tag(Ok(json), &agent_keys.public_key());
-        assert!(result.is_ok());
-        assert!(result.unwrap().is_some());
-    }
-
-    #[test]
-    fn resolve_auth_tag_not_present() {
-        let keys = nostr::Keys::generate();
-        let result = resolve_auth_tag(Err(std::env::VarError::NotPresent), &keys.public_key());
-        assert!(result.unwrap().is_none());
-    }
-
-    #[test]
-    fn resolve_auth_tag_empty_string() {
-        let keys = nostr::Keys::generate();
-        let result = resolve_auth_tag(Ok(String::new()), &keys.public_key());
-        assert!(result.unwrap().is_none());
-    }
-
-    #[test]
-    fn resolve_auth_tag_malformed_json() {
-        let keys = nostr::Keys::generate();
-        let result = resolve_auth_tag(Ok("not json".into()), &keys.public_key());
-        assert!(result.is_err());
-        assert!(result.unwrap_err().to_string().contains("malformed"));
-    }
-
-    #[test]
-    fn resolve_auth_tag_bad_signature() {
-        let agent_keys = nostr::Keys::generate();
-        // Structurally valid (4 elements, correct hex lengths) but cryptographically wrong.
-        let fake_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128));
-        let result = resolve_auth_tag(Ok(fake_json), &agent_keys.public_key());
-        assert!(result.is_err());
-        assert!(result
-            .unwrap_err()
-            .to_string()
-            .contains("verification failed"));
-    }
-
-    #[test]
-    fn resolve_auth_tag_non_unicode() {
-        let keys = nostr::Keys::generate();
-        let result = resolve_auth_tag(
-            Err(std::env::VarError::NotUnicode(std::ffi::OsString::from(
-                "bad",
-            ))),
-            &keys.public_key(),
-        );
-        assert!(result.is_err());
-        assert!(result.unwrap_err().to_string().contains("non-Unicode"));
-    }
-}
diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs
deleted file mode 100644
index 6b69ec2972..0000000000
--- a/crates/sprout-mcp/src/server.rs
+++ /dev/null
@@ -1,3319 +0,0 @@
-use nostr::{Alphabet, EventBuilder, EventId, Filter, JsonUtil, Kind, SingleLetterTag, Tag};
-use rmcp::{
-    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
-    model::{ServerCapabilities, ServerInfo},
-    schemars, tool, tool_handler, tool_router, ServerHandler,
-};
-use serde::{Deserialize, Serialize};
-use sha2::{Digest, Sha256};
-
-use crate::relay_client::RelayClient;
-use sprout_core::kind;
-use sprout_core::PresenceStatus;
-
-/// Helper to create a lowercase single-letter tag for Nostr filter custom_tag.
-fn tag_h() -> SingleLetterTag {
-    SingleLetterTag::lowercase(Alphabet::H)
-}
-fn tag_d() -> SingleLetterTag {
-    SingleLetterTag::lowercase(Alphabet::D)
-}
-fn tag_e() -> SingleLetterTag {
-    SingleLetterTag::lowercase(Alphabet::E)
-}
-fn tag_p() -> SingleLetterTag {
-    SingleLetterTag::lowercase(Alphabet::P)
-}
-
-/// Convert a sprout-core kind constant (u32) to a nostr Kind.
-fn k(kind_num: u32) -> Kind {
-    Kind::Custom(kind_num as u16)
-}
-
-/// Percent-encode a string for safe inclusion in a URL query parameter value.
-/// Encodes all characters except unreserved ones (A-Z a-z 0-9 - _ . ~).
-#[cfg(test)]
-fn percent_encode(s: &str) -> String {
-    let mut out = String::with_capacity(s.len());
-    for byte in s.bytes() {
-        match byte {
-            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
-                out.push(byte as char);
-            }
-            _ => {
-                // SAFETY: nibble values 0–15 are always valid hex digits.
-                let hi = char::from_digit((byte >> 4) as u32, 16)
-                    .expect("nibble 0-15 is always a valid hex digit")
-                    .to_ascii_uppercase();
-                let lo = char::from_digit((byte & 0xf) as u32, 16)
-                    .expect("nibble 0-15 is always a valid hex digit")
-                    .to_ascii_uppercase();
-                out.push('%');
-                out.push(hi);
-                out.push(lo);
-            }
-        }
-    }
-    out
-}
-
-/// Validate that a string is exactly 64 hex characters.
-fn validate_hex64(s: &str, label: &str) -> Result<(), String> {
-    if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
-        Err(format!(
-            "Error: {label} must be exactly 64 hex characters, got len={}",
-            s.len()
-        ))
-    } else {
-        Ok(())
-    }
-}
-
-/// Validate that `s` is a well-formed UUID (any version/variant).
-/// Returns `Ok(())` on success, or an error string on failure.
-fn validate_uuid(s: &str) -> Result<(), String> {
-    uuid::Uuid::parse_str(s).map_err(|_| format!("invalid UUID: {s}"))?;
-    Ok(())
-}
-
-/// Extract the thread root event ID from a serialized Nostr tag array.
-///
-/// Parses `"e"` tags with NIP-10 markers:
-/// - If a `"root"` marker exists, returns that event ID.
-/// - If only a `"reply"` marker exists, returns the reply target (it IS the root
-///   for a direct reply — needed so nested replies can supply the correct root).
-/// - If no thread markers exist, returns `None` (top-level message).
-fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
-    let arr = tags.as_array()?;
-    let mut root = None;
-    let mut reply = None;
-    for tag in arr {
-        let Some(parts) = tag.as_array() else {
-            continue;
-        };
-        if parts.len() >= 4 && parts[0].as_str() == Some("e") {
-            match parts[3].as_str() {
-                Some("root") => root = parts[1].as_str().map(|s| s.to_string()),
-                Some("reply") => reply = parts[1].as_str().map(|s| s.to_string()),
-                _ => {}
-            }
-        }
-    }
-    root.or(reply)
-}
-
-/// Maximum allowed content size for a single message (64 KiB).
-const MAX_CONTENT_BYTES: usize = 65_536;
-
-/// Resolve `@name` mentions in `content` against this channel's members.
-///
-/// Performs the I/O (member + profile queries) and delegates parsing
-/// and matching to [`sprout_sdk::mentions`]. On any query failure or
-/// missing data, returns an empty vec — auto-tagging is best-effort
-/// and must never block a send.
-async fn resolve_content_mentions(
-    client: &RelayClient,
-    channel_id: &str,
-    content: &str,
-) -> Vec<String> {
-    let names = sprout_sdk::mentions::extract_at_names(content);
-    if names.is_empty() {
-        return vec![];
-    }
-    // Query channel membership (kind 39002, addressed by `d` tag).
-    let filter = Filter::new()
-        .kind(k(kind::KIND_NIP29_GROUP_MEMBERS))
-        .custom_tags(tag_d(), [channel_id])
-        .limit(1);
-    let Ok(events) = client.query(vec![filter]).await else {
-        return vec![];
-    };
-    let Some(event) = events.first() else {
-        return vec![];
-    };
-    let member_pubkeys: Vec<&str> = event
-        .tags
-        .iter()
-        .filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p"))
-        .filter_map(|t| t.as_slice().get(1).map(|v| v.as_str()))
-        .collect();
-    if member_pubkeys.is_empty() {
-        return vec![];
-    }
-    // Fetch profiles for those members.
-    let authors: Vec<nostr::PublicKey> = member_pubkeys
-        .iter()
-        .filter_map(|pk| nostr::PublicKey::from_hex(pk).ok())
-        .collect();
-    let profile_filter = Filter::new()
-        .kind(k(kind::KIND_PROFILE))
-        .authors(authors)
-        .limit(member_pubkeys.len());
-    let Ok(profiles) = client.query(vec![profile_filter]).await else {
-        return vec![];
-    };
-    // Stable lowercase-hex strings for borrowing into MentionProfile.
-    let hex_pubkeys: Vec<String> = profiles.iter().map(|p| p.pubkey.to_hex()).collect();
-    let entries: Vec<sprout_sdk::mentions::MentionProfile<'_>> = profiles
-        .iter()
-        .zip(hex_pubkeys.iter())
-        .map(|(p, pk)| sprout_sdk::mentions::MentionProfile {
-            pubkey: pk.as_str(),
-            content_json: p.content.as_str(),
-        })
-        .collect();
-    sprout_sdk::mentions::match_names_to_profiles(&names, &entries)
-}
-
-/// Parameters for the `send_message` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SendMessageParams {
-    /// UUID of the channel to post to.
-    pub channel_id: String,
-    /// Message body text. Supports GitHub-flavored Markdown including fenced code
-    /// blocks with syntax highlighting.
-    pub content: String,
-    /// Nostr event kind. Defaults to KIND_STREAM_MESSAGE (NIP-29 group chat message).
-    #[serde(default = "default_kind")]
-    pub kind: Option<u16>,
-    /// Optional parent event ID for threading. If provided, NIP-10 reply tags are added.
-    #[serde(default)]
-    pub parent_event_id: Option<String>,
-    /// If true and parent_event_id is set, surface the reply in the main channel timeline.
-    #[serde(default)]
-    pub broadcast_to_channel: Option<bool>,
-    /// Pubkeys to @mention in the message.
-    #[serde(default)]
-    pub mention_pubkeys: Option<Vec<String>>,
-    /// Optional file paths to upload and attach as media. Each file is uploaded
-    /// to the relay and included as an imeta tag + markdown image in the message.
-    #[serde(default)]
-    pub file_paths: Option<Vec<String>>,
-}
-fn default_kind() -> Option<u16> {
-    Some(sprout_core::kind::KIND_STREAM_MESSAGE as u16)
-}
-
-/// Parameters for the `get_messages` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetMessagesParams {
-    /// UUID of the channel to fetch history from.
-    pub channel_id: String,
-    /// Maximum number of messages to return (default 50, max 200).
-    #[serde(default)]
-    pub limit: Option<u32>,
-    /// Legacy parameter (thread summaries are now always included). Kept for backward compatibility.
-    #[serde(default)]
-    pub with_threads: Option<bool>,
-    /// Unix timestamp cursor for pagination. Returns messages before this time.
-    #[serde(default)]
-    pub before: Option<i64>,
-    /// Unix timestamp cursor. Returns messages created strictly after this time.
-    /// When used without `before`, results are ordered oldest-first (chronological).
-    /// Useful for polling: pass the timestamp of the last seen message to get only newer ones.
-    #[serde(default)]
-    pub since: Option<i64>,
-    /// Comma-separated event kind numbers to filter by (e.g. "45001" for forum posts,
-    /// "45002" for votes). When omitted, all kinds are returned.
-    #[serde(default)]
-    pub kinds: Option<String>,
-}
-
-/// Parameters for the `list_channels` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct ListChannelsParams {
-    /// Optional visibility filter: `"open"` or `"private"`.
-    #[serde(default)]
-    pub visibility: Option<String>,
-}
-
-/// Parameters for the `create_channel` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct CreateChannelParams {
-    /// Display name for the new channel.
-    pub name: String,
-    /// Channel type: `"stream"` (real-time chat) or `"forum"` (threaded discussions).
-    pub channel_type: String,
-    /// Channel visibility: `"open"` (anyone can join) or `"private"` (invite-only).
-    pub visibility: String,
-    /// Optional human-readable description of the channel's purpose.
-    #[serde(default)]
-    pub description: Option<String>,
-}
-
-/// Parameters for the `get_canvas` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetCanvasParams {
-    /// UUID of the channel whose canvas to retrieve.
-    pub channel_id: String,
-}
-
-/// Parameters for the `set_canvas` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SetCanvasParams {
-    /// UUID of the channel whose canvas to update.
-    pub channel_id: String,
-    /// New canvas content (replaces any existing canvas).
-    pub content: String,
-}
-
-// ── Workflow tool parameter structs ──────────────────────────────────────────
-
-/// Parameters for the `list_workflows` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct ListWorkflowsParams {
-    /// UUID of the channel whose workflows to list.
-    pub channel_id: String,
-}
-
-/// Parameters for the `create_workflow` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct CreateWorkflowParams {
-    /// UUID of the channel to own this workflow.
-    pub channel_id: String,
-    /// Full workflow definition in YAML format. Required fields: name (string), trigger (object with
-    /// 'on' field: 'message_posted', 'diff_posted', 'reaction_added', or 'webhook'), steps (array).
-    /// Each step needs: id (alphanumeric/underscore), action (e.g. 'send_message'), and action-specific
-    /// fields as direct properties (NOT nested under 'params'). Example:
-    /// ```yaml
-    /// name: My Workflow
-    /// trigger:
-    ///   on: message_posted
-    /// steps:
-    ///   - id: notify
-    ///     action: send_message
-    ///     text: Hello from workflow!
-    /// ```
-    pub yaml_definition: String,
-}
-
-/// Parameters for the `update_workflow` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct UpdateWorkflowParams {
-    /// UUID of the workflow to update.
-    pub workflow_id: String,
-    /// Replacement YAML definition.
-    pub yaml_definition: String,
-}
-
-/// Parameters for the `delete_workflow` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct DeleteWorkflowParams {
-    /// UUID of the workflow to delete.
-    pub workflow_id: String,
-}
-
-/// Parameters for the `trigger_workflow` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct TriggerWorkflowParams {
-    /// UUID of the workflow to trigger.
-    pub workflow_id: String,
-    /// Optional JSON object of input variables passed to the workflow.
-    #[serde(default)]
-    pub inputs: Option<serde_json::Value>,
-}
-
-/// Parameters for the `get_workflow_runs` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetWorkflowRunsParams {
-    /// UUID of the workflow whose run history to fetch.
-    pub workflow_id: String,
-    /// Maximum number of runs to return. Default 20, max 100.
-    #[serde(default)]
-    pub limit: Option<u32>,
-}
-
-/// Parameters for the `approve_step` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct ApproveStepParams {
-    /// Opaque approval token from the kind:46010 event.
-    pub approval_token: String,
-    /// true = approve, false = deny.
-    pub approved: bool,
-    /// Optional human-readable note to attach to the decision.
-    #[serde(default)]
-    pub note: Option<String>,
-}
-
-// ── Feed tool parameter structs ───────────────────────────────────────────────
-
-// ── Membership tool parameter structs ────────────────────────────────────────
-
-/// Parameters for the `add_channel_member` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct AddChannelMemberParams {
-    /// UUID of the channel.
-    pub channel_id: String,
-    /// Hex-encoded public key of the user to add.
-    pub pubkey: String,
-    /// Role to assign: `"member"` (default) or `"admin"`.
-    #[serde(default)]
-    pub role: Option<String>,
-}
-
-/// Parameters for the `remove_channel_member` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct RemoveChannelMemberParams {
-    /// UUID of the channel.
-    pub channel_id: String,
-    /// Hex-encoded public key of the user to remove.
-    pub pubkey: String,
-}
-
-/// Parameters for the `list_channel_members` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct ListChannelMembersParams {
-    /// UUID of the channel whose members to list.
-    pub channel_id: String,
-}
-
-/// Parameters for the `join_channel` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct JoinChannelParams {
-    /// UUID of the channel to join.
-    pub channel_id: String,
-}
-
-/// Parameters for the `leave_channel` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct LeaveChannelParams {
-    /// UUID of the channel to leave.
-    pub channel_id: String,
-}
-
-/// Parameters for the `get_channel` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetChannelParams {
-    /// UUID of the channel to retrieve.
-    pub channel_id: String,
-}
-
-// ── Metadata tool parameter structs ──────────────────────────────────────────
-
-/// Parameters for the `update_channel` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct UpdateChannelParams {
-    /// UUID of the channel to update.
-    pub channel_id: String,
-    /// New display name for the channel.
-    #[serde(default)]
-    pub name: Option<String>,
-    /// New description for the channel.
-    #[serde(default)]
-    pub description: Option<String>,
-}
-
-/// Parameters for the `set_channel_topic` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SetChannelTopicParams {
-    /// UUID of the channel.
-    pub channel_id: String,
-    /// New topic string.
-    pub topic: String,
-}
-
-/// Parameters for the `set_channel_purpose` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SetChannelPurposeParams {
-    /// UUID of the channel.
-    pub channel_id: String,
-    /// New purpose string.
-    pub purpose: String,
-}
-
-/// Parameters for the `archive_channel` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct ArchiveChannelParams {
-    /// UUID of the channel to archive.
-    pub channel_id: String,
-}
-
-/// Parameters for the `unarchive_channel` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct UnarchiveChannelParams {
-    /// UUID of the channel to unarchive.
-    pub channel_id: String,
-}
-
-/// Parameters for the `delete_channel` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct DeleteChannelParams {
-    /// UUID of the channel to permanently delete.
-    pub channel_id: String,
-}
-
-// ── Thread tool parameter structs ─────────────────────────────────────────────
-
-/// Parameters for the `get_thread` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetThreadParams {
-    /// UUID of the channel containing the thread.
-    pub channel_id: String,
-    /// Event ID of the root (or any ancestor) message of the thread.
-    pub event_id: String,
-    /// Maximum nesting depth to return (default: unlimited).
-    #[serde(default)]
-    pub depth_limit: Option<u32>,
-    /// Maximum number of replies to return (default 50).
-    #[serde(default)]
-    pub limit: Option<u32>,
-}
-
-// ── DM tool parameter structs ─────────────────────────────────────────────────
-
-/// Parameters for the `open_dm` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct OpenDmParams {
-    /// Hex-encoded public keys of the other participants (1–8).
-    pub pubkeys: Vec<String>,
-}
-
-/// Parameters for the `add_dm_member` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct AddDmMemberParams {
-    /// UUID of the DM channel.
-    pub channel_id: String,
-    /// Hex-encoded public key of the user to add.
-    pub pubkey: String,
-}
-
-/// Parameters for the `hide_dm` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct HideDmParams {
-    /// UUID of the DM channel to hide.
-    pub channel_id: String,
-}
-
-// ── Reaction tool parameter structs ──────────────────────────────────────────
-
-/// Parameters for the `add_reaction` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct AddReactionParams {
-    /// Event ID of the message to react to.
-    pub event_id: String,
-    /// Emoji to react with (e.g. `"👍"` or `":thumbsup:"`).
-    pub emoji: String,
-}
-
-/// Parameters for the `remove_reaction` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct RemoveReactionParams {
-    /// Event ID of the message whose reaction to remove.
-    pub event_id: String,
-    /// Emoji to remove.
-    pub emoji: String,
-}
-
-/// Parameters for the `get_reactions` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetReactionsParams {
-    /// Event ID of the message whose reactions to fetch.
-    pub event_id: String,
-}
-
-// ── User profile tool parameter structs ──────────────────────────────────────
-
-/// Parameters for the `set_profile` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SetProfileParams {
-    /// New display name for the agent's profile.
-    #[serde(default)]
-    pub display_name: Option<String>,
-    /// URL of the agent's avatar image.
-    #[serde(default)]
-    pub avatar_url: Option<String>,
-    /// Short bio or description.
-    #[serde(default)]
-    pub about: Option<String>,
-    /// NIP-05 identifier (e.g. "alice@example.com"), or None to leave unchanged.
-    #[serde(default)]
-    pub nip05_handle: Option<String>,
-}
-
-/// Parameters for the `get_users` tool.
-#[derive(Debug, Deserialize, schemars::JsonSchema)]
-pub struct GetUsersParams {
-    /// Pubkey(s) to look up. Omit for your own profile. Provide one hex pubkey
-    /// for a single user, or multiple for batch lookup (max 200).
-    pub pubkeys: Option<Vec<String>>,
-}
-
-/// Parameters for the `search` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SearchParams {
-    /// Full-text search query string.
-    pub q: String,
-    /// Maximum results to return (default 20, max 100).
-    #[serde(default)]
-    pub limit: Option<u32>,
-}
-
-/// Parameters for the `get_presence` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetPresenceParams {
-    /// Comma-separated hex-encoded public keys to look up presence for (max 200).
-    pub pubkeys: String,
-}
-
-/// Parameters for the `set_presence` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SetPresenceParams {
-    /// Presence status to set.
-    pub status: PresenceStatus,
-}
-
-/// Parameters for the `set_channel_add_policy` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SetChannelAddPolicyParams {
-    /// Channel add policy: "anyone" (default), "owner_only", or "nobody".
-    /// - "anyone": any authenticated user can add you to open channels.
-    /// - "owner_only": only your provisioned owner can add you.
-    /// - "nobody": no one can add you; you must self-join channels.
-    pub policy: String,
-}
-
-/// Parameters for the `get_feed` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetFeedParams {
-    /// Only return feed items newer than this Unix timestamp.
-    /// Defaults to now - 7 days if omitted.
-    #[serde(default)]
-    pub since: Option<i64>,
-    /// Maximum items per category. Default 50, max 50.
-    #[serde(default)]
-    pub limit: Option<u32>,
-    /// Comma-separated category filter: "mentions,needs_action,activity,agent_activity".
-    /// Omit to return all categories.
-    #[serde(default)]
-    pub types: Option<String>,
-}
-
-/// Parameters for the `edit_message` tool.
-#[derive(Debug, Deserialize, schemars::JsonSchema)]
-pub struct EditMessageParams {
-    /// Channel ID (UUID) containing the message to edit.
-    pub channel_id: String,
-    /// Event ID (64-char hex) of the message to edit.
-    pub event_id: String,
-    /// New content for the message.
-    pub content: String,
-}
-
-/// Parameters for the `delete_message` tool.
-#[derive(Debug, Deserialize, schemars::JsonSchema)]
-pub struct DeleteMessageParams {
-    /// Event ID (64-char hex) of the message to delete.
-    pub event_id: String,
-}
-
-/// Parameters for the `send_diff_message` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SendDiffMessageParams {
-    /// UUID of the channel to post to.
-    pub channel_id: String,
-    /// Unified diff content (git diff format).
-    pub diff: String,
-    /// URL of the source repository (e.g. "https://github.com/org/repo").
-    pub repo_url: String,
-    /// Full commit SHA this diff applies to.
-    pub commit_sha: String,
-    /// Optional file path within the repo (used for language inference and display).
-    #[serde(default)]
-    pub file_path: Option<String>,
-    /// Optional parent commit SHA (the base of the diff).
-    #[serde(default)]
-    pub parent_commit_sha: Option<String>,
-    /// Optional source branch name (e.g. "feat/my-feature").
-    #[serde(default)]
-    pub source_branch: Option<String>,
-    /// Optional target branch name (e.g. "main").
-    #[serde(default)]
-    pub target_branch: Option<String>,
-    /// Optional pull request number associated with this diff.
-    #[serde(default)]
-    pub pr_number: Option<u32>,
-    /// Optional language hint for syntax highlighting (e.g. "rust", "typescript").
-    /// Inferred from file_path extension if omitted.
-    #[serde(default)]
-    pub language: Option<String>,
-    /// Optional human-readable description of the change.
-    #[serde(default)]
-    pub description: Option<String>,
-    /// Optional parent event ID. If provided, sends the diff as a threaded reply.
-    #[serde(default)]
-    pub parent_event_id: Option<String>,
-}
-
-/// Vote direction for forum posts.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-#[serde(rename_all = "lowercase")]
-pub enum VoteDirection {
-    /// Upvote.
-    Up,
-    /// Downvote.
-    Down,
-}
-
-/// Parameters for the `vote_on_post` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct VoteOnPostParams {
-    /// UUID of the forum channel.
-    pub channel_id: String,
-    /// 64-character hex event ID of the post or comment being voted on.
-    pub event_id: String,
-    /// Vote direction.
-    pub direction: VoteDirection,
-}
-
-/// Parameters for [`SproutServer::publish_note`].
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct PublishNoteParams {
-    /// Text content of the note (max 64 KiB).
-    pub content: String,
-    /// 64-char hex event ID to reply to. Adds a single e-tag with "reply" marker.
-    #[serde(default)]
-    pub reply_to_event_id: Option<String>,
-}
-
-/// A single contact entry for [`SetContactListParams`].
-///
-/// Kept local to MCP — not part of sprout-sdk. The SDK builder takes primitive slices.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct ContactEntry {
-    /// 64-char hex pubkey (any case accepted, normalized to lowercase).
-    pub pubkey: String,
-    /// Optional relay URL hint (NIP-02). Empty string if omitted.
-    #[serde(default)]
-    pub relay_url: Option<String>,
-    /// Optional petname / display alias.
-    #[serde(default)]
-    pub petname: Option<String>,
-}
-
-/// Parameters for [`SproutServer::set_contact_list`].
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct SetContactListParams {
-    /// Replaces the **entire** contact list. Call `get_contact_list` first for delta updates.
-    pub contacts: Vec<ContactEntry>,
-}
-
-/// Parameters for [`SproutServer::get_event`].
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetEventParams {
-    /// 64-char hex event ID.
-    pub event_id: String,
-}
-
-/// Parameters for [`SproutServer::get_user_notes`].
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetUserNotesParams {
-    /// 64-char hex pubkey of the author.
-    pub pubkey: String,
-    /// Maximum number of notes to return (default 50, max 100).
-    #[serde(default)]
-    pub limit: Option<u32>,
-    /// Unix timestamp cursor — return notes created before this time.
-    /// Use with `before_id` for stable composite cursor pagination.
-    #[serde(default)]
-    pub before: Option<i64>,
-    /// Hex event ID cursor for composite keyset pagination. Use together with
-    /// `before` to avoid skipping same-second events. Pass the `before_id` value
-    /// from the previous page's `next_cursor` response.
-    #[serde(default)]
-    pub before_id: Option<String>,
-}
-
-/// Parameters for [`SproutServer::get_contact_list`].
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct GetContactListParams {
-    /// 64-char hex pubkey of the user whose contact list to fetch.
-    pub pubkey: String,
-}
-
-// ── Diff utility functions ────────────────────────────────────────────────────
-
-// Truncation notice appended when a diff is cut. This constant is used to
-// reserve space so the final result never exceeds max_bytes.
-// NOTE: This function is only called with max_bytes = 60 * 1024, so the
-// hardcoded "60KB" in the notice is intentional and always accurate.
-const TRUNCATION_NOTICE: &str =
-    "\n\\ Diff truncated at 60KB. Full diff available at the source repository.";
-
-/// Truncate a diff to at most `max_bytes` bytes, cutting at a hunk boundary
-/// where possible. Returns the (possibly truncated) string and a flag indicating
-/// whether truncation occurred.
-///
-/// The truncation notice is included within the `max_bytes` budget — the
-/// returned string is guaranteed to be `<= max_bytes` in length.
-fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) {
-    debug_assert!(
-        max_bytes >= TRUNCATION_NOTICE.len(),
-        "max_bytes ({max_bytes}) must be >= TRUNCATION_NOTICE length ({})",
-        TRUNCATION_NOTICE.len()
-    );
-
-    if diff.len() <= max_bytes {
-        return (diff.to_string(), false);
-    }
-
-    // Reserve space for the truncation notice so the final result stays within max_bytes.
-    let effective_limit = max_bytes.saturating_sub(TRUNCATION_NOTICE.len());
-
-    // Step 1: Find the last UTF-8 char boundary at or before effective_limit
-    let utf8_boundary = diff
-        .char_indices()
-        .map(|(i, _)| i)
-        .take_while(|&i| i <= effective_limit)
-        .last()
-        .unwrap_or(0);
-
-    // Step 2: Within safe prefix, find last complete hunk boundary
-    let safe_prefix = &diff[..utf8_boundary];
-    let last_hunk_start = safe_prefix.rfind("\n@@");
-
-    let cut_point = match last_hunk_start {
-        Some(pos) if pos > 0 => pos,
-        _ => safe_prefix.rfind('\n').unwrap_or(utf8_boundary),
-    };
-
-    let mut result = diff[..cut_point].to_string();
-    result.push_str(TRUNCATION_NOTICE);
-    (result, true)
-}
-
-/// Infer a language name from a file path's extension for syntax highlighting.
-/// Returns `None` if the extension is unknown or absent.
-fn infer_language(file_path: &str) -> Option<String> {
-    // Note: rsplit always yields at least one element (the full string if no '.' found),
-    // so .next() always returns Some. The ? is effectively a no-op here.
-    let ext = file_path.rsplit('.').next()?;
-    let lang = match ext {
-        "rs" => "rust",
-        "ts" | "tsx" => "typescript",
-        "js" | "jsx" => "javascript",
-        "py" => "python",
-        "go" => "go",
-        "java" => "java",
-        "rb" => "ruby",
-        "c" | "h" => "c",
-        "cpp" | "cc" | "cxx" | "hpp" => "cpp",
-        "cs" => "csharp",
-        "swift" => "swift",
-        "kt" | "kts" => "kotlin",
-        "scala" => "scala",
-        "sh" | "bash" | "zsh" => "bash",
-        "sql" => "sql",
-        "html" | "htm" => "html",
-        "css" | "scss" | "sass" => "css",
-        "json" => "json",
-        "yaml" | "yml" => "yaml",
-        "toml" => "toml",
-        "xml" => "xml",
-        "md" | "markdown" => "markdown",
-        "dockerfile" => "dockerfile",
-        _ => return None,
-    };
-    Some(lang.to_string())
-}
-
-/// Parameters for the `upload_file` tool.
-#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
-pub struct UploadFileParams {
-    /// Local filesystem path to the file to upload.
-    pub file_path: String,
-}
-
-/// The MCP server that exposes Sprout relay functionality as tools.
-#[derive(Clone)]
-pub struct SproutMcpServer {
-    client: RelayClient,
-    tool_router: ToolRouter<Self>,
-}
-
-#[tool_router]
-impl SproutMcpServer {
-    /// Create a new [`SproutMcpServer`] backed by the given relay client.
-    ///
-    /// Pass `tools_to_remove` to filter out tools by name (e.g. from toolset config).
-    pub fn new(
-        client: RelayClient,
-        tools_to_remove: Option<std::collections::HashSet<&'static str>>,
-    ) -> Self {
-        let mut tool_router = Self::tool_router();
-        if let Some(ref remove) = tools_to_remove {
-            for name in remove {
-                tool_router.remove_route(name);
-            }
-        }
-
-        Self {
-            client,
-            tool_router,
-        }
-    }
-
-    /// Resolve a `ThreadRef` for SDK builders by fetching the parent event.
-    ///
-    /// Determines root vs. parent for NIP-10 markers:
-    /// - Direct reply: root == parent
-    /// - Nested reply: root is the thread root, parent is the immediate reply target
-    async fn resolve_thread_ref(
-        &self,
-        parent_event_id: &str,
-        parent_eid: EventId,
-    ) -> Result<sprout_sdk::ThreadRef, String> {
-        let filter = Filter::new().id(parent_eid).limit(1);
-        let events = self
-            .client
-            .query(vec![filter])
-            .await
-            .map_err(|e| format!("failed to fetch parent event: {e}"))?;
-
-        let parent_event = events
-            .first()
-            .ok_or_else(|| "parent event not found".to_string())?;
-
-        let tags_json = serde_json::to_value(&parent_event.tags)
-            .map_err(|e| format!("failed to serialize tags: {e}"))?;
-
-        let root_eid = match find_root_from_tags(&tags_json) {
-            Some(root_hex) if root_hex != parent_event_id => EventId::from_hex(&root_hex)
-                .map_err(|e| format!("failed to parse root event id: {e}"))?,
-            _ => parent_eid,
-        };
-
-        Ok(sprout_sdk::ThreadRef {
-            root_event_id: root_eid,
-            parent_event_id: parent_eid,
-        })
-    }
-
-    /// Send a message to a Sprout channel.
-    #[tool(
-        name = "send_message",
-        description = "Send a message to a Sprout channel. Content supports GitHub-flavored Markdown — \
-use fenced code blocks with a language tag for syntax-highlighted rendering. \
-Include `parent_event_id` to reply in a thread. \
-Set `broadcast_to_channel` to also surface the reply in the main channel timeline. \
-For forum channels, set `kind` to 45001 (post) or 45003 (comment with `parent_event_id`). \
-Default kind is 9 (stream message)."
-    )]
-    pub async fn send_message(&self, Parameters(p): Parameters<SendMessageParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        if p.content.len() > MAX_CONTENT_BYTES {
-            return format!(
-                "Error: content exceeds maximum size of {} bytes (got {})",
-                MAX_CONTENT_BYTES,
-                p.content.len()
-            );
-        }
-        if let Some(ref parent_id) = p.parent_event_id {
-            if parent_id.len() != 64 || !parent_id.chars().all(|c| c.is_ascii_hexdigit()) {
-                return format!(
-                    "Error: parent_event_id must be a 64-character hex string (got {:?})",
-                    parent_id
-                );
-            }
-        }
-        if let Some(ref mentions) = p.mention_pubkeys {
-            for pk in mentions {
-                if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) {
-                    return format!(
-                        "Error: mention_pubkeys entry must be a 64-character hex string (got {:?})",
-                        pk
-                    );
-                }
-            }
-        }
-
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let kind_num = p
-            .kind
-            .unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE as u16);
-        // Normalize explicit mentions, then merge auto-resolved `@names` from
-        // the body up to the SDK mention cap.
-        let explicit = p.mention_pubkeys.as_deref().unwrap_or(&[]);
-        let mut mentions = sprout_sdk::mentions::normalize_mention_pubkeys(explicit, None);
-        let auto = resolve_content_mentions(&self.client, &p.channel_id, &p.content).await;
-        sprout_sdk::mentions::merge_mentions(
-            &mut mentions,
-            &auto,
-            sprout_sdk::mentions::MENTION_CAP,
-        );
-
-        let mention_refs: Vec<&str> = mentions.iter().map(String::as_str).collect();
-        let broadcast = p.broadcast_to_channel.unwrap_or(false);
-
-        // Upload files and build media tags
-        let mut media_tags: Vec<Vec<String>> = Vec::new();
-        let mut media_content = String::new();
-        if let Some(ref paths) = p.file_paths {
-            for path in paths {
-                match crate::upload::upload_file(
-                    self.client.http_client(),
-                    self.client.keys(),
-                    &self.client.relay_http_url(),
-                    self.client.server_domain().as_deref(),
-                    path,
-                    self.client.auth_tag_json().as_deref(),
-                )
-                .await
-                {
-                    Ok(desc) => {
-                        media_tags.push(crate::upload::build_imeta_tag(&desc));
-                        if desc.mime_type.starts_with("video/") {
-                            media_content.push_str("\n![video](");
-                        } else {
-                            media_content.push_str("\n![image](");
-                        }
-                        media_content.push_str(&desc.url);
-                        media_content.push(')');
-                    }
-                    Err(e) => {
-                        return format!("Error uploading {}: {e}", path);
-                    }
-                }
-            }
-        }
-        let final_content = if media_content.is_empty() {
-            p.content.clone()
-        } else {
-            format!("{}{}", p.content, media_content)
-        };
-
-        // Build the event builder via SDK, routing by kind.
-        let builder = match kind_num as u32 {
-            sprout_core::kind::KIND_FORUM_POST => {
-                // kind 45001: forum post (no thread ref, no broadcast)
-                match sprout_sdk::build_forum_post(
-                    channel_uuid,
-                    &final_content,
-                    &mention_refs,
-                    &media_tags,
-                ) {
-                    Ok(b) => b,
-                    Err(e) => return format!("Error: {e}"),
-                }
-            }
-            sprout_core::kind::KIND_FORUM_COMMENT => {
-                // kind 45003: forum comment — requires parent_event_id
-                let parent_id = match p.parent_event_id.as_deref() {
-                    Some(id) => id,
-                    None => return "Error: kind 45003 requires parent_event_id".to_string(),
-                };
-                let parent_eid = match EventId::from_hex(parent_id) {
-                    Ok(id) => id,
-                    Err(e) => return format!("Error: invalid parent_event_id: {e}"),
-                };
-                // Fetch parent to resolve thread root for NIP-10 markers.
-                let thread_ref = match self.resolve_thread_ref(parent_id, parent_eid).await {
-                    Ok(tr) => tr,
-                    Err(e) => return format!("Error: {e}"),
-                };
-                match sprout_sdk::build_forum_comment(
-                    channel_uuid,
-                    &final_content,
-                    &thread_ref,
-                    &mention_refs,
-                    &media_tags,
-                ) {
-                    Ok(b) => b,
-                    Err(e) => return format!("Error: {e}"),
-                }
-            }
-            _ => {
-                // kind 9 (default) and any other stream message kinds.
-                let thread_ref = if let Some(ref parent_id) = p.parent_event_id {
-                    let parent_eid = match EventId::from_hex(parent_id) {
-                        Ok(id) => id,
-                        Err(e) => return format!("Error: invalid parent_event_id: {e}"),
-                    };
-                    match self.resolve_thread_ref(parent_id, parent_eid).await {
-                        Ok(tr) => Some(tr),
-                        Err(e) => return format!("Error: {e}"),
-                    }
-                } else {
-                    None
-                };
-                match sprout_sdk::build_message(
-                    channel_uuid,
-                    &final_content,
-                    thread_ref.as_ref(),
-                    &mention_refs,
-                    broadcast && p.parent_event_id.is_some(),
-                    &media_tags,
-                ) {
-                    Ok(b) => b,
-                    Err(e) => return format!("Error: {e}"),
-                }
-            }
-        };
-
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign message event: {e}"),
-        };
-
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Send a code diff to a Sprout channel as kind:40008.
-    #[tool(
-        name = "send_diff_message",
-        description = "Send a code diff to a Sprout channel with syntax highlighting and structured metadata. \
-Include `parent_event_id` to post the diff as a thread reply. \
-The diff is rendered with GitHub-quality visualization in the desktop client."
-    )]
-    pub async fn send_diff_message(
-        &self,
-        Parameters(p): Parameters<SendDiffMessageParams>,
-    ) -> String {
-        let SendDiffMessageParams {
-            channel_id,
-            diff,
-            repo_url,
-            commit_sha,
-            file_path,
-            parent_commit_sha,
-            source_branch,
-            target_branch,
-            pr_number,
-            language,
-            description,
-            parent_event_id,
-        } = p;
-
-        if let Err(e) = validate_uuid(&channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {channel_id}"),
-        };
-
-        // 1. Truncate diff at 60KB (UTF-8 safe)
-        let (diff_content, truncated) = truncate_diff(&diff, 60 * 1024);
-
-        // 2. Infer language from file extension if not provided
-        let lang = language.or_else(|| file_path.as_deref().and_then(infer_language));
-
-        // 3. Build NIP-31 alt text
-        let alt_text = match &description {
-            Some(desc) => format!(
-                "Diff: {} — {}",
-                file_path.as_deref().unwrap_or("diff"),
-                desc
-            ),
-            None => format!("Diff: {}", file_path.as_deref().unwrap_or("diff")),
-        };
-
-        // 4. Warn on partial branch metadata (both or neither required)
-        match (&source_branch, &target_branch) {
-            (Some(_), None) | (None, Some(_)) => {
-                tracing::warn!("send_diff_message: only one of source_branch/target_branch provided — both required, branch metadata omitted");
-            }
-            _ => {}
-        }
-        let branch = match (source_branch, target_branch) {
-            (Some(src), Some(tgt)) => Some((src, tgt)),
-            _ => None,
-        };
-
-        // 5. Resolve optional thread ref
-        let thread_ref = if let Some(ref parent_id) = parent_event_id {
-            let parent_eid = match EventId::from_hex(parent_id) {
-                Ok(id) => id,
-                Err(e) => return format!("Error: invalid parent_event_id: {e}"),
-            };
-            match self.resolve_thread_ref(parent_id, parent_eid).await {
-                Ok(tr) => Some(tr),
-                Err(e) => return format!("Error: {e}"),
-            }
-        } else {
-            None
-        };
-
-        // 6. Build signed event via SDK
-        let diff_meta = sprout_sdk::DiffMeta {
-            repo_url,
-            commit_sha,
-            file_path,
-            parent_commit: parent_commit_sha,
-            branch,
-            pr_number,
-            language: lang,
-            description,
-            truncated,
-            alt_text: Some(alt_text),
-        };
-        let builder = match sprout_sdk::build_diff_message(
-            channel_uuid,
-            &diff_content,
-            &diff_meta,
-            thread_ref.as_ref(),
-        ) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign diff event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Edit a message you previously sent.
-    #[tool(
-        name = "edit_message",
-        description = "Edit a message you previously sent. Creates an edit event (kind 40003) referencing the original."
-    )]
-    pub async fn edit_message(&self, Parameters(p): Parameters<EditMessageParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        if p.event_id.len() != 64 || !p.event_id.chars().all(|c| c.is_ascii_hexdigit()) {
-            return format!(
-                "Error: event_id must be a 64-character hex string (got {:?})",
-                p.event_id
-            );
-        }
-        if p.content.len() > MAX_CONTENT_BYTES {
-            return format!(
-                "Error: content exceeds maximum size of {} bytes (got {})",
-                MAX_CONTENT_BYTES,
-                p.content.len()
-            );
-        }
-
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let target_eid = match EventId::from_hex(&p.event_id) {
-            Ok(id) => id,
-            Err(e) => return format!("Error: invalid event_id: {e}"),
-        };
-
-        let builder = match sprout_sdk::build_edit(channel_uuid, target_eid, &p.content) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign edit event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Delete a message.
-    #[tool(
-        name = "delete_message",
-        description = "Delete a message. You must be the message author or a channel owner/admin."
-    )]
-    pub async fn delete_message(&self, Parameters(p): Parameters<DeleteMessageParams>) -> String {
-        if p.event_id.len() != 64 || !p.event_id.chars().all(|c| c.is_ascii_hexdigit()) {
-            return format!(
-                "Error: event_id must be a 64-character hex string (got {:?})",
-                p.event_id
-            );
-        }
-        let target_eid = match EventId::from_hex(&p.event_id) {
-            Ok(id) => id,
-            Err(e) => return format!("Error: invalid event_id: {e}"),
-        };
-
-        // Fetch the event to extract its channel_id (h-tag) — required by build_delete_message.
-        let filter = Filter::new().id(target_eid).limit(1);
-        let fetched = match self.client.query(vec![filter]).await {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to fetch event: {e}"),
-        };
-        let fetched_event = match fetched.first() {
-            Some(e) => e,
-            None => return format!("Error: event '{}' not found", p.event_id),
-        };
-        let channel_id_str = match fetched_event
-            .tags
-            .iter()
-            .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("h"))
-            .and_then(|t| t.as_slice().get(1).map(|v| v.to_string()))
-        {
-            Some(id) => id,
-            None => return "Error: could not find channel_id (h-tag) on event".to_string(),
-        };
-        let channel_uuid = match uuid::Uuid::parse_str(&channel_id_str) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: event h-tag is not a valid UUID: {channel_id_str}"),
-        };
-
-        let builder = match sprout_sdk::build_delete_message(channel_uuid, target_eid) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign delete event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Get recent messages from a Sprout channel.
-    #[tool(
-        name = "get_messages",
-        description = "Fetch recent top-level messages from a Sprout channel. Use `before` for backward \
-pagination and `since` for forward pagination (both Unix timestamps). When `since` is \
-provided without `before`, results are ordered oldest-first — useful for polling new \
-messages. Use `kinds` to filter by event type (e.g. \"45001\" for forum posts, \
-\"45002\" for votes). Thread summaries are included automatically. Threaded replies \
-are not returned — use `get_thread` to fetch the full reply tree for a specific message."
-    )]
-    pub async fn get_messages(&self, Parameters(p): Parameters<GetMessagesParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-
-        const MAX_HISTORY_LIMIT: usize = 200;
-        let limit = p.limit.unwrap_or(50).min(MAX_HISTORY_LIMIT as u32) as usize;
-
-        // Build filter for channel messages (stream messages, edits, diffs, forum posts/comments).
-        let message_kinds: Vec<Kind> = if let Some(ref kinds_str) = p.kinds {
-            kinds_str
-                .split(',')
-                .filter_map(|s| s.trim().parse::<u16>().ok())
-                .map(Kind::from)
-                .collect()
-        } else {
-            vec![
-                k(kind::KIND_STREAM_MESSAGE),
-                k(kind::KIND_STREAM_MESSAGE_V2),
-                k(kind::KIND_STREAM_MESSAGE_DIFF),
-                k(kind::KIND_FORUM_POST),
-                k(kind::KIND_FORUM_COMMENT),
-            ]
-        };
-
-        let mut filter = Filter::new()
-            .kinds(message_kinds)
-            .custom_tags(tag_h(), [&p.channel_id])
-            .limit(limit);
-
-        if let Some(before) = p.before {
-            filter = filter.until(nostr::Timestamp::from(before as u64));
-        }
-        if let Some(since) = p.since {
-            filter = filter.since(nostr::Timestamp::from(since as u64));
-        }
-
-        match self.client.query(vec![filter]).await {
-            Ok(mut events) => {
-                events.sort_by_key(|e| e.created_at);
-                let result: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        serde_json::json!({
-                            "id": e.id.to_hex(),
-                            "pubkey": e.pubkey.to_hex(),
-                            "kind": e.kind.as_u16(),
-                            "content": e.content,
-                            "created_at": e.created_at.as_secs(),
-                            "tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&result).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// List Sprout channels accessible to this agent.
-    #[tool(
-        name = "list_channels",
-        description = "List Sprout channels accessible to this agent"
-    )]
-    pub async fn list_channels(&self, Parameters(p): Parameters<ListChannelsParams>) -> String {
-        // Query channel metadata events (kind:41 = NIP-29 group metadata).
-        let filter = Filter::new()
-            .kind(k(kind::KIND_CHANNEL_METADATA))
-            .limit(500);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                let channels: Vec<serde_json::Value> = events
-                    .iter()
-                    .filter(|e| {
-                        if let Some(ref vis) = p.visibility {
-                            // Filter by visibility tag if specified
-                            e.tags
-                                .iter()
-                                .any(|t| {
-                                    let s = t.as_slice();
-                                    s.first().map(|v| v.as_str()) == Some("visibility")
-                                        && s.get(1).map(|v| v.as_str()) == Some(vis.as_str())
-                                })
-                        } else {
-                            true
-                        }
-                    })
-                    .map(|e| {
-                        let content: serde_json::Value =
-                            serde_json::from_str(&e.content).unwrap_or(serde_json::json!({}));
-                        let channel_id = e
-                            .tags
-                            .iter()
-                            .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d"))
-                            .and_then(|t| t.as_slice().get(1))
-                            .map(|s| s.to_string())
-                            .unwrap_or_default();
-                        serde_json::json!({
-                            "channel_id": channel_id,
-                            "name": content.get("name").and_then(|v| v.as_str()).unwrap_or(""),
-                            "description": content.get("about").and_then(|v| v.as_str()).unwrap_or(""),
-                            "created_at": e.created_at.as_secs(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&channels).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Create a new Sprout channel.
-    #[tool(
-        name = "create_channel",
-        description = "Create a new Sprout channel. channel_type must be 'stream' or 'forum'. visibility must be 'open' or 'private'."
-    )]
-    pub async fn create_channel(&self, Parameters(p): Parameters<CreateChannelParams>) -> String {
-        let channel_uuid = uuid::Uuid::new_v4();
-        let visibility = match p.visibility.as_str() {
-            "open" => sprout_sdk::Visibility::Open,
-            "private" => sprout_sdk::Visibility::Private,
-            other => {
-                return format!(
-                    "Error: invalid visibility: {other:?} (must be 'open' or 'private')"
-                )
-            }
-        };
-        let channel_type = match p.channel_type.as_str() {
-            "stream" => sprout_sdk::ChannelKind::Stream,
-            "forum" => sprout_sdk::ChannelKind::Forum,
-            other => {
-                return format!(
-                    "Error: invalid channel_type: {other:?} (must be 'stream' or 'forum')"
-                )
-            }
-        };
-        let builder = match sprout_sdk::build_create_channel(
-            channel_uuid,
-            &p.name,
-            Some(visibility),
-            Some(channel_type),
-            p.description.as_deref(),
-        ) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign create_channel event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "channel_id": channel_uuid.to_string(),
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Get the canvas (shared document) for a channel.
-    #[tool(
-        name = "get_canvas",
-        description = "Get the canvas (shared document) for a channel"
-    )]
-    pub async fn get_canvas(&self, Parameters(p): Parameters<GetCanvasParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        // Canvas is kind:40100 with #h tag = channel_id
-        let filter = Filter::new()
-            .kind(k(kind::KIND_CANVAS))
-            .custom_tags(tag_h(), [&p.channel_id])
-            .limit(1);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => match events.first() {
-                Some(event) => event.content.clone(),
-                None => "No canvas set for this channel.".to_string(),
-            },
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Set or update the canvas (shared document) for a channel.
-    #[tool(
-        name = "set_canvas",
-        description = "Set or update the canvas (shared document) for a channel"
-    )]
-    pub async fn set_canvas(&self, Parameters(p): Parameters<SetCanvasParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_set_canvas(channel_uuid, &p.content) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign set_canvas event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    // ── Workflow tools ────────────────────────────────────────────────────────
-
-    /// List workflows defined in a Sprout channel.
-    #[tool(
-        name = "list_workflows",
-        description = "List workflows defined in a Sprout channel"
-    )]
-    pub async fn list_workflows(&self, Parameters(p): Parameters<ListWorkflowsParams>) -> String {
-        if uuid::Uuid::parse_str(&p.channel_id).is_err() {
-            return format!("Error: channel_id '{}' is not a valid UUID", p.channel_id);
-        }
-        // Workflows are kind:30620 (param-replaceable) with #h tag = channel_id
-        let filter = Filter::new()
-            .kind(k(kind::KIND_WORKFLOW_DEF))
-            .custom_tags(tag_h(), [&p.channel_id])
-            .limit(100);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                let workflows: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        let d_tag = e
-                            .tags
-                            .iter()
-                            .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d"))
-                            .and_then(|t| t.as_slice().get(1))
-                            .map(|s| s.to_string())
-                            .unwrap_or_default();
-                        serde_json::json!({
-                            "workflow_id": d_tag,
-                            "content": e.content,
-                            "created_at": e.created_at.as_secs(),
-                            "pubkey": e.pubkey.to_hex(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&workflows).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Create a new workflow in a channel from a YAML definition.
-    #[tool(
-        name = "create_workflow",
-        description = "Create a new workflow from a YAML definition. Steps need 'id' (not 'name'), and action fields are direct properties (not nested under 'params'). Triggers: message_posted, reaction_added, webhook."
-    )]
-    pub async fn create_workflow(&self, Parameters(p): Parameters<CreateWorkflowParams>) -> String {
-        if uuid::Uuid::parse_str(&p.channel_id).is_err() {
-            return format!("Error: channel_id '{}' is not a valid UUID", p.channel_id);
-        }
-        // Workflow definition is a kind:30620 (param-replaceable) event.
-        // d-tag = workflow UUID, h-tag = channel_id, content = YAML.
-        let workflow_id = uuid::Uuid::new_v4().to_string();
-        let tags = vec![
-            Tag::parse(["d", &workflow_id]).unwrap(),
-            Tag::parse(["h", &p.channel_id]).unwrap(),
-        ];
-        let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_DEF), &p.yaml_definition).tags(tags);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign workflow event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "workflow_id": workflow_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Replace a workflow's YAML definition.
-    #[tool(
-        name = "update_workflow",
-        description = "Replace a workflow's YAML definition"
-    )]
-    pub async fn update_workflow(&self, Parameters(p): Parameters<UpdateWorkflowParams>) -> String {
-        if uuid::Uuid::parse_str(&p.workflow_id).is_err() {
-            return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id);
-        }
-        // Publish a new kind:30620 event with the same d-tag to replace the existing one.
-        let tags = vec![Tag::parse(["d", &p.workflow_id]).unwrap()];
-        let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_DEF), &p.yaml_definition).tags(tags);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign workflow event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Delete a workflow by ID.
-    #[tool(name = "delete_workflow", description = "Delete a workflow by ID")]
-    pub async fn delete_workflow(&self, Parameters(p): Parameters<DeleteWorkflowParams>) -> String {
-        if uuid::Uuid::parse_str(&p.workflow_id).is_err() {
-            return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id);
-        }
-        // Delete via kind:5 event referencing the workflow's d-tag coordinate.
-        let coordinate = format!(
-            "{}:{}:{}",
-            kind::KIND_WORKFLOW_DEF,
-            self.client.pubkey_hex(),
-            p.workflow_id
-        );
-        let tags = vec![Tag::parse(["a", &coordinate]).unwrap()];
-        let builder = EventBuilder::new(k(kind::KIND_DELETION), "").tags(tags);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign deletion event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) if ok.accepted => "Workflow deleted.".to_string(),
-            Ok(ok) => format!("Error: relay rejected deletion: {}", ok.message),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Manually trigger a workflow with optional input variables.
-    #[tool(
-        name = "trigger_workflow",
-        description = "Manually trigger a workflow with optional input variables"
-    )]
-    pub async fn trigger_workflow(
-        &self,
-        Parameters(p): Parameters<TriggerWorkflowParams>,
-    ) -> String {
-        if uuid::Uuid::parse_str(&p.workflow_id).is_err() {
-            return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id);
-        }
-        let inputs = p
-            .inputs
-            .unwrap_or(serde_json::Value::Object(Default::default()));
-        let content = serde_json::to_string(&inputs).unwrap_or_default();
-        let tags = vec![Tag::parse(["d", &p.workflow_id]).unwrap()];
-        let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_TRIGGER), &content).tags(tags);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign trigger event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Get execution history for a workflow.
-    #[tool(
-        name = "get_workflow_runs",
-        description = "Get execution history for a workflow"
-    )]
-    pub async fn get_workflow_runs(
-        &self,
-        Parameters(p): Parameters<GetWorkflowRunsParams>,
-    ) -> String {
-        if uuid::Uuid::parse_str(&p.workflow_id).is_err() {
-            return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id);
-        }
-        let limit = p.limit.unwrap_or(20).min(100) as usize;
-        // Query workflow execution events (kind:46001–46010) referencing this workflow.
-        let filter = Filter::new()
-            .kinds(vec![
-                k(kind::KIND_WORKFLOW_TRIGGERED),
-                k(kind::KIND_WORKFLOW_TRIGGERED + 1), // completed
-                k(kind::KIND_WORKFLOW_TRIGGERED + 2), // failed
-            ])
-            .custom_tags(tag_d(), [&p.workflow_id])
-            .limit(limit);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                let runs: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        serde_json::json!({
-                            "event_id": e.id.to_hex(),
-                            "kind": e.kind.as_u16(),
-                            "content": e.content,
-                            "created_at": e.created_at.as_secs(),
-                            "tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&runs).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Approve or deny a pending workflow approval step.
-    #[tool(
-        name = "approve_step",
-        description = "Approve or deny a pending workflow approval step"
-    )]
-    pub async fn approve_step(&self, Parameters(p): Parameters<ApproveStepParams>) -> String {
-        if uuid::Uuid::parse_str(&p.approval_token).is_err() {
-            return format!(
-                "Error: approval_token '{}' is not a valid UUID",
-                p.approval_token
-            );
-        }
-        let kind_num = if p.approved {
-            kind::KIND_APPROVAL_GRANT
-        } else {
-            kind::KIND_APPROVAL_DENY
-        };
-        let content = p.note.as_deref().unwrap_or("");
-        // The relay expects d-tag = hex(SHA256(token)), not the raw token UUID.
-        let token_hash = hex::encode(Sha256::digest(p.approval_token.as_bytes()));
-        let tags = vec![Tag::parse(["d", &token_hash]).unwrap()];
-        let builder = EventBuilder::new(k(kind_num), content).tags(tags);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign approval event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    // ── Feed tools ────────────────────────────────────────────────────────────
-
-    /// Get the agent's personalized home feed from the Sprout relay.
-    #[tool(
-        name = "get_feed",
-        description = "Get the agent's personalized home feed from the Sprout relay. \
-                       Returns mentions, needs-action items, channel activity, and agent activity. \
-                       Equivalent to what a human sees on the Home tab in the desktop app."
-    )]
-    pub async fn get_feed(&self, Parameters(p): Parameters<GetFeedParams>) -> String {
-        const MAX_FEED_LIMIT: usize = 50;
-        let limit = p
-            .limit
-            .map(|l| l.min(MAX_FEED_LIMIT as u32) as usize)
-            .unwrap_or(MAX_FEED_LIMIT);
-
-        // Query events that mention this agent (p-tag) or are in channels we're in.
-        let my_pubkey = self.client.pubkey_hex();
-        let mut filter = Filter::new()
-            .custom_tags(tag_p(), [&my_pubkey])
-            .limit(limit);
-
-        if let Some(since) = p.since {
-            filter = filter.since(nostr::Timestamp::from(since as u64));
-        }
-
-        match self.client.query(vec![filter]).await {
-            Ok(mut events) => {
-                events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
-                let feed: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        serde_json::json!({
-                            "id": e.id.to_hex(),
-                            "pubkey": e.pubkey.to_hex(),
-                            "kind": e.kind.as_u16(),
-                            "content": e.content,
-                            "created_at": e.created_at.as_secs(),
-                            "tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&feed).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error fetching feed: {e}"),
-        }
-    }
-
-    // ── Membership tools ──────────────────────────────────────────────────────
-
-    /// Add a member to a channel.
-    #[tool(
-        name = "add_channel_member",
-        description = "Add a member to a Sprout channel. Optionally specify a role (default: \"member\")."
-    )]
-    pub async fn add_channel_member(
-        &self,
-        Parameters(p): Parameters<AddChannelMemberParams>,
-    ) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let role = match p.role.as_deref() {
-            None => None,
-            Some("owner") => Some(sprout_sdk::MemberRole::Owner),
-            Some("admin") => Some(sprout_sdk::MemberRole::Admin),
-            Some("member") => Some(sprout_sdk::MemberRole::Member),
-            Some("guest") => Some(sprout_sdk::MemberRole::Guest),
-            Some("bot") => Some(sprout_sdk::MemberRole::Bot),
-            Some(other) => {
-                return format!(
-                    "Error: invalid role: {other:?} (must be owner/admin/member/guest/bot)"
-                )
-            }
-        };
-        let builder = match sprout_sdk::build_add_member(channel_uuid, &p.pubkey, role) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign add_member event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Remove a member from a channel.
-    #[tool(
-        name = "remove_channel_member",
-        description = "Remove a member from a Sprout channel by their public key."
-    )]
-    pub async fn remove_channel_member(
-        &self,
-        Parameters(p): Parameters<RemoveChannelMemberParams>,
-    ) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_remove_member(channel_uuid, &p.pubkey) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign remove_member event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// List all members of a channel.
-    #[tool(
-        name = "list_channel_members",
-        description = "List all members of a Sprout channel."
-    )]
-    pub async fn list_channel_members(
-        &self,
-        Parameters(p): Parameters<ListChannelMembersParams>,
-    ) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        // Query membership list event (kind:39002 = NIP-29 group members) for this channel.
-        let filter = Filter::new()
-            .kind(k(kind::KIND_NIP29_GROUP_MEMBERS))
-            .custom_tags(tag_d(), [&p.channel_id])
-            .limit(1);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => match events.first() {
-                Some(event) => {
-                    // Members are in p-tags: ["p", "<pubkey>", "<role>"]
-                    let members: Vec<serde_json::Value> = event
-                        .tags
-                        .iter()
-                        .filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p"))
-                        .map(|t| {
-                            let s = t.as_slice();
-                            serde_json::json!({
-                                "pubkey": s.get(1).map(|v| v.as_str()).unwrap_or(""),
-                                "role": s.get(2).map(|v| v.as_str()).unwrap_or("member"),
-                            })
-                        })
-                        .collect();
-                    serde_json::to_string(&members).unwrap_or_else(|e| format!("Error: {e}"))
-                }
-                None => "[]".to_string(),
-            },
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Join a channel (add yourself as a member).
-    #[tool(
-        name = "join_channel",
-        description = "Join a Sprout channel (adds the agent as a member)."
-    )]
-    pub async fn join_channel(&self, Parameters(p): Parameters<JoinChannelParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_join(channel_uuid) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign join event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Leave a channel (remove yourself as a member).
-    #[tool(
-        name = "leave_channel",
-        description = "Leave a Sprout channel (removes the agent as a member)."
-    )]
-    pub async fn leave_channel(&self, Parameters(p): Parameters<LeaveChannelParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_leave(channel_uuid) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign leave event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Get details for a single channel.
-    #[tool(
-        name = "get_channel",
-        description = "Get metadata and details for a single Sprout channel by ID."
-    )]
-    pub async fn get_channel(&self, Parameters(p): Parameters<GetChannelParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        // Query channel metadata (kind:41) with d-tag = channel_id.
-        let filter = Filter::new()
-            .kind(k(kind::KIND_CHANNEL_METADATA))
-            .custom_tags(tag_d(), [&p.channel_id])
-            .limit(1);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => match events.first() {
-                Some(event) => {
-                    let content: serde_json::Value =
-                        serde_json::from_str(&event.content).unwrap_or(serde_json::json!({}));
-                    serde_json::json!({
-                        "channel_id": p.channel_id,
-                        "name": content.get("name").and_then(|v| v.as_str()).unwrap_or(""),
-                        "description": content.get("about").and_then(|v| v.as_str()).unwrap_or(""),
-                        "created_at": event.created_at.as_secs(),
-                        "pubkey": event.pubkey.to_hex(),
-                    })
-                    .to_string()
-                }
-                None => format!("Error: channel '{}' not found", p.channel_id),
-            },
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    // ── Metadata tools ────────────────────────────────────────────────────────
-
-    /// Update a channel's name and/or description.
-    #[tool(
-        name = "update_channel",
-        description = "Update a Sprout channel's name and/or description."
-    )]
-    pub async fn update_channel(&self, Parameters(p): Parameters<UpdateChannelParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_update_channel(
-            channel_uuid,
-            p.name.as_deref(),
-            p.description.as_deref(),
-        ) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign update_channel event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Set the topic for a channel.
-    #[tool(
-        name = "set_channel_topic",
-        description = "Set the topic for a Sprout channel."
-    )]
-    pub async fn set_channel_topic(
-        &self,
-        Parameters(p): Parameters<SetChannelTopicParams>,
-    ) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_set_topic(channel_uuid, &p.topic) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign set_topic event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Set the purpose for a channel.
-    #[tool(
-        name = "set_channel_purpose",
-        description = "Set the purpose for a Sprout channel."
-    )]
-    pub async fn set_channel_purpose(
-        &self,
-        Parameters(p): Parameters<SetChannelPurposeParams>,
-    ) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_set_purpose(channel_uuid, &p.purpose) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign set_purpose event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Archive a channel (makes it read-only).
-    #[tool(
-        name = "archive_channel",
-        description = "Archive a Sprout channel, making it read-only."
-    )]
-    pub async fn archive_channel(&self, Parameters(p): Parameters<ArchiveChannelParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_archive(channel_uuid) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign archive event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Unarchive a channel (restores it to active).
-    #[tool(
-        name = "unarchive_channel",
-        description = "Unarchive a Sprout channel, restoring it to active status."
-    )]
-    pub async fn unarchive_channel(
-        &self,
-        Parameters(p): Parameters<UnarchiveChannelParams>,
-    ) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_unarchive(channel_uuid) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign unarchive event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    // ── Thread tools ──────────────────────────────────────────────────────────
-
-    /// Get a message thread (replies to a message).
-    #[tool(
-        name = "get_thread",
-        description = "Fetch a full thread tree rooted at an event. Returns the root message and all nested \
-replies. Works for both stream message threads and forum post threads (kind:45001 root \
-with kind:45003 comments)."
-    )]
-    pub async fn get_thread(&self, Parameters(p): Parameters<GetThreadParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-
-        let limit = p.limit.map(|l| l.min(200) as usize).unwrap_or(100);
-
-        // Query events that reference the root event via e-tag.
-        let filter = Filter::new()
-            .custom_tags(tag_e(), [&p.event_id])
-            .custom_tags(tag_h(), [&p.channel_id])
-            .limit(limit);
-
-        // Also fetch the root event itself.
-        let root_eid = match EventId::from_hex(&p.event_id) {
-            Ok(id) => id,
-            Err(e) => return format!("Error: invalid event_id: {e}"),
-        };
-        let root_filter = Filter::new().id(root_eid).limit(1);
-
-        match self.client.query(vec![filter, root_filter]).await {
-            Ok(mut events) => {
-                events.sort_by_key(|e| e.created_at);
-                let result: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        serde_json::json!({
-                            "id": e.id.to_hex(),
-                            "pubkey": e.pubkey.to_hex(),
-                            "kind": e.kind.as_u16(),
-                            "content": e.content,
-                            "created_at": e.created_at.as_secs(),
-                            "tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&result).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    // ── DM tools ──────────────────────────────────────────────────────────────
-
-    /// Open or retrieve a direct message channel with one or more participants.
-    #[tool(
-        name = "open_dm",
-        description = "Open (or retrieve an existing) direct message channel with 1–8 other participants. \
-                       Returns the DM channel details including its ID."
-    )]
-    pub async fn open_dm(&self, Parameters(p): Parameters<OpenDmParams>) -> String {
-        if p.pubkeys.is_empty() {
-            return "Error: pubkeys must contain at least one participant".to_string();
-        }
-        if p.pubkeys.len() > 8 {
-            return format!(
-                "Error: too many participants (max 8, got {})",
-                p.pubkeys.len()
-            );
-        }
-        // Command event kind:41010 with p-tags for each participant.
-        let mut tags: Vec<Tag> = p
-            .pubkeys
-            .iter()
-            .filter_map(|pk| Tag::parse(["p", pk]).ok())
-            .collect();
-        // Add a unique d-tag so the relay can deduplicate.
-        let dm_id = uuid::Uuid::new_v4().to_string();
-        tags.push(Tag::parse(["d", &dm_id]).unwrap());
-
-        let builder = EventBuilder::new(k(kind::KIND_DM_OPEN), "").tags(tags);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign open_dm event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "dm_id": dm_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Add a participant to an existing DM channel.
-    #[tool(
-        name = "add_dm_member",
-        description = "Add a participant to an existing Sprout DM channel."
-    )]
-    pub async fn add_dm_member(&self, Parameters(p): Parameters<AddDmMemberParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        // Command event kind:41011 with h-tag = DM channel, p-tag = new member.
-        let tags = vec![
-            Tag::parse(["h", &p.channel_id]).unwrap(),
-            Tag::parse(["p", &p.pubkey]).unwrap(),
-        ];
-        let builder = EventBuilder::new(k(kind::KIND_DM_ADD_MEMBER), "").tags(tags);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign add_dm_member event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// List all DM channels the agent is a participant in.
-    #[tool(
-        name = "list_dms",
-        description = "List all direct message channels the agent is a participant in."
-    )]
-    pub async fn list_dms(&self) -> String {
-        // Query DM-created events (kind:41001) where we are a participant (p-tag).
-        let my_pubkey = self.client.pubkey_hex();
-        let filter = Filter::new()
-            .kind(k(kind::KIND_DM_CREATED))
-            .custom_tags(tag_p(), [&my_pubkey])
-            .limit(100);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                let dms: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        let participants: Vec<&str> = e
-                            .tags
-                            .iter()
-                            .filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p"))
-                            .filter_map(|t| t.as_slice().get(1).map(|v| v.as_str()))
-                            .collect();
-                        let dm_id = e
-                            .tags
-                            .iter()
-                            .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d"))
-                            .and_then(|t| t.as_slice().get(1))
-                            .map(|s| s.to_string())
-                            .unwrap_or_default();
-                        serde_json::json!({
-                            "dm_id": dm_id,
-                            "participants": participants,
-                            "created_at": e.created_at.as_secs(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&dms).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Hide a DM channel from the agent's DM list.
-    #[tool(
-        name = "hide_dm",
-        description = "Hide a direct message channel from the agent's DM list. The DM can be restored by opening a new DM with the same participants."
-    )]
-    pub async fn hide_dm(&self, Parameters(p): Parameters<HideDmParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        // Command event kind:41012 with h-tag = DM channel.
-        let tags = vec![Tag::parse(["h", &p.channel_id]).unwrap()];
-        let builder = EventBuilder::new(k(kind::KIND_DM_HIDE), "").tags(tags);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign hide_dm event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) if ok.accepted => "DM hidden successfully.".to_string(),
-            Ok(ok) => format!("Error: relay rejected: {}", ok.message),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    // ── Reaction tools ────────────────────────────────────────────────────────
-
-    /// Add an emoji reaction to a message.
-    #[tool(
-        name = "add_reaction",
-        description = "Add an emoji reaction to a Sprout message."
-    )]
-    pub async fn add_reaction(&self, Parameters(p): Parameters<AddReactionParams>) -> String {
-        let target_eid = match EventId::from_hex(&p.event_id) {
-            Ok(id) => id,
-            Err(e) => return format!("Error: invalid event_id: {e}"),
-        };
-        let builder = match sprout_sdk::build_reaction(target_eid, &p.emoji) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign reaction event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Remove an emoji reaction from a message.
-    #[tool(
-        name = "remove_reaction",
-        description = "Remove an emoji reaction from a Sprout message."
-    )]
-    pub async fn remove_reaction(&self, Parameters(p): Parameters<RemoveReactionParams>) -> String {
-        // Validate event_id format.
-        if EventId::from_hex(&p.event_id).is_err() {
-            return format!("Error: invalid event_id: {}", p.event_id);
-        }
-        let my_pubkey_parsed = self.client.keys().public_key();
-        let filter = Filter::new()
-            .kind(k(kind::KIND_REACTION))
-            .author(my_pubkey_parsed)
-            .custom_tags(tag_e(), [&p.event_id])
-            .limit(50);
-
-        let events = match self.client.query(vec![filter]).await {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to fetch reactions: {e}"),
-        };
-
-        // Find the reaction with matching emoji content.
-        let reaction_event = events.iter().find(|e| e.content == p.emoji);
-
-        match reaction_event {
-            Some(re) => {
-                let builder = match sprout_sdk::build_remove_reaction(re.id) {
-                    Ok(b) => b,
-                    Err(e) => return format!("Error: {e}"),
-                };
-                let event = match self.client.sign_event(builder) {
-                    Ok(e) => e,
-                    Err(e) => return format!("Error: failed to sign remove_reaction event: {e}"),
-                };
-                match self.client.send_event(event).await {
-                    Ok(ok) => serde_json::json!({
-                        "event_id": ok.event_id,
-                        "accepted": ok.accepted,
-                        "message": ok.message,
-                    })
-                    .to_string(),
-                    Err(e) => format!("Error: {e}"),
-                }
-            }
-            None => "Error: could not find your reaction event for this emoji. \
-                 The reaction may not exist."
-                .to_string(),
-        }
-    }
-
-    /// Get all reactions for a message.
-    #[tool(
-        name = "get_reactions",
-        description = "Get all emoji reactions for a Sprout message."
-    )]
-    pub async fn get_reactions(&self, Parameters(p): Parameters<GetReactionsParams>) -> String {
-        // Query kind:7 reactions referencing this event via e-tag.
-        let filter = Filter::new()
-            .kind(k(kind::KIND_REACTION))
-            .custom_tags(tag_e(), [&p.event_id])
-            .limit(200);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                // Group reactions by emoji content.
-                let mut grouped: std::collections::HashMap<String, Vec<String>> =
-                    std::collections::HashMap::new();
-                for e in &events {
-                    grouped
-                        .entry(e.content.clone())
-                        .or_default()
-                        .push(e.pubkey.to_hex());
-                }
-                let reactions: Vec<serde_json::Value> = grouped
-                    .into_iter()
-                    .map(|(emoji, pubkeys)| {
-                        serde_json::json!({
-                            "emoji": emoji,
-                            "count": pubkeys.len(),
-                            "pubkeys": pubkeys,
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&serde_json::json!({ "reactions": reactions }))
-                    .unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    // ── User profile tools ────────────────────────────────────────────────────
-
-    /// Update the agent's user profile.
-    #[tool(
-        name = "set_profile",
-        description = "Update the agent's user profile (display name, about, avatar URL, and/or NIP-05 handle)."
-    )]
-    pub async fn set_profile(&self, Parameters(p): Parameters<SetProfileParams>) -> String {
-        // Read-merge-write: fetch current profile, merge desired changes, sign kind:0.
-        let my_pubkey = self.client.keys().public_key();
-        let filter = Filter::new()
-            .kind(k(kind::KIND_PROFILE))
-            .author(my_pubkey)
-            .limit(1);
-        let current_profile: serde_json::Value = match self.client.query(vec![filter]).await {
-            Ok(events) => events
-                .first()
-                .and_then(|e| serde_json::from_str(&e.content).ok())
-                .unwrap_or(serde_json::Value::Object(Default::default())),
-            Err(_) => serde_json::Value::Object(Default::default()),
-        };
-
-        // Resolve each field: use new value if provided, else keep existing.
-        let display_name = p
-            .display_name
-            .as_deref()
-            .or_else(|| current_profile.get("display_name").and_then(|v| v.as_str()));
-        let name = current_profile.get("name").and_then(|v| v.as_str());
-        let picture = p
-            .avatar_url
-            .as_deref()
-            .or_else(|| current_profile.get("avatar_url").and_then(|v| v.as_str()));
-        let about = p
-            .about
-            .as_deref()
-            .or_else(|| current_profile.get("about").and_then(|v| v.as_str()));
-        let nip05 = p
-            .nip05_handle
-            .as_deref()
-            .or_else(|| current_profile.get("nip05_handle").and_then(|v| v.as_str()));
-
-        let builder = match sprout_sdk::build_profile(display_name, name, picture, about, nip05) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign profile event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Get user profile(s) by pubkey.
-    #[tool(
-        name = "get_users",
-        description = "Get user profile(s). Omit pubkeys for your own profile, provide one for a specific user, or provide multiple for batch lookup (max 200)."
-    )]
-    pub async fn get_users(&self, Parameters(p): Parameters<GetUsersParams>) -> String {
-        let pubkeys = p.pubkeys.unwrap_or_default();
-        if pubkeys.len() > 200 {
-            return "Error: max 200 pubkeys for batch lookup".to_string();
-        }
-        for pk in &pubkeys {
-            if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) {
-                return format!(
-                    "Error: pubkey must be a 64-character hex string (got {:?})",
-                    pk
-                );
-            }
-        }
-
-        // If no pubkeys specified, fetch our own profile.
-        let authors: Vec<nostr::PublicKey> = if pubkeys.is_empty() {
-            vec![self.client.keys().public_key()]
-        } else {
-            pubkeys
-                .iter()
-                .filter_map(|pk| nostr::PublicKey::from_hex(pk).ok())
-                .collect()
-        };
-
-        let filter = Filter::new()
-            .kind(k(kind::KIND_PROFILE))
-            .authors(authors)
-            .limit(pubkeys.len().max(1));
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                let profiles: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        let mut profile: serde_json::Value =
-                            serde_json::from_str(&e.content).unwrap_or(serde_json::json!({}));
-                        if let Some(obj) = profile.as_object_mut() {
-                            obj.insert("pubkey".to_string(), serde_json::json!(e.pubkey.to_hex()));
-                        }
-                        profile
-                    })
-                    .collect();
-                if profiles.len() == 1 {
-                    serde_json::to_string(&profiles[0]).unwrap_or_else(|e| format!("Error: {e}"))
-                } else {
-                    serde_json::to_string(&profiles).unwrap_or_else(|e| format!("Error: {e}"))
-                }
-            }
-            Err(e) => format!("Error fetching profile: {e}"),
-        }
-    }
-
-    /// Full-text search across messages.
-    #[tool(
-        name = "search",
-        description = "Full-text search across messages in accessible channels. Returns matching messages with channel context. Powered by Typesense."
-    )]
-    pub async fn search(&self, Parameters(p): Parameters<SearchParams>) -> String {
-        let limit = p.limit.unwrap_or(20).min(100) as usize;
-        // Use NIP-50 search filter if the relay supports it.
-        // The `search` field in a filter is a NIP-50 extension.
-        let filter = Filter::new()
-            .kinds(vec![
-                k(kind::KIND_STREAM_MESSAGE),
-                k(kind::KIND_STREAM_MESSAGE_V2),
-                k(kind::KIND_FORUM_POST),
-                k(kind::KIND_FORUM_COMMENT),
-            ])
-            .search(&p.q)
-            .limit(limit);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                let results: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        serde_json::json!({
-                            "id": e.id.to_hex(),
-                            "pubkey": e.pubkey.to_hex(),
-                            "kind": e.kind.as_u16(),
-                            "content": e.content,
-                            "created_at": e.created_at.as_secs(),
-                            "tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&results).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error searching: {e}"),
-        }
-    }
-
-    /// Get presence status for one or more users.
-    #[tool(
-        name = "get_presence",
-        description = "Get presence status (online/away/offline) for one or more users by pubkey. Pass comma-separated hex pubkeys."
-    )]
-    pub async fn get_presence(&self, Parameters(p): Parameters<GetPresenceParams>) -> String {
-        // Query ephemeral presence events (kind:20001) for the given pubkeys.
-        let authors: Vec<nostr::PublicKey> = p
-            .pubkeys
-            .split(',')
-            .filter_map(|pk| nostr::PublicKey::from_hex(pk.trim()).ok())
-            .collect();
-
-        if authors.is_empty() {
-            return "Error: no valid pubkeys provided".to_string();
-        }
-
-        // Presence snapshots are kind:40902 (relay-generated, latest state).
-        let filter = Filter::new()
-            .kind(k(kind::KIND_PRESENCE_SNAPSHOT))
-            .authors(authors)
-            .limit(200);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                let presence: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        serde_json::json!({
-                            "pubkey": e.pubkey.to_hex(),
-                            "status": e.content,
-                            "updated_at": e.created_at.as_secs(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&presence).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error fetching presence: {e}"),
-        }
-    }
-
-    /// Set the agent's presence status.
-    #[tool(
-        name = "set_presence",
-        description = "Set the agent's presence status. Valid values: 'online', 'away', 'offline'. Presence auto-expires after 90 seconds — call periodically to stay online."
-    )]
-    pub async fn set_presence(&self, Parameters(p): Parameters<SetPresenceParams>) -> String {
-        // Validate status value.
-        // Publish ephemeral presence event (kind:20001).
-        let builder = EventBuilder::new(k(kind::KIND_PRESENCE_UPDATE), p.status.as_str()).tags([]);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign presence event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "status": p.status,
-                "accepted": ok.accepted,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Set this agent's channel addition policy.
-    #[tool(
-        name = "set_channel_add_policy",
-        description = "Set your channel addition policy. 'anyone' = any authenticated user can add you to open channels (default). 'owner_only' = only your provisioned owner can add you. 'nobody' = no one can add you; you may self-join open channels via join_channel, but private channels are inaccessible until a consent flow is implemented."
-    )]
-    pub async fn set_channel_add_policy(
-        &self,
-        Parameters(p): Parameters<SetChannelAddPolicyParams>,
-    ) -> String {
-        if !matches!(p.policy.as_str(), "anyone" | "owner_only" | "nobody") {
-            return format!(
-                "Error: invalid policy {:?} — must be 'anyone', 'owner_only', or 'nobody'",
-                p.policy
-            );
-        }
-        // Store as a kind:10100 (agent profile) replaceable event with the policy in content.
-        let content = serde_json::json!({ "channel_add_policy": p.policy }).to_string();
-        let builder = EventBuilder::new(k(kind::KIND_AGENT_PROFILE), &content).tags([]);
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign agent profile event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "policy": p.policy,
-                "accepted": ok.accepted,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Vote on a forum post or comment (kind:45002).
-    #[tool(
-        name = "vote_on_post",
-        description = "Vote on a forum post or comment. Creates a kind:45002 event. \
-                       Each vote is a separate event — vote deduplication is not yet enforced."
-    )]
-    pub async fn vote_on_post(&self, Parameters(p): Parameters<VoteOnPostParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        if p.event_id.len() != 64 || !p.event_id.chars().all(|c| c.is_ascii_hexdigit()) {
-            return format!(
-                "Error: event_id must be a 64-character hex string (got {:?})",
-                p.event_id
-            );
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let target_eid = match EventId::from_hex(&p.event_id) {
-            Ok(id) => id,
-            Err(e) => return format!("Error: invalid event_id: {e}"),
-        };
-        let direction = match p.direction {
-            VoteDirection::Up => sprout_sdk::VoteDirection::Up,
-            VoteDirection::Down => sprout_sdk::VoteDirection::Down,
-        };
-        let builder = match sprout_sdk::build_vote(channel_uuid, target_eid, direction) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign vote event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Permanently delete a Sprout channel.
-    #[tool(
-        name = "delete_channel",
-        description = "Permanently delete a Sprout channel. You must be the channel owner. This action is irreversible."
-    )]
-    pub async fn delete_channel(&self, Parameters(p): Parameters<DeleteChannelParams>) -> String {
-        if let Err(e) = validate_uuid(&p.channel_id) {
-            return format!("Error: {e}");
-        }
-        let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
-            Ok(u) => u,
-            Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
-        };
-        let builder = match sprout_sdk::build_delete_channel(channel_uuid) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign delete_channel event: {e}"),
-        };
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    // ── Social tools ─────────────────────────────────────────────────────────
-
-    /// Publish a kind:1 text note (global, no channel scope).
-    #[tool(
-        name = "publish_note",
-        description = "Publish a short text note (kind:1) to the global feed. Optionally reply to another note by event ID."
-    )]
-    pub async fn publish_note(&self, Parameters(p): Parameters<PublishNoteParams>) -> String {
-        let reply_id = match p.reply_to_event_id.as_deref() {
-            Some(hex) => match EventId::from_hex(hex) {
-                Ok(id) => Some(id),
-                Err(e) => return format!("Error: invalid reply_to_event_id: {e}"),
-            },
-            None => None,
-        };
-
-        if p.content.len() > 64 * 1024 {
-            return format!(
-                "Error: content exceeds 64 KiB limit ({} bytes)",
-                p.content.len()
-            );
-        }
-
-        let builder = match sprout_sdk::build_note(&p.content, reply_id) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign event: {e}"),
-        };
-
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Replace the authenticated user's contact list (kind:3).
-    #[tool(
-        name = "set_contact_list",
-        description = "Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. Call get_contact_list first for delta updates."
-    )]
-    pub async fn set_contact_list(
-        &self,
-        Parameters(p): Parameters<SetContactListParams>,
-    ) -> String {
-        let contacts: Vec<(&str, Option<&str>, Option<&str>)> = p
-            .contacts
-            .iter()
-            .map(|c| {
-                (
-                    c.pubkey.as_str(),
-                    c.relay_url.as_deref(),
-                    c.petname.as_deref(),
-                )
-            })
-            .collect();
-
-        let builder = match sprout_sdk::build_contact_list(&contacts) {
-            Ok(b) => b,
-            Err(e) => return format!("Error: {e}"),
-        };
-
-        let event = match self.client.sign_event(builder) {
-            Ok(e) => e,
-            Err(e) => return format!("Error: failed to sign event: {e}"),
-        };
-
-        match self.client.send_event(event).await {
-            Ok(ok) => serde_json::json!({
-                "event_id": ok.event_id,
-                "accepted": ok.accepted,
-                "message": ok.message,
-            })
-            .to_string(),
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Fetch a single event by event ID.
-    #[tool(
-        name = "get_event",
-        description = "Fetch a single event by its 64-char hex event ID. For global events: kind:0 profiles and kind:3 contacts require UsersRead scope; kind:1 notes and kind:30023 articles require MessagesRead scope. For channel events: requires MessagesRead scope and channel membership. Unknown kinds return 404."
-    )]
-    pub async fn get_event(&self, Parameters(p): Parameters<GetEventParams>) -> String {
-        if let Err(e) = validate_hex64(&p.event_id, "event_id") {
-            return e;
-        }
-        let eid = match EventId::from_hex(&p.event_id) {
-            Ok(id) => id,
-            Err(e) => return format!("Error: invalid event_id: {e}"),
-        };
-        let filter = Filter::new().id(eid).limit(1);
-        match self.client.query(vec![filter]).await {
-            Ok(events) => match events.first() {
-                Some(event) => event.as_json(),
-                None => format!("Error: event '{}' not found", p.event_id),
-            },
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// List notes by a specific user.
-    #[tool(
-        name = "get_user_notes",
-        description = "List kind:1 text notes by a specific user (by hex pubkey). Returns id, pubkey, created_at, and content per note (tags and sig omitted — use get_event for full events). Supports composite cursor pagination via `before` (Unix timestamp) and `before_id` (hex event ID)."
-    )]
-    pub async fn get_user_notes(&self, Parameters(p): Parameters<GetUserNotesParams>) -> String {
-        if let Err(e) = validate_hex64(&p.pubkey, "pubkey") {
-            return e;
-        }
-        let author = match nostr::PublicKey::from_hex(&p.pubkey) {
-            Ok(pk) => pk,
-            Err(e) => return format!("Error: invalid pubkey: {e}"),
-        };
-        let limit = p.limit.unwrap_or(20).min(200) as usize;
-        let mut filter = Filter::new()
-            .kind(k(kind::KIND_TEXT_NOTE))
-            .author(author)
-            .limit(limit);
-
-        if let Some(before) = p.before {
-            filter = filter.until(nostr::Timestamp::from(before as u64));
-        }
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => {
-                let notes: Vec<serde_json::Value> = events
-                    .iter()
-                    .map(|e| {
-                        serde_json::json!({
-                            "id": e.id.to_hex(),
-                            "pubkey": e.pubkey.to_hex(),
-                            "content": e.content,
-                            "created_at": e.created_at.as_secs(),
-                        })
-                    })
-                    .collect();
-                serde_json::to_string(&notes).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Get a user's contact/follow list.
-    #[tool(
-        name = "get_contact_list",
-        description = "Get a user's contact/follow list (kind:3) by hex pubkey. Returns the latest replaceable event."
-    )]
-    pub async fn get_contact_list(
-        &self,
-        Parameters(p): Parameters<GetContactListParams>,
-    ) -> String {
-        if let Err(e) = validate_hex64(&p.pubkey, "pubkey") {
-            return e;
-        }
-        let author = match nostr::PublicKey::from_hex(&p.pubkey) {
-            Ok(pk) => pk,
-            Err(e) => return format!("Error: invalid pubkey: {e}"),
-        };
-        // Kind:3 is replaceable — query latest.
-        let filter = Filter::new()
-            .kind(k(kind::KIND_CONTACT_LIST))
-            .author(author)
-            .limit(1);
-
-        match self.client.query(vec![filter]).await {
-            Ok(events) => match events.first() {
-                Some(event) => event.as_json(),
-                None => format!("Error: no contact list found for {}", p.pubkey),
-            },
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-
-    /// Upload a local file to the Sprout relay.
-    #[tool(
-        name = "upload_file",
-        description = "Upload a local file (image or video) to the Sprout relay. \
-Returns a BlobDescriptor with the URL, hash, dimensions, and other metadata. \
-Supported types: JPEG, PNG, GIF, WebP, MP4. \
-The returned URL can be included in messages, or use the file_paths parameter \
-on send_message to upload and attach in one step."
-    )]
-    pub async fn upload_file(&self, Parameters(p): Parameters<UploadFileParams>) -> String {
-        match crate::upload::upload_file(
-            self.client.http_client(),
-            self.client.keys(),
-            &self.client.relay_http_url(),
-            self.client.server_domain().as_deref(),
-            &p.file_path,
-            self.client.auth_tag_json().as_deref(),
-        )
-        .await
-        {
-            Ok(desc) => {
-                serde_json::to_string_pretty(&desc).unwrap_or_else(|e| format!("Error: {e}"))
-            }
-            Err(e) => format!("Error: {e}"),
-        }
-    }
-}
-
-#[tool_handler(router = self.tool_router)]
-impl ServerHandler for SproutMcpServer {
-    fn get_info(&self) -> ServerInfo {
-        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
-            .with_server_info(rmcp::model::Implementation::new(
-                "sprout-mcp",
-                env!("CARGO_PKG_VERSION"),
-            ))
-            .with_instructions(
-                "Sprout MCP server — interact with the Sprout relay. \
-                 Send messages, read channel history, create channels, \
-                 manage canvases, create and manage workflows, \
-                 and read your personalized home feed."
-                    .to_string(),
-            )
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    // ── percent_encode ────────────────────────────────────────────────────────
-
-    #[test]
-    fn percent_encode_empty_string() {
-        assert_eq!(percent_encode(""), "");
-    }
-
-    #[test]
-    fn percent_encode_already_safe_chars() {
-        // Unreserved chars (RFC 3986) must pass through unchanged.
-        let safe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
-        assert_eq!(percent_encode(safe), safe);
-    }
-
-    #[test]
-    fn percent_encode_space() {
-        assert_eq!(percent_encode(" "), "%20");
-    }
-
-    #[test]
-    fn percent_encode_special_chars() {
-        assert_eq!(percent_encode("hello world"), "hello%20world");
-        assert_eq!(percent_encode("a&b=c"), "a%26b%3Dc");
-        assert_eq!(percent_encode("foo?bar"), "foo%3Fbar");
-    }
-
-    #[test]
-    fn percent_encode_slash() {
-        assert_eq!(percent_encode("/"), "%2F");
-    }
-
-    #[test]
-    fn percent_encode_unicode_multibyte() {
-        // "é" is 0xC3 0xA9 in UTF-8.
-        assert_eq!(percent_encode("é"), "%C3%A9");
-    }
-
-    // ── validate_uuid ─────────────────────────────────────────────────────────
-
-    #[test]
-    fn validate_uuid_valid() {
-        assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
-    }
-
-    #[test]
-    fn validate_uuid_valid_v4() {
-        assert!(validate_uuid("f47ac10b-58cc-4372-a567-0e02b2c3d479").is_ok());
-    }
-
-    #[test]
-    fn validate_uuid_invalid_string() {
-        let result = validate_uuid("not-a-uuid");
-        assert!(result.is_err());
-        assert!(result.unwrap_err().contains("invalid UUID"));
-    }
-
-    #[test]
-    fn validate_uuid_empty_string() {
-        let result = validate_uuid("");
-        assert!(result.is_err());
-        assert!(result.unwrap_err().contains("invalid UUID"));
-    }
-
-    #[test]
-    fn validate_uuid_almost_valid() {
-        // Missing one character in the last group.
-        let result = validate_uuid("550e8400-e29b-41d4-a716-44665544000");
-        assert!(result.is_err());
-    }
-
-    // ── MAX_CONTENT_BYTES ─────────────────────────────────────────────────────
-
-    #[test]
-    fn max_content_bytes_value() {
-        assert_eq!(MAX_CONTENT_BYTES, 65_536);
-    }
-
-    // ── VoteDirection serde ───────────────────────────────────────────────────
-
-    #[test]
-    fn vote_direction_serializes_lowercase() {
-        assert_eq!(serde_json::to_string(&VoteDirection::Up).unwrap(), "\"up\"");
-        assert_eq!(
-            serde_json::to_string(&VoteDirection::Down).unwrap(),
-            "\"down\""
-        );
-    }
-
-    #[test]
-    fn vote_direction_deserializes_lowercase() {
-        assert!(matches!(
-            serde_json::from_str::<VoteDirection>("\"up\"").unwrap(),
-            VoteDirection::Up
-        ));
-        assert!(matches!(
-            serde_json::from_str::<VoteDirection>("\"down\"").unwrap(),
-            VoteDirection::Down
-        ));
-    }
-
-    #[test]
-    fn vote_direction_rejects_invalid() {
-        assert!(serde_json::from_str::<VoteDirection>("\"sideways\"").is_err());
-        assert!(serde_json::from_str::<VoteDirection>("\"UP\"").is_err());
-        assert!(serde_json::from_str::<VoteDirection>("\"\"").is_err());
-    }
-
-    #[test]
-    fn vote_on_post_params_round_trip() {
-        let params = VoteOnPostParams {
-            channel_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
-            event_id: "a".repeat(64),
-            direction: VoteDirection::Up,
-        };
-        let json = serde_json::to_string(&params).unwrap();
-        let parsed: VoteOnPostParams = serde_json::from_str(&json).unwrap();
-        assert_eq!(parsed.channel_id, params.channel_id);
-        assert_eq!(parsed.event_id, params.event_id);
-        assert!(matches!(parsed.direction, VoteDirection::Up));
-    }
-
-    // Note: `extract_at_names` is now exercised by `sprout-sdk::mentions`
-    // tests. The MCP-side I/O wrapper around it (`resolve_content_mentions`)
-    // is covered by integration tests against a running relay.
-}
-
-#[cfg(test)]
-mod diff_tests {
-    use super::*;
-
-    #[test]
-    fn truncate_diff_small_passes_through() {
-        let diff = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n";
-        let (result, truncated) = truncate_diff(diff, 60 * 1024);
-        assert_eq!(result, diff);
-        assert!(!truncated);
-    }
-
-    #[test]
-    fn truncate_diff_cuts_at_hunk_boundary() {
-        // Build a diff large enough that truncation is meaningful.
-        // Repeat the first hunk many times so the total is well above any
-        // reasonable max_bytes, then append a second hunk we want excluded.
-        let hunk_unit = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n";
-        let mut diff = hunk_unit.repeat(20); // ~1140 bytes of first-hunk content
-        diff.push_str("@@ -10,3 +10,3 @@\n more context\n-old2\n+new2\n");
-
-        // max_bytes sits inside the repeated first-hunk region (well below total)
-        // but above TRUNCATION_NOTICE.len() so effective_limit > 0.
-        // effective_limit = max_bytes - TRUNCATION_NOTICE.len() ≈ 500 - 72 = 428,
-        // which lands inside the repeated first-hunk block.
-        let max_bytes = 500;
-        let (result, truncated) = truncate_diff(&diff, max_bytes);
-        assert!(truncated);
-        assert!(
-            result.contains("context"),
-            "should contain first-hunk content"
-        );
-        assert!(result.contains("Diff truncated"));
-        assert!(
-            !result.contains("@@ -10,3"),
-            "second hunk should be excluded"
-        );
-        // Result must not exceed max_bytes.
-        assert!(
-            result.len() <= max_bytes,
-            "truncated result ({}) exceeds max_bytes ({})",
-            result.len(),
-            max_bytes
-        );
-    }
-
-    #[test]
-    fn truncate_diff_utf8_safe() {
-        // Create a diff with multi-byte chars near the boundary
-        let mut diff = String::from("--- a/file\n+++ b/file\n@@ -1,1 +1,1 @@\n-");
-        // Add enough content to exceed a small limit, with multi-byte chars
-        for _ in 0..100 {
-            diff.push('日'); // 3-byte UTF-8 char
-        }
-        diff.push('\n');
-        let (result, truncated) = truncate_diff(&diff, 80);
-        assert!(truncated);
-        // Must not panic and must produce valid UTF-8
-        assert!(result.is_char_boundary(result.len()));
-    }
-
-    #[test]
-    fn truncate_diff_result_within_limit() {
-        let mut diff = String::new();
-        for i in 0..2000 {
-            diff.push_str(&format!(
-                "@@ -{i},1 +{i},1 @@\n-old line {i}\n+new line {i}\n"
-            ));
-        }
-        let max = 1024;
-        let (result, truncated) = truncate_diff(&diff, max);
-        assert!(truncated);
-        assert!(
-            result.len() <= max,
-            "truncated result ({}) exceeds max_bytes ({})",
-            result.len(),
-            max
-        );
-    }
-
-    #[test]
-    fn infer_language_known_extensions() {
-        assert_eq!(infer_language("src/main.rs"), Some("rust".to_string()));
-        assert_eq!(infer_language("app.tsx"), Some("typescript".to_string()));
-        assert_eq!(infer_language("script.py"), Some("python".to_string()));
-        assert_eq!(infer_language("Makefile"), None);
-    }
-
-    #[test]
-    fn infer_language_no_extension() {
-        assert_eq!(infer_language("Dockerfile"), None);
-        // But "foo.dockerfile" should match
-        assert_eq!(
-            infer_language("foo.dockerfile"),
-            Some("dockerfile".to_string())
-        );
-    }
-}
diff --git a/crates/sprout-mcp/src/toolsets.rs b/crates/sprout-mcp/src/toolsets.rs
deleted file mode 100644
index a8ac37b41c..0000000000
--- a/crates/sprout-mcp/src/toolsets.rs
+++ /dev/null
@@ -1,456 +0,0 @@
-//! # Toolset System
-//!
-//! Controls which MCP tools are exposed based on the `SPROUT_TOOLSETS` environment
-//! variable.
-//!
-//! ## Syntax
-//!
-//! ```text
-//! SPROUT_TOOLSETS="default,channel_admin:ro,canvas"
-//! ```
-//!
-//! Comma-separated list of toolset names with optional `:ro` / `:rw` suffix.
-//! Special keywords: `default`, `all`, `none`.
-//!
-//! Later entries override earlier ones, so `all:ro,default:rw` gives read-only
-//! access everywhere except the default toolset which gets full write access.
-//!
-//! ## Toolsets
-//!
-//! | Name            | Tools |
-//! |-----------------|-------|
-//! | `default`       | 26    |
-//! | `channel_admin` | 5     |
-//! | `dms`           | 3     |
-//! | `canvas`        | 2     |
-//! | `workflow_admin`| 5     |
-//! | `identity`      | 1     |
-//! | `forums`        | 1     |
-//! | `social`        | 5     |
-
-use std::collections::{HashMap, HashSet};
-use std::sync::LazyLock;
-
-// ---------------------------------------------------------------------------
-// Static data
-// ---------------------------------------------------------------------------
-
-/// `(tool_name, toolset_name, is_read)`
-///
-/// Single source of truth for every tool's toolset membership and read/write
-/// classification. `is_read = true` means the tool is safe to include under
-/// a `:ro` (read-only) mode restriction.
-///
-/// See [`DEFERRED_TOOLS`] for tools planned but not yet implemented.
-pub const ALL_TOOLS: &[(&str, &str, bool)] = &[
-    // ── default ─────────────────────────────────────────────────────────────
-    ("send_message", "default", false),
-    ("send_diff_message", "default", false),
-    ("edit_message", "default", false),
-    ("delete_message", "default", false),
-    ("get_messages", "default", true),
-    ("get_thread", "default", true),
-    ("search", "default", true),
-    ("get_feed", "default", true),
-    ("add_reaction", "default", false),
-    ("remove_reaction", "default", false),
-    ("get_reactions", "default", true),
-    ("list_channels", "default", true),
-    ("get_channel", "default", true),
-    ("join_channel", "default", false),
-    ("leave_channel", "default", false),
-    ("update_channel", "default", false),
-    ("set_channel_topic", "default", false),
-    ("set_channel_purpose", "default", false),
-    ("open_dm", "default", false),
-    ("get_users", "default", true),
-    ("set_profile", "default", false),
-    ("get_presence", "default", true),
-    ("set_presence", "default", false),
-    ("trigger_workflow", "default", false),
-    ("approve_step", "default", false),
-    ("list_channel_members", "default", true),
-    // ── channel_admin ────────────────────────────────────────────────────────
-    ("create_channel", "channel_admin", false),
-    ("archive_channel", "channel_admin", false),
-    ("unarchive_channel", "channel_admin", false),
-    ("add_channel_member", "channel_admin", false),
-    ("remove_channel_member", "channel_admin", false),
-    // ── dms ──────────────────────────────────────────────────────────────────
-    ("add_dm_member", "dms", false),
-    ("hide_dm", "dms", false),
-    ("list_dms", "dms", true),
-    // ── canvas ───────────────────────────────────────────────────────────────
-    ("get_canvas", "canvas", true),
-    ("set_canvas", "canvas", false),
-    // ── workflow_admin ────────────────────────────────────────────────────────
-    ("list_workflows", "workflow_admin", true),
-    ("create_workflow", "workflow_admin", false),
-    ("update_workflow", "workflow_admin", false),
-    ("delete_workflow", "workflow_admin", false),
-    ("get_workflow_runs", "workflow_admin", true),
-    // ── identity ──────────────────────────────────────────────────────────────
-    ("set_channel_add_policy", "identity", false),
-    // ── forums ───────────────────────────────────────────────────────────────
-    ("vote_on_post", "forums", false),
-    // ── social ───────────────────────────────────────────────────────────────
-    // Social tools for NIP-01/NIP-02 (text notes + contact lists).
-    // `get_event` returns global events (kind:0/1/3/30023) with scope checks
-    // and channel events with membership verification. Unknown global kinds
-    // return 404 (closed-default allowlist in events.rs).
-    ("publish_note", "social", false),
-    ("set_contact_list", "social", false),
-    ("get_event", "social", true),
-    ("get_user_notes", "social", true),
-    ("get_contact_list", "social", true),
-    // ── media ────────────────────────────────────────────────────────────────
-    ("upload_file", "media", false),
-];
-
-/// Tools planned but not yet implemented. These will be added to ALL_TOOLS
-/// when their #[tool] handlers are created in server.rs.
-pub const DEFERRED_TOOLS: &[(&str, &str, bool)] = &[
-    ("subscribe", "realtime", true),
-    ("unsubscribe", "realtime", false),
-];
-
-// ---------------------------------------------------------------------------
-// Public types
-// ---------------------------------------------------------------------------
-
-/// Access mode for a toolset.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum Mode {
-    /// All tools in the toolset (read + write).
-    ReadWrite,
-    /// Read-only tools only.
-    ReadOnly,
-}
-
-/// Metadata about a toolset.
-#[derive(Debug, Clone)]
-pub struct ToolsetDef {
-    /// Toolset name, e.g. `"channel_admin"`.
-    pub name: &'static str,
-    /// All tools belonging to this toolset.
-    pub tools: &'static [ToolDef],
-}
-
-/// Metadata about a single tool.
-#[derive(Debug, Clone, Copy)]
-pub struct ToolDef {
-    /// Tool name, e.g. `"get_messages"`.
-    pub name: &'static str,
-    /// Whether the tool is safe under `:ro` mode.
-    pub is_read: bool,
-}
-
-/// Parsed toolset configuration.
-///
-/// Construct via [`ToolsetConfig::parse`] or [`ToolsetConfig::from_env`].
-#[derive(Debug, Clone)]
-pub struct ToolsetConfig {
-    /// `toolset_name → Mode`. Only explicitly enabled toolsets appear here.
-    enabled: HashMap<&'static str, Mode>,
-}
-
-// ---------------------------------------------------------------------------
-// Known toolset names (compile-time set for validation)
-// ---------------------------------------------------------------------------
-
-const KNOWN_TOOLSETS: &[&str] = &[
-    "default",
-    "channel_admin",
-    "dms",
-    "canvas",
-    "workflow_admin",
-    "media",
-    "realtime",
-    "identity",
-    "forums",
-    "social",
-];
-
-// ---------------------------------------------------------------------------
-// Lazy static toolset definitions (built from ALL_TOOLS)
-// ---------------------------------------------------------------------------
-
-static TOOLSET_DEFS: LazyLock<Vec<ToolsetDef>> = LazyLock::new(|| {
-    let mut map: std::collections::BTreeMap<&'static str, Vec<ToolDef>> =
-        std::collections::BTreeMap::new();
-    for &(tool, ts, is_read) in ALL_TOOLS {
-        map.entry(ts).or_default().push(ToolDef {
-            name: tool,
-            is_read,
-        });
-    }
-    map.into_iter()
-        .map(|(name, tools)| ToolsetDef {
-            name,
-            tools: Box::leak(tools.into_boxed_slice()),
-        })
-        .collect()
-});
-
-/// Returns all toolset definitions, built once from [`ALL_TOOLS`].
-pub fn all_toolsets() -> &'static [ToolsetDef] {
-    &TOOLSET_DEFS
-}
-
-/// Returns the tools belonging to `name`, or `None` if the toolset is unknown.
-pub fn tools_in_toolset(name: &str) -> Option<Vec<ToolDef>> {
-    let tools: Vec<ToolDef> = ALL_TOOLS
-        .iter()
-        .filter(|&&(_, ts, _)| ts == name)
-        .map(|&(tool, _, is_read)| ToolDef {
-            name: tool,
-            is_read,
-        })
-        .collect();
-    if tools.is_empty() {
-        None
-    } else {
-        Some(tools)
-    }
-}
-
-// ---------------------------------------------------------------------------
-// ToolsetConfig implementation
-// ---------------------------------------------------------------------------
-
-impl ToolsetConfig {
-    /// Parse a comma-separated toolset string.
-    ///
-    /// # Keywords
-    /// - `default`  — enables the `default` toolset
-    /// - `all`      — enables every toolset
-    /// - `none`     — clears all enabled toolsets
-    ///
-    /// # Mode suffixes
-    /// - `:ro`  — read-only (only tools with `is_read = true`)
-    /// - `:rw`  — read-write (default)
-    ///
-    /// Later entries override earlier ones.
-    pub fn parse(input: &str) -> Self {
-        let mut enabled: HashMap<&'static str, Mode> = HashMap::new();
-
-        for token in input.split(',').map(str::trim).filter(|s| !s.is_empty()) {
-            let (name, mode) = if let Some(n) = token.strip_suffix(":ro") {
-                (n, Mode::ReadOnly)
-            } else if let Some(n) = token.strip_suffix(":rw") {
-                (n, Mode::ReadWrite)
-            } else {
-                (token, Mode::ReadWrite)
-            };
-
-            match name {
-                "none" => {
-                    enabled.clear();
-                }
-                "all" => {
-                    for &ts in KNOWN_TOOLSETS {
-                        enabled.insert(ts, mode);
-                    }
-                }
-                "default" => {
-                    enabled.insert("default", mode);
-                }
-                other => {
-                    // Intern to &'static str if known; warn and skip if not.
-                    if let Some(&known) = KNOWN_TOOLSETS.iter().find(|&&k| k == other) {
-                        enabled.insert(known, mode);
-                    } else {
-                        eprintln!("sprout-mcp: unknown toolset {:?} — skipping", other);
-                    }
-                }
-            }
-        }
-
-        Self { enabled }
-    }
-
-    /// Parse from `SPROUT_TOOLSETS`, falling back to `"default"`.
-    ///
-    /// An empty string (e.g. `SPROUT_TOOLSETS=""`) is treated the same as unset.
-    pub fn from_env() -> Self {
-        let raw = std::env::var("SPROUT_TOOLSETS")
-            .ok()
-            .filter(|v| !v.is_empty())
-            .unwrap_or_else(|| "default".to_string());
-        Self::parse(&raw)
-    }
-
-    /// Returns the set of tool names that should be **removed** from the router.
-    ///
-    /// Callers pass each name to `ToolRouter::remove_route()`.
-    pub fn tools_to_remove(&self) -> HashSet<&'static str> {
-        ALL_TOOLS
-            .iter()
-            .filter(|&&(_tool, ts, is_read)| {
-                match self.enabled.get(ts) {
-                    None => true,                     // toolset not enabled → remove
-                    Some(Mode::ReadWrite) => false,   // fully enabled → keep
-                    Some(Mode::ReadOnly) => !is_read, // ro → remove write tools
-                }
-            })
-            .map(|&(tool, _, _)| tool)
-            .collect()
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    fn enabled_tools(input: &str) -> HashSet<&'static str> {
-        let cfg = ToolsetConfig::parse(input);
-        let remove = cfg.tools_to_remove();
-        ALL_TOOLS
-            .iter()
-            .map(|&(t, _, _)| t)
-            .filter(|t| !remove.contains(t))
-            .collect()
-    }
-
-    #[test]
-    fn default_includes_26_tools() {
-        let tools = enabled_tools("default");
-        assert_eq!(tools.len(), 26);
-        assert!(tools.contains("send_message"));
-        assert!(tools.contains("approve_step"));
-        assert!(!tools.contains("create_channel"));
-    }
-
-    #[test]
-    fn none_removes_all_tools() {
-        assert!(enabled_tools("none").is_empty());
-    }
-
-    #[test]
-    fn all_includes_all_tools() {
-        assert_eq!(enabled_tools("all").len(), ALL_TOOLS.len());
-    }
-
-    #[test]
-    fn ro_keeps_only_read_tools() {
-        let tools = enabled_tools("default:ro");
-        // Every enabled tool must be a read tool
-        for t in &tools {
-            let is_read = ALL_TOOLS.iter().find(|&&(n, _, _)| n == *t).unwrap().2;
-            assert!(is_read, "{t} should not be present in :ro mode");
-        }
-        assert!(tools.contains("get_messages"));
-        assert!(!tools.contains("send_message"));
-    }
-
-    #[test]
-    fn later_entry_overrides_earlier() {
-        // all:ro then default:rw → default tools are rw, rest are ro
-        let cfg = ToolsetConfig::parse("all:ro,default:rw");
-        let remove = cfg.tools_to_remove();
-        // send_message is default+write → should be kept (rw)
-        assert!(!remove.contains("send_message"));
-        // create_channel is channel_admin+write → should be removed (ro)
-        assert!(remove.contains("create_channel"));
-        // list_channel_members is default+read → should be kept (rw)
-        assert!(!remove.contains("list_channel_members"));
-    }
-
-    #[test]
-    fn unknown_toolset_is_skipped_gracefully() {
-        // Should not panic; unknown toolset is silently ignored
-        let tools = enabled_tools("default,nonexistent_toolset");
-        assert_eq!(tools.len(), 26); // only default
-    }
-
-    #[test]
-    fn empty_input_enables_nothing() {
-        assert!(enabled_tools("").is_empty());
-    }
-
-    #[test]
-    fn none_after_all_clears() {
-        assert!(enabled_tools("all,none").is_empty());
-    }
-
-    #[test]
-    fn rw_suffix_is_same_as_bare() {
-        assert_eq!(enabled_tools("default:rw"), enabled_tools("default"));
-    }
-
-    #[test]
-    fn all_tools_count_is_49() {
-        assert_eq!(ALL_TOOLS.len(), 49);
-    }
-
-    #[test]
-    fn deferred_tools_count_is_2() {
-        assert_eq!(DEFERRED_TOOLS.len(), 2);
-    }
-
-    #[test]
-    fn tools_in_toolset_returns_correct_tools() {
-        let tools = tools_in_toolset("canvas").unwrap();
-        assert_eq!(tools.len(), 2);
-        let names: Vec<_> = tools.iter().map(|t| t.name).collect();
-        assert!(names.contains(&"get_canvas"));
-        assert!(names.contains(&"set_canvas"));
-    }
-
-    #[test]
-    fn tools_in_toolset_unknown_returns_none() {
-        assert!(tools_in_toolset("bogus").is_none());
-    }
-
-    #[test]
-    fn all_toolsets_returns_correct_count() {
-        // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social, media
-        // (realtime has no implemented tools yet)
-        let defs = all_toolsets();
-        assert_eq!(defs.len(), 9);
-        let names: Vec<_> = defs.iter().map(|d| d.name).collect();
-        assert!(names.contains(&"default"));
-        assert!(names.contains(&"canvas"));
-        assert!(names.contains(&"forums"));
-        assert!(names.contains(&"social"));
-        assert!(names.contains(&"media"));
-    }
-
-    // ── Cross-check: ALL_TOOLS integrity ────────────────────────────────────
-
-    #[test]
-    fn all_tools_has_no_duplicates() {
-        let mut seen = std::collections::HashSet::new();
-        for &(name, _, _) in ALL_TOOLS {
-            assert!(
-                seen.insert(name),
-                "duplicate tool name in ALL_TOOLS: {name}"
-            );
-        }
-    }
-
-    #[test]
-    fn all_tools_names_are_valid_identifiers() {
-        for &(name, _, _) in ALL_TOOLS {
-            assert!(!name.is_empty(), "empty tool name in ALL_TOOLS");
-            assert!(
-                name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
-                "invalid tool name in ALL_TOOLS: {name}"
-            );
-        }
-    }
-
-    // ── from_env empty-string fallback ──────────────────────────────────────
-
-    #[test]
-    fn parse_empty_string_enables_nothing() {
-        // parse("") is the raw parser — empty input → no toolsets enabled.
-        // from_env() adds the fallback before calling parse, so agents always
-        // get at least the default toolset even when SPROUT_TOOLSETS="".
-        assert!(enabled_tools("").is_empty());
-    }
-}
diff --git a/crates/sprout-mcp/src/upload.rs b/crates/sprout-mcp/src/upload.rs
deleted file mode 100644
index 2f195d1c25..0000000000
--- a/crates/sprout-mcp/src/upload.rs
+++ /dev/null
@@ -1,421 +0,0 @@
-//! File upload pipeline for Sprout media (Blossom protocol).
-//!
-//! Reads a local file, validates it against size/type constraints, computes a SHA-256
-//! hash, signs a kind:24242 Blossom auth event, PUTs the file to the relay's
-//! `/media/upload` endpoint, and returns a [`BlobDescriptor`].
-
-use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
-use hex;
-use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
-use sha2::{Digest, Sha256};
-
-// ── Constants ─────────────────────────────────────────────────────────────────
-
-/// Maximum file size for image uploads (50 MB).
-pub const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
-
-/// Maximum file size for video uploads (500 MB).
-pub const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
-
-/// MIME types we accept for upload.
-const ALLOWED_MIMES: &[&str] = &[
-    "image/jpeg",
-    "image/png",
-    "image/gif",
-    "image/webp",
-    "video/mp4",
-];
-
-// ── Types ─────────────────────────────────────────────────────────────────────
-
-/// Descriptor returned by the relay after a successful upload.
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
-pub struct BlobDescriptor {
-    /// Public URL of the uploaded blob.
-    pub url: String,
-    /// Hex-encoded SHA-256 of the file content.
-    pub sha256: String,
-    /// File size in bytes.
-    pub size: u64,
-    /// MIME type (e.g. `image/jpeg`).
-    #[serde(rename = "type")]
-    pub mime_type: String,
-    /// Unix timestamp when the file was uploaded.
-    pub uploaded: i64,
-    /// Image dimensions as `<width>x<height>` (optional).
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub dim: Option<String>,
-    /// Blurhash placeholder string (optional).
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub blurhash: Option<String>,
-    /// Thumbnail URL (optional).
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub thumb: Option<String>,
-    /// Duration in seconds for video/audio (optional).
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub duration: Option<f64>,
-}
-
-/// Errors that can occur during the upload pipeline.
-#[derive(Debug, thiserror::Error)]
-pub enum UploadError {
-    /// The path does not exist on disk.
-    #[error("file not found: {0}")]
-    FileNotFound(String),
-    /// The path exists but is not a regular file.
-    #[error("not a file: {0}")]
-    NotAFile(String),
-    /// File exceeds the size limit for its type.
-    #[error("file too large: {size} bytes (max {max})")]
-    FileTooLarge {
-        /// Actual file size.
-        size: u64,
-        /// Maximum allowed size.
-        max: u64,
-    },
-    /// MIME type is not in the allowlist.
-    #[error("unsupported file type: {0}")]
-    UnsupportedFileType(String),
-    /// Read bytes don't match metadata length.
-    #[error("size mismatch: metadata says {expected}, read {actual}")]
-    SizeMismatch {
-        /// Size reported by filesystem metadata.
-        expected: u64,
-        /// Actual number of bytes read.
-        actual: u64,
-    },
-    /// Filesystem I/O error.
-    #[error("IO error: {0}")]
-    Io(#[from] std::io::Error),
-    /// Nostr event signing failed.
-    #[error("signing failed: {0}")]
-    SigningFailed(String),
-    /// Server returned a non-success status.
-    #[error("upload rejected ({status}): {body}")]
-    ServerRejected {
-        /// HTTP status code.
-        status: u16,
-        /// Response body text.
-        body: String,
-    },
-    /// HTTP transport error.
-    #[error("HTTP error: {0}")]
-    Http(#[from] reqwest::Error),
-    /// Could not parse the server's response as a BlobDescriptor.
-    #[error("invalid response: {0}")]
-    InvalidResponse(String),
-}
-
-// ── Upload pipeline ───────────────────────────────────────────────────────────
-
-/// Upload a local file to the Sprout relay's media endpoint.
-///
-/// Performs validation, SHA-256 hashing, Blossom auth signing, and the HTTP PUT.
-/// Returns the relay's [`BlobDescriptor`] on success.
-///
-/// `auth_tag_json` is an optional NIP-OA auth tag (JSON-array string) sent as
-/// the `x-auth-tag` header for relay membership delegation.
-pub async fn upload_file(
-    http: &reqwest::Client,
-    keys: &Keys,
-    relay_http_url: &str,
-    server_domain: Option<&str>,
-    file_path: &str,
-    auth_tag_json: Option<&str>,
-) -> Result<BlobDescriptor, UploadError> {
-    // 1. Validate path exists
-    let metadata = std::fs::metadata(file_path).map_err(|e| {
-        if e.kind() == std::io::ErrorKind::NotFound {
-            UploadError::FileNotFound(file_path.to_string())
-        } else {
-            UploadError::Io(e)
-        }
-    })?;
-
-    if !metadata.is_file() {
-        return Err(UploadError::NotAFile(file_path.to_string()));
-    }
-
-    let expected_size = metadata.len();
-
-    // Early rejection: no supported file type exceeds MAX_VIDEO_BYTES.
-    // This prevents buffering hundreds of MB into RAM before MIME detection.
-    if expected_size > MAX_VIDEO_BYTES {
-        return Err(UploadError::FileTooLarge {
-            size: expected_size,
-            max: MAX_VIDEO_BYTES,
-        });
-    }
-
-    // 2. Read file into memory
-    let bytes = std::fs::read(file_path)?;
-    let actual_size = bytes.len() as u64;
-
-    // Post-read size check
-    if actual_size != expected_size {
-        return Err(UploadError::SizeMismatch {
-            expected: expected_size,
-            actual: actual_size,
-        });
-    }
-
-    // 3. Detect MIME via magic bytes
-    let mime = infer::get(&bytes)
-        .map(|t| t.mime_type())
-        .unwrap_or("application/octet-stream");
-
-    if !ALLOWED_MIMES.contains(&mime) {
-        return Err(UploadError::UnsupportedFileType(mime.to_string()));
-    }
-
-    // 4. Pre-check file size against type-specific limits
-    let max_size = if mime.starts_with("video/") {
-        MAX_VIDEO_BYTES
-    } else {
-        MAX_IMAGE_BYTES
-    };
-
-    if actual_size > max_size {
-        return Err(UploadError::FileTooLarge {
-            size: actual_size,
-            max: max_size,
-        });
-    }
-
-    // 5. Compute SHA-256
-    let sha256 = hex::encode(Sha256::digest(&bytes));
-
-    // 6. Sign Blossom auth event (kind:24242)
-    let now = Timestamp::now().as_secs();
-    let expiry = if mime.starts_with("video/") {
-        3600
-    } else {
-        600
-    };
-    let exp_str = (now + expiry).to_string();
-
-    let mut tags = vec![
-        Tag::parse(["t", "upload"]).map_err(|e| UploadError::SigningFailed(e.to_string()))?,
-        Tag::parse(["x", &sha256]).map_err(|e| UploadError::SigningFailed(e.to_string()))?,
-        Tag::parse(["expiration", &exp_str])
-            .map_err(|e| UploadError::SigningFailed(e.to_string()))?,
-    ];
-    if let Some(domain) = server_domain {
-        tags.push(
-            Tag::parse(["server", domain])
-                .map_err(|e| UploadError::SigningFailed(e.to_string()))?,
-        );
-    }
-
-    let auth_event = EventBuilder::new(Kind::from(24242), "Upload file")
-        .tags(tags)
-        .sign_with_keys(keys)
-        .map_err(|e| UploadError::SigningFailed(e.to_string()))?;
-
-    // 7. Base64url encode the auth event
-    let auth_header = format!(
-        "Nostr {}",
-        URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
-    );
-
-    // 8. HTTP PUT — with a generous per-request timeout.
-    // The shared reqwest client has a 10s timeout suitable for REST API calls,
-    // but uploads can take minutes for large files. Override per-request.
-    let upload_timeout = if mime.starts_with("video/") {
-        std::time::Duration::from_secs(600) // 10 min for video (up to 500 MB)
-    } else {
-        std::time::Duration::from_secs(120) // 2 min for images (up to 50 MB)
-    };
-
-    let url = format!("{}/media/upload", relay_http_url.trim_end_matches('/'));
-    let mut req = http
-        .put(&url)
-        .timeout(upload_timeout)
-        .header("Authorization", &auth_header)
-        .header("Content-Type", mime)
-        .header("X-SHA-256", &sha256);
-    if let Some(tag) = auth_tag_json {
-        req = req.header("x-auth-tag", tag);
-    }
-    let resp = req.body(bytes).send().await?;
-
-    // 9. Handle response
-    let status = resp.status();
-    if !status.is_success() {
-        let body = resp.text().await.unwrap_or_default();
-        return Err(UploadError::ServerRejected {
-            status: status.as_u16(),
-            body,
-        });
-    }
-
-    let body = resp.text().await?;
-    serde_json::from_str::<BlobDescriptor>(&body)
-        .map_err(|e| UploadError::InvalidResponse(format!("{e}: {body}")))
-}
-
-// ── Helpers ───────────────────────────────────────────────────────────────────
-
-/// Build a NIP-92 `imeta` tag from a [`BlobDescriptor`].
-///
-/// The returned `Vec<String>` is suitable for passing to `Tag::parse`.
-pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
-    let mut tag = vec![
-        "imeta".to_string(),
-        format!("url {}", d.url),
-        format!("m {}", d.mime_type),
-        format!("x {}", d.sha256),
-        format!("size {}", d.size),
-    ];
-    if let Some(ref dim) = d.dim {
-        tag.push(format!("dim {dim}"));
-    }
-    if let Some(ref bh) = d.blurhash {
-        tag.push(format!("blurhash {bh}"));
-    }
-    if let Some(ref th) = d.thumb {
-        tag.push(format!("thumb {th}"));
-    }
-    if let Some(dur) = d.duration {
-        tag.push(format!("duration {dur}"));
-    }
-    tag
-}
-
-// ── Tests ─────────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    fn image_descriptor() -> BlobDescriptor {
-        BlobDescriptor {
-            url: "https://relay.example.com/media/abc123.jpg".to_string(),
-            sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
-            size: 12345,
-            mime_type: "image/jpeg".to_string(),
-            uploaded: 1700000000,
-            dim: Some("1920x1080".to_string()),
-            blurhash: Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string()),
-            thumb: Some("https://relay.example.com/media/abc123_thumb.jpg".to_string()),
-            duration: None,
-        }
-    }
-
-    fn video_descriptor() -> BlobDescriptor {
-        BlobDescriptor {
-            url: "https://relay.example.com/media/vid456.mp4".to_string(),
-            sha256: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
-            size: 5_000_000,
-            mime_type: "video/mp4".to_string(),
-            uploaded: 1700000000,
-            dim: Some("1280x720".to_string()),
-            blurhash: None,
-            thumb: None,
-            duration: Some(42.5),
-        }
-    }
-
-    fn minimal_descriptor() -> BlobDescriptor {
-        BlobDescriptor {
-            url: "https://relay.example.com/media/min.png".to_string(),
-            sha256: "0000000000000000000000000000000000000000000000000000000000000000".to_string(),
-            size: 100,
-            mime_type: "image/png".to_string(),
-            uploaded: 1700000000,
-            dim: None,
-            blurhash: None,
-            thumb: None,
-            duration: None,
-        }
-    }
-
-    #[test]
-    fn test_build_imeta_tag_image() {
-        let d = image_descriptor();
-        let tag = build_imeta_tag(&d);
-
-        assert_eq!(tag[0], "imeta");
-        assert_eq!(tag[1], "url https://relay.example.com/media/abc123.jpg");
-        assert_eq!(tag[2], "m image/jpeg");
-        assert_eq!(
-            tag[3],
-            "x e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
-        );
-        assert_eq!(tag[4], "size 12345");
-        assert_eq!(tag[5], "dim 1920x1080");
-        assert_eq!(tag[6], "blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj");
-        assert_eq!(
-            tag[7],
-            "thumb https://relay.example.com/media/abc123_thumb.jpg"
-        );
-        // No duration for images
-        assert_eq!(tag.len(), 8);
-    }
-
-    #[test]
-    fn test_build_imeta_tag_video() {
-        let d = video_descriptor();
-        let tag = build_imeta_tag(&d);
-
-        assert_eq!(tag[0], "imeta");
-        assert_eq!(tag[1], "url https://relay.example.com/media/vid456.mp4");
-        assert_eq!(tag[2], "m video/mp4");
-        assert_eq!(
-            tag[3],
-            "x abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
-        );
-        assert_eq!(tag[4], "size 5000000");
-        assert_eq!(tag[5], "dim 1280x720");
-        // No blurhash or thumb
-        assert_eq!(tag[6], "duration 42.5");
-        assert_eq!(tag.len(), 7);
-    }
-
-    #[test]
-    fn test_build_imeta_tag_minimal() {
-        let d = minimal_descriptor();
-        let tag = build_imeta_tag(&d);
-
-        assert_eq!(tag.len(), 5);
-        assert_eq!(tag[0], "imeta");
-        assert_eq!(tag[1], "url https://relay.example.com/media/min.png");
-        assert_eq!(tag[2], "m image/png");
-        assert_eq!(
-            tag[3],
-            "x 0000000000000000000000000000000000000000000000000000000000000000"
-        );
-        assert_eq!(tag[4], "size 100");
-    }
-
-    #[test]
-    fn test_mime_allowlist() {
-        // Allowed types
-        assert!(ALLOWED_MIMES.contains(&"image/jpeg"));
-        assert!(ALLOWED_MIMES.contains(&"image/png"));
-        assert!(ALLOWED_MIMES.contains(&"image/gif"));
-        assert!(ALLOWED_MIMES.contains(&"image/webp"));
-        assert!(ALLOWED_MIMES.contains(&"video/mp4"));
-
-        // Rejected types
-        assert!(!ALLOWED_MIMES.contains(&"application/pdf"));
-        assert!(!ALLOWED_MIMES.contains(&"text/plain"));
-        assert!(!ALLOWED_MIMES.contains(&"image/svg+xml"));
-        assert!(!ALLOWED_MIMES.contains(&"video/webm"));
-        assert!(!ALLOWED_MIMES.contains(&"application/octet-stream"));
-    }
-
-    #[test]
-    fn test_file_size_limits() {
-        // Image limit: 50 MB
-        assert_eq!(MAX_IMAGE_BYTES, 50 * 1024 * 1024);
-        assert_eq!(MAX_IMAGE_BYTES, 52_428_800);
-
-        // Video limit: 500 MB
-        assert_eq!(MAX_VIDEO_BYTES, 500 * 1024 * 1024);
-        assert_eq!(MAX_VIDEO_BYTES, 524_288_000);
-
-        // Video limit is 10x image limit
-        assert_eq!(MAX_VIDEO_BYTES, MAX_IMAGE_BYTES * 10);
-    }
-}
diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index 86f296a773..cf39edee47 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -458,16 +458,16 @@ mod tests {
     fn mcp_merge_shared_only() {
         let shared = serde_json::json!({
             "mcpServers": {
-                "sprout-mcp": {
+                "example-mcp": {
                     "command": "npx",
-                    "args": ["-y", "sprout-mcp"],
+                    "args": ["-y", "example-mcp"],
                     "env": { "TOKEN": "abc" }
                 }
             }
         });
         let result = merge_mcp_servers(Some(&shared), &[]);
         assert_eq!(result.len(), 1);
-        assert_eq!(result[0].name, "sprout-mcp");
+        assert_eq!(result[0].name, "example-mcp");
         assert_eq!(result[0].command, "npx");
         assert_eq!(result[0].env, vec![("TOKEN".into(), "abc".into())]);
     }
diff --git a/crates/sprout-relay-client/Cargo.toml b/crates/sprout-relay-client/Cargo.toml
new file mode 100644
index 0000000000..cb086f48bb
--- /dev/null
+++ b/crates/sprout-relay-client/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "sprout-relay-client"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "WebSocket relay client for Sprout (NIP-42 auth, subscriptions, reconnect)"
+
+[dependencies]
+nostr = { workspace = true }
+tokio = { workspace = true }
+tokio-tungstenite = { workspace = true }
+futures-util = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+reqwest = { workspace = true }
+uuid = { workspace = true }
+tracing = { workspace = true }
+thiserror = { workspace = true }
+url = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["test-util"] }
+tokio-tungstenite = { workspace = true }
diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-relay-client/src/lib.rs
similarity index 99%
rename from crates/sprout-mcp/src/relay_client.rs
rename to crates/sprout-relay-client/src/lib.rs
index 915bb4b283..6d66b16b20 100644
--- a/crates/sprout-mcp/src/relay_client.rs
+++ b/crates/sprout-relay-client/src/lib.rs
@@ -1,3 +1,9 @@
+#![deny(unsafe_code)]
+#![warn(missing_docs)]
+
+//! WebSocket client for the Sprout relay with NIP-42 authentication,
+//! subscription management, and automatic reconnection.
+
 use std::collections::HashMap;
 use std::time::Duration;
 
@@ -814,7 +820,8 @@ impl RelayClient {
         relay_ws_to_http(&self.relay_url)
     }
 
-    pub(crate) fn pubkey_hex(&self) -> String {
+    /// Returns the hex-encoded public key for this client's keypair.
+    pub fn pubkey_hex(&self) -> String {
         self.keys.public_key().to_hex()
     }
 
@@ -985,7 +992,7 @@ impl RelayClient {
 /// Converts `ws://` → `http://` and `wss://` → `https://`, strips trailing slash.
 ///
 /// Extracted as a free function so it can be unit-tested without a live connection.
-pub(crate) fn relay_ws_to_http(url: &str) -> String {
+pub fn relay_ws_to_http(url: &str) -> String {
     url.replace("wss://", "https://")
         .replace("ws://", "http://")
         .trim_end_matches('/')
diff --git a/crates/sprout-test-client/Cargo.toml b/crates/sprout-test-client/Cargo.toml
index 0d2cf7350d..c1776b7045 100644
--- a/crates/sprout-test-client/Cargo.toml
+++ b/crates/sprout-test-client/Cargo.toml
@@ -10,7 +10,7 @@ description = "Integration test client and E2E test suite for Sprout"
 [dependencies]
 anyhow = { workspace = true }
 sprout-core = { workspace = true }
-sprout-mcp = { workspace = true }
+sprout-relay-client = { workspace = true }
 nostr = { workspace = true }
 tokio = { workspace = true }
 tokio-tungstenite = { workspace = true }
diff --git a/crates/sprout-test-client/src/lib.rs b/crates/sprout-test-client/src/lib.rs
index 04d81194da..25902142d9 100644
--- a/crates/sprout-test-client/src/lib.rs
+++ b/crates/sprout-test-client/src/lib.rs
@@ -14,7 +14,7 @@ use tokio::time::timeout;
 use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
 use tracing::debug;
 
-pub use sprout_mcp::relay_client::{parse_relay_message, OkResponse, RelayMessage};
+pub use sprout_relay_client::{parse_relay_message, OkResponse, RelayMessage};
 
 /// Errors returned by [`SproutTestClient`] operations.
 #[derive(Debug, Error)]
@@ -67,9 +67,9 @@ impl From<nostr::event::builder::Error> for TestClientError {
 }
 
 // Map RelayClientError → TestClientError for parse_relay_message calls.
-impl From<sprout_mcp::relay_client::RelayClientError> for TestClientError {
-    fn from(e: sprout_mcp::relay_client::RelayClientError) -> Self {
-        use sprout_mcp::relay_client::RelayClientError as E;
+impl From<sprout_relay_client::RelayClientError> for TestClientError {
+    fn from(e: sprout_relay_client::RelayClientError) -> Self {
+        use sprout_relay_client::RelayClientError as E;
         match e {
             E::WebSocket(e) => TestClientError::WebSocket(e),
             E::Json(e) => TestClientError::Json(e),
diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs
deleted file mode 100644
index 1c239d05c7..0000000000
--- a/crates/sprout-test-client/tests/e2e_mcp.rs
+++ /dev/null
@@ -1,1264 +0,0 @@
-//! End-to-end tests that exercise the Sprout MCP server against a live relay.
-//!
-//! These tests spawn the `sprout-mcp-server` binary as a subprocess, communicate
-//! with it over JSON-RPC on stdin/stdout (exactly as a real AI agent host like
-//! goose or Claude Desktop would), and verify that the MCP tools work correctly
-//! against a running Sprout relay.
-//!
-//! # Running
-//!
-//! Start the relay on port 3001, then run:
-//!
-//! ```text
-//! RELAY_URL=ws://localhost:3001 cargo test -p sprout-test-client --test e2e_mcp -- --ignored
-//! ```
-//!
-//! # Auth
-//!
-//! Each test generates a known keypair, creates a fresh channel via the REST API
-//! (so the keypair is the channel owner and member), then passes the private key
-//! as `SPROUT_PRIVATE_KEY` to the MCP server subprocess.  This ensures the MCP
-//! server uses a stable identity that has access to the channels under test.
-
-use std::io::{BufRead, BufReader, Write};
-use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
-use std::time::Duration;
-
-use nostr::{EventBuilder, Keys, Kind, Tag, ToBech32};
-use serde_json::{json, Value};
-
-// ── Helpers ───────────────────────────────────────────────────────────────────
-
-/// WebSocket relay URL (e.g. `ws://localhost:3001`).
-fn relay_ws_url() -> String {
-    std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
-}
-
-/// HTTP relay URL derived from the WebSocket URL.
-fn relay_http_url() -> String {
-    relay_ws_url()
-        .replace("wss://", "https://")
-        .replace("ws://", "http://")
-        .trim_end_matches('/')
-        .to_string()
-}
-
-/// Generate a fresh Nostr keypair for a test run.
-fn generate_test_keys() -> Keys {
-    Keys::generate()
-}
-
-/// Encode the secret key as an `nsec1…` bech32 string.
-fn nsec_from_keys(keys: &Keys) -> String {
-    keys.secret_key().to_bech32().expect("bech32 encode nsec")
-}
-
-/// Create a fresh channel via the REST API using the given keypair as the owner.
-///
-/// Returns the new channel's UUID string.  The creating pubkey is automatically
-/// added as a member, so the MCP server (using the same keypair) will have
-/// access to it.
-async fn create_channel_for_test(keys: &Keys, name: &str) -> String {
-    let client = reqwest::Client::new();
-    let pubkey_hex = keys.public_key().to_hex();
-    let channel_uuid = uuid::Uuid::new_v4();
-    let tags = vec![
-        Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
-        Tag::parse(["name", name]).unwrap(),
-        Tag::parse(["channel_type", "stream"]).unwrap(),
-        Tag::parse(["visibility", "open"]).unwrap(),
-    ];
-    let event = EventBuilder::new(Kind::Custom(9007), "")
-        .tags(tags)
-        .sign_with_keys(keys)
-        .unwrap();
-    let resp = client
-        .post(format!("{}/api/events", relay_http_url()))
-        .header("X-Pubkey", &pubkey_hex)
-        .header("Content-Type", "application/json")
-        .body(serde_json::to_string(&event).unwrap())
-        .send()
-        .await
-        .expect("submit create-channel event");
-    assert!(
-        resp.status().is_success(),
-        "channel creation failed: {}",
-        resp.status()
-    );
-    channel_uuid.to_string()
-}
-
-/// Set a user profile via a signed kind:0 event submitted to POST /api/events.
-async fn set_profile_via_event(
-    client: &reqwest::Client,
-    keys: &Keys,
-    display_name: Option<&str>,
-    about: Option<&str>,
-) {
-    let pubkey_hex = keys.public_key().to_hex();
-    let mut map = serde_json::Map::new();
-    if let Some(n) = display_name {
-        map.insert("display_name".into(), serde_json::Value::String(n.into()));
-    }
-    if let Some(a) = about {
-        map.insert("about".into(), serde_json::Value::String(a.into()));
-    }
-    let content = serde_json::Value::Object(map).to_string();
-    let event = EventBuilder::new(Kind::Custom(0), &content)
-        .tags([])
-        .sign_with_keys(keys)
-        .unwrap();
-    let resp = client
-        .post(format!("{}/api/events", relay_http_url()))
-        .header("X-Pubkey", &pubkey_hex)
-        .header("Content-Type", "application/json")
-        .body(serde_json::to_string(&event).unwrap())
-        .send()
-        .await
-        .expect("submit profile event");
-    assert!(
-        resp.status().is_success(),
-        "profile set failed: {}",
-        resp.status()
-    );
-}
-
-/// Spawn the MCP server as a subprocess with stdin/stdout piped.
-///
-/// The server connects to the relay and performs NIP-42 auth on startup using
-/// the provided keypair (passed via `SPROUT_PRIVATE_KEY`).
-fn spawn_mcp_server(keys: &Keys) -> Child {
-    let nsec = nsec_from_keys(keys);
-    Command::new("cargo")
-        .args([
-            "run",
-            "-p",
-            "sprout-mcp",
-            "--bin",
-            "sprout-mcp-server",
-            "--",
-        ])
-        .env("SPROUT_RELAY_URL", relay_ws_url())
-        .env("SPROUT_PRIVATE_KEY", &nsec)
-        // Tests exercise all 43 tools — enable every toolset.
-        .env("SPROUT_TOOLSETS", "all")
-        // Prevent a stale SPROUT_API_TOKEN from the host .env leaking into
-        // the subprocess and causing NIP-42 auth failures against a fresh DB.
-        .env_remove("SPROUT_API_TOKEN")
-        // Suppress verbose startup logs so they don't pollute stderr output.
-        .env("RUST_LOG", "error")
-        .stdin(Stdio::piped())
-        .stdout(Stdio::piped())
-        .stderr(Stdio::piped())
-        .spawn()
-        .expect("failed to spawn sprout-mcp-server — is `cargo` in PATH?")
-}
-
-/// MCP session: wraps the child process and its I/O handles.
-struct McpSession {
-    child: Child,
-    stdin: ChildStdin,
-    reader: BufReader<ChildStdout>,
-    next_id: u64,
-}
-
-impl McpSession {
-    /// Spawn the MCP server with the given keypair and wait for it to connect.
-    async fn start(keys: &Keys) -> Self {
-        let mut child = spawn_mcp_server(keys);
-        let stdin = child.stdin.take().expect("stdin not piped");
-        let stdout = child.stdout.take().expect("stdout not piped");
-        let reader = BufReader::new(stdout);
-
-        // Give the server time to connect and authenticate with the relay.
-        // The binary prints "connected and authenticated." to stderr when ready.
-        tokio::time::sleep(Duration::from_secs(10)).await;
-
-        McpSession {
-            child,
-            stdin,
-            reader,
-            next_id: 1,
-        }
-    }
-
-    /// Send a JSON-RPC request and return the parsed response.
-    ///
-    /// MCP uses newline-delimited JSON over stdio.
-    fn send_request(&mut self, method: &str, params: Value) -> Value {
-        let id = self.next_id;
-        self.next_id += 1;
-
-        let request = json!({
-            "jsonrpc": "2.0",
-            "id": id,
-            "method": method,
-            "params": params,
-        });
-
-        let mut line = serde_json::to_string(&request).expect("serialize request");
-        line.push('\n');
-        self.stdin
-            .write_all(line.as_bytes())
-            .expect("write to MCP stdin");
-        self.stdin.flush().expect("flush MCP stdin");
-
-        // Read lines until we get a response matching our request ID.
-        // The server may emit notifications (no id) before the response.
-        loop {
-            let mut buf = String::new();
-            self.reader
-                .read_line(&mut buf)
-                .expect("read from MCP stdout");
-
-            if buf.trim().is_empty() {
-                continue;
-            }
-
-            let v: Value = serde_json::from_str(buf.trim())
-                .unwrap_or_else(|e| panic!("invalid JSON from MCP server: {e}\nraw: {buf}"));
-
-            // Skip notifications (no "id" field).
-            if v.get("id").is_none() {
-                continue;
-            }
-
-            if v["id"] == json!(id) {
-                return v;
-            }
-        }
-    }
-
-    /// Send the MCP `initialize` handshake.
-    fn initialize(&mut self) -> Value {
-        let resp = self.send_request(
-            "initialize",
-            json!({
-                "protocolVersion": "2024-11-05",
-                "capabilities": {},
-                "clientInfo": {
-                    "name": "sprout-e2e-test",
-                    "version": "0.1.0"
-                }
-            }),
-        );
-
-        // Send the `notifications/initialized` notification (no response expected).
-        let notif = json!({
-            "jsonrpc": "2.0",
-            "method": "notifications/initialized",
-        });
-        let mut line = serde_json::to_string(&notif).expect("serialize notif");
-        line.push('\n');
-        self.stdin
-            .write_all(line.as_bytes())
-            .expect("write notification");
-        self.stdin.flush().expect("flush");
-
-        resp
-    }
-
-    /// Call a tool by name with the given arguments.
-    fn call_tool(&mut self, tool_name: &str, arguments: Value) -> Value {
-        self.send_request(
-            "tools/call",
-            json!({
-                "name": tool_name,
-                "arguments": arguments,
-            }),
-        )
-    }
-
-    /// Extract the text content from a `tools/call` response.
-    fn tool_text(resp: &Value) -> String {
-        resp["result"]["content"]
-            .as_array()
-            .and_then(|arr| arr.first())
-            .and_then(|item| item["text"].as_str())
-            .unwrap_or_default()
-            .to_string()
-    }
-
-    /// Kill the MCP server subprocess.
-    fn stop(&mut self) {
-        let _ = self.child.kill();
-        let _ = self.child.wait();
-    }
-}
-
-// ── Tests ─────────────────────────────────────────────────────────────────────
-
-/// Spawn the MCP server, complete the initialize handshake, and verify that
-/// all 43 expected tools are listed by `tools/list`.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_initialize_and_list_tools() {
-    let keys = generate_test_keys();
-    let mut session = McpSession::start(&keys).await;
-
-    // ── initialize ──────────────────────────────────────────────────────────
-    let init_resp = session.initialize();
-
-    assert!(
-        init_resp.get("result").is_some(),
-        "initialize must return a result, got: {init_resp}"
-    );
-    assert!(
-        init_resp.get("error").is_none(),
-        "initialize must not return an error: {init_resp}"
-    );
-
-    let result = &init_resp["result"];
-    assert_eq!(
-        result["protocolVersion"].as_str().unwrap_or(""),
-        "2024-11-05",
-        "protocol version mismatch"
-    );
-    assert_eq!(
-        result["serverInfo"]["name"].as_str().unwrap_or(""),
-        "sprout-mcp",
-        "server name mismatch"
-    );
-
-    // ── tools/list ──────────────────────────────────────────────────────────
-    let list_resp = session.send_request("tools/list", json!({}));
-
-    assert!(
-        list_resp.get("result").is_some(),
-        "tools/list must return a result, got: {list_resp}"
-    );
-    assert!(
-        list_resp.get("error").is_none(),
-        "tools/list must not return an error: {list_resp}"
-    );
-
-    let tools = list_resp["result"]["tools"]
-        .as_array()
-        .expect("tools/list result must have a 'tools' array");
-
-    assert_eq!(
-        tools.len(),
-        49,
-        "expected exactly 49 tools, got {}. Tools: {:?}",
-        tools.len(),
-        tools
-            .iter()
-            .filter_map(|t| t["name"].as_str())
-            .collect::<Vec<_>>()
-    );
-
-    // Verify all expected tool names are present.
-    let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
-
-    let expected_tools = [
-        "add_channel_member",
-        "add_dm_member",
-        "add_reaction",
-        "approve_step",
-        "archive_channel",
-        "create_channel",
-        "create_workflow",
-        "delete_channel",
-        "delete_message",
-        "delete_workflow",
-        "edit_message",
-        "get_canvas",
-        "get_channel",
-        "get_feed",
-        "get_messages",
-        "get_presence",
-        "get_reactions",
-        "get_thread",
-        "get_users",
-        "get_workflow_runs",
-        "hide_dm",
-        "join_channel",
-        "leave_channel",
-        "list_channel_members",
-        "list_channels",
-        "list_dms",
-        "list_workflows",
-        "open_dm",
-        "remove_channel_member",
-        "remove_reaction",
-        "search",
-        "send_diff_message",
-        "send_message",
-        "set_canvas",
-        "set_channel_add_policy",
-        "set_channel_purpose",
-        "set_channel_topic",
-        "set_presence",
-        "set_profile",
-        "trigger_workflow",
-        "unarchive_channel",
-        "update_channel",
-        "update_workflow",
-        "vote_on_post",
-    ];
-
-    for expected in &expected_tools {
-        assert!(
-            tool_names.contains(expected),
-            "expected tool '{expected}' not found in tools list: {tool_names:?}"
-        );
-    }
-
-    // Each tool must have a name and description.
-    for tool in tools {
-        assert!(
-            tool.get("name").is_some(),
-            "tool missing 'name' field: {tool}"
-        );
-        assert!(
-            tool.get("description").is_some(),
-            "tool '{}' missing 'description' field",
-            tool["name"]
-        );
-    }
-
-    session.stop();
-}
-
-/// Call `list_channels` via MCP and verify the response contains the channel
-/// we created for this test run.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_list_channels() {
-    let keys = generate_test_keys();
-    let channel_id = create_channel_for_test(
-        &keys,
-        &format!("mcp-e2e-list-{}", uuid::Uuid::new_v4().simple()),
-    )
-    .await;
-
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    let resp = session.call_tool("list_channels", json!({}));
-
-    assert!(
-        resp.get("error").is_none(),
-        "list_channels returned an error: {resp}"
-    );
-
-    let text = McpSession::tool_text(&resp);
-    assert!(
-        !text.is_empty(),
-        "list_channels returned empty text response"
-    );
-    assert!(
-        !text.starts_with("Error:"),
-        "list_channels returned an error string: {text}"
-    );
-
-    // The response should be a JSON array of channels.
-    let channels: Vec<Value> = serde_json::from_str(&text)
-        .unwrap_or_else(|e| panic!("list_channels response is not valid JSON array: {e}\n{text}"));
-
-    assert!(
-        !channels.is_empty(),
-        "list_channels returned an empty channel list"
-    );
-
-    // Verify the channel we just created is present.
-    let ids: Vec<&str> = channels.iter().filter_map(|ch| ch["id"].as_str()).collect();
-
-    assert!(
-        ids.contains(&channel_id.as_str()),
-        "expected created channel (id={channel_id}) in list, got: {ids:?}"
-    );
-
-    // Each channel must have the required fields.
-    for ch in &channels {
-        assert!(ch.get("id").is_some(), "channel missing 'id': {ch}");
-        assert!(ch.get("name").is_some(), "channel missing 'name': {ch}");
-        assert!(
-            ch.get("channel_type").is_some(),
-            "channel missing 'channel_type': {ch}"
-        );
-    }
-
-    session.stop();
-}
-
-/// Send a message to a channel via `send_message`, then read it back via
-/// `get_messages` and verify the content matches.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_send_and_read_message() {
-    let keys = generate_test_keys();
-    let channel_id = create_channel_for_test(
-        &keys,
-        &format!("mcp-e2e-msg-{}", uuid::Uuid::new_v4().simple()),
-    )
-    .await;
-
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    // Generate a unique message content so we can identify it in history.
-    let unique_token = format!("mcp-e2e-msg-{}", uuid::Uuid::new_v4().simple());
-    let content = format!("MCP E2E test message: {unique_token}");
-
-    // ── send_message ────────────────────────────────────────────────────────
-    let send_resp = session.call_tool(
-        "send_message",
-        json!({
-            "channel_id": channel_id,
-            "content": content,
-        }),
-    );
-
-    assert!(
-        send_resp.get("error").is_none(),
-        "send_message returned a JSON-RPC error: {send_resp}"
-    );
-
-    let send_text = McpSession::tool_text(&send_resp);
-    assert!(
-        send_text.contains("event_id"),
-        "expected 'event_id' in send_message response, got: {send_text}"
-    );
-    assert!(
-        !send_text.starts_with("Error"),
-        "send_message returned an error: {send_text}"
-    );
-
-    // Small delay to let the event propagate through the relay.
-    tokio::time::sleep(Duration::from_millis(300)).await;
-
-    // ── get_messages ─────────────────────────────────────────────────
-    let history_resp = session.call_tool(
-        "get_messages",
-        json!({
-            "channel_id": channel_id,
-            "limit": 20,
-        }),
-    );
-
-    assert!(
-        history_resp.get("error").is_none(),
-        "get_messages returned a JSON-RPC error: {history_resp}"
-    );
-
-    let history_text = McpSession::tool_text(&history_resp);
-    assert!(
-        !history_text.starts_with("Error"),
-        "get_messages returned an error: {history_text}"
-    );
-
-    let history_json: Value = serde_json::from_str(&history_text)
-        .unwrap_or_else(|e| panic!("get_messages response is not valid JSON: {e}\n{history_text}"));
-    let events = history_json
-        .get("messages")
-        .and_then(|m| m.as_array())
-        .unwrap_or_else(|| panic!("expected 'messages' array in response: {history_text}"));
-
-    let found = events
-        .iter()
-        .any(|ev| ev["content"].as_str().unwrap_or("").contains(&unique_token));
-
-    assert!(
-        found,
-        "sent message with token '{unique_token}' not found in channel history. \
-         History ({} events): {history_text}",
-        events.len()
-    );
-
-    session.stop();
-}
-
-/// Send a message with a unique token, wait for indexing, then call `search`
-/// via MCP and verify the message appears in results.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_search() {
-    let keys = generate_test_keys();
-    let channel_id = create_channel_for_test(
-        &keys,
-        &format!("mcp-e2e-search-{}", uuid::Uuid::new_v4().simple()),
-    )
-    .await;
-
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    // Generate a unique token that will appear in the search index.
-    let unique_token = format!("mcpsearch{}", uuid::Uuid::new_v4().simple());
-    let content = format!("MCP E2E search test: {unique_token}");
-
-    // ── send_message to seed the search index ───────────────────────────────
-    let send_resp = session.call_tool(
-        "send_message",
-        json!({
-            "channel_id": channel_id,
-            "content": content,
-        }),
-    );
-
-    assert!(
-        send_resp.get("error").is_none(),
-        "send_message returned a JSON-RPC error: {send_resp}"
-    );
-
-    let send_text = McpSession::tool_text(&send_resp);
-    assert!(
-        send_text.contains("event_id"),
-        "expected 'event_id' in send_message response, got: {send_text}"
-    );
-
-    // Wait for the search index to catch up.
-    tokio::time::sleep(Duration::from_millis(800)).await;
-
-    // ── list_channels to verify the MCP client can access the relay ─────────
-    // (Also exercises the relay_client's REST path used by search)
-    let channels_resp = session.call_tool("list_channels", json!({}));
-    let channels_text = McpSession::tool_text(&channels_resp);
-    assert!(
-        !channels_text.starts_with("Error"),
-        "list_channels failed before search: {channels_text}"
-    );
-
-    // ── get_messages as a proxy for search ────────────────────────────
-    // The MCP server's `search` tool is not directly exposed; instead we verify
-    // the message is findable via get_messages (which uses the relay's
-    // subscription API, not Typesense). This confirms the full send→store→retrieve
-    // round-trip works through MCP.
-    let history_resp = session.call_tool(
-        "get_messages",
-        json!({
-            "channel_id": channel_id,
-            "limit": 50,
-        }),
-    );
-
-    assert!(
-        history_resp.get("error").is_none(),
-        "get_messages returned a JSON-RPC error: {history_resp}"
-    );
-
-    let history_text = McpSession::tool_text(&history_resp);
-    assert!(
-        !history_text.starts_with("Error"),
-        "get_messages returned an error: {history_text}"
-    );
-
-    let history_json: Value = serde_json::from_str(&history_text)
-        .unwrap_or_else(|e| panic!("get_messages response is not valid JSON: {e}\n{history_text}"));
-    let events = history_json
-        .get("messages")
-        .and_then(|m| m.as_array())
-        .unwrap_or_else(|| panic!("expected 'messages' array in response: {history_text}"));
-
-    let found = events
-        .iter()
-        .any(|ev| ev["content"].as_str().unwrap_or("").contains(&unique_token));
-
-    assert!(
-        found,
-        "message with token '{unique_token}' not found in channel history after send. \
-         Got {} events.",
-        events.len()
-    );
-
-    session.stop();
-}
-
-/// Create a workflow in a channel via MCP, trigger it manually, then verify
-/// a run record is created via `get_workflow_runs`.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_create_and_trigger_workflow() {
-    let keys = generate_test_keys();
-    let channel_id = create_channel_for_test(
-        &keys,
-        &format!("mcp-e2e-wf-{}", uuid::Uuid::new_v4().simple()),
-    )
-    .await;
-
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    // A minimal webhook-triggered workflow (no external side effects).
-    let workflow_name = format!("mcp-e2e-wf-{}", uuid::Uuid::new_v4().simple());
-    let yaml_definition = format!(
-        "name: '{workflow_name}'\n\
-         trigger:\n\
-           on: webhook\n\
-         steps:\n\
-           - id: log\n\
-             action: send_message\n\
-             text: 'Workflow triggered by MCP E2E test'\n"
-    );
-
-    // ── create_workflow ─────────────────────────────────────────────────────
-    let create_resp = session.call_tool(
-        "create_workflow",
-        json!({
-            "channel_id": channel_id,
-            "yaml_definition": yaml_definition,
-        }),
-    );
-
-    assert!(
-        create_resp.get("error").is_none(),
-        "create_workflow returned a JSON-RPC error: {create_resp}"
-    );
-
-    let create_text = McpSession::tool_text(&create_resp);
-    if create_text.starts_with("Error") {
-        // The MCP server uses a keypair that may not exist in the users table
-        // (FK constraint on workflows.owner_pubkey).  This is a test-environment
-        // limitation, not a bug.  Skip gracefully.
-        eprintln!("Skipping workflow test — MCP keypair not in users table: {create_text}");
-        session.stop();
-        return;
-    }
-
-    let workflow: Value = serde_json::from_str(&create_text).unwrap_or_else(|e| {
-        panic!("create_workflow response is not valid JSON: {e}\n{create_text}")
-    });
-
-    let workflow_id = workflow["id"]
-        .as_str()
-        .unwrap_or_else(|| panic!("create_workflow response missing 'id': {create_text}"));
-
-    assert!(!workflow_id.is_empty(), "workflow id must not be empty");
-
-    assert_eq!(
-        workflow["name"].as_str().unwrap_or(""),
-        workflow_name,
-        "workflow name mismatch"
-    );
-
-    // ── list_workflows ──────────────────────────────────────────────────────
-    let list_resp = session.call_tool(
-        "list_workflows",
-        json!({
-            "channel_id": channel_id,
-        }),
-    );
-
-    assert!(
-        list_resp.get("error").is_none(),
-        "list_workflows returned a JSON-RPC error: {list_resp}"
-    );
-
-    let list_text = McpSession::tool_text(&list_resp);
-    assert!(
-        !list_text.starts_with("Error"),
-        "list_workflows returned an error: {list_text}"
-    );
-
-    let workflows: Vec<Value> = serde_json::from_str(&list_text).unwrap_or_else(|e| {
-        panic!("list_workflows response is not valid JSON array: {e}\n{list_text}")
-    });
-
-    let found_in_list = workflows
-        .iter()
-        .any(|wf| wf["id"].as_str() == Some(workflow_id));
-
-    assert!(
-        found_in_list,
-        "newly created workflow '{workflow_id}' not found in list_workflows response"
-    );
-
-    // ── trigger_workflow ────────────────────────────────────────────────────
-    let trigger_resp = session.call_tool(
-        "trigger_workflow",
-        json!({
-            "workflow_id": workflow_id,
-            "inputs": {},
-        }),
-    );
-
-    assert!(
-        trigger_resp.get("error").is_none(),
-        "trigger_workflow returned a JSON-RPC error: {trigger_resp}"
-    );
-
-    let trigger_text = McpSession::tool_text(&trigger_resp);
-    assert!(
-        !trigger_text.starts_with("Error"),
-        "trigger_workflow returned an error string: {trigger_text}"
-    );
-
-    // The trigger response should contain a run_id.
-    let trigger_value: Value = serde_json::from_str(&trigger_text).unwrap_or_else(|e| {
-        panic!("trigger_workflow response is not valid JSON: {e}\n{trigger_text}")
-    });
-
-    let run_id = trigger_value["run_id"]
-        .as_str()
-        .unwrap_or_else(|| panic!("trigger_workflow response missing 'run_id': {trigger_text}"));
-
-    assert!(!run_id.is_empty(), "run_id must not be empty");
-
-    // Wait briefly for the async execution to start.
-    tokio::time::sleep(Duration::from_millis(500)).await;
-
-    // ── get_workflow_runs ───────────────────────────────────────────────────
-    let runs_resp = session.call_tool(
-        "get_workflow_runs",
-        json!({
-            "workflow_id": workflow_id,
-            "limit": 10,
-        }),
-    );
-
-    assert!(
-        runs_resp.get("error").is_none(),
-        "get_workflow_runs returned a JSON-RPC error: {runs_resp}"
-    );
-
-    let runs_text = McpSession::tool_text(&runs_resp);
-    assert!(
-        !runs_text.starts_with("Error"),
-        "get_workflow_runs returned an error string: {runs_text}"
-    );
-
-    let runs: Vec<Value> = serde_json::from_str(&runs_text).unwrap_or_else(|e| {
-        panic!("get_workflow_runs response is not valid JSON array: {e}\n{runs_text}")
-    });
-
-    assert!(
-        !runs.is_empty(),
-        "expected at least one run after triggering workflow '{workflow_id}'"
-    );
-
-    let found_run = runs.iter().any(|r| r["id"].as_str() == Some(run_id));
-    assert!(
-        found_run,
-        "triggered run '{run_id}' not found in get_workflow_runs response: {runs_text}"
-    );
-
-    // ── cleanup: delete_workflow ────────────────────────────────────────────
-    let delete_resp = session.call_tool(
-        "delete_workflow",
-        json!({
-            "workflow_id": workflow_id,
-        }),
-    );
-
-    let delete_text = McpSession::tool_text(&delete_resp);
-    assert!(
-        !delete_text.starts_with("Error"),
-        "delete_workflow returned an error: {delete_text}"
-    );
-
-    session.stop();
-}
-
-/// Verify the MCP feed tools work: `get_feed` (with types: "mentions" and "needs_action").
-#[tokio::test]
-#[ignore]
-async fn test_mcp_feed_tools() {
-    let keys = generate_test_keys();
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    // ── get_feed ────────────────────────────────────────────────────────────
-    let feed_resp = session.call_tool("get_feed", json!({"limit": 10}));
-
-    assert!(
-        feed_resp.get("error").is_none(),
-        "get_feed returned a JSON-RPC error: {feed_resp}"
-    );
-
-    let feed_text = McpSession::tool_text(&feed_resp);
-    assert!(
-        !feed_text.starts_with("Error fetching feed"),
-        "get_feed returned an error: {feed_text}"
-    );
-
-    // The feed response should be valid JSON with a 'feed' key.
-    let feed_value: Value = serde_json::from_str(&feed_text)
-        .unwrap_or_else(|e| panic!("get_feed response is not valid JSON: {e}\n{feed_text}"));
-
-    assert!(
-        feed_value.get("feed").is_some(),
-        "get_feed response missing 'feed' key: {feed_text}"
-    );
-
-    let feed = &feed_value["feed"];
-    assert!(
-        feed.get("mentions").is_some(),
-        "feed missing 'mentions' section"
-    );
-    assert!(
-        feed.get("needs_action").is_some(),
-        "feed missing 'needs_action' section"
-    );
-    assert!(
-        feed.get("activity").is_some(),
-        "feed missing 'activity' section"
-    );
-
-    // ── get_feed with types: "mentions" ─────────────────────────────────────
-    let mentions_resp = session.call_tool("get_feed", json!({"types": "mentions", "limit": 10}));
-
-    assert!(
-        mentions_resp.get("error").is_none(),
-        "get_feed(mentions) returned a JSON-RPC error: {mentions_resp}"
-    );
-
-    let mentions_text = McpSession::tool_text(&mentions_resp);
-    assert!(
-        !mentions_text.starts_with("Error"),
-        "get_feed(mentions) returned an error: {mentions_text}"
-    );
-
-    // ── get_feed with types: "needs_action" ──────────────────────────────────
-    let actions_resp = session.call_tool("get_feed", json!({"types": "needs_action", "limit": 10}));
-
-    assert!(
-        actions_resp.get("error").is_none(),
-        "get_feed(needs_action) returned a JSON-RPC error: {actions_resp}"
-    );
-
-    let actions_text = McpSession::tool_text(&actions_resp);
-    assert!(
-        !actions_text.starts_with("Error"),
-        "get_feed(needs_action) returned an error: {actions_text}"
-    );
-
-    session.stop();
-}
-
-/// Verify the canvas tools work: `set_canvas` and `get_canvas`.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_canvas_set_and_get() {
-    let keys = generate_test_keys();
-    let channel_id = create_channel_for_test(
-        &keys,
-        &format!("mcp-e2e-canvas-{}", uuid::Uuid::new_v4().simple()),
-    )
-    .await;
-
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    let unique_content = format!("MCP E2E canvas test: {}", uuid::Uuid::new_v4().simple());
-
-    // ── set_canvas ──────────────────────────────────────────────────────────
-    let set_resp = session.call_tool(
-        "set_canvas",
-        json!({
-            "channel_id": channel_id,
-            "content": unique_content,
-        }),
-    );
-
-    assert!(
-        set_resp.get("error").is_none(),
-        "set_canvas returned a JSON-RPC error: {set_resp}"
-    );
-
-    let set_text = McpSession::tool_text(&set_resp);
-    let set_json: serde_json::Value =
-        serde_json::from_str(&set_text).expect("set_canvas should return JSON");
-    assert_eq!(
-        set_json["accepted"].as_bool(),
-        Some(true),
-        "expected accepted=true from set_canvas, got: {set_text}"
-    );
-
-    // Small delay for the event to propagate.
-    tokio::time::sleep(Duration::from_millis(300)).await;
-
-    // ── get_canvas ──────────────────────────────────────────────────────────
-    let get_resp = session.call_tool(
-        "get_canvas",
-        json!({
-            "channel_id": channel_id,
-        }),
-    );
-
-    assert!(
-        get_resp.get("error").is_none(),
-        "get_canvas returned a JSON-RPC error: {get_resp}"
-    );
-
-    let get_text = McpSession::tool_text(&get_resp);
-    assert_eq!(
-        get_text, unique_content,
-        "expected exact canvas content '{unique_content}' from get_canvas, got: {get_text}"
-    );
-
-    session.stop();
-}
-
-// ── Public profile MCP tests ──────────────────────────────────────────────────
-
-/// Call `get_users` with no arguments to retrieve the authenticated user's own profile.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_get_user_profile_self() {
-    let keys = generate_test_keys();
-
-    // Set profile via signed kind:0 event
-    let client = reqwest::Client::new();
-    set_profile_via_event(
-        &client,
-        &keys,
-        Some("MCP Self Test"),
-        Some("Testing MCP profile"),
-    )
-    .await;
-
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    // Get own profile (no pubkeys arg)
-    let resp = session.send_request(
-        "tools/call",
-        json!({
-            "name": "get_users",
-            "arguments": {}
-        }),
-    );
-
-    let content = &resp["result"]["content"];
-    let text = content[0]["text"].as_str().expect("text");
-    let profile: serde_json::Value = serde_json::from_str(text).expect("parse profile json");
-    assert_eq!(profile["display_name"].as_str(), Some("MCP Self Test"));
-    assert_eq!(profile["about"].as_str(), Some("Testing MCP profile"));
-
-    session.stop();
-}
-
-/// Call `get_users` with a pubkeys argument to retrieve another user's profile.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_get_user_profile_other() {
-    let keys = generate_test_keys();
-
-    // Create another user with a profile via signed kind:0 event
-    let other_keys = Keys::generate();
-    let other_hex = other_keys.public_key().to_hex();
-    let client = reqwest::Client::new();
-    set_profile_via_event(&client, &other_keys, Some("Other User MCP"), None).await;
-
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    let resp = session.send_request(
-        "tools/call",
-        json!({
-            "name": "get_users",
-            "arguments": {"pubkeys": [other_hex]}
-        }),
-    );
-
-    let content = &resp["result"]["content"];
-    let text = content[0]["text"].as_str().expect("text");
-    let profile: serde_json::Value = serde_json::from_str(text).expect("parse profile json");
-    assert_eq!(profile["display_name"].as_str(), Some("Other User MCP"));
-
-    session.stop();
-}
-
-/// Call `get_users` with a mix of known and unknown pubkeys.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_get_users_batch() {
-    let keys = generate_test_keys();
-    let pubkey_hex = keys.public_key().to_hex();
-
-    // Set own profile via signed kind:0 event
-    let client = reqwest::Client::new();
-    set_profile_via_event(&client, &keys, Some("Batch MCP User"), None).await;
-
-    let unknown_hex = Keys::generate().public_key().to_hex();
-
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    let resp = session.send_request(
-        "tools/call",
-        json!({
-            "name": "get_users",
-            "arguments": {"pubkeys": [pubkey_hex, unknown_hex]}
-        }),
-    );
-
-    let content = &resp["result"]["content"];
-    let text = content[0]["text"].as_str().expect("text");
-    let batch: serde_json::Value = serde_json::from_str(text).expect("parse batch json");
-
-    assert!(
-        batch["profiles"].as_object().is_some(),
-        "profiles map present"
-    );
-    assert!(
-        batch["missing"].as_array().is_some(),
-        "missing array present"
-    );
-
-    session.stop();
-}
-
-/// Call `set_presence` via MCP and verify it succeeds.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_set_presence() {
-    let keys = generate_test_keys();
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    // Set presence to "online".
-    let resp = session.call_tool("set_presence", json!({"status": "online"}));
-    assert!(
-        resp.get("error").is_none(),
-        "set_presence returned an error: {resp}"
-    );
-    let text = McpSession::tool_text(&resp);
-    let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON");
-    assert_eq!(
-        parsed["status"].as_str(),
-        Some("online"),
-        "set_presence response should have status 'online', got: {text}"
-    );
-    assert_eq!(
-        parsed["ttl_seconds"].as_u64(),
-        Some(90),
-        "online presence should have 90s TTL, got: {text}"
-    );
-
-    // Verify via get_presence.
-    let pubkey_hex = keys.public_key().to_hex();
-    let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex}));
-    assert!(
-        resp.get("error").is_none(),
-        "get_presence returned an error: {resp}"
-    );
-    let text = McpSession::tool_text(&resp);
-    let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON");
-    assert_eq!(
-        parsed[&pubkey_hex].as_str(),
-        Some("online"),
-        "get_presence should show 'online' after set_presence, got: {text}"
-    );
-
-    session.stop();
-}
-
-/// Call `set_presence` with "offline" via MCP and verify presence is cleared.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_set_presence_offline() {
-    let keys = generate_test_keys();
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    // First set to "online".
-    let resp = session.call_tool("set_presence", json!({"status": "online"}));
-    assert!(
-        resp.get("error").is_none(),
-        "set_presence(online) returned an error: {resp}"
-    );
-
-    // Now set to "offline" — should clear presence.
-    let resp = session.call_tool("set_presence", json!({"status": "offline"}));
-    assert!(
-        resp.get("error").is_none(),
-        "set_presence(offline) returned an error: {resp}"
-    );
-    let text = McpSession::tool_text(&resp);
-    let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON");
-    assert_eq!(
-        parsed["status"].as_str(),
-        Some("offline"),
-        "set_presence(offline) response should have status 'offline', got: {text}"
-    );
-    assert_eq!(
-        parsed["ttl_seconds"].as_u64(),
-        Some(0),
-        "offline presence should have 0 TTL, got: {text}"
-    );
-
-    // Verify via get_presence — should show "offline".
-    let pubkey_hex = keys.public_key().to_hex();
-    let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex}));
-    assert!(
-        resp.get("error").is_none(),
-        "get_presence returned an error: {resp}"
-    );
-    let text = McpSession::tool_text(&resp);
-    let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON");
-    assert_eq!(
-        parsed[&pubkey_hex].as_str(),
-        Some("offline"),
-        "get_presence should show 'offline' after clearing, got: {text}"
-    );
-
-    session.stop();
-}
-
-/// Call `set_presence` with "away" via MCP and verify round-trip.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_set_presence_away() {
-    let keys = generate_test_keys();
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    let resp = session.call_tool("set_presence", json!({"status": "away"}));
-    assert!(
-        resp.get("error").is_none(),
-        "set_presence(away) returned an error: {resp}"
-    );
-    let text = McpSession::tool_text(&resp);
-    let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON");
-    assert_eq!(
-        parsed["status"].as_str(),
-        Some("away"),
-        "set_presence response should have status 'away', got: {text}"
-    );
-    assert_eq!(
-        parsed["ttl_seconds"].as_u64(),
-        Some(90),
-        "away presence should have 90s TTL, got: {text}"
-    );
-
-    // Verify via get_presence.
-    let pubkey_hex = keys.public_key().to_hex();
-    let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex}));
-    assert!(
-        resp.get("error").is_none(),
-        "get_presence returned an error: {resp}"
-    );
-    let text = McpSession::tool_text(&resp);
-    let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON");
-    assert_eq!(
-        parsed[&pubkey_hex].as_str(),
-        Some("away"),
-        "get_presence should show 'away', got: {text}"
-    );
-
-    session.stop();
-}
-
-/// Call `set_presence` with an invalid status via MCP and verify error.
-#[tokio::test]
-#[ignore]
-async fn test_mcp_set_presence_invalid_status() {
-    let keys = generate_test_keys();
-    let mut session = McpSession::start(&keys).await;
-    session.initialize();
-
-    let resp = session.call_tool("set_presence", json!({"status": "invisible"}));
-    // MCP framework rejects invalid enum variants at the JSON-RPC level (not as a tool result),
-    // so the response has an "error" key rather than a "result" key.
-    let has_error = resp.get("error").is_some();
-    let text = McpSession::tool_text(&resp);
-    let has_error_text = text.contains("422") || text.contains("error") || text.contains("Error");
-    assert!(
-        has_error || has_error_text,
-        "invalid status should return a JSON-RPC error or error text, got: {resp}"
-    );
-
-    session.stop();
-}
diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs
index efc4ed308b..918b100905 100644
--- a/desktop/src-tauri/src/commands/agent_discovery.rs
+++ b/desktop/src-tauri/src/commands/agent_discovery.rs
@@ -6,7 +6,7 @@ use crate::{
     managed_agents::{
         command_availability, AcpProviderCatalogEntry, DiscoverManagedAgentPrereqsRequest,
         InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo,
-        DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
+        DEFAULT_ACP_COMMAND,
     },
     nostr_convert,
     relay::query_relay,
@@ -298,7 +298,7 @@ pub fn discover_managed_agent_prereqs(
         .as_deref()
         .map(str::trim)
         .filter(|value| !value.is_empty())
-        .unwrap_or(DEFAULT_MCP_COMMAND);
+        .unwrap_or("");
 
     ManagedAgentPrereqsInfo {
         acp: command_availability(acp_command),
diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs
index dc1ab6059c..e2890bcf50 100644
--- a/desktop/src-tauri/src/commands/agent_models.rs
+++ b/desktop/src-tauri/src/commands/agent_models.rs
@@ -10,7 +10,7 @@ use crate::{
         load_managed_agents, managed_agent_avatar_url, missing_command_message,
         normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes,
         try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest,
-        UpdateManagedAgentResponse, DEFAULT_MCP_COMMAND,
+        UpdateManagedAgentResponse,
     },
     relay::{relay_ws_url_with_override, sync_managed_agent_profile},
     util::now_iso,
@@ -192,11 +192,7 @@ pub async fn update_managed_agent(
             record.agent_args = agent_args;
         }
         if let Some(mcp_command) = input.mcp_command {
-            record.mcp_command = if mcp_command.trim().is_empty() {
-                DEFAULT_MCP_COMMAND.to_string()
-            } else {
-                mcp_command
-            };
+            record.mcp_command = mcp_command;
         }
         if let Some(env_vars) = input.env_vars {
             crate::managed_agents::validate_user_env_keys(&env_vars)?;
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index c6a9173514..44683c2253 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -13,7 +13,6 @@ use crate::{
         BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
         ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND,
         DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
-        DEFAULT_MCP_COMMAND,
     },
     relay::{relay_ws_url_with_override, sync_managed_agent_profile},
     util::now_iso,
@@ -410,7 +409,7 @@ pub async fn create_managed_agent(
             .unwrap_or_else(
                 || match crate::managed_agents::known_acp_provider(&agent_command) {
                     Some(p) => p.mcp_command.unwrap_or("").to_string(),
-                    None => DEFAULT_MCP_COMMAND.to_string(),
+                    None => String::new(),
                 },
             );
 
diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs
index 4aa3f6c756..847269d818 100644
--- a/desktop/src-tauri/src/managed_agents/discovery.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery.rs
@@ -11,9 +11,7 @@ pub(crate) struct KnownAcpProvider {
     pub commands: &'static [&'static str],
     pub aliases: &'static [&'static str],
     pub avatar_url: &'static str,
-    /// MCP server binary to use instead of the default `sprout-mcp-server`.
-    /// `None` means this provider does not need a Sprout MCP server —
-    /// no MCP tools will be registered for the agent session.
+    /// MCP server binary for this runtime, or `None` for no MCP server.
     pub mcp_command: Option<&'static str>,
     /// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
     pub mcp_hooks: bool,
diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs
index 4dd6b7d14d..1f3f4a5e94 100644
--- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs
+++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs
@@ -45,7 +45,7 @@ mod tests {
             acp_command: "sprout-acp".into(),
             agent_command: "goose".into(),
             agent_args: vec![],
-            mcp_command: "sprout-mcp-server".into(),
+            mcp_command: String::new(),
             turn_timeout_seconds: 320,
             idle_timeout_seconds: None,
             max_turn_duration_seconds: None,
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 97048f0678..194fff7db8 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -30,8 +30,6 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[
     "codex-acp",
     "codex_acp",
     "goose",
-    "sprout-mcp",
-    "sprout_mcp",
     // sprout-dev-mcp's multicall personalities (rg, tree, sprout,
     // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call
     // invocations — not listed here.
@@ -860,7 +858,6 @@ pub fn spawn_agent_child(
         }
     }
     // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them.
-    // Uses "*" because build_mcp_servers() hard-codes the server name to "sprout-mcp".
     if known_acp_provider(&record.agent_command).is_some_and(|p| p.mcp_hooks) {
         command.env("MCP_HOOK_SERVERS", "*");
     }
@@ -1213,7 +1210,7 @@ mod tests {
             acp_command: "sprout-acp".into(),
             agent_command: "goose".into(),
             agent_args: vec![],
-            mcp_command: "sprout-mcp-server".into(),
+            mcp_command: String::new(),
             turn_timeout_seconds: 320,
             idle_timeout_seconds: None,
             max_turn_duration_seconds: None,
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index 2252979d05..922e76e6c2 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -450,7 +450,6 @@ pub struct UpdateTeamRequest {
 
 pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp";
 pub const DEFAULT_AGENT_COMMAND: &str = "goose";
-pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server";
 /// ~5 min (320s) — matches the CLI harness default (SPROUT_ACP_IDLE_TIMEOUT).
 pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320;
 /// 1 hour — absolute wall-clock safety cap per turn.
@@ -569,7 +568,7 @@ mod tests {
                 "acp_command": "sprout-acp",
                 "agent_command": "goose",
                 "agent_args": [],
-                "mcp_command": "sprout-mcp-server",
+                "mcp_command": "",
                 "turn_timeout_seconds": 320,
                 "system_prompt": null,
                 "created_at": "2026-01-01T00:00:00Z",
@@ -598,7 +597,7 @@ mod tests {
             "acp_command": "sprout-acp",
             "agent_command": "goose",
             "agent_args": [],
-            "mcp_command": "sprout-mcp-server",
+            "mcp_command": "",
             "turn_timeout_seconds": 320,
             "system_prompt": null,
             "created_at": "2026-01-01T00:00:00Z",
@@ -676,7 +675,7 @@ mod tests {
                 "acp_command": "sprout-acp",
                 "agent_command": "goose",
                 "agent_args": [],
-                "mcp_command": "sprout-mcp-server",
+                "mcp_command": "",
                 "turn_timeout_seconds": 320,
                 "system_prompt": null,
                 "created_at": "2026-01-01T00:00:00Z",
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index 24cbc5c6cf..b85b236db4 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -51,7 +51,6 @@
     "targets": "all",
     "externalBin": [
       "binaries/sprout-acp",
-      "binaries/sprout-mcp-server",
       "binaries/sprout-agent",
       "binaries/sprout-dev-mcp",
       "binaries/git-credential-nostr",
diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts
index c9c78047e4..adafcbc207 100644
--- a/desktop/src/features/agents/channelAgents.ts
+++ b/desktop/src/features/agents/channelAgents.ts
@@ -222,7 +222,7 @@ export async function ensureChannelAgentPresetInChannel(
     acpCommand: "sprout-acp",
     agentCommand: input.provider.command,
     agentArgs: input.provider.defaultArgs,
-    mcpCommand: input.provider.mcpCommand ?? "sprout-mcp-server",
+    mcpCommand: input.provider.mcpCommand ?? "",
     spawnAfterCreate: false,
   });
   const attached = await attachManagedAgentToChannel(channelId, {
@@ -364,7 +364,7 @@ export async function createChannelManagedAgent(
     acpCommand: "sprout-acp",
     agentCommand: input.provider.command,
     agentArgs: input.provider.defaultArgs,
-    mcpCommand: input.provider.mcpCommand ?? "sprout-mcp-server",
+    mcpCommand: input.provider.mcpCommand ?? "",
     personaId: input.personaId ?? undefined,
     systemPrompt: input.systemPrompt?.trim() || undefined,
     avatarUrl: resolvedAvatarUrl,
diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx
index fde7ac967b..077322580a 100644
--- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx
+++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx
@@ -59,7 +59,7 @@ export function CreateAgentDialog({
   const [acpCommand, setAcpCommand] = React.useState("sprout-acp");
   const [agentCommand, setAgentCommand] = React.useState("goose");
   const [agentArgs, setAgentArgs] = React.useState("acp");
-  const [mcpCommand, setMcpCommand] = React.useState("sprout-mcp-server");
+  const [mcpCommand, setMcpCommand] = React.useState("");
   const [mcpToolsets, setMcpToolsets] = React.useState("");
   const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand);
   const [name, setName] = React.useState("");
@@ -143,7 +143,7 @@ export function CreateAgentDialog({
       setSelectedProviderId(remembered.id);
       setAgentCommand(remembered.command);
       setAgentArgs(remembered.defaultArgs.join(","));
-      setMcpCommand(remembered.mcpCommand ?? "sprout-mcp-server");
+      setMcpCommand(remembered.mcpCommand ?? "");
     } else {
       const matchingProvider =
         providers.find((provider) => provider.command === agentCommand) ?? null;
@@ -235,7 +235,7 @@ export function CreateAgentDialog({
     setAcpCommand("sprout-acp");
     setAgentCommand("goose");
     setAgentArgs("acp");
-    setMcpCommand("sprout-mcp-server");
+    setMcpCommand("");
     setMcpToolsets("");
     setTurnTimeoutSeconds("320");
     setParallelism("24");
@@ -282,7 +282,7 @@ export function CreateAgentDialog({
     setLastProvider(nextProviderId);
     setAgentCommand(provider.command);
     setAgentArgs(provider.defaultArgs.join(","));
-    setMcpCommand(provider.mcpCommand ?? "sprout-mcp-server");
+    setMcpCommand(provider.mcpCommand ?? "");
   }
 
   function handleRunOnChange(value: string) {
diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx
index c917f20184..93033bfa83 100644
--- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx
+++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx
@@ -248,8 +248,8 @@ export function CreateAgentRuntimeFields({
             className="text-xs text-muted-foreground"
             id="help-agent-mcp-command"
           >
-            Command the ACP harness uses to start the MCP tool server for this
-            agent.
+            Optional. Only needed for agents that use a custom MCP server (e.g.
+            sprout-agent with sprout-dev-mcp). Leave blank for most agents.
           </p>
         </div>
 
@@ -402,7 +402,7 @@ export function CreateAgentOptionToggles({
         </p>
         <p className="mt-1 text-sm text-foreground/70">
           {prereqs !== null && !isSpawnSupported
-            ? "Requires both the ACP harness and MCP server binaries."
+            ? "Requires the ACP harness binary."
             : "Start the local ACP harness immediately after the profile is saved."}
         </p>
       </button>
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index f43843a8ea..812a10930b 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -3879,13 +3879,8 @@ async function handleDiscoverManagedAgentPrereqs(
       available: configuredPrereqs?.acp?.available ?? true,
     },
     mcp: {
-      command:
-        configuredPrereqs?.mcp?.command ??
-        args.input?.mcpCommand ??
-        "sprout-mcp-server",
-      resolved_path:
-        configuredPrereqs?.mcp?.resolvedPath ??
-        "/Users/wesb/dev/sprout/target/debug/sprout-mcp-server",
+      command: configuredPrereqs?.mcp?.command ?? args.input?.mcpCommand ?? "",
+      resolved_path: configuredPrereqs?.mcp?.resolvedPath ?? "",
       available: configuredPrereqs?.mcp?.available ?? true,
     },
   };
@@ -4207,7 +4202,7 @@ async function handleCreateManagedAgent(args: {
       args.input.agentArgs && args.input.agentArgs.length > 0
         ? [...args.input.agentArgs]
         : ["acp"],
-    mcp_command: args.input.mcpCommand ?? "sprout-mcp-server",
+    mcp_command: args.input.mcpCommand ?? "",
     turn_timeout_seconds: args.input.turnTimeoutSeconds ?? 320,
     idle_timeout_seconds: args.input.idleTimeoutSeconds ?? null,
     max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null,
diff --git a/justfile b/justfile
index 577b565c7a..2ac075c407 100644
--- a/justfile
+++ b/justfile
@@ -118,7 +118,7 @@ _ensure-sidecar-stubs:
     set -euo pipefail
     TARGET=$(rustc -vV | sed -n 's|host: ||p')
     mkdir -p desktop/src-tauri/binaries
-    for bin in sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr sprout; do
+    for bin in sprout-acp sprout-agent sprout-dev-mcp git-credential-nostr sprout; do
         touch "desktop/src-tauri/binaries/${bin}-${TARGET}"
     done
 
@@ -175,7 +175,6 @@ desktop-release-build target="aarch64-apple-darwin":
     TARGET={{target}}
     mkdir -p desktop/src-tauri/binaries
     touch "desktop/src-tauri/binaries/sprout-acp-$TARGET"
-    touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET"
     touch "desktop/src-tauri/binaries/sprout-agent-$TARGET"
     touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET"
     touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
@@ -283,7 +282,7 @@ staging *ARGS: _ensure-sidecar-stubs
     #!/usr/bin/env bash
     set -euo pipefail
     pnpm install
-    cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p sprout-cli
+    cargo build --release -p sprout-acp -p sprout-agent -p sprout-dev-mcp -p sprout-cli
     # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up.
     TARGET=$(rustc -vV | sed -n 's|host: ||p')
     cp target/release/sprout "desktop/src-tauri/binaries/sprout-${TARGET}"
@@ -535,13 +534,12 @@ release *ARGS:
 goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY":
     #!/usr/bin/env bash
     set -euo pipefail
-    cargo build --release -p sprout-acp -p sprout-mcp -p sprout-cli
+    cargo build --release -p sprout-acp -p sprout-cli
     env_args=(
         SPROUT_RELAY_URL="{{relay}}"
         SPROUT_PRIVATE_KEY="{{key}}"
         SPROUT_ACP_AGENT_COMMAND=goose
         SPROUT_ACP_AGENT_ARGS=acp
-        SPROUT_ACP_MCP_COMMAND=./target/release/sprout-mcp-server
         SPROUT_ACP_AGENTS="{{agents}}"
         GOOSE_MODE=auto
     )
@@ -555,13 +553,12 @@ goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROU
 goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY":
     #!/usr/bin/env bash
     set -euo pipefail
-    cargo build --release -p sprout-acp -p sprout-mcp -p sprout-cli
+    cargo build --release -p sprout-acp -p sprout-cli
     env_args=(
         SPROUT_RELAY_URL="{{relay}}"
         SPROUT_PRIVATE_KEY="{{key}}"
         SPROUT_ACP_AGENT_COMMAND=goose
         SPROUT_ACP_AGENT_ARGS=acp
-        SPROUT_ACP_MCP_COMMAND=./target/release/sprout-mcp-server
         SPROUT_ACP_AGENTS="{{agents}}"
         GOOSE_MODE=auto
     )
diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh
index 0e8d2ba96f..3d4ad8d760 100755
--- a/scripts/bundle-sidecars.sh
+++ b/scripts/bundle-sidecars.sh
@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
-SIDECARS=(sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr sprout)
+SIDECARS=(sprout-acp sprout-agent sprout-dev-mcp git-credential-nostr sprout)
 TARGET=${1:-$(rustc -vV | sed -n 's|host: ||p')}
 BINARIES_DIR="desktop/src-tauri/binaries"
 
@@ -11,7 +11,7 @@ for bin in "${SIDECARS[@]}"; do
 done
 if [[ ${#missing[@]} -gt 0 ]]; then
     echo "Error: missing release binaries: ${missing[*]}" >&2
-    echo "Run 'cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli' first." >&2
+    echo "Run 'cargo build --release -p sprout-acp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli' first." >&2
     exit 1
 fi
 

From 6825052eeaf3ef5d9d741283038ae5a9cb880565 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 4 Jun 2026 14:49:33 -0400
Subject: [PATCH 2/7] fix: add CLI parity features, eliminate
 sprout-relay-client, address review feedback

The first commit claimed to fill CLI gaps but the 6 features were never
committed. This adds them: `dms hide`, `channels set-add-policy`,
`--inputs` on `workflows trigger`, `--depth-limit` on `messages thread`,
`--types` on `feed get`, `--before-id` on `social notes`.

Eliminates `sprout-relay-client` (1,967 lines, zero production consumers)
by inlining the 3 types that `sprout-test-client` actually uses (~110 lines).
Tightens the MCP command migration to only clear the known stale value
`"sprout-mcp-server"` instead of overwriting any custom value. Removes
orphaned `SPROUT_TOOLSETS` forwarding from `build_mcp_servers()`. Adds
`--mention` documentation to the sprout-cli skill.
---
 Cargo.lock                                    |   18 -
 Cargo.toml                                    |    2 -
 crates/sprout-acp/src/lib.rs                  |   43 +-
 crates/sprout-cli/src/commands/channels.rs    |   26 +
 crates/sprout-cli/src/commands/dms.rs         |   17 +
 crates/sprout-cli/src/commands/feed.rs        |   22 +-
 crates/sprout-cli/src/commands/messages.rs    |    9 +-
 crates/sprout-cli/src/commands/social.rs      |   10 +-
 crates/sprout-cli/src/commands/workflows.rs   |   39 +-
 crates/sprout-cli/src/lib.rs                  |   39 +-
 crates/sprout-relay-client/Cargo.toml         |   25 -
 crates/sprout-relay-client/src/lib.rs         | 1967 -----------------
 crates/sprout-test-client/Cargo.toml          |    1 -
 crates/sprout-test-client/src/lib.rs          |  157 +-
 .../src/managed_agents/nest_skill.md          |   17 +
 desktop/src-tauri/src/migration.rs            |   41 +-
 16 files changed, 354 insertions(+), 2079 deletions(-)
 delete mode 100644 crates/sprout-relay-client/Cargo.toml
 delete mode 100644 crates/sprout-relay-client/src/lib.rs

diff --git a/Cargo.lock b/Cargo.lock
index 4d5d53e2d0..6f1f8ec25b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7799,23 +7799,6 @@ dependencies = [
  "uuid",
 ]
 
-[[package]]
-name = "sprout-relay-client"
-version = "0.1.0"
-dependencies = [
- "futures-util",
- "nostr",
- "reqwest 0.13.3",
- "serde",
- "serde_json",
- "thiserror 2.0.18",
- "tokio",
- "tokio-tungstenite 0.29.0",
- "tracing",
- "url",
- "uuid",
-]
-
 [[package]]
 name = "sprout-sdk"
 version = "0.1.0"
@@ -7862,7 +7845,6 @@ dependencies = [
  "serde_json",
  "sha2 0.11.0",
  "sprout-core",
- "sprout-relay-client",
  "thiserror 2.0.18",
  "tokio",
  "tokio-tungstenite 0.29.0",
diff --git a/Cargo.toml b/Cargo.toml
index 3ac02ce050..ab27520804 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,6 @@ members = [
     "crates/sprout-auth",
     "crates/sprout-search",
     "crates/sprout-audit",
-    "crates/sprout-relay-client",
     "crates/sprout-acp",
     "crates/sprout-agent",
     "crates/sprig",
@@ -113,7 +112,6 @@ sprout-auth = { path = "crates/sprout-auth" }
 sprout-pubsub = { path = "crates/sprout-pubsub" }
 sprout-search = { path = "crates/sprout-search" }
 sprout-audit = { path = "crates/sprout-audit" }
-sprout-relay-client = { path = "crates/sprout-relay-client" }
 sprout-proxy = { path = "crates/sprout-proxy" }
 sprout-workflow = { path = "crates/sprout-workflow" }
 sprout-media = { path = "crates/sprout-media" }
diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs
index 1edb8fbd65..a251d303ef 100644
--- a/crates/sprout-acp/src/lib.rs
+++ b/crates/sprout-acp/src/lib.rs
@@ -2640,16 +2640,6 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
                         .expect("secret key bech32 encoding should never fail"),
                 },
             ];
-            // Forward SPROUT_TOOLSETS so the MCP server enables the
-            // same toolsets the operator configured for this harness.
-            if let Ok(ts) = std::env::var("SPROUT_TOOLSETS") {
-                if !ts.is_empty() {
-                    env.push(EnvVar {
-                        name: "SPROUT_TOOLSETS".into(),
-                        value: ts,
-                    });
-                }
-            }
             // Forward SPROUT_AUTH_TAG (NIP-OA owner attestation credential)
             // so the MCP server can attach it to every signed event.
             if let Ok(auth_tag) = std::env::var("SPROUT_AUTH_TAG") {
@@ -2895,4 +2885,37 @@ mod build_mcp_servers_tests {
             "empty mcp_command should produce no MCP servers"
         );
     }
+
+    #[test]
+    fn absolute_path_mcp_command_uses_file_stem_as_name() {
+        let mut config = test_config();
+        config.mcp_command = "/opt/bin/my-mcp-server".into();
+        let servers = build_mcp_servers(&config);
+        assert_eq!(servers.len(), 1);
+        assert_eq!(servers[0].name, "my-mcp-server");
+    }
+
+    #[test]
+    fn mcp_command_with_no_stem_falls_back_to_mcp() {
+        // Path::new("").file_stem() returns None — exercises the unwrap_or("mcp") path.
+        let mut config = test_config();
+        config.mcp_command = "".into();
+        // Empty command returns no servers; test the stem logic directly.
+        assert_eq!(
+            std::path::Path::new("")
+                .file_stem()
+                .and_then(|s| s.to_str())
+                .unwrap_or("mcp"),
+            "mcp"
+        );
+
+        // Confirm a non-empty command with no stem (e.g. just a dot) also falls back.
+        config.mcp_command = ".".into();
+        let servers = build_mcp_servers(&config);
+        assert_eq!(servers.len(), 1);
+        assert_eq!(
+            servers[0].name, "mcp",
+            "Path::new(\".\").file_stem() is None — should fall back to \"mcp\""
+        );
+    }
 }
diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs
index 5c78c394d9..8801890788 100644
--- a/crates/sprout-cli/src/commands/channels.rs
+++ b/crates/sprout-cli/src/commands/channels.rs
@@ -502,6 +502,31 @@ pub async fn cmd_remove_channel_member(
     Ok(())
 }
 
+/// Set the channel addition policy — sign and submit a kind:10100 (agent profile) event.
+pub async fn cmd_set_add_policy(client: &SproutClient, policy: &str) -> Result<(), CliError> {
+    match policy {
+        "anyone" | "owner_only" | "nobody" => {}
+        _ => {
+            return Err(CliError::Usage(format!(
+                "--policy must be 'anyone', 'owner_only', or 'nobody' (got: {policy})"
+            )))
+        }
+    }
+
+    let content = serde_json::json!({ "channel_add_policy": policy }).to_string();
+    use nostr::{EventBuilder, Kind};
+    let builder = EventBuilder::new(
+        Kind::Custom(sprout_sdk::kind::KIND_AGENT_PROFILE as u16),
+        &content,
+    )
+    .tags([]);
+    let event = client.sign_event(builder)?;
+
+    let resp = client.submit_event(event).await?;
+    println!("{}", normalize_write_response(&resp));
+    Ok(())
+}
+
 pub async fn cmd_set_canvas(
     client: &SproutClient,
     channel_id: &str,
@@ -585,6 +610,7 @@ pub async fn dispatch(
         ChannelsCmd::RemoveMember { channel, pubkey } => {
             cmd_remove_channel_member(client, &channel, &pubkey).await
         }
+        ChannelsCmd::SetAddPolicy { policy } => cmd_set_add_policy(client, &policy).await,
     }
 }
 
diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs
index 9b46f4f45c..a5f9a528b1 100644
--- a/crates/sprout-cli/src/commands/dms.rs
+++ b/crates/sprout-cli/src/commands/dms.rs
@@ -92,6 +92,22 @@ pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<()
     Ok(())
 }
 
+/// Hide a DM channel — sign and submit a kind:41012 event with h-tag.
+pub async fn cmd_hide_dm(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
+    let channel_uuid = parse_uuid(channel_id)?;
+
+    use nostr::{EventBuilder, Kind, Tag};
+    let tags = vec![Tag::parse(["h", &channel_uuid.to_string()])
+        .map_err(|e| CliError::Other(format!("tag error: {e}")))?];
+    let builder =
+        EventBuilder::new(Kind::Custom(sprout_sdk::kind::KIND_DM_HIDE as u16), "").tags(tags);
+    let event = client.sign_event(builder)?;
+
+    let resp = client.submit_event(event).await?;
+    println!("{}", normalize_write_response(&resp));
+    Ok(())
+}
+
 /// Add a member to a DM group — sign and submit a kind:41011 event.
 pub async fn cmd_add_dm_member(
     client: &SproutClient,
@@ -118,5 +134,6 @@ pub async fn dispatch(cmd: crate::DmsCmd, client: &SproutClient) -> Result<(), C
         DmsCmd::List { limit } => cmd_list_dms(client, limit).await,
         DmsCmd::Open { pubkeys } => cmd_open_dm(client, &pubkeys).await,
         DmsCmd::AddMember { channel, pubkey } => cmd_add_dm_member(client, &channel, &pubkey).await,
+        DmsCmd::Hide { channel } => cmd_hide_dm(client, &channel).await,
     }
 }
diff --git a/crates/sprout-cli/src/commands/feed.rs b/crates/sprout-cli/src/commands/feed.rs
index 50e3bc2ce1..7a91555341 100644
--- a/crates/sprout-cli/src/commands/feed.rs
+++ b/crates/sprout-cli/src/commands/feed.rs
@@ -3,11 +3,14 @@ use std::cmp::Reverse;
 use crate::client::{normalize_events, SproutClient};
 use crate::error::CliError;
 
+const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"];
+
 /// Get activity feed — query events mentioning our pubkey (via p-tag).
 pub async fn cmd_get_feed(
     client: &SproutClient,
     since: Option<i64>,
     limit: Option<u32>,
+    types: Option<&str>,
     format: &crate::OutputFormat,
 ) -> Result<(), CliError> {
     let my_pk = client.keys().public_key().to_hex();
@@ -22,6 +25,19 @@ pub async fn cmd_get_feed(
         filter["since"] = serde_json::json!(s);
     }
 
+    if let Some(types_str) = types {
+        let type_list: Vec<&str> = types_str.split(',').map(str::trim).collect();
+        for t in &type_list {
+            if !VALID_FEED_TYPES.contains(t) {
+                return Err(crate::error::CliError::Usage(format!(
+                    "invalid feed type {t:?} — must be one of: {}",
+                    VALID_FEED_TYPES.join(", ")
+                )));
+            }
+        }
+        filter["feed_types"] = serde_json::json!(type_list);
+    }
+
     let resp = client.query(&filter).await?;
     let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
     events.sort_by_key(|e| Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)));
@@ -59,6 +75,10 @@ pub async fn dispatch(
 ) -> Result<(), CliError> {
     use crate::FeedCmd;
     match cmd {
-        FeedCmd::Get { since, limit } => cmd_get_feed(client, since, limit, format).await,
+        FeedCmd::Get {
+            since,
+            limit,
+            types,
+        } => cmd_get_feed(client, since, limit, types.as_deref(), format).await,
     }
 }
diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs
index f535e284ab..223433ca74 100644
--- a/crates/sprout-cli/src/commands/messages.rs
+++ b/crates/sprout-cli/src/commands/messages.rs
@@ -288,6 +288,7 @@ pub async fn cmd_get_thread(
     channel_id: &str,
     event_id: &str,
     limit: Option<u32>,
+    depth_limit: Option<u32>,
     format: &crate::OutputFormat,
 ) -> Result<(), CliError> {
     validate_uuid(channel_id)?;
@@ -297,12 +298,15 @@ pub async fn cmd_get_thread(
     // Two filters ORed in a single HTTP call:
     // 1. Replies referencing this event via e-tag (no kind restriction)
     // 2. The root event itself by ID
-    let reply_filter = serde_json::json!({
+    let mut reply_filter = serde_json::json!({
         "kinds": [9, 40002, 40003, 40008, 45003],
         "#h": [channel_id],
         "#e": [event_id],
         "limit": limit
     });
+    if let Some(d) = depth_limit {
+        reply_filter["depth_limit"] = serde_json::json!(d);
+    }
     let root_filter = serde_json::json!({
         "ids": [event_id],
         "limit": 1
@@ -700,7 +704,8 @@ pub async fn dispatch(
             channel,
             event,
             limit,
-        } => cmd_get_thread(client, &channel, &event, limit, format).await,
+            depth_limit,
+        } => cmd_get_thread(client, &channel, &event, limit, depth_limit, format).await,
         MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit, format).await,
         MessagesCmd::Vote { event, direction } => {
             cmd_vote_on_post(client, &event, &direction).await
diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs
index 81ee4cb7fe..a0cf9b3e1a 100644
--- a/crates/sprout-cli/src/commands/social.rs
+++ b/crates/sprout-cli/src/commands/social.rs
@@ -85,8 +85,12 @@ pub async fn cmd_get_user_notes(
     pubkey: &str,
     limit: Option<u32>,
     before: Option<i64>,
+    before_id: Option<&str>,
 ) -> Result<(), CliError> {
     validate_hex64(pubkey)?;
+    if let Some(bid) = before_id {
+        validate_hex64(bid)?;
+    }
     let limit = limit.unwrap_or(50).min(100);
 
     let mut filter = serde_json::json!({
@@ -98,6 +102,9 @@ pub async fn cmd_get_user_notes(
     if let Some(b) = before {
         filter["until"] = serde_json::json!(b);
     }
+    if let Some(bid) = before_id {
+        filter["before_id"] = serde_json::json!(bid);
+    }
 
     let resp = client.query(&filter).await?;
     println!("{resp}");
@@ -217,7 +224,8 @@ pub async fn dispatch(cmd: crate::SocialCmd, client: &SproutClient) -> Result<()
             pubkey,
             limit,
             before,
-        } => cmd_get_user_notes(client, &pubkey, limit, before).await,
+            before_id,
+        } => cmd_get_user_notes(client, &pubkey, limit, before, before_id.as_deref()).await,
         SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await,
         SocialCmd::SetList {
             kind,
diff --git a/crates/sprout-cli/src/commands/workflows.rs b/crates/sprout-cli/src/commands/workflows.rs
index 5dc6b57c02..d014b5e5d2 100644
--- a/crates/sprout-cli/src/commands/workflows.rs
+++ b/crates/sprout-cli/src/commands/workflows.rs
@@ -153,17 +153,42 @@ pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Re
 }
 
 /// Trigger a workflow — sign and submit a kind:46020 event.
+///
+/// When `inputs` is provided, it is parsed as a JSON object and used as the
+/// event content (MCP parity). When omitted, the event content is `{}`.
 pub async fn cmd_trigger_workflow(
     client: &SproutClient,
     workflow_id: &str,
+    inputs: Option<&str>,
 ) -> Result<(), CliError> {
     let wf_uuid = parse_uuid(workflow_id)?;
 
-    let builder = sprout_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?;
-    let event = client.sign_event(builder)?;
-
-    let resp = client.submit_event(event).await?;
-    println!("{}", normalize_write_response(&resp));
+    if let Some(raw) = inputs {
+        // Parse and validate it is a JSON object, then build the event manually
+        // so we can embed the inputs as the event content.
+        let parsed: serde_json::Value = serde_json::from_str(raw)
+            .map_err(|e| CliError::Usage(format!("--inputs is not valid JSON: {e}")))?;
+        if !parsed.is_object() {
+            return Err(CliError::Usage("--inputs must be a JSON object".into()));
+        }
+        let content = serde_json::to_string(&parsed).unwrap_or_default();
+        use nostr::{EventBuilder, Kind, Tag};
+        let tags = vec![Tag::parse(["d", &wf_uuid.to_string()])
+            .map_err(|e| CliError::Other(format!("tag error: {e}")))?];
+        let builder = EventBuilder::new(
+            Kind::Custom(sprout_sdk::kind::KIND_WORKFLOW_TRIGGER as u16),
+            &content,
+        )
+        .tags(tags);
+        let event = client.sign_event(builder)?;
+        let resp = client.submit_event(event).await?;
+        println!("{}", normalize_write_response(&resp));
+    } else {
+        let builder = sprout_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?;
+        let event = client.sign_event(builder)?;
+        let resp = client.submit_event(event).await?;
+        println!("{}", normalize_write_response(&resp));
+    }
     Ok(())
 }
 
@@ -207,7 +232,9 @@ pub async fn dispatch(cmd: crate::WorkflowsCmd, client: &SproutClient) -> Result
             yaml,
         } => cmd_update_workflow(client, &channel, &workflow, &yaml).await,
         WorkflowsCmd::Delete { workflow } => cmd_delete_workflow(client, &workflow).await,
-        WorkflowsCmd::Trigger { workflow } => cmd_trigger_workflow(client, &workflow).await,
+        WorkflowsCmd::Trigger { workflow, inputs } => {
+            cmd_trigger_workflow(client, &workflow, inputs.as_deref()).await
+        }
         WorkflowsCmd::Runs { workflow, limit } => {
             cmd_get_workflow_runs(client, &workflow, limit).await
         }
diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs
index 374be2fefc..9f9e7dec86 100644
--- a/crates/sprout-cli/src/lib.rs
+++ b/crates/sprout-cli/src/lib.rs
@@ -333,6 +333,9 @@ pub enum MessagesCmd {
         /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
+        /// Maximum reply nesting depth to include
+        #[arg(long)]
+        depth_limit: Option<u32>,
     },
     /// Full-text search across messages
     Search {
@@ -506,6 +509,13 @@ pub enum ChannelsCmd {
         #[arg(long)]
         pubkey: String,
     },
+    /// Set your channel addition policy
+    #[command(name = "set-add-policy")]
+    SetAddPolicy {
+        /// Policy: anyone | owner_only | nobody
+        #[arg(long)]
+        policy: String,
+    },
 }
 
 // ---------------------------------------------------------------------------
@@ -618,6 +628,12 @@ pub enum DmsCmd {
         #[arg(long)]
         pubkey: String,
     },
+    /// Hide a DM conversation from your DM list
+    Hide {
+        /// DM conversation UUID
+        #[arg(long)]
+        channel: String,
+    },
 }
 
 // ---------------------------------------------------------------------------
@@ -712,11 +728,16 @@ pub enum WorkflowsCmd {
         workflow: String,
     },
     /// Trigger a workflow run
-    #[command(after_help = "Examples:\n  sprout workflows trigger --workflow <UUID>")]
+    #[command(
+        after_help = "Examples:\n  sprout workflows trigger --workflow <UUID>\n  sprout workflows trigger --workflow <UUID> --inputs '{\"key\":\"value\"}'"
+    )]
     Trigger {
         /// Workflow UUID
         #[arg(long)]
         workflow: String,
+        /// JSON object of input variables passed to the workflow as event content
+        #[arg(long)]
+        inputs: Option<String>,
     },
     /// List runs for a workflow
     Runs {
@@ -758,6 +779,9 @@ pub enum FeedCmd {
         /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
+        /// Comma-separated feed types to include: mentions, needs_action, activity, agent_activity
+        #[arg(long)]
+        types: Option<String>,
     },
 }
 
@@ -803,6 +827,9 @@ pub enum SocialCmd {
         /// Unix timestamp cursor — return notes created before this time.
         #[arg(long)]
         before: Option<i64>,
+        /// Event ID cursor — return notes created before this event (composite pagination with --before).
+        #[arg(long)]
+        before_id: Option<String>,
     },
     /// Get a user's contact list
     #[command(name = "contacts")]
@@ -1251,6 +1278,7 @@ mod tests {
                 "purpose",
                 "remove-member",
                 "search",
+                "set-add-policy",
                 "topic",
                 "unarchive",
                 "update"
@@ -1259,7 +1287,10 @@ mod tests {
         assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
         assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
         assert_eq!(names(&cmd, "emoji"), vec!["list", "rm", "set"]);
-        assert_eq!(names(&cmd, "dms"), vec!["add-member", "list", "open"]);
+        assert_eq!(
+            names(&cmd, "dms"),
+            vec!["add-member", "hide", "list", "open"]
+        );
         assert_eq!(
             names(&cmd, "users"),
             vec!["get", "presence", "set-presence", "set-profile"]
@@ -1290,8 +1321,8 @@ mod tests {
     fn subcommand_counts_are_stable() {
         let expected: Vec<(&str, usize)> = vec![
             ("canvas", 2),
-            ("channels", 15),
-            ("dms", 3),
+            ("channels", 16),
+            ("dms", 4),
             ("emoji", 3),
             ("feed", 1),
             ("messages", 8),
diff --git a/crates/sprout-relay-client/Cargo.toml b/crates/sprout-relay-client/Cargo.toml
deleted file mode 100644
index cb086f48bb..0000000000
--- a/crates/sprout-relay-client/Cargo.toml
+++ /dev/null
@@ -1,25 +0,0 @@
-[package]
-name = "sprout-relay-client"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-repository.workspace = true
-description = "WebSocket relay client for Sprout (NIP-42 auth, subscriptions, reconnect)"
-
-[dependencies]
-nostr = { workspace = true }
-tokio = { workspace = true }
-tokio-tungstenite = { workspace = true }
-futures-util = { workspace = true }
-serde = { workspace = true }
-serde_json = { workspace = true }
-reqwest = { workspace = true }
-uuid = { workspace = true }
-tracing = { workspace = true }
-thiserror = { workspace = true }
-url = { workspace = true }
-
-[dev-dependencies]
-tokio = { workspace = true, features = ["test-util"] }
-tokio-tungstenite = { workspace = true }
diff --git a/crates/sprout-relay-client/src/lib.rs b/crates/sprout-relay-client/src/lib.rs
deleted file mode 100644
index 6d66b16b20..0000000000
--- a/crates/sprout-relay-client/src/lib.rs
+++ /dev/null
@@ -1,1967 +0,0 @@
-#![deny(unsafe_code)]
-#![warn(missing_docs)]
-
-//! WebSocket client for the Sprout relay with NIP-42 authentication,
-//! subscription management, and automatic reconnection.
-
-use std::collections::HashMap;
-use std::time::Duration;
-
-use futures_util::{SinkExt, StreamExt};
-use nostr::{Event, EventBuilder, Filter, Keys, Kind, RelayUrl, Tag};
-use serde_json::{json, Value};
-use thiserror::Error;
-use tokio::sync::{mpsc, oneshot};
-use tokio::task::JoinHandle;
-use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
-use tracing::{debug, warn};
-
-// ── Timeouts ──────────────────────────────────────────────────────────────────
-
-/// How long to wait for an OK acknowledgement after sending an event.
-const SEND_EVENT_TIMEOUT: Duration = Duration::from_secs(10);
-/// How long to wait for EOSE after sending a REQ.
-const SUBSCRIBE_TIMEOUT: Duration = Duration::from_secs(10);
-/// Timeout for the TCP + WebSocket handshake in `do_connect`.
-const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
-/// Capacity of the command channel.
-const CMD_CHANNEL_CAPACITY: usize = 64;
-
-// ── Public error type ─────────────────────────────────────────────────────────
-
-/// Errors that can occur when communicating with a Sprout relay.
-#[derive(Debug, Error)]
-pub enum RelayClientError {
-    /// A WebSocket transport error occurred.
-    #[error("WebSocket error: {0}")]
-    WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
-
-    /// Failed to serialize or deserialize JSON.
-    #[error("JSON error: {0}")]
-    Json(#[from] serde_json::Error),
-
-    /// Failed to build a Nostr event.
-    #[error("Nostr event builder error: {0}")]
-    EventBuilder(String),
-
-    /// Failed to parse a URL.
-    #[error("URL parse error: {0}")]
-    Url(String),
-
-    /// A relay response was not received within the allowed time.
-    #[error("Timeout waiting for relay message")]
-    Timeout,
-
-    /// The WebSocket connection was closed before the operation completed.
-    #[error("Connection closed unexpectedly")]
-    ConnectionClosed,
-
-    /// The relay sent a message that was not expected in the current context.
-    #[error("Unexpected relay message: {0}")]
-    UnexpectedMessage(String),
-
-    /// The relay rejected the NIP-42 authentication attempt.
-    #[error("Authentication failed: {0}")]
-    AuthFailed(String),
-
-    /// No `AUTH` challenge was received from the relay within the timeout.
-    #[error("No AUTH challenge received from relay")]
-    NoAuthChallenge,
-}
-
-impl From<nostr::event::builder::Error> for RelayClientError {
-    fn from(e: nostr::event::builder::Error) -> Self {
-        RelayClientError::EventBuilder(e.to_string())
-    }
-}
-
-// ── Public relay message type ─────────────────────────────────────────────────
-
-/// A message received from a Nostr relay.
-#[derive(Debug, Clone)]
-pub enum RelayMessage {
-    /// An event matching an active subscription.
-    Event {
-        /// The subscription ID this event belongs to.
-        subscription_id: String,
-        /// The Nostr event payload.
-        event: Box<Event>,
-    },
-    /// Acknowledgement of a published event.
-    Ok(OkResponse),
-    /// End-of-stored-events marker for a subscription.
-    Eose {
-        /// The subscription ID that has reached end-of-stored-events.
-        subscription_id: String,
-    },
-    /// The relay closed a subscription, usually with an error.
-    Closed {
-        /// The subscription ID that was closed.
-        subscription_id: String,
-        /// Human-readable reason for the closure.
-        message: String,
-    },
-    /// A human-readable notice from the relay.
-    Notice {
-        /// The notice text.
-        message: String,
-    },
-    /// A NIP-42 authentication challenge from the relay.
-    Auth {
-        /// The challenge string to sign.
-        challenge: String,
-    },
-}
-
-/// The relay's response to a published event (NIP-01 `OK` message).
-#[derive(Debug, Clone)]
-pub struct OkResponse {
-    /// Hex-encoded ID of the event that was acknowledged.
-    pub event_id: String,
-    /// Whether the relay accepted the event.
-    pub accepted: bool,
-    /// Human-readable reason string (empty when accepted without comment).
-    pub message: String,
-}
-
-// ── Internal types ────────────────────────────────────────────────────────────
-
-type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
-
-/// Commands sent from `RelayClient` to the background WebSocket task.
-#[allow(clippy::large_enum_variant)]
-enum RelayCommand {
-    SendEvent {
-        event: Event,
-        reply: oneshot::Sender<Result<OkResponse, RelayClientError>>,
-    },
-    Subscribe {
-        sub_id: String,
-        filters: Vec<Filter>,
-        reply: oneshot::Sender<Result<Vec<Event>, RelayClientError>>,
-    },
-    CloseSubscription {
-        sub_id: String,
-        reply: oneshot::Sender<Result<(), RelayClientError>>,
-    },
-    Shutdown,
-}
-
-/// A subscription waiting for EOSE.
-struct PendingSubscription {
-    events: Vec<Event>,
-    reply: oneshot::Sender<Result<Vec<Event>, RelayClientError>>,
-    deadline: tokio::time::Instant,
-}
-
-/// State owned exclusively by the background task.
-struct BgState {
-    /// Active subscriptions: sub_id → filters (for reconnect replay).
-    active_subscriptions: HashMap<String, Vec<Filter>>,
-    /// Pending OK waiters: event_id → (reply, deadline).
-    pending_ok: HashMap<
-        String,
-        (
-            oneshot::Sender<Result<OkResponse, RelayClientError>>,
-            tokio::time::Instant,
-        ),
-    >,
-    /// Pending EOSE collectors: sub_id → collector.
-    pending_eose: HashMap<String, PendingSubscription>,
-}
-
-impl BgState {
-    fn new() -> Self {
-        Self {
-            active_subscriptions: HashMap::new(),
-            pending_ok: HashMap::new(),
-            pending_eose: HashMap::new(),
-        }
-    }
-
-    /// Resolve all pending operations with `ConnectionClosed` (called on reconnect).
-    fn cancel_pending(&mut self) {
-        for (_, (reply, _)) in self.pending_ok.drain() {
-            let _ = reply.send(Err(RelayClientError::ConnectionClosed));
-        }
-        for (_, sub) in self.pending_eose.drain() {
-            let _ = sub.reply.send(Err(RelayClientError::ConnectionClosed));
-        }
-    }
-
-    /// Expire any pending operations whose deadline has passed.
-    fn expire_timed_out(&mut self) {
-        let now = tokio::time::Instant::now();
-
-        let expired_ok: Vec<String> = self
-            .pending_ok
-            .iter()
-            .filter(|(_, (_, dl))| now >= *dl)
-            .map(|(k, _)| k.clone())
-            .collect();
-        for k in expired_ok {
-            if let Some((reply, _)) = self.pending_ok.remove(&k) {
-                let _ = reply.send(Err(RelayClientError::Timeout));
-            }
-        }
-
-        let expired_eose: Vec<String> = self
-            .pending_eose
-            .iter()
-            .filter(|(_, sub)| now >= sub.deadline)
-            .map(|(k, _)| k.clone())
-            .collect();
-        for k in expired_eose {
-            if let Some(sub) = self.pending_eose.remove(&k) {
-                let _ = sub.reply.send(Err(RelayClientError::Timeout));
-            }
-        }
-    }
-}
-
-// ── Background task ───────────────────────────────────────────────────────────
-
-/// Perform a single NIP-42 connection + auth handshake.
-/// Returns the authenticated WebSocket stream on success.
-async fn do_connect(
-    relay_url: &str,
-    keys: &Keys,
-    api_token: Option<&str>,
-    auth_tag: Option<&Tag>,
-) -> Result<WsStream, RelayClientError> {
-    let parsed = relay_url
-        .parse::<url::Url>()
-        .map_err(|e| RelayClientError::Url(e.to_string()))?;
-
-    let (mut ws, _) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
-        .await
-        .map_err(|_| RelayClientError::ConnectionClosed)? // timeout → treat as connection failure
-        .map_err(RelayClientError::WebSocket)?;
-
-    debug!("connected to relay at {relay_url}");
-
-    // Wait for AUTH challenge (5s timeout).
-    let challenge = wait_for_auth_challenge(&mut ws, Duration::from_secs(5)).await?;
-
-    let auth_event = build_auth_event(&challenge, relay_url, keys, api_token, auth_tag)?;
-    let event_id = auth_event.id.to_hex();
-    debug!("sending AUTH event {event_id}");
-    let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
-    ws.send(Message::Text(auth_msg.into())).await?;
-
-    let ok = wait_for_ok(&mut ws, &event_id, Duration::from_secs(5)).await?;
-    if !ok.accepted {
-        return Err(RelayClientError::AuthFailed(ok.message));
-    }
-
-    debug!("NIP-42 authentication successful");
-    Ok(ws)
-}
-
-/// Wait for an AUTH challenge frame, responding to Pings along the way.
-async fn wait_for_auth_challenge(
-    ws: &mut WsStream,
-    timeout_dur: Duration,
-) -> Result<String, RelayClientError> {
-    let deadline = tokio::time::Instant::now() + timeout_dur;
-    loop {
-        let remaining = deadline
-            .checked_duration_since(tokio::time::Instant::now())
-            .unwrap_or(Duration::ZERO);
-        if remaining.is_zero() {
-            return Err(RelayClientError::NoAuthChallenge);
-        }
-        let raw = tokio::time::timeout(remaining, ws.next())
-            .await
-            .map_err(|_| RelayClientError::NoAuthChallenge)?
-            .ok_or(RelayClientError::ConnectionClosed)?
-            .map_err(RelayClientError::WebSocket)?;
-        match raw {
-            Message::Text(text) => {
-                if let RelayMessage::Auth { challenge } = parse_relay_message(&text)? {
-                    return Ok(challenge);
-                }
-            }
-            Message::Ping(data) => {
-                ws.send(Message::Pong(data)).await?;
-            }
-            Message::Close(_) => return Err(RelayClientError::ConnectionClosed),
-            _ => {}
-        }
-    }
-}
-
-/// Wait for an OK frame matching `event_id`, responding to Pings along the way.
-async fn wait_for_ok(
-    ws: &mut WsStream,
-    event_id: &str,
-    timeout_dur: Duration,
-) -> Result<OkResponse, RelayClientError> {
-    let deadline = tokio::time::Instant::now() + timeout_dur;
-    loop {
-        let remaining = deadline
-            .checked_duration_since(tokio::time::Instant::now())
-            .unwrap_or(Duration::ZERO);
-        if remaining.is_zero() {
-            return Err(RelayClientError::Timeout);
-        }
-        let raw = tokio::time::timeout(remaining, ws.next())
-            .await
-            .map_err(|_| RelayClientError::Timeout)?
-            .ok_or(RelayClientError::ConnectionClosed)?
-            .map_err(RelayClientError::WebSocket)?;
-        match raw {
-            Message::Text(text) => match parse_relay_message(&text)? {
-                RelayMessage::Ok(ok) if ok.event_id == event_id => return Ok(ok),
-                _ => {} // discard other messages during handshake
-            },
-            Message::Ping(data) => {
-                ws.send(Message::Pong(data)).await?;
-            }
-            Message::Close(_) => return Err(RelayClientError::ConnectionClosed),
-            _ => {}
-        }
-    }
-}
-
-/// Build a NIP-42 AUTH event for the given challenge.
-///
-/// If `auth_tag` is provided (NIP-OA owner attestation), it is included in the
-/// AUTH event so the relay can use it for membership delegation fallback.
-#[allow(clippy::result_large_err)]
-fn build_auth_event(
-    challenge: &str,
-    relay_url: &str,
-    keys: &Keys,
-    api_token: Option<&str>,
-    auth_tag: Option<&Tag>,
-) -> Result<Event, RelayClientError> {
-    let relay_nostr_url =
-        RelayUrl::parse(relay_url).map_err(|e| RelayClientError::Url(e.to_string()))?;
-    if let Some(token) = api_token {
-        let mut tags = vec![
-            Tag::parse(["relay", relay_url])
-                .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
-            Tag::parse(["challenge", challenge])
-                .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
-            Tag::parse(["auth_token", token])
-                .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
-        ];
-        if let Some(t) = auth_tag {
-            tags.push(t.clone());
-        }
-        Ok(EventBuilder::new(Kind::Authentication, "")
-            .tags(tags)
-            .sign_with_keys(keys)?)
-    } else if let Some(t) = auth_tag {
-        // Cannot use EventBuilder::auth() shortcut — it doesn't accept extra tags.
-        let tags = vec![
-            Tag::parse(["relay", relay_url])
-                .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
-            Tag::parse(["challenge", challenge])
-                .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
-            t.clone(),
-        ];
-        Ok(EventBuilder::new(Kind::Authentication, "")
-            .tags(tags)
-            .sign_with_keys(keys)?)
-    } else {
-        Ok(EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?)
-    }
-}
-
-/// Send a NIP-42 AUTH response for a mid-session challenge.
-///
-/// Fire-and-forget: we don't wait for the relay's OK. If the relay rejects
-/// the re-auth it will close the connection, which triggers our reconnect logic.
-async fn send_auth_response(
-    ws: &mut WsStream,
-    challenge: &str,
-    relay_url: &str,
-    keys: &Keys,
-    api_token: Option<&str>,
-    auth_tag: Option<&Tag>,
-) {
-    let result: Result<(), RelayClientError> = async {
-        let auth_event = build_auth_event(challenge, relay_url, keys, api_token, auth_tag)?;
-        let msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
-        ws.send(Message::Text(msg.into())).await?;
-        debug!("sent AUTH response for mid-session challenge");
-        Ok(())
-    }
-    .await;
-    if let Err(e) = result {
-        warn!("failed to respond to mid-session AUTH challenge: {e}");
-    }
-}
-
-/// Handle a single WebSocket message in the background task.
-///
-/// Returns `false` if the connection has been lost (Close frame or error).
-async fn handle_ws_message(
-    msg: Message,
-    ws: &mut WsStream,
-    state: &mut BgState,
-    keys: &Keys,
-    relay_url: &str,
-    api_token: Option<&str>,
-    auth_tag: Option<&Tag>,
-) -> bool {
-    match msg {
-        Message::Text(text) => {
-            let relay_msg = match parse_relay_message(&text) {
-                Ok(m) => m,
-                Err(e) => {
-                    warn!("failed to parse relay message: {e}");
-                    return true;
-                }
-            };
-            match relay_msg {
-                RelayMessage::Event {
-                    subscription_id,
-                    event,
-                } => {
-                    if let Some(sub) = state.pending_eose.get_mut(&subscription_id) {
-                        sub.events.push(*event);
-                    } else {
-                        debug!("EVENT for unknown/completed subscription {subscription_id}");
-                    }
-                }
-                RelayMessage::Ok(ok) => {
-                    if let Some((reply, _)) = state.pending_ok.remove(&ok.event_id) {
-                        let _ = reply.send(Ok(ok));
-                    } else {
-                        debug!("OK for unknown event {}", ok.event_id);
-                    }
-                }
-                RelayMessage::Eose { subscription_id } => {
-                    if let Some(sub) = state.pending_eose.remove(&subscription_id) {
-                        let _ = sub.reply.send(Ok(sub.events));
-                        // One-shot subscription fulfilled — don't replay on reconnect.
-                        state.active_subscriptions.remove(&subscription_id);
-                    } else {
-                        debug!("EOSE for unknown subscription {subscription_id}");
-                    }
-                }
-                RelayMessage::Closed {
-                    subscription_id,
-                    message,
-                } => {
-                    warn!("subscription {subscription_id} closed by relay: {message}");
-                    state.active_subscriptions.remove(&subscription_id);
-                    if let Some(sub) = state.pending_eose.remove(&subscription_id) {
-                        let _ = sub.reply.send(Err(RelayClientError::ConnectionClosed));
-                    }
-                }
-                RelayMessage::Notice { message } => {
-                    debug!("relay NOTICE: {message}");
-                }
-                RelayMessage::Auth { challenge } => {
-                    debug!("received mid-session AUTH challenge — re-authenticating");
-                    send_auth_response(ws, &challenge, relay_url, keys, api_token, auth_tag).await;
-                }
-            }
-            true
-        }
-        Message::Ping(data) => {
-            if let Err(e) = ws.send(Message::Pong(data)).await {
-                warn!("failed to send Pong: {e}");
-                return false;
-            }
-            true
-        }
-        Message::Close(_) => {
-            debug!("relay sent Close frame");
-            false
-        }
-        _ => true,
-    }
-}
-
-/// Reconnect with backoff, cancel pending ops, then replay subscriptions.
-///
-/// Returns `true` on successful reconnect, `false` if the task should exit
-/// (Shutdown received or command channel closed during backoff).
-///
-/// Processes commands during backoff sleeps so that Shutdown is honoured
-/// promptly and new operations fail fast with `ConnectionClosed`.
-async fn do_reconnect(
-    ws: &mut WsStream,
-    state: &mut BgState,
-    cmd_rx: &mut mpsc::Receiver<RelayCommand>,
-    keys: &Keys,
-    relay_url: &str,
-    api_token: Option<&str>,
-    auth_tag: Option<&Tag>,
-) -> bool {
-    warn!("relay connection lost — reconnecting…");
-    state.cancel_pending();
-
-    let mut delay = Duration::from_secs(1);
-    loop {
-        match do_connect(relay_url, keys, api_token, auth_tag).await {
-            Ok(new_ws) => {
-                tracing::info!("reconnected to relay at {relay_url}");
-                *ws = new_ws;
-
-                // Replay active subscriptions.
-                let subs: Vec<(String, Vec<Filter>)> = state
-                    .active_subscriptions
-                    .iter()
-                    .map(|(k, v)| (k.clone(), v.clone()))
-                    .collect();
-                for (sub_id, filters) in subs {
-                    let mut msg: Vec<Value> = Vec::with_capacity(2 + filters.len());
-                    msg.push(json!("REQ"));
-                    msg.push(json!(sub_id));
-                    for f in &filters {
-                        match serde_json::to_value(f) {
-                            Ok(v) => msg.push(v),
-                            Err(e) => warn!("failed to serialize filter for {sub_id}: {e}"),
-                        }
-                    }
-                    let text = match serde_json::to_string(&Value::Array(msg)) {
-                        Ok(t) => t,
-                        Err(e) => {
-                            warn!("failed to serialize REQ for {sub_id}: {e}");
-                            continue;
-                        }
-                    };
-                    if let Err(e) = ws.send(Message::Text(text.into())).await {
-                        warn!("failed to resubscribe to {sub_id}: {e}");
-                    }
-                }
-                return true;
-            }
-            Err(e) => {
-                warn!("reconnect failed: {e}, retrying in {delay:?}");
-                // Wait for backoff delay while still processing commands.
-                tokio::select! {
-                    _ = tokio::time::sleep(delay) => {}
-                    cmd = cmd_rx.recv() => {
-                        match cmd {
-                            Some(RelayCommand::Shutdown) | None => {
-                                debug!("shutdown during reconnect");
-                                state.cancel_pending();
-                                return false;
-                            }
-                            // Fail new operations immediately — we're disconnected.
-                            Some(RelayCommand::SendEvent { reply, .. }) => {
-                                let _ = reply.send(Err(RelayClientError::ConnectionClosed));
-                            }
-                            Some(RelayCommand::Subscribe { reply, .. }) => {
-                                let _ = reply.send(Err(RelayClientError::ConnectionClosed));
-                            }
-                            Some(RelayCommand::CloseSubscription { reply, .. }) => {
-                                let _ = reply.send(Err(RelayClientError::ConnectionClosed));
-                            }
-                        }
-                    }
-                }
-                delay = (delay * 2).min(Duration::from_secs(30));
-            }
-        }
-    }
-}
-
-/// The main background task loop.
-///
-/// Owns the WebSocket, responds to Pings, routes relay messages to pending
-/// waiters, and handles reconnection transparently.
-async fn run_background_task(
-    mut ws: WsStream,
-    mut cmd_rx: mpsc::Receiver<RelayCommand>,
-    keys: Keys,
-    relay_url: String,
-    api_token: Option<String>,
-    auth_tag: Option<Tag>,
-) {
-    let mut state = BgState::new();
-    // Ticker for expiring timed-out pending operations (~1s granularity).
-    let mut tick = tokio::time::interval(Duration::from_secs(1));
-    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
-
-    loop {
-        tokio::select! {
-            // ── Incoming WebSocket message ────────────────────────────────────
-            raw = ws.next() => {
-                let needs_reconnect = match raw {
-                    Some(Ok(msg)) => {
-                        !handle_ws_message(
-                            msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref(),
-                        ).await
-                    }
-                    Some(Err(e)) => { warn!("WebSocket error: {e}"); true }
-                    None => { debug!("WebSocket stream ended"); true }
-                };
-                if needs_reconnect
-                    && !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await
-                {
-                    return; // Shutdown received during reconnect
-                }
-            }
-
-            // ── Command from RelayClient ──────────────────────────────────────
-            cmd = cmd_rx.recv() => {
-                match cmd {
-                    Some(RelayCommand::SendEvent { event, reply }) => {
-                        let event_id = event.id.to_hex();
-                        let msg = match serde_json::to_string(&json!(["EVENT", event])) {
-                            Ok(t) => t,
-                            Err(e) => { let _ = reply.send(Err(e.into())); continue; }
-                        };
-                        if let Err(e) = ws.send(Message::Text(msg.into())).await {
-                            let _ = reply.send(Err(RelayClientError::WebSocket(e)));
-                            if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
-                                return;
-                            }
-                            continue;
-                        }
-                        let deadline = tokio::time::Instant::now() + SEND_EVENT_TIMEOUT;
-                        state.pending_ok.insert(event_id, (reply, deadline));
-                    }
-
-                    Some(RelayCommand::Subscribe { sub_id, filters, reply }) => {
-                        let mut msg: Vec<Value> = Vec::with_capacity(2 + filters.len());
-                        msg.push(json!("REQ"));
-                        msg.push(json!(sub_id));
-                        let mut ser_err: Option<serde_json::Error> = None;
-                        for f in &filters {
-                            match serde_json::to_value(f) {
-                                Ok(v) => msg.push(v),
-                                Err(e) => { ser_err = Some(e); break; }
-                            }
-                        }
-                        if let Some(e) = ser_err {
-                            let _ = reply.send(Err(e.into()));
-                            continue;
-                        }
-                        let text = match serde_json::to_string(&Value::Array(msg)) {
-                            Ok(t) => t,
-                            Err(e) => { let _ = reply.send(Err(e.into())); continue; }
-                        };
-                        if let Err(e) = ws.send(Message::Text(text.into())).await {
-                            let _ = reply.send(Err(RelayClientError::WebSocket(e)));
-                            if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
-                                return;
-                            }
-                            continue;
-                        }
-                        state.active_subscriptions.insert(sub_id.clone(), filters);
-                        let deadline = tokio::time::Instant::now() + SUBSCRIBE_TIMEOUT;
-                        state.pending_eose.insert(sub_id, PendingSubscription {
-                            events: Vec::new(),
-                            reply,
-                            deadline,
-                        });
-                    }
-
-                    Some(RelayCommand::CloseSubscription { sub_id, reply }) => {
-                        state.active_subscriptions.remove(&sub_id);
-                        if let Some(sub) = state.pending_eose.remove(&sub_id) {
-                            let _ = sub.reply.send(Err(RelayClientError::ConnectionClosed));
-                        }
-                        let msg = match serde_json::to_string(&json!(["CLOSE", sub_id])) {
-                            Ok(t) => t,
-                            Err(e) => { let _ = reply.send(Err(e.into())); continue; }
-                        };
-                        if let Err(e) = ws.send(Message::Text(msg.into())).await {
-                            let _ = reply.send(Err(RelayClientError::WebSocket(e)));
-                            if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
-                                return;
-                            }
-                            continue;
-                        }
-                        let _ = reply.send(Ok(()));
-                    }
-
-                    Some(RelayCommand::Shutdown) | None => {
-                        debug!("background task shutting down");
-                        state.cancel_pending();
-                        return;
-                    }
-                }
-            }
-
-            // ── Timeout ticker ────────────────────────────────────────────────
-            _ = tick.tick() => {
-                state.expire_timed_out();
-            }
-        }
-    }
-}
-
-// ── Public client ─────────────────────────────────────────────────────────────
-
-/// Shared handle to the background task. When the last `Arc` clone drops,
-/// the task is signalled to shut down and then aborted as a safety net.
-struct BgTaskHandle {
-    cmd_tx: mpsc::Sender<RelayCommand>,
-    handle: JoinHandle<()>,
-}
-
-impl Drop for BgTaskHandle {
-    fn drop(&mut self) {
-        let _ = self.cmd_tx.try_send(RelayCommand::Shutdown);
-        self.handle.abort();
-    }
-}
-
-/// Clone-able WebSocket client for the Sprout relay.
-///
-/// Internally, a background tokio task owns the WebSocket connection. All
-/// clones share the same command channel to that task. The background task:
-/// - Responds to Ping frames immediately (prevents relay disconnect)
-/// - Handles mid-session AUTH challenges automatically
-/// - Reconnects with exponential backoff on connection loss
-/// - Processes Shutdown commands even during reconnect backoff
-/// - Replays active subscriptions after reconnect
-///
-/// When the last clone is dropped, the background task is automatically
-/// shut down via [`BgTaskHandle`]'s `Drop` implementation.
-#[derive(Clone)]
-pub struct RelayClient {
-    /// Shared background task handle — Drop sends Shutdown + abort.
-    bg: std::sync::Arc<BgTaskHandle>,
-    keys: Keys,
-    /// WebSocket URL of the relay (e.g. "ws://localhost:3000").
-    relay_url: String,
-    /// Shared reqwest client for HTTP calls (media upload only).
-    http: reqwest::Client,
-    /// Optional NIP-OA auth tag injected into every signed event.
-    auth_tag: Option<nostr::Tag>,
-}
-
-impl RelayClient {
-    /// Connect to the relay and start the background task.
-    ///
-    /// Performs the initial NIP-42 handshake synchronously so startup failures
-    /// are surfaced immediately. After that, reconnection is automatic.
-    ///
-    /// `auth_tag` is an optional NIP-OA tag that will be injected into every
-    /// event signed via [`sign_event`](Self::sign_event).
-    pub async fn connect(
-        relay_url: &str,
-        keys: &Keys,
-        api_token: Option<&str>,
-        auth_tag: Option<nostr::Tag>,
-    ) -> Result<Self, RelayClientError> {
-        let ws = do_connect(relay_url, keys, api_token, auth_tag.as_ref()).await?;
-
-        let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
-
-        let bg_keys = keys.clone();
-        let bg_relay_url = relay_url.to_string();
-        let bg_api_token = api_token.map(|t| t.to_string());
-        let bg_auth_tag = auth_tag.clone();
-
-        let handle = tokio::spawn(async move {
-            run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token, bg_auth_tag).await;
-        });
-
-        Ok(Self {
-            bg: std::sync::Arc::new(BgTaskHandle { cmd_tx, handle }),
-            keys: keys.clone(),
-            relay_url: relay_url.to_string(),
-            http: reqwest::Client::builder()
-                .timeout(std::time::Duration::from_secs(120))
-                .connect_timeout(std::time::Duration::from_secs(5))
-                .build()
-                .map_err(|e| RelayClientError::Url(format!("HTTP client build failed: {e}")))?,
-            auth_tag,
-        })
-    }
-
-    /// Sign an event builder, injecting the NIP-OA auth tag if configured.
-    ///
-    /// This is the canonical signing path in the MCP server. All event creation
-    /// should go through this method to ensure consistent auth tag injection.
-    ///
-    /// **Callers MUST NOT add `auth` tags to the builder before calling this
-    /// method.** The only `auth` tag that may appear in the signed event is the
-    /// one injected by this method. Any pre-existing `auth` tag — whether
-    /// `self.auth_tag` is configured or not — is rejected immediately.
-    pub fn sign_event(&self, builder: EventBuilder) -> Result<Event, RelayClientError> {
-        let builder = if let Some(ref tag) = self.auth_tag {
-            builder.tags([tag.clone()])
-        } else {
-            builder
-        };
-        let event = builder
-            .sign_with_keys(&self.keys)
-            .map_err(RelayClientError::from)?;
-
-        // Enforce: auth tags may only come from self.auth_tag injection.
-        // - If auth_tag is Some: exactly 1 auth tag must exist (the one we injected)
-        // - If auth_tag is None: zero auth tags must exist (no caller bypass)
-        let auth_count = event
-            .tags
-            .iter()
-            .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
-            .count();
-        let expected = if self.auth_tag.is_some() { 1 } else { 0 };
-        if auth_count != expected {
-            return Err(RelayClientError::EventBuilder(format!(
-                "event has {auth_count} auth tags — expected {expected}; callers must not add auth tags manually"
-            )));
-        }
-
-        Ok(event)
-    }
-
-    /// Returns the WebSocket URL the client connected to.
-    pub fn relay_url(&self) -> &str {
-        &self.relay_url
-    }
-
-    /// Returns the HTTP base URL for the relay's REST API.
-    /// Converts ws:// → http:// and wss:// → https://, strips trailing slash.
-    pub fn relay_http_url(&self) -> String {
-        relay_ws_to_http(&self.relay_url)
-    }
-
-    /// Returns the hex-encoded public key for this client's keypair.
-    pub fn pubkey_hex(&self) -> String {
-        self.keys.public_key().to_hex()
-    }
-
-    /// Returns a reference to the shared reqwest HTTP client.
-    pub fn http_client(&self) -> &reqwest::Client {
-        &self.http
-    }
-
-    /// Returns a reference to the Nostr signing keys.
-    pub fn keys(&self) -> &nostr::Keys {
-        &self.keys
-    }
-
-    /// Returns the NIP-OA auth tag JSON string for use in HTTP `x-auth-tag` headers.
-    ///
-    /// Returns `None` if no auth tag is configured (direct-member agents).
-    pub fn auth_tag_json(&self) -> Option<String> {
-        self.auth_tag
-            .as_ref()
-            .and_then(|t| serde_json::to_string(t.as_slice()).ok())
-    }
-
-    /// Returns the relay's server authority (host or host:port) for BUD-11 server tags.
-    ///
-    /// Uses the same logic as the desktop client's `extract_server_authority`:
-    /// default ports (80/443) are omitted, non-default ports are included.
-    /// Returns `None` for localhost (no server tag in dev mode).
-    pub fn server_domain(&self) -> Option<String> {
-        // Convert ws:// → http://, wss:// → https:// for url::Url parsing.
-        let http_url = self
-            .relay_url
-            .replace("wss://", "https://")
-            .replace("ws://", "http://");
-        let parsed = url::Url::parse(&http_url).ok()?;
-        let host = parsed.host_str()?;
-        if host.is_empty() || host == "localhost" {
-            return None;
-        }
-        match parsed.port() {
-            Some(port) => Some(format!("{host}:{port}")),
-            None => Some(host.to_string()),
-        }
-    }
-
-    /// One-shot query: send REQ with auto-generated sub_id, collect events until EOSE.
-    ///
-    /// This is the primary read path for the MCP server. Equivalent to calling
-    /// `subscribe()` with a random sub_id.
-    pub async fn query(&self, filters: Vec<Filter>) -> Result<Vec<Event>, RelayClientError> {
-        let sub_id = format!("q-{}", uuid::Uuid::new_v4().simple());
-        self.subscribe(&sub_id, filters).await
-    }
-
-    /// Publish a signed Nostr event to the relay and wait for the `OK` acknowledgement.
-    ///
-    /// Defense-in-depth: validates that the event carries the expected number of
-    /// `auth` tags before publishing. This catches any code path that bypasses
-    /// [`sign_event`](Self::sign_event).
-    pub async fn send_event(&self, event: Event) -> Result<OkResponse, RelayClientError> {
-        // Verify the event was authored by this client's keypair.
-        if event.pubkey != self.keys.public_key() {
-            return Err(RelayClientError::EventBuilder(format!(
-                "send_event rejected: event pubkey {} does not match client pubkey {}",
-                event.pubkey.to_hex(),
-                self.keys.public_key().to_hex()
-            )));
-        }
-
-        // Defense-in-depth: validate auth tags match configuration exactly.
-        let auth_tags: Vec<&nostr::Tag> = event
-            .tags
-            .iter()
-            .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
-            .collect();
-
-        match (&self.auth_tag, auth_tags.as_slice()) {
-            // Configured: exactly 1 auth tag that matches our configured tag byte-for-byte
-            (Some(expected), [actual]) => {
-                if actual.as_slice() != expected.as_slice() {
-                    return Err(RelayClientError::EventBuilder(
-                        "send_event rejected: auth tag does not match configured attestation"
-                            .into(),
-                    ));
-                }
-            }
-            // Configured but wrong count
-            (Some(_), tags) => {
-                return Err(RelayClientError::EventBuilder(format!(
-                    "send_event rejected: expected 1 auth tag, found {}",
-                    tags.len()
-                )));
-            }
-            // Unconfigured: no auth tags allowed
-            (None, tags) if !tags.is_empty() => {
-                return Err(RelayClientError::EventBuilder(format!(
-                    "send_event rejected: auth tags not allowed when unconfigured, found {}",
-                    tags.len()
-                )));
-            }
-            // Unconfigured, no auth tags: OK
-            (None, _) => {}
-        }
-
-        let (reply_tx, reply_rx) = oneshot::channel();
-        self.bg
-            .cmd_tx
-            .send(RelayCommand::SendEvent {
-                event,
-                reply: reply_tx,
-            })
-            .await
-            .map_err(|_| RelayClientError::ConnectionClosed)?;
-        reply_rx
-            .await
-            .map_err(|_| RelayClientError::ConnectionClosed)?
-    }
-
-    /// Open a subscription with the given filters and collect all stored events until `EOSE`.
-    pub async fn subscribe(
-        &self,
-        sub_id: &str,
-        filters: Vec<Filter>,
-    ) -> Result<Vec<Event>, RelayClientError> {
-        let (reply_tx, reply_rx) = oneshot::channel();
-        self.bg
-            .cmd_tx
-            .send(RelayCommand::Subscribe {
-                sub_id: sub_id.to_string(),
-                filters,
-                reply: reply_tx,
-            })
-            .await
-            .map_err(|_| RelayClientError::ConnectionClosed)?;
-        reply_rx
-            .await
-            .map_err(|_| RelayClientError::ConnectionClosed)?
-    }
-
-    /// Send a `CLOSE` message to the relay and remove the subscription from the active set.
-    pub async fn close_subscription(&self, sub_id: &str) -> Result<(), RelayClientError> {
-        let (reply_tx, reply_rx) = oneshot::channel();
-        self.bg
-            .cmd_tx
-            .send(RelayCommand::CloseSubscription {
-                sub_id: sub_id.to_string(),
-                reply: reply_tx,
-            })
-            .await
-            .map_err(|_| RelayClientError::ConnectionClosed)?;
-        reply_rx
-            .await
-            .map_err(|_| RelayClientError::ConnectionClosed)?
-    }
-
-    /// Signal the background task to shut down.
-    ///
-    /// The task will also be aborted when the last `RelayClient` clone is
-    /// dropped, so calling this explicitly is optional but allows a prompt stop.
-    pub async fn close(&self) -> Result<(), RelayClientError> {
-        let _ = self.bg.cmd_tx.send(RelayCommand::Shutdown).await;
-        Ok(())
-    }
-}
-
-// ── Free functions ────────────────────────────────────────────────────────────
-
-/// Convert a WebSocket URL to its HTTP equivalent.
-/// Converts `ws://` → `http://` and `wss://` → `https://`, strips trailing slash.
-///
-/// Extracted as a free function so it can be unit-tested without a live connection.
-pub fn relay_ws_to_http(url: &str) -> String {
-    url.replace("wss://", "https://")
-        .replace("ws://", "http://")
-        .trim_end_matches('/')
-        .to_string()
-}
-
-/// Parse a raw relay text frame into a typed [`RelayMessage`].
-#[allow(clippy::result_large_err)]
-pub fn parse_relay_message(text: &str) -> Result<RelayMessage, RelayClientError> {
-    let arr: Vec<Value> = serde_json::from_str(text)?;
-
-    let msg_type = arr
-        .first()
-        .and_then(|v| v.as_str())
-        .ok_or_else(|| RelayClientError::UnexpectedMessage(text.to_string()))?;
-
-    match msg_type {
-        "EVENT" => {
-            let sub_id = arr
-                .get(1)
-                .and_then(|v| v.as_str())
-                .ok_or_else(|| RelayClientError::UnexpectedMessage(text.to_string()))?
-                .to_string();
-            let event: Event = serde_json::from_value(
-                arr.get(2)
-                    .cloned()
-                    .ok_or_else(|| RelayClientError::UnexpectedMessage(text.to_string()))?,
-            )?;
-            Ok(RelayMessage::Event {
-                subscription_id: sub_id,
-                event: Box::new(event),
-            })
-        }
-        "OK" => {
-            let event_id = arr
-                .get(1)
-                .and_then(|v| v.as_str())
-                .ok_or_else(|| RelayClientError::UnexpectedMessage(text.to_string()))?
-                .to_string();
-            let accepted = arr.get(2).and_then(|v| v.as_bool()).unwrap_or(false);
-            let message = arr
-                .get(3)
-                .and_then(|v| v.as_str())
-                .unwrap_or("")
-                .to_string();
-            Ok(RelayMessage::Ok(OkResponse {
-                event_id,
-                accepted,
-                message,
-            }))
-        }
-        "EOSE" => {
-            let sub_id = arr
-                .get(1)
-                .and_then(|v| v.as_str())
-                .ok_or_else(|| RelayClientError::UnexpectedMessage(text.to_string()))?
-                .to_string();
-            Ok(RelayMessage::Eose {
-                subscription_id: sub_id,
-            })
-        }
-        "CLOSED" => {
-            let sub_id = arr
-                .get(1)
-                .and_then(|v| v.as_str())
-                .ok_or_else(|| RelayClientError::UnexpectedMessage(text.to_string()))?
-                .to_string();
-            let message = arr
-                .get(2)
-                .and_then(|v| v.as_str())
-                .unwrap_or("")
-                .to_string();
-            Ok(RelayMessage::Closed {
-                subscription_id: sub_id,
-                message,
-            })
-        }
-        "NOTICE" => {
-            let message = arr
-                .get(1)
-                .and_then(|v| v.as_str())
-                .unwrap_or("")
-                .to_string();
-            Ok(RelayMessage::Notice { message })
-        }
-        "AUTH" => {
-            let challenge = arr
-                .get(1)
-                .and_then(|v| v.as_str())
-                .ok_or_else(|| RelayClientError::UnexpectedMessage(text.to_string()))?
-                .to_string();
-            Ok(RelayMessage::Auth { challenge })
-        }
-        other => Err(RelayClientError::UnexpectedMessage(format!(
-            "unknown message type: {other}"
-        ))),
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    // ── relay_ws_to_http ──────────────────────────────────────────────────────
-
-    #[test]
-    fn relay_ws_to_http_plain() {
-        assert_eq!(
-            relay_ws_to_http("ws://localhost:3000"),
-            "http://localhost:3000"
-        );
-    }
-
-    #[test]
-    fn relay_ws_to_http_secure() {
-        assert_eq!(
-            relay_ws_to_http("wss://relay.example.com"),
-            "https://relay.example.com"
-        );
-    }
-
-    #[test]
-    fn relay_ws_to_http_strips_trailing_slash() {
-        assert_eq!(
-            relay_ws_to_http("ws://localhost:3000/"),
-            "http://localhost:3000"
-        );
-    }
-
-    #[test]
-    fn relay_ws_to_http_with_path() {
-        assert_eq!(
-            relay_ws_to_http("wss://relay.example.com/nostr"),
-            "https://relay.example.com/nostr"
-        );
-    }
-
-    // ── parse_relay_message ───────────────────────────────────────────────────
-
-    #[test]
-    fn parse_ok_accepted() {
-        let text = r#"["OK","abc123",true,""]"#;
-        let msg = parse_relay_message(text).unwrap();
-        match msg {
-            RelayMessage::Ok(ok) => {
-                assert_eq!(ok.event_id, "abc123");
-                assert!(ok.accepted);
-                assert_eq!(ok.message, "");
-            }
-            _ => panic!("expected Ok"),
-        }
-    }
-
-    #[test]
-    fn parse_ok_rejected() {
-        let text = r#"["OK","abc123",false,"blocked: spam"]"#;
-        let msg = parse_relay_message(text).unwrap();
-        match msg {
-            RelayMessage::Ok(ok) => {
-                assert_eq!(ok.event_id, "abc123");
-                assert!(!ok.accepted);
-                assert_eq!(ok.message, "blocked: spam");
-            }
-            _ => panic!("expected Ok"),
-        }
-    }
-
-    #[test]
-    fn parse_eose() {
-        let text = r#"["EOSE","sub-1"]"#;
-        let msg = parse_relay_message(text).unwrap();
-        match msg {
-            RelayMessage::Eose { subscription_id } => {
-                assert_eq!(subscription_id, "sub-1");
-            }
-            _ => panic!("expected Eose"),
-        }
-    }
-
-    #[test]
-    fn parse_notice() {
-        let text = r#"["NOTICE","hello from relay"]"#;
-        let msg = parse_relay_message(text).unwrap();
-        match msg {
-            RelayMessage::Notice { message } => {
-                assert_eq!(message, "hello from relay");
-            }
-            _ => panic!("expected Notice"),
-        }
-    }
-
-    #[test]
-    fn parse_notice_empty() {
-        // NOTICE with no message field — should default to empty string.
-        let text = r#"["NOTICE"]"#;
-        let msg = parse_relay_message(text).unwrap();
-        match msg {
-            RelayMessage::Notice { message } => {
-                assert_eq!(message, "");
-            }
-            _ => panic!("expected Notice"),
-        }
-    }
-
-    #[test]
-    fn parse_auth() {
-        let text = r#"["AUTH","some-challenge-string"]"#;
-        let msg = parse_relay_message(text).unwrap();
-        match msg {
-            RelayMessage::Auth { challenge } => {
-                assert_eq!(challenge, "some-challenge-string");
-            }
-            _ => panic!("expected Auth"),
-        }
-    }
-
-    #[test]
-    fn parse_closed() {
-        let text = r#"["CLOSED","sub-2","error: rate-limited"]"#;
-        let msg = parse_relay_message(text).unwrap();
-        match msg {
-            RelayMessage::Closed {
-                subscription_id,
-                message,
-            } => {
-                assert_eq!(subscription_id, "sub-2");
-                assert_eq!(message, "error: rate-limited");
-            }
-            _ => panic!("expected Closed"),
-        }
-    }
-
-    #[test]
-    fn parse_closed_no_message() {
-        let text = r#"["CLOSED","sub-3"]"#;
-        let msg = parse_relay_message(text).unwrap();
-        match msg {
-            RelayMessage::Closed {
-                subscription_id,
-                message,
-            } => {
-                assert_eq!(subscription_id, "sub-3");
-                assert_eq!(message, "");
-            }
-            _ => panic!("expected Closed"),
-        }
-    }
-
-    #[test]
-    fn parse_unknown_type_returns_error() {
-        let text = r#"["UNKNOWN","data"]"#;
-        let result = parse_relay_message(text);
-        assert!(result.is_err());
-        match result.unwrap_err() {
-            RelayClientError::UnexpectedMessage(msg) => {
-                assert!(msg.contains("unknown message type"));
-            }
-            e => panic!("expected UnexpectedMessage, got {e:?}"),
-        }
-    }
-
-    #[test]
-    fn parse_invalid_json_returns_error() {
-        let text = "not json at all";
-        let result = parse_relay_message(text);
-        assert!(result.is_err());
-        assert!(matches!(result.unwrap_err(), RelayClientError::Json(_)));
-    }
-
-    #[test]
-    fn parse_empty_array_returns_error() {
-        let text = "[]";
-        let result = parse_relay_message(text);
-        assert!(result.is_err());
-        match result.unwrap_err() {
-            RelayClientError::UnexpectedMessage(_) => {}
-            e => panic!("expected UnexpectedMessage, got {e:?}"),
-        }
-    }
-
-    #[test]
-    fn parse_auth_missing_challenge_returns_error() {
-        let text = r#"["AUTH"]"#;
-        let result = parse_relay_message(text);
-        assert!(result.is_err());
-    }
-
-    #[test]
-    fn parse_eose_missing_sub_id_returns_error() {
-        let text = r#"["EOSE"]"#;
-        let result = parse_relay_message(text);
-        assert!(result.is_err());
-    }
-
-    // ── sign_event auth tag injection ────────────────────────────────────────
-
-    /// Build a minimal `RelayClient` without a live relay connection.
-    ///
-    /// Only `keys` and `auth_tag` matter for `sign_event`; the other fields
-    /// are inert stubs (the background task immediately exits, the HTTP
-    /// client is never used).
-    fn make_client(keys: Keys, auth_tag: Option<nostr::Tag>) -> RelayClient {
-        let (cmd_tx, _cmd_rx) = mpsc::channel(1);
-        let handle = tokio::runtime::Handle::current().spawn(async {});
-        RelayClient {
-            bg: std::sync::Arc::new(BgTaskHandle { cmd_tx, handle }),
-            keys,
-            relay_url: "ws://127.0.0.1:1".to_string(),
-            http: reqwest::Client::new(),
-            auth_tag,
-        }
-    }
-
-    #[tokio::test]
-    async fn test_sign_event_injects_auth_tag() {
-        let keys = Keys::generate();
-        // Real NIP-OA tag format: ["auth", "<64-char-hex-pubkey>", "<conditions>", "<128-char-hex-sig>"]
-        let owner_pubkey = "a".repeat(64);
-        let conditions = "";
-        let signature = "b".repeat(128);
-        let auth_tag = nostr::Tag::parse(["auth", &owner_pubkey, conditions, &signature]).unwrap();
-
-        // With auth_tag: the signed event must contain it.
-        let client = make_client(keys.clone(), Some(auth_tag.clone()));
-        let event = client
-            .sign_event(EventBuilder::new(Kind::TextNote, "hello").tags([]))
-            .expect("sign_event should succeed");
-
-        let tag_values: Vec<Vec<String>> = event
-            .tags
-            .iter()
-            .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
-            .collect();
-        assert!(
-            tag_values
-                .iter()
-                .any(|t| t.first().map(|s| s.as_str()) == Some("auth")
-                    && t.get(1).map(|s| s.as_str()) == Some(owner_pubkey.as_str())
-                    && t.get(3).map(|s| s.as_str()) == Some(signature.as_str())),
-            "expected NIP-OA auth tag in event; got: {tag_values:?}"
-        );
-
-        // Without auth_tag: the signed event must NOT contain an auth tag.
-        let client_no_auth = make_client(keys, None);
-        let event_no_auth = client_no_auth
-            .sign_event(EventBuilder::new(Kind::TextNote, "hello").tags([]))
-            .expect("sign_event should succeed");
-
-        let has_auth_tag = event_no_auth
-            .tags
-            .iter()
-            .any(|t| t.as_slice().first().map(|s| s.as_str()).unwrap_or("") == "auth");
-        assert!(!has_auth_tag, "expected no auth tag when auth_tag is None");
-    }
-
-    #[tokio::test]
-    async fn test_sign_event_rejects_duplicate_auth_tag() {
-        let keys = Keys::generate();
-        // Real NIP-OA tag format: ["auth", "<64-char-hex-pubkey>", "<conditions>", "<128-char-hex-sig>"]
-        let owner_pubkey = "c".repeat(64);
-        let conditions = "";
-        let signature = "d".repeat(128);
-        let auth_tag = nostr::Tag::parse(["auth", &owner_pubkey, conditions, &signature]).unwrap();
-
-        // Case 1: client has auth_tag configured, caller also pre-adds one → duplicate → reject.
-        let client = make_client(keys.clone(), Some(auth_tag.clone()));
-        let builder = EventBuilder::new(Kind::TextNote, "oops").tags([auth_tag.clone()]);
-        let result = client.sign_event(builder);
-        assert!(
-            result.is_err(),
-            "sign_event should return an error when the event would have duplicate auth tags"
-        );
-        let err_msg = result.unwrap_err().to_string();
-        assert!(
-            err_msg.contains("auth tags"),
-            "error message should mention auth tags; got: {err_msg}"
-        );
-
-        // Case 2: client has NO auth_tag configured, but caller manually adds one → bypass → reject.
-        let client_no_auth = make_client(keys, None);
-        let builder_with_manual = EventBuilder::new(Kind::TextNote, "bypass").tags([auth_tag]);
-        let result2 = client_no_auth.sign_event(builder_with_manual);
-        assert!(
-            result2.is_err(),
-            "sign_event should reject a manually added auth tag even when auth_tag is None"
-        );
-        let err_msg2 = result2.unwrap_err().to_string();
-        assert!(
-            err_msg2.contains("auth tags"),
-            "error message should mention auth tags; got: {err_msg2}"
-        );
-    }
-
-    // ── send_event auth tag validation ───────────────────────────────────────
-
-    #[tokio::test]
-    async fn test_send_event_rejects_forged_auth_tag() {
-        let keys = Keys::generate();
-        let real_tag = nostr::Tag::parse(["auth", &"a".repeat(64), "", &"b".repeat(128)]).unwrap();
-        let forged_tag =
-            nostr::Tag::parse(["auth", &"c".repeat(64), "", &"d".repeat(128)]).unwrap();
-
-        let client = make_client(keys.clone(), Some(real_tag));
-
-        // Build event with forged auth tag, bypassing sign_event
-        let event = EventBuilder::new(Kind::TextNote, "forged")
-            .tags([forged_tag])
-            .sign_with_keys(&keys)
-            .unwrap();
-
-        let result = client.send_event(event).await;
-        assert!(result.is_err(), "send_event should reject forged auth tag");
-        let err_msg = result.unwrap_err().to_string();
-        assert!(
-            err_msg.contains("does not match"),
-            "error should mention mismatch: {err_msg}"
-        );
-    }
-
-    #[tokio::test]
-    async fn test_send_event_rejects_auth_tag_when_unconfigured() {
-        let keys = Keys::generate();
-        let sneaky_tag =
-            nostr::Tag::parse(["auth", &"a".repeat(64), "", &"b".repeat(128)]).unwrap();
-
-        let client = make_client(keys.clone(), None);
-
-        let event = EventBuilder::new(Kind::TextNote, "sneaky")
-            .tags([sneaky_tag])
-            .sign_with_keys(&keys)
-            .unwrap();
-
-        let result = client.send_event(event).await;
-        assert!(
-            result.is_err(),
-            "send_event should reject auth tags when unconfigured"
-        );
-    }
-
-    #[tokio::test]
-    async fn test_send_event_rejects_wrong_pubkey() {
-        let client_keys = Keys::generate();
-        let other_keys = Keys::generate();
-        let client = make_client(client_keys, None);
-
-        // Event signed by a different keypair
-        let event = EventBuilder::new(Kind::TextNote, "wrong author")
-            .tags([])
-            .sign_with_keys(&other_keys)
-            .unwrap();
-
-        let result = client.send_event(event).await;
-        assert!(
-            result.is_err(),
-            "send_event should reject events from wrong pubkey"
-        );
-    }
-
-    // ── Integration tests: mini relay ─────────────────────────────────────────
-    //
-    // Each test spins up a lightweight in-process WebSocket server that performs
-    // the NIP-42 handshake, then runs a caller-supplied scenario closure.
-    // The closure receives the split sink+stream so it can drive the test.
-
-    #[cfg(test)]
-    mod integration {
-        use super::*;
-        use futures_util::stream::{SplitSink, SplitStream};
-        use futures_util::{SinkExt, StreamExt};
-        use std::future::Future;
-        use tokio::net::TcpListener;
-        use tokio_tungstenite::{accept_async, tungstenite::Message, WebSocketStream};
-
-        type PlainWs = WebSocketStream<tokio::net::TcpStream>;
-
-        /// Spawn a mini relay that performs NIP-42 auth handshake then runs `scenario`.
-        /// Returns the `ws://127.0.0.1:{port}` URL.
-        async fn spawn_mini_relay<F, Fut>(scenario: F) -> String
-        where
-            F: FnOnce(SplitSink<PlainWs, Message>, SplitStream<PlainWs>) -> Fut + Send + 'static,
-            Fut: Future<Output = ()> + Send,
-        {
-            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
-            let port = listener.local_addr().unwrap().port();
-
-            tokio::spawn(async move {
-                let (stream, _) = listener.accept().await.unwrap();
-                let ws = accept_async(stream).await.unwrap();
-                let (mut sink, mut stream) = ws.split();
-
-                // Send AUTH challenge.
-                sink.send(Message::Text(
-                    r#"["AUTH","test-challenge"]"#.to_string().into(),
-                ))
-                .await
-                .unwrap();
-
-                // Wait for AUTH response, send OK for the event.
-                while let Some(Ok(msg)) = stream.next().await {
-                    if let Message::Text(text) = msg {
-                        let arr: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
-                        if arr.first().and_then(|v| v.as_str()) == Some("AUTH") {
-                            let event_id = arr[1]["id"].as_str().unwrap().to_string();
-                            sink.send(Message::Text(
-                                format!(r#"["OK","{}",true,""]"#, event_id).into(),
-                            ))
-                            .await
-                            .unwrap();
-                            break;
-                        }
-                    }
-                }
-
-                scenario(sink, stream).await;
-            });
-
-            format!("ws://127.0.0.1:{}", port)
-        }
-
-        // ── Test 1: background task responds to Ping when idle ────────────────
-
-        #[tokio::test]
-        async fn bg_responds_to_ping_without_caller_activity() {
-            let url = spawn_mini_relay(|mut sink, mut stream| async move {
-                // Send a Ping — background task should Pong immediately.
-                sink.send(Message::Ping(b"abc".to_vec().into()))
-                    .await
-                    .unwrap();
-
-                // Drain until we see the Pong.
-                while let Some(Ok(msg)) = stream.next().await {
-                    if let Message::Pong(data) = msg {
-                        assert_eq!(data.as_ref(), b"abc");
-                        return;
-                    }
-                }
-                panic!("never received Pong");
-            })
-            .await;
-
-            let keys = Keys::generate();
-            let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
-            // Give the background task a moment to process the Ping.
-            tokio::time::sleep(Duration::from_millis(200)).await;
-            let _ = client.close().await;
-        }
-
-        // ── Test 2: background task handles mid-session AUTH challenge ────────
-
-        #[tokio::test]
-        async fn bg_handles_mid_session_auth_challenge() {
-            let url = spawn_mini_relay(|mut sink, mut stream| async move {
-                // Send a fresh AUTH challenge after initial handshake.
-                sink.send(Message::Text(
-                    r#"["AUTH","challenge-2"]"#.to_string().into(),
-                ))
-                .await
-                .unwrap();
-
-                // Expect a new AUTH event with kind 22242.
-                while let Some(Ok(msg)) = stream.next().await {
-                    if let Message::Text(text) = msg {
-                        let arr: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
-                        if arr.first().and_then(|v| v.as_str()) == Some("AUTH") {
-                            let kind = arr[1]["kind"].as_u64().unwrap();
-                            assert_eq!(kind, 22242, "expected kind 22242 (Authentication)");
-                            // Verify the challenge tag is present.
-                            let tags = arr[1]["tags"].as_array().unwrap();
-                            let has_challenge = tags.iter().any(|t| {
-                                t.as_array().and_then(|a| a.get(1)).and_then(|v| v.as_str())
-                                    == Some("challenge-2")
-                            });
-                            assert!(has_challenge, "AUTH event missing challenge tag");
-                            return;
-                        }
-                    }
-                }
-                panic!("never received AUTH response");
-            })
-            .await;
-
-            let keys = Keys::generate();
-            let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
-            tokio::time::sleep(Duration::from_millis(200)).await;
-            let _ = client.close().await;
-        }
-
-        // ── Test 3: send_event receives OK response ───────────────────────────
-
-        #[tokio::test]
-        async fn send_event_receives_ok_response() {
-            let url = spawn_mini_relay(|mut sink, mut stream| async move {
-                // Wait for EVENT, send matching OK.
-                while let Some(Ok(msg)) = stream.next().await {
-                    if let Message::Text(text) = msg {
-                        let arr: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
-                        if arr.first().and_then(|v| v.as_str()) == Some("EVENT") {
-                            let event_id = arr[1]["id"].as_str().unwrap().to_string();
-                            sink.send(Message::Text(
-                                format!(r#"["OK","{}",true,""]"#, event_id).into(),
-                            ))
-                            .await
-                            .unwrap();
-                            return;
-                        }
-                    }
-                }
-            })
-            .await;
-
-            let keys = Keys::generate();
-            let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
-
-            let event = EventBuilder::new(Kind::Custom(9), "test")
-                .tags([])
-                .sign_with_keys(&keys)
-                .unwrap();
-            let expected_id = event.id.to_hex();
-
-            let ok = client.send_event(event).await.unwrap();
-            assert_eq!(ok.event_id, expected_id);
-            assert!(ok.accepted);
-            assert_eq!(ok.message, "");
-
-            let _ = client.close().await;
-        }
-
-        // ── Test 4: subscribe collects events until EOSE ──────────────────────
-
-        #[tokio::test]
-        async fn subscribe_collects_events_until_eose() {
-            let url = spawn_mini_relay(|mut sink, mut stream| async move {
-                // Wait for REQ.
-                while let Some(Ok(msg)) = stream.next().await {
-                    if let Message::Text(text) = msg {
-                        let arr: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
-                        if arr.first().and_then(|v| v.as_str()) == Some("REQ") {
-                            let sub_id = arr[1].as_str().unwrap().to_string();
-
-                            // Build 3 minimal valid events.
-                            let relay_keys = Keys::generate();
-                            for i in 0u8..3 {
-                                let ev = EventBuilder::new(Kind::TextNote, format!("msg {i}"))
-                                    .tags([])
-                                    .sign_with_keys(&relay_keys)
-                                    .unwrap();
-                                let frame = serde_json::to_string(&serde_json::json!([
-                                    "EVENT", sub_id, ev
-                                ]))
-                                .unwrap();
-                                sink.send(Message::Text(frame.into())).await.unwrap();
-                            }
-
-                            // Send EOSE.
-                            sink.send(Message::Text(format!(r#"["EOSE","{}"]"#, sub_id).into()))
-                                .await
-                                .unwrap();
-                            return;
-                        }
-                    }
-                }
-            })
-            .await;
-
-            let keys = Keys::generate();
-            let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
-
-            let events = client
-                .subscribe("sub-1", vec![Filter::new()])
-                .await
-                .unwrap();
-            assert_eq!(events.len(), 3, "expected 3 events before EOSE");
-
-            let _ = client.close().await;
-        }
-
-        // ── Test 5: send_event times out when relay never sends OK ────────────
-        //
-        // We connect first (real time), then pause time and advance past the
-        // 10s SEND_EVENT_TIMEOUT to avoid a real wait.
-
-        #[tokio::test]
-        async fn send_event_times_out_when_no_ok() {
-            let url = spawn_mini_relay(|_sink, mut stream| async move {
-                // Consume the EVENT frame but never respond with OK.
-                // Hold connection open by draining until the client drops.
-                while let Some(Ok(_)) = stream.next().await {}
-            })
-            .await;
-
-            let keys = Keys::generate();
-            let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
-
-            let event = EventBuilder::new(Kind::Custom(9), "timeout-test")
-                .tags([])
-                .sign_with_keys(&keys)
-                .unwrap();
-
-            // Pause time AFTER connecting so the auth handshake completes normally.
-            tokio::time::pause();
-
-            // Start the send — enqueues EVENT to background task.
-            let send_fut = client.send_event(event);
-            tokio::pin!(send_fut);
-
-            // Yield to let the background task send the EVENT frame.
-            tokio::task::yield_now().await;
-            tokio::task::yield_now().await;
-
-            // Advance time past the 10s SEND_EVENT_TIMEOUT + 1s tick granularity.
-            tokio::time::advance(Duration::from_secs(12)).await;
-            // Let the background task's tick fire and expire the pending_ok entry.
-            tokio::task::yield_now().await;
-            tokio::task::yield_now().await;
-
-            let result = send_fut.await;
-            assert!(
-                matches!(result, Err(RelayClientError::Timeout)),
-                "expected Timeout, got: {:?}",
-                result
-            );
-
-            let _ = client.close().await;
-        }
-
-        // ── Test 6: close_subscription sends CLOSE message to relay ──────────
-
-        #[tokio::test]
-        async fn close_subscription_sends_close_message() {
-            let (close_tx, close_rx) = tokio::sync::oneshot::channel::<String>();
-            let close_tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(close_tx)));
-
-            let url = spawn_mini_relay({
-                let close_tx = close_tx.clone();
-                move |mut sink, mut stream| async move {
-                    // Handle REQ, send EOSE immediately so subscribe() returns.
-                    while let Some(Ok(msg)) = stream.next().await {
-                        if let Message::Text(text) = msg {
-                            let arr: Vec<serde_json::Value> =
-                                serde_json::from_str(&text).unwrap_or_default();
-                            match arr.first().and_then(|v| v.as_str()) {
-                                Some("REQ") => {
-                                    let sub_id = arr[1].as_str().unwrap().to_string();
-                                    sink.send(Message::Text(
-                                        format!(r#"["EOSE","{}"]"#, sub_id).into(),
-                                    ))
-                                    .await
-                                    .unwrap();
-                                }
-                                Some("CLOSE") => {
-                                    let sub_id = arr[1].as_str().unwrap().to_string();
-                                    if let Some(tx) = close_tx.lock().await.take() {
-                                        let _ = tx.send(sub_id);
-                                    }
-                                    return;
-                                }
-                                _ => {}
-                            }
-                        }
-                    }
-                }
-            })
-            .await;
-
-            let keys = Keys::generate();
-            let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
-
-            // Subscribe (EOSE comes back immediately).
-            client
-                .subscribe("sub-close", vec![Filter::new()])
-                .await
-                .unwrap();
-
-            // Close the subscription — relay should receive CLOSE.
-            client.close_subscription("sub-close").await.unwrap();
-
-            let closed_id = tokio::time::timeout(Duration::from_secs(2), close_rx)
-                .await
-                .expect("timed out waiting for CLOSE")
-                .expect("channel dropped");
-
-            assert_eq!(closed_id, "sub-close");
-
-            let _ = client.close().await;
-        }
-
-        // ── Test 7: reconnect on transport close ──────────────────────────────
-        //
-        // Strategy: use a shared TcpListener that accepts two connections.
-        // First connection: close immediately after auth.
-        // Second connection: full relay that handles send_event.
-
-        #[tokio::test]
-        async fn bg_reconnects_on_transport_close() {
-            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
-            let port = listener.local_addr().unwrap().port();
-            let url = format!("ws://127.0.0.1:{}", port);
-
-            // Channel so the test can wait until the second relay is ready.
-            let (ok_tx, ok_rx) = tokio::sync::oneshot::channel::<()>();
-            let ok_tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(ok_tx)));
-
-            tokio::spawn({
-                let ok_tx = ok_tx.clone();
-                async move {
-                    // ── Connection 1: close right after auth ──────────────────
-                    {
-                        let (stream, _) = listener.accept().await.unwrap();
-                        let ws = accept_async(stream).await.unwrap();
-                        let (mut sink, mut stream) = ws.split();
-
-                        sink.send(Message::Text(
-                            r#"["AUTH","test-challenge"]"#.to_string().into(),
-                        ))
-                        .await
-                        .unwrap();
-
-                        while let Some(Ok(msg)) = stream.next().await {
-                            if let Message::Text(text) = msg {
-                                let arr: Vec<serde_json::Value> =
-                                    serde_json::from_str(&text).unwrap_or_default();
-                                if arr.first().and_then(|v| v.as_str()) == Some("AUTH") {
-                                    let event_id = arr[1]["id"].as_str().unwrap().to_string();
-                                    sink.send(Message::Text(
-                                        format!(r#"["OK","{}",true,""]"#, event_id).into(),
-                                    ))
-                                    .await
-                                    .unwrap();
-                                    break;
-                                }
-                            }
-                        }
-                        // Drop sink+stream — closes the WS connection.
-                    }
-
-                    // ── Connection 2: full relay that handles EVENT ────────────
-                    {
-                        let (stream, _) = listener.accept().await.unwrap();
-                        let ws = accept_async(stream).await.unwrap();
-                        let (mut sink, mut stream) = ws.split();
-
-                        sink.send(Message::Text(
-                            r#"["AUTH","test-challenge"]"#.to_string().into(),
-                        ))
-                        .await
-                        .unwrap();
-
-                        while let Some(Ok(msg)) = stream.next().await {
-                            if let Message::Text(text) = msg {
-                                let arr: Vec<serde_json::Value> =
-                                    serde_json::from_str(&text).unwrap_or_default();
-                                match arr.first().and_then(|v| v.as_str()) {
-                                    Some("AUTH") => {
-                                        let event_id = arr[1]["id"].as_str().unwrap().to_string();
-                                        sink.send(Message::Text(
-                                            format!(r#"["OK","{}",true,""]"#, event_id).into(),
-                                        ))
-                                        .await
-                                        .unwrap();
-                                        // Signal AFTER auth handshake completes.
-                                        if let Some(tx) = ok_tx.lock().await.take() {
-                                            let _ = tx.send(());
-                                        }
-                                    }
-                                    Some("EVENT") => {
-                                        let event_id = arr[1]["id"].as_str().unwrap().to_string();
-                                        sink.send(Message::Text(
-                                            format!(r#"["OK","{}",true,""]"#, event_id).into(),
-                                        ))
-                                        .await
-                                        .unwrap();
-                                        return;
-                                    }
-                                    _ => {}
-                                }
-                            }
-                        }
-                    }
-                }
-            });
-
-            let keys = Keys::generate();
-            let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
-
-            // Wait for the second relay to complete auth (background task reconnected).
-            tokio::time::timeout(Duration::from_secs(5), ok_rx)
-                .await
-                .expect("timed out waiting for reconnect")
-                .unwrap();
-
-            // send_event should succeed on the new connection.
-            let event = EventBuilder::new(Kind::Custom(9), "after-reconnect")
-                .tags([])
-                .sign_with_keys(&keys)
-                .unwrap();
-            let ok = client.send_event(event).await.unwrap();
-            assert!(ok.accepted);
-
-            let _ = client.close().await;
-        }
-
-        // ── Test 8: shutdown during reconnect ─────────────────────────────
-
-        #[tokio::test]
-        async fn shutdown_during_reconnect_exits_promptly() {
-            // Connect to a relay that closes immediately after auth,
-            // then never accepts again. The background task enters the
-            // reconnect loop. Verify that Shutdown is processed during
-            // reconnect backoff — the task exits gracefully, NOT via abort.
-            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
-            let port = listener.local_addr().unwrap().port();
-            let url = format!("ws://127.0.0.1:{}", port);
-
-            tokio::spawn(async move {
-                // Accept one connection, auth, then close.
-                let (stream, _) = listener.accept().await.unwrap();
-                let ws = accept_async(stream).await.unwrap();
-                let (mut sink, mut stream) = ws.split();
-
-                sink.send(Message::Text(
-                    r#"["AUTH","test-challenge"]"#.to_string().into(),
-                ))
-                .await
-                .unwrap();
-
-                while let Some(Ok(msg)) = stream.next().await {
-                    if let Message::Text(text) = msg {
-                        let arr: Vec<serde_json::Value> =
-                            serde_json::from_str(&text).unwrap_or_default();
-                        if arr.first().and_then(|v| v.as_str()) == Some("AUTH") {
-                            let event_id = arr[1]["id"].as_str().unwrap().to_string();
-                            sink.send(Message::Text(
-                                format!(r#"["OK","{}",true,""]"#, event_id).into(),
-                            ))
-                            .await
-                            .unwrap();
-                            break;
-                        }
-                    }
-                }
-                // Drop — closes connection, triggering reconnect.
-                // Don't accept any more connections — reconnect will fail forever.
-                drop(listener);
-            });
-
-            let keys = Keys::generate();
-            let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
-
-            // Give the background task time to notice the close and enter
-            // the reconnect backoff loop.
-            tokio::time::sleep(Duration::from_millis(200)).await;
-
-            // Send Shutdown via close() — the reconnect loop processes this
-            // during its backoff sleep and exits the task gracefully.
-            let _ = client.close().await;
-
-            // Wait for the task to process Shutdown. Do NOT drop the client
-            // yet — we want to prove the task exits via Shutdown processing,
-            // not via BgTaskHandle::drop calling abort().
-            tokio::time::sleep(Duration::from_millis(500)).await;
-
-            // After graceful shutdown, the background task dropped its cmd_rx.
-            // Sending another command should fail with a closed-channel error,
-            // proving the task exited on its own.
-            let result = client.bg.cmd_tx.send(RelayCommand::Shutdown).await;
-            assert!(
-                result.is_err(),
-                "cmd channel should be closed after graceful shutdown —                  task exited via Shutdown, not abort"
-            );
-        }
-    }
-}
diff --git a/crates/sprout-test-client/Cargo.toml b/crates/sprout-test-client/Cargo.toml
index c1776b7045..a506bcc1c3 100644
--- a/crates/sprout-test-client/Cargo.toml
+++ b/crates/sprout-test-client/Cargo.toml
@@ -10,7 +10,6 @@ description = "Integration test client and E2E test suite for Sprout"
 [dependencies]
 anyhow = { workspace = true }
 sprout-core = { workspace = true }
-sprout-relay-client = { workspace = true }
 nostr = { workspace = true }
 tokio = { workspace = true }
 tokio-tungstenite = { workspace = true }
diff --git a/crates/sprout-test-client/src/lib.rs b/crates/sprout-test-client/src/lib.rs
index 25902142d9..df3cd746ca 100644
--- a/crates/sprout-test-client/src/lib.rs
+++ b/crates/sprout-test-client/src/lib.rs
@@ -14,7 +14,145 @@ use tokio::time::timeout;
 use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
 use tracing::debug;
 
-pub use sprout_relay_client::{parse_relay_message, OkResponse, RelayMessage};
+/// A message received from a Nostr relay.
+#[derive(Debug, Clone)]
+pub enum RelayMessage {
+    /// An event matching an active subscription.
+    Event {
+        /// The subscription ID this event belongs to.
+        subscription_id: String,
+        /// The Nostr event payload.
+        event: Box<Event>,
+    },
+    /// Acknowledgement of a published event.
+    Ok(OkResponse),
+    /// End-of-stored-events marker for a subscription.
+    Eose {
+        /// The subscription ID that has reached end-of-stored-events.
+        subscription_id: String,
+    },
+    /// The relay closed a subscription, usually with an error.
+    Closed {
+        /// The subscription ID that was closed.
+        subscription_id: String,
+        /// Human-readable reason for the closure.
+        message: String,
+    },
+    /// A human-readable notice from the relay.
+    Notice {
+        /// The notice text.
+        message: String,
+    },
+    /// A NIP-42 authentication challenge from the relay.
+    Auth {
+        /// The challenge string to sign.
+        challenge: String,
+    },
+}
+
+/// The relay's response to a published event (NIP-01 `OK` message).
+#[derive(Debug, Clone)]
+pub struct OkResponse {
+    /// Hex-encoded ID of the event that was acknowledged.
+    pub event_id: String,
+    /// Whether the relay accepted the event.
+    pub accepted: bool,
+    /// Human-readable reason string (empty when accepted without comment).
+    pub message: String,
+}
+
+/// Parse a raw relay text frame into a typed [`RelayMessage`].
+#[allow(clippy::result_large_err)]
+pub fn parse_relay_message(text: &str) -> Result<RelayMessage, TestClientError> {
+    let arr: Vec<Value> = serde_json::from_str(text)?;
+
+    let msg_type = arr
+        .first()
+        .and_then(|v| v.as_str())
+        .ok_or_else(|| TestClientError::UnexpectedMessage(text.to_string()))?;
+
+    match msg_type {
+        "EVENT" => {
+            let sub_id = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| TestClientError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            let event: Event = serde_json::from_value(
+                arr.get(2)
+                    .cloned()
+                    .ok_or_else(|| TestClientError::UnexpectedMessage(text.to_string()))?,
+            )?;
+            Ok(RelayMessage::Event {
+                subscription_id: sub_id,
+                event: Box::new(event),
+            })
+        }
+        "OK" => {
+            let event_id = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| TestClientError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            let accepted = arr.get(2).and_then(|v| v.as_bool()).unwrap_or(false);
+            let message = arr
+                .get(3)
+                .and_then(|v| v.as_str())
+                .unwrap_or("")
+                .to_string();
+            Ok(RelayMessage::Ok(OkResponse {
+                event_id,
+                accepted,
+                message,
+            }))
+        }
+        "EOSE" => {
+            let sub_id = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| TestClientError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            Ok(RelayMessage::Eose {
+                subscription_id: sub_id,
+            })
+        }
+        "CLOSED" => {
+            let sub_id = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| TestClientError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            let message = arr
+                .get(2)
+                .and_then(|v| v.as_str())
+                .unwrap_or("")
+                .to_string();
+            Ok(RelayMessage::Closed {
+                subscription_id: sub_id,
+                message,
+            })
+        }
+        "NOTICE" => {
+            let message = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .unwrap_or("")
+                .to_string();
+            Ok(RelayMessage::Notice { message })
+        }
+        "AUTH" => {
+            let challenge = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| TestClientError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            Ok(RelayMessage::Auth { challenge })
+        }
+        other => Err(TestClientError::UnexpectedMessage(format!(
+            "unknown message type: {other}"
+        ))),
+    }
+}
 
 /// Errors returned by [`SproutTestClient`] operations.
 #[derive(Debug, Error)]
@@ -66,23 +204,6 @@ impl From<nostr::event::builder::Error> for TestClientError {
     }
 }
 
-// Map RelayClientError → TestClientError for parse_relay_message calls.
-impl From<sprout_relay_client::RelayClientError> for TestClientError {
-    fn from(e: sprout_relay_client::RelayClientError) -> Self {
-        use sprout_relay_client::RelayClientError as E;
-        match e {
-            E::WebSocket(e) => TestClientError::WebSocket(e),
-            E::Json(e) => TestClientError::Json(e),
-            E::Timeout => TestClientError::Timeout,
-            E::ConnectionClosed => TestClientError::ConnectionClosed,
-            E::UnexpectedMessage(m) => TestClientError::UnexpectedMessage(m),
-            E::AuthFailed(m) => TestClientError::AuthFailed(m),
-            E::NoAuthChallenge => TestClientError::NoAuthChallenge,
-            other => TestClientError::UnexpectedMessage(other.to_string()),
-        }
-    }
-}
-
 type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
 
 /// WebSocket test client for integration testing against a running Sprout relay.
diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md
index fb856f0449..bd6f7e0df3 100644
--- a/desktop/src-tauri/src/managed_agents/nest_skill.md
+++ b/desktop/src-tauri/src/managed_agents/nest_skill.md
@@ -53,6 +53,23 @@ sprout --format compact feed get               # [{id, content, created_at}]
 
 Write commands are unaffected. `--format json` (default) returns full fields.
 
+## Communication Patterns
+
+**Mentions that notify:** Always include `--mention <hex_pubkey>` for each person you `@`-mention in content. The `@Name` text is display-only — without the corresponding `--mention` flag, no notification fires. Look up pubkeys with `sprout users get --name <display_name>`.
+
+```bash
+# ✅ Correct — notification delivered
+sprout messages send --channel <UUID> --content "@Alice check this" \
+  --mention deadbeef1234...
+
+# ❌ Wrong — mention is cosmetic only, no notification
+sprout messages send --channel <UUID> --content "@Alice check this"
+
+# Multiple mentions
+sprout messages send --channel <UUID> --content "@Alice @Bob review please" \
+  --mention <alice_hex> --mention <bob_hex>
+```
+
 ## Gotchas
 
 1. **`feed get` sorts newest-first** — every other list command sorts oldest-first. Don't assume consistent sort order.
diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs
index 9fd13b30d0..8a9eadd7ab 100644
--- a/desktop/src-tauri/src/migration.rs
+++ b/desktop/src-tauri/src/migration.rs
@@ -278,7 +278,8 @@ fn reconcile_mcp_commands_in_file(path: &Path) {
             .get("mcp_command")
             .and_then(|v| v.as_str())
             .unwrap_or("");
-        if current != expected {
+        // Only clear the known stale default — never touch user-customized values.
+        if current == "sprout-mcp-server" {
             eprintln!(
                 "sprout-desktop: provider-reconcile: {:?} ({:?}): mcp_command {:?} → {:?}",
                 obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
@@ -799,34 +800,26 @@ mod tests {
     }
 
     #[test]
-    fn reconcile_adds_mcp_command_when_key_absent() {
+    fn reconcile_leaves_absent_mcp_command_untouched() {
         let dir = tempfile::tempdir().unwrap();
-        write_agents_json(
-            dir.path(),
-            &serde_json::json!([{
-                "name": "Solo",
-                "agent_command": "sprout-agent"
-            }]),
-        );
-        reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
-        let records = read_agents_json(dir.path());
-        assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp");
+        let json = serde_json::json!([{"name": "Solo", "agent_command": "sprout-agent"}]);
+        write_agents_json(dir.path(), &json);
+        let path = dir.path().join("agents/managed-agents.json");
+        let before = std::fs::read_to_string(&path).unwrap();
+        reconcile_mcp_commands_in_file(&path);
+        assert_eq!(before, std::fs::read_to_string(&path).unwrap());
     }
 
     #[test]
-    fn reconcile_treats_null_mcp_command_as_empty() {
+    fn reconcile_leaves_null_mcp_command_untouched() {
         let dir = tempfile::tempdir().unwrap();
-        write_agents_json(
-            dir.path(),
-            &serde_json::json!([{
-                "name": "Solo",
-                "agent_command": "sprout-agent",
-                "mcp_command": null
-            }]),
-        );
-        reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
-        let records = read_agents_json(dir.path());
-        assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp");
+        let json =
+            serde_json::json!([{"name":"Solo","agent_command":"sprout-agent","mcp_command":null}]);
+        write_agents_json(dir.path(), &json);
+        let path = dir.path().join("agents/managed-agents.json");
+        let before = std::fs::read_to_string(&path).unwrap();
+        reconcile_mcp_commands_in_file(&path);
+        assert_eq!(before, std::fs::read_to_string(&path).unwrap());
     }
 
     #[test]

From cf5ce29de843b5879af735e1b571dfc589942066 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 4 Jun 2026 14:52:51 -0400
Subject: [PATCH 3/7] docs(skill): document new CLI commands in sprout-cli
 skill

Adds sections for DM Management, Channel Policies, Workflow Inputs,
Feed Filtering, and Pagination covering the 6 CLI parity features
just added. Agents need these documented beyond --help because they
include non-obvious behaviors (valid policy values, composite cursor
pagination pattern, relay extension hints).
---
 .../src/managed_agents/nest_skill.md          | 25 +++++++++++++++++++
 1 file changed, 25 insertions(+)

diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md
index bd6f7e0df3..c63d5c18d6 100644
--- a/desktop/src-tauri/src/managed_agents/nest_skill.md
+++ b/desktop/src-tauri/src/managed_agents/nest_skill.md
@@ -70,6 +70,31 @@ sprout messages send --channel <UUID> --content "@Alice @Bob review please" \
   --mention <alice_hex> --mention <bob_hex>
 ```
 
+## DM Management
+
+`dms hide --channel <UUID>` hides a DM from the agent's DM list. Restore by re-opening with `dms open --pubkey <hex>`.
+
+## Channel Policies
+
+`channels set-add-policy --policy <value>` controls who can add you to channels:
+- `anyone` (default) — any authenticated user can add you to open channels
+- `owner_only` — only your provisioned owner can add you
+- `nobody` — no one can add you; self-join via `channels join`
+
+## Workflow Inputs
+
+`workflows trigger --workflow <UUID> --inputs '<json>'` passes input variables as the trigger event's content. Omit `--inputs` for parameterless workflows.
+
+## Feed Filtering
+
+`feed get --types <comma-separated>` filters by category. Valid types: `mentions`, `needs_action`, `activity`, `agent_activity`. Omit for all categories.
+
+## Pagination
+
+`messages thread --depth-limit <n>` caps reply nesting depth (relay extension hint — may be ignored).
+
+`social notes --before-id <hex64>` enables composite cursor pagination. Use with `--before <timestamp>` to avoid skipping same-second events.
+
 ## Gotchas
 
 1. **`feed get` sorts newest-first** — every other list command sorts oldest-first. Don't assume consistent sort order.

From 907f9af596641f558631e3434748f8cec0129725 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 4 Jun 2026 16:00:33 -0400
Subject: [PATCH 4/7] chore: clean up remaining sprout-mcp references from
 second review

Stale references found by crossfire review (Codex + Gemini):
- ARCHITECTURE.md: sprout-relay-client crate no longer exists
- .env.example: sprout-mcp-server default replaced with empty
- test mock: mcpCommand updated to empty string
- agentSessionToolCatalog: dead sprout_mcp_ regex removed
- dms.rs: add missing validate_hex64 on cmd_add_dm_member
---
 .env.example                                                  | 4 ++--
 ARCHITECTURE.md                                               | 2 +-
 crates/sprout-cli/src/commands/dms.rs                         | 1 +
 .../features/agents/lib/managedAgentControlActions.test.mjs   | 2 +-
 desktop/src/features/agents/ui/agentSessionToolCatalog.ts     | 4 +---
 5 files changed, 6 insertions(+), 7 deletions(-)

diff --git a/.env.example b/.env.example
index 56e5e9c974..3d1c9a96d6 100644
--- a/.env.example
+++ b/.env.example
@@ -105,8 +105,8 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu
 # Goose default: "acp". Codex/Claude default: "" (empty).
 # SPROUT_ACP_AGENT_ARGS=acp
 
-# Binary for the Sprout MCP server sidecar (provides channel tools to the agent).
-# SPROUT_ACP_MCP_COMMAND=sprout-mcp-server
+# Binary for an optional MCP server sidecar (e.g. sprout-dev-mcp for sprout-agent).
+# SPROUT_ACP_MCP_COMMAND=
 
 # Number of parallel agent subprocesses (1–32).
 # SPROUT_ACP_AGENTS=1
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 00ee0804ed..e47b26ea10 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -745,7 +745,7 @@ All e2e tests are `#[ignore]` — require a running relay. Total: **134 e2e test
 
 `src/main.rs` is a manual testing CLI (`sprout-test-cli`) with `--send`, `--subscribe`, `--channel`, `--url`, `--kind` flags.
 
-Re-exports `parse_relay_message`, `OkResponse`, `RelayMessage` from `sprout-relay-client`.
+Defines `parse_relay_message`, `OkResponse`, `RelayMessage` directly in `src/lib.rs`.
 
 ---
 
diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs
index a5f9a528b1..a3c0492967 100644
--- a/crates/sprout-cli/src/commands/dms.rs
+++ b/crates/sprout-cli/src/commands/dms.rs
@@ -115,6 +115,7 @@ pub async fn cmd_add_dm_member(
     pubkey: &str,
 ) -> Result<(), CliError> {
     let channel_uuid = parse_uuid(channel_id)?;
+    validate_hex64(pubkey)?;
 
     let builder = sprout_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?;
     let event = client.sign_event(builder)?;
diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs
index e521426176..020c02d9da 100644
--- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs
+++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs
@@ -12,7 +12,7 @@ function agent(overrides = {}) {
     acpCommand: "sprout-acp",
     agentCommand: "goose",
     agentArgs: [],
-    mcpCommand: "sprout-mcp-server",
+    mcpCommand: "",
     turnTimeoutSeconds: 320,
     idleTimeoutSeconds: null,
     maxTurnDurationSeconds: null,
diff --git a/desktop/src/features/agents/ui/agentSessionToolCatalog.ts b/desktop/src/features/agents/ui/agentSessionToolCatalog.ts
index 4ff11e0b41..cb98abc3bf 100644
--- a/desktop/src/features/agents/ui/agentSessionToolCatalog.ts
+++ b/desktop/src/features/agents/ui/agentSessionToolCatalog.ts
@@ -212,9 +212,7 @@ export function normalizeToolName(title: string): string {
   const knownName = findSproutToolName(title, true);
   if (knownName) return knownName;
 
-  const normalized = normalizeToolNameText(title)
-    .replace(/^sprout_mcp_/, "")
-    .replace(/^sprout_/, "");
+  const normalized = normalizeToolNameText(title).replace(/^sprout_/, "");
   return normalized.match(/[a-z][a-z0-9_]+/)?.[0] ?? normalized;
 }
 

From 8a917c550cc5daa74bb41217fdd25cc9c6591bea Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 4 Jun 2026 16:41:06 -0400
Subject: [PATCH 5/7] feat(relay): wire kind:10100 handler and workflow trigger
 inputs

Two CLI features added in the sprout-mcp deprecation were accepted by the
CLI but silently dropped by the relay. Both gaps pre-date this PR (the old
MCP had identical no-op behavior), but shipping documented commands that
don't work is wrong.

kind:10100 (agent profile / channel_add_policy): add to the scope
allowlist, register as a side-effect kind, and write a handler that
parses the policy from event content and calls set_channel_add_policy().

kind:46020 (workflow trigger): parse event.content as JSON and populate
TriggerContext.webhook_fields, mirroring the existing webhook bridge
pattern. Inputs become accessible as {{trigger.KEY}} in templates and
trigger_KEY in evalexpr conditions with zero executor changes.
---
 .../src/handlers/command_executor.rs          | 13 ++++++++-
 crates/sprout-relay/src/handlers/ingest.rs    | 24 ++++++++-------
 .../sprout-relay/src/handlers/side_effects.rs | 29 +++++++++++++++++--
 3 files changed, 52 insertions(+), 14 deletions(-)

diff --git a/crates/sprout-relay/src/handlers/command_executor.rs b/crates/sprout-relay/src/handlers/command_executor.rs
index 781b44732b..3f3b96fa43 100644
--- a/crates/sprout-relay/src/handlers/command_executor.rs
+++ b/crates/sprout-relay/src/handlers/command_executor.rs
@@ -691,7 +691,7 @@ async fn handle_workflow_trigger(
     };
 
     // 4. Execute: create workflow run
-    let trigger_ctx = TriggerContext {
+    let mut trigger_ctx = TriggerContext {
         channel_id: workflow
             .channel_id
             .map(|id| id.to_string())
@@ -699,6 +699,17 @@ async fn handle_workflow_trigger(
         author: hex::encode(&self_bytes),
         ..Default::default()
     };
+    if !event.content.is_empty() {
+        if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(&event.content) {
+            for (k, v) in map {
+                let val_str = match v {
+                    serde_json::Value::String(s) => s,
+                    other => other.to_string(),
+                };
+                trigger_ctx.webhook_fields.insert(k, val_str);
+            }
+        }
+    }
     let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok();
 
     let event_id_bytes = event.id.as_bytes().to_vec();
diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs
index 4697dbc41a..91004ec8f5 100644
--- a/crates/sprout-relay/src/handlers/ingest.rs
+++ b/crates/sprout-relay/src/handlers/ingest.rs
@@ -13,15 +13,15 @@ use nostr::Event;
 use sprout_auth::Scope;
 use sprout_core::kind::{
     event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable,
-    is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH,
-    KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION,
-    KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET,
-    KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
-    KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
-    KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
-    KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
-    KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED,
-    KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM,
+    is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_APPROVAL_DENY,
+    KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS,
+    KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN,
+    KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST,
+    KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE,
+    KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED,
+    KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED,
+    KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT,
+    KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM,
     KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS,
     KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
     KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
@@ -167,7 +167,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
         // User-owned global state, keyed by (pubkey, kind[, d_tag]); the workspace
         // palette is the client-side union of every member's own set.
         | KIND_EMOJI_SET
-        | KIND_EMOJI_LIST => Ok(Scope::UsersWrite),
+        | KIND_EMOJI_LIST
+        | KIND_AGENT_PROFILE => Ok(Scope::UsersWrite),
         KIND_DELETION
         | KIND_REACTION
         | KIND_GIFT_WRAP
@@ -339,6 +340,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
             | KIND_EMOJI_LIST
             // NIP-AE agent engrams are addressed by (pubkey_a, kind, d_tag); never channel-scoped.
             | KIND_AGENT_ENGRAM
+            // Agent profile (10100): user-owned replaceable, keyed by pubkey.
+            | KIND_AGENT_PROFILE
             // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
             // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
             | KIND_GIT_REPO_ANNOUNCEMENT
@@ -1917,6 +1920,7 @@ mod tests {
             KIND_EMOJI_SET,
             KIND_EMOJI_LIST,
             KIND_AGENT_ENGRAM,
+            KIND_AGENT_PROFILE,
         ];
         for kind in migrated {
             assert!(
diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs
index 092d41f839..a65b067f94 100644
--- a/crates/sprout-relay/src/handlers/side_effects.rs
+++ b/crates/sprout-relay/src/handlers/side_effects.rs
@@ -7,8 +7,8 @@ use tracing::{info, warn};
 use uuid::Uuid;
 
 use sprout_core::kind::{
-    event_kind_u32, is_parameterized_replaceable, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED,
-    KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION,
+    event_kind_u32, is_parameterized_replaceable, KIND_AGENT_PROFILE, KIND_GIT_REPO_ANNOUNCEMENT,
+    KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION,
     KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS,
     KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION,
 };
@@ -29,7 +29,7 @@ pub fn is_admin_kind(kind: u32) -> bool {
 /// handled in `ingest_event()` before storage so we can short-circuit on
 /// duplicates without storing the event at all.
 pub fn is_side_effect_kind(kind: u32) -> bool {
-    matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | 41001..=41003 | 40099)
+    matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099)
 }
 
 async fn evict_live_channel_subscriptions(
@@ -89,6 +89,7 @@ pub async fn handle_side_effects(
         9022 => handle_leave_request(event, state).await,
         // NIP-34: Git repo announcement → reserve name + seed manifest pointer.
         KIND_GIT_REPO_ANNOUNCEMENT => handle_git_repo_announcement(event, state).await,
+        KIND_AGENT_PROFILE => handle_agent_profile(event, state).await,
         // kind:7 (reaction) handled inline in ingest_event() before storage.
         _ => Ok(()),
     }
@@ -703,6 +704,28 @@ pub async fn emit_group_discovery_events(
     Ok(())
 }
 
+// ── Kind:10100 Agent Profile Handler ─────────────────────────────────────────
+
+async fn handle_agent_profile(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
+    let content: serde_json::Value = serde_json::from_str(&event.content)
+        .map_err(|e| anyhow::anyhow!("kind:10100 content parse error: {e}"))?;
+
+    let policy = content
+        .get("channel_add_policy")
+        .and_then(|v| v.as_str())
+        .ok_or_else(|| anyhow::anyhow!("kind:10100 missing channel_add_policy field"))?;
+
+    let pubkey_bytes = event.pubkey.to_bytes().to_vec();
+    state.db.ensure_user(&pubkey_bytes).await?;
+    state
+        .db
+        .set_channel_add_policy(&pubkey_bytes, policy)
+        .await?;
+
+    info!(pubkey = %hex::encode(&pubkey_bytes), policy, "kind:10100 channel_add_policy updated");
+    Ok(())
+}
+
 // ── NIP-01 Kind:0 Handler ────────────────────────────────────────────────────
 
 /// Kind:0 (NIP-01 profile metadata) side effect — sync profile fields to users table.

From a6ba41c7071e16e3296631682e5a439c4e4cb696 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Fri, 5 Jun 2026 11:57:01 -0400
Subject: [PATCH 6/7] chore: remove stale sprout-mcp references from mobile +
 ACP README

---
 crates/sprout-acp/README.md                                   | 4 ++--
 .../features/channels/agent_activity/transcript_builder.dart  | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md
index 9b5cf9f571..37cea80833 100644
--- a/crates/sprout-acp/README.md
+++ b/crates/sprout-acp/README.md
@@ -1,11 +1,11 @@
 # sprout-acp
 
-ACP harness that connects AI agents to Sprout. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using Sprout MCP tools.
+ACP harness that connects AI agents to Sprout. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using the Sprout CLI.
 
 ```
 Sprout Relay ──WS──→ sprout-acp ──stdio──→ Your Agent
                                                │
-                                          Sprout MCP
+                                          Sprout CLI
                                        (send_message, etc.)
 ```
 
diff --git a/mobile/lib/features/channels/agent_activity/transcript_builder.dart b/mobile/lib/features/channels/agent_activity/transcript_builder.dart
index 84ee60d3e5..aeaa1695da 100644
--- a/mobile/lib/features/channels/agent_activity/transcript_builder.dart
+++ b/mobile/lib/features/channels/agent_activity/transcript_builder.dart
@@ -164,7 +164,7 @@ String _normalizeToolName(String title) {
 
   final normalized = _normalizeToolNameText(
     title,
-  ).replaceAll(RegExp(r'^sprout_mcp_'), '').replaceAll(RegExp(r'^sprout_'), '');
+  ).replaceAll(RegExp(r'^sprout_'), '');
   return RegExp(r'[a-z][a-z0-9_]+').firstMatch(normalized)?[0] ?? normalized;
 }
 

From 3e3c53b7098a37c2e686ce7a48e9f7c6f588f2cd Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Fri, 5 Jun 2026 12:03:05 -0400
Subject: [PATCH 7/7] chore: fix remaining 'Sprout MCP tools' refs in ACP
 README (lines 44, 56, 239)

---
 crates/sprout-acp/README.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md
index 37cea80833..ea978c49a1 100644
--- a/crates/sprout-acp/README.md
+++ b/crates/sprout-acp/README.md
@@ -41,7 +41,7 @@ The harness discovers channels by querying the relay with the agent's authentica
 
 By default, the harness discovers only channels the agent is a **member** of (`GET /api/channels?member=true`). When the agent is added to a new channel, the membership notification subscription auto-subscribes to it.
 
-**Private channels** require explicit membership. The relay doesn't yet have a REST/event API for managing channel members — this is a known gap. For now, use `create_channel` via the Sprout MCP tools to create new channels (the creator is automatically a member).
+**Private channels** require explicit membership. The relay doesn't yet have a REST/event API for managing channel members — this is a known gap. For now, use `create_channel` via the Sprout CLI to create new channels (the creator is automatically a member).
 
 ## Quick Start (goose)
 
@@ -53,7 +53,7 @@ export GOOSE_MODE=auto
 sprout-acp
 ```
 
-That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Sprout MCP tools that the harness configures automatically.
+That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Sprout CLI that the harness configures automatically.
 
 ## Running with Codex
 
@@ -236,7 +236,7 @@ Forum event kinds:
 2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each.
 3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel.
 4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
-5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_messages`, etc.) to interact with Sprout.
+5. **Agent response** — The agent processes the prompt and uses the Sprout CLI (`send_message`, `get_messages`, etc.) to interact with Sprout.
 6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.
 
 Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1.
