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
48 changes: 30 additions & 18 deletions crates/rmcp-macros/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,45 @@ pub fn none_expr() -> syn::Result<Expr> {
}

/// Extract documentation from doc attributes
pub fn extract_doc_line(existing_docs: Option<String>, attr: &Attribute) -> Option<String> {
pub fn extract_doc_line(
existing_docs: Option<Expr>,
attr: &Attribute,
) -> syn::Result<Option<Expr>> {
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<Expr> = 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::<Expr>(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),
}
}

Expand Down
33 changes: 28 additions & 5 deletions crates/rmcp-macros/src/prompt.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -23,7 +23,7 @@ pub struct PromptAttribute {
pub struct ResolvedPromptAttribute {
pub name: String,
pub title: Option<String>,
pub description: Option<String>,
pub description: Option<Expr>,
pub arguments: Expr,
pub icons: Option<Expr>,
}
Expand Down Expand Up @@ -98,9 +98,14 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream>
};

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 {
Expand Down Expand Up @@ -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<String> {
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(())
}
}
36 changes: 30 additions & 6 deletions crates/rmcp-macros/src/tool.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -82,7 +82,7 @@ pub struct ToolAttribute {
pub struct ResolvedToolAttribute {
pub name: String,
pub title: Option<String>,
pub description: Option<String>,
pub description: Option<Expr>,
pub input_schema: Expr,
pub output_schema: Option<Expr>,
pub annotations: Expr,
Expand Down Expand Up @@ -244,11 +244,17 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
}
});

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,
Expand Down Expand Up @@ -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(())
}
}