Skip to content

Commit 4546c53

Browse files
committed
feat(macros): add tool_router(server_handler) to elide separate #[tool_handler] impl
1 parent 52ac7fc commit 4546c53

File tree

5 files changed

+175
-28
lines changed

5 files changed

+175
-28
lines changed

crates/rmcp-macros/README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ For **getting started** and **full MCP feature documentation**, see the [main RE
2020
| Macro | Description |
2121
|-------|-------------|
2222
| [`#[tool]`][tool] | Mark a function as an MCP tool handler |
23-
| [`#[tool_router]`][tool_router] | Generate a tool router from an impl block |
23+
| [`#[tool_router]`][tool_router] | Generate a tool router from an impl block (optional `server_handler` flag elides a separate `#[tool_handler]` block for tools-only servers) |
2424
| [`#[tool_handler]`][tool_handler] | Generate `call_tool` and `list_tools` handler methods |
2525
| [`#[prompt]`][prompt] | Mark a function as an MCP prompt handler |
2626
| [`#[prompt_router]`][prompt_router] | Generate a prompt router from an impl block |
@@ -37,6 +37,25 @@ For **getting started** and **full MCP feature documentation**, see the [main RE
3737

3838
## Quick Example
3939

40+
Tools-only server with a single `impl` block (`server_handler` expands `#[tool_handler]` in a second macro pass):
41+
42+
```rust,ignore
43+
use rmcp::{tool, tool_router};
44+
45+
#[derive(Clone)]
46+
struct MyServer;
47+
48+
#[tool_router(server_handler)]
49+
impl MyServer {
50+
#[tool(description = "Say hello")]
51+
async fn hello(&self) -> String {
52+
"Hello, world!".into()
53+
}
54+
}
55+
```
56+
57+
If you need custom `#[tool_handler(...)]` arguments (e.g. `instructions`, `name`, or stacked `#[prompt_handler]` on the same `impl ServerHandler`), use two blocks instead:
58+
4059
```rust,ignore
4160
use rmcp::{tool, tool_router, tool_handler, ServerHandler};
4261

crates/rmcp-macros/src/lib.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream {
5252
///
5353
/// ## Usage
5454
///
55-
/// | field | type | usage |
56-
/// | :- | :- | :- |
57-
/// | `router` | `Ident` | The name of the router function to be generated. Defaults to `tool_router`. |
58-
/// | `vis` | `Visibility` | The visibility of the generated router function. Defaults to empty. |
55+
/// | field | type | usage |
56+
/// | :- | :- | :- |
57+
/// | `router` | `Ident` | The name of the router function to be generated. Defaults to `tool_router`. |
58+
/// | `vis` | `Visibility` | The visibility of the generated router function. Defaults to empty. |
59+
/// | `server_handler` | `flag` | When set, also emits `#[::rmcp::tool_handler]` on `impl ServerHandler for Self` so you can omit a separate `#[tool_handler]` block. |
5960
///
6061
/// ## Example
6162
///
@@ -73,6 +74,24 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream {
7374
/// impl ServerHandler for MyToolHandler {}
7475
/// ```
7576
///
77+
/// ### Eliding `#[tool_handler]`
78+
///
79+
/// For a tools-only server, pass `server_handler` so the `impl ServerHandler` block is not written by hand:
80+
///
81+
/// ```rust,ignore
82+
/// #[tool_router(server_handler)]
83+
/// impl MyToolHandler {
84+
/// #[tool]
85+
/// fn my_tool() {}
86+
/// }
87+
/// ```
88+
///
89+
/// This expands in two steps: first `#[tool_router]` emits the inherent impl plus
90+
/// `#[::rmcp::tool_handler] impl ServerHandler for MyToolHandler {}`, then `#[tool_handler]`
91+
/// fills in `call_tool`, `list_tools`, `get_info`, and related methods. If you combine tools with
92+
/// prompts or tasks on the **same** `impl ServerHandler` block (stacked `#[tool_handler]` /
93+
/// `#[prompt_handler]` attributes), keep using an explicit `#[tool_handler]` impl instead of `server_handler`.
94+
///
7695
/// Or specify the visibility and router name, which would be helpful when you want to combine multiple routers into one:
7796
///
7897
/// ```rust,ignore

crates/rmcp-macros/src/tool_router.rs

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
//! ```ignore
2-
//! #[rmcp::tool_router(router)]
3-
//! impl Handler {
4-
//!
5-
//! }
6-
//! ```
1+
//! Procedural macro implementation for `#[tool_router]` (see `lib.rs`).
72
//!
3+
//! When `server_handler` is set, we emit a second `impl ServerHandler` item decorated with
4+
//! `#[::rmcp::tool_handler]` so `tool_handler` expands in a later proc-macro pass—keeping all
5+
//! tool dispatch and `get_info` logic in `tool_handler.rs` without duplicating it here.
86
97
use darling::{FromMeta, ast::NestedMeta};
108
use proc_macro2::TokenStream;
@@ -16,21 +14,29 @@ use syn::{Ident, ImplItem, ItemImpl, Visibility};
1614
pub struct ToolRouterAttribute {
1715
pub router: Ident,
1816
pub vis: Option<Visibility>,
17+
/// When set, also emit `#[::rmcp::tool_handler]` on `impl ServerHandler for Self` so callers
18+
/// can skip a separate `#[tool_handler]` block (expanded in a later macro pass).
19+
pub server_handler: bool,
1920
}
2021

2122
impl Default for ToolRouterAttribute {
2223
fn default() -> Self {
2324
Self {
2425
router: format_ident!("tool_router"),
2526
vis: None,
27+
server_handler: false,
2628
}
2729
}
2830
}
2931

3032
pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
3133
let attr_args = NestedMeta::parse_meta_list(attr)?;
32-
let ToolRouterAttribute { router, vis } = ToolRouterAttribute::from_list(&attr_args)?;
33-
let mut item_impl = syn::parse2::<ItemImpl>(input.clone())?;
34+
let ToolRouterAttribute {
35+
router,
36+
vis,
37+
server_handler,
38+
} = ToolRouterAttribute::from_list(&attr_args)?;
39+
let mut item_impl = syn::parse2::<ItemImpl>(input)?;
3440
// find all function marked with `#[rmcp::tool]`
3541
let tool_attr_fns: Vec<_> = item_impl
3642
.items
@@ -52,7 +58,7 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result<TokenSt
5258
}
5359
})
5460
.collect();
55-
let mut routers = vec![];
61+
let mut routers = Vec::with_capacity(tool_attr_fns.len());
5662
for handler in tool_attr_fns {
5763
let tool_attr_fn_ident = format_ident!("{handler}_tool_attr");
5864
routers.push(quote! {
@@ -66,26 +72,69 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result<TokenSt
6672
}
6773
})?;
6874
item_impl.items.push(router_fn);
69-
Ok(item_impl.into_token_stream())
75+
76+
if !server_handler {
77+
return Ok(item_impl.into_token_stream());
78+
}
79+
80+
if item_impl.trait_.is_some() {
81+
return Err(syn::Error::new_spanned(
82+
item_impl,
83+
"`server_handler` is only supported on inherent impl blocks (e.g. `impl MyType { ... }`)",
84+
));
85+
}
86+
87+
let self_ty = &item_impl.self_ty;
88+
let (impl_generics, ty_generics, where_clause) = item_impl.generics.split_for_impl();
89+
90+
Ok(quote! {
91+
#item_impl
92+
93+
#[::rmcp::tool_handler(router = Self::#router())]
94+
impl #impl_generics ::rmcp::ServerHandler for #self_ty #ty_generics #where_clause {}
95+
})
7096
}
7197

7298
#[cfg(test)]
7399
mod test {
74100
use super::*;
101+
75102
#[test]
76-
fn test_router_attr() -> Result<(), Box<dyn std::error::Error>> {
103+
fn tool_router_attribute_parses_router_visibility_and_defaults_server_handler_off()
104+
-> syn::Result<()> {
77105
let attr = quote! {
78106
router = test_router,
79107
vis = "pub(crate)"
80108
};
81109
let attr_args = NestedMeta::parse_meta_list(attr)?;
82-
let ToolRouterAttribute { router, vis } = ToolRouterAttribute::from_list(&attr_args)?;
83-
println!("router: {}", router);
84-
if let Some(vis) = vis {
85-
println!("visibility: {}", vis.to_token_stream());
86-
} else {
87-
println!("visibility: None");
88-
}
110+
let ToolRouterAttribute {
111+
router,
112+
vis,
113+
server_handler,
114+
} = ToolRouterAttribute::from_list(&attr_args)?;
115+
assert_eq!(router.to_string(), "test_router");
116+
assert!(vis.is_some(), "vis = \"pub(crate)\" should parse");
117+
assert!(
118+
!server_handler,
119+
"server_handler should default to false when omitted"
120+
);
121+
Ok(())
122+
}
123+
124+
#[test]
125+
fn tool_router_attribute_parses_server_handler_flag() -> syn::Result<()> {
126+
let attr = quote! {
127+
router = custom_router,
128+
server_handler
129+
};
130+
let attr_args = NestedMeta::parse_meta_list(attr)?;
131+
let ToolRouterAttribute {
132+
router,
133+
server_handler,
134+
..
135+
} = ToolRouterAttribute::from_list(&attr_args)?;
136+
assert_eq!(router.to_string(), "custom_router");
137+
assert!(server_handler);
89138
Ok(())
90139
}
91140
}

crates/rmcp/src/handler/server/router/tool.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//!
66
//! ```rust
77
//! # use rmcp::{
8-
//! # tool_router, tool, tool_handler, ServerHandler,
8+
//! # tool_router, tool,
99
//! # handler::server::{wrapper::{Parameters, Json}, tool::ToolRouter},
1010
//! # schemars
1111
//! # };
@@ -21,7 +21,7 @@
2121
//! struct AddOutput {
2222
//! sum: usize
2323
//! }
24-
//! #[tool_router]
24+
//! #[tool_router(server_handler)]
2525
//! impl Server {
2626
//! #[tool(name = "adder", description = "Modular add two integers")]
2727
//! fn add(
@@ -31,11 +31,13 @@
3131
//! Json(AddOutput { sum: left.wrapping_add(right) })
3232
//! }
3333
//! }
34-
//!
35-
//! #[tool_handler]
36-
//! impl ServerHandler for Server {}
3734
//! ```
3835
//!
36+
//! The `server_handler` flag emits `#[tool_handler]` for you (tools-only servers). For custom
37+
//! `#[tool_handler(...)]` options or multiple handler macros on one `impl ServerHandler`, write
38+
//! `#[tool_router]` and `#[tool_handler] impl ServerHandler for ...` explicitly—see
39+
//! [`tool_router`][crate::tool_router] and [`tool_handler`][crate::tool_handler].
40+
//!
3941
//! Using the macro-based code pattern above is suitable for small MCP servers with simple interfaces.
4042
//! When the business logic become larger, it is recommended that each tool should reside
4143
//! in individual file, combined into MCP server using [`SyncTool`] and [`AsyncTool`] traits.

crates/rmcp/tests/test_tool_macros.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,64 @@ async fn test_minimal_server_tool_call() -> anyhow::Result<()> {
449449
Ok(())
450450
}
451451

452+
/// Same minimal pattern as [`MinimalServer`], but `#[tool_handler]` is omitted using
453+
/// `#[tool_router(server_handler)]` (emits `#[tool_handler]` for a second macro pass).
454+
#[derive(Debug, Clone)]
455+
pub struct ElidedToolHandlerServer;
456+
457+
#[tool_router(server_handler)]
458+
impl ElidedToolHandlerServer {
459+
#[tool(description = "Say hi")]
460+
fn hi(&self) -> String {
461+
"hi".to_string()
462+
}
463+
}
464+
465+
#[test]
466+
fn test_tool_router_server_handler_flag_matches_minimal_server_get_info() {
467+
let server = ElidedToolHandlerServer;
468+
let info = server.get_info();
469+
470+
assert!(info.capabilities.tools.is_some());
471+
assert!(
472+
info.capabilities.prompts.is_none(),
473+
"prompts should not be auto-enabled"
474+
);
475+
}
476+
477+
#[tokio::test]
478+
async fn test_tool_router_server_handler_flag_end_to_end_tool_call() -> anyhow::Result<()> {
479+
let (server_transport, client_transport) = tokio::io::duplex(4096);
480+
481+
let server_handle = tokio::spawn(async move {
482+
ElidedToolHandlerServer
483+
.serve(server_transport)
484+
.await?
485+
.waiting()
486+
.await?;
487+
anyhow::Ok(())
488+
});
489+
490+
let client = DummyClientHandler::default()
491+
.serve(client_transport)
492+
.await?;
493+
494+
let result = client.call_tool(CallToolRequestParams::new("hi")).await?;
495+
496+
let text = result
497+
.content
498+
.first()
499+
.and_then(|c| c.raw.as_text())
500+
.map(|t| t.text.as_str())
501+
.expect("Expected text content");
502+
503+
assert_eq!(text, "hi");
504+
505+
client.cancel().await?;
506+
server_handle.await??;
507+
Ok(())
508+
}
509+
452510
/// Server with custom name/version/instructions via tool_handler attributes.
453511
#[derive(Debug, Clone)]
454512
pub struct CustomInfoServer;

0 commit comments

Comments
 (0)