diff --git a/src/core/types.rs b/src/core/types.rs index 12cd69b..5f329c2 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -110,6 +110,79 @@ impl ServerInfo { } } +// ── Profile metadata ──────────────────────────────────────────────── + +/// Nostr profile metadata for server identity (NIP-01 kind 0 / CEP-23). +/// +/// Opt-in profile that servers can publish so clients see a human-friendly +/// identity on the Nostr network. Serialized as the content of a kind 0 event. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ProfileMetadata { + /// Display name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Short description or bio. + #[serde(skip_serializing_if = "Option::is_none")] + pub about: Option, + /// Avatar / profile picture URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, + /// Banner image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub banner: Option, + /// Website URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + /// NIP-05 verification identifier (e.g. `user@example.com`). + #[serde(skip_serializing_if = "Option::is_none")] + pub nip05: Option, + /// Lightning address for payments (LUD-16). + #[serde(skip_serializing_if = "Option::is_none")] + pub lud16: Option, + /// Arbitrary additional fields preserved across round-trips. + #[serde(flatten)] + pub extra: HashMap, +} + +impl ProfileMetadata { + /// Set the display name. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + /// Set the description / bio. + pub fn with_about(mut self, about: impl Into) -> Self { + self.about = Some(about.into()); + self + } + /// Set the avatar URL. + pub fn with_picture(mut self, picture: impl Into) -> Self { + self.picture = Some(picture.into()); + self + } + /// Set the banner image URL. + pub fn with_banner(mut self, banner: impl Into) -> Self { + self.banner = Some(banner.into()); + self + } + /// Set the website URL. + pub fn with_website(mut self, website: impl Into) -> Self { + self.website = Some(website.into()); + self + } + /// Set the NIP-05 verification identifier. + pub fn with_nip05(mut self, nip05: impl Into) -> Self { + self.nip05 = Some(nip05.into()); + self + } + /// Set the Lightning address (LUD-16). + pub fn with_lud16(mut self, lud16: impl Into) -> Self { + self.lud16 = Some(lud16.into()); + self + } +} + // ── Client session ────────────────────────────────────────────────── /// Client session state tracked by the server transport. @@ -619,4 +692,67 @@ mod tests { session.update_activity(); assert!(session.last_activity > first); } + + #[test] + fn test_profile_metadata_serde_roundtrip() { + let meta = ProfileMetadata { + name: Some("My Server".to_string()), + about: Some("Does things".to_string()), + picture: Some("https://example.com/pic.png".to_string()), + banner: Some("https://example.com/banner.png".to_string()), + website: Some("https://example.com".to_string()), + nip05: Some("server@example.com".to_string()), + lud16: Some("server@getalby.com".to_string()), + extra: HashMap::new(), + }; + let json_str = serde_json::to_string(&meta).unwrap(); + let parsed: ProfileMetadata = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed.name, meta.name); + assert_eq!(parsed.about, meta.about); + assert_eq!(parsed.picture, meta.picture); + assert_eq!(parsed.banner, meta.banner); + assert_eq!(parsed.website, meta.website); + assert_eq!(parsed.nip05, meta.nip05); + assert_eq!(parsed.lud16, meta.lud16); + } + + #[test] + fn test_profile_metadata_default_serializes_empty() { + let meta = ProfileMetadata::default(); + let json_str = serde_json::to_string(&meta).unwrap(); + assert_eq!(json_str, "{}"); + } + + #[test] + fn test_profile_metadata_preserves_custom_fields() { + let json_str = r#"{"name":"Srv","custom_flag":true,"rank":42}"#; + let parsed: ProfileMetadata = serde_json::from_str(json_str).unwrap(); + assert_eq!(parsed.name, Some("Srv".to_string())); + assert_eq!(parsed.extra.get("custom_flag"), Some(&json!(true))); + assert_eq!(parsed.extra.get("rank"), Some(&json!(42))); + + let reserialized = serde_json::to_string(&parsed).unwrap(); + let reparsed: serde_json::Value = serde_json::from_str(&reserialized).unwrap(); + assert_eq!(reparsed["custom_flag"], json!(true)); + assert_eq!(reparsed["rank"], json!(42)); + } + + #[test] + fn test_profile_metadata_builder() { + let meta = ProfileMetadata::default() + .with_name("Test") + .with_about("Bio") + .with_picture("https://pic.url") + .with_banner("https://banner.url") + .with_website("https://web.url") + .with_nip05("user@example.com") + .with_lud16("user@getalby.com"); + assert_eq!(meta.name.as_deref(), Some("Test")); + assert_eq!(meta.about.as_deref(), Some("Bio")); + assert_eq!(meta.picture.as_deref(), Some("https://pic.url")); + assert_eq!(meta.banner.as_deref(), Some("https://banner.url")); + assert_eq!(meta.website.as_deref(), Some("https://web.url")); + assert_eq!(meta.nip05.as_deref(), Some("user@example.com")); + assert_eq!(meta.lud16.as_deref(), Some("user@getalby.com")); + } } diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs index d5ffa3a..87e76aa 100644 --- a/src/discovery/mod.rs +++ b/src/discovery/mod.rs @@ -42,12 +42,18 @@ pub struct ServerAnnouncement { pub pubkey: String, /// Parsed public key. pub pubkey_parsed: PublicKey, - /// Server information from the announcement content. + /// Server information extracted from the announcement content. pub server_info: ServerInfo, /// The Nostr event ID of the announcement. pub event_id: EventId, /// When the announcement was created. pub created_at: Timestamp, + /// MCP protocol version (present when content is a full `InitializeResult`). + pub protocol_version: Option, + /// Server capabilities (present when content is a full `InitializeResult`). + pub capabilities: Option, + /// Human-readable instructions (present when content is a full `InitializeResult`). + pub instructions: Option, } /// Discover MCP servers by fetching kind 11316 announcement events from relays. @@ -64,13 +70,17 @@ pub async fn discover_servers( let mut announcements = Vec::new(); for event in events { - let server_info: ServerInfo = serde_json::from_str(&event.content).unwrap_or_default(); + let (server_info, protocol_version, capabilities, instructions) = + parse_announcement_content(&event.content); announcements.push(ServerAnnouncement { pubkey: event.pubkey.to_hex(), pubkey_parsed: event.pubkey, server_info, event_id: event.id, created_at: event.created_at, + protocol_version, + capabilities, + instructions, }); } @@ -165,6 +175,72 @@ pub async fn discover_resource_templates_typed( // ── Internal ──────────────────────────────────────────────────────── +/// Parse kind 11316 event content, supporting two formats: +/// +/// - **New (InitializeResult):** `{ "protocolVersion": "…", "capabilities": {…}, +/// "serverInfo": {…}, "instructions": "…" }` — used when the server publishes +/// the full MCP InitializeResult as content. +/// - **Legacy (ServerInfo):** `{ "name": "…", "version": "…", … }` — the original +/// rs-sdk format where content is just `ServerInfo`. +fn parse_announcement_content( + content: &str, +) -> ( + ServerInfo, + Option, + Option, + Option, +) { + let Ok(value) = serde_json::from_str::(content) else { + return (ServerInfo::default(), None, None, None); + }; + + // Detect new format by the presence of "protocolVersion" (camelCase from rmcp). + if value.get("protocolVersion").is_some() { + let server_info = value + .get("serverInfo") + .map(server_info_from_implementation) + .unwrap_or_default(); + let protocol_version = value + .get("protocolVersion") + .and_then(|v| v.as_str()) + .map(String::from); + let capabilities = value.get("capabilities").cloned(); + let instructions = value + .get("instructions") + .and_then(|v| v.as_str()) + .map(String::from); + (server_info, protocol_version, capabilities, instructions) + } else { + // Legacy: content is a flat ServerInfo object. + let server_info = serde_json::from_value::(value).unwrap_or_default(); + (server_info, None, None, None) + } +} + +/// Map an rmcp `Implementation` JSON object to our `ServerInfo`. +/// +/// Field mapping: `name`→`name`, `version`→`version`, +/// `websiteUrl`→`website`, `description`→`about`. The `picture` field has no +/// equivalent in `Implementation` so it is left `None`. +fn server_info_from_implementation(val: &serde_json::Value) -> ServerInfo { + ServerInfo { + name: val.get("name").and_then(|v| v.as_str()).map(String::from), + version: val + .get("version") + .and_then(|v| v.as_str()) + .map(String::from), + website: val + .get("websiteUrl") + .and_then(|v| v.as_str()) + .map(String::from), + about: val + .get("description") + .and_then(|v| v.as_str()) + .map(String::from), + picture: None, + } +} + async fn fetch_list( client: &Arc, server_pubkey: &PublicKey, @@ -285,9 +361,90 @@ mod tests { ) .unwrap(), created_at: Timestamp::now(), + protocol_version: None, + capabilities: None, + instructions: None, }; assert_eq!(announcement.pubkey, pubkey.to_hex()); assert_eq!(announcement.server_info.name, Some("Test".to_string())); } + + #[test] + fn test_parse_announcement_content_legacy_format() { + let content = r#"{"name":"Legacy Server","version":"0.1.0","about":"Old format"}"#; + let (info, pv, caps, instr) = super::parse_announcement_content(content); + assert_eq!(info.name.as_deref(), Some("Legacy Server")); + assert_eq!(info.version.as_deref(), Some("0.1.0")); + assert_eq!(info.about.as_deref(), Some("Old format")); + assert!(pv.is_none()); + assert!(caps.is_none()); + assert!(instr.is_none()); + } + + #[test] + fn test_parse_announcement_content_initialize_result_format() { + let content = r#"{ + "protocolVersion": "2025-03-26", + "capabilities": { + "tools": { "listChanged": true }, + "resources": { "subscribe": false, "listChanged": false } + }, + "serverInfo": { + "name": "NewServer", + "version": "2.0.0", + "description": "Full InitializeResult", + "websiteUrl": "https://example.com" + }, + "instructions": "Use tool X for Y" + }"#; + let (info, pv, caps, instr) = super::parse_announcement_content(content); + + assert_eq!(info.name.as_deref(), Some("NewServer")); + assert_eq!(info.version.as_deref(), Some("2.0.0")); + assert_eq!(info.about.as_deref(), Some("Full InitializeResult")); + assert_eq!(info.website.as_deref(), Some("https://example.com")); + assert!(info.picture.is_none()); + + assert_eq!(pv.as_deref(), Some("2025-03-26")); + assert!(caps.is_some()); + let caps = caps.unwrap(); + assert!(caps.get("tools").is_some()); + assert_eq!(instr.as_deref(), Some("Use tool X for Y")); + } + + #[test] + fn test_parse_announcement_content_invalid_json() { + let (info, pv, caps, instr) = super::parse_announcement_content("not json"); + assert!(info.name.is_none()); + assert!(pv.is_none()); + assert!(caps.is_none()); + assert!(instr.is_none()); + } + + #[test] + fn test_parse_announcement_content_empty_object() { + let (info, pv, caps, instr) = super::parse_announcement_content("{}"); + assert!(info.name.is_none()); + assert!(pv.is_none()); + assert!(caps.is_none()); + assert!(instr.is_none()); + } + + #[test] + fn test_server_info_from_implementation() { + let val = serde_json::json!({ + "name": "TestImpl", + "version": "3.0", + "title": "Fancy Title", + "description": "Impl description", + "websiteUrl": "https://impl.example.com" + }); + let info = super::server_info_from_implementation(&val); + assert_eq!(info.name.as_deref(), Some("TestImpl")); + assert_eq!(info.version.as_deref(), Some("3.0")); + assert_eq!(info.website.as_deref(), Some("https://impl.example.com")); + assert_eq!(info.about.as_deref(), Some("Impl description")); + assert!(info.picture.is_none()); + } } diff --git a/src/lib.rs b/src/lib.rs index 493a901..d8aab47 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,7 +62,7 @@ pub use core::error::{Error, Result}; pub use core::types::{ CapabilityExclusion, ClientSession, EncryptionMode, GiftWrapMode, JsonRpcError, JsonRpcErrorResponse, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, - ServerInfo, + ProfileMetadata, ServerInfo, }; // ── Discovery ────────────────────────────────────────────────────────