modelcontextprotocol / modelcontextprotocol/rust-sdk

#[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{..}))

Open
#1,092 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

P2
Dominant language
Rust
Stars
3.9k
Forks
645
Avg merge
4d 13h
Merged PRs (30d)
36

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 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the minimal reproduction in src/main.rs and run cargo check to confirm the #[tool] error. Then inspect the generated dispatch behavior described in the issue and how the tool macro imposes its higher-ranked bound. Done means either the async stream pattern compiles through #[tool] or the limitation is documented where users will encounter it.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.