Skip to content

English · Español

05 — MCP Over the Wire + a ~100-line Server

🇪🇸 MCP es JSON-RPC 2.0 sobre stdio (o WebSocket / SSE). Esta sección muestra exactamente qué bytes viajan en un tool call, y construye un servidor MCP funcional en ~100 líneas de Python — sin librería externa, sólo json, sys, asyncio (opcional).

Anchors: theory/01-function-calling-formats.md, theory/02-mcp-architecture.md, theory/03-authn-authz.md.


What MCP is, in one paragraph

The Model Context Protocol (Anthropic, 2024) is a wire format for exposing tools, resources, and prompts to LLM clients. Concretely it's JSON-RPC 2.0 carried over stdio (default) or HTTP + SSE (server-sent events) or WebSocket. It standardizes:

  • how a client discovers what a server can do (tools/list, resources/list, prompts/list);
  • how a client invokes a capability (tools/call);
  • how a server returns structured results, errors, or streamed partials.

MCP is intentionally minimal: it does not prescribe the agent loop, the prompt format, the sampling strategy, or the model. It standardizes the plug, not the appliance. Phase 32 (agents) builds the agent loop on top.

The wire format: 4 message types

JSON-RPC 2.0 carries four message kinds. Each is one JSON object per line over stdio.

Request (client → server)

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "conjugate",
    "arguments": {"verb": "eat", "tense": "past simple", "person": "3rd singular"}
  }
}

id is a client-chosen correlation token. method is a dotted namespace; MCP defines initialize, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get, and a few more.

Response (server → client, with result)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {"type": "text", "text": "ate"}
    ],
    "isError": false
  }
}

The result for tools/call is always a {content: [...], isError: bool} object. Content items have a type (text, image, resource) — the protocol is multi-modal at the wire level.

Response (server → client, with error)

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "Invalid params: 'verb' must be one of the 20 allowed verbs"
  }
}

Error codes follow JSON-RPC 2.0's reserved range (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error) plus MCP-specific codes above -32000.

Notification (client → server, no response expected)

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {"progressToken": "abc", "progress": 0.5}
}

No id field. Used for streaming progress or cancellation signals.

The handshake

Before any tool call, the client and server must negotiate:

  1. initialize (client → server):

    {"jsonrpc": "2.0", "id": 0, "method": "initialize",
     "params": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
                "clientInfo": {"name": "lynx-cortex-agent", "version": "0.1.0"}}}
    

  2. initialize response (server → client) with the server's capabilities + version:

    {"jsonrpc": "2.0", "id": 0, "result": {
        "protocolVersion": "2024-11-05",
        "capabilities": {"tools": {"listChanged": true}},
        "serverInfo": {"name": "lynx-cortex-grammar-mcp", "version": "0.1.0"}}}
    

  3. notifications/initialized (client → server, no response). The handshake completes; the client can now call tools/list and tools/call.

A complete tool-call round-trip

client                                 server
  │                                      │
  │── initialize ─────────────────────▶  │
  │                                      │
  │  ◀── initialize result ─────────────│
  │                                      │
  │── notifications/initialized ──────▶  │
  │                                      │
  │── tools/list ─────────────────────▶  │
  │                                      │
  │  ◀── result: [{name: "conjugate",   │
  │                inputSchema: {...}}] │
  │                                      │
  │── tools/call (conjugate, eat) ────▶  │
  │                                      │
  │                            (server runs the tool)
  │                                      │
  │  ◀── result: {content: [{text:"ate"}]}
  │                                      │

That's the entire protocol surface for a synchronous tool call.

A ~100-line MCP server

Below: a complete grammar-conjugation MCP server, no library beyond Python stdlib. Borja writes this in src/minimcp/server.py — file is shown here as a target, not a copy.

"""A minimal MCP server exposing a §A13 verb-conjugation tool.

Wire format: JSON-RPC 2.0 over stdio.
Usage: python -m src.minimcp.server  (the client connects via stdin/stdout)
"""
from __future__ import annotations
import json, sys
from typing import Any, Callable

PROTOCOL_VERSION = "2024-11-05"

# Tool implementations live as plain Python functions.
_TOOLS: dict[str, tuple[Callable[[dict], str], dict[str, Any]]] = {}

def register(name: str, schema: dict[str, Any]):
    """Decorator: register a tool with its JSON-Schema input spec."""
    def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
        _TOOLS[name] = (fn, schema)
        return fn
    return decorator

CONJUGATE_SCHEMA = {
    "type": "object",
    "required": ["verb", "tense", "person"],
    "properties": {
        "verb":   {"enum": ["work","play","walk","talk","listen","watch",
                            "study","finish","start","look","want","like",
                            "be","have","do","go","come","see","eat","write"]},
        "tense":  {"enum": ["infinitive","present simple","past simple",
                            "past participle","simple future"]},
        "person": {"enum": ["1st singular","2nd singular","3rd singular"]},
    },
}

# The actual lookup table (§A13). 20 verbs × 5 tenses × 3 persons = 300 entries.
_LOOKUP = {("eat","past simple","3rd singular"): "ate",
           ("write","past simple","3rd singular"): "wrote",
           # ... 298 more rows; in production this is loaded from data/a13.yaml
          }

@register("conjugate", CONJUGATE_SCHEMA)
def conjugate(args: dict) -> str:
    """Return the English conjugation for (verb, tense, person)."""
    key = (args["verb"], args["tense"], args["person"])
    return _LOOKUP.get(key, "unknown")

def _send(msg: dict) -> None:
    sys.stdout.write(json.dumps(msg) + "\n")
    sys.stdout.flush()

def _error(req_id: Any, code: int, message: str) -> dict:
    return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}

def _handle(req: dict) -> dict | None:
    method = req.get("method")
    rid = req.get("id")
    if method == "initialize":
        return {"jsonrpc": "2.0", "id": rid, "result": {
            "protocolVersion": PROTOCOL_VERSION,
            "capabilities": {"tools": {"listChanged": False}},
            "serverInfo": {"name": "minimcp-grammar", "version": "0.1.0"}}}
    if method == "notifications/initialized":
        return None        # notifications have no response
    if method == "tools/list":
        tools = [{"name": n, "inputSchema": s} for n, (_, s) in _TOOLS.items()]
        return {"jsonrpc": "2.0", "id": rid, "result": {"tools": tools}}
    if method == "tools/call":
        name = req["params"]["name"]
        args = req["params"].get("arguments", {})
        if name not in _TOOLS:
            return _error(rid, -32601, f"Tool '{name}' not found")
        fn, _ = _TOOLS[name]
        try:
            out = fn(args)
        except KeyError as e:
            return _error(rid, -32602, f"Missing argument: {e}")
        return {"jsonrpc": "2.0", "id": rid, "result": {
            "content": [{"type": "text", "text": out}], "isError": False}}
    return _error(rid, -32601, f"Unknown method: {method}")

def main() -> int:
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            req = json.loads(line)
        except json.JSONDecodeError as e:
            _send(_error(None, -32700, f"Parse error: {e}"))
            continue
        resp = _handle(req)
        if resp is not None:
            _send(resp)
    return 0

if __name__ == "__main__":
    sys.exit(main())

Line count: ~85 lines including comments and the conjugate tool. Add input-schema validation (10-15 lines using jsonschema) and you're at 100.

This is a complete, spec-compliant MCP server. It handles the handshake, exposes a tool, validates dispatch, returns errors with the correct codes, and runs over stdio. It does not implement resources, prompts, sampling, or progress notifications — those are extensions, not requirements.

Walking through one round-trip with bytes on the wire

Stdin from client:

{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"conjugate","arguments":{"verb":"eat","tense":"past simple","person":"3rd singular"}}}

Stdout from server:

{"jsonrpc": "2.0", "id": 0, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {"listChanged": false}}, "serverInfo": {"name": "minimcp-grammar", "version": "0.1.0"}}}
{"jsonrpc": "2.0", "id": 1, "result": {"tools": [{"name": "conjugate", "inputSchema": {...}}]}}
{"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "ate"}], "isError": false}}

Four bytes that matter: the JSON-RPC id keeps client and server in lockstep. Everything else is structural.

Why MCP, and not a custom protocol

The pre-MCP world: every LLM vendor invented its own tool-call schema — OpenAI's function_call, Anthropic's tool_use, Google's function_call, each subtly different. The cost was N × M integration: every agent framework had to support every model's format.

MCP's pitch: standardize the transport (JSON-RPC) and the tool-description format (JSON-Schema), so any client can talk to any server. The model still produces the tool-call args; the MCP layer is between the agent loop and the tool implementation, not between the model and the agent.

For Phase 32's grammar tutor, this means: the agent loop calls mcp_client.call("conjugate", {...}) regardless of which model is generating the tool-call arguments. Swap Mini-GPT for Claude or GPT-4 and the MCP server above doesn't change.

What this server intentionally omits

  1. Auth. A real production MCP server checks credentials at initialize. For local stdio, the trust boundary is the process; for HTTP transport, it isn't, and you need OAuth or PSK. See theory/03-authn-authz.md.
  2. Streaming. tools/call here returns one response. For long-running tools, MCP uses notifications/progress and a final result; not implemented here.
  3. Resources and prompts. MCP also exposes read-only resources (e.g., a file system mount) and prompt templates. Phase 31 covers tools; resources/prompts are a dig-deeper extension.
  4. Sampling. MCP allows a server to request the client to sample from the LLM (e.g., for a grammar-aware tool that needs the model to generate options). Out of scope here.

The 85 lines are the floor of a useful MCP server. Production servers add ~500-1000 lines of validation, auth, retry, and observability — but the shape stays the same.

Citations

One-paragraph recap

MCP is JSON-RPC 2.0 over stdio with a small number of method names (initialize, tools/list, tools/call, plus resources / prompts). A complete, spec-compliant server fits in ~100 lines of stdlib Python: a dispatch table from method name to handler, JSON-Schema for tool arguments, and the standard error codes. The protocol's value is not what it adds but what it standardizes — making any agent able to talk to any tool, without bespoke per-vendor integration. For the §A13 grammar tutor, this means the agent loop in Phase 32 doesn't care about the model behind it; only that the MCP server returns {"text": "ate"} when asked for the past simple of eat.

Next: lab/01-mcp-server.md to implement the server above and lab/02-mcp-roundtrip.md to drive it from a client.