Skip to main content

tonin_mcp_macros/
lib.rs

1//! Proc-macro that derives an MCP tool surface from a gRPC service impl.
2//!
3//! # Don't depend on this crate directly
4//!
5//! Use [`tonin`] and write `#[tonin::mcp_expose]`. The umbrella crate
6//! re-exports this macro alongside the rmcp runtime it plugs into.
7//!
8//! # Example
9//!
10//! Before — a normal tonic service impl:
11//!
12//! ```rust,ignore
13//! #[tonic::async_trait]
14//! impl Greeter for GreeterImpl {
15//!     async fn say_hello(
16//!         &self,
17//!         req: Request<HelloRequest>,
18//!     ) -> Result<Response<HelloReply>, Status> { /* ... */ }
19//! }
20//! ```
21//!
22//! After — one attribute added:
23//!
24//! ```rust,ignore
25//! #[tonic::async_trait]
26//! #[tonin::mcp_expose]
27//! impl Greeter for GreeterImpl {
28//!     async fn say_hello(
29//!         &self,
30//!         req: Request<HelloRequest>,
31//!     ) -> Result<Response<HelloReply>, Status> { /* ... */ }
32//! }
33//! ```
34//!
35//! Every gRPC method is now also an MCP tool, dispatched through a generated
36//! `GreeterImplMcpAdapter` that `Service::handler` wires into the in-process
37//! MCP server.
38//!
39//! # Sample app
40//!
41//! <https://github.com/Rushit/tonin/tree/main/examples/greeter>
42//!
43//! [`tonin`]: https://crates.io/crates/tonin
44
45use proc_macro::TokenStream;
46use proc_macro2::TokenStream as TokenStream2;
47use quote::{format_ident, quote};
48use syn::{
49    FnArg, GenericArgument, ImplItem, ImplItemFn, ItemImpl, PathArguments, ReturnType, Type,
50    TypePath, parse_macro_input,
51};
52
53#[proc_macro_attribute]
54pub fn mcp_expose(_attr: TokenStream, item: TokenStream) -> TokenStream {
55    let input = parse_macro_input!(item as ItemImpl);
56
57    // The Self type of the impl block — `GreeterImpl` in the example.
58    let self_ty = &input.self_ty;
59    let self_ident = extract_type_ident(self_ty).unwrap_or_else(|| format_ident!("Impl"));
60    let adapter_ident = format_ident!("{}McpAdapter", self_ident);
61
62    // Walk methods, build per-method tool entries.
63    let mut tool_methods = Vec::new();
64    let mut skipped = Vec::new();
65    for item in &input.items {
66        if let ImplItem::Fn(method) = item {
67            match parse_grpc_method(method) {
68                Some(spec) => tool_methods.push(emit_adapter_tool(self_ty, &spec)),
69                None => skipped.push(method.sig.ident.to_string()),
70            }
71        }
72    }
73    if tool_methods.is_empty() {
74        let msg = format!(
75            "mcp_expose: no gRPC-shaped methods found. Methods seen: [{}]. \
76             Expected each to be `async fn name(&self, req: Request<X>) -> Result<Response<Y>, Status>`.",
77            skipped.join(", ")
78        );
79        return syn::Error::new_spanned(self_ty, msg)
80            .to_compile_error()
81            .into();
82    }
83
84    // rmcp's `#[tool_router]` / `#[tool_handler]` macros pattern-match
85    // on `#[tool]` by attribute NAME, not by full path. So we have to
86    // emit bare `#[tool(...)]` and bring the macros into scope at the
87    // expansion site. We do that via a `use rmcp::...` inside the
88    // emitted code — the user's crate has `rmcp` + `rmcp-macros` as
89    // deps via the scaffold's Cargo.toml.
90    let adapter = quote! {
91        // -------------------------------------------------------------
92        // tonin generated MCP adapter for the impl block above.
93        // -------------------------------------------------------------
94        use ::rmcp::{tool, tool_router, tool_handler, ServerHandler as __ToninMcpServerHandler};
95
96        #[derive(::std::clone::Clone)]
97        pub struct #adapter_ident {
98            inner: ::std::sync::Arc<#self_ty>,
99            tool_router: ::rmcp::handler::server::router::tool::ToolRouter<#adapter_ident>,
100        }
101
102        impl #adapter_ident {
103            pub fn new(inner: #self_ty) -> Self {
104                Self {
105                    inner: ::std::sync::Arc::new(inner),
106                    tool_router: Self::tool_router(),
107                }
108            }
109        }
110
111        #[tool_router]
112        impl #adapter_ident {
113            #(#tool_methods)*
114        }
115
116        #[tool_handler]
117        impl __ToninMcpServerHandler for #adapter_ident {
118            fn get_info(&self) -> ::rmcp::model::ServerInfo {
119                ::rmcp::model::ServerInfo::new(
120                    ::rmcp::model::ServerCapabilities::builder().enable_tools().build(),
121                )
122                .with_server_info(::rmcp::model::Implementation::from_build_env())
123                .with_protocol_version(::rmcp::model::ProtocolVersion::V_2024_11_05)
124                .with_instructions(
125                    "tonin service MCP endpoint. Tools auto-derived from gRPC methods.".to_string(),
126                )
127            }
128        }
129    };
130
131    let expanded = quote! {
132        #input
133        #adapter
134    };
135
136    expanded.into()
137}
138
139struct GrpcMethodSpec {
140    rust_name: syn::Ident,
141    description: String,
142    request_type: Type,
143}
144
145fn parse_grpc_method(method: &ImplItemFn) -> Option<GrpcMethodSpec> {
146    let sig = &method.sig;
147    sig.asyncness.as_ref()?;
148    let mut inputs = sig.inputs.iter();
149    match inputs.next() {
150        Some(FnArg::Receiver(_)) => {}
151        _ => return None,
152    }
153    let second = inputs.next()?;
154    let pat_type = match second {
155        FnArg::Typed(t) => t,
156        _ => return None,
157    };
158    let request_type = extract_request_inner(&pat_type.ty)?;
159    // Strict return-type check was rejecting valid `std::result::Result<...>`
160    // shapes in observed user code. The signal that this is a gRPC method
161    // is already strong from the first two params (&self + Request<X>) —
162    // just check that there IS a return type at all.
163    if !matches!(&sig.output, ReturnType::Type(_, _)) {
164        return None;
165    }
166    let rust_name = sig.ident.clone();
167    let description =
168        extract_doc_comment(method).unwrap_or_else(|| format!("gRPC method {rust_name}"));
169    Some(GrpcMethodSpec {
170        rust_name,
171        description,
172        request_type,
173    })
174}
175
176fn extract_type_ident(ty: &Type) -> Option<syn::Ident> {
177    if let Type::Path(TypePath { path, .. }) = ty {
178        return path.segments.last().map(|s| s.ident.clone());
179    }
180    None
181}
182
183fn extract_request_inner(ty: &Type) -> Option<Type> {
184    let path = match ty {
185        Type::Path(TypePath { path, .. }) => path,
186        _ => return None,
187    };
188    let last = path.segments.last()?;
189    if last.ident != "Request" {
190        return None;
191    }
192    let args = match &last.arguments {
193        PathArguments::AngleBracketed(a) => a,
194        _ => return None,
195    };
196    for arg in &args.args {
197        if let GenericArgument::Type(t) = arg {
198            return Some(t.clone());
199        }
200    }
201    None
202}
203
204fn extract_doc_comment(method: &ImplItemFn) -> Option<String> {
205    for attr in &method.attrs {
206        if attr.path().is_ident("doc")
207            && let syn::Meta::NameValue(nv) = &attr.meta
208            && let syn::Expr::Lit(syn::ExprLit {
209                lit: syn::Lit::Str(s),
210                ..
211            }) = &nv.value
212        {
213            return Some(s.value().trim().to_string());
214        }
215    }
216    None
217}
218
219/// Emit the rmcp `#[tool]` method that wraps a single user gRPC method.
220///
221/// The body calls into the user's impl via `self.inner.{method}(...)`,
222/// then serializes the response message to JSON and returns it as a
223/// rmcp text-content CallToolResult.
224fn emit_adapter_tool(self_ty: &Type, spec: &GrpcMethodSpec) -> TokenStream2 {
225    let method = &spec.rust_name;
226    let description = &spec.description;
227    let req_ty = &spec.request_type;
228    quote! {
229        #[tool(description = #description)]
230        async fn #method(
231            &self,
232            ::rmcp::handler::server::wrapper::Parameters(req): ::rmcp::handler::server::wrapper::Parameters<#req_ty>,
233        ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::ErrorData> {
234            let inner: ::std::sync::Arc<#self_ty> = ::std::sync::Arc::clone(&self.inner);
235            let tonic_req = ::tonic::Request::new(req);
236            match inner.#method(tonic_req).await {
237                Ok(resp) => {
238                    let body = resp.into_inner();
239                    let body_json = ::serde_json::to_string(&body)
240                        .map_err(|e| ::rmcp::ErrorData::internal_error(
241                            format!("serialize response: {}", e),
242                            ::std::option::Option::None,
243                        ))?;
244                    Ok(::rmcp::model::CallToolResult::success(vec![
245                        ::rmcp::model::Content::text(body_json),
246                    ]))
247                }
248                Err(status) => Err(::rmcp::ErrorData::internal_error(
249                    status.message().to_string(),
250                    ::std::option::Option::None,
251                )),
252            }
253        }
254    }
255}