Repository navigation
#[tool] async method fails to compile (FnOnce not general enough) when its call tree uses |x: &T| async move {} closures (e.g. stream::iter(..).map(|x| async move{..})) #1092
Description
Summary
An async #[tool] method fails to compile with a higher-ranked-trait-bound error if the transitive call tree of the tool method contains a closure |x: &T| async move { .. } — most commonly futures::stream::iter(coll.iter()).map(|x| async move { .. }).buffer_unordered(..). The same code compiles fine outside the #[tool] macro.
Environment
- rmcp 3.0.1 (crates.io)
- rustc 1.94.0 stable
- macOS (reproduced locally)
Minimal reproduction
Cargo.toml
[package]
name = "rmcp-async-repro"
version = "0.0.0"
edition = "2021"
[dependencies]
rmcp = { version = "3", features = ["server", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"src/main.rs
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router, ServerHandler,
};
use serde_json::Value;
#[derive(serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
struct Req {
msg: String,
}
#[derive(Clone, Default)]
struct S {
tool_router: ToolRouter<S>,
}
impl S {
async fn execute(&self, _name: &str, _args: &Value) -> CallToolResult {
use futures::stream::{self, StreamExt};
let ips = vec!["1.1.1.1".to_string()];
let _out: Vec<String> = stream::iter(ips.iter())
.map(|ip| async move { ip.to_string() }) // <-- trigger
.buffer_unordered(4)
.collect()
.await;
CallToolResult::default()
}
}
#[tool_router]
impl S {
#[tool(name = "echo_tool", description = "echo")]
async fn echo(&self, Parameters(req): Parameters<Req>) -> CallToolResult {
let args = serde_json::to_value(&req).unwrap_or_default();
self.execute("echo", &args).await
}
}
#[tool_handler]
impl ServerHandler for S {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info
}
}
fn main() {}Error
error: implementation of `FnOnce` is not general enough
--> src/main.rs (originates in the `#[tool]` attribute macro)
= note: closure with signature `fn(&'0 String) -> {async block@src/main.rs:26:23: 26:33}`
must implement `FnOnce<(&'1 String,)>`, for any two lifetimes `'0` and `'1`...
= note: ...but it actually implements `FnOnce<(&String,)>`
Analysis
The #[tool] macro generates dispatch code carrying a higher-ranked trait bound. A |x: &T| async move { .. } closure (e.g. produced by futures::stream::iter(..).map(..)) cannot satisfy a for<'a> Fn(&'a T) -> Future<..>-style bound — the long-standing rustc "async closure + HRTB" limitation. The pattern compiles in ordinary code because it is never placed under such a bound; the macro forces it into one.
Removing the stream::iter(..).map(|x| async move{..}) from the tool's call tree (or replacing it with an async fn helper + .then(..), or owning the items before the stream) makes the #[tool] macro compile. Implementing ServerHandler manually (without #[tool]) also avoids it.
Workaround
- Refactor
|x: &T| async move { .. }in the tool's call tree (use anasync fnhelper +.then(..), or own items before the stream), or - Implement
ServerHandlermanually instead of using#[tool].
Ask
If #[tool] could avoid forcing the user's call tree into an HRTB that async-move closures can't satisfy (e.g. boxing the future / restructuring the generated handler), that would unblock a fairly common futures::stream pattern. At minimum, documenting the limitation would save significant diagnosis time — the error is very hard to map back to the trigger.
Activity
IgorKasianenko commentedon Aug 11, 2026
I had a go at this and reduced it below the macro. The short version: the trigger is
the
Sendbound, not#[tool]'s codegen, and I do not think a macro change can fixit. I would like to do the documentation tier instead. Evidence below.
Setup:
rmcpfrommain(d81bfa8) as a path dependency, rustc 1.96.1 (thetoolchain pinned in
rust-toolchain.toml), the repro from the issue body unchanged.It fails exactly as reported.
Ladder — each step removes one thing:
#[tool]+#[tool_router]+#[tool_handler]#[tool]on the method only — no#[tool_router], never registered on a routerfn echo(&self) -> Pin<Box<dyn Future<Output = usize> + Send + '_>> { Box::pin(async move { self.execute().await }) }fn assert_send<F: Future + Send>(f: F) -> F { f }applied tos.execute()SendStep 4 is the one that decides it. There is no attribute macro, no
Box::pin, nodyn Future, and no higher-ranked bound anywhere — onlyF: Send— and the error isidentical, same closure, same "for any two lifetimes" note:
So the analysis in the issue body — that the macro forces the call tree under a
for<'a> Fn(&'a T) -> Future<..>bound — is not what is happening.#[tool]rewritesan
async fninto-> Pin<Box<dyn Future<Output = T> + Send + '_>>(
crates/rmcp-macros/src/tool.rs) because the router stores handlers asBoxFuture(
DynCallToolHandlerincrates/rmcp/src/handler/server/tool.rs), andBoxFutureisSend. Asking forSendis the entire trigger.tokio::spawn,-> impl Send, or anySend-bounded generic reproduces it identically with no rmcp in the picture.This is an open rustc bug: rust-lang/rust#104382
("async block with futures::stream::Buffered is not Send",
A-async-await,AsyncAwait-Triaged,T-compiler) andrust-lang/rust#126551. Their minimal
case has no macro and no rmcp:
I checked: still fails on 1.96.1.
Why I do not think there is a macro-side fix. The only lever the macro has is
dropping
+ Send, and the router's handler type cannot accept a non-Sendfuture.The
localcrate feature already does exactly that — I checked, and the repro compilesunder it — at the cost of a single-threaded server. Moving the boxing to the dispatch
boundary, or restructuring
the generated handler, changes where the
Sendobligation is written down, not whetherit holds — steps 3 and 4 show it fails on its own, with no macro involved. I would
rather not ship a codegen change that cannot fix the reported case.
#[prompt]has the same codegen and the sameMaybeBoxFuturebound, so it carries thesame limitation.
Workarounds, each verified against the exact repro under
#[tool]:.boxed()on the stream before.buffer_unordered(..)— one line, keeps the borrow:own the items instead of borrowing them (
ips.into_iter()rather thanips.iter()).the two already in this issue: an
async fnhelper plus.then(..), or a manualServerHandlerimpl.if the server does not have to be
Send, thelocalcrate feature drops+ Sendfrom the generated signature and the repro compiles unchanged.
What I would like to do, if you agree with the scope:
Send" section on the#[tool]and#[prompt]rustdoc: the signature the macro generates, why
Sendis required, the exact errortext so it is searchable, links to the two upstream issues, and the workarounds
above. Today the error points at the
#[tool]attribute and says nothing aboutSend, which sends people looking inside rmcp.crates/rmcp/testscovering thestream::iter().map().boxed().buffer_unordered()shape under#[tool]and#[prompt], so the documented workarounds do not rot.Is that the right scope? If you would rather this lived somewhere other than the macro
rustdoc, or not at all, say so and I will drop it.
docs(macros): document the Send limitation on async #[tool]/#[prompt]…
IgorKasianenko commentedon Aug 12, 2026
Branch with the documentation change, in case it is easier to read as a diff than as prose:
IgorKasianenko/rust-sdk@a408ab9(branchfix-1092-tool-macro-hrtb, on top ofd81bfa8).Three files, +171, no public API change:
Sendsection on the#[tool]rustdoc — generated signature, whySendis required, the error text so it is searchable, links to the two upstream rustc issues, and the four workarounds;#[prompt], which shares the codegen and fails identically (checked, not assumed);State of the checks on my side:
cargo +nightly fmt --allclean,cargo clippy --all-targets --all-features -- -D warningsclean,cargo +nightly doc --no-deps -p rmcp -p rmcp-macros --all-featureswithRUSTDOCFLAGS="--cfg docsrs -Dwarnings"clean, andtest_tool_macros(13) andtest_prompt_macros(7) both pass including the two new tests. The widercargo test -p rmcpacross the non-local feature set I have not got a result for — my machine ran out of disk part way through the link step — so treat that one as unrun rather than green.I have not opened a PR. Let me know if this is good PR, or advice on other solution.
Full diff
github-actions commentedon Oct 12, 2026
This issue has had no activity for 60 days and is now marked as stale. It will be closed in 14 days if there is no further activity. Add the
keeplabel to keep it open.gocamille commentedon Oct 26, 2026
Sorry this sat so long, @IgorKasianenko! Your scope sounds right to me: since the
Sendbound is the whole trigger, the docs plus regression tests on#[tool]and#[prompt]make more sense than a codegen change. I'm addingkeepso this doesn't close.I tried a408ab9 on top of main (8aec389), and it holds up. The only conflict is both sides appending to the end of
test_tool_macros.rs, so keeping both works.test_tool_macros(16) andtest_prompt_macros(8) pass, and dropping the.boxed()brings back the exactFnOnce is not general enougherror, so the test really pins the workaround. The repro from the issue still fails on main and compiles withlocal, as your docs say. Rustdoc with-Dwarningsand fmt are clean.Small one on the docs: I think
.then(..)runs the futures one at a time, so that bullet gives up the concurrencybuffer_unorderedwas there for (I measured 1 in flight vs 4). It also compiles with the original inline closure, so the helper isn't what fixes it. Would it make sense to word it as "use.then(..)if you don't need the futures to run concurrently"? Happy to leave it if you'd rather keep the list short.Another tiny one: clippy flags
stream::iter(items.into_iter())infan_out_ownedasuseless_conversion, sostream::iter(items)would do. The "Run clippy (all features except local)" CI step would flag it too.Would you be up for opening a PR from your branch? Happy to review. If you've moved on, no worries, just say and I can open it from your branch with you as co-author.
IgorKasianenko commentedon Oct 26, 2026
Thanks @gocamille for the comments. I don't have the dev env as of now, so if you could continue the work in your PR and co-author that would be amazing.