From bb92a05eb5446e07c6288e266bd06d7b5899eee5 Mon Sep 17 00:00:00 2001 From: main Date: Fri, 20 Mar 2026 20:14:50 -0400 Subject: Add projection traits and doctrine testkit --- crates/libmcp-derive/src/lib.rs | 259 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 crates/libmcp-derive/src/lib.rs (limited to 'crates/libmcp-derive/src') diff --git a/crates/libmcp-derive/src/lib.rs b/crates/libmcp-derive/src/lib.rs new file mode 100644 index 0000000..bee9185 --- /dev/null +++ b/crates/libmcp-derive/src/lib.rs @@ -0,0 +1,259 @@ +//! Derive macros for `libmcp` projection traits. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Field, Fields, Ident, LitStr, Result, parse_macro_input}; + +/// Derives `libmcp::StructuredProjection` and `libmcp::SurfacePolicy`. +#[proc_macro_derive(ToolProjection, attributes(libmcp))] +pub fn derive_tool_projection(input: TokenStream) -> TokenStream { + expand_tool_projection(parse_macro_input!(input as DeriveInput)) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Derives `libmcp::SelectorProjection`. +#[proc_macro_derive(SelectorProjection, attributes(libmcp))] +pub fn derive_selector_projection(input: TokenStream) -> TokenStream { + expand_selector_projection(parse_macro_input!(input as DeriveInput)) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +fn expand_tool_projection(input: DeriveInput) -> Result { + let ident = input.ident; + let data = match input.data { + Data::Struct(data) => data, + _ => { + return Err(syn::Error::new_spanned( + ident, + "ToolProjection only supports structs", + )); + } + }; + let fields = match data.fields { + Fields::Named(fields) => fields.named.into_iter().collect::>(), + _ => { + return Err(syn::Error::new_spanned( + ident, + "ToolProjection requires named fields", + )); + } + }; + let container = parse_container_attrs(&input.attrs)?; + let kind = surface_kind_expr(&container.kind); + let reference_only = container.reference_only; + let forbid_opaque_ids = !container.allow_opaque_ids; + + let concise_entries = fields + .iter() + .filter(|field| include_in_concise(field)) + .map(project_field) + .collect::>>()?; + let full_entries = fields + .iter() + .filter(|field| include_in_full(field)) + .map(project_field) + .collect::>>()?; + + Ok(quote! { + impl ::libmcp::StructuredProjection for #ident { + fn concise_projection( + &self, + ) -> ::std::result::Result<::serde_json::Value, ::libmcp::ProjectionError> { + let mut object = ::serde_json::Map::new(); + #(#concise_entries)* + Ok(::serde_json::Value::Object(object)) + } + + fn full_projection( + &self, + ) -> ::std::result::Result<::serde_json::Value, ::libmcp::ProjectionError> { + let mut object = ::serde_json::Map::new(); + #(#full_entries)* + Ok(::serde_json::Value::Object(object)) + } + } + + impl ::libmcp::SurfacePolicy for #ident { + const KIND: ::libmcp::SurfaceKind = #kind; + const FORBID_OPAQUE_IDS: bool = #forbid_opaque_ids; + const REFERENCE_ONLY: bool = #reference_only; + } + }) +} + +fn expand_selector_projection(input: DeriveInput) -> Result { + let ident = input.ident; + let data = match input.data { + Data::Struct(data) => data, + _ => { + return Err(syn::Error::new_spanned( + ident, + "SelectorProjection only supports structs", + )); + } + }; + let fields = match data.fields { + Fields::Named(fields) => fields.named.into_iter().collect::>(), + _ => { + return Err(syn::Error::new_spanned( + ident, + "SelectorProjection requires named fields", + )); + } + }; + + let slug_field = fields + .iter() + .find(|field| has_field_flag(field, "selector") || field_ident(field) == "slug") + .ok_or_else(|| syn::Error::new_spanned(&ident, "SelectorProjection needs a slug field"))?; + let title_field = fields + .iter() + .find(|field| has_field_flag(field, "title") || field_ident(field) == "title"); + + let slug_ident = slug_field.ident.as_ref().ok_or_else(|| { + syn::Error::new_spanned(slug_field, "SelectorProjection needs named fields") + })?; + let title_tokens = if let Some(field) = title_field { + let title_ident = field.ident.as_ref().ok_or_else(|| { + syn::Error::new_spanned(field, "SelectorProjection needs named fields") + })?; + quote! { title: ::std::option::Option::Some(self.#title_ident.clone().into()) } + } else { + quote! { title: ::std::option::Option::None } + }; + + Ok(quote! { + impl ::libmcp::SelectorProjection for #ident { + fn selector_ref(&self) -> ::libmcp::SelectorRef { + ::libmcp::SelectorRef { + slug: self.#slug_ident.clone(), + #title_tokens, + } + } + } + }) +} + +fn project_field(field: &Field) -> Result { + let ident = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(field, "ToolProjection requires named fields"))?; + let key = LitStr::new(field_ident(field).as_str(), ident.span()); + if has_field_flag(field, "skip_none") { + Ok(quote! { + if let ::std::option::Option::Some(value) = &self.#ident { + object.insert( + #key.to_owned(), + ::serde_json::to_value(value).map_err(::libmcp::ProjectionError::from)?, + ); + } + }) + } else { + Ok(quote! { + object.insert( + #key.to_owned(), + ::serde_json::to_value(&self.#ident).map_err(::libmcp::ProjectionError::from)?, + ); + }) + } +} + +fn include_in_concise(field: &Field) -> bool { + !has_field_flag(field, "skip") + && !has_field_flag(field, "full_only") + && !has_field_flag(field, "full") +} + +fn include_in_full(field: &Field) -> bool { + !has_field_flag(field, "skip") && !has_field_flag(field, "concise_only") +} + +fn field_ident(field: &Field) -> String { + field + .ident + .as_ref() + .map_or_else(String::new, ToString::to_string) +} + +#[derive(Default)] +struct ContainerAttrs { + kind: Option, + reference_only: bool, + allow_opaque_ids: bool, +} + +fn parse_container_attrs(attrs: &[syn::Attribute]) -> Result { + let mut parsed = ContainerAttrs::default(); + for attr in attrs.iter().filter(|attr| attr.path().is_ident("libmcp")) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("kind") { + let value = meta.value()?; + let kind = value.parse::()?; + parsed.kind = Some(Ident::new( + normalize_surface_kind(kind.value()).as_str(), + kind.span(), + )); + return Ok(()); + } + if meta.path.is_ident("reference_only") { + parsed.reference_only = true; + return Ok(()); + } + if meta.path.is_ident("allow_opaque_ids") { + parsed.allow_opaque_ids = true; + return Ok(()); + } + Err(meta.error("unsupported libmcp container attribute")) + })?; + } + Ok(parsed) +} + +fn has_field_flag(field: &Field, flag: &str) -> bool { + field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("libmcp")) + .any(|attr| attr_has_flag(attr, flag)) +} + +fn attr_has_flag(attr: &syn::Attribute, flag: &str) -> bool { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(flag) { + found = true; + return Ok(()); + } + Ok(()) + }); + found +} + +fn surface_kind_expr(kind: &Option) -> proc_macro2::TokenStream { + let ident = kind + .clone() + .unwrap_or_else(|| Ident::new("Read", proc_macro2::Span::call_site())); + quote!(::libmcp::SurfaceKind::#ident) +} + +fn normalize_surface_kind(kind: String) -> String { + let normalized = kind.trim().replace('-', "_"); + let mut out = String::new(); + let mut uppercase_next = true; + for ch in normalized.chars() { + if ch == '_' { + uppercase_next = true; + continue; + } + if uppercase_next { + out.extend(ch.to_uppercase()); + uppercase_next = false; + } else { + out.push(ch); + } + } + out +} -- cgit v1.2.3