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