mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: set_presence REST endpoint, MCP tool, and shared PresenceStatus enum (#33)
This commit is contained in:
Generated
+1
@@ -2689,6 +2689,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"nostr",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
|
||||
+25
-16
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -163,7 +163,7 @@ 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
|
||||
|
||||
@@ -171,7 +171,7 @@ RELAY_URL=ws://localhost:3000 \
|
||||
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
|
||||
```
|
||||
@@ -179,12 +179,12 @@ RELAY_URL=ws://localhost:3000 \
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.)
|
||||
@@ -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**
|
||||
@@ -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)**
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "Core types, event verification, and filter matching for Sprout"
|
||||
|
||||
[features]
|
||||
test-utils = []
|
||||
mcp-schema = ["schemars"]
|
||||
|
||||
[dependencies]
|
||||
nostr = { workspace = true }
|
||||
@@ -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
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
|
||||
@@ -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 - _ . ~).
|
||||
@@ -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 {
|
||||
@@ -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]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! - `events` — GET /api/events/:id
|
||||
//! - `search` — GET /api/search
|
||||
//! - `agents` — GET /api/agents
|
||||
//! - `presence` — GET /api/presence
|
||||
//! - `presence` — GET/PUT /api/presence
|
||||
//! - `workflows` — workflow CRUD + trigger + webhook
|
||||
//! - `approvals` — approval grant/deny
|
||||
//! - `feed` — GET /api/feed
|
||||
@@ -59,7 +59,7 @@ pub use events::get_event;
|
||||
pub use feed::feed_handler;
|
||||
pub use members::{add_members, join_channel, leave_channel, list_members, remove_member};
|
||||
pub use messages::{delete_message, get_thread, list_messages, send_message};
|
||||
pub use presence::presence_handler;
|
||||
pub use presence::{presence_handler, set_presence_handler};
|
||||
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
|
||||
pub use search::search_handler;
|
||||
pub use users::{get_profile, get_user_profile, get_users_batch, update_profile};
|
||||
@@ -294,6 +294,29 @@ pub(crate) async fn check_channel_access(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom JSON extractor ─────────────────────────────────────────────────────
|
||||
|
||||
use axum::extract::{rejection::JsonRejection, FromRequest, Request};
|
||||
|
||||
/// A JSON extractor that returns our standard `{"error": "..."}` envelope
|
||||
/// on deserialization failure instead of Axum's default plain-text 422.
|
||||
pub struct ApiJson<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for ApiJson<T>
|
||||
where
|
||||
axum::Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, Json<serde_json::Value>);
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match axum::Json::<T>::from_request(req, state).await {
|
||||
Ok(axum::Json(value)) => Ok(ApiJson(value)),
|
||||
Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GET /api/presence — bulk presence lookup by pubkey.
|
||||
//! Presence API — GET /api/presence (bulk lookup) and PUT /api/presence (set status).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -8,6 +8,8 @@ use axum::{
|
||||
response::Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sprout_core::PresenceStatus;
|
||||
use sprout_pubsub::presence::PRESENCE_TTL_SECS;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -64,3 +66,52 @@ pub async fn presence_handler(
|
||||
|
||||
Ok(Json(serde_json::Value::Object(result)))
|
||||
}
|
||||
|
||||
/// Request body for `PUT /api/presence`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetPresenceBody {
|
||||
/// Presence status to set.
|
||||
pub status: PresenceStatus,
|
||||
}
|
||||
|
||||
/// Set the authenticated user's presence status.
|
||||
///
|
||||
/// Accepts `{"status": "online" | "away" | "offline"}` (case-sensitive).
|
||||
/// Serde rejects unknown variants automatically, returning a 422.
|
||||
/// - `"offline"` clears the presence entry (TTL 0).
|
||||
/// - `"online"` / `"away"` upsert the entry with a 90-second TTL.
|
||||
///
|
||||
/// Returns `{"status": "...", "ttl_seconds": N}`.
|
||||
///
|
||||
/// **Note:** The WebSocket path (kind:20001) accepts arbitrary status strings
|
||||
/// for forward-compatibility, but the REST/MCP surface intentionally restricts
|
||||
/// to the curated enum above. Aligning the WebSocket path is tracked separately.
|
||||
pub async fn set_presence_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
super::ApiJson(body): super::ApiJson<SetPresenceBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
|
||||
|
||||
match body.status {
|
||||
PresenceStatus::Online | PresenceStatus::Away => {
|
||||
state
|
||||
.pubsub
|
||||
.set_presence(&pubkey, body.status.as_str())
|
||||
.await
|
||||
.map_err(|e| super::internal_error(&format!("presence error: {e}")))?;
|
||||
}
|
||||
PresenceStatus::Offline => {
|
||||
state
|
||||
.pubsub
|
||||
.clear_presence(&pubkey)
|
||||
.await
|
||||
.map_err(|e| super::internal_error(&format!("presence error: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": body.status,
|
||||
"ttl_seconds": if body.status == PresenceStatus::Offline { 0 } else { PRESENCE_TTL_SECS },
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/api/events/{id}", get(api::get_event))
|
||||
.route("/api/search", get(api::search_handler))
|
||||
.route("/api/agents", get(api::agents_handler))
|
||||
.route("/api/presence", get(api::presence_handler))
|
||||
.route(
|
||||
"/api/presence",
|
||||
get(api::presence_handler).put(api::set_presence_handler),
|
||||
)
|
||||
// Workflow routes
|
||||
.route(
|
||||
"/api/channels/{channel_id}/workflows",
|
||||
|
||||
@@ -246,7 +246,7 @@ impl McpSession {
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Spawn the MCP server, complete the initialize handshake, and verify that
|
||||
/// all 40 expected tools are listed by `tools/list`.
|
||||
/// all 41 expected tools are listed by `tools/list`.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_mcp_initialize_and_list_tools() {
|
||||
@@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
|
||||
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
40,
|
||||
"expected exactly 40 tools, got {}. Tools: {:?}",
|
||||
41,
|
||||
"expected exactly 41 tools, got {}. Tools: {:?}",
|
||||
tools.len(),
|
||||
tools
|
||||
.iter()
|
||||
@@ -348,6 +348,7 @@ async fn test_mcp_initialize_and_list_tools() {
|
||||
"get_users_batch",
|
||||
"search",
|
||||
"get_presence",
|
||||
"set_presence",
|
||||
];
|
||||
|
||||
for expected in &expected_tools {
|
||||
@@ -1064,3 +1065,166 @@ async fn test_mcp_get_users_batch() {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -631,6 +631,182 @@ async fn test_presence_empty_pubkeys() {
|
||||
);
|
||||
}
|
||||
|
||||
/// PUT /api/presence sets the user's presence and can be read back.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_set_presence_online() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
|
||||
// Set presence to "online" via REST.
|
||||
let url = format!("{}/api/presence", relay_http_url());
|
||||
let resp = authed_put(
|
||||
&client,
|
||||
&url,
|
||||
&pubkey_hex,
|
||||
serde_json::json!({"status": "online"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200, "PUT /api/presence should return 200");
|
||||
let body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert_eq!(body["status"].as_str(), Some("online"));
|
||||
assert_eq!(
|
||||
body["ttl_seconds"].as_u64(),
|
||||
Some(90),
|
||||
"online presence should have 90s TTL"
|
||||
);
|
||||
|
||||
// Verify via GET.
|
||||
let get_url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url());
|
||||
let resp = authed_get(&client, &get_url, &pubkey_hex).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert_eq!(
|
||||
body[&pubkey_hex].as_str(),
|
||||
Some("online"),
|
||||
"presence should be 'online' after PUT"
|
||||
);
|
||||
}
|
||||
|
||||
/// PUT /api/presence with "away" then "offline" updates and clears presence.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_set_presence_away_and_offline() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let url = format!("{}/api/presence", relay_http_url());
|
||||
|
||||
// Set to "away".
|
||||
let resp = authed_put(
|
||||
&client,
|
||||
&url,
|
||||
&pubkey_hex,
|
||||
serde_json::json!({"status": "away"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let away_body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert_eq!(
|
||||
away_body["status"].as_str(),
|
||||
Some("away"),
|
||||
"PUT response should echo 'away'"
|
||||
);
|
||||
assert_eq!(
|
||||
away_body["ttl_seconds"].as_u64(),
|
||||
Some(90),
|
||||
"away should have 90s TTL"
|
||||
);
|
||||
|
||||
// Verify "away".
|
||||
let get_url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url());
|
||||
let resp = authed_get(&client, &get_url, &pubkey_hex).await;
|
||||
let body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert_eq!(body[&pubkey_hex].as_str(), Some("away"));
|
||||
|
||||
// Set to "offline" — should clear presence.
|
||||
let resp = authed_put(
|
||||
&client,
|
||||
&url,
|
||||
&pubkey_hex,
|
||||
serde_json::json!({"status": "offline"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let offline_body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert_eq!(
|
||||
offline_body["status"].as_str(),
|
||||
Some("offline"),
|
||||
"PUT response should echo 'offline'"
|
||||
);
|
||||
assert_eq!(
|
||||
offline_body["ttl_seconds"].as_u64(),
|
||||
Some(0),
|
||||
"offline should have 0 TTL"
|
||||
);
|
||||
|
||||
// Verify "offline" (key deleted from Redis, defaults to "offline").
|
||||
let resp = authed_get(&client, &get_url, &pubkey_hex).await;
|
||||
let body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert_eq!(body[&pubkey_hex].as_str(), Some("offline"));
|
||||
}
|
||||
|
||||
/// PUT /api/presence with an invalid status returns 422 with standard error envelope.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_set_presence_invalid_status() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
|
||||
let url = format!("{}/api/presence", relay_http_url());
|
||||
let resp = authed_put(
|
||||
&client,
|
||||
&url,
|
||||
&pubkey_hex,
|
||||
serde_json::json!({"status": "invisible"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
422,
|
||||
"invalid enum variant should return 422 Unprocessable Entity"
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert!(
|
||||
body["error"].as_str().is_some(),
|
||||
"422 response should contain standard error envelope, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// PUT /api/presence without auth returns 401.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_set_presence_requires_auth() {
|
||||
let client = http_client();
|
||||
let url = format!("{}/api/presence", relay_http_url());
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"status": "online"}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request failed");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"PUT /api/presence without auth should return 401"
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert_eq!(
|
||||
body["error"].as_str(),
|
||||
Some("authentication required"),
|
||||
"401 response should contain standard error envelope"
|
||||
);
|
||||
}
|
||||
|
||||
/// PUT /api/presence with missing status field returns a structured error.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_set_presence_missing_field() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
|
||||
let url = format!("{}/api/presence", relay_http_url());
|
||||
let resp = authed_put(&client, &url, &pubkey_hex, serde_json::json!({})).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
422,
|
||||
"missing required field should return 422"
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.expect("JSON");
|
||||
assert!(
|
||||
body["error"].as_str().is_some(),
|
||||
"422 response should contain standard error envelope, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agents tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/agents returns a JSON array with the expected fields.
|
||||
|
||||
Reference in New Issue
Block a user