Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 25 additions & 16 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Sprout Testing Guide

This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 40 MCP tools against a live relay.
This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 41 MCP tools against a live relay.

## Two Test Modes

| Mode | What It Does | When to Use |
|------|-------------|-------------|
| **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions |
| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 40 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features |
| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 41 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features |

Run both modes for a complete regression check. Run automated-only for a fast sanity check.

Expand All @@ -27,7 +27,7 @@ Run both modes for a complete regression check. Run automated-only for a fast sa
- [3.7 Expected Results](#37-expected-results)
4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios)
5. [Workflow YAML Reference](#5-workflow-yaml-reference)
6. [The 40 MCP Tools](#6-the-40-mcp-tools)
6. [The 41 MCP Tools](#6-the-41-mcp-tools)
7. [Cleanup](#7-cleanup)
8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting)

Expand Down Expand Up @@ -163,28 +163,28 @@ sleep 3 && curl -s http://localhost:3000/health
Then run the integration suites:

```bash
# REST API integration tests (35 tests)
# REST API integration tests (40 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_rest_api -- --ignored

# WebSocket relay integration tests (14 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_relay -- --ignored

# MCP server integration tests (10 tests)
# MCP server integration tests (14 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_mcp -- --ignored
```

### Expected Results

```
test result: ok. 35 passed; 0 failed; 0 ignored ← REST API
test result: ok. 40 passed; 0 failed; 0 ignored ← REST API
test result: ok. 14 passed; 0 failed; 0 ignored ← relay
test result: ok. 10 passed; 0 failed; 0 ignored ← MCP
test result: ok. 14 passed; 0 failed; 0 ignored ← MCP
```

All 59 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E.
All 68 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E.

---

Expand All @@ -202,7 +202,7 @@ Operator (you)
Sprout Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose
sprout-mcp-server
(40 MCP tools)
(41 MCP tools)
Sprout Relay
(send_message, etc.)
Expand Down Expand Up @@ -440,7 +440,7 @@ mention "$GENERAL" "$ALICE_PUBKEY" \

```bash
mention "$GENERAL" "$ALICE_PUBKEY" \
"Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY)."
"Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use set_presence to set your status to 'online'. Finally, use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY) and confirm it shows 'online'."
```

**A-6: Feed, search, and membership**
Expand Down Expand Up @@ -502,7 +502,14 @@ mention "$GENERAL" "$BOB_PUBKEY" \

```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Get the presence status for Alice (pubkey: $ALICE_PUBKEY). Get your own presence status. Report both."
"Use set_presence to set your status to 'away'. Then get the presence status for Alice (pubkey: $ALICE_PUBKEY) and yourself. Report both statuses — Alice should be 'online' (from A-5) and you should be 'away'."
```

**B-8: Profile resolution (public profiles)**

```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Use get_user_profile to look up Alice's profile (pubkey: $ALICE_PUBKEY). Report her display name and about text. Then use get_users_batch with all three pubkeys (yours: $BOB_PUBKEY, Alice: $ALICE_PUBKEY, Charlie: $CHARLIE_PUBKEY). Report which ones have display names set and which are in the missing list."
```

**B-8: Profile resolution (public profiles)**
Expand Down Expand Up @@ -696,6 +703,7 @@ After all exercises complete, the following should be true:
| Profile resolution | Bob can read Alice's profile via `get_user_profile`; `get_users_batch` returns Alice and Bob with display names, Charlie with null display name (all in profiles map) |
| NIP-05 verification | Charlie queries `/.well-known/nostr.json?name=alice` and gets Alice's pubkey (Alice set `alice@localhost` in A-5) |
| Profile edge cases | Charlie gets appropriate errors for invalid/unknown pubkeys |
| Presence | Alice is 'online' (A-5), Bob is 'away' (B-7); Charlie can read both via get_presence (C-7) |
| Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors |
| charlie-lifecycle | Unarchived and final message sent successfully |

Expand Down Expand Up @@ -1003,9 +1011,9 @@ steps:

---

## 6. The 40 MCP Tools
## 6. The 41 MCP Tools

The `sprout-mcp-server` exposes 40 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness.
The `sprout-mcp-server` exposes 41 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness.

### Channels (8)

Expand Down Expand Up @@ -1096,6 +1104,7 @@ The `sprout-mcp-server` exposes 40 tools covering the full Sprout feature surfac
| Tool | Description |
|------|-------------|
| `get_presence` | Bulk presence lookup by pubkey |
| `set_presence` | Set presence status (online/away/offline) with TTL |

### Members (3)

Expand Down Expand Up @@ -1170,9 +1179,9 @@ rm -f /tmp/alice-keys.txt /tmp/agent-b-keys.txt /tmp/agent-c-keys.txt

All automated tests pass as of 2026-03-11:

- ✅ 32/32 REST API integration tests
- ✅ 13/13 WebSocket relay integration tests
- ✅ 10/10 MCP server integration tests
- ✅ 40/40 REST API integration tests
- ✅ 14/14 WebSocket relay integration tests
- ✅ 14/14 MCP server integration tests
- ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness

---
Expand Down
2 changes: 2 additions & 0 deletions crates/sprout-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ description = "Core types, event verification, and filter matching for Sprout"

[features]
test-utils = []
mcp-schema = ["schemars"]

[dependencies]
nostr = { workspace = true }
Expand All @@ -18,5 +19,6 @@ thiserror = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
hex = { workspace = true }
schemars = { workspace = true, optional = true }

# NO tokio, NO sqlx, NO redis, NO axum — zero I/O dependencies
3 changes: 3 additions & 0 deletions crates/sprout-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ pub mod filter;
pub mod kind;
/// Network utilities — SSRF-safe IP classification.
pub mod network;
/// Presence status types shared across crates.
pub mod presence;
/// Schnorr signature and event ID verification.
pub mod verification;

pub use error::VerificationError;
pub use event::StoredEvent;
pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey};
pub use presence::PresenceStatus;
pub use verification::verify_event;

#[cfg(any(test, feature = "test-utils"))]
Expand Down
74 changes: 74 additions & 0 deletions crates/sprout-core/src/presence.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! Presence status types shared across REST, MCP, and WebSocket surfaces.

use serde::{Deserialize, Serialize};

/// Allowed presence statuses for the REST/MCP surface.
///
/// 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.
Online,
/// User is away / idle.
Away,
/// User is offline; clears the presence entry.
Offline,
}

impl PresenceStatus {
/// Returns the lowercase string representation stored in Redis.
pub fn as_str(&self) -> &'static str {
match self {
Self::Online => "online",
Self::Away => "away",
Self::Offline => "offline",
}
}
}

impl std::fmt::Display for PresenceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn serde_roundtrip() {
let online: PresenceStatus = serde_json::from_str(r#""online""#).unwrap();
assert_eq!(online, PresenceStatus::Online);
assert_eq!(serde_json::to_string(&online).unwrap(), r#""online""#);

let away: PresenceStatus = serde_json::from_str(r#""away""#).unwrap();
assert_eq!(away, PresenceStatus::Away);

let offline: PresenceStatus = serde_json::from_str(r#""offline""#).unwrap();
assert_eq!(offline, PresenceStatus::Offline);
}

#[test]
fn serde_rejects_unknown_variant() {
let result: Result<PresenceStatus, _> = serde_json::from_str(r#""invisible""#);
assert!(result.is_err());
}

#[test]
fn as_str_matches_serde() {
assert_eq!(PresenceStatus::Online.as_str(), "online");
assert_eq!(PresenceStatus::Away.as_str(), "away");
assert_eq!(PresenceStatus::Offline.as_str(), "offline");
}

#[test]
fn display_matches_as_str() {
assert_eq!(format!("{}", PresenceStatus::Online), "online");
assert_eq!(format!("{}", PresenceStatus::Away), "away");
assert_eq!(format!("{}", PresenceStatus::Offline), "offline");
}
}
2 changes: 1 addition & 1 deletion crates/sprout-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ path = "src/main.rs"

[dependencies]
# Sprout core types
sprout-core = { workspace = true }
sprout-core = { workspace = true, features = ["mcp-schema"] }

# MCP SDK
rmcp = { workspace = true }
Expand Down
20 changes: 15 additions & 5 deletions crates/sprout-mcp/src/relay_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,9 @@ impl RelayClient {
let url = format!("{}{}", self.relay_http_url(), path);
let resp = self.apply_auth(self.http.get(&url)).send().await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url));
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
Expand All @@ -514,7 +516,9 @@ impl RelayClient {
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url));
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
Expand All @@ -528,7 +532,9 @@ impl RelayClient {
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url));
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
Expand All @@ -538,7 +544,9 @@ impl RelayClient {
let url = format!("{}{}", self.relay_http_url(), path);
let resp = self.apply_auth(self.http.delete(&url)).send().await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url));
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
Expand All @@ -560,7 +568,9 @@ impl RelayClient {
pub async fn get_api(&self, url: &str) -> anyhow::Result<String> {
let resp = self.apply_auth(self.http.get(url)).send().await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url));
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
Expand Down
21 changes: 21 additions & 0 deletions crates/sprout-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use rmcp::{
use serde::{Deserialize, Serialize};

use crate::relay_client::RelayClient;
use sprout_core::PresenceStatus;

/// 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 - _ . ~).
Expand Down Expand Up @@ -417,6 +418,13 @@ pub struct GetPresenceParams {
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 `get_feed` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetFeedParams {
Expand Down Expand Up @@ -1397,6 +1405,19 @@ impl SproutMcpServer {
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 {
let body = serde_json::json!({ "status": p.status });
match self.client.put("/api/presence", &body).await {
Ok(b) => b,
Err(e) => format!("Error: {e}"),
}
}
}

#[tool_handler]
Expand Down
Loading
Loading