Skip to main content

contextvm_sdk/discovery/
mod.rs

1//! Server discovery for the ContextVM protocol.
2//!
3//! Discover MCP servers and their capabilities (tools, resources, prompts)
4//! published as Nostr events on relays.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use contextvm_sdk::discovery;
10//! use contextvm_sdk::signer;
11//!
12//! # async fn example() -> contextvm_sdk::Result<()> {
13//! let keys = signer::generate();
14//! let relay_pool = contextvm_sdk::RelayPool::new(keys).await?;
15//! let relays = vec!["wss://relay.damus.io".to_string()];
16//! relay_pool.connect(&relays).await?;
17//! let client = relay_pool.client();
18//!
19//! let servers = discovery::discover_servers(client, &relays).await?;
20//! for server in &servers {
21//!     println!("Found server: {} ({:?})", server.pubkey, server.server_info.name);
22//!     let tools = discovery::discover_tools(client, &server.pubkey_parsed, &relays).await?;
23//!     println!("  Tools: {:?}", tools);
24//! }
25//! # Ok(())
26//! # }
27//! ```
28
29use std::sync::Arc;
30use std::time::Duration;
31
32use nostr_sdk::prelude::*;
33
34use crate::core::constants::*;
35use crate::core::error::{Error, Result};
36use crate::core::types::ServerInfo;
37
38/// A discovered server announcement.
39#[derive(Debug, Clone)]
40pub struct ServerAnnouncement {
41    /// Server public key (hex).
42    pub pubkey: String,
43    /// Parsed public key.
44    pub pubkey_parsed: PublicKey,
45    /// Server information extracted from the announcement content.
46    pub server_info: ServerInfo,
47    /// The Nostr event ID of the announcement.
48    pub event_id: EventId,
49    /// When the announcement was created.
50    pub created_at: Timestamp,
51    /// MCP protocol version (present when content is a full `InitializeResult`).
52    pub protocol_version: Option<String>,
53    /// Server capabilities (present when content is a full `InitializeResult`).
54    pub capabilities: Option<serde_json::Value>,
55    /// Human-readable instructions (present when content is a full `InitializeResult`).
56    pub instructions: Option<String>,
57}
58
59/// Discover MCP servers by fetching kind 11316 announcement events from relays.
60pub async fn discover_servers(
61    client: &Arc<Client>,
62    _relay_urls: &[String],
63) -> Result<Vec<ServerAnnouncement>> {
64    let filter = Filter::new().kind(Kind::Custom(SERVER_ANNOUNCEMENT_KIND));
65
66    let events = client
67        .fetch_events(filter, Duration::from_secs(10))
68        .await
69        .map_err(|e| Error::Transport(e.to_string()))?;
70
71    let mut announcements = Vec::new();
72    for event in events {
73        let (server_info, protocol_version, capabilities, instructions) =
74            parse_announcement_content(&event.content);
75        announcements.push(ServerAnnouncement {
76            pubkey: event.pubkey.to_hex(),
77            pubkey_parsed: event.pubkey,
78            server_info,
79            event_id: event.id,
80            created_at: event.created_at,
81            protocol_version,
82            capabilities,
83            instructions,
84        });
85    }
86
87    Ok(announcements)
88}
89
90/// Discover tools published by a specific server (kind 11317).
91pub async fn discover_tools(
92    client: &Arc<Client>,
93    server_pubkey: &PublicKey,
94    _relay_urls: &[String],
95) -> Result<Vec<serde_json::Value>> {
96    fetch_list(client, server_pubkey, TOOLS_LIST_KIND, "tools").await
97}
98
99/// Discover resources published by a specific server (kind 11318).
100pub async fn discover_resources(
101    client: &Arc<Client>,
102    server_pubkey: &PublicKey,
103    _relay_urls: &[String],
104) -> Result<Vec<serde_json::Value>> {
105    fetch_list(client, server_pubkey, RESOURCES_LIST_KIND, "resources").await
106}
107
108/// Discover prompts published by a specific server (kind 11320).
109pub async fn discover_prompts(
110    client: &Arc<Client>,
111    server_pubkey: &PublicKey,
112    _relay_urls: &[String],
113) -> Result<Vec<serde_json::Value>> {
114    fetch_list(client, server_pubkey, PROMPTS_LIST_KIND, "prompts").await
115}
116
117/// Discover resource templates published by a specific server (kind 11319).
118pub async fn discover_resource_templates(
119    client: &Arc<Client>,
120    server_pubkey: &PublicKey,
121    _relay_urls: &[String],
122) -> Result<Vec<serde_json::Value>> {
123    fetch_list(
124        client,
125        server_pubkey,
126        RESOURCETEMPLATES_LIST_KIND,
127        "resourceTemplates",
128    )
129    .await
130}
131
132/// Discover tools and parse them into rmcp typed descriptors.
133#[cfg(feature = "rmcp")]
134pub async fn discover_tools_typed(
135    client: &Arc<Client>,
136    server_pubkey: &PublicKey,
137    relay_urls: &[String],
138) -> Result<Vec<rmcp::model::Tool>> {
139    let raw = discover_tools(client, server_pubkey, relay_urls).await?;
140    parse_typed_list(raw)
141}
142
143/// Discover resources and parse them into rmcp typed descriptors.
144#[cfg(feature = "rmcp")]
145pub async fn discover_resources_typed(
146    client: &Arc<Client>,
147    server_pubkey: &PublicKey,
148    relay_urls: &[String],
149) -> Result<Vec<rmcp::model::Resource>> {
150    let raw = discover_resources(client, server_pubkey, relay_urls).await?;
151    parse_typed_list(raw)
152}
153
154/// Discover prompts and parse them into rmcp typed descriptors.
155#[cfg(feature = "rmcp")]
156pub async fn discover_prompts_typed(
157    client: &Arc<Client>,
158    server_pubkey: &PublicKey,
159    relay_urls: &[String],
160) -> Result<Vec<rmcp::model::Prompt>> {
161    let raw = discover_prompts(client, server_pubkey, relay_urls).await?;
162    parse_typed_list(raw)
163}
164
165/// Discover resource templates and parse them into rmcp typed descriptors.
166#[cfg(feature = "rmcp")]
167pub async fn discover_resource_templates_typed(
168    client: &Arc<Client>,
169    server_pubkey: &PublicKey,
170    relay_urls: &[String],
171) -> Result<Vec<rmcp::model::ResourceTemplate>> {
172    let raw = discover_resource_templates(client, server_pubkey, relay_urls).await?;
173    parse_typed_list(raw)
174}
175
176// ── Internal ────────────────────────────────────────────────────────
177
178/// Parse kind 11316 event content, supporting two formats:
179///
180/// - **New (InitializeResult):** `{ "protocolVersion": "…", "capabilities": {…},
181///   "serverInfo": {…}, "instructions": "…" }` — used when the server publishes
182///   the full MCP InitializeResult as content.
183/// - **Legacy (ServerInfo):** `{ "name": "…", "version": "…", … }` — the original
184///   rs-sdk format where content is just `ServerInfo`.
185fn parse_announcement_content(
186    content: &str,
187) -> (
188    ServerInfo,
189    Option<String>,
190    Option<serde_json::Value>,
191    Option<String>,
192) {
193    let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
194        return (ServerInfo::default(), None, None, None);
195    };
196
197    // Detect new format by the presence of "protocolVersion" (camelCase from rmcp).
198    if value.get("protocolVersion").is_some() {
199        let server_info = value
200            .get("serverInfo")
201            .map(server_info_from_implementation)
202            .unwrap_or_default();
203        let protocol_version = value
204            .get("protocolVersion")
205            .and_then(|v| v.as_str())
206            .map(String::from);
207        let capabilities = value.get("capabilities").cloned();
208        let instructions = value
209            .get("instructions")
210            .and_then(|v| v.as_str())
211            .map(String::from);
212        (server_info, protocol_version, capabilities, instructions)
213    } else {
214        // Legacy: content is a flat ServerInfo object.
215        let server_info = serde_json::from_value::<ServerInfo>(value).unwrap_or_default();
216        (server_info, None, None, None)
217    }
218}
219
220/// Map an rmcp `Implementation` JSON object to our `ServerInfo`.
221///
222/// Field mapping: `name`→`name`, `version`→`version`,
223/// `websiteUrl`→`website`, `description`→`about`. The `picture` field has no
224/// equivalent in `Implementation` so it is left `None`.
225fn server_info_from_implementation(val: &serde_json::Value) -> ServerInfo {
226    ServerInfo {
227        name: val.get("name").and_then(|v| v.as_str()).map(String::from),
228        version: val
229            .get("version")
230            .and_then(|v| v.as_str())
231            .map(String::from),
232        website: val
233            .get("websiteUrl")
234            .and_then(|v| v.as_str())
235            .map(String::from),
236        about: val
237            .get("description")
238            .and_then(|v| v.as_str())
239            .map(String::from),
240        picture: None,
241    }
242}
243
244async fn fetch_list(
245    client: &Arc<Client>,
246    server_pubkey: &PublicKey,
247    kind: u16,
248    list_key: &str,
249) -> Result<Vec<serde_json::Value>> {
250    let filter = Filter::new()
251        .kind(Kind::Custom(kind))
252        .author(*server_pubkey);
253
254    let events = client
255        .fetch_events(filter, Duration::from_secs(10))
256        .await
257        .map_err(|e| Error::Transport(e.to_string()))?;
258
259    // Take the most recent event
260    let event = match events.into_iter().next() {
261        Some(e) => e,
262        None => return Ok(Vec::new()),
263    };
264
265    let parsed: serde_json::Value =
266        serde_json::from_str(&event.content).map_err(|e| Error::Other(e.to_string()))?;
267
268    Ok(parsed
269        .get(list_key)
270        .and_then(|v| v.as_array())
271        .cloned()
272        .unwrap_or_default())
273}
274
275#[cfg(feature = "rmcp")]
276fn parse_typed_list<T>(raw: Vec<serde_json::Value>) -> Result<Vec<T>>
277where
278    T: serde::de::DeserializeOwned,
279{
280    let mut parsed = Vec::new();
281    for item in raw {
282        let value = serde_json::from_value(item)
283            .map_err(|e| Error::Other(format!("Failed to parse typed discovery item: {e}")))?;
284        parsed.push(value);
285    }
286    Ok(parsed)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::core::types::ServerInfo;
293
294    #[test]
295    fn test_server_info_serialization() {
296        let info = ServerInfo {
297            name: Some("Test Server".to_string()),
298            version: Some("1.0.0".to_string()),
299            about: Some("A test MCP server".to_string()),
300            website: Some("https://example.com".to_string()),
301            picture: Some("https://example.com/pic.png".to_string()),
302        };
303
304        let json = serde_json::to_string(&info).unwrap();
305        let parsed: ServerInfo = serde_json::from_str(&json).unwrap();
306
307        assert_eq!(parsed.name, Some("Test Server".to_string()));
308        assert_eq!(parsed.version, Some("1.0.0".to_string()));
309        assert_eq!(parsed.about, Some("A test MCP server".to_string()));
310        assert_eq!(parsed.website, Some("https://example.com".to_string()));
311        assert_eq!(
312            parsed.picture,
313            Some("https://example.com/pic.png".to_string())
314        );
315    }
316
317    #[test]
318    fn test_server_info_default() {
319        let info = ServerInfo::default();
320        assert!(info.name.is_none());
321        assert!(info.version.is_none());
322        assert!(info.about.is_none());
323        assert!(info.website.is_none());
324        assert!(info.picture.is_none());
325    }
326
327    #[test]
328    fn test_server_info_partial_serialization() {
329        let info = ServerInfo {
330            name: Some("Minimal".to_string()),
331            ..Default::default()
332        };
333
334        let json = serde_json::to_string(&info).unwrap();
335        // Optional fields should be skipped
336        assert!(!json.contains("version"));
337        assert!(!json.contains("about"));
338        assert!(json.contains("Minimal"));
339    }
340
341    #[test]
342    fn test_server_info_deserialization_from_empty() {
343        let info: ServerInfo = serde_json::from_str("{}").unwrap();
344        assert!(info.name.is_none());
345    }
346
347    #[test]
348    fn test_server_announcement_struct() {
349        let keys = nostr_sdk::Keys::generate();
350        let pubkey = keys.public_key();
351
352        let announcement = ServerAnnouncement {
353            pubkey: pubkey.to_hex(),
354            pubkey_parsed: pubkey,
355            server_info: ServerInfo {
356                name: Some("Test".to_string()),
357                ..Default::default()
358            },
359            event_id: EventId::from_hex(
360                "0000000000000000000000000000000000000000000000000000000000000001",
361            )
362            .unwrap(),
363            created_at: Timestamp::now(),
364            protocol_version: None,
365            capabilities: None,
366            instructions: None,
367        };
368
369        assert_eq!(announcement.pubkey, pubkey.to_hex());
370        assert_eq!(announcement.server_info.name, Some("Test".to_string()));
371    }
372
373    #[test]
374    fn test_parse_announcement_content_legacy_format() {
375        let content = r#"{"name":"Legacy Server","version":"0.1.0","about":"Old format"}"#;
376        let (info, pv, caps, instr) = super::parse_announcement_content(content);
377        assert_eq!(info.name.as_deref(), Some("Legacy Server"));
378        assert_eq!(info.version.as_deref(), Some("0.1.0"));
379        assert_eq!(info.about.as_deref(), Some("Old format"));
380        assert!(pv.is_none());
381        assert!(caps.is_none());
382        assert!(instr.is_none());
383    }
384
385    #[test]
386    fn test_parse_announcement_content_initialize_result_format() {
387        let content = r#"{
388            "protocolVersion": "2025-03-26",
389            "capabilities": {
390                "tools": { "listChanged": true },
391                "resources": { "subscribe": false, "listChanged": false }
392            },
393            "serverInfo": {
394                "name": "NewServer",
395                "version": "2.0.0",
396                "description": "Full InitializeResult",
397                "websiteUrl": "https://example.com"
398            },
399            "instructions": "Use tool X for Y"
400        }"#;
401        let (info, pv, caps, instr) = super::parse_announcement_content(content);
402
403        assert_eq!(info.name.as_deref(), Some("NewServer"));
404        assert_eq!(info.version.as_deref(), Some("2.0.0"));
405        assert_eq!(info.about.as_deref(), Some("Full InitializeResult"));
406        assert_eq!(info.website.as_deref(), Some("https://example.com"));
407        assert!(info.picture.is_none());
408
409        assert_eq!(pv.as_deref(), Some("2025-03-26"));
410        assert!(caps.is_some());
411        let caps = caps.unwrap();
412        assert!(caps.get("tools").is_some());
413        assert_eq!(instr.as_deref(), Some("Use tool X for Y"));
414    }
415
416    #[test]
417    fn test_parse_announcement_content_invalid_json() {
418        let (info, pv, caps, instr) = super::parse_announcement_content("not json");
419        assert!(info.name.is_none());
420        assert!(pv.is_none());
421        assert!(caps.is_none());
422        assert!(instr.is_none());
423    }
424
425    #[test]
426    fn test_parse_announcement_content_empty_object() {
427        let (info, pv, caps, instr) = super::parse_announcement_content("{}");
428        assert!(info.name.is_none());
429        assert!(pv.is_none());
430        assert!(caps.is_none());
431        assert!(instr.is_none());
432    }
433
434    #[test]
435    fn test_server_info_from_implementation() {
436        let val = serde_json::json!({
437            "name": "TestImpl",
438            "version": "3.0",
439            "title": "Fancy Title",
440            "description": "Impl description",
441            "websiteUrl": "https://impl.example.com"
442        });
443        let info = super::server_info_from_implementation(&val);
444        assert_eq!(info.name.as_deref(), Some("TestImpl"));
445        assert_eq!(info.version.as_deref(), Some("3.0"));
446        assert_eq!(info.website.as_deref(), Some("https://impl.example.com"));
447        assert_eq!(info.about.as_deref(), Some("Impl description"));
448        assert!(info.picture.is_none());
449    }
450}