#[cfg(all(test, feature = "rmcp"))]
mod tests {
use std::sync::Arc;
use rmcp::model::{
CallToolRequestParams, CallToolResult, ClientJsonRpcMessage, ClientResult, ErrorData,
Implementation, ProtocolVersion, RequestId, ServerCapabilities, ServerInfo,
ServerJsonRpcMessage, ServerResult,
};
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
schemars, tool, tool_handler, tool_router, ClientHandler, ServerHandler, ServiceExt,
};
use crate::core::serializers;
use crate::core::types::{EncryptionMode, GiftWrapMode};
use crate::core::types::{
JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
};
use crate::relay::mock::MockRelayPool;
use crate::relay::RelayPoolTrait;
use crate::rmcp_transport::convert::{
internal_to_rmcp_client_rx, internal_to_rmcp_server_rx, rmcp_client_tx_to_internal,
rmcp_server_tx_to_internal,
};
use crate::transport::{
client::{NostrClientTransport, NostrClientTransportConfig},
server::{NostrServerTransport, NostrServerTransportConfig},
};
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct EchoParams {
message: String,
}
#[derive(Clone)]
struct StatelessTestServer {
tool_router: ToolRouter<Self>,
}
impl StatelessTestServer {
fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
}
#[tool_router]
impl StatelessTestServer {
#[tool(description = "Echo a message back unchanged")]
async fn echo(
&self,
Parameters(EchoParams { message }): Parameters<EchoParams>,
) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::success(vec![rmcp::model::Content::text(
format!("Echo: {message}"),
)]))
}
}
#[tool_handler]
impl ServerHandler for StatelessTestServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::LATEST,
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: "stateless-test-server".to_string(),
title: Some("Stateless Test Server".to_string()),
version: "0.1.0".to_string(),
description: Some("Stateless rmcp regression test server".to_string()),
icons: None,
website_url: None,
},
instructions: Some("Use the echo tool".to_string()),
}
}
}
#[derive(Clone, Default)]
struct StatelessTestClient;
impl ClientHandler for StatelessTestClient {}
#[test]
fn layer1_nostr_content_to_internal_request() {
let content = r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#;
let msg = serializers::nostr_event_to_mcp_message(content)
.expect("valid MCP request should parse");
assert!(msg.is_request());
assert_eq!(msg.method(), Some("ping"));
assert_eq!(msg.id(), Some(&serde_json::json!(1)));
}
#[test]
fn layer1_nostr_content_to_internal_tools_list() {
let content = r#"{"jsonrpc":"2.0","id":"abc","method":"tools/list","params":{}}"#;
let msg = serializers::nostr_event_to_mcp_message(content).unwrap();
assert_eq!(msg.method(), Some("tools/list"));
assert_eq!(msg.id(), Some(&serde_json::json!("abc")));
}
#[test]
fn layer1_nostr_content_to_internal_notification() {
let content = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
let msg = serializers::nostr_event_to_mcp_message(content).unwrap();
assert!(!msg.is_request());
assert_eq!(msg.method(), Some("notifications/initialized"));
}
#[test]
fn layer1_nostr_content_invalid_json_returns_none() {
assert!(serializers::nostr_event_to_mcp_message("not json").is_none());
}
#[test]
fn layer1_nostr_event_to_mcp_message_no_version_check() {
let content = r#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#;
let msg = serializers::nostr_event_to_mcp_message(content);
if let Some(msg) = msg {
assert_eq!(msg.method(), Some("ping"));
}
}
#[test]
fn layer2_internal_request_converts_to_rmcp_server_rx() {
let msg = make_request("ping", serde_json::json!(1), None);
let rmcp = internal_to_rmcp_server_rx(&msg).expect("ping should convert");
let v = serde_json::to_value(&rmcp).unwrap();
assert_eq!(v["method"], "ping");
assert_eq!(v["id"], serde_json::json!(1));
assert_eq!(v["jsonrpc"], "2.0");
}
#[test]
fn layer2_string_id_preserved_through_bridge() {
let msg = make_request("tools/list", serde_json::json!("req-xyz"), None);
let rmcp = internal_to_rmcp_server_rx(&msg).unwrap();
let v = serde_json::to_value(&rmcp).unwrap();
assert_eq!(v["id"], serde_json::json!("req-xyz"));
}
#[test]
fn layer2_notification_converts_to_rmcp_server_rx() {
let msg = JsonRpcMessage::Notification(JsonRpcNotification {
jsonrpc: "2.0".to_string(),
method: "notifications/initialized".to_string(),
params: None,
});
let rmcp =
internal_to_rmcp_server_rx(&msg).expect("initialized notification should convert");
let v = serde_json::to_value(&rmcp).unwrap();
assert_eq!(v["method"], "notifications/initialized");
}
#[test]
fn layer2_tools_list_with_params_converts() {
let msg = make_request(
"tools/list",
serde_json::json!(7),
Some(serde_json::json!({"cursor": "next-page"})),
);
let rmcp = internal_to_rmcp_server_rx(&msg).unwrap();
let v = serde_json::to_value(&rmcp).unwrap();
assert_eq!(v["method"], "tools/list");
assert_eq!(v["params"]["cursor"], "next-page");
}
#[test]
fn layer4_rmcp_ping_response_roundtrip_number_id() {
let rmcp_response =
ServerJsonRpcMessage::response(ServerResult::empty(()), RequestId::Number(42));
let internal =
rmcp_server_tx_to_internal(rmcp_response).expect("ping response should convert back");
match internal {
JsonRpcMessage::Response(r) => {
assert_eq!(r.id, serde_json::json!(42));
assert_eq!(r.jsonrpc, "2.0");
}
other => panic!("expected Response, got {other:?}"),
}
}
#[test]
fn layer4_rmcp_ping_response_roundtrip_string_id() {
let rmcp_response = ServerJsonRpcMessage::response(
ServerResult::empty(()),
RequestId::String(std::sync::Arc::from("req-xyz")),
);
let internal = rmcp_server_tx_to_internal(rmcp_response).unwrap();
match internal {
JsonRpcMessage::Response(r) => {
assert_eq!(r.id, serde_json::json!("req-xyz"));
}
other => panic!("expected Response, got {other:?}"),
}
}
#[test]
fn full_server_roundtrip_request_id_preserved() {
let original = make_request("ping", serde_json::json!(99), None);
let rmcp_rx = internal_to_rmcp_server_rx(&original).unwrap();
let rmcp_value = serde_json::to_value(&rmcp_rx).unwrap();
let id_seen_by_rmcp = rmcp_value["id"].clone();
assert_eq!(id_seen_by_rmcp, serde_json::json!(99));
let rmcp_tx =
ServerJsonRpcMessage::response(ServerResult::empty(()), RequestId::Number(99));
let response = rmcp_server_tx_to_internal(rmcp_tx).unwrap();
assert_eq!(response.id(), Some(&serde_json::json!(99)));
}
#[test]
fn full_client_roundtrip_response_id_preserved() {
let rmcp_tx = ClientJsonRpcMessage::response(ClientResult::empty(()), RequestId::Number(7));
let internal = rmcp_client_tx_to_internal(rmcp_tx).unwrap();
assert_eq!(internal.id(), Some(&serde_json::json!(7)));
let incoming_response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(7),
result: serde_json::json!({"tools": []}),
});
let rmcp_rx = internal_to_rmcp_client_rx(&incoming_response).unwrap();
let v = serde_json::to_value(&rmcp_rx).unwrap();
assert_eq!(v["id"], serde_json::json!(7));
assert_eq!(v["result"]["tools"], serde_json::json!([]));
}
#[test]
fn layer5_worker_uses_event_id_as_request_id() {
let event_id = "abc123def456";
let mut req = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(42),
method: "tools/list".to_string(),
params: None,
};
req.id = serde_json::json!(event_id);
assert_eq!(req.id, serde_json::json!("abc123def456"));
let msg = JsonRpcMessage::Request(req);
let rmcp_rx = internal_to_rmcp_server_rx(&msg).unwrap();
let v = serde_json::to_value(&rmcp_rx).unwrap();
assert_eq!(v["id"], serde_json::json!("abc123def456"));
let rmcp_tx = ServerJsonRpcMessage::response(
ServerResult::empty(()),
RequestId::String(std::sync::Arc::from(event_id)),
);
let response = rmcp_server_tx_to_internal(rmcp_tx).unwrap();
match response {
JsonRpcMessage::Response(r) => {
assert_eq!(r.id.as_str(), Some(event_id));
}
other => panic!("expected Response, got {other:?}"),
}
}
#[test]
fn layer5_worker_two_clients_no_collision() {
let event_id_a = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111";
let event_id_b = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222";
let mut req_a = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(1),
method: "tools/list".to_string(),
params: None,
};
let mut req_b = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(1),
method: "tools/list".to_string(),
params: None,
};
req_a.id = serde_json::json!(event_id_a);
req_b.id = serde_json::json!(event_id_b);
assert_ne!(req_a.id, req_b.id);
assert_eq!(req_a.id.as_str(), Some(event_id_a));
assert_eq!(req_b.id.as_str(), Some(event_id_b));
let rmcp_resp_a = ServerJsonRpcMessage::response(
ServerResult::empty(()),
RequestId::String(std::sync::Arc::from(event_id_a)),
);
let rmcp_resp_b = ServerJsonRpcMessage::response(
ServerResult::empty(()),
RequestId::String(std::sync::Arc::from(event_id_b)),
);
let resp_a = rmcp_server_tx_to_internal(rmcp_resp_a).unwrap();
let resp_b = rmcp_server_tx_to_internal(rmcp_resp_b).unwrap();
assert_eq!(resp_a.id().unwrap().as_str(), Some(event_id_a));
assert_eq!(resp_b.id().unwrap().as_str(), Some(event_id_b));
}
#[test]
fn layer5_error_response_carries_event_id() {
let event_id = "deadbeef";
let mut req = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(5),
method: "tools/call".to_string(),
params: None,
};
req.id = serde_json::json!(event_id);
let rmcp_err = ServerJsonRpcMessage::error(
rmcp::model::ErrorData {
code: rmcp::model::ErrorCode::METHOD_NOT_FOUND,
message: "Method not found".into(),
data: None,
},
RequestId::String(std::sync::Arc::from(event_id)),
);
let internal = rmcp_server_tx_to_internal(rmcp_err).unwrap();
match internal {
JsonRpcMessage::ErrorResponse(r) => {
assert_eq!(r.id.as_str(), Some(event_id));
}
other => panic!("expected ErrorResponse, got {other:?}"),
}
}
#[tokio::test]
async fn stateless_rmcp_roundtrip_over_mock_relay_preserves_correlation() {
let (server_pool, client_pool) = MockRelayPool::create_pair();
let server_pubkey = server_pool
.public_key()
.await
.expect("server mock relay pubkey")
.to_hex();
let server_transport = NostrServerTransport::with_relay_pool(
NostrServerTransportConfig::default()
.with_relay_urls(vec!["mock://relay".to_string()])
.with_encryption_mode(EncryptionMode::Disabled)
.with_gift_wrap_mode(GiftWrapMode::Optional),
Arc::new(server_pool),
)
.await
.expect("server transport");
let server_task = tokio::spawn(async move {
StatelessTestServer::new()
.serve(server_transport)
.await
.expect("server should start")
.waiting()
.await
.expect("server should keep running until aborted");
});
let client_transport = NostrClientTransport::with_relay_pool(
NostrClientTransportConfig::default()
.with_relay_urls(vec!["mock://relay".to_string()])
.with_server_pubkey(server_pubkey)
.with_encryption_mode(EncryptionMode::Disabled)
.with_gift_wrap_mode(GiftWrapMode::Optional)
.with_stateless(true),
Arc::new(client_pool),
)
.await
.expect("client transport");
let client = StatelessTestClient
.serve(client_transport)
.await
.expect("stateless client should start");
let peer_info = client
.peer_info()
.expect("peer info from emulated initialize");
assert_eq!(peer_info.server_info.name, "Emulated-Stateless-Server");
let tools = client
.list_all_tools()
.await
.expect("tools/list should succeed");
assert!(
tools.iter().any(|tool| tool.name == "echo"),
"expected echo tool from server"
);
let result = client
.call_tool(CallToolRequestParams {
name: "echo".into(),
arguments: serde_json::from_value(serde_json::json!({
"message": "hello from stateless test"
}))
.ok(),
meta: None,
task: None,
})
.await
.expect("tools/call should succeed");
let echoed = result
.content
.iter()
.find_map(|content| match &content.raw {
rmcp::model::RawContent::Text(text) => Some(text.text.clone()),
_ => None,
})
.expect("echo response text");
assert_eq!(echoed, "Echo: hello from stateless test");
client.cancel().await.expect("client cancel");
server_task.abort();
}
fn make_request(
method: &str,
id: serde_json::Value,
params: Option<serde_json::Value>,
) -> JsonRpcMessage {
JsonRpcMessage::Request(JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id,
method: method.to_string(),
params,
})
}
}