Skip to content

Commit b57aa47

Browse files
authored
feat(SEP-973): add support for icons and websiteUrl across relevant types (#432)
1 parent 6fbd941 commit b57aa47

File tree

15 files changed

+611
-63
lines changed

15 files changed

+611
-63
lines changed

crates/rmcp-macros/src/prompt.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ pub struct PromptAttribute {
1616
pub description: Option<String>,
1717
/// Arguments that can be passed to the prompt
1818
pub arguments: Option<Expr>,
19+
/// Optional icons for the prompt
20+
pub icons: Option<Expr>,
1921
}
2022

2123
pub struct ResolvedPromptAttribute {
2224
pub name: String,
2325
pub title: Option<String>,
2426
pub description: Option<String>,
2527
pub arguments: Expr,
28+
pub icons: Option<Expr>,
2629
}
2730

2831
impl ResolvedPromptAttribute {
@@ -32,6 +35,7 @@ impl ResolvedPromptAttribute {
3235
description,
3336
arguments,
3437
title,
38+
icons,
3539
} = self;
3640
let description = if let Some(description) = description {
3741
quote! { Some(#description.into()) }
@@ -43,13 +47,19 @@ impl ResolvedPromptAttribute {
4347
} else {
4448
quote! { None }
4549
};
50+
let icons = if let Some(icons) = icons {
51+
quote! { Some(#icons) }
52+
} else {
53+
quote! { None }
54+
};
4655
let tokens = quote! {
4756
pub fn #fn_ident() -> rmcp::model::Prompt {
4857
rmcp::model::Prompt {
4958
name: #name.into(),
5059
description: #description,
5160
arguments: #arguments,
5261
title: #title,
62+
icons: #icons,
5363
}
5464
}
5565
};
@@ -98,6 +108,7 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream>
98108
description: description.clone(),
99109
arguments: arguments.clone(),
100110
title: attribute.title,
111+
icons: attribute.icons,
101112
};
102113
let prompt_attr_fn = resolved_prompt_attr.into_fn(prompt_attr_fn_ident.clone())?;
103114

crates/rmcp-macros/src/tool.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ pub struct ToolAttribute {
7575
pub output_schema: Option<Expr>,
7676
/// Optional additional tool information.
7777
pub annotations: Option<ToolAnnotationsAttribute>,
78+
/// Optional icons for the tool
79+
pub icons: Option<Expr>,
7880
}
7981

8082
pub struct ResolvedToolAttribute {
@@ -84,6 +86,7 @@ pub struct ResolvedToolAttribute {
8486
pub input_schema: Expr,
8587
pub output_schema: Option<Expr>,
8688
pub annotations: Expr,
89+
pub icons: Option<Expr>,
8790
}
8891

8992
impl ResolvedToolAttribute {
@@ -95,6 +98,7 @@ impl ResolvedToolAttribute {
9598
input_schema,
9699
output_schema,
97100
annotations,
101+
icons,
98102
} = self;
99103
let description = if let Some(description) = description {
100104
quote! { Some(#description.into()) }
@@ -111,6 +115,11 @@ impl ResolvedToolAttribute {
111115
} else {
112116
quote! { None }
113117
};
118+
let icons = if let Some(icons) = icons {
119+
quote! { Some(#icons) }
120+
} else {
121+
quote! { None }
122+
};
114123
let tokens = quote! {
115124
pub fn #fn_ident() -> rmcp::model::Tool {
116125
rmcp::model::Tool {
@@ -120,6 +129,7 @@ impl ResolvedToolAttribute {
120129
input_schema: #input_schema,
121130
output_schema: #output_schema,
122131
annotations: #annotations,
132+
icons: #icons,
123133
}
124134
}
125135
};
@@ -240,6 +250,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
240250
output_schema: output_schema_expr,
241251
annotations: annotations_expr,
242252
title: attribute.title,
253+
icons: attribute.icons,
243254
};
244255
let tool_attr_fn = resolved_tool_attr.into_fn(tool_attr_fn_ident)?;
245256
// modify the the input function

crates/rmcp/src/model.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,13 +693,41 @@ impl Default for ClientInfo {
693693
}
694694
}
695695

696+
/// A URL pointing to an icon resource or a base64-encoded data URI.
697+
///
698+
/// Clients that support rendering icons MUST support at least the following MIME types:
699+
/// - image/png - PNG images (safe, universal compatibility)
700+
/// - image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)
701+
///
702+
/// Clients that support rendering icons SHOULD also support:
703+
/// - image/svg+xml - SVG images (scalable but requires security precautions)
704+
/// - image/webp - WebP images (modern, efficient format)
696705
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
706+
#[serde(rename_all = "camelCase")]
707+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
708+
pub struct Icon {
709+
/// A standard URI pointing to an icon resource
710+
pub src: String,
711+
/// Optional override if the server's MIME type is missing or generic
712+
#[serde(skip_serializing_if = "Option::is_none")]
713+
pub mime_type: Option<String>,
714+
/// Size specification (e.g., "48x48", "any" for SVG, or "48x48 96x96")
715+
#[serde(skip_serializing_if = "Option::is_none")]
716+
pub sizes: Option<String>,
717+
}
718+
719+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
720+
#[serde(rename_all = "camelCase")]
697721
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
698722
pub struct Implementation {
699723
pub name: String,
700724
#[serde(skip_serializing_if = "Option::is_none")]
701725
pub title: Option<String>,
702726
pub version: String,
727+
#[serde(skip_serializing_if = "Option::is_none")]
728+
pub icons: Option<Vec<Icon>>,
729+
#[serde(skip_serializing_if = "Option::is_none")]
730+
pub website_url: Option<String>,
703731
}
704732

705733
impl Default for Implementation {
@@ -714,6 +742,8 @@ impl Implementation {
714742
name: env!("CARGO_CRATE_NAME").to_owned(),
715743
title: None,
716744
version: env!("CARGO_PKG_VERSION").to_owned(),
745+
icons: None,
746+
website_url: None,
717747
}
718748
}
719749
}
@@ -1926,6 +1956,7 @@ mod tests {
19261956
assert_eq!(capabilities.tools.unwrap().list_changed, Some(true));
19271957
assert_eq!(server_info.name, "ExampleServer");
19281958
assert_eq!(server_info.version, "1.0.0");
1959+
assert_eq!(server_info.icons, None);
19291960
assert_eq!(instructions, None);
19301961
}
19311962
other => panic!("Expected InitializeResult, got {other:?}"),
@@ -2021,4 +2052,108 @@ mod tests {
20212052
let v2 = ProtocolVersion::V_2025_03_26;
20222053
assert!(v1 < v2);
20232054
}
2055+
2056+
#[test]
2057+
fn test_icon_serialization() {
2058+
let icon = Icon {
2059+
src: "https://example.com/icon.png".to_string(),
2060+
mime_type: Some("image/png".to_string()),
2061+
sizes: Some("48x48".to_string()),
2062+
};
2063+
2064+
let json = serde_json::to_value(&icon).unwrap();
2065+
assert_eq!(json["src"], "https://example.com/icon.png");
2066+
assert_eq!(json["mimeType"], "image/png");
2067+
assert_eq!(json["sizes"], "48x48");
2068+
2069+
// Test deserialization
2070+
let deserialized: Icon = serde_json::from_value(json).unwrap();
2071+
assert_eq!(deserialized, icon);
2072+
}
2073+
2074+
#[test]
2075+
fn test_icon_minimal() {
2076+
let icon = Icon {
2077+
src: "data:image/svg+xml;base64,PHN2Zy8+".to_string(),
2078+
mime_type: None,
2079+
sizes: None,
2080+
};
2081+
2082+
let json = serde_json::to_value(&icon).unwrap();
2083+
assert_eq!(json["src"], "data:image/svg+xml;base64,PHN2Zy8+");
2084+
assert!(json.get("mimeType").is_none());
2085+
assert!(json.get("sizes").is_none());
2086+
}
2087+
2088+
#[test]
2089+
fn test_implementation_with_icons() {
2090+
let implementation = Implementation {
2091+
name: "test-server".to_string(),
2092+
title: Some("Test Server".to_string()),
2093+
version: "1.0.0".to_string(),
2094+
icons: Some(vec![
2095+
Icon {
2096+
src: "https://example.com/icon.png".to_string(),
2097+
mime_type: Some("image/png".to_string()),
2098+
sizes: Some("48x48".to_string()),
2099+
},
2100+
Icon {
2101+
src: "https://example.com/icon.svg".to_string(),
2102+
mime_type: Some("image/svg+xml".to_string()),
2103+
sizes: Some("any".to_string()),
2104+
},
2105+
]),
2106+
website_url: Some("https://example.com".to_string()),
2107+
};
2108+
2109+
let json = serde_json::to_value(&implementation).unwrap();
2110+
assert_eq!(json["name"], "test-server");
2111+
assert_eq!(json["websiteUrl"], "https://example.com");
2112+
assert!(json["icons"].is_array());
2113+
assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png");
2114+
assert_eq!(json["icons"][1]["mimeType"], "image/svg+xml");
2115+
}
2116+
2117+
#[test]
2118+
fn test_backward_compatibility() {
2119+
// Test that old JSON without icons still deserializes correctly
2120+
let old_json = json!({
2121+
"name": "legacy-server",
2122+
"version": "0.9.0"
2123+
});
2124+
2125+
let implementation: Implementation = serde_json::from_value(old_json).unwrap();
2126+
assert_eq!(implementation.name, "legacy-server");
2127+
assert_eq!(implementation.version, "0.9.0");
2128+
assert_eq!(implementation.icons, None);
2129+
assert_eq!(implementation.website_url, None);
2130+
}
2131+
2132+
#[test]
2133+
fn test_initialize_with_icons() {
2134+
let init_result = InitializeResult {
2135+
protocol_version: ProtocolVersion::default(),
2136+
capabilities: ServerCapabilities::default(),
2137+
server_info: Implementation {
2138+
name: "icon-server".to_string(),
2139+
title: None,
2140+
version: "2.0.0".to_string(),
2141+
icons: Some(vec![Icon {
2142+
src: "https://example.com/server.png".to_string(),
2143+
mime_type: Some("image/png".to_string()),
2144+
sizes: None,
2145+
}]),
2146+
website_url: Some("https://docs.example.com".to_string()),
2147+
},
2148+
instructions: None,
2149+
};
2150+
2151+
let json = serde_json::to_value(&init_result).unwrap();
2152+
assert!(json["serverInfo"]["icons"].is_array());
2153+
assert_eq!(
2154+
json["serverInfo"]["icons"][0]["src"],
2155+
"https://example.com/server.png"
2156+
);
2157+
assert_eq!(json["serverInfo"]["websiteUrl"], "https://docs.example.com");
2158+
}
20242159
}

crates/rmcp/src/model/content.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ mod tests {
257257
description: Some("A test file".to_string()),
258258
mime_type: Some("text/plain".to_string()),
259259
size: Some(100),
260+
icons: None,
260261
});
261262

262263
let json = serde_json::to_string(&resource_link).unwrap();

crates/rmcp/src/model/prompt.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use base64::engine::{Engine, general_purpose::STANDARD as BASE64_STANDARD};
22
use serde::{Deserialize, Serialize};
33

44
use super::{
5-
AnnotateAble, Annotations, RawEmbeddedResource, RawImageContent,
5+
AnnotateAble, Annotations, Icon, RawEmbeddedResource, RawImageContent,
66
content::{EmbeddedResource, ImageContent},
77
resource::ResourceContents,
88
};
@@ -22,6 +22,9 @@ pub struct Prompt {
2222
/// Optional arguments that can be passed to customize the prompt
2323
#[serde(skip_serializing_if = "Option::is_none")]
2424
pub arguments: Option<Vec<PromptArgument>>,
25+
/// Optional list of icons for the prompt
26+
#[serde(skip_serializing_if = "Option::is_none")]
27+
pub icons: Option<Vec<Icon>>,
2528
}
2629

2730
impl Prompt {
@@ -40,6 +43,7 @@ impl Prompt {
4043
title: None,
4144
description: description.map(Into::into),
4245
arguments,
46+
icons: None,
4347
}
4448
}
4549
}

crates/rmcp/src/model/resource.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use serde::{Deserialize, Serialize};
22

3-
use super::{Annotated, Meta};
3+
use super::{Annotated, Icon, Meta};
44

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

3134
pub type Resource = Annotated<RawResource>;
@@ -91,6 +94,7 @@ impl RawResource {
9194
description: None,
9295
mime_type: None,
9396
size: None,
97+
icons: None,
9498
}
9599
}
96100
}
@@ -110,6 +114,7 @@ mod tests {
110114
description: Some("Test resource".to_string()),
111115
mime_type: Some("text/plain".to_string()),
112116
size: Some(100),
117+
icons: None,
113118
};
114119

115120
let json = serde_json::to_string(&resource).unwrap();

crates/rmcp/src/model/tool.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use schemars::JsonSchema;
66
use serde::{Deserialize, Serialize};
77
use serde_json::Value;
88

9-
use super::JsonObject;
9+
use super::{Icon, JsonObject};
1010

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

3437
/// Additional properties describing a Tool to clients.
@@ -146,6 +149,7 @@ impl Tool {
146149
input_schema: input_schema.into(),
147150
output_schema: None,
148151
annotations: None,
152+
icons: None,
149153
}
150154
}
151155

crates/rmcp/tests/test_elicitation.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,8 @@ async fn test_initialize_request_with_elicitation() {
893893
name: "test-client".to_string(),
894894
version: "1.0.0".to_string(),
895895
title: None,
896+
website_url: None,
897+
icons: None,
896898
},
897899
};
898900

@@ -935,6 +937,8 @@ async fn test_capability_checking_logic() {
935937
name: "test-client".to_string(),
936938
version: "1.0.0".to_string(),
937939
title: None,
940+
website_url: None,
941+
icons: None,
938942
},
939943
};
940944

@@ -953,9 +957,10 @@ async fn test_capability_checking_logic() {
953957
name: "test-client".to_string(),
954958
version: "1.0.0".to_string(),
955959
title: None,
960+
website_url: None,
961+
icons: None,
956962
},
957963
};
958-
959964
let supports_elicitation = client_without_capability.capabilities.elicitation.is_some();
960965
assert!(!supports_elicitation);
961966
}

0 commit comments

Comments
 (0)