Build an MCP Server
A wall outlet is the same shape in every room of every house in the country, and that is why a lamp built in 1962 still works in a kitchen wired in 2024. The plug has two flat prongs, the socket has two flat slots, and nobody has to negotiate the contract every time. The Model Context Protocol — MCP — is the wall-outlet shape for language models and tools. Before MCP, every team that wanted Claude or GPT to call out to a calculator, a database, or a weather API invented its own plug and its own socket, and the two only fit each other. After MCP, any model that speaks the protocol can plug into any tool that exposes it, and neither side has to know the other exists.

Anthropic published the spec in late 2024 because the bottleneck was no longer the model. Claude could already write code, draft emails, and reason about diagrams — what it could not do was reach for a real value that lived outside the chat. Every customer who wanted that reach had to wire it themselves. One team would build a "weather plugin" that returned a custom JSON shape. Another team would build a "calendar plugin" that returned a different shape. Six months of integration work later, the assistant could call three tools, and adding a fourth meant reading three vendor PDFs and writing more glue. MCP collapsed the glue into one contract — JSON-RPC 2.0 messages over standard input and standard output — so a tool written once works against every MCP-aware client without modification.
Start with the three messages the protocol cares about. Every conversation between a model and a server walks the same three steps, and naming them as an enum makes the rest of the code impossible to get wrong.
#[derive(Copy, Clone)]
enum McpMethod {
Initialize,
ListTools,
CallTool,
}
struct McpRequest {
id: u32,
method: McpMethod,
params: String,
}
struct McpResponse {
id: u32,
result: String,
}
fn method_name(m: McpMethod) -> &'static str {
match m {
McpMethod::Initialize => "initialize",
McpMethod::ListTools => "tools/list",
McpMethod::CallTool => "tools/call",
}
}McpMethod is the wall outlet. Three holes, three shapes, nothing else. Initialize is the handshake — the client says "I speak version X, here is my name" and the server says "I speak version X too, here is my name." ListTools is the directory request — the client asks "what can you do?" and the server hands back a list of named tools with descriptions. CallTool is the actual work — the client says "run this tool with these arguments" and the server returns the result. An McpRequest carries an id so responses can be matched to questions, a method that says which of the three things it is, and a params string carrying the JSON arguments. An McpResponse echoes the id back so the client knows which request it answered.
JSON-RPC 2.0 is the envelope format MCP picked, and it predates the protocol by 15 years. Douglas Crockford and the JSON-RPC working group settled on it in 2010 because every other remote-call format at the time — XML-RPC, SOAP, CORBA — was either bloated or required a special parser. JSON-RPC is so thin you can write its parser by hand in an afternoon. Every message has jsonrpc: "2.0", an id, a method name, and a params object. A response replaces method and params with result or error. That is the entire spec, and MCP inherits it untouched.

The server is the part that lives behind the outlet. It does not know which model is calling, it does not care how the messages arrived — it takes a request, looks at the method, and hands back a response. Real MCP servers read these messages from standard input and write replies to standard output, so the parent process — Claude Desktop, an editor plugin, a custom client — can spawn the server as a child and pipe bytes back and forth. This lesson skips the pipes and runs the server in memory so the snapshot stays deterministic, but the function signature is the same one a production server uses.
fn handle(req: &McpRequest) -> McpResponse {
let result = match req.method {
McpMethod::Initialize => String::from(
"{\"protocolVersion\":\"2024-11-05\",\
\"serverInfo\":{\"name\":\"toy-mcp\",\"version\":\"0.1.0\"}}",
),
McpMethod::ListTools => String::from(
"{\"tools\":[\
{\"name\":\"add\",\"description\":\"sum of two numbers\"},\
{\"name\":\"echo\",\"description\":\"return the input text\"}\
]}",
),
McpMethod::CallTool => call_tool(&req.params),
};
McpResponse { id: req.id, result }
}
fn call_tool(params: &str) -> String {
if params.contains("\"name\":\"add\"") {
let sum = parse_two_ints(params).map(|(a, b)| a + b).unwrap_or(0);
format!(
"{{\"content\":[{{\"type\":\"text\",\"text\":\"{}\"}}]}}",
sum
)
} else if params.contains("\"name\":\"echo\"") {
let text = parse_text(params).unwrap_or_default();
format!(
"{{\"content\":[{{\"type\":\"text\",\"text\":\"{}\"}}]}}",
text
)
} else {
String::from("{\"error\":\"unknown tool\"}")
}
}handle is the whole server. A match on the method picks the response. Initialize returns a fixed string that names the protocol version and the server. ListTools returns the tool catalog — two entries, add and echo, each with a name and a one-line description. CallTool is the only branch that has to look at the parameters, because the client has named which tool to run and passed arguments. A real server would route to a function for each tool. This one inlines the dispatch into call_tool and returns the result wrapped in MCP's standard content shape — an array of typed parts, each part either text, an image, or a resource reference. The model receives that shape and turns it into something the user can read.
The JSON parsing is the boring part nobody mentions in protocol docs but every server has to handle. A real server would pull in serde_json and let it do the work in 3 lines. This lesson stays on stdlib so the moving parts are visible, which means the parser is two helpers that walk the string looking for keys.
fn parse_two_ints(s: &str) -> Option<(i64, i64)> {
let a = grab_number(s, "\"a\":")?;
let b = grab_number(s, "\"b\":")?;
Some((a, b))
}
fn grab_number(s: &str, key: &str) -> Option<i64> {
let start = s.find(key)? + key.len();
let tail = &s[start..];
let end = tail.find(|c: char| c == ',' || c == '}').unwrap_or(tail.len());
tail[..end].trim().parse().ok()
}
fn parse_text(s: &str) -> Option<String> {
let key = "\"text\":\"";
let start = s.find(key)? + key.len();
let tail = &s[start..];
let end = tail.find('"')?;
Some(tail[..end].to_string())
}grab_number finds a key like "a":, slices to the next comma or closing brace, and parses the slice as an integer. parse_text finds "text":" and copies bytes up to the next quote. Neither handles escapes, nested objects, or whitespace surprises, which is exactly why serde_json exists. The point of writing them by hand here is to show that the wire format is not magic — bytes go in, bytes come out, and the rest is plumbing.

The wire encoder is the last piece. Every request and every response leaves the program as one line of JSON, framed by braces, with no allocations beyond the format string.
fn encode_request(req: &McpRequest) -> String {
format!(
"{{\"jsonrpc\":\"2.0\",\"id\":{},\"method\":\"{}\",\"params\":{}}}",
req.id,
method_name(req.method),
req.params
)
}
fn encode_response(res: &McpResponse) -> String {
format!(
"{{\"jsonrpc\":\"2.0\",\"id\":{},\"result\":{}}}",
res.id, res.result
)
}encode_request produces a jsonrpc: "2.0" envelope around the method name and the params blob. encode_response does the same shape with result instead of method. Two production servers run two different libraries to do this work — the format is the same either way because the spec is fixed.
Now run the demo and watch a full session play out — handshake, discovery, one tool call.
fn show_protocol() {
println!("--- protocol surface ---");
for (i, m) in [
McpMethod::Initialize,
McpMethod::ListTools,
McpMethod::CallTool,
]
.iter()
.enumerate()
{
println!("method {}: {}", i + 1, method_name(*m));
}
println!();
}
fn show_session() {
let requests = [
McpRequest {
id: 1,
method: McpMethod::Initialize,
params: String::from("{\"clientInfo\":{\"name\":\"claude\"}}"),
},
McpRequest {
id: 2,
method: McpMethod::ListTools,
params: String::from("{}"),
},
McpRequest {
id: 3,
method: McpMethod::CallTool,
params: String::from("{\"name\":\"add\",\"arguments\":{\"a\":2,\"b\":3}}"),
},
];
println!("--- scripted session ---");
for req in &requests {
let res = handle(req);
println!("--> {}", encode_request(req));
println!("<-- {}", encode_response(&res));
println!();
}
}show_protocol lists the three method names so the reader can see the alphabet first. show_session then sends three requests through the server in order. The first is the initialize handshake. The second asks for the tool list. The third calls add with arguments {"a":2,"b":3} and the server hands back 5 wrapped in MCP's content envelope.
--- protocol surface ---
method 1: initialize
method 2: tools/list
method 3: tools/call
--- scripted session ---
--> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"claude"}}}
<-- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"toy-mcp","version":"0.1.0"}}}
--> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
<-- {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"add","description":"sum of two numbers"},{"name":"echo","description":"return the input text"}]}}
--> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}
<-- {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"5"}]}}Read the session bottom to top after the first pass. The third exchange is the only one that does real work — the client requests tools/call, the server runs the addition, and the answer comes back as "text":"5". Notice that the model side never sees the Rust code that did the addition. All it sees is a JSON message claiming the result is 5, which the model trusts because the tool was advertised in the previous step. The second exchange is what made that trust possible — the server published its menu, and the client now knows the names and shapes of every tool before it tries to call one. The first exchange is the handshake, the protocol-version negotiation, the moment both sides agree they speak the same dialect.
One question worth asking — why does every message carry an id? Because MCP is asynchronous. A client can fire three tools/call requests in a row without waiting for the first to finish, and the responses can come back in any order. The id is how the client matches a result to the question it asked, the same way an order ticket at a coffee shop is how the barista hands the right drink to the right customer. Remove the id and the protocol collapses into request-then-wait, which is what every pre-MCP integration was forced into and what made parallel tool use so painful to build.
The thing this server cannot do on its own is run anywhere a real model can find it. The handle function lives in memory and never reads standard input, so no Claude Desktop instance can spawn it as a child process. The next bottleneck is the transport layer — wiring the same handle function to a real stdio loop so an outside model can plug in — which is what an async runtime exists to solve.