diff --git a/crates/rmcp-macros/src/common.rs b/crates/rmcp-macros/src/common.rs index 39d44f1e2..877f89955 100644 --- a/crates/rmcp-macros/src/common.rs +++ b/crates/rmcp-macros/src/common.rs @@ -9,33 +9,45 @@ pub fn none_expr() -> syn::Result { } /// Extract documentation from doc attributes -pub fn extract_doc_line(existing_docs: Option, attr: &Attribute) -> Option { +pub fn extract_doc_line( + existing_docs: Option, + attr: &Attribute, +) -> syn::Result> { if !attr.path().is_ident("doc") { - return None; + return Ok(None); } let syn::Meta::NameValue(name_value) = &attr.meta else { - return None; + return Ok(None); }; - let syn::Expr::Lit(expr_lit) = &name_value.value else { - return None; - }; - - let syn::Lit::Str(lit_str) = &expr_lit.lit else { - return None; + let value = &name_value.value; + let this_expr: Option = match value { + // Preserve macros such as `include_str!(...)` + syn::Expr::Macro(_) => Some(value.clone()), + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) => { + let content = lit_str.value().trim().to_string(); + if content.is_empty() { + return Ok(existing_docs); + } + Some(Expr::Lit(syn::ExprLit { + attrs: Vec::new(), + lit: syn::Lit::Str(syn::LitStr::new(&content, lit_str.span())), + })) + } + _ => return Ok(None), }; - let content = lit_str.value().trim().to_string(); - match (existing_docs, content) { - (Some(mut existing_docs), content) if !content.is_empty() => { - existing_docs.push('\n'); - existing_docs.push_str(&content); - Some(existing_docs) + match (existing_docs, this_expr) { + (Some(existing), Some(this)) => { + syn::parse2::(quote! { concat!(#existing, "\n", #this) }).map(Some) } - (Some(existing_docs), _) => Some(existing_docs), - (None, content) if !content.is_empty() => Some(content), - _ => None, + (Some(existing), None) => Ok(Some(existing)), + (None, Some(this)) => Ok(Some(this)), + _ => Ok(None), } } diff --git a/crates/rmcp-macros/src/prompt.rs b/crates/rmcp-macros/src/prompt.rs index 7326b6615..cb3d7442e 100644 --- a/crates/rmcp-macros/src/prompt.rs +++ b/crates/rmcp-macros/src/prompt.rs @@ -1,5 +1,5 @@ use darling::{FromMeta, ast::NestedMeta}; -use proc_macro2::TokenStream; +use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote}; use syn::{Expr, Ident, ImplItemFn, ReturnType}; @@ -23,7 +23,7 @@ pub struct PromptAttribute { pub struct ResolvedPromptAttribute { pub name: String, pub title: Option, - pub description: Option, + pub description: Option, pub arguments: Expr, pub icons: Option, } @@ -98,9 +98,14 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result }; let name = attribute.name.unwrap_or_else(|| fn_ident.to_string()); - let description = attribute - .description - .or_else(|| fn_item.attrs.iter().fold(None, extract_doc_line)); + let description = if let Some(s) = attribute.description { + Some(Expr::Lit(syn::ExprLit { + attrs: Vec::new(), + lit: syn::Lit::Str(syn::LitStr::new(&s, Span::call_site())), + })) + } else { + fn_item.attrs.iter().try_fold(None, extract_doc_line)? + }; let arguments = arguments_expr; let resolved_prompt_attr = ResolvedPromptAttribute { @@ -200,4 +205,22 @@ mod test { Ok(()) } + + #[test] + fn test_doc_include_description() -> syn::Result<()> { + let attr = quote! {}; // No explicit description + let input = quote! { + #[doc = include_str!("some/test/data/doc.txt")] + fn test_prompt_included(&self) -> Result { + Ok("Test".to_string()) + } + }; + let result = prompt(attr, input)?; + + // The generated tokens should preserve the include_str! invocation + let result_str = result.to_string(); + assert!(result_str.contains("include_str")); + + Ok(()) + } } diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index 07863215e..5623ffb8d 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -1,7 +1,7 @@ use darling::{FromMeta, ast::NestedMeta}; -use proc_macro2::TokenStream; +use proc_macro2::{Span, TokenStream}; use quote::{ToTokens, format_ident, quote}; -use syn::{Expr, Ident, ImplItemFn, ReturnType, parse_quote}; +use syn::{Expr, Ident, ImplItemFn, LitStr, ReturnType, parse_quote}; use crate::common::{extract_doc_line, none_expr}; @@ -82,7 +82,7 @@ pub struct ToolAttribute { pub struct ResolvedToolAttribute { pub name: String, pub title: Option, - pub description: Option, + pub description: Option, pub input_schema: Expr, pub output_schema: Option, pub annotations: Expr, @@ -244,11 +244,17 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { } }); + let description_expr = if let Some(s) = attribute.description { + Some(Expr::Lit(syn::ExprLit { + attrs: Vec::new(), + lit: syn::Lit::Str(LitStr::new(&s, Span::call_site())), + })) + } else { + fn_item.attrs.iter().try_fold(None, extract_doc_line)? + }; let resolved_tool_attr = ResolvedToolAttribute { name: attribute.name.unwrap_or_else(|| fn_ident.to_string()), - description: attribute - .description - .or_else(|| fn_item.attrs.iter().fold(None, extract_doc_line)), + description: description_expr, input_schema: input_schema_expr, output_schema: output_schema_expr, annotations: annotations_expr, @@ -352,4 +358,22 @@ mod test { assert!(result_str.contains("Explicit description has priority")); Ok(()) } + + #[test] + fn test_doc_include_description() -> syn::Result<()> { + let attr = quote! {}; // No explicit description + let input = quote! { + #[doc = include_str!("some/test/data/doc.txt")] + fn test_function(&self) -> Result<(), Error> { + Ok(()) + } + }; + let result = tool(attr, input)?; + + // The macro should preserve include_str! in the generated tokens so we at least + // see the include_str invocation in the generated function source. + let result_str = result.to_string(); + assert!(result_str.contains("include_str")); + Ok(()) + } }