Skip to content

#[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

Open

Description

@tmalldedede

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 an async fn helper + .then(..), or own items before the stream), or
  • Implement ServerHandler manually 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

  1. added
    P2Medium: important but non-blocking improvement
    on Jul 30, 2026
  2. IgorKasianenko commented on Aug 11, 2026

    @IgorKasianenko

    I had a go at this and reduced it below the macro. The short version: the trigger is
    the Send bound, not #[tool]'s codegen, and I do not think a macro change can fix
    it. I would like to do the documentation tier instead. Evidence below.

    Setup: rmcp from main (d81bfa8) as a path dependency, rustc 1.96.1 (the
    toolchain pinned in rust-toolchain.toml), the repro from the issue body unchanged.
    It fails exactly as reported.

    Ladder — each step removes one thing:

    # what it is result
    1 the repro from this issue: #[tool] + #[tool_router] + #[tool_handler] fails
    2 #[tool] on the method only — no #[tool_router], never registered on a router fails
    3 no rmcp at all: hand-written fn echo(&self) -> Pin<Box<dyn Future<Output = usize> + Send + '_>> { Box::pin(async move { self.execute().await }) } fails
    4 no rmcp, no boxing, no trait object: fn assert_send<F: Future + Send>(f: F) -> F { f } applied to s.execute() fails
    5 the same call tree with nothing asking for Send compiles

    Step 4 is the one that decides it. There is no attribute macro, no Box::pin, no
    dyn Future, and no higher-ranked bound anywhere — only F: Send — and the error is
    identical, same closure, same "for any two lifetimes" note:

    error: implementation of `FnOnce` is not general enough
      --> src/bin/v4_assert_send.rs:27:16
       |
    27 |     let _fut = assert_send(s.execute("echo"));
       |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ implementation of `FnOnce` is not general enough
       |
       = note: closure with signature `fn(&'0 String) -> {async block@src/bin/v4_assert_send.rs:13:23: 13:33}` must implement `FnOnce<(&'1 String,)>`, for any two lifetimes `'0` and `'1`...
       = note: ...but it actually implements `FnOnce<(&String,)>`
    

    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] rewrites
    an async fn into -> Pin<Box<dyn Future<Output = T> + Send + '_>>
    (crates/rmcp-macros/src/tool.rs) because the router stores handlers as BoxFuture
    (DynCallToolHandler in crates/rmcp/src/handler/server/tool.rs), and BoxFuture is
    Send. Asking for Send is the entire trigger. tokio::spawn, -> impl Send, or any
    Send-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) and
    rust-lang/rust#126551. Their minimal
    case has no macro and no rmcp:

    use futures::stream::{empty, StreamExt};
    use std::future::ready;
    
    fn send<T: Send>(_: T) {}
    
    fn main() {
        send(async { empty().map(ready::<&()>).buffered(0).next().await });
    }

    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-Send future.
    The local crate feature already does exactly that — I checked, and the repro compiles
    under it — at the cost of a single-threaded server. Moving the boxing to the dispatch
    boundary, or restructuring
    the generated handler, changes where the Send obligation is written down, not whether
    it 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 same MaybeBoxFuture bound, so it carries the
    same limitation.

    Workarounds, each verified against the exact repro under #[tool]:

    • .boxed() on the stream before .buffer_unordered(..) — one line, keeps the borrow:

      stream::iter(ips.iter())
          .map(|ip| async move { ip.to_string() })
          .boxed()                 // <-- erases the closure from the Send computation
          .buffer_unordered(4)
    • own the items instead of borrowing them (ips.into_iter() rather than ips.iter()).

    • the two already in this issue: an async fn helper plus .then(..), or a manual
      ServerHandler impl.

    • if the server does not have to be Send, the local crate feature drops + Send
      from the generated signature and the repro compiles unchanged.

    What I would like to do, if you agree with the scope:

    1. A short "async tool bodies must be Send" section on the #[tool] and #[prompt]
      rustdoc: the signature the macro generates, why Send is required, the exact error
      text 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 about
      Send, which sends people looking inside rmcp.
    2. Compile-only regression tests in crates/rmcp/tests covering the
      stream::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.

  3. IgorKasianenko commented on Aug 12, 2026

    @IgorKasianenko

    Branch with the documentation change, in case it is easier to read as a diff than as prose: IgorKasianenko/rust-sdk@a408ab9 (branch fix-1092-tool-macro-hrtb, on top of d81bfa8).

    Three files, +171, no public API change:

    • a Send section on the #[tool] rustdoc — generated signature, why Send is required, the error text so it is searchable, links to the two upstream rustc issues, and the four workarounds;
    • a short cross-referencing section on #[prompt], which shares the codegen and fails identically (checked, not assumed);
    • regression tests under both macros so the documented workarounds keep compiling.

    State of the checks on my side: cargo +nightly fmt --all clean, cargo clippy --all-targets --all-features -- -D warnings clean, cargo +nightly doc --no-deps -p rmcp -p rmcp-macros --all-features with RUSTDOCFLAGS="--cfg docsrs -Dwarnings" clean, and test_tool_macros (13) and test_prompt_macros (7) both pass including the two new tests. The wider cargo test -p rmcp across 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
    diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs
    index 156e53b..65ce149 100644
    --- a/crates/rmcp-macros/src/lib.rs
    +++ b/crates/rmcp-macros/src/lib.rs
    @@ -33,6 +33,46 @@ mod tool_router;
     ///     // handling tool request
     /// }
     /// ```
    +///
    +/// ## Async tool bodies must be `Send`
    +///
    +/// An `async fn` is rewritten into
    +/// `fn(..) -> Pin<Box<dyn Future<Output = T> + Send + '_>>`, because the router keeps
    +/// every handler as a `Send` boxed future. Everything the tool awaits therefore has to
    +/// be `Send`.
    +///
    +/// One shape fails that check even though the code is sound. A closure that takes a
    +/// reference and returns an `async move` block, fed into a `futures` combinator that
    +/// buffers the futures, does not compile:
    +///
    +/// ```rust,ignore
    +/// stream::iter(items.iter())
    +///     .map(|item| async move { work(item).await })
    +///     .buffer_unordered(4)
    +/// ```
    +///
    +/// ```text
    +/// error: implementation of `FnOnce` is not general enough
    +///    = note: closure with signature `fn(&'0 String) -> {async block}`
    +///            must implement `FnOnce<(&'1 String,)>`, for any two lifetimes `'0` and `'1`...
    +///    = note: ...but it actually implements `FnOnce<(&String,)>`
    +/// ```
    +///
    +/// The span points at `#[tool]`, which is misleading. This is a rustc limitation around
    +/// `Send` checking of async blocks, not something the macro introduces: the same code
    +/// fails under `tokio::spawn`, under `-> impl Send`, or under any `Send`-bounded generic,
    +/// with no macro involved. See [rust-lang/rust#104382] and [rust-lang/rust#126551].
    +///
    +/// Ways round it, least disruptive first:
    +///
    +/// - add `.boxed()` before `.buffer_unordered(..)` or `.buffered(..)`;
    +/// - own the items instead of borrowing them (`items.into_iter()`);
    +/// - move the closure body into an `async fn` helper and use `.then(..)`;
    +/// - if the server does not have to be `Send`, the `local` crate feature drops the
    +///   `+ Send` bound from the generated signature.
    +///
    +/// [rust-lang/rust#104382]: https://github.com/rust-lang/rust/issues/104382
    +/// [rust-lang/rust#126551]: https://github.com/rust-lang/rust/issues/126551
     #[proc_macro_attribute]
     pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream {
         tool::tool(attr.into(), input.into())
    @@ -219,6 +259,16 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> TokenStream {
     ///     // Generate prompt messages based on arguments
     /// }
     /// ```
    +///
    +/// ## Async prompt bodies must be `Send`
    +///
    +/// An `async fn` is rewritten into
    +/// `fn(..) -> Pin<Box<dyn Future<Output = T> + Send + '_>>`, the same as [`macro@tool`],
    +/// so the same `Send` limitation applies: a closure taking a reference and returning an
    +/// `async move` block, fed into `.buffer_unordered(..)` or `.buffered(..)`, fails to
    +/// compile with "implementation of `FnOnce` is not general enough" even though the code
    +/// is sound. See the matching section on [`macro@tool`] for the error text and the ways
    +/// round it.
     #[proc_macro_attribute]
     pub fn prompt(attr: TokenStream, input: TokenStream) -> TokenStream {
         prompt::prompt(attr.into(), input.into())
    diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs
    index 7a00249..e442705 100644
    --- a/crates/rmcp/tests/test_prompt_macros.rs
    +++ b/crates/rmcp/tests/test_prompt_macros.rs
    @@ -373,3 +373,54 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> {
         server_handle.await??;
         Ok(())
     }
    +
    +/// Regression cover for <https://github.com/modelcontextprotocol/rust-sdk/issues/1092>.
    +///
    +/// `#[prompt]` rewrites an `async fn` into `-> Pin<Box<dyn Future<Output = T> + Send + '_>>`
    +/// exactly as `#[tool]` does, so it carries the same `Send` limitation: a closure taking a
    +/// reference and returning an `async move` block, buffered by a `futures` combinator, does
    +/// not compile without boxing the stream first (rust-lang/rust#104382).
    +#[derive(Debug, Clone)]
    +struct BufferedStreamPromptServer;
    +
    +impl BufferedStreamPromptServer {
    +    /// Fan out over borrowed items, boxing the stream so the closure drops out of the
    +    /// `Send` computation.
    +    async fn fan_out_borrowed(&self, items: &[String]) -> Vec<String> {
    +        use futures::stream::{self, StreamExt};
    +        stream::iter(items.iter())
    +            .map(|item| async move { item.to_uppercase() })
    +            .boxed()
    +            .buffer_unordered(4)
    +            .collect()
    +            .await
    +    }
    +}
    +
    +#[prompt_router]
    +impl BufferedStreamPromptServer {
    +    /// Build a message from a fixed list run through a boxed, buffered stream.
    +    #[prompt(name = "buffered_stream", description = "Fan out over borrowed items")]
    +    async fn buffered_stream(&self) -> Vec<PromptMessage> {
    +        let items = vec!["a".to_string(), "b".to_string()];
    +        let mut out = self.fan_out_borrowed(&items).await;
    +        out.sort();
    +        vec![PromptMessage::new_text(Role::User, out.join(","))]
    +    }
    +}
    +
    +#[prompt_handler]
    +impl ServerHandler for BufferedStreamPromptServer {}
    +
    +#[tokio::test]
    +async fn test_async_prompt_with_buffered_stream() {
    +    let server = BufferedStreamPromptServer;
    +    let messages = server.buffered_stream().await;
    +    match &messages.first().unwrap().content {
    +        ContentBlock::Text(text) => assert_eq!(text.text, "A,B"),
    +        _ => panic!("Expected text content"),
    +    }
    +
    +    let router = BufferedStreamPromptServer::prompt_router();
    +    assert!(router.has_route("buffered_stream"));
    +}
    diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs
    index 9b9530a..b75c221 100644
    --- a/crates/rmcp/tests/test_tool_macros.rs
    +++ b/crates/rmcp/tests/test_tool_macros.rs
    @@ -573,3 +573,73 @@ fn test_manual_get_info_not_overridden() {
             "manual resources should be preserved"
         );
     }
    +
    +/// Regression cover for <https://github.com/modelcontextprotocol/rust-sdk/issues/1092>.
    +///
    +/// An `async fn` tool is rewritten into `-> Pin<Box<dyn Future<Output = T> + Send + '_>>`,
    +/// so the whole call tree has to pass the `Send` check. A closure taking a reference and
    +/// returning an `async move` block, buffered by a `futures` combinator, fails that check
    +/// on current rustc (rust-lang/rust#104382). Both shapes below are the documented ways
    +/// round it; this test exists so they keep compiling.
    +#[derive(Debug, Clone)]
    +struct BufferedStreamServer;
    +
    +impl BufferedStreamServer {
    +    /// Fan out over borrowed items, boxing the stream so the closure drops out of the
    +    /// `Send` computation.
    +    async fn fan_out_borrowed(&self, items: &[String]) -> Vec<String> {
    +        use futures::stream::{self, StreamExt};
    +        stream::iter(items.iter())
    +            .map(|item| async move { item.to_uppercase() })
    +            .boxed()
    +            .buffer_unordered(4)
    +            .collect()
    +            .await
    +    }
    +
    +    /// Fan out over owned items, so the closure argument is not a reference at all.
    +    async fn fan_out_owned(&self, items: Vec<String>) -> Vec<String> {
    +        use futures::stream::{self, StreamExt};
    +        stream::iter(items.into_iter())
    +            .map(|item| async move { item.to_uppercase() })
    +            .buffer_unordered(4)
    +            .collect()
    +            .await
    +    }
    +}
    +
    +#[tool_router]
    +impl BufferedStreamServer {
    +    /// Uppercase a fixed list through a boxed, buffered stream over borrowed items.
    +    #[tool(description = "Fan out over borrowed items")]
    +    async fn borrowed(&self) -> String {
    +        let items = vec!["a".to_string(), "b".to_string()];
    +        let mut out = self.fan_out_borrowed(&items).await;
    +        out.sort();
    +        out.join(",")
    +    }
    +
    +    /// Uppercase a fixed list through a buffered stream over owned items.
    +    #[tool(description = "Fan out over owned items")]
    +    async fn owned(&self) -> String {
    +        let mut out = self
    +            .fan_out_owned(vec!["c".to_string(), "d".to_string()])
    +            .await;
    +        out.sort();
    +        out.join(",")
    +    }
    +}
    +
    +#[tool_handler]
    +impl ServerHandler for BufferedStreamServer {}
    +
    +#[tokio::test]
    +async fn test_async_tool_with_buffered_stream() {
    +    let server = BufferedStreamServer;
    +    assert_eq!(server.borrowed().await, "A,B");
    +    assert_eq!(server.owned().await, "C,D");
    +
    +    let router = BufferedStreamServer::tool_router();
    +    assert!(router.has_route("borrowed"));
    +    assert!(router.has_route("owned"));
    +}
  4. github-actions commented on Oct 12, 2026

    @github-actions
    Contributor

    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 keep label to keep it open.

  5. gocamille commented on Oct 26, 2026

    @gocamille
    Contributor

    Sorry this sat so long, @IgorKasianenko! Your scope sounds right to me: since the Send bound is the whole trigger, the docs plus regression tests on #[tool] and #[prompt] make more sense than a codegen change. I'm adding keep so 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) and test_prompt_macros (8) pass, and dropping the .boxed() brings back the exact FnOnce is not general enough error, so the test really pins the workaround. The repro from the issue still fails on main and compiles with local, as your docs say. Rustdoc with -Dwarnings and fmt are clean.

    Small one on the docs: I think .then(..) runs the futures one at a time, so that bullet gives up the concurrency buffer_unordered was 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()) in fan_out_owned as useless_conversion, so stream::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.

  6. IgorKasianenko commented on Oct 26, 2026

    @IgorKasianenko

    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.

  7. self-assigned this
    on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Medium: important but non-blocking improvementkeep

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions