MCP servers in Rust
MCP (Model Context Protocol) servers, which make data and analytics discoverable and accessible by large language models, are built using the JSON RPC protocol, and can thus be written in Rust (or most other languages) with the standard HTTP libraries. But rmcp has become the standard way to do this, and I wanted to explore how this works, and how it can expose the fast analytics made possible by Rust. This entry shows a basic MCP server that makes it possible to get a file system directory listing, and to get some attributes of one of those files.
I wanted to make the MCP server available over HTTP transport (over a network), rather than the simpler stdio transport (which uses console input/output), since the persistent process is more realistic and scalable in production. In addition, I wanted to illustrate a basic service that takes no parameters, as well as one that requires a parameter (in this case, a file name).
Getting the dependencies
The rmcp library is built on top of several other libraries, and the neatly integrated stack consists of the following:
- rmcp is the official MCP implementation for Rust
- axum is an HTTP routing and request-handling library that focuses on ergonomics and modularity, required for creating the HTTP transport
- tokio is a runtime for writing reliable, asynchronous, and slim applications with the Rust
- serde provides serializing/deserializing to/from JSON
- schemars generate JSON Schema documents from Rust code
In addition, we also use chrono to process the modification date of a file. So to install these, add the following to the dependencies section of your project’s Cargo.toml:
[dependencies]
axum = "0.8"
rmcp = { version = "3.3.0", features = ["server", "transport-streamable-http-server"] }
schemars = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] }
chrono = "0.4"
The necessary components from these libraries can be imported into your main.rs as follows:
// MCP library imports (rmcp)
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{ServerCapabilities, ServerConfig};
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use rmcp::{ErrorData, Json, ServerHandler, tool, tool_handler, tool_router};
// JSON imports (serde)
use schemars::JsonSchema;
use serde::{Deserialize, Serialize}
// For getting file info
use chrono::{DateTime, Datelike, Utc};
use std::fs::{metadata, read_dir};
Defining a tool
Tools in rmcp are defined by prefixing a function with the attribute tool, with
the description as parameter. The function needs to return a Result, with a JSON
representation if successful, or an rmcp::ErrorData if it fails (see below for an
explanation of this error handling in rmcp).
Tools without parameters
Here is our tool definition for listing files (we will see shortly where this fits):
// Tool for listing files, use /tmp for demo
#[tool(description = "List all the files in data directory")]
fn list_files(&self) -> Result<Json<Vec<String>>, ErrorData> {
let names = get_file_list("/tmp")?;
Ok(Json(names))
}
Tools with parameters
If a tool takes one or more parameters, you need to include these in the function parameter list, using rmcp::handler::server::wrapper::Parameters and a schema for the request parameters. These need to be at the top level, for example:
// Schema definition for request parameters
// Request schema for a file name for which info has been requested
#[derive(Serialize, Deserialize, JsonSchema)]
struct FileRequest {
name: String,
}
There may also be a schema for structured response values, e.g.,
// Schema definition for the result, turned into JSON upon return
// Information returned about a file
#[derive(Serialize, Deserialize, JsonSchema)]
struct FileInfo {
name: String,
size: u64,
modified: String,
is_dir: bool,
}
Here is the second tool definition, which includes a parameter, and returns a structured output:
// Tool for getting information about one file
#[tool(description = "Get information about a file")]
fn file_info(&self, params: Parameters<FileRequest>) -> Result<Json<FileInfo>, ErrorData> {
let info = get_file_info(¶ms.0.name)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
Ok(Json(info))
}
Putting it together
Here is the complete tool definition, with both tools, and a constructor. We are
calling our tool set FileServer, but you can use any name you want. Note the
struct which encapsulated the tool router, and the separate impl which defines
the methods, including the constructor:
// MCP server exposing files in the data directory
#[derive(Clone)]
struct FileServer {
tool_router: ToolRouter<Self>,
}
// The methods for the MCP server
#[tool_router(router = tool_router)]
impl FileServer {
// Constructor
fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
// Tool for listing files, use /tmp for demo
#[tool(description = "List all the files in data directory")]
fn list_files(&self) -> Result<Json<Vec<String>>, ErrorData> {
let names = get_file_list("/tmp")?;
Ok(Json(names))
}
// Tool for getting information about one file, e.g., name, size, modification date
#[tool(description = "Get information about a file")]
fn file_info(&self, params: Parameters<FileRequest>) -> Result<Json<FileInfo>, ErrorData> {
let info = get_file_info(¶ms.0.name)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
Ok(Json(info))
}
}
You also need to define a ServerHandler for the tool set, which provides the instructions that enable the LLM to discover to tool set, and to make sense of the tools within it. This is again at the top level:
// Enable tools for the MCP server, with description to allow the LLM to see what is available
#[tool_handler(router = self.tool_router)]
impl ServerHandler for FileServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_instructions(
"Query file system, use list_files to get list of files, or file_info to get information about a file.",
)
}
}
A note on error handling
Normal Rust functions can return a Result, which provides Ok(xxx) inside the function to return a successful value, but propagates an error of type std::io::Error if the function fails. For rmcp tools, the function needs to return an ErrorData instead, to allow the MCP consumer to interpret and report the error. You may have noticed that both our tools above included this return option.
If your tool calls a function that returns a normal std::io::Error, it can convert
the error to an ErrorData, by calling ErrorData::internal_error like this:
let info = get_file_info(¶ms.0.name)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
In this example, to keep the tool code clean, I chose to do this the utility functions that get the file listing and attributes of a file, rather than inside the tool, but it works either way (see the utility functions below).
Utility functions for file system
Here are the two functions that actually do the work of the tools. The first one gets a list of the names of all files in a given directory. Note the conversion of errors to the ErrorData required for the MCP server, in case the read_dir() call fails:
/ Utility function to get a list of filenames in a directory, called by the MCP server above.
// File names from read_dir need to be converted from OsStr to str then to string.
fn get_file_list(dir: &str) -> Result<Vec<String>, ErrorData> {
let files = read_dir(dir).map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
Ok(files
.map(|f| f.unwrap().path().to_str().unwrap().to_string())
.collect())
}
And here is the second utility function, to get information about one file, with two such error conversions, to convert a standard Rust error to the ErrorData required for the MCP server. Also note the function returns a FileInfo structure, defined above:
// Utility function to get info for one file, called by the MCP server above.
// Note that the standard library functions returns std::io::Result, so
// errors need to be converted to rmcp's ErrorData using internal_data function
fn get_file_info(filename: &str) -> Result<FileInfo, ErrorData> {
let info = metadata(filename).map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
// Convert file date to string
let mdate = info
.modified()
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let dt: DateTime<Utc> = mdate.into();
let ymd = format!("{}-{:02}-{:02}", dt.year(), dt.month(), dt.day());
Ok(FileInfo {
name: filename.to_string(),
size: info.len(),
modified: ymd,
is_dir: info.is_dir(),
})
}
Putting it all together: the main() function
To build and launch the server, four steps are required:
- The MCP service needs to be built, as a StreamableHttpService (could also be a simpler stdin/stdout server), which includes an instance of our tool server (here created with FileServer::new())
- A router needs to be created, using axum, that wraps the service and mounts it on a path (typically /mcp)
- A TCP listener needs to be created, to talk over the network (not necessary if you are using stdin/stdout transport)
- Finally, the server needs to be started, again using axum, and taking the listener and router as arguments
So the tools go inside a tool server, which goes inside an MCP service, which goes inside a router, which goes inside the server along with the listener.
Here is the main function which does all this:
// Main function sets up and starts the server
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Define the MCP service using rcmp (streamable HTTP service)
let service = StreamableHttpService::new(
|| Ok(FileServer::new()), // Our server, service factory
LocalSessionManager::default().into(), // Tracks active client connections
StreamableHttpServerConfig::default(), // Transport layer configuration
);
// Define axum router to put service under /mcp
let router = axum::Router::new().nest_service("/mcp", service);
// Define tokio TCP listener
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
// Start the axum server, listening on address and port, and using router to
// send all requests to /mcp path
axum::serve(listener, router).await?;
Ok(())
}
The complete code is in this repository, including some shell scripts to test the MCP server.
In a future blog entry, we will create a more ambitious MCP server that allows very fast data queries on Parquet files.