Skip to main content

libmcp_derive/
lib.rs

1//! Derive macros for `libmcp` projection traits.
2
3use proc_macro::TokenStream;
4use proc_macro_crate::{FoundCrate, crate_name};
5use quote::{format_ident, quote};
6use std::collections::BTreeSet;
7use syn::{
8    Data, DeriveInput, Field, Fields, Ident, LitStr, Result, Type, ext::IdentExt,
9    meta::ParseNestedMeta, parse_macro_input, parse_quote,
10};
11
12#[cfg(test)]
13use {libmcp as _, trybuild as _};
14
15const LIBMCP_ATTR: &str = "libmcp";
16const ATTR_KIND: &str = "kind";
17const ATTR_REFERENCE_ONLY: &str = "reference_only";
18const ATTR_ALLOW_OPAQUE_IDS: &str = "allow_opaque_ids";
19const FIELD_SELECTOR: &str = "selector";
20const FIELD_TITLE: &str = "title";
21const FIELD_SLUG: &str = "slug";
22const FIELD_SKIP: &str = "skip";
23const FIELD_SKIP_NONE: &str = "skip_none";
24const FIELD_FULL_ONLY: &str = "full_only";
25const FIELD_FULL: &str = "full";
26const FIELD_CONCISE_ONLY: &str = "concise_only";
27
28/// Derives `libmcp::StructuredProjection` and `libmcp::SurfacePolicy`.
29#[proc_macro_derive(ToolProjection, attributes(libmcp))]
30pub fn derive_tool_projection(input: TokenStream) -> TokenStream {
31    expand_tool_projection(parse_macro_input!(input as DeriveInput))
32        .unwrap_or_else(syn::Error::into_compile_error)
33        .into()
34}
35
36/// Derives `libmcp::SelectorProjection`.
37#[proc_macro_derive(SelectorProjection, attributes(libmcp))]
38pub fn derive_selector_projection(input: TokenStream) -> TokenStream {
39    expand_selector_projection(parse_macro_input!(input as DeriveInput))
40        .unwrap_or_else(syn::Error::into_compile_error)
41        .into()
42}
43
44fn expand_tool_projection(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
45    let DeriveInput {
46        attrs,
47        ident,
48        mut generics,
49        data,
50        ..
51    } = input;
52    let fields = named_fields(data, &ident, "ToolProjection")?;
53    let container = parse_container_attrs(&attrs)?;
54    let parsed_fields = fields
55        .iter()
56        .map(|field| parse_field_attrs(field).map(|attrs| (field, attrs)))
57        .collect::<Result<Vec<_>>>()?;
58    let libmcp = libmcp_path()?;
59
60    for (field, attrs) in &parsed_fields {
61        if attrs.projected() {
62            let ty = &field.ty;
63            generics
64                .make_where_clause()
65                .predicates
66                .push(parse_quote!(#ty: #libmcp::__macro::serde::Serialize));
67        }
68    }
69
70    let concise_entries = parsed_fields
71        .iter()
72        .filter(|(_, attrs)| attrs.in_concise())
73        .map(|(field, attrs)| project_field(field, attrs, &libmcp))
74        .collect::<Result<Vec<_>>>()?;
75    let full_entries = parsed_fields
76        .iter()
77        .filter(|(_, attrs)| attrs.in_full())
78        .map(|(field, attrs)| project_field(field, attrs, &libmcp))
79        .collect::<Result<Vec<_>>>()?;
80    let kind = container.kind.tokens(&libmcp);
81    let reference_only = container.reference_only;
82    let forbid_opaque_ids = !container.allow_opaque_ids;
83    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
84
85    Ok(quote! {
86        impl #impl_generics #libmcp::StructuredProjection for #ident #ty_generics #where_clause {
87            fn concise_projection(
88                &self,
89            ) -> ::std::result::Result<
90                #libmcp::__macro::serde_json::Value,
91                #libmcp::ProjectionError,
92            > {
93                let mut object = #libmcp::__macro::serde_json::Map::new();
94                #(#concise_entries)*
95                Ok(#libmcp::__macro::serde_json::Value::Object(object))
96            }
97
98            fn full_projection(
99                &self,
100            ) -> ::std::result::Result<
101                #libmcp::__macro::serde_json::Value,
102                #libmcp::ProjectionError,
103            > {
104                let mut object = #libmcp::__macro::serde_json::Map::new();
105                #(#full_entries)*
106                Ok(#libmcp::__macro::serde_json::Value::Object(object))
107            }
108        }
109
110        impl #impl_generics #libmcp::SurfacePolicy for #ident #ty_generics #where_clause {
111            fn projection_policy(&self) -> #libmcp::ProjectionPolicy {
112                #libmcp::ProjectionPolicy::from_surface(
113                    #kind,
114                    #forbid_opaque_ids,
115                    #reference_only,
116                )
117            }
118        }
119    })
120}
121
122fn expand_selector_projection(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
123    let DeriveInput {
124        attrs,
125        ident,
126        mut generics,
127        data,
128        ..
129    } = input;
130    let _container = parse_container_attrs(&attrs)?;
131    let fields = named_fields(data, &ident, "SelectorProjection")?;
132    let parsed_fields = fields
133        .iter()
134        .map(|field| parse_field_attrs(field).map(|attrs| (field, attrs)))
135        .collect::<Result<Vec<_>>>()?;
136    let libmcp = libmcp_path()?;
137
138    let slug_field = unique_marked_field(
139        &parsed_fields,
140        |attrs| attrs.has(FieldFlag::Selector),
141        "selector",
142    )?
143    .or_else(|| {
144        parsed_fields
145            .iter()
146            .find(|(field, _)| field_ident(field) == FIELD_SLUG)
147            .map(|(field, _)| *field)
148    })
149    .ok_or_else(|| syn::Error::new_spanned(&ident, "SelectorProjection needs a slug field"))?;
150    let title_field =
151        unique_marked_field(&parsed_fields, |attrs| attrs.has(FieldFlag::Title), "title")?.or_else(
152            || {
153                parsed_fields
154                    .iter()
155                    .find(|(field, _)| field_ident(field) == FIELD_TITLE)
156                    .map(|(field, _)| *field)
157            },
158        );
159
160    let slug_ident = named_ident(slug_field, "SelectorProjection needs named fields")?;
161    let slug_ty = &slug_field.ty;
162    generics.make_where_clause().predicates.push(parse_quote!(
163        #slug_ty: ::std::clone::Clone + ::std::convert::Into<::std::string::String>
164    ));
165    let title_tokens = if let Some(field) = title_field {
166        let title_ident = named_ident(field, "SelectorProjection needs named fields")?;
167        let title_ty = &field.ty;
168        generics.make_where_clause().predicates.push(parse_quote!(
169            #title_ty: ::std::clone::Clone + ::std::convert::Into<::std::string::String>
170        ));
171        quote! {
172            title: ::std::option::Option::Some(self.#title_ident.clone().into())
173        }
174    } else {
175        quote! { title: ::std::option::Option::None }
176    };
177    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
178
179    Ok(quote! {
180        impl #impl_generics #libmcp::SelectorProjection for #ident #ty_generics #where_clause {
181            fn selector_ref(&self) -> #libmcp::SelectorRef {
182                #libmcp::SelectorRef {
183                    slug: self.#slug_ident.clone().into(),
184                    #title_tokens,
185                }
186            }
187        }
188    })
189}
190
191fn named_fields(data: Data, ident: &Ident, derive: &str) -> Result<Vec<Field>> {
192    let data = match data {
193        Data::Struct(data) => data,
194        Data::Enum(_) | Data::Union(_) => {
195            return Err(syn::Error::new_spanned(
196                ident,
197                format!("{derive} only supports structs"),
198            ));
199        }
200    };
201    match data.fields {
202        Fields::Named(fields) => Ok(fields.named.into_iter().collect()),
203        Fields::Unnamed(_) | Fields::Unit => Err(syn::Error::new_spanned(
204            ident,
205            format!("{derive} requires named fields"),
206        )),
207    }
208}
209
210fn project_field(
211    field: &Field,
212    attrs: &FieldAttrs,
213    libmcp: &proc_macro2::TokenStream,
214) -> Result<proc_macro2::TokenStream> {
215    let ident = named_ident(field, "ToolProjection requires named fields")?;
216    let key = LitStr::new(field_ident(field).as_str(), ident.span());
217    if attrs.has(FieldFlag::SkipNone) {
218        Ok(quote! {
219            if let ::std::option::Option::Some(value) = &self.#ident {
220                object.insert(
221                    #key.to_owned(),
222                    #libmcp::__macro::serde_json::to_value(value)
223                        .map_err(#libmcp::ProjectionError::from)?,
224                );
225            }
226        })
227    } else {
228        Ok(quote! {
229            object.insert(
230                #key.to_owned(),
231                #libmcp::__macro::serde_json::to_value(&self.#ident)
232                    .map_err(#libmcp::ProjectionError::from)?,
233            );
234        })
235    }
236}
237
238fn named_ident<'a>(field: &'a Field, message: &str) -> Result<&'a Ident> {
239    field
240        .ident
241        .as_ref()
242        .ok_or_else(|| syn::Error::new_spanned(field, message))
243}
244
245fn field_ident(field: &Field) -> String {
246    field
247        .ident
248        .as_ref()
249        .map_or_else(String::new, |ident| ident.unraw().to_string())
250}
251
252fn unique_marked_field<'a>(
253    fields: &'a [(&'a Field, FieldAttrs)],
254    marked: impl Fn(&FieldAttrs) -> bool,
255    marker: &str,
256) -> Result<Option<&'a Field>> {
257    let mut matches = fields
258        .iter()
259        .filter(|(_, attrs)| marked(attrs))
260        .map(|(field, _)| *field);
261    let first = matches.next();
262    if let Some(duplicate) = matches.next() {
263        return Err(syn::Error::new_spanned(
264            duplicate,
265            format!("only one field may be marked {marker}"),
266        ));
267    }
268    Ok(first)
269}
270
271#[derive(Default)]
272struct ContainerAttrs {
273    kind: SurfaceKindAttr,
274    kind_seen: bool,
275    reference_only: bool,
276    allow_opaque_ids: bool,
277}
278
279fn parse_container_attrs(attrs: &[syn::Attribute]) -> Result<ContainerAttrs> {
280    let mut parsed = ContainerAttrs::default();
281    for attr in attrs
282        .iter()
283        .filter(|attr| attr.path().is_ident(LIBMCP_ATTR))
284    {
285        attr.parse_nested_meta(|meta| {
286            if meta.path.is_ident(ATTR_KIND) {
287                if parsed.kind_seen {
288                    return Err(meta.error("duplicate libmcp kind"));
289                }
290                let kind = meta.value()?.parse::<LitStr>()?;
291                parsed.kind = SurfaceKindAttr::parse(&kind)?;
292                parsed.kind_seen = true;
293                return Ok(());
294            }
295            if meta.path.is_ident(ATTR_REFERENCE_ONLY) {
296                return set_flag(&mut parsed.reference_only, &meta);
297            }
298            if meta.path.is_ident(ATTR_ALLOW_OPAQUE_IDS) {
299                return set_flag(&mut parsed.allow_opaque_ids, &meta);
300            }
301            Err(meta.error("unsupported libmcp container attribute"))
302        })?;
303    }
304    Ok(parsed)
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
308enum FieldFlag {
309    Selector,
310    Title,
311    Skip,
312    SkipNone,
313    FullOnly,
314    ConciseOnly,
315}
316
317#[derive(Default)]
318struct FieldAttrs {
319    flags: BTreeSet<FieldFlag>,
320}
321
322impl FieldAttrs {
323    fn has(&self, flag: FieldFlag) -> bool {
324        self.flags.contains(&flag)
325    }
326
327    fn projected(&self) -> bool {
328        !self.has(FieldFlag::Skip)
329    }
330
331    fn in_concise(&self) -> bool {
332        self.projected() && !self.has(FieldFlag::FullOnly)
333    }
334
335    fn in_full(&self) -> bool {
336        self.projected() && !self.has(FieldFlag::ConciseOnly)
337    }
338}
339
340fn parse_field_attrs(field: &Field) -> Result<FieldAttrs> {
341    let mut parsed = FieldAttrs::default();
342    for attr in field
343        .attrs
344        .iter()
345        .filter(|attr| attr.path().is_ident(LIBMCP_ATTR))
346    {
347        attr.parse_nested_meta(|meta| {
348            let flag = if meta.path.is_ident(FIELD_SELECTOR) {
349                FieldFlag::Selector
350            } else if meta.path.is_ident(FIELD_TITLE) {
351                FieldFlag::Title
352            } else if meta.path.is_ident(FIELD_SKIP) {
353                FieldFlag::Skip
354            } else if meta.path.is_ident(FIELD_SKIP_NONE) {
355                FieldFlag::SkipNone
356            } else if meta.path.is_ident(FIELD_FULL_ONLY) || meta.path.is_ident(FIELD_FULL) {
357                FieldFlag::FullOnly
358            } else if meta.path.is_ident(FIELD_CONCISE_ONLY) {
359                FieldFlag::ConciseOnly
360            } else {
361                return Err(meta.error("unsupported libmcp field attribute"));
362            };
363            insert_field_flag(&mut parsed.flags, flag, &meta)
364        })?;
365    }
366
367    if parsed.has(FieldFlag::Skip)
368        && [
369            FieldFlag::SkipNone,
370            FieldFlag::FullOnly,
371            FieldFlag::ConciseOnly,
372        ]
373        .into_iter()
374        .any(|flag| parsed.has(flag))
375    {
376        return Err(syn::Error::new_spanned(
377            field,
378            "skip cannot be combined with projection detail attributes",
379        ));
380    }
381    if parsed.has(FieldFlag::FullOnly) && parsed.has(FieldFlag::ConciseOnly) {
382        return Err(syn::Error::new_spanned(
383            field,
384            "full_only and concise_only are mutually exclusive",
385        ));
386    }
387    if parsed.has(FieldFlag::SkipNone) && !is_option(&field.ty) {
388        return Err(syn::Error::new_spanned(
389            &field.ty,
390            "skip_none requires an Option field",
391        ));
392    }
393    if parsed.has(FieldFlag::Selector) && parsed.has(FieldFlag::Title) {
394        return Err(syn::Error::new_spanned(
395            field,
396            "selector and title must identify different fields",
397        ));
398    }
399    Ok(parsed)
400}
401
402fn insert_field_flag(
403    flags: &mut BTreeSet<FieldFlag>,
404    flag: FieldFlag,
405    meta: &ParseNestedMeta<'_>,
406) -> Result<()> {
407    if meta.input.peek(syn::Token![=]) || meta.input.peek(syn::token::Paren) {
408        return Err(meta.error("libmcp flag does not take a value"));
409    }
410    if !flags.insert(flag) {
411        return Err(meta.error("duplicate libmcp flag"));
412    }
413    Ok(())
414}
415
416fn set_flag(slot: &mut bool, meta: &ParseNestedMeta<'_>) -> Result<()> {
417    if meta.input.peek(syn::Token![=]) || meta.input.peek(syn::token::Paren) {
418        return Err(meta.error("libmcp flag does not take a value"));
419    }
420    if std::mem::replace(slot, true) {
421        return Err(meta.error("duplicate libmcp flag"));
422    }
423    Ok(())
424}
425
426fn is_option(ty: &Type) -> bool {
427    matches!(
428        ty,
429        Type::Path(path) if path.qself.is_none()
430            && path.path.segments.last().is_some_and(|segment| segment.ident == "Option")
431    )
432}
433
434#[derive(Default)]
435enum SurfaceKindAttr {
436    Overview,
437    List,
438    #[default]
439    Read,
440    Mutation,
441    Ops,
442}
443
444impl SurfaceKindAttr {
445    fn parse(kind: &LitStr) -> Result<Self> {
446        match kind.value().trim().to_ascii_lowercase().as_str() {
447            "overview" => Ok(Self::Overview),
448            "list" => Ok(Self::List),
449            "read" => Ok(Self::Read),
450            "mutation" => Ok(Self::Mutation),
451            "ops" => Ok(Self::Ops),
452            _ => Err(syn::Error::new_spanned(
453                kind,
454                "kind must be overview, list, read, mutation, or ops",
455            )),
456        }
457    }
458
459    fn tokens(&self, libmcp: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
460        let variant = match self {
461            Self::Overview => quote!(Overview),
462            Self::List => quote!(List),
463            Self::Read => quote!(Read),
464            Self::Mutation => quote!(Mutation),
465            Self::Ops => quote!(Ops),
466        };
467        quote!(#libmcp::SurfaceKind::#variant)
468    }
469}
470
471fn libmcp_path() -> Result<proc_macro2::TokenStream> {
472    match crate_name("libmcp") {
473        Ok(FoundCrate::Itself) => Ok(quote!(crate)),
474        Ok(FoundCrate::Name(name)) => {
475            let ident = format_ident!("{name}");
476            Ok(quote!(::#ident))
477        }
478        Err(error) => Err(syn::Error::new(
479            proc_macro2::Span::call_site(),
480            format!("failed to locate libmcp crate: {error}"),
481        )),
482    }
483}