Vibe Code Textbook

Harnesses · · 1,223 words · 6 min read

MCP server tutorial in Python: a stdio server from scratch

What the Model Context Protocol changes for coding agents, the JSON-RPC on the wire, and a dependency-free Python stdio server exercised with a scripted client.

mcp python json-rpc tools

You get the shape of the Model Context Protocol from its specification, the exact JSON that crosses the wire for a tool call, a note on what the July 2026 revision changed, and a stdio server written in plain Python with no packages that I ran in this session against a scripted client, with the real request and response lines included.

What the protocol is for

The specification's overview says MCP "is an open protocol that enables seamless integration between LLM applications and external data sources and tools" (the word "seamless" is theirs). It uses JSON-RPC 2.0 messages between three roles: "Hosts: LLM applications that initiate connections", "Clients: Connectors within the host application", and "Servers: Services that provide context and capabilities". A coding-agent harness is a host; each configured server gets a client inside it.

Servers offer three kinds of feature: "Resources: Context and data, for the user or the AI model to use", "Prompts: Templated messages and workflows for users", and "Tools: Functions for the AI model to execute". Clients could offer sampling, roots, and elicitation back to servers in the 2025-06-18 revision. For a coding agent, tools are the part that matters, because they are the mechanism by which a harness gains an action it did not ship with, the same way built-in tools work in the loop described at /posts/what-is-a-coding-agent-harness.html.

The spec takes inspiration from the Language Server Protocol, and the analogy is apt: one server, written once, works in every host that speaks the protocol.

The wire format

The transports page defines two standard transports, stdio and Streamable HTTP, and says "Clients SHOULD support stdio whenever possible." The stdio rules are short and strict. The client launches the server as a subprocess. Messages "are delimited by newlines, and MUST NOT contain embedded newlines." The server "MAY write UTF-8 strings to its standard error (stderr) for logging purposes" but "MUST NOT write anything to its stdout that is not a valid MCP message." That last rule is the one that breaks most first attempts: one stray print() and the host cannot parse the stream.

The tools page gives the two messages a tool needs. Discovery is a tools/list request whose response carries a list of tool definitions; each has a name, a description, and an inputSchema in JSON Schema, optionally a title, an outputSchema, and annotations. Invocation is tools/call:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "location": "New York" }
  }
}

and the result is a list of content blocks plus an isError flag:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [{ "type": "text", "text": "Current weather in New York: 72°F, Partly cloudy" }],
    "isError": false
  }
}

The page distinguishes two error channels. A protocol error, such as an unknown tool, is a JSON-RPC error object with a code like -32602. A tool execution error, such as an API failure, is a normal result with isError: true, so the model sees the failure text and can react to it. Its security section says servers "MUST" validate inputs and implement access controls, and clients "SHOULD" prompt for confirmation on sensitive operations and "Show tool inputs to the user before calling the server".

What changed in the 2026-07-28 revision

The python-sdk README at commit 7bb486a10fa6 says v2 of the SDK is "a major rework" built "to support the 2026-07-28 MCP specification (and every earlier revision)", and warns that pip install mcp now installs 2.x, so pin mcp>=1.28,<2 if you have not migrated. The changelog for that revision lists the headline changes. The protocol became stateless: it removed "the initialize/notifications/initialized handshake", and "Every request now carries its protocol version and client capabilities in _meta". It added server/discover, which "servers MUST implement" so a client can learn versions and capabilities up front. It removed the Mcp-Session-Id header from Streamable HTTP. And it deprecated three client-side features at once: "Deprecate the Roots, Sampling, and Logging features", with the suggested migrations being tool parameters instead of roots, direct LLM APIs instead of sampling, and stderr or OpenTelemetry instead of logging.

The practical consequence for a server author is that the 2025-06-18 handshake is still worth implementing, because hosts on the older revision send it and the changelog says clients "MUST treat results from earlier-protocol servers that omit the field as complete". My server below answers initialize for those hosts and ignores the notification that follows it.

The SDK way

If you want the SDK, the README shows the whole thing in a dozen lines, quoted here from the same commit:

from mcp.server import MCPServer

mcp = MCPServer("Demo")


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

The README's point is what you did not write: "no JSON Schema (a: int, b: int is the schema), no request parsing, no validation code, no protocol handling." Run it with uv run mcp dev server.py to get the inspector. That is the right choice for real servers. Writing the protocol by hand once is the right way to understand what the decorator hides.

The server from scratch

The shipped mcp-server-tutorial-python.py is about 130 lines and imports only json, os, and sys. It exposes two tools, word_count and read_lines, the second restricted to files under a root directory set by MCP_ROOT. The dispatcher is the whole protocol:

def handle(msg: dict) -> dict | None:
    method, id_, params = msg.get("method"), msg.get("id"), msg.get("params") or {}
    if method == "initialize":
        return {"jsonrpc": "2.0", "id": id_, "result": {
            "protocolVersion": PROTOCOL,
            "capabilities": {"tools": {"listChanged": False}},
            "serverInfo": {"name": "notes-tools", "version": "0.1.0"}}}
    if method == "notifications/initialized":
        log("client initialized")
        return None
    if method == "tools/list":
        return {"jsonrpc": "2.0", "id": id_, "result": {"tools": TOOLS}}
    if method == "tools/call":
        name, args = params.get("name"), params.get("arguments") or {}
        if name not in HANDLERS:
            return error(id_, -32602, f"Unknown tool: {name}")
        try:
            text = HANDLERS[name](args)
            return {"jsonrpc": "2.0", "id": id_, "result": {"content": [{"type": "text", "text": text}], "isError": False}}
        except Exception as e:
            return {"jsonrpc": "2.0", "id": id_, "result": {"content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}], "isError": True}}
    if id_ is None:
        return None
    return error(id_, -32601, f"Method not found: {method}")

Notifications have no id and get no reply. Unknown tools are protocol errors; a tool that raises is a result with isError set, which is the split the spec asks for. The log() helper writes to stderr only.

What ran here

I did not connect the server to a live host in this session. I exercised it with a scripted client: a Python snippet that started the server as a subprocess with Python 3.13.12 on Windows, wrote eight JSON-RPC lines to its stdin, and captured stdout and stderr. The requests were an initialize, the initialized notification, tools/list, a word_count call, a read_lines call on the site's robots.txt, a read_lines call that tried to escape the root with ../, a call to a tool that does not exist, and a resources/list request the server does not implement. These are the seven response lines that came back on stdout, with the long tools/list line cut at the width of the page:

{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {"listChanged": false}}, "serverInfo": {"name": "notes-tools", "version": "0.1.0"}}}
{"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "word_count", "description": "Count words, lines, and characters in a piece of text.", "inputSchema": {...}}, {"name": "read_lines", ...}]}}
{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "{\"words\": 4, \"lines\": 2, \"characters\": 18}"}], "isError": false}}
{"jsonrpc": "2.0", "id": 4, "result": {"content": [{"type": "text", "text": "1: User-agent: *\n2: Allow: /\n3: "}], "isError": false}}
{"jsonrpc": "2.0", "id": 5, "result": {"content": [{"type": "text", "text": "ValueError: path escapes the server root: ../_blogkit/engine.py"}], "isError": true}}
{"jsonrpc": "2.0", "id": 6, "error": {"code": -32602, "message": "Unknown tool: nope"}}
{"jsonrpc": "2.0", "id": 7, "error": {"code": -32601, "message": "Method not found: resources/list"}}

Stderr carried two lines, serving 2 tools and client initialized, and nothing else reached stdout: the client checked that every stdout line parsed as JSON, and it did. Seven responses for eight requests is correct, because the notification gets none.

Registering it with a harness

The Claude Code MCP page gives the command for a local server: claude mcp add [options] <name> -- <command> [args...], where "the -- (double dash) separates Claude's options from the server command". For this file that is claude mcp add --transport stdio notes -- python mcp-server-tutorial-python.py. A project-scoped server goes in .mcp.json at the repository root under a mcpServers key, and the page's scope table says that file is the only one shared through version control. Once registered, each tool has a permission name of the form mcp__<server-name>__<tool-name>, so mcp__notes__read_lines is what you would put in an allow rule.

What I did not verify

I did not run this server under Claude Code, Codex, or any other host in this session, so I cannot say which of them still send the 2025-06-18 handshake and which have moved to server/discover. The server does not implement server/discover, resources, prompts, or Streamable HTTP. Its path check compares absolute paths and rejects anything outside the root, which handles ../ but was not tested against symlinks.

Code and data

Sources

  1. Model Context Protocol, "Specification 2025-06-18" (read 2026-09-05)
  2. Model Context Protocol, "Transports" (specification 2025-06-18, read 2026-09-05)
  3. Model Context Protocol, "Tools" (specification 2025-06-18, read 2026-09-05)
  4. Model Context Protocol, "Key Changes" (specification 2026-07-28, read 2026-09-05)
  5. modelcontextprotocol/python-sdk, "README at commit 7bb486a10fa6" (read 2026-09-05)
  6. Anthropic, "Connect Claude Code to tools via MCP" (Claude Code docs, read 2026-09-05)