contextvm-sdk 0.1.1

Rust SDK for the ContextVM protocol — MCP over Nostr
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! End-to-end pipeline tests for the rmcp ↔ Nostr transport integration.
//!
//! These tests verify every step of the message journey without requiring a live
//! relay connection:
//!
//! ```text
//! Nostr event content (JSON string)
//!   → serializers::nostr_event_to_mcp_message   [Layer 1: deserialise]
//!   → internal_to_rmcp_server_rx                [Layer 2: type bridge]
//!   → (rmcp handler processes it)               [Layer 3: rmcp dispatch – simulated]
//!   → rmcp_server_tx_to_internal                [Layer 4: type bridge back]
//!   → send_response (event_id correlation)      [Layer 5: route back to Nostr – mocked]
//! ```

#[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 {}

    // ── Layer 1: Nostr event content → JsonRpcMessage ──────────────────────

    #[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() {
        // DESIGN NOTE: nostr_event_to_mcp_message uses raw serde deserialization —
        // it does NOT reject invalid jsonrpc versions.  Version enforcement happens
        // one layer up in base.rs via validate_message(), which IS tested separately
        // in core::validation::tests::test_invalid_version and
        // transport::base::tests::test_convert_event_to_mcp_invalid_jsonrpc_version.
        //
        // A message with jsonrpc "1.0" will parse successfully at the serializer
        // layer because JsonRpcRequest accepts any String for the jsonrpc field.
        let content = r#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#;
        // It parses — the struct captures jsonrpc as a plain String.
        let msg = serializers::nostr_event_to_mcp_message(content);
        // We don't assert None here; rejection happens in base.rs, not here.
        // What we DO assert: if it parsed, the method and id are intact.
        if let Some(msg) = msg {
            assert_eq!(msg.method(), Some("ping"));
        }
        // The real rejection path is covered by:
        //   transport::base::tests::test_convert_event_to_mcp_invalid_jsonrpc_version
    }

    // ── Layer 2: JsonRpcMessage → rmcp RxJsonRpcMessage (server) ───────────

    #[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");
    }

    // ── Layer 3+4: Simulated handler → rmcp response → internal ────────────

    #[test]
    fn layer4_rmcp_ping_response_roundtrip_number_id() {
        // Simulate rmcp handler producing a ping response
        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:?}"),
        }
    }

    // ── Full roundtrip: internal → rmcp → internal ──────────────────────────

    #[test]
    fn full_server_roundtrip_request_id_preserved() {
        // Layer 2: convert incoming request to rmcp
        let original = make_request("ping", serde_json::json!(99), None);
        let rmcp_rx = internal_to_rmcp_server_rx(&original).unwrap();

        // Extract the ID that rmcp sees
        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));

        // Layer 4: rmcp produces a response with the same ID echoed back
        let rmcp_tx =
            ServerJsonRpcMessage::response(ServerResult::empty(()), RequestId::Number(99));
        let response = rmcp_server_tx_to_internal(rmcp_tx).unwrap();

        // The response ID must equal the original request ID
        assert_eq!(response.id(), Some(&serde_json::json!(99)));
    }

    #[test]
    fn full_client_roundtrip_response_id_preserved() {
        // Client side: rmcp produces an outbound request
        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)));

        // And an incoming server response converts to rmcp correctly
        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!([]));
    }

    // ── Layer 5: event_id-based request correlation (mirrors NostrServerWorker) ──

    #[test]
    fn layer5_worker_uses_event_id_as_request_id() {
        // Simulate the worker rewriting req.id to the Nostr event_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,
        };

        // Worker inbound path: rewrite id to event_id
        req.id = serde_json::json!(event_id);
        assert_eq!(req.id, serde_json::json!("abc123def456"));

        // Convert through rmcp bridge — ID must survive the roundtrip
        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"));

        // Simulate rmcp handler echoing the event_id back in the response
        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();

        // The response ID is the event_id — worker passes it directly to send_response
        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() {
        // Two clients both send requests with id: 1.  The worker rewrites each
        // to its unique Nostr event_id, so no collision occurs.
        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,
        };

        // Worker rewrites both to their respective event IDs
        req_a.id = serde_json::json!(event_id_a);
        req_b.id = serde_json::json!(event_id_b);

        // After rewrite, the IDs are distinct even though both clients sent id: 1
        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));

        // Responses echo back the event_id — each routes to the correct client
        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();

        // Each response carries its own event_id — no cross-wiring
        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() {
        // Error responses also carry the event_id for routing.
        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);

        // rmcp handler returns an error with the rewritten 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();
    }

    // ── Helper ──────────────────────────────────────────────────────────────

    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,
        })
    }
}