llmops · rust · tutorial

An MCP server is a protocol you can read

Jul 7, 2026 · 10 min read

Every MCP tutorial starts the same way: install the SDK, derive some macros, get a weather tool running, never once look at what actually travels between the model and your code. That's a fine way to ship a demo. It's a bad way to understand a protocol you're about to hand the keys to your database.

So this tutorial goes the other way. We're going to build an MCP server in Rust with no SDK: one file, serde_json, and the standard library. And to keep ourselves honest, the server's only tool will be a wiretap: call show_wire and it returns every raw JSON-RPC message that has crossed its stdio pipe, verbatim and in order, including the request you're making right now. The server teaches the protocol by showing it to you.

So let's read what actually goes over the wire.

What MCP is when you strip the branding

Underneath the ecosystem, the registries, and the SDKs, the Model Context Protocol is three ideas stacked on top of each other:

  1. JSON-RPC 2.0. Requests have an id, responses echo it, notifications have none. That's a 2010-vintage spec you can read in ten minutes.
  2. A lifecycle. The client sends initialize, the server answers with its capabilities, the client confirms with a notifications/initialized notification. Only then does real traffic flow.
  3. A handful of methods. For a tool server: tools/list (here's what I can do, with JSON Schema) and tools/call (do it).

The transport we'll use is stdio: the client launches your server as a subprocess and talks to it through stdin and stdout. If you've built language servers, note the trap: MCP's stdio framing is newline-delimited JSON, not the Content-Length header framing LSP uses. One message per line, no embedded newlines, UTF-8. I've verified every wire-level claim in this essay against the current spec revision, 2025-11-25.

JSON-RPC over stdio: one request, one response newline-delimited JSON — one message per line MCP client the parent process MCP server your code, a subprocess request {"jsonrpc":"2.0", "id":2, "method":"tools/call", "params":{…}} stdin — client writes → server reads response {"jsonrpc":"2.0", "id":2, "result":{…}} stdout — server writes → client reads id echoed notifications carry no id; the parse-error reply uses a null id
A JSON-RPC exchange over stdio: the client writes a request line — jsonrpc, id, method, params — onto the server's stdin, and the server writes a response line back onto stdout with the same id echoed and a result, one newline-delimited message per line.

The transport has two rules:

  • stdout is sacred. The spec: the server MUST NOT write anything to stdout that is not a valid MCP message. One stray println! for debugging and you've corrupted the stream.
  • stderr is yours. Log whatever you want there; clients may capture or ignore it.

The wiretap

Since the whole point is seeing the wire, the server's core is a log of it: every line that crosses the pipe, tagged with its direction and lifecycle phase, capped so a long session can't grow memory forever.

/// Every message that crossed the pipe, verbatim, in order.
struct WireLog {
    entries: VecDeque<WireEntry>,
    dropped: usize,
}

struct WireEntry {
    dir: Direction,   // ClientToServer or ServerToClient
    phase: Phase,     // Handshake or Operation
    line: String,     // the raw JSON-RPC, untouched
}

impl WireLog {
    fn record(&mut self, dir: Direction, phase: Phase, line: &str) {
        if self.entries.len() == WIRE_CAP {
            self.entries.pop_front();
            self.dropped += 1;
        }
        self.entries.push_back(WireEntry { dir, phase, line: line.to_string() });
    }
}

There's a pleasing quine-adjacent property hiding in here. When the model calls show_wire, the incoming tools/call line is recorded before it's handled, so the transcript it triggers contains the request that asked for it. The one message a transcript can never contain is its own answer, which is only recorded after rendering. Call the tool twice, though, and the second transcript includes the first answer. The tap records everything, its own past replies included.

The transport is a loop

Here is the entire stdio transport. Not a simplification of it — the actual thing:

fn main() -> io::Result<()> {
    let stdin = io::stdin();
    let mut stdout = io::stdout().lock();
    let mut log = WireLog::new();
    let mut conn = Connection::Starting(Server::new());

    eprintln!("wiretap-mcp: listening on stdio");

    for line in stdin.lock().lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }

        // Handshake covers everything up to and including the client's
        // `notifications/initialized`; after that we are in operation.
        let phase = match &conn {
            Connection::Starting(_) => Phase::Handshake,
            Connection::Running(_) => {
                if line.contains("notifications/initialized") {
                    Phase::Handshake
                } else {
                    Phase::Operation
                }
            }
        };
        log.record(Direction::ClientToServer, phase, &line);

        let msg: Value = match serde_json::from_str(&line) {
            Ok(v) => v,
            Err(_) => {
                let reply = error(&Value::Null, -32700, "Parse error");
                send(&mut stdout, &mut log, phase, &reply)?;
                continue;
            }
        };

        let (reply, next) = match conn {
            Connection::Starting(server) => server.handle(&msg),
            Connection::Running(server) => {
                let reply = server.handle(&msg, &log);
                (reply, Connection::Running(server))
            }
        };
        if let Some(reply) = reply {
            send(&mut stdout, &mut log, phase, &reply)?;
        }
        conn = next;
    }
    // The client closed our stdin: that is the shutdown signal.
    Ok(())
}

Read a line, record it, parse it, answer it, record the answer. No async runtime: a blocking loop is exactly the right shape for a protocol that is one subprocess talking to one parent. (Yes, the phase tag is decided by a string check on the raw line. The tag is presentation, not protocol logic; parsing before deciding would be effort spent where nothing depends on it.) Garbage on the wire gets a JSON-RPC -32700 parse error with a null id, which is what the JSON-RPC spec prescribes when you couldn't read an id in the first place. And when stdin closes, you exit: that's the stdio transport's entire shutdown ceremony.

The send function is where the "stdout is sacred" rule becomes code. Serialize, record, one line out, flush:

fn send(stdout: &mut impl Write, log: &mut WireLog, phase: Phase, reply: &Value) -> io::Result<()> {
    let line = serde_json::to_string(reply).expect("reply serializes");
    log.record(Direction::ServerToClient, phase, &line);
    writeln!(stdout, "{line}")?;
    stdout.flush()
}

The handshake, and the state machine everyone tracks with a boolean

The lifecycle rule is simple: a connection that hasn't answered initialize must not serve tools. Most implementations track this with an initialized: bool and a check at the top of every handler, and hope that whoever adds the next handler remembers the check.

Rust lets us do better. This is the typestate pattern, and it's the one opinionated choice in this file:

struct Uninitialized;

struct Initialized {
    /// The protocol version we agreed on during the handshake.
    protocol_version: String,
}

struct Server<State> {
    state: State,
}

/// The *only* place where "are we initialized yet?" is a runtime question.
enum Connection {
    Starting(Server<Uninitialized>),
    Running(Server<Initialized>),
}

The tool handlers are defined only on Server<Initialized>, and the only way to obtain that type is to consume a Server<Uninitialized> by answering initialize. It is not that answering a tool call before the handshake is checked and rejected — it is that the code which could do so does not compile. The illegal state isn't validated away; it's unrepresentable.

The handshake itself:

impl Server<Uninitialized> {
    fn handle(self, msg: &Value) -> (Option<Value>, Connection) {
        let method = msg["method"].as_str().unwrap_or_default();
        let id = &msg["id"];

        if id.is_null() {
            // Notifications are fire-and-forget; nothing to say yet.
            return (None, Connection::Starting(self));
        }
        match method {
            "initialize" => {
                let requested = msg["params"]["protocolVersion"].as_str();
                let version = negotiate(requested);
                let result = json!({
                    "protocolVersion": version,
                    "capabilities": { "tools": {} },
                    "serverInfo": {
                        "name": "wiretap-mcp",
                        "title": "Wiretap",
                        "version": env!("CARGO_PKG_VERSION"),
                    },
                });
                let next = Server {
                    state: Initialized { protocol_version: version.to_string() },
                };
                (Some(response(id, result)), Connection::Running(next))
            }
            "ping" => (Some(response(id, json!({}))), Connection::Starting(self)),
            _ => (
                Some(error(id, -32002, "Server not initialized")),
                Connection::Starting(self),
            ),
        }
    }
}

Note the signature: handle(self, …) takes the server by value. Answering initialize consumes the uninitialized server and returns the initialized one. The old state doesn't linger anywhere; the compiler won't let you touch it again.

And version negotiation — the part that sounds like it should be complicated — is this:

const SUPPORTED_VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26"];

/// Echo the requested revision if we support it, otherwise offer our
/// newest and let the client decide whether to hang up.
fn negotiate(requested: Option<&str>) -> &'static str {
    SUPPORTED_VERSIONS
        .iter()
        .find(|v| Some(**v) == requested)
        .unwrap_or(&SUPPORTED_VERSIONS[0])
}

The algorithm comes straight from the spec: if the server supports the requested version it must echo it, otherwise it responds with its latest and the client decides.

Tools are just JSON you promised to honor

tools/list returns tool definitions with a JSON Schema for the input. There's no macro behind this — it's the literal object the client will read:

fn tool_definition() -> Value {
    json!({
        "name": "show_wire",
        "title": "Show the wire",
        "description":
            "Return every JSON-RPC message that has crossed this session's \
             stdio pipe, verbatim and in order — including the request you \
             are making right now.",
        "inputSchema": {
            "type": "object",
            "properties": {},
            "additionalProperties": false,
        },
    })
}

tools/call dispatches on the tool name. This is also where MCP's two-tier error taxonomy lives, and it's subtler than it looks:

fn call_tool(&self, id: &Value, params: &Value, log: &WireLog) -> Value {
    match params["name"].as_str() {
        Some("show_wire") => {
            let text = format!(
                "negotiated protocol: {}\n{}",
                self.state.protocol_version,
                log.render()
            );
            response(id, json!({
                "content": [{ "type": "text", "text": text }],
                "isError": false,
            }))
        }
        // An unknown tool is a protocol error (-32602), not a tool error:
        // the client asked for something that does not exist, so there is
        // no tool result to wrap a failure in.
        _ => error(id, -32602, "Unknown tool"),
    }
}

The distinction: protocol errors (JSON-RPC error objects) are for a broken conversation, an unknown method or an unknown tool. Tool execution errors go in a normal result with isError: true, as content the model can read. The 2025-11-25 revision sharpened this deliberately: even input-validation failures should be tool errors, not protocol errors, so the model can read what went wrong and correct itself. An MCP server's error messages have two audiences, and one of them will retry.

Run it against something real

Build the release binary and drive a session through it by hand — no client needed, it's just lines on a pipe:

cargo build --release
printf '%s\n' \
  '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"bash","version":"5.3"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"show_wire","arguments":{}}}' \
  | ./target/release/wiretap-mcp

The final response carries the transcript. This is real output from the binary this essay describes, reformatted for line width, long objects elided with …:

negotiated protocol: 2025-11-25
wire transcript — 6 message(s), 0 dropped (cap 64)
[handshake] → {"jsonrpc":"2.0","id":0,"method":"initialize","params":
              {"protocolVersion":"2025-11-25","capabilities":{},
               "clientInfo":{"name":"bash","version":"5.3"}}}
[handshake] ← {"id":0,"jsonrpc":"2.0","result":{"capabilities":{"tools":{}},
               "protocolVersion":"2025-11-25","serverInfo":
               {"name":"wiretap-mcp","title":"Wiretap","version":"0.1.0"}, …}}
[handshake] → {"jsonrpc":"2.0","method":"notifications/initialized"}
[operation] → {"jsonrpc":"2.0","id":1,"method":"tools/list"}
[operation] ← {"id":1,"jsonrpc":"2.0","result":{"tools":[{"name":"show_wire", …}]}}
[operation] → {"jsonrpc":"2.0","id":2,"method":"tools/call","params":
              {"name":"show_wire","arguments":{}}}

There it is: the three-message handshake, the phase boundary, and — last line — the request whose answer you're reading. That's the entire Model Context Protocol for a tool server, six lines of JSON, no magic left.

To watch a real model poke at it, register the binary with an MCP client — in Claude Code:

claude mcp add wiretap -- /path/to/wiretap-mcp/target/release/wiretap-mcp

then ask it to call show_wire. You'll see its clientInfo, which protocol revision it actually requests, and every message it sent that your demo client didn't.

What the wire teaches you about security

The reason I care that engineers see this layer at least once isn't purist pride. It's that every MCP security question becomes concrete the moment you look at the wire:

  • Everything arriving is untrusted text. Not "arguments from the model" — bytes on a pipe. The type system starts helping only after serde_json::from_str, and your validation starts helping only where you write it. There is no layer underneath doing it for you; you are the layer underneath.
  • The trust boundary points both ways. The spec is explicit that clients must treat tool descriptions and annotations as untrusted unless the server is trusted — because those strings go into a model's context. Your description and instructions fields are prompt-injection surface. On the wire this is obvious: they're just text you're feeding to someone else's LLM.
  • The lifecycle is a security control, not ceremony. "No tool calls before initialize" is an invariant your server enforces or doesn't. A boolean flag makes it a convention; a typestate makes it a theorem.
  • An SDK is a convenience, not a boundary. Use one in production — you want maintained schema validation and transport code, and I do use them for real work. But use it knowing which of these properties it gives you (framing, parsing, lifecycle plumbing) and which it never can (what your handlers do with validated input). The parts of MCP that will hurt you are not the parts the SDK wraps.

The whole server — wire log, handshake, typestate, tool, transport loop — assembles into one main.rs of 383 lines, with integration tests that drive the compiled binary over a real pipe the way an actual client would, including the pre-initialize -32002 case and the quine property. It lives in the wiretap-mcp crate in this site's repository.

Build it once, keep the wiretap around. The next time an MCP server misbehaves, you'll reach for the tool that shows you the conversation instead of the docs that describe it — and the protocol will be something you've read, not something you've trusted.


Thoughts? Find me on LinkedIn.