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
136 changes: 136 additions & 0 deletions src/core/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Short description or bio.
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
/// Avatar / profile picture URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
/// Banner image URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub banner: Option<String>,
/// Website URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
/// NIP-05 verification identifier (e.g. `user@example.com`).
#[serde(skip_serializing_if = "Option::is_none")]
pub nip05: Option<String>,
/// Lightning address for payments (LUD-16).
#[serde(skip_serializing_if = "Option::is_none")]
pub lud16: Option<String>,
/// Arbitrary additional fields preserved across round-trips.
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}

impl ProfileMetadata {
/// Set the display name.
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Set the description / bio.
pub fn with_about(mut self, about: impl Into<String>) -> Self {
self.about = Some(about.into());
self
}
/// Set the avatar URL.
pub fn with_picture(mut self, picture: impl Into<String>) -> Self {
self.picture = Some(picture.into());
self
}
/// Set the banner image URL.
pub fn with_banner(mut self, banner: impl Into<String>) -> Self {
self.banner = Some(banner.into());
self
}
/// Set the website URL.
pub fn with_website(mut self, website: impl Into<String>) -> Self {
self.website = Some(website.into());
self
}
/// Set the NIP-05 verification identifier.
pub fn with_nip05(mut self, nip05: impl Into<String>) -> Self {
self.nip05 = Some(nip05.into());
self
}
/// Set the Lightning address (LUD-16).
pub fn with_lud16(mut self, lud16: impl Into<String>) -> Self {
self.lud16 = Some(lud16.into());
self
}
}

// ── Client session ──────────────────────────────────────────────────

/// Client session state tracked by the server transport.
Expand Down Expand Up @@ -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"));
}
}
161 changes: 159 additions & 2 deletions src/discovery/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Server capabilities (present when content is a full `InitializeResult`).
pub capabilities: Option<serde_json::Value>,
/// Human-readable instructions (present when content is a full `InitializeResult`).
pub instructions: Option<String>,
}

/// Discover MCP servers by fetching kind 11316 announcement events from relays.
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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<String>,
Option<serde_json::Value>,
Option<String>,
) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(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::<ServerInfo>(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<Client>,
server_pubkey: &PublicKey,
Expand Down Expand Up @@ -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());
}
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────
Expand Down
Loading