rust_mcp_macros/
lib.rs

1extern crate proc_macro;
2
3mod utils;
4
5use proc_macro::TokenStream;
6use quote::quote;
7use syn::{
8    parse::Parse, parse_macro_input, punctuated::Punctuated, Data, DeriveInput, Error, Expr,
9    ExprLit, Fields, Lit, Meta, Token,
10};
11use utils::{is_option, renamed_field, type_to_json_schema};
12
13/// Represents the attributes for the `mcp_tool` procedural macro.
14///
15/// This struct parses and validates the `name` and `description` attributes provided
16/// to the `mcp_tool` macro. Both attributes are required and must not be empty strings.
17///
18/// # Fields
19/// * `name` - An optional string representing the tool's name.
20/// * `description` - An optional string describing the tool.
21///
22#[cfg(feature = "2024_11_05")]
23struct McpToolMacroAttributes {
24    name: Option<String>,
25    description: Option<String>,
26}
27
28/// Represents the attributes for the `mcp_tool` procedural macro.
29///
30/// This struct parses and validates the `name` and `description` attributes provided
31/// to the `mcp_tool` macro. Both attributes are required and must not be empty strings.
32///
33/// # Fields
34/// * `name` - An optional string representing the tool's name.
35/// * `description` - An optional string describing the tool.
36/// * `destructive_hint` - Optional boolean for `ToolAnnotations::destructive_hint`.
37/// * `idempotent_hint` - Optional boolean for `ToolAnnotations::idempotent_hint`.
38/// * `open_world_hint` - Optional boolean for `ToolAnnotations::open_world_hint`.
39/// * `read_only_hint` - Optional boolean for `ToolAnnotations::read_only_hint`.
40/// * `title` - Optional string for `ToolAnnotations::title`.
41///
42#[cfg(feature = "2025_03_26")]
43struct McpToolMacroAttributes {
44    name: Option<String>,
45    description: Option<String>,
46    destructive_hint: Option<bool>,
47    idempotent_hint: Option<bool>,
48    open_world_hint: Option<bool>,
49    read_only_hint: Option<bool>,
50    title: Option<String>,
51}
52
53use syn::parse::ParseStream;
54
55struct ExprList {
56    exprs: Punctuated<Expr, Token![,]>,
57}
58
59impl Parse for ExprList {
60    fn parse(input: ParseStream) -> syn::Result<Self> {
61        Ok(ExprList {
62            exprs: Punctuated::parse_terminated(input)?,
63        })
64    }
65}
66
67impl Parse for McpToolMacroAttributes {
68    /// Parses the macro attributes from a `ParseStream`.
69    ///
70    /// This implementation extracts `name` and `description` from the attribute input,
71    /// ensuring they are provided as string literals and are non-empty.
72    ///
73    /// # Errors
74    /// Returns a `syn::Error` if:
75    /// - The `name` attribute is missing or empty.
76    /// - The `description` attribute is missing or empty.
77    fn parse(attributes: syn::parse::ParseStream) -> syn::Result<Self> {
78        let mut name = None;
79        let mut description = None;
80        let mut destructive_hint = None;
81        let mut idempotent_hint = None;
82        let mut open_world_hint = None;
83        let mut read_only_hint = None;
84        let mut title = None;
85
86        let meta_list: Punctuated<Meta, Token![,]> = Punctuated::parse_terminated(attributes)?;
87        for meta in meta_list {
88            if let Meta::NameValue(meta_name_value) = meta {
89                let ident = meta_name_value.path.get_ident().unwrap();
90                let ident_str = ident.to_string();
91
92                match ident_str.as_str() {
93                    "name" | "description" => {
94                        let value = match &meta_name_value.value {
95                            Expr::Lit(ExprLit {
96                                lit: Lit::Str(lit_str),
97                                ..
98                            }) => lit_str.value(),
99                            Expr::Macro(expr_macro) => {
100                                let mac = &expr_macro.mac;
101                                if mac.path.is_ident("concat") {
102                                    let args: ExprList = syn::parse2(mac.tokens.clone())?;
103                                    let mut result = String::new();
104                                    for expr in args.exprs {
105                                        if let Expr::Lit(ExprLit {
106                                            lit: Lit::Str(lit_str),
107                                            ..
108                                        }) = expr
109                                        {
110                                            result.push_str(&lit_str.value());
111                                        } else {
112                                            return Err(Error::new_spanned(
113                                                expr,
114                                                "Only string literals are allowed inside concat!()",
115                                            ));
116                                        }
117                                    }
118                                    result
119                                } else {
120                                    return Err(Error::new_spanned(
121                                        expr_macro,
122                                        "Only concat!(...) is supported here",
123                                    ));
124                                }
125                            }
126                            _ => {
127                                return Err(Error::new_spanned(
128                                    &meta_name_value.value,
129                                    "Expected a string literal or concat!(...)",
130                                ));
131                            }
132                        };
133                        match ident_str.as_str() {
134                            "name" => name = Some(value),
135                            "description" => description = Some(value),
136                            _ => {}
137                        }
138                    }
139                    "destructive_hint" | "idempotent_hint" | "open_world_hint"
140                    | "read_only_hint" => {
141                        let value = match &meta_name_value.value {
142                            Expr::Lit(ExprLit {
143                                lit: Lit::Bool(lit_bool),
144                                ..
145                            }) => lit_bool.value,
146                            _ => {
147                                return Err(Error::new_spanned(
148                                    &meta_name_value.value,
149                                    "Expected a boolean literal",
150                                ));
151                            }
152                        };
153                        match ident_str.as_str() {
154                            "destructive_hint" => destructive_hint = Some(value),
155                            "idempotent_hint" => idempotent_hint = Some(value),
156                            "open_world_hint" => open_world_hint = Some(value),
157                            "read_only_hint" => read_only_hint = Some(value),
158                            _ => {}
159                        }
160                    }
161                    "title" => {
162                        let value = match &meta_name_value.value {
163                            Expr::Lit(ExprLit {
164                                lit: Lit::Str(lit_str),
165                                ..
166                            }) => lit_str.value(),
167                            _ => {
168                                return Err(Error::new_spanned(
169                                    &meta_name_value.value,
170                                    "Expected a string literal",
171                                ));
172                            }
173                        };
174                        title = Some(value);
175                    }
176                    _ => {}
177                }
178            }
179        }
180
181        // Validate presence and non-emptiness
182        if name.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
183            return Err(Error::new(
184                attributes.span(),
185                "The 'name' attribute is required and must not be empty.",
186            ));
187        }
188        if description
189            .as_ref()
190            .map(|s| s.trim().is_empty())
191            .unwrap_or(true)
192        {
193            return Err(Error::new(
194                attributes.span(),
195                "The 'description' attribute is required and must not be empty.",
196            ));
197        }
198
199        #[cfg(feature = "2024_11_05")]
200        let instance = Self { name, description };
201
202        #[cfg(feature = "2025_03_26")]
203        let instance = Self {
204            name,
205            description,
206            destructive_hint,
207            idempotent_hint,
208            open_world_hint,
209            read_only_hint,
210            title,
211        };
212
213        Ok(instance)
214    }
215}
216
217/// A procedural macro attribute to generate rust_mcp_schema::Tool related utility methods for a struct.
218///
219/// The `mcp_tool` macro generates an implementation for the annotated struct that includes:
220/// - A `tool_name()` method returning the tool's name as a string.
221/// - A `tool()` method returning a `rust_mcp_schema::Tool` instance with the tool's name,
222///   description, and input schema derived from the struct's fields.
223///
224/// # Attributes
225/// * `name` - The name of the tool (required, non-empty string).
226/// * `description` - A description of the tool (required, non-empty string).
227///
228/// # Panics
229/// Panics if the macro is applied to anything other than a struct.
230///
231/// # Example
232/// ```rust
233/// #[rust_mcp_macros::mcp_tool(name = "example_tool", description = "An example tool", idempotent_hint=true )]
234/// #[derive(rust_mcp_macros::JsonSchema)]
235/// struct ExampleTool {
236///     field1: String,
237///     field2: i32,
238/// }
239///
240/// assert_eq!(ExampleTool::tool_name() , "example_tool");
241/// let tool : rust_mcp_schema::Tool = ExampleTool::tool();
242/// assert_eq!(tool.name , "example_tool");
243/// assert_eq!(tool.description.unwrap() , "An example tool");
244/// assert_eq!(tool.annotations.unwrap().idempotent_hint.unwrap() , true);
245///
246/// let schema_properties = tool.input_schema.properties.unwrap();
247/// assert_eq!(schema_properties.len() , 2);
248/// assert!(schema_properties.contains_key("field1"));
249/// assert!(schema_properties.contains_key("field2"));
250///
251/// ```
252#[proc_macro_attribute]
253pub fn mcp_tool(attributes: TokenStream, input: TokenStream) -> TokenStream {
254    let input = parse_macro_input!(input as DeriveInput); // Parse the input as a function
255    let input_ident = &input.ident;
256
257    let macro_attributes = parse_macro_input!(attributes as McpToolMacroAttributes);
258
259    let tool_name = macro_attributes.name.unwrap_or_default();
260    let tool_description = macro_attributes.description.unwrap_or_default();
261
262    #[cfg(feature = "2025_03_26")]
263    let some_annotations = macro_attributes.destructive_hint.is_some()
264        || macro_attributes.idempotent_hint.is_some()
265        || macro_attributes.open_world_hint.is_some()
266        || macro_attributes.read_only_hint.is_some()
267        || macro_attributes.title.is_some();
268
269    #[cfg(feature = "2025_03_26")]
270    let annotations = if some_annotations {
271        let destructive_hint = macro_attributes
272            .destructive_hint
273            .map_or(quote! {None}, |v| quote! {Some(#v)});
274
275        let idempotent_hint = macro_attributes
276            .idempotent_hint
277            .map_or(quote! {None}, |v| quote! {Some(#v)});
278        let open_world_hint = macro_attributes
279            .open_world_hint
280            .map_or(quote! {None}, |v| quote! {Some(#v)});
281        let read_only_hint = macro_attributes
282            .read_only_hint
283            .map_or(quote! {None}, |v| quote! {Some(#v)});
284        let title = macro_attributes
285            .title
286            .map_or(quote! {None}, |v| quote! {Some(#v)});
287        quote! {
288            Some(rust_mcp_schema::ToolAnnotations {
289                                    destructive_hint: #destructive_hint,
290                                    idempotent_hint: #idempotent_hint,
291                                    open_world_hint: #open_world_hint,
292                                    read_only_hint: #read_only_hint,
293                                    title: #title,
294                                }),
295        }
296    } else {
297        quote! {None}
298    };
299
300    #[cfg(feature = "2025_03_26")]
301    let tool_token = quote! {
302        rust_mcp_schema::Tool {
303            name: #tool_name.to_string(),
304            description: Some(#tool_description.to_string()),
305            input_schema: rust_mcp_schema::ToolInputSchema::new(required, properties),
306            annotations: #annotations
307        }
308    };
309    #[cfg(feature = "2024_11_05")]
310    let tool_token = quote! {
311        rust_mcp_schema::Tool {
312            name: #tool_name.to_string(),
313            description: Some(#tool_description.to_string()),
314            input_schema: rust_mcp_schema::ToolInputSchema::new(required, properties),
315        }
316    };
317
318    let output = quote! {
319        impl #input_ident {
320            /// Returns the name of the tool as a string.
321            pub fn tool_name()->String{
322                #tool_name.to_string()
323            }
324
325            /// Constructs and returns a `rust_mcp_schema::Tool` instance.
326            ///
327            /// The tool includes the name, description, and input schema derived from
328            /// the struct's attributes.
329            pub fn tool()-> rust_mcp_schema::Tool
330            {
331                let json_schema = &#input_ident::json_schema();
332
333                let required: Vec<_> = match json_schema.get("required").and_then(|r| r.as_array()) {
334                    Some(arr) => arr
335                        .iter()
336                        .filter_map(|item| item.as_str().map(String::from))
337                        .collect(),
338                    None => Vec::new(), // Default to an empty vector if "required" is missing or not an array
339                };
340
341                let properties: Option<
342                    std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>>,
343                > = json_schema
344                    .get("properties")
345                    .and_then(|v| v.as_object()) // Safely extract "properties" as an object.
346                    .map(|properties| {
347                        properties
348                            .iter()
349                            .filter_map(|(key, value)| {
350                                serde_json::to_value(value)
351                                    .ok() // If serialization fails, return None.
352                                    .and_then(|v| {
353                                        if let serde_json::Value::Object(obj) = v {
354                                            Some(obj)
355                                        } else {
356                                            None
357                                        }
358                                    })
359                                    .map(|obj| (key.to_string(), obj)) // Return the (key, value) tuple
360                            })
361                            .collect()
362                    });
363
364                #tool_token
365            }
366        }
367        // Retain the original item (struct definition)
368        #input
369    };
370
371    TokenStream::from(output)
372}
373
374/// Derives a JSON Schema representation for a struct.
375///
376/// This procedural macro generates a `json_schema()` method for the annotated struct, returning a
377/// `serde_json::Map<String, serde_json::Value>` that represents the struct as a JSON Schema object.
378/// The schema includes the struct's fields as properties, with support for basic types, `Option<T>`,
379/// `Vec<T>`, and nested structs that also derive `JsonSchema`.
380///
381/// # Features
382/// - **Basic Types:** Maps `String` to `"string"`, `i32` to `"integer"`, `bool` to `"boolean"`, etc.
383/// - **`Option<T>`:** Adds `"nullable": true` to the schema of the inner type, indicating the field is optional.
384/// - **`Vec<T>`:** Generates an `"array"` schema with an `"items"` field describing the inner type.
385/// - **Nested Structs:** Recursively includes the schema of nested structs (assumed to derive `JsonSchema`),
386///   embedding their `"properties"` and `"required"` fields.
387/// - **Required Fields:** Adds a top-level `"required"` array listing field names not wrapped in `Option`.
388///
389/// # Notes
390/// It’s designed as a straightforward solution to meet the basic needs of this package, supporting
391/// common types and simple nested structures. For more advanced features or robust JSON Schema generation,
392/// consider exploring established crates like
393/// [`schemars`](https://crates.io/crates/schemars) on crates.io
394///
395/// # Limitations
396/// - Supports only structs with named fields (e.g., `struct S { field: Type }`).
397/// - Nested structs must also derive `JsonSchema`, or compilation will fail.
398/// - Unknown types are mapped to `{"type": "unknown"}`.
399/// - Type paths must be in scope (e.g., fully qualified paths like `my_mod::InnerStruct` work if imported).
400///
401/// # Panics
402/// - If the input is not a struct with named fields (e.g., tuple structs or enums).
403///
404/// # Dependencies
405/// Relies on `serde_json` for `Map` and `Value` types.
406///
407#[proc_macro_derive(JsonSchema)]
408pub fn derive_json_schema(input: TokenStream) -> TokenStream {
409    let input = parse_macro_input!(input as DeriveInput);
410    let name = &input.ident;
411
412    let fields = match &input.data {
413        Data::Struct(data) => match &data.fields {
414            Fields::Named(fields) => &fields.named,
415            _ => panic!("JsonSchema derive macro only supports named fields"),
416        },
417        _ => panic!("JsonSchema derive macro only supports structs"),
418    };
419
420    let field_entries = fields.iter().map(|field| {
421        let field_attrs = &field.attrs;
422        let renamed_field = renamed_field(field_attrs);
423        let field_name = renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string());
424        let field_type = &field.ty;
425
426        let schema = type_to_json_schema(field_type, field_attrs);
427        quote! {
428            properties.insert(
429                #field_name.to_string(),
430                serde_json::Value::Object(#schema)
431            );
432        }
433    });
434
435    let required_fields = fields.iter().filter_map(|field| {
436        let renamed_field = renamed_field(&field.attrs);
437        let field_name = renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string());
438
439        let field_type = &field.ty;
440        if !is_option(field_type) {
441            Some(quote! {
442                required.push(#field_name.to_string());
443            })
444        } else {
445            None
446        }
447    });
448
449    let expanded = quote! {
450        impl #name {
451            pub fn json_schema() -> serde_json::Map<String, serde_json::Value> {
452                let mut schema = serde_json::Map::new();
453                let mut properties = serde_json::Map::new();
454                let mut required = Vec::new();
455
456                #(#field_entries)*
457
458                #(#required_fields)*
459
460                schema.insert("type".to_string(), serde_json::Value::String("object".to_string()));
461                schema.insert("properties".to_string(), serde_json::Value::Object(properties));
462                if !required.is_empty() {
463                    schema.insert("required".to_string(), serde_json::Value::Array(
464                        required.into_iter().map(serde_json::Value::String).collect()
465                    ));
466                }
467
468                schema
469            }
470        }
471    };
472    TokenStream::from(expanded)
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use syn::parse_str;
479    #[test]
480    fn test_valid_macro_attributes() {
481        let input = r#"name = "test_tool", description = "A test tool.""#;
482        let parsed: McpToolMacroAttributes = parse_str(input).unwrap();
483
484        assert_eq!(parsed.name.unwrap(), "test_tool");
485        assert_eq!(parsed.description.unwrap(), "A test tool.");
486    }
487
488    #[test]
489    fn test_missing_name() {
490        let input = r#"description = "Only description""#;
491        let result: Result<McpToolMacroAttributes, Error> = parse_str(input);
492        assert!(result.is_err());
493        assert_eq!(
494            result.err().unwrap().to_string(),
495            "The 'name' attribute is required and must not be empty."
496        )
497    }
498
499    #[test]
500    fn test_missing_description() {
501        let input = r#"name = "OnlyName""#;
502        let result: Result<McpToolMacroAttributes, Error> = parse_str(input);
503        assert!(result.is_err());
504        assert_eq!(
505            result.err().unwrap().to_string(),
506            "The 'description' attribute is required and must not be empty."
507        )
508    }
509
510    #[test]
511    fn test_empty_name_field() {
512        let input = r#"name = "", description = "something""#;
513        let result: Result<McpToolMacroAttributes, Error> = parse_str(input);
514        assert!(result.is_err());
515        assert_eq!(
516            result.err().unwrap().to_string(),
517            "The 'name' attribute is required and must not be empty."
518        );
519    }
520    #[test]
521    fn test_empty_description_field() {
522        let input = r#"name = "my-tool", description = """#;
523        let result: Result<McpToolMacroAttributes, Error> = parse_str(input);
524        assert!(result.is_err());
525        assert_eq!(
526            result.err().unwrap().to_string(),
527            "The 'description' attribute is required and must not be empty."
528        );
529    }
530}