Skip to content

#[tool_router] builds an empty router without complaining when the impl has no directly-visible #[tool] fn #1174

Description

@SergiusDu

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:

  1. 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.
  2. A warn-level diagnostic saying the same.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions