rmcp

Library Featured

Rust SDK for Model Context Protocol

Checked · Sources

Downloads
12,149,426
Dependents
2,400
Stars
4,101
Forks
702
Contributors
228
Commits
793
Latest release
3.9.0 ·

About

From the project README ·

An official Rust Model Context Protocol SDK implementation with tokio async runtime.

Migrating to 3.x? See the migration guide for breaking changes and upgrade instructions.

This repository contains the following crates:

  • rmcp: The core crate providing the RMCP protocol implementation - see rmcp
  • rmcp-macros: A procedural macro crate for generating RMCP tool implementations - see rmcp-macros

This SDK implements the stable MCP 2026-07-28 specification while remaining fully compatible with the 2025-11-25 release and earlier versions. Features introduced in 2026-07-28 — server discovery & negotiation, transport-neutral subscriptions, long-running tasks, response caching, multi-round-trip requests, and standard HTTP routing headers — are documented below. For the full MCP specification, see modelcontextprotocol.io.

Table of Contents

  • Usage
  • Tools
  • Resources
  • Prompts
  • Sampling
  • Elicitation
  • Roots
  • Logging
  • Completions
  • Notifications
  • Subscriptions
  • Multi-Round-Trip Requests
  • Tasks
  • Caching
  • Standard HTTP Headers
  • Stateless Streamable HTTP
  • Transports
  • Pagination
  • Capability & Protocol Version Negotiation
  • JSON Schema 2020-12
  • Examples
  • OAuth Support
  • Related Resources
  • Related Projects
  • Development
Read the full README

Usage

Import the crate

Add the latest published version with cargo:

cargo add rmcp --features server

Or use the dev channel:

cargo add rmcp --features server --git https://github.com/modelcontextprotocol/rust-sdk --branch main
Third Dependencies

Basic dependencies:

Build a Client

Start a client

use rmcp::{ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}};
use tokio::process::Command;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = ().serve(TokioChildProcess::new(Command::new("npx").configure(|cmd| {
        cmd.arg("-y").arg("@modelcontextprotocol/server-everything");
    }))?).await?;
    Ok(())
}
Client lifecycle modes

serve() uses the legacy MCP lifecycle: the client sends initialize, receives the negotiated server information, and then sends notifications/initialized. Use ClientServiceExt::serve_with_lifecycle to select another lifecycle explicitly:

use rmcp::{ClientLifecycleMode, ClientServiceExt, ProtocolVersion, model::ClientConfig};

// Start directly with server/discover and include client metadata on every request.
let client = ClientConfig::default()
    .serve_with_lifecycle(
        transport,
        ClientLifecycleMode::Discover {
            preferred_versions: vec![ProtocolVersion::V_2026_07_28],
        },
    )
    .await?;

// Or probe the discover lifecycle and fall back when a legacy server reports
// that server/discover is not implemented or does not respond within 10 seconds.
let client = ClientConfig::default()
    .serve_with_lifecycle(
        transport,
        ClientLifecycleMode::Auto {
            preferred_versions: vec![ProtocolVersion::V_2026_07_28],
            legacy_version: Some(ProtocolVersion::V_2025_11_25),
        },
    )
    .await?;

ClientLifecycleMode::Initialize is equivalent to the existing serve() behavior. Discover startup does not send notifications/initialized; discovery completes startup, and each subsequent request carries its protocol version, client information, and capabilities in _meta.

Build a Server

Build a transport

use tokio::io::{stdin, stdout};
let transport = (stdin(), stdout());

Build a service

You can easily build a service by using ServerHandler or ClientHandler.

let service = common::counter::Counter::new();

Start the server

// Await protocol startup, then receive a handle to the running service.
let server = service.serve(transport).await?;

For a server, serve() waits for peer input that establishes the protocol lifecycle, such as initialize from a legacy client. A stdio MCP server is normally launched by an MCP client as a child process. Running the executable alone in a terminal can leave this await pending because no client has sent protocol messages yet.

After serve() returns, the service handles messages in the background. You can run other asynchronous work, then use waiting() to wait for shutdown. To cancel during startup, use serve_with_ct() with a CancellationToken. Tokio's stdin uses a blocking read that cannot be cancelled, so runtime shutdown may still wait for input or EOF; see Tokio's stdin documentation.

For example, build the counter server from the repository root, then let the MCP Inspector CLI launch it and list its tools:

cargo build -p mcp-server-examples --example servers_counter_stdio
npx @modelcontextprotocol/inspector --cli ./target/debug/examples/servers_counter_stdio --method tools/list

Interact with the server

Once the server is initialized, you can send requests or notifications:

// request
let roots = server.list_roots().await?;

// or send notification
server.notify_cancelled(...).await?;

Waiting for service shutdown

let quit_reason = server.waiting().await?;
// or cancel it
let quit_reason = server.cancel().await?;

Tools

Tools let servers expose callable functions to clients. Each tool has a name, description, and a JSON Schema for its parameters. Clients discover tools via list_tools and invoke them via call_tool.

MCP Spec: Tools

Server-side

The #[tool], #[tool_router], and #[tool_handler] macros handle all the wiring. For a tools-only server you can use #[tool_router(server_handler)] to skip the separate ServerHandler impl:

use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, ServiceExt, transport::stdio};

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AddParams {
    a: i32,
    b: i32,
}

#[derive(Clone)]
struct Calculator;

#[tool_router(server_handler)]
impl Calculator {
    #[tool(description = "Add two numbers")]
    fn add(&self, Parameters(AddParams { a, b }): Parameters<AddParams>) -> String {
        (a + b).to_string()
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let service = Calculator.serve(stdio()).await?;
    service.waiting().await?;
    Ok(())
}

The generated tool inputSchema and outputSchema are derived from the fields of T. The type name and documentation on T are ignored; only field names, field types, and field documentation are used.

2026-07-28 (SEP-2106): outputSchema may now be any JSON Schema type (not just object), and a tool result's structuredContent may be any JSON value (string, array, number, …) rather than only an object. Existing object-typed tools are unaffected.

When you need custom server metadata or multiple capabilities (tools + prompts), use explicit #[tool_handler]:

use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, tool_handler, ServerHandler, ServiceExt};

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AddParams {
    a: i32,
    b: i32,
}

#[derive(Clone)]
struct Calculator;

#[tool_router]
impl Calculator {
    #[tool(description = "Add two numbers")]
    fn add(&self, Parameters(AddParams { a, b }): Parameters<AddParams>) -> String {
        (a + b).to_string()
    }
}

#[tool_handler(name = "calculator", version = "1.0.0", instructions = "A simple calculator")]
impl ServerHandler for Calculator {}

See crates/rmcp-macros for full macro documentation.

Tool result content types

Beyond a plain String, tools can return images, audio, embedded resources, and mixed content. Build a CallToolResult from a Vec<ContentBlock>:

use rmcp::model::{CallToolResult, ContentBlock, ResourceContents};

#[tool(description = "Render a chart")]
async fn chart(&self) -> Result<CallToolResult, McpError> {
    let png_base64 = render_png(); // base64-encoded image bytes
    let wav_base64 = render_wav(); // base64-encoded audio bytes

    Ok(CallToolResult::success(vec![
        // Text
        ContentBlock::text("Here is your chart:"),
        // Image — base64 data + MIME type
        ContentBlock::image(png_base64, "image/png"),
        // Audio — base64 data + MIME type
        ContentBlock::audio(wav_base64, "audio/wav"),
        // Embedded resource — inline text (or ResourceContents::blob for binary)
        ContentBlock::resource(ResourceContents::text(
            "chart source data",
            "chart://last/data.csv",
        )),
    ]))
}
# fn render_png() -> String { String::new() }
# fn render_wav() -> String { String::new() }

Image and audio data are base64 strings with a MIME type. For embedded resources, ResourceContents::text(..) inlines text and ResourceContents::blob(base64, uri) inlines binary.

Error handling

Two failure modes, chosen by whose problem it is:

  • Tool-level error — Ok(CallToolResult::error(vec![...])). The tool ran but failed in a way the caller should see (no rows matched, upstream 500). The client renders your content, so the message reaches the user. Use this for almost every "the tool ran and didn't work" case.
  • Protocol error — Err(McpError) with a JSON-RPC code (e.g. McpError::invalid_params(..)). Use this when the server can't route or process the request at all; clients render these opaquely, so the caller does not see your message.
use rmcp::model::{CallToolResult, ContentBlock};
use rmcp::ErrorData as McpError;

#[tool(description = "Look up a record")]
async fn lookup(&self, Parameters(args): Parameters<LookupArgs>) -> Result<CallToolResult, McpError> {
    // Malformed request — the server can't run anything → protocol error.
    if args.query.is_empty() {
        return Err(McpError::invalid_params("query must be non-empty", None));
    }

    // Tool ran, no result → tool-level error the user should see.
    let rows = self.run_query(&args.query).await;
    if rows.is_empty() {
        return Ok(CallToolResult::error(vec![ContentBlock::text(
            format!("no rows matched '{}'", args.query),
        )]));
    }

    Ok(CallToolResult::success(vec![ContentBlock::text(format_rows(&rows))]))
}
Client-side
use rmcp::model::CallToolRequestParams;

// List all tools
let tools = client.list_all_tools().await?;

// Call a tool by name
let result = client.call_tool(CallToolRequestParams::new("add")).await?;

Example: examples/servers/src/common/calculator.rs (server), examples/servers/src/calculator_stdio.rs (stdio runner)


Resources

Resources let servers expose data (files, database records, API responses) that clients can read. Each resource is identified by a URI and returns content as text or binary (base64-encoded) data. Resource templates allow servers to declare URI patterns with dynamic parameters.

MCP Spec: Resources

Server-side

Implement list_resources(), read_resource(), and optionally list_resource_templates() on the ServerHandler trait. Enable the resources capability in get_info().

use rmcp::{
    ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
    model::*,
    service::RequestContext,
    transport::stdio,
};
use serde_json::json;

#[derive(Clone)]
struct MyServer;

impl ServerHandler for MyServer {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(
            ServerCapabilities::builder()
                .enable_resources()
                .build(),
        )
    }

    async fn list_resources(
        &self,
        _request: Option<PaginatedRequestParams>,
        _context: RequestContext<RoleServer>,
    ) -> Result<ListResourcesResult, McpError> {
        Ok(ListResourcesResult {
            resources: vec![
                Resource::new("file:///config.json", "config"),
                Resource::new("memo://insights", "insights"),
            ],
            next_cursor: None,
            meta: None,
        })
    }

    async fn read_resource(
        &self,
        request: ReadResourceRequestParams,
        _context: RequestContext<RoleServer>,
    ) -> Result<ReadResourceResult, McpError> {
        match request.uri.as_str() {
            "file:///config.json" => Ok(ReadResourceResult::new(vec![
                ResourceContents::text(r#"{"key": "value"}"#, &request.uri),
            ])),
            "memo://insights" => Ok(ReadResourceResult::new(vec![
                ResourceContents::text("Analysis results...", &request.uri),
            ])),
            // Binary resource — base64-encode the bytes and return a blob.
            "file:///logo.png" => {
                use base64::{Engine, prelude::BASE64_STANDARD};
                let bytes = std::fs::read("logo.png").unwrap_or_default();
                let blob = BASE64_STANDARD.encode(bytes);
                Ok(ReadResourceResult::new(vec![
                    ResourceContents::blob(blob, &request.uri)
                        .with_mime_type("image/png"),
                ]))
            }
            // Template-expanded URI — the client fills in `{user_id}` from the
            // `users://{user_id}/profile` template declared in
            // `list_resource_templates`, and the server reads the concrete URI.
            uri if uri.starts_with("users://") && uri.ends_with("/profile") => {
                let user_id = uri
                    .trim_start_matches("users://")
                    .trim_end_matches("/profile");
                Ok(ReadResourceResult::new(vec![ResourceContents::text(
                    format!(r#"{{"id": "{user_id}", "name": "User {user_id}"}}"#),
                    uri,
                )]))
            }
            _ => Err(McpError::resource_not_found(
                "resource_not_found",
                Some(json!({ "uri": request.uri })),
            )),
        }
    }

    async fn list_resource_templates(
        &self,
        _request: Option<PaginatedRequestParams>,
        _context: RequestContext<RoleServer>,
    ) -> Result<ListResourceTemplatesResult, McpError> {
        // Declare a URI template with a `{user_id}` parameter. Clients expand it
        // (e.g. `users://42/profile`) and pass the concrete URI to `read_resource`.
        Ok(ListResourceTemplatesResult {
            resource_templates: vec![
                ResourceTemplate::new("users://{user_id}/profile", "user-profile"),
            ],
            next_cursor: None,
            meta: None,
        })
    }
}
Client-side
use rmcp::model::{ReadResourceRequestParams};

// List all resources (handles pagination automatically)
let resources = client.list_all_resources().await?;

// Read a specific resource by URI
let result = client.read_resource(
    ReadResourceRequestParams::new("file:///config.json"),
).await?;

// List resource templates, then read a resource through one by expanding its
// parameters into a concrete URI (`users://{user_id}/profile` → `users://42/profile`).
let templates = client.list_all_resource_templates().await?;
let profile = client.read_resource(
    ReadResourceRequestParams::new("users://42/profile"),
).await?;
Notifications

Servers can notify clients when the resource list changes or when a specific resource is updated:

// Notify that the resource list has changed (clients should re-fetch)
context.peer.notify_resource_list_changed().await?;

// Notify that a specific resource was updated
context.peer.notify_resource_updated(
    ResourceUpdatedNotificationParam::new("file:///config.json"),
).await?;

Clients handle these via ClientHandler:

impl ClientHandler for MyClient {
    async fn on_resource_list_changed(
        &self,
        _context: NotificationContext<RoleClient>,
    ) {
        // Re-fetch the resource list
    }

    async fn on_resource_updated(
        &self,
        params: ResourceUpdatedNotificationParam,
        _context: NotificationContext<RoleClient>,
    ) {
        // Re-read the updated resource at params.uri
    }
}

Example: examples/servers/src/common/counter.rs (server), examples/clients/src/everything_stdio.rs (client)


Prompts

Prompts are reusable message templates that servers expose to clients. They accept typed arguments and return conversation messages. The #[prompt] macro handles argument validation and routing automatically.

MCP Spec: Prompts

Server-side

Use the #[prompt_router], #[prompt], and #[prompt_handler] macros to define prompts declaratively. Arguments are defined as structs deriving JsonSchema.

use rmcp::{
    ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
    handler::server::{router::prompt::PromptRouter, wrapper::Parameters},
    model::*,
    prompt, prompt_handler, prompt_router,
    schemars::JsonSchema,
    service::RequestContext,
    transport::stdio,
};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CodeReviewArgs {
    #[schemars(description = "Programming language of the code")]
    pub language: String,
    #[schemars(description = "Focus areas for the review")]
    pub focus_areas: Option<Vec<String>>,
}

#[derive(Clone)]
pub struct MyServer {
    prompt_router: PromptRouter<Self>,
}

#[prompt_router]
impl MyServer {
    fn new() -> Self {
        Self { prompt_router: Self::prompt_router() }
    }

    /// Simple prompt without parameters
    #[prompt(name = "greeting", description = "A simple greeting")]
    async fn greeting(&self) -> Vec<PromptMessage> {
        vec![PromptMessage::new_text(
            Role::User,
            "Hello! How can you help me today?",
        )]
    }

    /// Prompt with typed arguments
    #[prompt(name = "code_review", description = "Review code in a given language")]
    async fn code_review(
        &self,
        Parameters(args): Parameters<CodeReviewArgs>,
    ) -> Result<GetPromptResult, McpError> {
        let focus = args.focus_areas
            .unwrap_or_else(|| vec!["correctness".into()]);

        Ok(GetPromptResult::new(vec![
            PromptMessage::new_text(
                Role::User,
                format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")),
            ),
        ])
        .with_description(format!("Code review for {}", args.language)))
    }
}

#[prompt_handler]
impl ServerHandler for MyServer {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(ServerCapabilities::builder().enable_prompts().build())
    }
}

Prompt functions support several return types:

  • Vec<PromptMessage> -- simple message list
  • GetPromptResult -- messages with an optional description
  • Result<T, McpError> -- either of the above, with error handling
Image and embedded-resource content

A PromptMessage can also carry an image or embedded resource. Use the dedicated constructors (image/audio require the base64 feature):

use rmcp::model::{PromptMessage, Role};

// Image content — raw bytes are base64-encoded for you.
let screenshot: &[u8] = load_png();
let msg = PromptMessage::new_image(Role::User, screenshot, "image/png", None, None);

// Embedded resource — inline a text resource by URI. Pass `Some(text)` for a
// text resource, or `None` for a blob resource.
let msg = PromptMessage::new_resource(
    Role::User,
    "file:///spec.md".to_string(),
    Some("text/markdown".to_string()),
    Some("# Specification\n...".to_string()),
    None, None, None,
);
# fn load_png() -> &'static [u8] { &[] }
Client-side
use rmcp::model::GetPromptRequestParams;

// List all prompts
let prompts = client.list_all_prompts().await?;

// Get a prompt with arguments
let result = client.get_prompt(GetPromptRequestParams {
    meta: None,
    name: "code_review".into(),
    arguments: Some(rmcp::object!({
        "language": "Rust",
        "focus_areas": ["performance", "safety"]
    })),
}).await?;
Notifications
// Server: notify that available prompts have changed
context.peer.notify_prompt_list_changed().await?;

Example: examples/servers/src/prompt_stdio.rs (server), examples/clients/src/everything_stdio.rs (client)


Sampling

Deprecated (SEP-2577): Sampling is deprecated and will be removed in a future release. It remains fully functional for now. See SEP-2577.

Sampling flips the usual direction: the server asks the client to run an LLM completion. The server sends a create_message request, the client processes it through its LLM, and returns the result.

MCP Spec: Sampling

Server-side (requesting sampling)

Access the client's sampling capability through context.peer.create_message():

use rmcp::model::*;

// Inside a ServerHandler method (e.g., call_tool):
let response = context.peer.create_message(
    CreateMessageRequestParams::new(
        vec![SamplingMessage::user_text("Explain this error: connection refused")],
        150,
    )
    .with_model_preferences(
        ModelPreferences::new()
            .with_hints(vec![ModelHint::new("claude")])
            .with_cost_priority(0.3)
            .with_speed_priority(0.8)
            .with_intelligence_priority(0.7),
    )
    .with_system_prompt("You are a helpful assistant.")
    .with_include_context(ContextInclusion::None)
    .with_temperature(0.7),
).await?;

// Extract the response text
let text = response.message.content
    .first()
    .and_then(|c| c.as_text())
    .map(|t| &t.text);
Client-side (handling sampling)

On the client side, implement ClientHandler::create_message(). This is where you'd call your actual LLM:

use rmcp::{ClientHandler, model::*, service::{RequestContext, RoleClient}};

#[derive(Clone, Default)]
struct MyClient;

impl ClientHandler for MyClient {
    async fn create_message(
        &self,
        params: CreateMessageRequestParams,
        _context: RequestContext<RoleClient>,
    ) -> Result<CreateMessageResult, ErrorData> {
        // Forward to your LLM, or return a mock response:
        let response_text = call_your_llm(&params.messages).await;

        Ok(CreateMessageResult::new(
            SamplingMessage::assistant_text(response_text),
            "my-model".into(),
        )
        .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN))
    }
}

Example: examples/servers/src/sampling_stdio.rs (server), examples/clients/src/sampling_stdio.rs (client)


Elicitation

Elicitation lets a server pause mid-operation to ask the user for input, in one of two modes: form mode (structured fields with a JSON Schema) or URL mode (send the user to a web page and wait for completion).

MCP Spec: Elicitation

Server-side (form mode)

Define a struct deriving JsonSchema, mark it elicit_safe!, and call elicit::<T>() on the peer. Schema validation, defaults, and enum choices all come from the type.

use rmcp::{elicit_safe, model::*, service::{RequestContext, RoleServer}};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(description = "User information")]
pub struct UserInfo {
    #[schemars(description = "User's name")]
    pub name: String,
    // Optional field; omitted if the user doesn't provide it.
    #[serde(default)]
    #[schemars(description = "Preferred greeting")]
    pub greeting: Option<String>,
}

// Whitelist the type for elicitation (schema-validated on both ends).
elicit_safe!(UserInfo);

#[tool(description = "Greet the user")]
async fn greet(&self, ctx: RequestContext<RoleServer>) -> Result<CallToolResult, McpError> {
    // Returns Ok(Some(UserInfo)) if the user accepts.
    // Decline and cancel are returned as ElicitationError variants.
    match ctx.peer.elicit::<UserInfo>("Please provide your name").await {
        Ok(Some(info)) => Ok(CallToolResult::success(vec![ContentBlock::text(
            format!("Hello, {}!", info.name),
        )])),
        Ok(None) => Ok(CallToolResult::success(vec![ContentBlock::text(
            "No name provided.",
        )])),
        Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(
            format!("Elicitation failed: {e}"),
        )])),
    }
}
Enum values

Enum fields become a choice list. schemars needs two hints to inline and type the enum correctly:

#[derive(Debug, Serialize, Deserialize, JsonSchema, Default)]
#[schemars(inline)]                     // inline the enum into the parent schema
#[schemars(extend("type" = "string"))]  // schemars omits `type` for enums; add it
enum Priority {
    #[schemars(title = "Low priority")]
    #[default]
    Low,
    #[schemars(title = "High priority")]
    High,
}

See examples/servers/src/elicitation_enum_inference.rs for single-select, multi-select, titled, and defaulted enum forms.

Server-side (URL mode)

For flows a form can't capture (OAuth consent, a payment page), send the user to a URL. elicit_url returns the user's ElicitationAction rather than typed data:

use rmcp::model::ElicitationAction;
use url::Url;

let action = ctx.peer.elicit_url(
    "Please complete setup in your browser",
    Url::parse("https://example.com/setup").unwrap(),
    "setup-123", // a unique elicitation id
).await?;

match action {
    ElicitationAction::Accept  => { /* user consented */ }
    ElicitationAction::Decline => { /* user declined */ }
    ElicitationAction::Cancel  => { /* user aborted */ }
}
Client-side

Implement ClientHandler::create_elicitation(), matching on the request variant to handle form vs. URL mode:

use rmcp::{ClientHandler, model::*, service::{RequestContext, RoleClient}};

impl ClientHandler for MyClient {
    async fn create_elicitation(
        &self,
        request: ElicitRequestParams,
        _context: RequestContext<RoleClient>,
    ) -> Result<ElicitResult, ErrorData> {
        match request {
            ElicitRequestParams::FormElicitationParams { message, .. } => {
                // Show `message` + the requested schema, collect input, then:
                Ok(ElicitResult {
                    action: ElicitationAction::Accept,
                    content: Some(rmcp::object!({ "name": "Ada" })),
                    meta: None,
                })
            }
            ElicitRequestParams::UrlElicitationParams { url, .. } => {
                // Open `url`, wait for the user, then report the action.
                let _ = url;
                Ok(ElicitResult { action: ElicitationAction::Accept, content: None, meta: None })
            }
        }
    }
}

On completion the client sends a notifications/elicitation/response notification to release the waiting server-side elicit_url call.

Example: examples/servers/src/elicitation_stdio.rs (form + URL), examples/servers/src/elicitation_enum_inference.rs (enum forms)


Roots

Deprecated (SEP-2577): Roots is deprecated and will be removed in a future release. It remains fully functional for now. See SEP-2577.

Roots tell servers which directories or projects the client is working in. A root is a URI (typically file://) pointing to a workspace or repository. Servers can query roots to know where to look for files and how to scope their work.

MCP Spec: Roots

Server-side

Ask the client for its root list, and handle change notifications:

use rmcp::{ServerHandler, model::*, service::{NotificationContext, RoleServer}};

impl ServerHandler for MyServer {
    // Query the client for its roots
    async fn call_tool(
        &self,
        request: CallToolRequestParams,
        context: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, ErrorData> {
        let roots = context.peer.list_roots().await?;
        // Use roots.roots to understand workspace boundaries
        // ...
    }

    // Called when the client's root list changes
    async fn on_roots_list_changed(
        &self,
        _context: NotificationContext<RoleServer>,
    ) {
        // Re-fetch roots to stay current
    }
}
Client-side

Clients declare roots capability and implement list_roots():

use rmcp::{ClientHandler, model::*};

impl ClientHandler for MyClient {
    async fn list_roots(
        &self,
        _context: RequestContext<RoleClient>,
    ) -> Result<ListRootsResult, ErrorData> {
        Ok(ListRootsResult::new(vec![
            Root::new("file:///home/user/project").with_name("My Project"),
        ]))
    }
}

Clients notify the server when roots change:

// After adding or removing a workspace root:
client.notify_roots_list_changed().await?;

Logging

Deprecated (SEP-2577): Logging is deprecated and will be removed in a future release. It remains fully functional for now. See SEP-2577.

Servers can send structured log messages to clients. The client sets a minimum severity level, and the server sends messages through the peer notification interface.

MCP Spec: Logging

Server-side

Enable the logging capability, handle level changes from the client, and send log messages via the peer:

use rmcp::{ServerHandler, model::*, service::RequestContext};

impl ServerHandler for MyServer {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(
            ServerCapabilities::builder()
                .enable_logging()
                .build(),
        )
    }

    // Client sets the minimum log level
    async fn set_level(
        &self,
        request: SetLevelRequestParams,
        _context: RequestContext<RoleServer>,
    ) -> Result<(), ErrorData> {
        // Store request.level and filter future log messages accordingly
        Ok(())
    }
}

// Send a log message from any handler with access to the peer:
context.peer.notify_logging_message(
    LoggingMessageNotificationParam::new(
        LoggingLevel::Info,
        serde_json::json!({
            "message": "Processing completed",
            "items_processed": 42
        }),
    )
    .with_logger("my-server"),
).await?;

Available log levels (from least to most severe): Debug, Info, Notice, Warning, Error, Critical, Alert, Emergency.

Client-side

Clients handle incoming log messages via ClientHandler:

impl ClientHandler for MyClient {
    async fn on_logging_message(
        &self,
        params: LoggingMessageNotificationParam,
        _context: NotificationContext<RoleClient>,
    ) {
        println!("[{}] {}: {}", params.level,
            params.logger.unwrap_or_default(), params.data);
    }
}

Clients can also set the server's log level:

client.set_level(SetLevelRequestParams::new(LoggingLevel::Warning)).await?;

Completions

Completions give auto-completion suggestions for prompt or resource template arguments. As a user fills in arguments, the client can ask the server for suggestions based on what's already been entered.

MCP Spec: Completions

Server-side

Enable the completions capability and implement the complete() handler. Use request.context to inspect previously filled arguments:

use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer};

impl ServerHandler for MyServer {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(
            ServerCapabilities::builder()
                .enable_completions()
                .enable_prompts()
                .build(),
        )
    }

    async fn complete(
        &self,
        request: CompleteRequestParams,
        _context: RequestContext<RoleServer>,
    ) -> Result<CompleteResult, McpError> {
        let values = match &request.r#ref {
            // Completion for a prompt argument (`ref/prompt`).
            Reference::Prompt(prompt_ref) if prompt_ref.name == "sql_query" => {
                match request.argument.name.as_str() {
                    "operation" => vec!["SELECT", "INSERT", "UPDATE", "DELETE"],
                    "table" => vec!["users", "orders", "products"],
                    "columns" => {
                        // Adapt suggestions based on previously filled arguments
                        if let Some(ctx) = &request.context {
                            if let Some(op) = ctx.get_argument("operation") {
                                match op.to_uppercase().as_str() {
                                    "SELECT" | "UPDATE" => {
                                        vec!["id", "name", "email", "created_at"]
                                    }
                                    _ => vec![],
                                }
                            } else { vec![] }
                        } else { vec![] }
                    }
                    _ => vec![],
                }
            }
            // Completion for a resource-template argument (`ref/resource`). The
            // `uri` identifies the template (e.g. `users://{user_id}/profile`)
            // and `argument.name` is the template variable being completed.
            Reference::Resource(resource_ref)
                if resource_ref.uri == "users://{user_id}/profile" =>
            {
                match request.argument.name.as_str() {
                    "user_id" => vec!["1", "2", "42"],
                    _ => vec![],
                }
            }
            _ => vec![],
        };

        // Filter by the user's partial input
        let filtered: Vec<String> = values.into_iter()
            .map(String::from)
            .filter(|v| v.to_lowercase().contains(&request.argument.value.to_lowercase()))
            .collect();

        let completion = CompletionInfo::with_pagination(filtered, None, false)
            .map_err(|e| McpError::internal_error(e, None))?;
        Ok(CompleteResult::new(completion))
    }
}
Client-side
use rmcp::model::*;

// Completion for a prompt argument.
let result = client.complete(CompleteRequestParams::new(
    Reference::for_prompt("sql_query"),
    ArgumentInfo::new("operation", "SEL"),
)).await?;

// result.completion.values contains suggestions like ["SELECT"]

// Completion for a resource-template argument: reference the template by URI
// and complete one of its variables (`user_id`).
let resource_completion = client.complete(CompleteRequestParams::new(
    Reference::for_resource("users://{user_id}/profile"),
    ArgumentInfo::new("user_id", "4"),
)).await?;

// resource_completion.completion.values contains suggestions like ["42"]

Example: examples/servers/src/completion_stdio.rs


Notifications

Notifications are fire-and-forget messages -- no response is expected. They cover progress updates, cancellation, and lifecycle events. Both sides can send and receive them.

MCP Spec: Notifications

Progress notifications

Servers can report progress during long-running operations:

use rmcp::model::*;

// Inside a tool handler:
for i in 0..total_items {
    process_item(i).await;

    context.peer.notify_progress(
        ProgressNotificationParam::new(
            ProgressToken(NumberOrString::Number(i as i64)),
            i as f64,
        )
        .with_total(total_items as f64)
        .with_message(format!("Processing item {}/{}", i + 1, total_items)),
    ).await?;
}
Cancellation

Either side can cancel an in-progress request:

// Send a cancellation
context.peer.notify_cancelled(CancelledNotificationParam::new(
    Some(the_request_id),
    Some("User requested cancellation".into()),
)).await?;

Handle cancellation in ServerHandler or ClientHandler:

impl ServerHandler for MyServer {
    async fn on_cancelled(
        &self,
        params: CancelledNotificationParam,
        _context: NotificationContext<RoleServer>,
    ) {
        // Abort work for params.request_id
    }
}
Ping

Either side can send a ping request to check that its counterpart is still responsive and the connection is alive. A ping carries no parameters and the receiver replies with an empty result. Because pings can flow in both directions, rmcp handles them symmetrically:

  • Sending a ping — construct a PingRequest and send it over the peer. A client pings the server with ClientRequest::PingRequest; a server pings the client with ServerRequest::PingRequest. send_request resolves once the empty response arrives, so a returned Ok confirms the peer is reachable:
use rmcp::model::{PingRequest, ServerRequest};

// From a server, ping the connected client to verify it is still alive.
context.peer
    .send_request(ServerRequest::PingRequest(PingRequest::default()))
    .await?;
use rmcp::model::{ClientRequest, PingRequest};

// From a client, ping the server. `running` is the value returned by serve().
running
    .send_request(ClientRequest::PingRequest(PingRequest::default()))
    .await?;
  • Responding to a ping — rmcp answers incoming pings automatically. The default ping method on ServerHandler and ClientHandler returns an empty result, so no code is required. Override it only if you want to run custom logic (for example, health checks) when a ping arrives:
impl ServerHandler for MyServer {
    async fn ping(
        &self,
        _context: RequestContext<RoleServer>,
    ) -> Result<(), McpError> {
        // Custom liveness logic here, if any.
        Ok(())
    }
}

MCP Spec: Ping

Initialized notification

Legacy clients send initialized after the initialize handshake completes. Clients using ClientLifecycleMode::Discover do not send this notification:

// Sent automatically by rmcp during the legacy serve() handshake.
// Servers handle it via:
impl ServerHandler for MyServer {
    async fn on_initialized(
        &self,
        _context: NotificationContext<RoleServer>,
    ) {
        // Server is ready to receive requests
    }
}
List-changed notifications

When available tools, prompts, or resources change, tell the client:

context.peer.notify_tool_list_changed().await?;
context.peer.notify_prompt_list_changed().await?;
context.peer.notify_resource_list_changed().await?;

Example: examples/servers/src/common/progress_demo.rs


Subscriptions

Protocol 2026-07-28 replaces resources/subscribe, resources/unsubscribe, and the standalone HTTP GET stream with the transport-neutral, long-lived subscriptions/listen request. Each requested notification category is opt-in.

MCP Spec: Subscriptions

Server-side

Declare the notification capabilities you serve, return the accepted subset, and use the filter-enforcing subscription sink:

use rmcp::{
    ErrorData, ServerHandler,
    model::*,
    service::SubscriptionContext,
};

impl ServerHandler for MyServer {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(
            ServerCapabilities::builder()
                .enable_tools()
                .enable_tool_list_changed()
                .build(),
        )
    }

    fn accepted_subscription_filter(
        &self,
        requested: &SubscriptionFilter,
    ) -> Option<SubscriptionFilter> {
        Some(requested.clone())
    }

    async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> {
        if context.accepted().tools_list_changed == Some(true) {
            context.sink().notify_tool_list_changed().await
                .map_err(|error| ErrorData::internal_error(error.to_string(), None))?;
        }
        context.cancelled().await;
        Ok(())
    }
}

The SDK intersects the handler's filter with the requested categories and the capabilities advertised by get_info(). It sends the acknowledgment before listen, tags every sink notification with the listen request ID, and rejects categories or resource URIs outside the accepted filter.

Client-side
use rmcp::model::*;

let mut subscription = client.listen(
    SubscriptionFilter::builder()
        .tools_list_changed()
        .resource_subscription("file:///config.json")
        .build(),
).await?;

println!("accepted: {:?}", subscription.acknowledged());
while let Some(notification) = subscription.next().await? {
    println!("notification: {notification:?}");
}

subscription.cancel().await?;

listen() buffers up to 64 notifications per subscription. Use listen_with_capacity() to choose a different non-zero capacity; if a consumer falls behind, Subscription::end() reports SubscriptionEnd::Lagged.

For older negotiated protocol versions, the deprecated subscribe() and unsubscribe() APIs retain their legacy wire behavior. Modern Streamable HTTP uses the listen POST response stream directly and does not use sessions, GET, DELETE, or Last-Event-ID. After an abrupt transport close, call listen again; subscription state is not resumed across HTTP or stdio reconnects.

See the modern subscription server and client examples.


Multi-Round-Trip Requests

Protocol 2026-07-28 adds Multi-Round-Trip Requests (MRTR, SEP-2322): a server can answer a tools/call, prompts/get, or resources/read with an InputRequiredResult instead of a final result, asking the client to fulfill one or more embedded server requests (elicitation, sampling, or roots) and then retry. The exchange is stateless — the server carries its progress in an opaque requestState that the client echoes back verbatim.

MCP Spec: Multiple Round-Trip Requests

Server-side

Return an InputRequiredResult via the outcome enum for the method (CallToolResponse, GetPromptResponse, or ReadResourceResponse). The SDK only forwards it to peers that negotiated 2026-07-28 or newer — older peers get a protocol error instead.

async fn call_tool(&self, request: CallToolRequestParams, _ctx: RequestContext<RoleServer>)
    -> Result<CallToolResponse, ErrorData>
{
    match request.request_state {
        // First round: ask the client for input, seal progress into requestState.
        None => {
            let mut input_requests = InputRequests::new();
            input_requests.insert("city".into(), InputRequest::Elicitation(elicit_city()));
            let sealed = self.codec.seal_json(&json!({ "awaiting": "city" }))?;
            Ok(InputRequiredResult::new(Some(input_requests), Some(sealed)).into())
        }
        // Retry round: verify the echoed state, read the responses, finish.
        Some(sealed) => {
            let _state = self.codec.open_json(&sealed)
                .map_err(|_| ErrorData::invalid_params("tampered request state", None))?;
            let city = request.input_responses.as_ref()
                .and_then(|r| r.get("city"));
            Ok(CallToolResult::success(vec![ContentBlock::text("It is sunny.")]).into())
        }
    }
}

requestState is untrusted. SEP-2322 requires servers to validate it because the client echoes it back verbatim. A stateless server that stores meaningful data in it MUST verify integrity first. Enable the request-state feature and use RequestStateCodec to seal and open it (HMAC-tagged), or keep state server-side and use requestState only as an opaque handle.

For multi-replica deployments, use RequestStateCodec::new_with_keyring to rotate signing keys without invalidating in-flight requests:

  1. Deploy the old and new keys everywhere, continuing to emit rs1 with the old key via with_rs1_signing("old").
  2. Start emitting rs2 with the new key while retaining the old key via with_rs1_fallback("old").
  3. After the maximum requestState lifetime has elapsed, remove the old key.
Client-side

The high-level call_tool, get_prompt, and read_resource helpers drive MRTR automatically: they fulfill each embedded request through the local ClientHandler and retry, up to DEFAULT_MRTR_MAX_ROUNDS (10).

// Auto mode: the SDK fulfills embedded requests and retries for you.
let result = client.call_tool(CallToolRequestParams::new("weather")).await?;

// Choose a custom round cap.
let result = client
    .call_tool_with_mrtr_max_rounds(CallToolRequestParams::new("weather"), 3)
    .await?;

// Manual mode: get the intermediate InputRequiredResult and drive rounds yourself.
match client.call_tool_once(CallToolRequestParams::new("weather")).await? {
    CallToolResponse::InputRequired(input_required) => { /* fulfill + retry */ }
    CallToolResponse::Complete(result) => { /* done */ }
    _ => {}
}

Example: examples/servers/src/mrtr.rs (end-to-end server + client)


Tasks (long-running tool invocations)

rmcp implements the MCP Tasks extension (SEP-2663, io.modelcontextprotocol/tasks). A client declares the extension in its capabilities; the server then decides per request whether to materialize a tools/call as a task, returning a CreateTaskResult (resultType: "task"). The client polls tasks/get, answers in-task input requests via tasks/update, and may request cooperative cancellation via tasks/cancel. Use rmcp::task_manager::TaskManager to manage task lifecycles server-side.

// Client: declare the tasks extension capability.
let caps = ClientCapabilities::builder().enable_tasks().build();

// Server: decide per request whether to materialize a task.
async fn call_tool(&self, request: CallToolRequestParams, context: RequestContext<RoleServer>)
    -> Result<CallToolResponse, McpError>
{
    let client_supports_tasks = context
        .client_capabilities()
        .is_some_and(|caps| caps.supports_tasks());
    if client_supports_tasks {
        let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| {
            Box::pin(async move { /* long-running work -> Ok(CallToolResult) */ })
        });
        return Ok(CallToolResponse::Task(CreateTaskResult::new(task)));
    }
    // ... fall back to synchronous execution
}

See servers_task_stdio and the matching clients_task_stdio for a runnable end-to-end example.

Caching

rmcp clients transparently cache responses that carry the SEP-2549 caching hints (ttlMs / cacheScope) for server/discover, tools/list, prompts/list, resources/list, resources/templates/list, and resources/read.

Caching is on by default but only stores a response when the server sends a positive ttlMs, so servers that omit the hint behave exactly as before. Entries expire after their TTL, are partitioned by cache scope, and are invalidated automatically by the matching list_changed / resource updated notifications.

No call-site changes are needed — existing calls benefit automatically:

let tools = peer.list_tools(None).await?;     // served from cache while fresh
let res   = peer.read_resource(params).await?; // cached per-URI

Tune or disable it per connection via the Peer:

use std::time::Duration;
use rmcp::ClientCacheConfig;

// Customize behavior.
peer.set_response_cache_config(
    ClientCacheConfig::default()
        .with_default_ttl(Duration::from_secs(30)) // TTL for servers that omit ttlMs
        .with_max_ttl(Duration::from_secs(3600))   // upper bound on any TTL
        .with_max_entries(1024)
        .with_private_partition(user_id)            // separate private caches per principal
        .with_serve_stale_on_error(false),          // surface errors instead of stale data
).await;

// Or turn it off entirely.
peer.set_response_cache_config(ClientCacheConfig::disabled()).await;

// Manually flush.
peer.clear_response_cache().await;

Note: with the default serve_stale_on_error, a failed re-fetch returns the last cached response (even if expired) as Ok(..) instead of an error. Set with_serve_stale_on_error(false) if callers must observe fetch failures.

Standard HTTP Headers

Protocol 2026-07-28 standardizes a set of Streamable HTTP request headers (SEP-2243) so proxies and gateways can route MCP traffic without parsing the JSON body: Mcp-Method, Mcp-Name, and Mcp-Param-*. rmcp emits and validates these automatically once a connection negotiates 2026-07-28 or newer — no call-site changes are required, and older negotiated versions are untouched.

MCP Spec: Header standardization

  • Mcp-Method — the JSON-RPC method (e.g. tools/call).
  • Mcp-Name — the target name, sourced from params.name (tools/call, prompts/get), params.uri (resources/*), or params.taskId (tasks/*).
  • Mcp-Param-* — selected tools/call arguments, promoted from the tool's input schema.

To promote a tool argument into a routing header, annotate its schema property with x-mcp-header:

// A `region` argument surfaces as the `Mcp-Param-Region` request header.
let schema = serde_json::json!({
    "type": "object",
    "properties": {
        "region": { "type": "string", "x-mcp-header": "Region" }
    }
});

Annotations must be non-empty RFC 9110 tokens, case-insensitively unique, and applied only to primitive (string/integer/boolean) properties. Nested properties are allowed when every step from the root is a properties key; an annotation under items, oneOf/anyOf/allOf/not, if/then/else, or $ref makes the tool invalid, and clients drop it from tools/list.

With schemars, a nested struct is emitted behind a $ref by default. Mark it #[schemars(inline)] so its annotated fields stay on the properties path:

#[derive(serde::Deserialize, schemars::JsonSchema)]
#[schemars(inline)]
struct Location {
    #[schemars(extend("x-mcp-header" = "Region"))]
    region: String,
}

#[derive(serde::Deserialize, schemars::JsonSchema)]
struct DeployRequest {
    location: Location,
    image: String,
}

Values that cannot travel as a bare header (leading/trailing whitespace, control/non-ASCII characters) are transparently Base64-wrapped as =?base64?<b64>?=.


Stateless Streamable HTTP

Per SEP-2567, rmcp serves the 2026-07-28 draft statelessly automatically: no Mcp-Session-Id, no standalone GET/DELETE stream, and no Last-Event-ID resumption. The legacy_session_mode flag below only controls behavior for legacy protocol versions (< 2026-07-28).

MCP Spec: Transports

Server-side

A default server already serves 2026-07-28 clients statelessly. To also serve legacy clients without sessions, disable legacy_session_mode (formerly stateful_mode; builder with_stateful_mode). Optionally set with_json_response(true) so simple request/response tools reply with a single application/json body instead of an SSE stream (the server still falls back to text/event-stream if a handler emits a notification or server request first):

use rmcp::transport::streamable_http_server::{
    StreamableHttpService, StreamableHttpServerConfig,
    session::local::LocalSessionManager,
};

let config = StreamableHttpServerConfig::default()
    .with_legacy_session_mode(false) // stateless for legacy versions too
    .with_json_response(true);       // plain JSON replies for simple tools

let service = StreamableHttpService::new(
    || Ok(Counter::new()),           // a fresh handler per request
    LocalSessionManager::default().into(),
    config,
);

// `StreamableHttpService` is a Tower service — mount it on any router.
let router = axum::Router::new().nest_service("/mcp", service);

Because there is no per-session state, the service_factory runs per request. Keep shared state (DB pools, caches) in a Clone handle captured by the closure; don't rely on in-memory state surviving between requests.

Modern-only servers can additionally call with_stateless_protocol_metadata_required(true) to reject the compatibility fallback for requests missing their per-request protocol signals. rmcp clients negotiated below 2026-07-28 do not attach that body metadata and will be rejected, so pair this option with a supported_protocol_versions implementation that advertises only 2026-07-28 and later.

Client-side

The Streamable HTTP client transport allows stateless operation by default (allow_stateless: true), so no configuration is needed to talk to a stateless server — it simply omits the session header when the server doesn't issue one:

use rmcp::transport::StreamableHttpClientTransport;

// Defaults are stateless-friendly.
let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp");
let client = ClientConfig::default().serve(transport).await?;

Example: examples/servers/src/counter_streamhttp.rs (server), examples/clients/src/streamable_http.rs (client)


Transports

A transport moves JSON-RPC messages between client and server. Any Transport impl can be passed to .serve(..); rmcp ships the common ones behind Cargo features.

MCP Spec: Transports

TransportFeature(s)Notes
stdiotransport-io (client + server)Communicate over stdin/stdout; the standard way to launch local MCP servers as child processes.
Child process (client)transport-child-processSpawn a server binary and talk to it over its stdio.
Streamable HTTP (server)transport-streamable-http-serverThe current HTTP transport. Exposes a Tower service you can mount on any router.
Streamable HTTP (client)transport-streamable-http-client-reqwestHTTP client transport built on reqwest.
Worker / in-processtransport-workerFor embedding or testing without real I/O.
stdio
use rmcp::{ServiceExt, transport::stdio};

// Server: serve over stdin/stdout.
let server = MyServer.serve(stdio()).await?;
server.waiting().await?;
use rmcp::{ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}};
use tokio::process::Command;

// Client: launch a server binary and talk to it over its stdio.
let transport = TokioChildProcess::new(Command::new("uvx").configure(|cmd| {
    cmd.arg("mcp-server-git");
}))?;
let client = ().serve(transport).await?;
Streamable HTTP

StreamableHttpService is a Tower service — mount it on any axum/hyper router (see Stateless Streamable HTTP for the full server example). The client transport connects with a single URI:

use rmcp::transport::StreamableHttpClientTransport;

let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp");
let client = ClientConfig::default().serve(transport).await?;

The client allows up to 16 ordinary http POSTs at once. Configure this with StreamableHttpClientTransportConfig::with_uri(url).max_concurrent_requests(n); 1 keeps ordinary POSTs serial, and 0 is treated as 1. An open sse response stream does not count against this limit. Cancellation and replies use a separate queue with one extra POST slot. Configure their timeout with control_request_timeout (default: five seconds). The timeout starts when the POST starts, excluding time in the queue. Cancellation stops a queued or active POST immediately. For an open legacy response stream, the client stops reading but keeps the stream alive until the cancellation send finishes or is dropped. This lets custom http adapters handle cancellation before their stream state is removed.

Session recovery waits up to five seconds for old POSTs, then stops any that remain. Those POSTs are not retried because the server may have processed them. Configure this wait and the separate reinitialization timeout with session_recovery_timeout. Callers still decide which tools may run at the same time and which need approval.

Server-Sent Events (SSE)

Streamable HTTP responses arrive as either a single application/json body or a text/event-stream (Server-Sent Events) stream when the server pushes notifications or requests before the result. rmcp handles both automatically (SSE parsing lives behind the client-side-sse feature). There is no separate "SSE transport" to configure — it's an implementation detail of Streamable HTTP.

Legacy HTTP+SSE transport (2024-11-05) — intentionally not provided

The standalone two-endpoint HTTP+SSE transport defined in protocol revision 2024-11-05 (a separate GET SSE channel plus a POST message endpoint) is a deliberate non-goal for rmcp. It was replaced by Streamable HTTP in the 2025-03-26 revision, and rmcp targets current spec revisions (2025-11-25 and 2026-07-28), so it ships no legacy HTTP+SSE client or server transport.

What to use instead:

  • New client/server code — use Streamable HTTP. It carries the same SSE streaming semantics over a single endpoint and is the transport all supported spec revisions expect.
  • Server-to-client streaming (push notifications, resource updates) — this is built into Streamable HTTP; see Subscriptions.
  • Talking to a legacy 2024-11-05-only server — front it with a proxy that speaks Streamable HTTP, or pin a dependency to a release that predates the transport's removal. rmcp will not add the legacy transport back.

This is a supported-surface decision, not a missing feature: every transport rmcp implements is listed in the Transports table above.


Pagination

List operations (tools/list, prompts/list, resources/list, resources/templates/list) are paginated via a next_cursor. The list_all_* helpers walk every page for you:

// Fetches all pages transparently.
let tools     = client.list_all_tools().await?;
let prompts   = client.list_all_prompts().await?;
let resources = client.list_all_resources().await?;

To page manually, call the single-page method and follow next_cursor until it's None:

use rmcp::model::PaginatedRequestParams;

let mut cursor = None;
loop {
    let page = client
        .list_tools(Some(PaginatedRequestParams { meta: None, cursor }))
        .await?;
    for tool in &page.tools {
        // handle each tool
    }
    cursor = page.next_cursor;
    if cursor.is_none() {
        break;
    }
}

On the server, return a next_cursor from your list_* handler when more pages remain (None when complete).

MCP Spec: Pagination


Capability & Protocol Version Negotiation

Capabilities

At initialization, client and server exchange capabilities so each side knows what the other supports. Declare yours with the ServerCapabilities builder in get_info():

use rmcp::model::{ServerCapabilities, ServerConfig};

fn get_info(&self) -> ServerConfig {
    ServerConfig::new(
        ServerCapabilities::builder()
            .enable_tools()
            .enable_prompts()
            .enable_resources()
            .enable_resources_subscribe()
            .enable_tool_list_changed()
            .enable_logging()
            .build(),
    )
}

Clients do the same via ClientCapabilities::builder(). Macros like #[tool_handler] / #[prompt_handler] set the relevant flags automatically. After connecting, read the peer's capabilities via peer.peer_info().

Protocol version

MCP is versioned by date. rmcp negotiates automatically on connect — the client offers a preferred ProtocolVersion and falls back to one the server supports:

use rmcp::model::ProtocolVersion;

ProtocolVersion::LATEST;        // newest stable version this SDK defaults to
ProtocolVersion::V_2026_07_28;  // a specific version constant
ProtocolVersion::KNOWN_VERSIONS; // every version this SDK understands

Version-specific behavior (SEP-2243 headers, SEP-2567 stateless serving, the SEP-2575 subscription model) is gated on the negotiated version, so older clients keep working while newer ones opt in.

MCP Spec: Versioning and Compatibility


JSON Schema 2020-12

Deriving schemars::JsonSchema on your parameter and result types generates JSON Schema draft 2020-12 — the dialect the MCP spec requires — for the tool's inputSchema and outputSchema.

use rmcp::schemars;

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SearchParams {
    /// Full-text query.
    query: String,
    /// Maximum number of results.
    #[serde(default)]
    limit: Option<u32>,
}

Field names, types, and doc comments flow into the schema — no manual authoring needed. As of 2026-07-28 (SEP-2106), outputSchema may be any JSON Schema type (not only object) and structuredContent may be any JSON value.


Examples

See examples.

OAuth Support

See Oauth_support for details.

Related Resources

Related Projects

Extending rmcp
Built with rmcp
  • goose - An open-source, extensible AI agent that goes beyond code suggestions
  • apollo-mcp-server - MCP server that connects AI agents to GraphQL APIs via Apollo GraphOS
  • rustfs-mcp - High-performance MCP server providing S3-compatible object storage operations for AI/LLM integration
  • containerd-mcp-server - A containerd-based MCP server implementation
  • rmcp-openapi-server - High-performance MCP server that exposes OpenAPI definition endpoints as MCP tools
  • nvim-mcp - A MCP server to interact with Neovim
  • terminator - AI-powered desktop automation MCP server with cross-platform support and >95% success rate
  • stakpak-agent - Security-hardened terminal agent for DevOps with MCP over mTLS, streaming, secret tokenization, and async task management
  • video-transcriber-mcp-rs - High-performance MCP server for transcribing videos from 1000+ platforms using whisper.cpp
  • NexusCore MCP - Advanced malware analysis & dynamic instrumentation MCP server with Frida integration and stealth unpacking capabilities
  • spreadsheet-mcp - Token-efficient MCP server for spreadsheet analysis with automatic region detection, recalculation, screenshot, and editing support for LLM agents
  • hyper-mcp - A fast, secure MCP server that extends its capabilities through WebAssembly (WASM) plugins
  • rudof-mcp - RDF validation and data processing MCP server with ShEx/SHACL validation, SPARQL queries, and format conversion. Supports stdio and streamable HTTP transports with full MCP capabilities (tools, prompts, resources, logging, completions, tasks)
  • MCPMate - Desktop app for progressive MCP management: start with guided server import, then grow into multi-client profiles and Unify meta tools to keep tool exposure, token use, and runtime state under control, with more options for efficiency, cost, and reliability
  • McpMux - Desktop app to configure MCP servers once at McpMux, connect every AI client (Cursor, Claude Desktop, VS Code, Windsurf) through a single encrypted local gateway with Spaces for project organization, FeatureSets to switch toolsets per client, and a built-in server registry
  • systemprompt-template - Single-binary Rust runtime providing MCP governance — authentication, authorisation, rate-limiting, audit trails, and cost tracking for AI agents. Self-hosted, air-gap capable, 3,300+ req/s with sub-5ms governance overhead
  • jilebi-mcp - an extensible MCP server through plugins in Javascript with a secure permissions model
  • Voltip - Push-to-talk dictation app whose local MCP server (Streamable HTTP, token-protected) lets coding agents read its dictionary, rules and correction history, and add dictionary entries the user approves

Development

Tips for Contributors

See docs/CONTRIBUTE.MD to get some tips for contributing.

Triage

New issues and pull requests are labeled by an automated pass and then assigned to the maintainer on weekly rotation, who owns the final call. See docs/CONTRIBUTE.MD for the rotation and the response-time expectations.

Using Dev Container

If you want to use dev container, see docs/DEVCONTAINER.md for instructions on using Dev Container for development.

Runs on
GPU support unknown
What shows it
  • no dependency or feature matches a backend row
Native code
None found
  • No native code found
Python bindings
No Python binding crate found
License
Apache-2.0

Compare with similar projects

Order: strongest tier first, then most downloads, then name, with unmeasured values last. A library counts downloads over 30 days. An app counts binary downloads per release. An app with none counts its crate downloads over 30 days. ML Rust runs this catalogue, so its closest project always gets a row.

rmcp beside the projects that share a topic with it
ProjectDownloadsDependentsStarsLatest releaseRuns on (GPU)Native codeLicense
rmcp This page Featured

Rust SDK for Model Context Protocol

12,149,426 downloads over 30 days 2,400 4,101 3.9.0 GPU support unknown No native code found Apache-2.0

Type-safe implementation of the Model Context Protocol in Rust, designed to reduce errors and accelerate development with powerful utilities.

107,187 downloads over 30 days 19 79 2.0.0 GPU support unknown No native code found MIT
rust-mcp-sdk Featured

An asynchronous SDK and framework for building MCP-Servers and MCP-Clients, leveraging the rust-mcp-schema for type safe MCP Schema Objects.

29,764 downloads over 30 days 51 199 2.0.0 GPU support unknown No native code found MIT

actix-web transport implementations for RMCP (Rust Model Context Protocol)

27,030 downloads over 30 days 5 Not measured 0.13.2 GPU support unknown No native code found MIT
edgequake Listed

Rust SDK for the EdgeQuake RAG platform

11 downloads over 30 days 0 2,297 0.4.1 GPU support unknown No native code found Apache-2.0
axp App Listed

An open protocol that gives AI agents a secure, sandboxed place to run code and use tools — with lean context usage. AXP works alongside MCP

Not measured Not applicable 3 Not measured Not measured Not measured Apache-2.0
cersei New

Cersei: A modular Rust SDK for building coding agents

107 downloads over 30 days 1 464 0.2.6 GPU support unknown Binds native code: tree-sitter, cxx, link-cplusplus, tree-sitter-language, tikv-jemalloc-sys (feature jemalloc-bench) MIT
Why FeaturedIt passes every Featured gate.

What it meets

  • It has 1255 active dependents and 1228 new dependents in 365 days. Featured needs 11 active dependents or 9 new dependents in 365 days.
  • Active dependents are at 100% of their peak. Featured needs at least 50%.
  • Docs are present. The release commit or a manifest ties the crate to the repository.
  • No vulnerability advisory stays unpatched past the grace window. At least one version is neither a prerelease nor 0.0.x.
  • The last activity is on 8 Nov 2026, 1 day ago. The rules expect an update within 800 days. Dormancy rule

Exact rules: Featured library. The check on applied thresholds version 9.

Every rule outcome, as the check records it
  • expected-interval: version 3.9.0 base 200, doubled for 12149426 downloads per month, doubled for 1254.92 active users: E = 800 days
  • dormancy: last activity 2026-11-08, 1 days ago, E = 800: active
  • featured: featured: active_users 1254.92, new_adopters 1227.92, former_glory 1
Crates2 crates

Measurements and sources

Each signal row shows the raw value, its source and the fetch date. The Sources list gathers every distinct source on this page. Movement needs more history.

Sources6 sources
Adoption1255 active dependents, 1228 new dependents in 365 days1 not measured
Adoption measurements
SignalValueSourceFetchedCounts
Active dependents1254.9167crates.io database dumpYes
New dependents (365 days)1227.9167crates.io database dumpYes
Peak active dependents1236.9167crates.io database dumpYes
Active dependents as a share of the peak1crates.io database dumpYes
Binary downloads per releaseNot measuredYes
MaintenanceLatest human commit: 1 day ago3 not measured
Maintenance measurements
SignalValueSourceFetchedCounts
Latest human commit2026-11-08GitHub repositoryYes
Latest release2026-11-05crates.io database dumpYes
First release2025-03-16crates.io database dumpYes
Latest version3.9.0crates.io database dumpYes
Days the oldest outside issue waits for a reply35GitHub repositoryYes
Maintainer replies over 365 days15GitHub repositoryYes
Repository archivedfalseGitHub repositoryYes
Repository gonefalseGitHub repositoryYes
Release commit or manifest in the repotruecrates.io packageYes
Published docstruecrates.io database dumpYes
Latest release fails cargo checkNot measuredYes
Build check dateNot measuredYes
Build check toolchainNot measuredYes
SecurityUnpatched vulnerabilities: none
Security measurements
SignalValueSourceFetchedCounts
Unpatched vulnerabilities past the grace windownoneRustSec advisory databaseYes
RustSec unmaintained advisorynoneRustSec advisory databaseYes
Deprecation noticenonecrates.io database dumpYes
Deprecated requirementnonecrates.io database dumpYes
Distribution71 releases, 12,149,426 downloads over 30 days5 not measured
Distribution measurements
SignalValueSourceFetchedCounts
Releases71crates.io database dumpYes
Downloads over 30 days12149426crates.io database dumpYes
Binary downloads over the last 3 releasesNot measuredYes
Homebrew installs on request over 365 daysNot measuredYes
Repology repository familiesNot measuredYes
Docker pull growth over 90 daysNot measuredYes
Docker pulls at the growth baselineNot measuredYes
Every version a prereleasefalsecrates.io database dumpYes
Every version 0.0.xfalsecrates.io database dumpYes
Every version yankedfalsecrates.io database dumpYes
Crate deleted from crates.iofalsecrates.io database dumpYes
Repository4,101 stars, 702 forks, 228 contributors, 793 commits. Stars alone never qualify a project.
Repository measurements
SignalValueSourceFetchedCounts
GitHub stars4101GitHub repositoryYes — one part of the repository route
GitHub forks702GitHub repositoryYes — one part of the repository route
Contributors with a GitHub account228GitHub contributorsYes — one part of the repository route
Commits on the default branch793GitHub repositoryNo
Repository's main crate left out of the entrynonecrates.io database dumpYes
Facts that never count
Facts that never count measurements
SignalValueSourceFetchedCounts
LicenseApache-2.0crates.io database dumpNo
Runs on, from listed crates and their direct dependenciesGPU support unknowncrates.io database dumpNo
What shows each backendno dependency or feature matches a backend rowcrates.io database dumpNo
Native code, from listed crates and their dependenciesNo native code foundcrates.io database dumpNo
Python bindings, from direct dependencies of listed cratesNo Python binding crate foundcrates.io database dumpNo
Crates that depend on a listed crate directly2400crates.io database dumpNo

Corrections and review history

Report a correction

No correction applies to this entry.

Judgments

A judgment enters only through an approved issue and a merged pull request.

No judgment applies to this entry.