feat(server): add annotation to tool macro (#184)
skip serializing the tool annotations if none, fixes mcp inspector
This commit is contained in:
parent
5e4e77efa5
commit
68ddea3ad5
4 changed files with 124 additions and 3 deletions
|
|
@ -18,6 +18,7 @@ proc-macro = true
|
||||||
syn = {version = "2", features = ["full"]}
|
syn = {version = "2", features = ["full"]}
|
||||||
quote = "1"
|
quote = "1"
|
||||||
proc-macro2 = "1"
|
proc-macro2 = "1"
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,45 @@ use std::collections::HashSet;
|
||||||
|
|
||||||
use proc_macro2::TokenStream;
|
use proc_macro2::TokenStream;
|
||||||
use quote::{ToTokens, quote};
|
use quote::{ToTokens, quote};
|
||||||
|
use serde_json::json;
|
||||||
use syn::{
|
use syn::{
|
||||||
Expr, FnArg, Ident, ItemFn, ItemImpl, MetaList, PatType, Token, Type, Visibility, parse::Parse,
|
Expr, FnArg, Ident, ItemFn, ItemImpl, Lit, MetaList, PatType, Token, Type, Visibility,
|
||||||
parse_quote, spanned::Spanned,
|
parse::Parse, parse_quote, spanned::Spanned,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Stores tool annotation attributes
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct ToolAnnotationAttrs(pub serde_json::Map<String, serde_json::Value>);
|
||||||
|
|
||||||
|
impl Parse for ToolAnnotationAttrs {
|
||||||
|
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||||
|
let mut attrs = serde_json::Map::new();
|
||||||
|
|
||||||
|
while !input.is_empty() {
|
||||||
|
let key: Ident = input.parse()?;
|
||||||
|
input.parse::<Token![:]>()?;
|
||||||
|
let value: Lit = input.parse()?;
|
||||||
|
let value = match value {
|
||||||
|
Lit::Str(s) => json!(s.value()),
|
||||||
|
Lit::Bool(b) => json!(b.value),
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
key.span(),
|
||||||
|
"annotations must be string or boolean literals",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
attrs.insert(key.to_string(), value);
|
||||||
|
if input.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
input.parse::<Token![,]>()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ToolAnnotationAttrs(attrs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ToolImplItemAttrs {
|
struct ToolImplItemAttrs {
|
||||||
tool_box: Option<Option<Ident>>,
|
tool_box: Option<Option<Ident>>,
|
||||||
|
|
@ -45,6 +79,7 @@ struct ToolFnItemAttrs {
|
||||||
name: Option<Expr>,
|
name: Option<Expr>,
|
||||||
description: Option<Expr>,
|
description: Option<Expr>,
|
||||||
vis: Option<Visibility>,
|
vis: Option<Visibility>,
|
||||||
|
annotations: Option<ToolAnnotationAttrs>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Parse for ToolFnItemAttrs {
|
impl Parse for ToolFnItemAttrs {
|
||||||
|
|
@ -52,6 +87,8 @@ impl Parse for ToolFnItemAttrs {
|
||||||
let mut name = None;
|
let mut name = None;
|
||||||
let mut description = None;
|
let mut description = None;
|
||||||
let mut vis = None;
|
let mut vis = None;
|
||||||
|
let mut annotations = None;
|
||||||
|
|
||||||
while !input.is_empty() {
|
while !input.is_empty() {
|
||||||
let key: Ident = input.parse()?;
|
let key: Ident = input.parse()?;
|
||||||
input.parse::<Token![=]>()?;
|
input.parse::<Token![=]>()?;
|
||||||
|
|
@ -68,6 +105,13 @@ impl Parse for ToolFnItemAttrs {
|
||||||
let value: Visibility = input.parse()?;
|
let value: Visibility = input.parse()?;
|
||||||
vis = Some(value);
|
vis = Some(value);
|
||||||
}
|
}
|
||||||
|
"annotations" => {
|
||||||
|
// Parse the annotations as a nested structure
|
||||||
|
let content;
|
||||||
|
syn::braced!(content in input);
|
||||||
|
let value = content.parse()?;
|
||||||
|
annotations = Some(value);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(key.span(), "unknown attribute"));
|
return Err(syn::Error::new(key.span(), "unknown attribute"));
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +126,7 @@ impl Parse for ToolFnItemAttrs {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
vis,
|
vis,
|
||||||
|
annotations,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -470,6 +515,17 @@ pub(crate) fn tool_fn_item(attr: TokenStream, mut input_fn: ItemFn) -> syn::Resu
|
||||||
};
|
};
|
||||||
let input_fn_attrs = &input_fn.attrs;
|
let input_fn_attrs = &input_fn.attrs;
|
||||||
let input_fn_vis = &input_fn.vis;
|
let input_fn_vis = &input_fn.vis;
|
||||||
|
|
||||||
|
let annotations_code = if let Some(annotations) = &tool_macro_attrs.fn_item.annotations {
|
||||||
|
let annotations =
|
||||||
|
serde_json::to_string(&annotations.0).expect("failed to serialize annotations");
|
||||||
|
quote! {
|
||||||
|
Some(serde_json::from_str::<rmcp::model::ToolAnnotations>(&#annotations).expect("Could not parse tool annotations"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! { None }
|
||||||
|
};
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
#(#input_fn_attrs)*
|
#(#input_fn_attrs)*
|
||||||
#input_fn_vis fn #tool_attr_fn_ident() -> rmcp::model::Tool {
|
#input_fn_vis fn #tool_attr_fn_ident() -> rmcp::model::Tool {
|
||||||
|
|
@ -477,7 +533,7 @@ pub(crate) fn tool_fn_item(attr: TokenStream, mut input_fn: ItemFn) -> syn::Resu
|
||||||
name: #name.into(),
|
name: #name.into(),
|
||||||
description: Some(#description.into()),
|
description: Some(#description.into()),
|
||||||
input_schema: #schema.into(),
|
input_schema: #schema.into(),
|
||||||
annotations: None
|
annotations: #annotations_code,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,13 @@ pub struct Tool {
|
||||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||||
pub struct ToolAnnotations {
|
pub struct ToolAnnotations {
|
||||||
/// A human-readable title for the tool.
|
/// A human-readable title for the tool.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
|
||||||
/// If true, the tool does not modify its environment.
|
/// If true, the tool does not modify its environment.
|
||||||
///
|
///
|
||||||
/// Default: false
|
/// Default: false
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub read_only_hint: Option<bool>,
|
pub read_only_hint: Option<bool>,
|
||||||
|
|
||||||
/// If true, the tool may perform destructive updates to its environment.
|
/// If true, the tool may perform destructive updates to its environment.
|
||||||
|
|
@ -51,6 +53,7 @@ pub struct ToolAnnotations {
|
||||||
///
|
///
|
||||||
/// Default: true
|
/// Default: true
|
||||||
/// A human-readable description of the tool's purpose.
|
/// A human-readable description of the tool's purpose.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub destructive_hint: Option<bool>,
|
pub destructive_hint: Option<bool>,
|
||||||
|
|
||||||
/// If true, calling the tool repeatedly with the same arguments
|
/// If true, calling the tool repeatedly with the same arguments
|
||||||
|
|
@ -59,6 +62,7 @@ pub struct ToolAnnotations {
|
||||||
/// (This property is meaningful only when `readOnlyHint == false`)
|
/// (This property is meaningful only when `readOnlyHint == false`)
|
||||||
///
|
///
|
||||||
/// Default: false.
|
/// Default: false.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub idempotent_hint: Option<bool>,
|
pub idempotent_hint: Option<bool>,
|
||||||
|
|
||||||
/// If true, this tool may interact with an "open world" of external
|
/// If true, this tool may interact with an "open world" of external
|
||||||
|
|
@ -67,6 +71,7 @@ pub struct ToolAnnotations {
|
||||||
/// of a memory tool is not.
|
/// of a memory tool is not.
|
||||||
///
|
///
|
||||||
/// Default: true
|
/// Default: true
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub open_world_hint: Option<bool>,
|
pub open_world_hint: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
59
crates/rmcp/tests/test_tool_macro_annotations.rs
Normal file
59
crates/rmcp/tests/test_tool_macro_annotations.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use rmcp::{ServerHandler, tool};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AnnotatedServer {}
|
||||||
|
|
||||||
|
impl AnnotatedServer {
|
||||||
|
// Tool with inline comments for documentation
|
||||||
|
/// Direct annotation test tool
|
||||||
|
/// This is used to test tool annotations
|
||||||
|
#[tool(
|
||||||
|
name = "direct-annotated-tool",
|
||||||
|
annotations = {
|
||||||
|
title: "Annotated Tool",
|
||||||
|
readOnlyHint: true
|
||||||
|
}
|
||||||
|
)]
|
||||||
|
pub async fn direct_annotated_tool(&self, #[tool(param)] input: String) -> String {
|
||||||
|
format!("Direct: {}", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerHandler for AnnotatedServer {
|
||||||
|
async fn call_tool(
|
||||||
|
&self,
|
||||||
|
request: rmcp::model::CallToolRequestParam,
|
||||||
|
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||||
|
) -> Result<rmcp::model::CallToolResult, rmcp::Error> {
|
||||||
|
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
|
||||||
|
match tcc.name() {
|
||||||
|
"direct-annotated-tool" => Self::direct_annotated_tool_tool_call(tcc).await,
|
||||||
|
_ => Err(rmcp::Error::invalid_params("method not found", None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_direct_tool_attributes() {
|
||||||
|
// Get the tool definition
|
||||||
|
let tool = AnnotatedServer::direct_annotated_tool_tool_attr();
|
||||||
|
|
||||||
|
// Verify basic properties
|
||||||
|
assert_eq!(tool.name, "direct-annotated-tool");
|
||||||
|
|
||||||
|
// Verify description is extracted from doc comments
|
||||||
|
assert!(tool.description.is_some());
|
||||||
|
assert!(
|
||||||
|
tool.description
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Direct annotation test tool")
|
||||||
|
);
|
||||||
|
|
||||||
|
let annotations = tool.annotations.unwrap();
|
||||||
|
assert_eq!(annotations.title.as_ref().unwrap(), "Annotated Tool");
|
||||||
|
assert_eq!(annotations.read_only_hint, Some(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue