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
14 changes: 14 additions & 0 deletions src/core/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ pub const INITIALIZE_METHOD: &str = "initialize";
/// MCP protocol method for the initialized notification
pub const NOTIFICATIONS_INITIALIZED_METHOD: &str = "notifications/initialized";

/// Sentinel request ID for the announcement auto-publish flow.
///
/// Synthetic initialize and capability-list requests use this ID so the
/// worker routes responses to the announcement handler rather than the
/// normal client response path.
pub const ANNOUNCEMENT_REQUEST_ID: &str = "announcement";

/// Kinds that should never be encrypted (public announcements)
pub const UNENCRYPTED_KINDS: &[u16] = &[
SERVER_ANNOUNCEMENT_KIND,
Expand Down Expand Up @@ -211,6 +218,13 @@ mod tests {
);
}

#[test]
fn test_announcement_request_id() {
assert_eq!(ANNOUNCEMENT_REQUEST_ID, "announcement");
// Must differ from the stateless synthetic sentinel used by the worker
assert_ne!(ANNOUNCEMENT_REQUEST_ID, "contextvm-stateless-init");
}

#[test]
fn test_unencrypted_kinds_contains_all_announcements() {
assert!(UNENCRYPTED_KINDS.contains(&SERVER_ANNOUNCEMENT_KIND));
Expand Down
52 changes: 52 additions & 0 deletions src/rmcp_transport/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! This file defines wrapper types that bind existing ContextVM Nostr
//! transports to rmcp's worker abstraction.

use crate::core::constants::ANNOUNCEMENT_REQUEST_ID;
use crate::core::error::Result;
use crate::core::types::{JsonRpcMessage, JsonRpcNotification, JsonRpcRequest};
use crate::transport::client::{NostrClientTransport, NostrClientTransportConfig};
Expand Down Expand Up @@ -118,6 +119,10 @@ impl Worker for NostrServerWorker {
.await
.map_err(WorkerQuitReason::fatal_context("starting server transport"))?;

// CEP-6: Spawn auto-publish after start() so the worker's select loop
// is running when synthetic messages arrive through message_tx.
self.transport.spawn_announcements();

let mut rx = self.transport.take_message_receiver().ok_or_else(|| {
WorkerQuitReason::fatal(
Self::Error::Other("server message receiver already taken".to_string()),
Expand Down Expand Up @@ -366,6 +371,17 @@ impl NostrServerWorker {
)
})?;

if event_id == ANNOUNCEMENT_REQUEST_ID {
tracing::debug!(
target: LOG_TARGET,
"Routing announcement response to handler"
);
return self
.transport
.handle_announcement_response(JsonRpcMessage::Response(resp))
.await;
}

if event_id == STATELESS_SYNTHETIC_EVENT_ID {
tracing::debug!(
target: LOG_TARGET,
Expand All @@ -386,6 +402,17 @@ impl NostrServerWorker {
)
})?;

if event_id == ANNOUNCEMENT_REQUEST_ID {
tracing::debug!(
target: LOG_TARGET,
"Routing announcement error to handler"
);
return self
.transport
.handle_announcement_response(JsonRpcMessage::ErrorResponse(resp))
.await;
}

if event_id == STATELESS_SYNTHETIC_EVENT_ID {
tracing::debug!(
target: LOG_TARGET,
Expand Down Expand Up @@ -517,4 +544,29 @@ mod tests {
&synthetic_initialize_message()
));
}

#[test]
fn test_announcement_sentinel_differs_from_stateless_sentinel() {
assert_ne!(ANNOUNCEMENT_REQUEST_ID, STATELESS_SYNTHETIC_EVENT_ID);
}

#[test]
fn test_announcement_response_id_detected() {
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({
"protocolVersion": "2025-11-25",
"capabilities": {},
"serverInfo": { "name": "test" }
}),
});

if let JsonRpcMessage::Response(ref resp) = response {
let event_id = resp.id.as_str().unwrap();
assert_eq!(event_id, ANNOUNCEMENT_REQUEST_ID);
// Must not be confused with the stateless synthetic sentinel
assert!(!is_synthetic_initialize_message(&response));
}
}
}
Loading
Loading