Skip to content

Commit 68ddea3

Browse files
feat(server): add annotation to tool macro (#184)
skip serializing the tool annotations if none, fixes mcp inspector
1 parent 5e4e77e commit 68ddea3

File tree

4 files changed

+124
-3
lines changed

4 files changed

+124
-3
lines changed

crates/rmcp-macros/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ proc-macro = true
1818
syn = {version = "2", features = ["full"]}
1919
quote = "1"
2020
proc-macro2 = "1"
21+
serde_json = "1.0"
2122

2223

2324
[features]

crates/rmcp-macros/src/tool.rs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,45 @@ use std::collections::HashSet;
22

33
use proc_macro2::TokenStream;
44
use quote::{ToTokens, quote};
5+
use serde_json::json;
56
use syn::{
6-
Expr, FnArg, Ident, ItemFn, ItemImpl, MetaList, PatType, Token, Type, Visibility, parse::Parse,
7-
parse_quote, spanned::Spanned,
7+
Expr, FnArg, Ident, ItemFn, ItemImpl, Lit, MetaList, PatType, Token, Type, Visibility,
8+
parse::Parse, parse_quote, spanned::Spanned,
89
};
910

11+
/// Stores tool annotation attributes
12+
#[derive(Default, Clone)]
13+
struct ToolAnnotationAttrs(pub serde_json::Map<String, serde_json::Value>);
14+
15+
impl Parse for ToolAnnotationAttrs {
16+
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
17+
let mut attrs = serde_json::Map::new();
18+
19+
while !input.is_empty() {
20+
let key: Ident = input.parse()?;
21+
input.parse::<Token![:]>()?;
22+
let value: Lit = input.parse()?;
23+
let value = match value {
24+
Lit::Str(s) => json!(s.value()),
25+
Lit::Bool(b) => json!(b.value),
26+
_ => {
27+
return Err(syn::Error::new(
28+
key.span(),
29+
"annotations must be string or boolean literals",
30+
));
31+
}
32+
};
33+
attrs.insert(key.to_string(), value);
34+
if input.is_empty() {
35+
break;
36+
}
37+
input.parse::<Token![,]>()?;
38+
}
39+
40+
Ok(ToolAnnotationAttrs(attrs))
41+
}
42+
}
43+
1044
#[derive(Default)]
1145
struct ToolImplItemAttrs {
1246
tool_box: Option<Option<Ident>>,
@@ -45,13 +79,16 @@ struct ToolFnItemAttrs {
4579
name: Option<Expr>,
4680
description: Option<Expr>,
4781
vis: Option<Visibility>,
82+
annotations: Option<ToolAnnotationAttrs>,
4883
}
4984

5085
impl Parse for ToolFnItemAttrs {
5186
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
5287
let mut name = None;
5388
let mut description = None;
5489
let mut vis = None;
90+
let mut annotations = None;
91+
5592
while !input.is_empty() {
5693
let key: Ident = input.parse()?;
5794
input.parse::<Token![=]>()?;
@@ -68,6 +105,13 @@ impl Parse for ToolFnItemAttrs {
68105
let value: Visibility = input.parse()?;
69106
vis = Some(value);
70107
}
108+
"annotations" => {
109+
// Parse the annotations as a nested structure
110+
let content;
111+
syn::braced!(content in input);
112+
let value = content.parse()?;
113+
annotations = Some(value);
114+
}
71115
_ => {
72116
return Err(syn::Error::new(key.span(), "unknown attribute"));
73117
}
@@ -82,6 +126,7 @@ impl Parse for ToolFnItemAttrs {
82126
name,
83127
description,
84128
vis,
129+
annotations,
85130
})
86131
}
87132
}
@@ -470,14 +515,25 @@ pub(crate) fn tool_fn_item(attr: TokenStream, mut input_fn: ItemFn) -> syn::Resu
470515
};
471516
let input_fn_attrs = &input_fn.attrs;
472517
let input_fn_vis = &input_fn.vis;
518+
519+
let annotations_code = if let Some(annotations) = &tool_macro_attrs.fn_item.annotations {
520+
let annotations =
521+
serde_json::to_string(&annotations.0).expect("failed to serialize annotations");
522+
quote! {
523+
Some(serde_json::from_str::<rmcp::model::ToolAnnotations>(&#annotations).expect("Could not parse tool annotations"))
524+
}
525+
} else {
526+
quote! { None }
527+
};
528+
473529
quote! {
474530
#(#input_fn_attrs)*
475531
#input_fn_vis fn #tool_attr_fn_ident() -> rmcp::model::Tool {
476532
rmcp::model::Tool {
477533
name: #name.into(),
478534
description: Some(#description.into()),
479535
input_schema: #schema.into(),
480-
annotations: None
536+
annotations: #annotations_code,
481537
}
482538
}
483539
}

crates/rmcp/src/model/tool.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ pub struct Tool {
3737
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3838
pub struct ToolAnnotations {
3939
/// A human-readable title for the tool.
40+
#[serde(skip_serializing_if = "Option::is_none")]
4041
pub title: Option<String>,
4142

4243
/// If true, the tool does not modify its environment.
4344
///
4445
/// Default: false
46+
#[serde(skip_serializing_if = "Option::is_none")]
4547
pub read_only_hint: Option<bool>,
4648

4749
/// If true, the tool may perform destructive updates to its environment.
@@ -51,6 +53,7 @@ pub struct ToolAnnotations {
5153
///
5254
/// Default: true
5355
/// A human-readable description of the tool's purpose.
56+
#[serde(skip_serializing_if = "Option::is_none")]
5457
pub destructive_hint: Option<bool>,
5558

5659
/// If true, calling the tool repeatedly with the same arguments
@@ -59,6 +62,7 @@ pub struct ToolAnnotations {
5962
/// (This property is meaningful only when `readOnlyHint == false`)
6063
///
6164
/// Default: false.
65+
#[serde(skip_serializing_if = "Option::is_none")]
6266
pub idempotent_hint: Option<bool>,
6367

6468
/// If true, this tool may interact with an "open world" of external
@@ -67,6 +71,7 @@ pub struct ToolAnnotations {
6771
/// of a memory tool is not.
6872
///
6973
/// Default: true
74+
#[serde(skip_serializing_if = "Option::is_none")]
7075
pub open_world_hint: Option<bool>,
7176
}
7277

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#[cfg(test)]
2+
mod tests {
3+
use rmcp::{ServerHandler, tool};
4+
5+
#[derive(Debug, Clone, Default)]
6+
pub struct AnnotatedServer {}
7+
8+
impl AnnotatedServer {
9+
// Tool with inline comments for documentation
10+
/// Direct annotation test tool
11+
/// This is used to test tool annotations
12+
#[tool(
13+
name = "direct-annotated-tool",
14+
annotations = {
15+
title: "Annotated Tool",
16+
readOnlyHint: true
17+
}
18+
)]
19+
pub async fn direct_annotated_tool(&self, #[tool(param)] input: String) -> String {
20+
format!("Direct: {}", input)
21+
}
22+
}
23+
24+
impl ServerHandler for AnnotatedServer {
25+
async fn call_tool(
26+
&self,
27+
request: rmcp::model::CallToolRequestParam,
28+
context: rmcp::service::RequestContext<rmcp::RoleServer>,
29+
) -> Result<rmcp::model::CallToolResult, rmcp::Error> {
30+
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
31+
match tcc.name() {
32+
"direct-annotated-tool" => Self::direct_annotated_tool_tool_call(tcc).await,
33+
_ => Err(rmcp::Error::invalid_params("method not found", None)),
34+
}
35+
}
36+
}
37+
38+
#[test]
39+
fn test_direct_tool_attributes() {
40+
// Get the tool definition
41+
let tool = AnnotatedServer::direct_annotated_tool_tool_attr();
42+
43+
// Verify basic properties
44+
assert_eq!(tool.name, "direct-annotated-tool");
45+
46+
// Verify description is extracted from doc comments
47+
assert!(tool.description.is_some());
48+
assert!(
49+
tool.description
50+
.as_ref()
51+
.unwrap()
52+
.contains("Direct annotation test tool")
53+
);
54+
55+
let annotations = tool.annotations.unwrap();
56+
assert_eq!(annotations.title.as_ref().unwrap(), "Annotated Tool");
57+
assert_eq!(annotations.read_only_hint, Some(true));
58+
}
59+
}

0 commit comments

Comments
 (0)