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…

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, andServerCapabilities.* - 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 succeedederror: 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 numbersecho(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 sumsuccess: 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.cppor whentools.hchanges. - 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
derivefeature 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
fullfeature includes all tokio functionality, enabling async/await patterns, networking, timers, and more - futures (0.3): Provides abstractions for asynchronous programming, particularly the
StreamExttrait 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(¤t_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(¤t_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:
- MCP Streamable HTTP Transport (2025–03–26) — Streamable HTTP transport specification https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/transports/
- MCP Server Features — Server capabilities and tools specification https://spec.modelcontextprotocol.io/specification/2024-11-05/server/
- MCP Lifecycle Management — Initialization and lifecycle specification https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle/
- JSON-RPC 2.0 Specification — Base messaging protocol https://www.jsonrpc.org/specification
- Anthropic MCP Documentation — Official MCP documentation https://modelcontextprotocol.io/
메타데이터
- 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