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