use proc_macro2::TokenStream;
use quote::quote;
use syn::{FnArg, ItemFn, Pat, PatType, Signature, Type};
#[derive(Clone)]
pub struct ToolInfo {
pub name: String,
pub description: String,
pub sig: Signature,
pub parameters: Vec<ParameterInfo>,
pub tags: Vec<String>,
pub version: Option<String>,
pub title: Option<String>,
pub icons: Vec<String>,
pub annotations: ToolAnnotationFlags,
pub output_schema: Option<Type>,
}
#[derive(Clone, Default)]
pub struct ToolAnnotationFlags {
pub read_only: Option<bool>,
pub destructive: Option<bool>,
pub idempotent: Option<bool>,
pub open_world: Option<bool>,
}
impl ToolAnnotationFlags {
pub fn is_empty(&self) -> bool {
self.read_only.is_none()
&& self.destructive.is_none()
&& self.idempotent.is_none()
&& self.open_world.is_none()
}
}
#[derive(Clone)]
pub struct ParameterInfo {
pub name: String,
pub ty: Type,
pub description: Option<String>,
pub is_optional: bool,
}
#[derive(Default)]
pub struct ToolAttrs {
pub description: Option<String>,
pub tags: Vec<String>,
pub version: Option<String>,
pub title: Option<String>,
pub icons: Vec<String>,
pub annotations: ToolAnnotationFlags,
pub output_schema: Option<Type>,
}
impl ToolAttrs {
pub fn parse(attr: &syn::Attribute) -> Result<Self, syn::Error> {
let mut attrs = Self::default();
let syn::Meta::List(meta_list) = &attr.meta else {
return Ok(attrs);
};
if let Ok(lit) = syn::parse2::<syn::LitStr>(meta_list.tokens.clone()) {
attrs.description = Some(lit.value());
return Ok(attrs);
}
let parser = syn::meta::parser(|meta| {
if meta.path.is_ident("description") {
let value: syn::LitStr = meta.value()?.parse()?;
attrs.description = Some(value.value());
} else if meta.path.is_ident("tags") {
attrs.tags = parse_lit_str_array(&meta)?;
} else if meta.path.is_ident("version") {
let value: syn::LitStr = meta.value()?.parse()?;
attrs.version = Some(value.value());
} else if meta.path.is_ident("title") {
let value: syn::LitStr = meta.value()?.parse()?;
attrs.title = Some(value.value());
} else if meta.path.is_ident("icons") {
attrs.icons = parse_lit_str_array(&meta)?;
} else if meta.path.is_ident("read_only") {
attrs.annotations.read_only = Some(meta.value()?.parse::<syn::LitBool>()?.value);
} else if meta.path.is_ident("destructive") {
attrs.annotations.destructive = Some(meta.value()?.parse::<syn::LitBool>()?.value);
} else if meta.path.is_ident("idempotent") {
attrs.annotations.idempotent = Some(meta.value()?.parse::<syn::LitBool>()?.value);
} else if meta.path.is_ident("open_world") {
attrs.annotations.open_world = Some(meta.value()?.parse::<syn::LitBool>()?.value);
} else if meta.path.is_ident("output_schema") {
attrs.output_schema = Some(meta.value()?.parse::<Type>()?);
} else {
let key = meta
.path
.get_ident()
.map(|i| i.to_string())
.unwrap_or_else(|| "<unknown>".to_string());
return Err(meta.error(format!(
"unknown #[tool] attribute key `{key}`; expected one of `description`, `tags`, `version`, `title`, `icons`, `read_only`, `destructive`, `idempotent`, `open_world`, `output_schema`",
)));
}
Ok(())
});
if syn::parse::Parser::parse2(parser, meta_list.tokens.clone()).is_err() {
attrs = Self::parse_alternative(&meta_list.tokens)?;
}
Ok(attrs)
}
fn parse_alternative(tokens: &proc_macro2::TokenStream) -> Result<Self, syn::Error> {
let mut attrs = Self::default();
let token_str = tokens.to_string();
attrs.description = parse_quoted_value(&token_str, "description");
attrs.version = parse_quoted_value(&token_str, "version");
attrs.title = parse_quoted_value(&token_str, "title");
attrs.tags = parse_string_array(&token_str, "tags");
attrs.icons = parse_string_array(&token_str, "icons");
attrs.annotations.read_only = parse_bool_value(&token_str, "read_only");
attrs.annotations.destructive = parse_bool_value(&token_str, "destructive");
attrs.annotations.idempotent = parse_bool_value(&token_str, "idempotent");
attrs.annotations.open_world = parse_bool_value(&token_str, "open_world");
Ok(attrs)
}
}
fn parse_lit_str_array(meta: &syn::meta::ParseNestedMeta<'_>) -> Result<Vec<String>, syn::Error> {
let value = meta.value()?;
let arr;
syn::bracketed!(arr in value);
let parsed: syn::punctuated::Punctuated<syn::LitStr, syn::Token![,]> =
syn::punctuated::Punctuated::parse_terminated(&arr)?;
Ok(parsed.into_iter().map(|s| s.value()).collect())
}
pub fn parse_quoted_value(token_str: &str, key: &str) -> Option<String> {
let tokens = syn::parse_str::<proc_macro2::TokenStream>(token_str).ok()?;
let mut iter = tokens.into_iter().peekable();
while let Some(token) = iter.next() {
let proc_macro2::TokenTree::Ident(ident) = &token else {
continue;
};
if ident != key {
continue;
}
let Some(proc_macro2::TokenTree::Punct(p)) = iter.next() else {
continue;
};
if p.as_char() != '=' {
continue;
}
let Some(proc_macro2::TokenTree::Literal(lit)) = iter.next() else {
continue;
};
if let Ok(s) = syn::parse_str::<syn::LitStr>(&lit.to_string()) {
return Some(s.value());
}
}
None
}
pub fn parse_string_array(token_str: &str, key: &str) -> Vec<String> {
let Ok(tokens) = syn::parse_str::<proc_macro2::TokenStream>(token_str) else {
return Vec::new();
};
let mut iter = tokens.into_iter();
while let Some(token) = iter.next() {
let proc_macro2::TokenTree::Ident(ident) = &token else {
continue;
};
if ident != key {
continue;
}
let Some(proc_macro2::TokenTree::Punct(p)) = iter.next() else {
continue;
};
if p.as_char() != '=' {
continue;
}
let Some(proc_macro2::TokenTree::Group(group)) = iter.next() else {
continue;
};
if group.delimiter() != proc_macro2::Delimiter::Bracket {
continue;
}
return group
.stream()
.into_iter()
.filter_map(|tt| {
if let proc_macro2::TokenTree::Literal(lit) = tt {
syn::parse_str::<syn::LitStr>(&lit.to_string())
.ok()
.map(|s| s.value())
} else {
None
}
})
.collect();
}
Vec::new()
}
pub fn parse_tags_array(token_str: &str) -> Vec<String> {
parse_string_array(token_str, "tags")
}
pub fn parse_bool_value(token_str: &str, key: &str) -> Option<bool> {
let tokens = syn::parse_str::<proc_macro2::TokenStream>(token_str).ok()?;
let mut iter = tokens.into_iter();
while let Some(token) = iter.next() {
let proc_macro2::TokenTree::Ident(ident) = &token else {
continue;
};
if ident != key {
continue;
}
let Some(proc_macro2::TokenTree::Punct(p)) = iter.next() else {
continue;
};
if p.as_char() != '=' {
continue;
}
let Some(next) = iter.next() else {
continue;
};
return match next {
proc_macro2::TokenTree::Ident(b) if b == "true" => Some(true),
proc_macro2::TokenTree::Ident(b) if b == "false" => Some(false),
_ => None,
};
}
None
}
impl ToolInfo {
pub fn from_fn(item: &ItemFn, attrs: ToolAttrs) -> Result<Self, syn::Error> {
let name = item.sig.ident.to_string();
let doc_description = extract_doc_comments(&item.attrs);
let description = attrs.description.or(doc_description).unwrap_or_default();
let parameters = analyze_parameters(&item.sig)?;
Ok(Self {
name,
description,
sig: item.sig.clone(),
parameters,
tags: attrs.tags,
version: attrs.version,
title: attrs.title,
icons: attrs.icons,
annotations: attrs.annotations,
output_schema: attrs.output_schema,
})
}
}
fn extract_doc_comments(attrs: &[syn::Attribute]) -> Option<String> {
let doc_lines: Vec<String> = attrs
.iter()
.filter_map(|attr| {
if attr.path().is_ident("doc")
&& let syn::Meta::NameValue(meta) = &attr.meta
&& let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit_str),
..
}) = &meta.value
{
return Some(lit_str.value().trim().to_string());
}
None
})
.collect();
if doc_lines.is_empty() {
None
} else {
Some(doc_lines.join(" "))
}
}
fn analyze_parameters(sig: &Signature) -> Result<Vec<ParameterInfo>, syn::Error> {
let mut parameters = Vec::new();
for input in &sig.inputs {
match input {
FnArg::Receiver(_) => {
continue;
}
FnArg::Typed(PatType { pat, ty, attrs, .. }) => {
if let Pat::Ident(pat_ident) = pat.as_ref() {
let param_name = pat_ident.ident.to_string();
if is_context_type(ty) {
continue;
}
let description =
extract_description_attr(attrs).or_else(|| extract_doc_comments(attrs));
let is_optional = is_option_type(ty);
parameters.push(ParameterInfo {
name: param_name,
ty: (**ty).clone(),
description,
is_optional,
});
}
}
}
}
Ok(parameters)
}
fn extract_description_attr(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("description") {
if let syn::Meta::List(meta_list) = &attr.meta
&& let Ok(lit) = syn::parse2::<syn::LitStr>(meta_list.tokens.clone())
{
return Some(lit.value());
}
if let syn::Meta::NameValue(meta_nv) = &attr.meta
&& let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit_str),
..
}) = &meta_nv.value
{
return Some(lit_str.value());
}
}
}
None
}
fn is_context_type(ty: &Type) -> bool {
match ty {
Type::Path(type_path) => type_path
.path
.segments
.last()
.is_some_and(|seg| seg.ident == "Context" || seg.ident == "RequestContext"),
Type::Reference(type_ref) => is_context_type(&type_ref.elem),
_ => false,
}
}
fn is_option_type(ty: &Type) -> bool {
if let Type::Path(type_path) = ty {
type_path
.path
.segments
.last()
.is_some_and(|seg| seg.ident == "Option")
} else {
false
}
}
pub fn generate_schema_code(parameters: &[ParameterInfo], krate: &TokenStream) -> TokenStream {
if parameters.is_empty() {
return quote! {
#krate::__macro_support::turbomcp_types::ToolInputSchema::empty()
};
}
let mut prop_code = Vec::new();
let mut required_names = Vec::new();
for param in parameters {
let name = ¶m.name;
let ty = ¶m.ty;
let schema_code = quote! {
{
let schema = #krate::__macro_support::schemars::schema_for!(#ty);
match #krate::__macro_support::serde_json::to_value(&schema) {
Ok(#krate::__macro_support::serde_json::Value::Object(map)) => map,
Ok(other) => {
let mut m = #krate::__macro_support::serde_json::Map::new();
m.insert(
"allOf".to_string(),
#krate::__macro_support::serde_json::Value::Array(vec![other]),
);
m
}
Err(_) => {
let mut m = #krate::__macro_support::serde_json::Map::new();
m.insert(
"type".to_string(),
#krate::__macro_support::serde_json::Value::String("object".to_string()),
);
m
}
}
}
};
let description_code = if let Some(desc) = ¶m.description {
quote! {
prop.insert("description".to_string(), #krate::__macro_support::serde_json::Value::String(#desc.to_string()));
}
} else {
quote! {}
};
prop_code.push(quote! {
{
let mut prop = #schema_code;
#description_code
properties.insert(#name.to_string(), #krate::__macro_support::serde_json::Value::Object(prop));
}
});
if !param.is_optional {
required_names.push(name.clone());
}
}
quote! {
{
let mut properties = #krate::__macro_support::serde_json::Map::new();
#(#prop_code)*
let required: Vec<String> = vec![#(#required_names.to_string()),*];
let mut extras = ::std::collections::HashMap::new();
extras.insert(
"$schema".to_string(),
#krate::__macro_support::serde_json::Value::String(
#krate::__macro_support::turbomcp_types::JSON_SCHEMA_DIALECT_2020_12.to_string(),
),
);
#krate::__macro_support::turbomcp_types::ToolInputSchema {
schema_type: Some("object".into()),
properties: Some(#krate::__macro_support::serde_json::Value::Object(properties)),
required: if required.is_empty() { None } else { Some(required) },
additional_properties: Some(false.into()),
extra_keywords: extras,
}
}
}
}
const MAX_PARAM_VALUE_SIZE: usize = 1024 * 1024;
pub fn generate_extraction_code(parameters: &[ParameterInfo], krate: &TokenStream) -> TokenStream {
if parameters.is_empty() {
return quote! {};
}
let param_count = parameters.len();
let mut extraction = quote! {
if args.len() > #param_count + 10 {
return Err(#krate::__macro_support::turbomcp_core::error::McpError::invalid_params(
format!("Too many parameters: got {}, expected at most {}", args.len(), #param_count)
));
}
};
for param in parameters {
let name_str = ¶m.name;
let name_ident = syn::Ident::new(¶m.name, proc_macro2::Span::call_site());
let ty = ¶m.ty;
let size_check = quote! {
if let Some(v) = args.get(#name_str) {
let size_estimate = v.to_string().len();
if size_estimate > #MAX_PARAM_VALUE_SIZE {
return Err(#krate::__macro_support::turbomcp_core::error::McpError::invalid_params(
format!("Parameter '{}' exceeds maximum size ({} bytes)", #name_str, size_estimate)
));
}
}
};
if param.is_optional {
extraction.extend(quote! {
#size_check
let #name_ident: #ty = match args.get(#name_str) {
None => None,
Some(v) => {
#krate::__macro_support::serde_json::from_value::<#ty>(v.clone())
.map_err(|e| #krate::__macro_support::turbomcp_core::error::McpError::invalid_params(
format!("Invalid parameter '{}': {}", #name_str, e)
))?
}
};
});
} else {
extraction.extend(quote! {
#size_check
let #name_ident: #ty = args
.get(#name_str)
.ok_or_else(|| #krate::__macro_support::turbomcp_core::error::McpError::invalid_params(
format!("Missing required parameter: {}", #name_str)
))
.and_then(|v| #krate::__macro_support::serde_json::from_value(v.clone())
.map_err(|e| #krate::__macro_support::turbomcp_core::error::McpError::invalid_params(
format!("Invalid parameter '{}': {}", #name_str, e)
)))?;
});
}
}
extraction
}
pub fn generate_icons_code(icons: &[String], krate: &TokenStream) -> TokenStream {
if icons.is_empty() {
return quote! { None };
}
let icon_exprs = icons.iter().map(|src| {
quote! {
#krate::__macro_support::turbomcp_types::Icon {
src: #src.to_string(),
mime_type: None,
sizes: None,
theme: None,
}
}
});
quote! {
Some(vec![#(#icon_exprs),*])
}
}
pub fn generate_annotations_code(
annotations: &ToolAnnotationFlags,
title: &Option<String>,
krate: &TokenStream,
) -> TokenStream {
if annotations.is_empty() && title.is_none() {
return quote! { None };
}
let read_only = match annotations.read_only {
Some(v) => quote! { Some(#v) },
None => quote! { None },
};
let destructive = match annotations.destructive {
Some(v) => quote! { Some(#v) },
None => quote! { None },
};
let idempotent = match annotations.idempotent {
Some(v) => quote! { Some(#v) },
None => quote! { None },
};
let open_world = match annotations.open_world {
Some(v) => quote! { Some(#v) },
None => quote! { None },
};
let title_code = match title {
Some(t) => quote! { Some(#t.to_string()) },
None => quote! { None },
};
quote! {
Some(#krate::__macro_support::turbomcp_types::ToolAnnotations {
read_only_hint: #read_only,
destructive_hint: #destructive,
idempotent_hint: #idempotent,
open_world_hint: #open_world,
title: #title_code,
})
}
}
pub fn generate_output_schema_code(ty: &Option<Type>, krate: &TokenStream) -> TokenStream {
let Some(ty) = ty else {
return quote! { None };
};
quote! {
{
let schema = #krate::__macro_support::schemars::schema_for!(#ty);
let value = #krate::__macro_support::serde_json::to_value(&schema)
.unwrap_or(#krate::__macro_support::serde_json::Value::Null);
Some(#krate::__macro_support::turbomcp_types::ToolOutputSchema::from_value(value))
}
}
}
pub fn generate_call_args(sig: &Signature) -> TokenStream {
let mut args = Vec::new();
for input in &sig.inputs {
match input {
FnArg::Receiver(_) => continue,
FnArg::Typed(PatType { pat, ty, .. }) => {
if let Pat::Ident(pat_ident) = pat.as_ref() {
if is_context_type(ty) {
args.push(quote! { ctx });
} else {
let name = &pat_ident.ident;
args.push(quote! { #name });
}
}
}
}
}
quote! { #(#args),* }
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_quote;
#[test]
fn test_extract_doc_comments() {
let attrs: Vec<syn::Attribute> = vec![parse_quote!(#[doc = " This is a test"])];
let doc = extract_doc_comments(&attrs);
assert_eq!(doc, Some("This is a test".to_string()));
}
#[test]
fn test_extract_description_attr_list_style() {
let attrs: Vec<syn::Attribute> = vec![parse_quote!(#[description("The name to greet")])];
let desc = extract_description_attr(&attrs);
assert_eq!(desc, Some("The name to greet".to_string()));
}
#[test]
fn test_extract_description_attr_name_value_style() {
let attrs: Vec<syn::Attribute> = vec![parse_quote!(#[description = "A value"])];
let desc = extract_description_attr(&attrs);
assert_eq!(desc, Some("A value".to_string()));
}
#[test]
fn test_is_option_type() {
let ty: Type = parse_quote!(Option<String>);
assert!(is_option_type(&ty));
let ty: Type = parse_quote!(String);
assert!(!is_option_type(&ty));
}
#[test]
fn test_is_context_type() {
let ty: Type = parse_quote!(Context);
assert!(is_context_type(&ty));
let ty: Type = parse_quote!(RequestContext);
assert!(is_context_type(&ty));
let ty: Type = parse_quote!(&RequestContext);
assert!(is_context_type(&ty));
let ty: Type = parse_quote!(String);
assert!(!is_context_type(&ty));
}
}