Summary
An impl annotated #[tool_router] that contains no directly visible #[tool] function compiles with no diagnostics and produces a router with zero tools. Nothing warns; the server simply advertises an empty catalogue, and the first thing to notice is a client at runtime.
The easiest way to reach that state is a macro_rules! helper inside the impl, which is a natural thing to reach for on a server with many similarly-shaped tools. Moving the same helper one level up — so it emits the whole #[tool_router] impl — works correctly, which makes the failing form look like a near-miss rather than a category error.
Version
rmcp 3.1.2, rustc 1.96.1, edition 2024.
Reproduction
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::tool;
pub struct Probe {
tool_router: ToolRouter<Probe>,
}
macro_rules! a_capability {
(broadcast = $broadcast:literal) => {
#[tool(description = $broadcast)]
async fn probe(&self) -> String { "probed".to_owned() }
};
}
#[rmcp::tool_router]
impl Probe {
a_capability!(broadcast = "what a capability would own");
}
#[test]
fn it_registers() {
// observed: 0
assert_eq!(Probe::tool_router().list_all().len(), 1);
}
Compiles with no errors and no warnings. list_all() returns an empty vector.
The same helper hoisted one level up registers correctly, description intact:
macro_rules! a_whole_surface {
($($name:ident => $broadcast:literal),* $(,)?) => {
#[rmcp::tool_router]
impl WrappedImpl {
$(
#[tool(description = $broadcast)]
async fn $name(&self) -> String { stringify!($name).to_owned() }
)*
}
};
}
a_whole_surface!(probe => "what a capability would own"); // registers 1 tool
Why it happens
This is a Rust expansion-order property rather than an rmcp defect. An attribute proc-macro receives the annotated item's unexpanded token stream, so #[tool_router] sees the a_capability!(…) invocation and not the function it becomes. There is no fix available inside the macro for the expansion itself, and the hoisted form works precisely because an item-level macro_rules! expands before the attribute inside its output runs.
Why it is still worth a diagnostic
The consequence is specific to this crate rather than generic: an MCP server whose catalogue is empty does nothing at all, and it does nothing quietly. There is no error to grep for and no failing test unless somebody thought to assert the router is non-empty. The symptom surfaces on the other side of a connection, in a model that simply finds no tools.
An impl annotated #[tool_router] that yields zero tools is almost always a mistake — collecting tools is the attribute's entire purpose. In preference order:
- A compile error, whose note mentions that
macro_rules! inside the impl is not expanded before this attribute runs and that wrapping the whole impl works. An escape hatch (#[tool_router(allow_empty)]) covers anyone who genuinely wants an empty router.
- A warn-level diagnostic saying the same.
- Failing both, a sentence in the
#[tool_router] documentation. The current page describes what the attribute collects, not what happens when it collects nothing.
What this report does not establish
It does not claim the expansion order should change — that is a language property. It also does not cover #[tool_handler] or the #[tool_router(server_handler)] single-block form; only the two-attribute form was measured.
Summary
An
implannotated#[tool_router]that contains no directly visible#[tool]function compiles with no diagnostics and produces a router with zero tools. Nothing warns; the server simply advertises an empty catalogue, and the first thing to notice is a client at runtime.The easiest way to reach that state is a
macro_rules!helper inside the impl, which is a natural thing to reach for on a server with many similarly-shaped tools. Moving the same helper one level up — so it emits the whole#[tool_router] impl— works correctly, which makes the failing form look like a near-miss rather than a category error.Version
rmcp 3.1.2, rustc 1.96.1, edition 2024.
Reproduction
Compiles with no errors and no warnings.
list_all()returns an empty vector.The same helper hoisted one level up registers correctly, description intact:
Why it happens
This is a Rust expansion-order property rather than an rmcp defect. An attribute proc-macro receives the annotated item's unexpanded token stream, so
#[tool_router]sees thea_capability!(…)invocation and not the function it becomes. There is no fix available inside the macro for the expansion itself, and the hoisted form works precisely because an item-levelmacro_rules!expands before the attribute inside its output runs.Why it is still worth a diagnostic
The consequence is specific to this crate rather than generic: an MCP server whose catalogue is empty does nothing at all, and it does nothing quietly. There is no error to grep for and no failing test unless somebody thought to assert the router is non-empty. The symptom surfaces on the other side of a connection, in a model that simply finds no tools.
An
implannotated#[tool_router]that yields zero tools is almost always a mistake — collecting tools is the attribute's entire purpose. In preference order:macro_rules!inside the impl is not expanded before this attribute runs and that wrapping the wholeimplworks. An escape hatch (#[tool_router(allow_empty)]) covers anyone who genuinely wants an empty router.#[tool_router]documentation. The current page describes what the attribute collects, not what happens when it collects nothing.What this report does not establish
It does not claim the expansion order should change — that is a language property. It also does not cover
#[tool_handler]or the#[tool_router(server_handler)]single-block form; only the two-attribute form was measured.