swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/libmcp-derive
diff options
context:
space:
mode:
Diffstat (limited to 'crates/libmcp-derive')
-rw-r--r--crates/libmcp-derive/Cargo.toml19
-rw-r--r--crates/libmcp-derive/src/lib.rs259
2 files changed, 278 insertions, 0 deletions
diff --git a/crates/libmcp-derive/Cargo.toml b/crates/libmcp-derive/Cargo.toml
new file mode 100644
index 0000000..7777392
--- /dev/null
+++ b/crates/libmcp-derive/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "libmcp-derive"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+description.workspace = true
+readme.workspace = true
+
+[lib]
+proc-macro = true
+
+[dependencies]
+proc-macro2.workspace = true
+quote.workspace = true
+syn.workspace = true
+
+[lints]
+workspace = true
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<proc_macro2::TokenStream> {
+ 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::<Vec<_>>(),
+ _ => {
+ 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::<Result<Vec<_>>>()?;
+ let full_entries = fields
+ .iter()
+ .filter(|field| include_in_full(field))
+ .map(project_field)
+ .collect::<Result<Vec<_>>>()?;
+
+ 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<proc_macro2::TokenStream> {
+ 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::<Vec<_>>(),
+ _ => {
+ 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<proc_macro2::TokenStream> {
+ 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<Ident>,
+ reference_only: bool,
+ allow_opaque_ids: bool,
+}
+
+fn parse_container_attrs(attrs: &[syn::Attribute]) -> Result<ContainerAttrs> {
+ 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::<LitStr>()?;
+ 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<Ident>) -> 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
+}