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
11 changes: 11 additions & 0 deletions crates/rmcp-macros/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ pub struct PromptAttribute {
pub description: Option<String>,
/// Arguments that can be passed to the prompt
pub arguments: Option<Expr>,
/// Optional icons for the prompt
pub icons: Option<Expr>,
}

pub struct ResolvedPromptAttribute {
pub name: String,
pub title: Option<String>,
pub description: Option<String>,
pub arguments: Expr,
pub icons: Option<Expr>,
}

impl ResolvedPromptAttribute {
Expand All @@ -32,6 +35,7 @@ impl ResolvedPromptAttribute {
description,
arguments,
title,
icons,
} = self;
let description = if let Some(description) = description {
quote! { Some(#description.into()) }
Expand All @@ -43,13 +47,19 @@ impl ResolvedPromptAttribute {
} else {
quote! { None }
};
let icons = if let Some(icons) = icons {
quote! { Some(#icons) }
} else {
quote! { None }
};
let tokens = quote! {
pub fn #fn_ident() -> rmcp::model::Prompt {
rmcp::model::Prompt {
name: #name.into(),
description: #description,
arguments: #arguments,
title: #title,
icons: #icons,
}
}
};
Expand Down Expand Up @@ -98,6 +108,7 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream>
description: description.clone(),
arguments: arguments.clone(),
title: attribute.title,
icons: attribute.icons,
};
let prompt_attr_fn = resolved_prompt_attr.into_fn(prompt_attr_fn_ident.clone())?;

Expand Down
11 changes: 11 additions & 0 deletions crates/rmcp-macros/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub struct ToolAttribute {
pub output_schema: Option<Expr>,
/// Optional additional tool information.
pub annotations: Option<ToolAnnotationsAttribute>,
/// Optional icons for the tool
pub icons: Option<Expr>,
}

pub struct ResolvedToolAttribute {
Expand All @@ -84,6 +86,7 @@ pub struct ResolvedToolAttribute {
pub input_schema: Expr,
pub output_schema: Option<Expr>,
pub annotations: Expr,
pub icons: Option<Expr>,
}

impl ResolvedToolAttribute {
Expand All @@ -95,6 +98,7 @@ impl ResolvedToolAttribute {
input_schema,
output_schema,
annotations,
icons,
} = self;
let description = if let Some(description) = description {
quote! { Some(#description.into()) }
Expand All @@ -111,6 +115,11 @@ impl ResolvedToolAttribute {
} else {
quote! { None }
};
let icons = if let Some(icons) = icons {
quote! { Some(#icons) }
} else {
quote! { None }
};
let tokens = quote! {
pub fn #fn_ident() -> rmcp::model::Tool {
rmcp::model::Tool {
Expand All @@ -120,6 +129,7 @@ impl ResolvedToolAttribute {
input_schema: #input_schema,
output_schema: #output_schema,
annotations: #annotations,
icons: #icons,
}
}
};
Expand Down Expand Up @@ -240,6 +250,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
output_schema: output_schema_expr,
annotations: annotations_expr,
title: attribute.title,
icons: attribute.icons,
};
let tool_attr_fn = resolved_tool_attr.into_fn(tool_attr_fn_ident)?;
// modify the the input function
Expand Down
135 changes: 135 additions & 0 deletions crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,13 +693,41 @@ impl Default for ClientInfo {
}
}

/// A URL pointing to an icon resource or a base64-encoded data URI.
///
/// Clients that support rendering icons MUST support at least the following MIME types:
/// - image/png - PNG images (safe, universal compatibility)
/// - image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)
///
/// Clients that support rendering icons SHOULD also support:
/// - image/svg+xml - SVG images (scalable but requires security precautions)
/// - image/webp - WebP images (modern, efficient format)
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Icon {
/// A standard URI pointing to an icon resource
pub src: String,
/// Optional override if the server's MIME type is missing or generic
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// Size specification (e.g., "48x48", "any" for SVG, or "48x48 96x96")
#[serde(skip_serializing_if = "Option::is_none")]
pub sizes: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Implementation {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website_url: Option<String>,
}

impl Default for Implementation {
Expand All @@ -714,6 +742,8 @@ impl Implementation {
name: env!("CARGO_CRATE_NAME").to_owned(),
title: None,
version: env!("CARGO_PKG_VERSION").to_owned(),
icons: None,
website_url: None,
}
}
}
Expand Down Expand Up @@ -1926,6 +1956,7 @@ mod tests {
assert_eq!(capabilities.tools.unwrap().list_changed, Some(true));
assert_eq!(server_info.name, "ExampleServer");
assert_eq!(server_info.version, "1.0.0");
assert_eq!(server_info.icons, None);
assert_eq!(instructions, None);
}
other => panic!("Expected InitializeResult, got {other:?}"),
Expand Down Expand Up @@ -2021,4 +2052,108 @@ mod tests {
let v2 = ProtocolVersion::V_2025_03_26;
assert!(v1 < v2);
}

#[test]
fn test_icon_serialization() {
let icon = Icon {
src: "https://example.com/icon.png".to_string(),
mime_type: Some("image/png".to_string()),
sizes: Some("48x48".to_string()),
};

let json = serde_json::to_value(&icon).unwrap();
assert_eq!(json["src"], "https://example.com/icon.png");
assert_eq!(json["mimeType"], "image/png");
assert_eq!(json["sizes"], "48x48");

// Test deserialization
let deserialized: Icon = serde_json::from_value(json).unwrap();
assert_eq!(deserialized, icon);
}

#[test]
fn test_icon_minimal() {
let icon = Icon {
src: "data:image/svg+xml;base64,PHN2Zy8+".to_string(),
mime_type: None,
sizes: None,
};

let json = serde_json::to_value(&icon).unwrap();
assert_eq!(json["src"], "data:image/svg+xml;base64,PHN2Zy8+");
assert!(json.get("mimeType").is_none());
assert!(json.get("sizes").is_none());
}

#[test]
fn test_implementation_with_icons() {
let implementation = Implementation {
name: "test-server".to_string(),
title: Some("Test Server".to_string()),
version: "1.0.0".to_string(),
icons: Some(vec![
Icon {
src: "https://example.com/icon.png".to_string(),
mime_type: Some("image/png".to_string()),
sizes: Some("48x48".to_string()),
},
Icon {
src: "https://example.com/icon.svg".to_string(),
mime_type: Some("image/svg+xml".to_string()),
sizes: Some("any".to_string()),
},
]),
website_url: Some("https://example.com".to_string()),
};

let json = serde_json::to_value(&implementation).unwrap();
assert_eq!(json["name"], "test-server");
assert_eq!(json["websiteUrl"], "https://example.com");
assert!(json["icons"].is_array());
assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png");
assert_eq!(json["icons"][1]["mimeType"], "image/svg+xml");
}

#[test]
fn test_backward_compatibility() {
// Test that old JSON without icons still deserializes correctly
let old_json = json!({
"name": "legacy-server",
"version": "0.9.0"
});

let implementation: Implementation = serde_json::from_value(old_json).unwrap();
assert_eq!(implementation.name, "legacy-server");
assert_eq!(implementation.version, "0.9.0");
assert_eq!(implementation.icons, None);
assert_eq!(implementation.website_url, None);
}

#[test]
fn test_initialize_with_icons() {
let init_result = InitializeResult {
protocol_version: ProtocolVersion::default(),
capabilities: ServerCapabilities::default(),
server_info: Implementation {
name: "icon-server".to_string(),
title: None,
version: "2.0.0".to_string(),
icons: Some(vec![Icon {
src: "https://example.com/server.png".to_string(),
mime_type: Some("image/png".to_string()),
sizes: None,
}]),
website_url: Some("https://docs.example.com".to_string()),
},
instructions: None,
};

let json = serde_json::to_value(&init_result).unwrap();
assert!(json["serverInfo"]["icons"].is_array());
assert_eq!(
json["serverInfo"]["icons"][0]["src"],
"https://example.com/server.png"
);
assert_eq!(json["serverInfo"]["websiteUrl"], "https://docs.example.com");
}
}
1 change: 1 addition & 0 deletions crates/rmcp/src/model/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ mod tests {
description: Some("A test file".to_string()),
mime_type: Some("text/plain".to_string()),
size: Some(100),
icons: None,
});

let json = serde_json::to_string(&resource_link).unwrap();
Expand Down
6 changes: 5 additions & 1 deletion crates/rmcp/src/model/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use base64::engine::{Engine, general_purpose::STANDARD as BASE64_STANDARD};
use serde::{Deserialize, Serialize};

use super::{
AnnotateAble, Annotations, RawEmbeddedResource, RawImageContent,
AnnotateAble, Annotations, Icon, RawEmbeddedResource, RawImageContent,
content::{EmbeddedResource, ImageContent},
resource::ResourceContents,
};
Expand All @@ -22,6 +22,9 @@ pub struct Prompt {
/// Optional arguments that can be passed to customize the prompt
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Vec<PromptArgument>>,
/// Optional list of icons for the prompt
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
}

impl Prompt {
Expand All @@ -40,6 +43,7 @@ impl Prompt {
title: None,
description: description.map(Into::into),
arguments,
icons: None,
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion crates/rmcp/src/model/resource.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};

use super::{Annotated, Meta};
use super::{Annotated, Icon, Meta};

/// Represents a resource in the extension with metadata
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
Expand All @@ -26,6 +26,9 @@ pub struct RawResource {
/// This can be used by Hosts to display file sizes and estimate context window us
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u32>,
/// Optional list of icons for the resource
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
}

pub type Resource = Annotated<RawResource>;
Expand Down Expand Up @@ -91,6 +94,7 @@ impl RawResource {
description: None,
mime_type: None,
size: None,
icons: None,
}
}
}
Expand All @@ -110,6 +114,7 @@ mod tests {
description: Some("Test resource".to_string()),
mime_type: Some("text/plain".to_string()),
size: Some(100),
icons: None,
};

let json = serde_json::to_string(&resource).unwrap();
Expand Down
6 changes: 5 additions & 1 deletion crates/rmcp/src/model/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use super::JsonObject;
use super::{Icon, JsonObject};

/// A tool that can be used by a model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand All @@ -29,6 +29,9 @@ pub struct Tool {
#[serde(skip_serializing_if = "Option::is_none")]
/// Optional additional tool information.
pub annotations: Option<ToolAnnotations>,
/// Optional list of icons for the tool
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
}

/// Additional properties describing a Tool to clients.
Expand Down Expand Up @@ -146,6 +149,7 @@ impl Tool {
input_schema: input_schema.into(),
output_schema: None,
annotations: None,
icons: None,
}
}

Expand Down
7 changes: 6 additions & 1 deletion crates/rmcp/tests/test_elicitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,8 @@ async fn test_initialize_request_with_elicitation() {
name: "test-client".to_string(),
version: "1.0.0".to_string(),
title: None,
website_url: None,
icons: None,
},
};

Expand Down Expand Up @@ -935,6 +937,8 @@ async fn test_capability_checking_logic() {
name: "test-client".to_string(),
version: "1.0.0".to_string(),
title: None,
website_url: None,
icons: None,
},
};

Expand All @@ -953,9 +957,10 @@ async fn test_capability_checking_logic() {
name: "test-client".to_string(),
version: "1.0.0".to_string(),
title: None,
website_url: None,
icons: None,
},
};

let supports_elicitation = client_without_capability.capabilities.elicitation.is_some();
assert!(!supports_elicitation);
}
Expand Down
Loading