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    // Conditionally select the path for Tool
258    let base_crate = if cfg!(feature = "sdk") {
259        quote! { rust_mcp_sdk::schema }
260    } else {
261        quote! { rust_mcp_schema }
262    };
263
264    let macro_attributes = parse_macro_input!(attributes as McpToolMacroAttributes);
265
266    let tool_name = macro_attributes.name.unwrap_or_default();
267    let tool_description = macro_attributes.description.unwrap_or_default();
268
269    #[cfg(feature = "2025_03_26")]
270    let some_annotations = macro_attributes.destructive_hint.is_some()
271        || macro_attributes.idempotent_hint.is_some()
272        || macro_attributes.open_world_hint.is_some()
273        || macro_attributes.read_only_hint.is_some()
274        || macro_attributes.title.is_some();
275
276    #[cfg(feature = "2025_03_26")]
277    let annotations = if some_annotations {
278        let destructive_hint = macro_attributes
279            .destructive_hint
280            .map_or(quote! {None}, |v| quote! {Some(#v)});
281
282        let idempotent_hint = macro_attributes
283            .idempotent_hint
284            .map_or(quote! {None}, |v| quote! {Some(#v)});
285        let open_world_hint = macro_attributes
286            .open_world_hint
287            .map_or(quote! {None}, |v| quote! {Some(#v)});
288        let read_only_hint = macro_attributes
289            .read_only_hint
290            .map_or(quote! {None}, |v| quote! {Some(#v)});
291        let title = macro_attributes
292            .title
293            .map_or(quote! {None}, |v| quote! {Some(#v)});
294        quote! {
295            Some(#base_crate::ToolAnnotations {
296                                    destructive_hint: #destructive_hint,
297                                    idempotent_hint: #idempotent_hint,
298                                    open_world_hint: #open_world_hint,
299                                    read_only_hint: #read_only_hint,
300                                    title: #title,
301                                }),
302        }
303    } else {
304        quote! {None}
305    };
306
307    #[cfg(feature = "2025_03_26")]
308    let tool_token = quote! {
309        #base_crate::Tool {
310            name: #tool_name.to_string(),
311            description: Some(#tool_description.to_string()),
312            input_schema: #base_crate::ToolInputSchema::new(required, properties),
313            annotations: #annotations
314        }
315    };
316    #[cfg(feature = "2024_11_05")]
317    let tool_token = quote! {
318        #base_crate::Tool {
319            name: #tool_name.to_string(),
320            description: Some(#tool_description.to_string()),
321            input_schema: #base_crate::ToolInputSchema::new(required, properties),
322        }
323    };
324
325    let output = quote! {
326        impl #input_ident {
327            /// Returns the name of the tool as a string.
328            pub fn tool_name()->String{
329                #tool_name.to_string()
330            }
331
332            /// Constructs and returns a `rust_mcp_schema::Tool` instance.
333            ///
334            /// The tool includes the name, description, and input schema derived from
335            /// the struct's attributes.
336            pub fn tool()-> #base_crate::Tool
337            {
338                let json_schema = &#input_ident::json_schema();
339
340                let required: Vec<_> = match json_schema.get("required").and_then(|r| r.as_array()) {
341                    Some(arr) => arr
342                        .iter()
343                        .filter_map(|item| item.as_str().map(String::from))
344                        .collect(),
345                    None => Vec::new(), // Default to an empty vector if "required" is missing or not an array
346                };
347
348                let properties: Option<
349                    std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>>,
350                > = json_schema
351                    .get("properties")
352                    .and_then(|v| v.as_object()) // Safely extract "properties" as an object.
353                    .map(|properties| {
354                        properties
355                            .iter()
356                            .filter_map(|(key, value)| {
357                                serde_json::to_value(value)
358                                    .ok() // If serialization fails, return None.
359                                    .and_then(|v| {
360                                        if let serde_json::Value::Object(obj) = v {
361                                            Some(obj)
362                                        } else {
363                                            None
364                                        }
365                                    })
366                                    .map(|obj| (key.to_string(), obj)) // Return the (key, value) tuple
367                            })
368                            .collect()
369                    });
370
371                #tool_token
372            }
373        }
374        // Retain the original item (struct definition)
375        #input
376    };
377
378    TokenStream::from(output)
379}
380
381/// Derives a JSON Schema representation for a struct.
382///
383/// This procedural macro generates a `json_schema()` method for the annotated struct, returning a
384/// `serde_json::Map<String, serde_json::Value>` that represents the struct as a JSON Schema object.
385/// The schema includes the struct's fields as properties, with support for basic types, `Option<T>`,
386/// `Vec<T>`, and nested structs that also derive `JsonSchema`.
387///
388/// # Features
389/// - **Basic Types:** Maps `String` to `"string"`, `i32` to `"integer"`, `bool` to `"boolean"`, etc.
390/// - **`Option<T>`:** Adds `"nullable": true` to the schema of the inner type, indicating the field is optional.
391/// - **`Vec<T>`:** Generates an `"array"` schema with an `"items"` field describing the inner type.
392/// - **Nested Structs:** Recursively includes the schema of nested structs (assumed to derive `JsonSchema`),
393///   embedding their `"properties"` and `"required"` fields.
394/// - **Required Fields:** Adds a top-level `"required"` array listing field names not wrapped in `Option`.
395///
396/// # Notes
397/// It’s designed as a straightforward solution to meet the basic needs of this package, supporting
398/// common types and simple nested structures. For more advanced features or robust JSON Schema generation,
399/// consider exploring established crates like
400/// [`schemars`](https://crates.io/crates/schemars) on crates.io
401///
402/// # Limitations
403/// - Supports only structs with named fields (e.g., `struct S { field: Type }`).
404/// - Nested structs must also derive `JsonSchema`, or compilation will fail.
405/// - Unknown types are mapped to `{"type": "unknown"}`.
406/// - Type paths must be in scope (e.g., fully qualified paths like `my_mod::InnerStruct` work if imported).
407///
408/// # Panics
409/// - If the input is not a struct with named fields (e.g., tuple structs or enums).
410///
411/// # Dependencies
412/// Relies on `serde_json` for `Map` and `Value` types.
413///
414#[proc_macro_derive(JsonSchema)]
415pub fn derive_json_schema(input: TokenStream) -> TokenStream {
416    let input = parse_macro_input!(input as DeriveInput);
417    let name = &input.ident;
418
419    let fields = match &input.data {
420        Data::Struct(data) => match &data.fields {
421            Fields::Named(fields) => &fields.named,
422            _ => panic!("JsonSchema derive macro only supports named fields"),
423        },
424        _ => panic!("JsonSchema derive macro only supports structs"),
425    };
426
427    let field_entries = fields.iter().map(|field| {
428        let field_attrs = &field.attrs;
429        let renamed_field = renamed_field(field_attrs);
430        let field_name = renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string());
431        let field_type = &field.ty;
432
433        let schema = type_to_json_schema(field_type, field_attrs);
434        quote! {
435            properties.insert(
436                #field_name.to_string(),
437                serde_json::Value::Object(#schema)
438            );
439        }
440    });
441
442    let required_fields = fields.iter().filter_map(|field| {
443        let renamed_field = renamed_field(&field.attrs);
444        let field_name = renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string());
445
446        let field_type = &field.ty;
447        if !is_option(field_type) {
448            Some(quote! {
449                required.push(#field_name.to_string());
450            })
451        } else {
452            None
453        }
454    });
455
456    let expanded = quote! {
457        impl #name {
458            pub fn json_schema() -> serde_json::Map<String, serde_json::Value> {
459                let mut schema = serde_json::Map::new();
460                let mut properties = serde_json::Map::new();
461                let mut required = Vec::new();
462
463                #(#field_entries)*
464
465                #(#required_fields)*
466
467                schema.insert("type".to_string(), serde_json::Value::String("object".to_string()));
468                schema.insert("properties".to_string(), serde_json::Value::Object(properties));
469                if !required.is_empty() {
470                    schema.insert("required".to_string(), serde_json::Value::Array(
471                        required.into_iter().map(serde_json::Value::String).collect()
472                    ));
473                }
474
475                schema
476            }
477        }
478    };
479    TokenStream::from(expanded)
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use syn::parse_str;
486    #[test]
487    fn test_valid_macro_attributes() {
488        let input = r#"name = "test_tool", description = "A test tool.""#;
489        let parsed: McpToolMacroAttributes = parse_str(input).unwrap();
490
491        assert_eq!(parsed.name.unwrap(), "test_tool");
492        assert_eq!(parsed.description.unwrap(), "A test tool.");
493    }
494
495    #[test]
496    fn test_missing_name() {
497        let input = r#"description = "Only description""#;
498        let result: Result<McpToolMacroAttributes, Error> = parse_str(input);
499        assert!(result.is_err());
500        assert_eq!(
501            result.err().unwrap().to_string(),
502            "The 'name' attribute is required and must not be empty."
503        )
504    }
505
506    #[test]
507    fn test_missing_description() {
508        let input = r#"name = "OnlyName""#;
509        let result: Result<McpToolMacroAttributes, Error> = parse_str(input);
510        assert!(result.is_err());
511        assert_eq!(
512            result.err().unwrap().to_string(),
513            "The 'description' attribute is required and must not be empty."
514        )
515    }
516
517    #[test]
518    fn test_empty_name_field() {
519        let input = r#"name = "", description = "something""#;
520        let result: Result<McpToolMacroAttributes, Error> = parse_str(input);
521        assert!(result.is_err());
522        assert_eq!(
523            result.err().unwrap().to_string(),
524            "The 'name' attribute is required and must not be empty."
525        );
526    }
527    #[test]
528    fn test_empty_description_field() {
529        let input = r#"name = "my-tool", description = """#;
530        let result: Result<McpToolMacroAttributes, Error> = parse_str(input);
531        assert!(result.is_err());
532        assert_eq!(
533            result.err().unwrap().to_string(),
534            "The 'description' attribute is required and must not be empty."
535        );
536    }
537}