← Back to list

Implementing a Streamable HTTP MCP Server and Client in Rust with C++ FFI

★ 1 — Introduction Building advanced AI applications requires more than a language model — you need real-world data and capabilities. The…

mohit pal singh · 2025-11-15 04:20 · 2 claps · 23.2 min read
#mcp-server #mcp-client #embedded-systems #yocto
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents

Implementing a Streamable HTTP MCP Server and Client in Rust with C++ FFI

★ 1 — Introduction Building advanced AI applications requires more than a language model — you need real-world data and capabilities. The Model Context Protocol (MCP) bridges this gap, connecting models to external environments. In this post, we’ll explore how to build an MCP server and client in Rust, leveraging C++ FFI and the new streamable HTTP transport layer.

MCP servers act as the backbone of MCP systems, exposing standardized capabilities that link language models to file systems, APIs, databases, and other data sources. Introduced by Anthropic in November 2024, MCP serves as a universal adapter, enabling models to access live data and deliver responses grounded in reality.

Communication between MCP clients and servers follows JSON-RPC 2.0, using requests, responses, and notifications. The new streamable HTTP transport replaces HTTP+SSE, improving reliability for remote servers. It uses HTTP POST/GET and supports Server-Sent Events (SSE) for streaming messages and notifications, allowing both simple and advanced MCP servers to handle real-time data efficiently.

Here’s how it works:

  • The MCP client sends requests to the server via HTTP POST
  • The server can respond with either a single JSON message or initiate a live event stream using Server-Sent Events (SSE)
  • All communication flows through one unified HTTP endpoint (e.g., [https://example.com/mcp)](https://example.com/mcp))
  • The endpoint accepts both POST requests (for sending commands) and GET requests (for establishing listening streams)

This single-endpoint design is a defining feature of streamable HTTP. The server MUST provide a single HTTP endpoint path that supports both POST and GET methods, dramatically simplifying implementation compared to older multi-endpoint architectures.

Under the hood, streamable HTTP still uses standard HTTP protocols (POST, GET) and SSE, ensuring compatibility with existing web infrastructure like proxies and load balancers while enabling long-lived streams of data. This backward compatibility makes it practical for production deployments without requiring specialized infrastructure.

In short, streamable HTTP is the mechanism that allows MCP to stream data over HTTP in a controlled way, using SSE to deliver continuous server messages when needed.

★ 2— Why Build This? This architecture is particularly valuable for memory-constrained embedded devices where native performance and tight resource control are critical. We’ll walk through both server and client implementations using the rmcp crate and the streamable HTTP transport introduced in protocol version 2025-03-26.

*★ 3—*** Project Setup: **Let's go through all the pieces of the puzzle that are required to implement the MCP server and a client.

★ 3.1 Code organization: The following section shows how the code is organized — in this case, for a Yocto meta layer.

meta-<your-layer>/
└── recipes/
    ├── mcp-client/
    │   ├── files/
    │   │   ├── src/
    │   │   │   └── main.rs
    │   │   └── Cargo.toml
    │   └── mcp-client_0.1.0.bb
    │
    └── mcp-server/
        ├── files/
        │   ├── cpp_api/
        │   │   ├── tools.cpp
        │   │   └── tools.h
        │   ├── src/
        │   │   └── main.rs 
        │   ├── build.rs
        │   └── Cargo.toml
        └── mcp-server_0.1.0.bb

*★ 4 — Server and its dependencies: mcp-server/files/Cargo.toml*


[package]
name = "http-stream-mcp-server"
version = "0.1.0"
edition = "2024"

[build-dependencies]
cc = "1.0"

[dependencies]
tokio = { version = "1", features = ["full"] }
rmcp = { version = "0.8", features = [
    "server",
    "macros",
    "transport-streamable-http-server",
] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1.0"

★ 4.1 Understanding the server Dependencies

  • rmcp: Implements the Model Context Protocol (MCP), including macros and server-side support for streamable HTTP transport. Enables tool routing, session management, and protocol compliance for AI-driven interactions.
  • axum: A web framework built on Tokio and hyper, designed for ergonomic and composable HTTP server development. Used here to expose your MCP service over HTTP.
  • tokio: An asynchronous runtime for Rust. The “full” feature enables all core components, such as TCP, timers, and task spawning — essential for running your async MCP server.
  • serde: A serialization/deserialization framework. The “derive” feature allows automatic generation of Serialize and Deserialize implementations for your data types.
  • schemars: Generates JSON Schema from Rust types. Useful for documenting tool parameters and improving AI agent understanding of input structures.
  • tracing: Structured, event-based logging for async Rust applications. Helps you monitor server behavior and debug protocol flows.
  • anyhow: Simplifies error handling by providing a flexible Result type that can encapsulate any error. Ideal for top-level error propagation in main.

*★ 4.2 Actual server: mcp-server/files/src/main.rs*

use rmcp::{
    ErrorData as McpError, ServerHandler,
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::*,
    schemars, tool, tool_handler, tool_router,
};
use serde::Deserialize;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_double};

use tracing_subscriber::prelude::*;

// FFI bindings to C++ functions
#[repr(C)]
struct AddNumbersResult {
    result: c_double,
    success: i32,
    error: *mut c_char,
}

#[repr(C)]
struct EchoResult {
    echo: *mut c_char,
    success: i32,
    error: *mut c_char,
}

unsafe extern "C" {
    fn add_numbers(a: c_double, b: c_double) -> AddNumbersResult;
    fn echo(message: *const c_char) -> EchoResult;
    fn free_add_numbers_result(result: *mut AddNumbersResult);
    fn free_echo_result(result: *mut EchoResult);
}

#[derive(Debug, Clone)]
struct SimpleServer {
    tool_router: ToolRouter<SimpleServer>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct AddNumbersRequest {
    #[schemars(description = "The first number to add")]
    a: f64,
    #[schemars(description = "The second number to add")]
    b: f64,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct EchoRequest {
    #[schemars(description = "The message to echo back")]
    message: String,
}

#[tool_router]
impl SimpleServer {
    fn new() -> Self {
        Self {
            tool_router: Self::tool_router(),
        }
    }

    #[tool(description = "Add two numbers together")]
    async fn add_numbers(
        &self,
        Parameters(AddNumbersRequest { a, b }): Parameters<AddNumbersRequest>,
    ) -> Result<CallToolResult, McpError> {
        // Call C++ function via FFI
        let mut c_result = unsafe { add_numbers(a, b) };

        let response = if c_result.success != 0 {
            let result_value = c_result.result;
            // Free C++ allocated memory
            unsafe { free_add_numbers_result(&mut c_result) };

            serde_json::json!({
                "success": true,
                "a": a,
                "b": b,
                "result": result_value
            })
        } else {
            let error_msg = if !c_result.error.is_null() {
                unsafe { CStr::from_ptr(c_result.error).to_string_lossy().into_owned() }
            } else {
                "Unknown error".to_string()
            };

            // Free C++ allocated memory
            unsafe { free_add_numbers_result(&mut c_result) };

            // Use invalid_params - convert to static string via leak (for error reporting)
            let full_error = format!("C++ error: {}", error_msg);
            let leaked: &'static str = Box::leak(full_error.into_boxed_str());
            return Err(McpError::invalid_params(leaked, None));
        };

        Ok(CallToolResult::success(vec![Content::text(
            serde_json::to_string_pretty(&response).unwrap(),
        )]))
    }

    #[tool(description = "Echo back a message")]
    async fn echo(
        &self,
        Parameters(EchoRequest { message }): Parameters<EchoRequest>,
    ) -> Result<CallToolResult, McpError> {
        // Convert Rust string to C string
        let c_message = CString::new(message.clone())
            .map_err(|e| {
                let err_msg = format!("Failed to convert message: {}", e);
                let leaked: &'static str = Box::leak(err_msg.into_boxed_str());
                McpError::invalid_params(leaked, None)
            })?;

        // Call C++ function via FFI
        let mut c_result = unsafe { echo(c_message.as_ptr()) };

        let response = if c_result.success != 0 {
            let echoed = if !c_result.echo.is_null() {
                unsafe { CStr::from_ptr(c_result.echo).to_string_lossy().into_owned() }
            } else {
                String::new()
            };

            unsafe { free_echo_result(&mut c_result) };

            serde_json::json!({
                "success": true,
                "echo": echoed
            })
        } else {
            let error_msg = if !c_result.error.is_null() {
                unsafe { CStr::from_ptr(c_result.error).to_string_lossy().into_owned() }
            } else {
                "Unknown error".to_string()
            };

            unsafe { free_echo_result(&mut c_result) };

            // Use invalid_params - convert to static string via leak (for error reporting)
            let full_error = format!("C++ error: {}", error_msg);
            let leaked: &'static str = Box::leak(full_error.into_boxed_str());
            return Err(McpError::invalid_params(leaked, None));
        };

        Ok(CallToolResult::success(vec![Content::text(
            serde_json::to_string_pretty(&response).unwrap(),
        )]))
    }
}

#[tool_handler]
impl ServerHandler for SimpleServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            protocol_version: ProtocolVersion::V_2024_11_05,
            capabilities: ServerCapabilities::builder().enable_tools().build(),
            server_info: Implementation {
                name: "simple-server".to_string(),
                version: "0.1.0".to_string(),
                title: None,
                website_url: None,
                icons: None,
            },
            instructions: Some(
                "A simple MCP server with two tools: add_numbers (adds two numbers) and echo (echoes a message)."
                    .to_string(),
            ),
        }
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info".to_string().into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    tracing::info!("Starting Simple MCP Server");

    let service = rmcp::transport::streamable_http_server::StreamableHttpService::new(
        || Ok(SimpleServer::new()),
        rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default()
            .into(),
        Default::default(),
    );

    let router = axum::Router::new().nest_service("/mcp", service);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await?;

    tracing::info!(" Server ready at http://127.0.0.1:8000/mcp");

    axum::serve(listener, router)
        .with_graceful_shutdown(async {
            tokio::signal::ctrl_c().await.unwrap();
        })
        .await?;

    Ok(())
}

Let’s break it down and understand various sections of the server code.

★ 4.3 Understanding the core server imports

When building an MCP server in Rust, we will need at least the following imports.

use rmcp::{
    ErrorData as McpError,
    ServerHandler,
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::*,
    schemars,
    tool,
    tool_handler,
    tool_router,
};
use serde::Deserialize;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_double};
use tracing_subscriber::prelude::*;

★ 4.3.1 Breaking Down the rmcp Imports

The rmcp crate provides everything you need to implement an MCP server:

  • ErrorData as McpError: Renames ErrorData to McpError for clarity. This is used to represent structured error information in MCP responses.
  • ServerHandler: The trait that defines how your server responds to MCP lifecycle events (like get_info()).
  • handler::server::{router::tool::ToolRouter, wrapper::Parameters}:
  • ToolRouter: Handles routing of tool calls to the correct implementation
  • Parameters: A wrapper for tool input parameters, making them easy to deserialize and validate
  • model::: Brings in MCP data models like ServerInfo, ProtocolVersion, and ServerCapabilities.*
  • schemars: Used for generating JSON schemas for tool parameters (important for MCP clients to validate input).
  • Macros: tool, tool_handler, tool_router: These macros simplify registering tools and handlers in your server.

★ 4.3.2 Other Essential Imports

  • serde::Deserialize: Essential for parsing JSON input into Rust structs. MCP uses JSON-RPC, so deserialization is key.

std::ffi and std::os::raw: This suggests the server exposes tools that interact with native C libraries or external systems.

  • CStr, CString: For working with C-style strings
  • c_char, c_double: Raw types for FFI (Foreign Function Interface)
  • tracing_subscriber::prelude::: Brings in logging and tracing capabilities. This is crucial for debugging and observability in async servers.*

★ 4.3.3 Setting Up the HTTP Server: To expose the MCP server over HTTP, we set up the Streamable HTTP transport, integrate it with Axum, and bind it to a local TCP socket:

let service = rmcp::transport::streamable_http_server::StreamableHttpService::new(
    || Ok(SimpleServer::new()),
    rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default().into(),
    Default::default(),
);

Let’s break this down further.

StreamableHttpService::new(…) creates a new MCP service that supports streaming, session management, and tool invocation over HTTP.

  • The first argument is a service factory: || Ok(SimpleServer::new()). This creates a fresh instance of the server for each session.
  • The second argument sets up a local session manager, which handles session lifecycles and routes messages to the correct server instance.
  • The third argument is a default configuration for the transport layer.

Next, we mount the MCP service at the /mcp endpoint using Axum:

let router = axum::Router::new().nest_service("/mcp", service);

This means all MCP traffic — initialization, tool calls, streaming — will be handled at [http://localhost:8000/mcp](http://localhost:8000/mcp.) .

Finally, we bind the server to a local TCP port:

let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await?;

This line tells Tokio to listen for incoming connections on port 8000. Once bound, we can pass this listener to axum::serve(...) to start handling requests:

axum::serve(listener, router)
    .with_graceful_shutdown(async {
        tokio::signal::ctrl_c().await.unwrap();
    })
    .await?;

This starts an Axum HTTP server, listens for incoming requests on the given listener, and shuts down gracefully when Ctrl+C is pressed, and also propagates any errors to the caller

Together, these lines bring the MCP server to life — ready to accept client connections, manage sessions, and stream responses in real time.

★ 4.3.4 Implementing the Server Handler: One of the first things you’ll need is a server that can communicate its capabilities and metadata to clients. Here’s the implementation of the ServerHandler trait for a minimal MCP server.

#[tool_handler]
impl ServerHandler for SimpleServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            protocol_version: ProtocolVersion::V_2024_11_05,
            capabilities: ServerCapabilities::builder()
                .enable_tools()
                .build(),
            server_info: Implementation {
                name: "simple-server".to_string(),
                version: "0.1.0".to_string(),
                title: None,
                website_url: None,
                icons: None,
            },
            instructions: Some(
                "A simple MCP server with two tools: add_numbers (adds two numbers) and echo (echoes a message)."
                    .to_string(),
            ),
        }
    }
}

ServerHandler for SimpleServer: This means our SimpleServer struct conforms to the ServerHandler trait, which defines how an MCP server should behave.

The get_info() method returns a ServerInfo object that describes:

Protocol Version: protocol_version: ProtocolVersion::V_2024_11_05 - The server supports MCP protocol version 2024-11-05.

Capabilities: Using the builder pattern, we enable tools, meaning this server can execute tool-based operations.

Server Metadata: server_info: Implementation { name, version, ... } - Basic details like name (simple-server) and version (0.1.0).

Instructions: instructions: Some("A simple MCP server with two tools...") - A helpful description for clients: this server exposes two tools—add_numbers and echo.

The get_info() method is the handshake between the MCP server and any client. It tells the client which protocol version to use, what features are available (like tools), and how to interact with the server

Without this, clients wouldn’t know what the server can do.

★ 4.3.5 Building MCP Tools with Rust and C++

When creating an MCP server that leverages existing C++ logic, you often need FFI (Foreign Function Interface) to bridge Rust and C++. Let’s break down the implementation into two parts:

4.3.5.1: C++ FFI Bindings

The first step is defining how Rust will talk to C++ functions. This is done using #[repr(C)] structs and extern "C" declarations:

#[repr(C)]
struct AddNumbersResult {
    result: c_double,
    success: i32,
    error: *mut c_char,
}

#[repr(C)]
struct EchoResult {
    echo: *mut c_char,
    success: i32,
    error: *mut c_char,
}

unsafe extern "C" {
    fn add_numbers(a: c_double, b: c_double) -> AddNumbersResult;
    fn echo(message: *const c_char) -> EchoResult;
    fn free_add_numbers_result(result: *mut AddNumbersResult);
    fn free_echo_result(result: *mut EchoResult);
}

What’s Happening Here?

#[repr(C)] ensures Rust structs have the same memory layout as C structs.

AddNumbersResult and EchoResult mirror the C++ return types, including:

  • success: Indicates if the operation succeeded
  • error: Pointer to an error message if something went wrong

extern “C” declares the C++ functions so Rust can call them:

  • add_numbers(a, b): Adds two numbers
  • echo(message): Returns the same message

Memory Management: Functions like free_add_numbers_result and free_echo_result are critical to avoid leaks since C++ allocates memory.

This layer is all about safe bridging between Rust and C++.

4.3.5.2: Rust MCP Tool Integration

Now that we can call C++ functions, let’s expose them as MCP tools using Rust macros and async handlers:

#[derive(Debug, Clone)]
struct SimpleServer {
    tool_router: ToolRouter<SimpleServer>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct AddNumbersRequest {
    a: f64,
    b: f64,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct EchoRequest {
    message: String,
}

#[tool_router]
impl SimpleServer {
    fn new() -> Self {
        Self {
            tool_router: Self::tool_router(),
        }
    }

    #[tool(description = "Add two numbers together")]
    async fn add_numbers(
        &self,
        Parameters(AddNumbersRequest { a, b }): Parameters<AddNumbersRequest>,
    ) -> Result<CallToolResult, McpError> {
        let mut c_result = unsafe { add_numbers(a, b) };

        let response = if c_result.success != 0 {
            let result_value = c_result.result;
            unsafe { free_add_numbers_result(&mut c_result) };

            serde_json::json!({
                "success": true,
                "a": a,
                "b": b,
                "result": result_value
            })
        } else {
            let error_msg = if !c_result.error.is_null() {
                unsafe {
                    CStr::from_ptr(c_result.error)
                        .to_string_lossy()
                        .into_owned()
                }
            } else {
                "Unknown error".to_string()
            };

            unsafe { free_add_numbers_result(&mut c_result) };

            let leaked: &'static str = Box::leak(
                format!("C++ error: {}", error_msg).into_boxed_str()
            );
            return Err(McpError::invalid_params(leaked, None));
        };

        Ok(CallToolResult::success(vec![Content::text(
            serde_json::to_string_pretty(&response).unwrap(),
        )]))
    }

    #[tool(description = "Echo back a message")]
    async fn echo(
        &self,
        Parameters(EchoRequest { message }): Parameters<EchoRequest>,
    ) -> Result<CallToolResult, McpError> {
        let c_message = CString::new(message.clone()).map_err(|e| {
            let leaked: &'static str = Box::leak(
                format!("Failed to convert message: {}", e).into_boxed_str()
            );
            McpError::invalid_params(leaked, None)
        })?;

        let mut c_result = unsafe { echo(c_message.as_ptr()) };

        let response = if c_result.success != 0 {
            let echoed = if !c_result.echo.is_null() {
                unsafe {
                    CStr::from_ptr(c_result.echo)
                        .to_string_lossy()
                        .into_owned()
                }
            } else {
                String::new()
            };

            unsafe { free_echo_result(&mut c_result) };

            serde_json::json!({
                "success": true,
                "echo": echoed
            })
        } else {
            let error_msg = if !c_result.error.is_null() {
                unsafe {
                    CStr::from_ptr(c_result.error)
                        .to_string_lossy()
                        .into_owned()
                }
            } else {
                "Unknown error".to_string()
            };

            unsafe { free_echo_result(&mut c_result) };

            let leaked: &'static str = Box::leak(
                format!("C++ error: {}", error_msg).into_boxed_str()
            );
            return Err(McpError::invalid_params(leaked, None));
        };

        Ok(CallToolResult::success(vec![Content::text(
            serde_json::to_string_pretty(&response).unwrap(),
        )]))
    }
}

Key Points

#[tool_router] and #[tool] macros: Automatically register tools with the MCP server.

Schema generation: schemars::JsonSchema ensures clients know the expected input format.

Error handling: Converts C++ errors into MCP-compliant invalid_params.

Memory safety: Always free C++ allocated memory after use.

Async execution: Tools run asynchronously, perfect for MCP’s event-driven architecture.

4.3.6 C++ Tool Implementation: Here are the two simple tools: add_numbers and echo, along with memory management helpers.

mcp-server/files/cpp_api/tools.cpp mcp-server/files/cpp_api/tools.h

Add Numbers Tool: Adds two numbers and returns the result. Return Type: AddNumbersResult struct with:

  • result: The sum
  • success: Flag (1 = success)
  • error: Null if no error

Echo Tool: Returns the same message back. Error Handling: If the message is null, it allocates an error string dynamically.

#include "tools.h"
#include <cstring>
#include <cstdlib>
#include <string>

extern "C" {

AddNumbersResult add_numbers(double a, double b) {
    AddNumbersResult result;
    result.result = a + b;
    result.success = 1;
    result.error = nullptr;
    return result;
}

EchoResult echo(const char* message) {
    EchoResult result;

    if (message == nullptr) {
        result.echo = nullptr;
        result.success = 0;
        size_t error_len = strlen("Message cannot be null") + 1;
        result.error = static_cast<char*>(malloc(error_len));
        strcpy(result.error, "Message cannot be null");
        return result;
    }

    // Allocate memory for the echoed message
    size_t msg_len = strlen(message);
    result.echo = static_cast<char*>(malloc(msg_len + 1));
    strcpy(result.echo, message);
    result.success = 1;
    result.error = nullptr;

    return result;
}

void free_add_numbers_result(AddNumbersResult* result) {
    if (result != nullptr && result->error != nullptr) {
        free(result->error);
        result->error = nullptr;
    }
}

void free_echo_result(EchoResult* result) {
    if (result != nullptr) {
        if (result->echo != nullptr) {
            free(result->echo);
            result->echo = nullptr;
        }
        if (result->error != nullptr) {
            free(result->error);
            result->error = nullptr;
        }
    }
}

} // extern "C"
#ifndef TOOLS_H
#define TOOLS_H

#ifdef __cplusplus
extern "C" {
#endif

// Structure to hold the result of add_numbers
typedef struct {
    double result;
    int success;
    char* error; 
} AddNumbersResult;

// Structure to hold the result of echo
typedef struct {
    char* echo;
    int success;
    char* error;
} EchoResult;

// Add two numbers together
AddNumbersResult add_numbers(double a, double b);

// Echo back a message
EchoResult echo(const char* message);

// Free memory allocated by the functions
void free_add_numbers_result(AddNumbersResult* result);
void free_echo_result(EchoResult* result);

#ifdef __cplusplus
}
#endif

#endif // TOOLS_H

*★ 4.3.7 Rust Build Integration: mcp-server/files/build.rs*

Rust needs to compile and link this C++ code. That’s where build.rs comes in.

use std::path::PathBuf;

fn main() {
    println!("cargo:rerun-if-changed=cpp_api/tools.cpp");
    println!("cargo:rerun-if-changed=cpp_api/tools.h");

    let cpp_api_dir = PathBuf::from("cpp_api");
    let tools_cpp = cpp_api_dir.join("tools.cpp");

    // Compile C++ code
    cc::Build::new()
        .cpp(true)
        .file(&tools_cpp)
        .include(&cpp_api_dir)
        .compile("tools");

    // Link C++ standard library
    println!("cargo:rustc-link-lib=stdc++");
}

Step-by-Step Explanation

  • cargo:rerun-if-changed: Tells Cargo to rebuild if tools.cpp or when tools.h changes.
  • cc::Build::new(): Uses the cc crate to compile C/C++ code during the Rust build process.
  • .cpp(true): Enables C++ mode.
  • .file(&tools_cpp) and .include(&cpp_api_dir): Specifies the source file and include directory.
  • .compile(“tools”): Compiles the C++ code into a static library named libtools.a.
  • println!(“cargo:rustc-link-lib=stdc++”);: Links the C++ standard library so Rust can call C++ functions.

4.3.8 Yocto recipe: Server recipe to cook everything together

mcp-server/mcp-server_0.1.0.bb

SUMMARY = "MCP Server with C++ API integration"
DESCRIPTION = "Model Context Protocol server with tools implemented in C++"
HOMEPAGE = "https://github.com/modelcontextprotocol"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"

PV = "0.1.0"
PR = "r0"
PN = "mcp-server"

inherit cargo_bin

DEPENDS += " \
    pkgconfig \
    openssl \
    zlib \
    rust-bin-cross-aarch64 \
    virtual/${TARGET_PREFIX}gcc \
    virtual/${TARGET_PREFIX}g++ \
"

RDEPENDS:${PN} = " \
    libgcc \
    libstdc++ \
"

SRC_URI = " \
    file://Cargo.toml \
    file://build.rs \
    file://src/main.rs \
    file://cpp_api/tools.h \
    file://cpp_api/tools.cpp \
"

S = "${WORKDIR}"

CARGO_FETCH_OFFLINE = "1"

INHERIT += "splitdebug"

do_install() {
    install -d ${D}${bindir}
    install -m 0755 ${S}/target/*/release/http-stream-mcp-server ${D}${bindir}/mcp-server

# ${STRIP} ${D}${bindir}/mcp-server
}

FILES:${PN} = "${bindir}/mcp-server"

★ 5— Implementing the MCP Client: To test our server, we need an MCP client.

This section explores the implementation of an MCP client that interacts with an MCP server via HTTP/Server-Sent Events (SSE), using JSON-RPC 2.0 messaging. We will walk through how the code aligns with MCP lifecycle, session management, transports, tool invocation.

*★ 5.1 Client and its Dependencies: mcp-client/files/Cargo.toml*

[package]
name = "mcp_test_client"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.11", features = ["json", "stream"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
futures = "0.3"

★ 5.1.1 Understanding the client Dependencies

  • reqwest (0.11): A popular HTTP client for Rust. We enable two critical features
  • *json: Provides built-in JSON serialization/deserialization for request and response bodies.*
  • *stream: Enables streaming response bodies, essential for handling Server-Sent Events (SSE)*
  • serde (1.0): The serialization framework for Rust. The derive feature allows us to automatically implement serialization traits using derive macros
  • serde_json (1.0): JSON support for serde, providing the json! macro for convenient JSON construction and parsing capabilities
  • tokio (1.x): An asynchronous runtime for Rust. The full feature includes all tokio functionality, enabling async/await patterns, networking, timers, and more
  • futures (0.3): Provides abstractions for asynchronous programming, particularly the StreamExt trait that we use for processing SSE streams

*★ 5.1.2 Actual client: mcp-client/files/src/main.rs*

use reqwest;
use serde_json::json;
use std::collections::HashMap;
use futures::StreamExt;

struct MCPClient {
    url: String,
    request_id: u64,
    client: reqwest::Client,
    initialized: bool,
    session_id: Option<String>,
}

impl MCPClient {
    fn new(url: &str) -> Self {
        MCPClient {
            url: url.to_string(),
            request_id: 1,
            client: reqwest::Client::new(),
            initialized: false,
            session_id: None,
        }
    }

    async fn send_request(
        &mut self,
        method: &str,
        params: serde_json::Value,
    ) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
        let request = json!({
            "jsonrpc": "2.0",
            "id": self.request_id,
            "method": method,
            "params": params
        });

        let request_id = self.request_id;
        self.request_id += 1;

        let mut request_builder = self
            .client
            .post(&self.url)
            .header("Accept", "application/json, text/event-stream")
            .header("Content-Type", "application/json");

        // Add session ID if we have one
        if let Some(session_id) = &self.session_id {
            request_builder = request_builder.header("mcp-session-id", session_id);
        }

        let response = request_builder.json(&request).send().await?;

        let status = response.status();

        // Extract session ID from response header if present
        // CRITICAL: Must extract BEFORE consuming response body
        if let Some(session_id_header) = response.headers().get("mcp-session-id") {
            if let Ok(session_id_str) = session_id_header.to_str() {
                self.session_id = Some(session_id_str.to_string());
            }
        }

        // Handle SSE streaming response
        if !status.is_success() {
            let mut error_text = String::new();
            let mut stream = response.bytes_stream();
            while let Some(item) = stream.next().await {
                if let Ok(chunk) = item {
                    error_text.push_str(&String::from_utf8_lossy(&chunk));
                }
            }
            return Err(format!("HTTP Error: {} - Response: {}", status, error_text).into());
        }

        let mut stream = response.bytes_stream();
        let mut buffer = String::new();
        let mut in_data_section = false;
        let mut current_data = String::new();

        while let Some(item) = stream.next().await {
            let chunk = item?;
            let text = String::from_utf8_lossy(&chunk);
            buffer.push_str(&text);

            // Process complete lines
            while let Some(newline_pos) = buffer.find('\n') {
                let line = buffer[..newline_pos].trim().to_string();
                buffer = buffer[newline_pos + 1..].to_string();

                if line.starts_with("data: ") {
                    in_data_section = true;
                    current_data = line[6..].trim().to_string();
                } else if (line.is_empty() || line == "\r") && in_data_section {
                    // End of SSE event, parse the JSON
                    if !current_data.is_empty() {
                        // SSE data might have extra lines, extract just the JSON
                        let json_data = current_data.split('\n').next().unwrap_or(&current_data);
                        if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(json_data) {
                            // Check if this is the response for our request
                            if json_value.get("id").and_then(|id| id.as_u64()) == Some(request_id) {
                                // Check for error
                                if let Some(error_obj) = json_value.get("error") {
                                    let error_msg = if error_obj.is_object() {
                                        format!("{}", error_obj)
                                    } else {
                                        format!("{}", error_obj)
                                    };
                                    return Err(format!("JSON-RPC Error: {}", error_msg).into());
                                }

                                // Extract result field
                                if let Some(result) = json_value.get("result") {
                                    return Ok(result.clone());
                                }
                                return Ok(json_value);
                            }
                        }
                    }
                    in_data_section = false;
                    current_data.clear();
                } else if in_data_section {
                    // Continuation of data section
                    current_data.push('\n');
                    current_data.push_str(&line);
                }
            }
        }

        // If we have remaining data, try to parse it
        if !current_data.is_empty() {
            let json_data = current_data.split('\n').next().unwrap_or(&current_data);
            if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(json_data) {
                if json_value.get("id").and_then(|id| id.as_u64()) == Some(request_id) {
                    if let Some(error_obj) = json_value.get("error") {
                        return Err(format!("JSON-RPC Error: {}", error_obj).into());
                    }
                    if let Some(result) = json_value.get("result") {
                        return Ok(result.clone());
                    }
                    return Ok(json_value);
                }
            }
        }

        Err("No response received from server".into())
    }

    async fn send_notification(
        &mut self,
        method: &str,
        params: serde_json::Value,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let request = json!({
            "jsonrpc": "2.0",
            "method": method,
            "params": params
        });

        let mut request_builder = self
            .client
            .post(&self.url)
            .header("Accept", "application/json, text/event-stream")
            .header("Content-Type", "application/json");

        // Add session ID if we have one
        if let Some(session_id) = &self.session_id {
            request_builder = request_builder.header("mcp-session-id", session_id);
        }

        let response = request_builder.json(&request).send().await?;

        let status = response.status();

        // Extract session ID from response header if present
        if let Some(session_id_header) = response.headers().get("mcp-session-id") {
            if let Ok(session_id_str) = session_id_header.to_str() {
                self.session_id = Some(session_id_str.to_string());
            }
        }

        if !status.is_success() {
            // For notifications, we might get errors but that's okay
            let _ = response.text().await;
        }

        Ok(())
    }

    async fn initialize(&mut self) -> Result<(), Box<dyn std::error::Error>> {
        if self.initialized && self.session_id.is_some() {
            return Ok(());
        }

        println!(" Initializing MCP connection...");

        let params = json!({
            "protocolVersion": "2024-11-05",
            "capabilities": {},
            "clientInfo": {
                "name": "mcp_test_client",
                "version": "0.1.0"
            }
        });

        // Send initialize request and wait for response
        let _init_result = self.send_request("initialize", params).await?;

        // Check if we got a session ID
        if self.session_id.is_none() {
            return Err("Failed to get session ID from initialize response".into());
        }

        // Send initialized notification after receiving initialize response
        let _ = self.send_notification("notifications/initialized", json!({})).await;

        self.initialized = true;
        println!("    Initialized successfully\n");

        Ok(())
    }

    async fn call_tool(
        &mut self,
        tool_name: &str,
        arguments: HashMap<String, serde_json::Value>,
    ) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
        // Ensure we're initialized and have a session ID
        if !self.initialized || self.session_id.is_none() {
            self.initialize().await?;
        }

        let tool_params = json!({
            "name": tool_name,
            "arguments": arguments
        });

        // Use send_request which handles SSE streaming
        let result = self.send_request("tools/call", tool_params).await?;

        // Extract content[0].text if present (rmcp format)
        if let Some(content) = result.get("content") {
            if let Some(content_array) = content.as_array() {
                if let Some(first_content) = content_array.get(0) {
                    if let Some(text) = first_content.get("text") {
                        // Parse the JSON string in text field
                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(
                            text.as_str().unwrap_or("")
                        ) {
                            return Ok(parsed);
                        }
                        return Ok(json!({"text": text}));
                    }
                }
            }
        }

        Ok(result)
    }

    async fn add_numbers(&mut self, a: f64, b: f64) -> Result<(), Box<dyn std::error::Error>> {
        println!("\n Testing add_numbers tool...");
        println!("   Input: a={}, b={}", a, b);

        let mut arguments = HashMap::new();
        arguments.insert("a".to_string(), json!(a));
        arguments.insert("b".to_string(), json!(b));

        match self.call_tool("add_numbers", arguments).await {
            Ok(result) => {
                if let Some(result_value) = result.get("result") {
                    println!("    Result: {}", result_value);
                }
                if let Some(success) = result.get("success") {
                    println!("   Success: {}", success);
                }
                Ok(())
            }
            Err(e) => {
                println!("    Error: {}", e);
                Err(e)
            }
        }
    }

    async fn echo(&mut self, message: &str) -> Result<(), Box<dyn std::error::Error>> {
        println!("\n Testing echo tool...");
        println!("   Input: message='{}'", message);

        let mut arguments = HashMap::new();
        arguments.insert("message".to_string(), json!(message));

        match self.call_tool("echo", arguments).await {
            Ok(result) => {
                if let Some(echo_value) = result.get("echo") {
                    println!("    Echoed: {}", echo_value);
                }
                if let Some(success) = result.get("success") {
                    println!("   Success: {}", success);
                }
                Ok(())
            }
            Err(e) => {
                println!("    Error: {}", e);
                Err(e)
            }
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("{}", "=".repeat(60));
    println!("MCP Server Test Client (Rust)");
    println!("{}", "=".repeat(60));

    let mut client = MCPClient::new("http://127.0.0.1:8000/mcp");

    // Initialize the connection first
    client.initialize().await?;

    // Test 1: add_numbers
    client.add_numbers(42.0, 58.0).await?;

    // Test 2: echo
    client.echo("Hello from MCP server test!").await?;

    // Additional tests
    println!("\n{}", "=".repeat(60));
    println!("Additional Tests");
    println!("{}", "=".repeat(60));

    client.add_numbers(10.5, 20.3).await?;
    client.echo("Testing with special characters: !@#$%^&*()").await?;

    println!("\n{}", "=".repeat(60));
    println!(" All tests completed!");
    println!("{}", "=".repeat(60));

    Ok(())
}

★ 5.1.3 Understanding the core client imports

The implementation uses the following imports

use reqwest;
use serde_json::json;
use std::collections::HashMap;
use futures::StreamExt;
  • *reqwest: The HTTP client library for making requests*
  • *serde_json::json: Macro for creating JSON values with a convenient syntax*
  • *std::collections::HashMap: Used for tool arguments and flexible key-value storage*
  • *futures::StreamExt: Trait extension providing .next() method for processing async streams.*

★ 5.1.4 The MCPClient Structure

Let’s examine the core structure of our MCP client implementation.

struct MCPClient {
    url: String,
    request_id: u64,
    client: reqwest::Client,
    initialized: bool,
    session_id: Option<String>,
}

Each field serves a specific purpose in maintaining protocol compliance:

  • url: The MCP endpoint (e.g., http://127.0.0.1:8000/mcp) — aligns with the HTTP endpoint in the Streamable HTTP transport
  • request_id: Keeps a running count of JSON-RPC request IDs (must be unique per session). This corresponds to the MCP rule that the requestor must not reuse an ID
  • initialized: Tracks whether the initialize handshake has run
  • session_id: Holds the session ID header returned by the server — this is not strictly mandated by JSON-RPC 2.0 but is typical in MCP for session state, and the code handles the “mcp-session-id” header accordingly

The constructor is straightforward and establishes the initial state

fn new(url: &str) -> Self

This sets up the client struct, initializes request_id to 1, creates a reqwest::Client, and marks initialized = false.

★ 5.1.5 Core Request Handling

Building JSON-RPC Requests: The heart of the client lies in the send_request method.

async fn send_request(&mut self, method: &str, params: serde_json::Value) 
    -> Result<serde_json::Value, Box<dyn std::error::Error>>

This function constructs a proper JSON-RPC request.

let request = json!({
    "jsonrpc": "2.0",
    "id": self.request_id,
    "method": method,
    "params": params
});

This adheres to MCP’s base JSON-RPC message definition: "jsonrpc": "2.0", "id" (string or number, and must not be null), etc. The implementation uses self.request_id then increments it — ensuring unique IDs per session.

★ 5.1.6 HTTP Transport with Proper Headers

The client sends requests via HTTP POST with the correct headers

let mut request_builder = self
    .client
    .post(&self.url)
    .header("Accept", "application/json, text/event-stream")
    .header("Content-Type", "application/json");

if let Some(session_id) = &self.session_id {
    request_builder = request_builder.header("mcp-session-id", session_id);
}

let response = request_builder.json(&request).send().await?;

The transport here is Streamable HTTP. According to the spec: when using HTTP POST for MCP messages, the client MUST send Accept: application/json, text/event-stream. It also optionally includes the mcp-session-id header if present — this is the client's mechanism to preserve session state.

The client extracts and stores the session ID from response headers

if let Some(session_id_header) = response.headers().get("mcp-session-id") {
    if let Ok(session_id_str) = session_id_header.to_str() {
        self.session_id = Some(session_id_str.to_string());
    }
}

This ensures session continuity across multiple requests.

★ 5.1.7 Handling SSE Streams

The implementation properly handles Server-Sent Events

if !status.is_success() {
   // read entire error stream
   ...
   return Err(...);
}

while let Some(item) = stream.next().await { … }

In MCP’s transport spec, if the server rejects a client request, it should return an HTTP error (e.g., 400) or an application error, as specified by JSON-RPC.

The code buffers lines until it finds a line starting with "data: " (the SSE chunk indicator) and then when it finds an empty line after that, it parses the accumulated JSON from current_data.

This aligns with the spec for Streamable HTTP transport: a POST may return Content-Type: text/event-stream, meaning the server may send multiple SSE events, including JSON-RPC responses and possibly other messages, before closing.

Response Parsing: Within the parsing logic, it checks for JSON with a matching "id" equal to request_id. This ensures the extraction of the correct response for the request. It checks for error vs result in the JSON-RPC response. The spec demands that one of those is present; if result is present, it returns the JSON value (cloned), if error is present, it returns an error with the error message. Finally, if the stream finishes and there is no response, it returns Err("No response received from server").

Summary of send_request: This method implements JSON-RPC request construction (base protocol), HTTP POST transport with SSE handling (Streamable HTTP transport), session header capture, response parsing of SSE events, including JSON-RPC responses, and error handling according to JSON-RPC rules.

★ 5.1.8 Notification Support: The client also supports one-way notifications

async fn send_notification(&mut self, method: &str, params: serde_json::Value) 
    -> Result<(), Box<dyn std::error::Error>>

This sends a JSON-RPC notification without an "id" field.

let request = json!({
    "jsonrpc": "2.0",
    "method": method,
    "params": params
});

Note: A notification in JSON-RPC 2.0 must not include a "id" field. The spec says: "Notifications are sent … as a one-way message; the receiver MUST NOT send a response."

The HTTP transport logic is very similar: it posts the request, sets headers, includes the session ID if present, then checks the status. On non-success, it reads the text and drops it (since notifications may fail silently).

In the transport spec, if the input is a JSON-RPC response or notification, the server must return HTTP 202 Accepted with no body (if it accepts). This method is consistent: it doesn’t parse a body, it returns Ok(()) if the status is success.

★ 5.1.9 The Initialization Handshake: The client implements the MCP initialization lifecycle

async fn initialize(&mut self) -> Result<(), Box<dyn std::error::Error>>

First, it checks if already initialized

if self.initialized && self.session_id.is_some() {
    return Ok(());
}

Then it builds the initialization parameters

let params = json!({
    "protocolVersion": "2024-11-05",
    "capabilities": {},
    "clientInfo": {
        "name": "mcp_test_client",
        "version": "0.1.0"
    }
});

The protocolVersion, capabilities, and clientInfo fields align with the MCP spec initialization handshake.

Initialization Flow: This method implements the complete MCP lifecycle handshake. The client sends an initialize request (JSON-RPC), the server responds with capabilities, and the client sends an initialized notification. At this point session/transport state is now ready for other operations.

Tool Invocation: The client provides a method for invoking tools exposed by the server

async fn call_tool(&mut self, tool_name: &str, arguments: HashMap<String, serde_json::Value>) 
    -> Result<serde_json::Value, Box<dyn std::error::Error>>

First, it ensures initialization. This is a good practice that guarantees the handshake is complete before making tool calls.

Then it builds the tool parameters

if !self.initialized || self.session_id.is_none() {
    self.initialize().await?;
}

let tool_params = json!({
    "name": tool_name,
    "arguments": arguments
});

★ 5.1.10 Convenience Methods: The implementation includes convenience methods for specific tools add_numbers : Builds arguments for the tool “add_numbers” with a and b parameters, then calls it and prints the result fields result and success. echo: Builds arguments for the tool “echo” with a message string, calls it, and prints fields echo and success.

These are simple wrappers to test the server’s tool functionality.

★ 5.1.11 Putting It All Together: The main function demonstrates the complete lifecycle

#[tokio::main] 
async fn main() -> Result<…, …>

The execution flow: Print banner, create MCPClient::new("http://127.0.0.1:8000/mcp"), call client.initialize().await?, test add_numbers(42.0, 58.0), then echo("Hello …") , additional tests with other inputs

This demonstrates a full lifecycle: initial handshake, tool invocation tests, and completion.

★ 5.1.12 Yocto recipe: client recipe to put it together. mcp-client/mcp-client_0.1.0.bb

SUMMARY = "MCP Test Client (Rust)"
DESCRIPTION = "Rust-based test client for Model Context Protocol servers"
HOMEPAGE = "https://github.com/modelcontextprotocol"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"

PV = "0.1.0"
PR = "r0"
PN = "mcp-client"

inherit cargo_bin
DEPENDS += " \
    pkgconfig \
    openssl \
    zlib \
    rust-bin-cross-aarch64 \
"
RDEPENDS:${PN} = " \
    libgcc \
"
SRC_URI = " \
    file://Cargo.toml \
    file://src/main.rs \
"
S = "${WORKDIR}"
CARGO_FETCH_OFFLINE = "1"
INHERIT += "splitdebug"

do_install() {
    install -d ${D}${bindir}
    install -m 0755 ${S}/target/*/release/mcp_test_client ${D}${bindir}/mcp-client

    # Create directory for debug symbols
    install -d ${D}${libdir}/.debug

    # Extract debug symbols to a separate file
    ${OBJCOPY} --only-keep-debug ${D}${bindir}/mcp-client ${D}${libdir}/.debug/mcp-client.debug

    # Strip the binary
    ${STRIP} --strip-debug --strip-unneeded ${D}${bindir}/mcp-client

    # Link the debug symbols back to the binary
    ${OBJCOPY} --add-gnu-debuglink=${D}${libdir}/.debug/mcp-client.debug ${D}${bindir}/mcp-client
}

FILES:${PN} = "${bindir}/mcp-client"
FILES:${PN}-dbg += "${libdir}/.debug/mcp-client.debug"
INSANE_SKIP:${PN} += "already-stripped"

★ 6— Running both server and client.

★ 61.1 Server: Running the server from a terminal.


./mcp-server
  INFO http_stream_mcp_server: Starting Simple MCP Server
  INFO http_stream_mcp_server:  Server ready at http://127.0.0.1:8000/mcp
  INFO rmcp::transport::streamable_http_server::session::local: create new session session_id="ca4fb82a-6c7b-4429-894a-4f337227859f"
  INFO rmcp::handler::server: client initialized
  INFO serve_inner: rmcp::service: Service initialized as server peer_info=Some(InitializeRequestParam { protocol_version: ProtocolVersion("2024-11-05"), capabilities: ClientCapabilities { experimental: None, roots: None, sampling: None, elicitation: None }, client_info: Implementation { name: "mcp_test_client", title: None, version: "0.1.0", icons: None, website_url: None } })

★ 6.1.2 Client: Running client from the second terminal.


# ./mcp-client
============================================================
MCP Server Test Client (Rust)
============================================================
 Initializing MCP connection...
    Initialized successfully

 Testing add_numbers tool...
   Input: a=42, b=58
    Result: 100.0
   Success: true

 Testing echo tool...
   Input: message='Hello from MCP server test!'
    Echoed: "Hello from MCP server test!"
   Success: true

============================================================
Additional Tests
============================================================

 Testing add_numbers tool...
   Input: a=10.5, b=20.3
    Result: 30.8
   Success: true

 Testing echo tool...
   Input: message='Testing with special characters: !@#$%^&*()'
    Echoed: "Testing with special characters: !@#$%^&*()"
   Success: true

============================================================
 All tests completed!
============================================================

★ 7— Conclusion: We’ve built a complete MCP server and client implementation that bridges Rust and C++ through FFI while leveraging the modern streamable HTTP transport layer. This architecture provides several key advantages.

  • Performance: Native C++ implementations deliver optimal performance for compute-intensive operations.
  • Memory Efficiency: Crucial for embedded and resource-constrained devices.
  • Standardization: Full compliance with the MCP protocol specification ensures compatibility with AI applications.
  • Streaming Support: Real-time bidirectional communication through SSE enables interactive AI experiences.

The combination of Rust’s safety guarantees, C++’s performance characteristics, and MCP’s standardized protocol creates a robust foundation for building AI-powered tools that can operate in demanding environments. Whether you’re targeting embedded systems, edge devices, or high-performance server deployments, this architecture provides a practical path forward for integrating legacy C++ codebases with modern AI applications.

References:


메타데이터
post_id
cdf6033cdda7
slug
implementing-a-streamable-http-mcp-server-and-client-in-rust-with-c-ffi-cdf6033cdda7
url
https://medium.com/@pal.mohit.singh/implementing-a-streamable-http-mcp-server-and-client-in-rust-with-c-ffi-cdf6033cdda7
canonical_url
https://medium.com/@pal.mohit.singh/implementing-a-streamable-http-mcp-server-and-client-in-rust-with-c-ffi-cdf6033cdda7
author_url
https://medium.com/@pal.mohit.singh
status
ok
fetched_at
2026-07-15 08:58:25