Skip to content

2. The protocol from scratch

Intermediate · 15 min read

SDKs hide the protocol, which is fine until something breaks and you need to read the JSON. This page builds a small MCP server and client with only the standard library, so you can see every message. It follows the stateless 2026-07-28 spec from page 1. On the next page you'll swap all of it for the official SDK in about 20 lines.

A teaching server, not a compliant one

The real spec has more required fields (cache hints, pagination, many content types) and more edge cases. Use this to learn the protocol. Use the SDK for anything real.

2.1 The server's data: tools, resources, prompts

A server is a few registries plus a dispatcher. Start with the data:

import json

PROTOCOL_VERSION = "2026-07-28"
SERVER_INFO = {"name": "orders", "version": "0.1.0"}

ORDERS = {"A100": {"status": "shipped", "eta": "Friday", "total_inr": 2499},
          "A101": {"status": "processing", "eta": "Monday", "total_inr": 899}}

TOOLS = {
    "get_order_status": {
        "description": "Shipping status of an order. Use when the user gives an order ID like A100.",
        "inputSchema": {"type": "object",
                        "properties": {"order_id": {"type": "string"}},
                        "required": ["order_id"]},
    },
    "cancel_order": {
        "description": "Cancel an order. The user is asked to confirm first.",
        "inputSchema": {"type": "object",
                        "properties": {"order_id": {"type": "string"}},
                        "required": ["order_id"]},
    },
}

RESOURCES = {"policy://refunds": "Refunds within 7 days of delivery. Damaged items: full refund."}

print(f"{len(TOOLS)} tools, {len(RESOURCES)} resource")
Output
2 tools, 1 resource

2.2 Tool code and argument checks

The model's arguments are untrusted input, so check them against the schema before running anything. A failed check, or a failure inside the tool, becomes a result with isError: true that the model can read and react to:

class ToolFailure(Exception):
    """A failure the model should see and recover from."""

def check_args(schema: dict, args: dict) -> None:
    missing = [k for k in schema["required"] if k not in args]
    extra = [k for k in args if k not in schema["properties"]]
    wrong = [k for k, v in args.items() if k in schema["properties"] and not isinstance(v, str)]
    problems = [f"missing {k!r}" for k in missing] + [f"unexpected {k!r}" for k in extra]
    problems += [f"{k!r} must be a string" for k in wrong]
    if problems:
        raise ToolFailure("; ".join(problems))

def get_order_status(order_id: str) -> dict:
    if order_id not in ORDERS:
        raise ToolFailure(f"order {order_id} not found: IDs look like A100")
    order = ORDERS[order_id]
    return {"order_id": order_id, "status": order["status"], "eta": order["eta"]}

def text_result(text: str, structured=None, is_error=False) -> dict:
    result = {"content": [{"type": "text", "text": text}], "isError": is_error, "resultType": "complete"}
    if structured is not None:
        result["structuredContent"] = structured
    return result

print(text_result("hello"))
Output
{'content': [{'type': 'text', 'text': 'hello'}], 'isError': False, 'resultType': 'complete'}

2.3 Multi round-trip: asking the user mid-call

cancel_order shouldn't run without a "yes" from the user. In the stateless spec the server can't send its own request in the middle of a call. Instead it returns early with resultType: "input_required" and a list of questions. The client asks the user and retries the same call with the answers in inputResponses.

sequenceDiagram
    participant C as Client (host)
    participant S as Server
    C->>S: tools/call cancel_order {order_id: A100}
    S-->>C: resultType: input_required<br/>inputRequests: {confirm: elicitation "Really cancel A100?"}
    Note over C: host shows the question to the user
    C->>S: tools/call cancel_order {order_id: A100}<br/>inputResponses: {confirm: {action: accept, content: {confirm: true}}}
    S-->>C: resultType: complete — "A100 cancelled"
def cancel_order(order_id: str, input_responses: dict | None) -> dict:
    answer = (input_responses or {}).get("confirm")
    if answer is None:                                     # first round: ask
        return {
            "resultType": "input_required",
            "inputRequests": {"confirm": {
                "method": "elicitation/create",
                "params": {"mode": "form", "message": f"Really cancel order {order_id}?",
                           "requestedSchema": {"type": "object",
                                               "properties": {"confirm": {"type": "boolean"}},
                                               "required": ["confirm"]}}}},
        }
    if answer.get("action") == "accept" and answer.get("content", {}).get("confirm"):
        ORDERS[order_id]["status"] = "cancelled"
        return text_result(f"{order_id} cancelled")
    return text_result(f"{order_id} kept: the user did not confirm")

The server keeps no memory between the two rounds. Everything it needs comes back in the retry. That's what lets any replica behind a load balancer answer the second request.

2.4 The dispatcher

Now the JSON-RPC layer: read a request, check the protocol version, route by method, and wrap the answer as a response or an error.

PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS = -32700, -32600, -32601, -32602
UNSUPPORTED_PROTOCOL_VERSION = -32022

def handle(raw: str) -> str | None:
    try:
        msg = json.loads(raw)
    except json.JSONDecodeError:
        return json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": PARSE_ERROR, "message": "Parse error"}})
    if "id" not in msg:
        return None                                        # a notification: no reply
    reply = {"jsonrpc": "2.0", "id": msg["id"]}
    try:
        reply["result"] = route(msg.get("method"), msg.get("params") or {})
        reply["result"].setdefault("resultType", "complete")
        reply["result"]["_meta"] = {"io.modelcontextprotocol/serverInfo": SERVER_INFO}
    except RpcError as err:
        reply["error"] = {"code": err.code, "message": str(err), **({"data": err.data} if err.data else {})}
    return json.dumps(reply)

class RpcError(Exception):
    def __init__(self, code, message, data=None):
        super().__init__(message)
        self.code, self.data = code, data

def route(method: str, params: dict) -> dict:
    meta = params.get("_meta", {})
    if method == "server/discover":
        return {"supportedVersions": [PROTOCOL_VERSION],
                "capabilities": {"tools": {}, "resources": {}},
                "serverInfo": SERVER_INFO}
    version = meta.get("io.modelcontextprotocol/protocolVersion")
    if version != PROTOCOL_VERSION:
        raise RpcError(UNSUPPORTED_PROTOCOL_VERSION, f"unsupported protocol version {version!r}",
                       {"supported": [PROTOCOL_VERSION]})
    if method == "tools/list":
        return {"tools": [{"name": n, **spec} for n, spec in TOOLS.items()]}
    if method == "tools/call":
        return call_tool(params)
    if method == "resources/list":
        return {"resources": [{"uri": u, "name": u.split("//")[1], "mimeType": "text/plain"} for u in RESOURCES]}
    if method == "resources/read":
        uri = params.get("uri")
        if uri not in RESOURCES:
            raise RpcError(INVALID_PARAMS, f"resource not found: {uri}")
        return {"contents": [{"uri": uri, "mimeType": "text/plain", "text": RESOURCES[uri]}]}
    raise RpcError(METHOD_NOT_FOUND, f"method not found: {method}")

def call_tool(params: dict) -> dict:
    name, args = params.get("name"), params.get("arguments") or {}
    if name not in TOOLS:
        raise RpcError(INVALID_PARAMS, f"unknown tool: {name}")
    try:
        check_args(TOOLS[name]["inputSchema"], args)
        if name == "get_order_status":
            data = get_order_status(**args)
            return text_result(json.dumps(data), structured=data)
        return cancel_order(args["order_id"], params.get("inputResponses"))
    except ToolFailure as err:
        return text_result(f"Error: {err}", is_error=True)

Note the two kinds of failure. An unknown tool or unknown method is a protocol error (error with a code). A tool that ran and failed returns a normal result with isError: true, because the model should see it.

2.5 A client

The client side is small: build a request with the _meta every stateless request needs, send it, and read the reply. Here it calls handle() directly. In 2.6 the same messages go over a real pipe.

CLIENT_META = {"io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION,
               "io.modelcontextprotocol/clientCapabilities": {"elicitation": {"form": {}}},
               "io.modelcontextprotocol/clientInfo": {"name": "notes-client", "version": "1.0"}}
next_id = 0

def request(method: str, params: dict | None = None, send=handle) -> dict:
    global next_id
    next_id += 1
    params = {**(params or {}), "_meta": CLIENT_META} if method != "server/discover" else (params or {})
    return json.loads(send(json.dumps({"jsonrpc": "2.0", "id": next_id, "method": method, "params": params})))

discover = request("server/discover")["result"]
print("server/discover ->", discover["supportedVersions"], list(discover["capabilities"]))

tools = request("tools/list")["result"]["tools"]
print("tools/list      ->", [t["name"] for t in tools])

ok = request("tools/call", {"name": "get_order_status", "arguments": {"order_id": "A100"}})["result"]
print("call A100       ->", ok["structuredContent"], "isError:", ok["isError"])

failed = request("tools/call", {"name": "get_order_status", "arguments": {"order_id": "Z9"}})["result"]
print("call Z9         ->", failed["content"][0]["text"], "isError:", failed["isError"])

bad = request("tools/call", {"name": "get_order_status", "arguments": {"id": 100}})["result"]
print("bad arguments   ->", bad["content"][0]["text"])

unknown = request("tools/call", {"name": "delete_everything", "arguments": {}})
print("unknown tool    ->", unknown["error"])

policy = request("resources/read", {"uri": "policy://refunds"})["result"]["contents"][0]["text"]
print("resources/read  ->", policy)
Output
server/discover -> ['2026-07-28'] ['tools', 'resources']
tools/list      -> ['get_order_status', 'cancel_order']
call A100       -> {'order_id': 'A100', 'status': 'shipped', 'eta': 'Friday'} isError: False
call Z9         -> Error: order Z9 not found: IDs look like A100 isError: True
bad arguments   -> Error: missing 'order_id'; unexpected 'id'
unknown tool    -> {'code': -32602, 'message': 'unknown tool: delete_everything'}
resources/read  -> Refunds within 7 days of delivery. Damaged items: full refund.

And the multi round-trip cancel, where the "user" is a function that always says yes:

def ask_user(question: dict) -> dict:
    print("  host shows:", question["params"]["message"])
    return {"action": "accept", "content": {"confirm": True}}

def call_with_input(name: str, arguments: dict) -> dict:
    responses = None
    for round_no in range(1, 4):                           # cap the rounds, like the SDK does
        params = {"name": name, "arguments": arguments}
        if responses:
            params["inputResponses"] = responses
        result = request("tools/call", params)["result"]
        print(f"round {round_no}: resultType={result['resultType']}")
        if result["resultType"] != "input_required":
            return result
        responses = {key: ask_user(q) for key, q in result["inputRequests"].items()}
    raise RuntimeError("too many input rounds")

final = call_with_input("cancel_order", {"order_id": "A101"})
print(final["content"][0]["text"], "| A101 is now", ORDERS["A101"]["status"])
Output
round 1: resultType=input_required
  host shows: Really cancel order A101?
round 2: resultType=complete
A101 cancelled | A101 is now cancelled

Finally, a client on an old spec version is refused with a clear error instead of a confusing failure later:

old = json.loads(handle(json.dumps({
    "jsonrpc": "2.0", "id": 99, "method": "tools/list",
    "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2025-06-18"}}})))
print(old["error"])
Output
{'code': -32022, 'message': "unsupported protocol version '2025-06-18'", 'data': {'supported': ['2026-07-28']}}

2.6 The stdio transport, for real

For a local server, the host starts it as a child process and they exchange one JSON message per line over stdin and stdout. Anything else the server prints must go to stderr, or it corrupts the stream.

Let's write a minimal server file and talk to it through a real pipe:

import subprocess
import sys
import textwrap

SERVER_FILE = textwrap.dedent('''
    import json, sys

    def handle(msg):
        if msg["method"] == "tools/list":
            return {"tools": [{"name": "ping", "description": "Health check",
                               "inputSchema": {"type": "object", "properties": {}}}]}
        if msg["method"] == "tools/call":
            return {"content": [{"type": "text", "text": "pong"}], "isError": False}
        return None

    print("orders server started", file=sys.stderr)        # logs go to stderr, never stdout
    for line in sys.stdin:                                   # one JSON-RPC message per line
        msg = json.loads(line)
        result = handle(msg)
        reply = ({"jsonrpc": "2.0", "id": msg["id"], "result": {**result, "resultType": "complete"}}
                 if result is not None else
                 {"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32601, "message": "method not found"}})
        print(json.dumps(reply), flush=True)
''')
with open("tiny_server.py", "w") as f:
    f.write(SERVER_FILE)

proc = subprocess.Popen([sys.executable, "tiny_server.py"], stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

def send_line(raw: str) -> str:
    proc.stdin.write(raw + "\n")
    proc.stdin.flush()
    return proc.stdout.readline()

print(request("tools/list", send=send_line)["result"]["tools"][0]["name"])
print(request("tools/call", {"name": "ping", "arguments": {}}, send=send_line)["result"]["content"][0]["text"])
print(request("prompts/list", send=send_line)["error"]["message"])

proc.stdin.close()
proc.wait()
print("server stderr:", proc.stderr.read().strip())
Output
ping
pong
method not found
server stderr: orders server started

That's the whole stdio transport. Claude Desktop, Cursor and Claude Code start local servers exactly like this, from a command in their config file (page 4). Remote servers use Streamable HTTP instead: the same JSON, sent as HTTP POSTs (page 5).

Interview questions

What's the difference between a JSON-RPC error and isError: true in MCP?

A JSON-RPC error means the request couldn't be processed: unknown method, invalid params, an unknown tool, an unsupported protocol version. isError: true inside a normal result means the tool ran and failed (order not found, API down). The model sees tool errors and can correct itself; protocol errors are for the client code.

How does a stateless MCP server ask the user a question in the middle of a tool call?

With the multi round-trip pattern. The server returns resultType: "input_required" with inputRequests (for example an elicitation form). The client collects the answers and re-sends the same request with inputResponses. The server keeps no state between rounds, so any replica can handle the retry. Servers can also return an opaque requestState that the client echoes back.

Why must a stdio MCP server never print to stdout?

Stdout is the protocol channel: the client parses every line as a JSON-RPC message. A stray print() corrupts the stream and breaks the connection. Logs go to stderr, which hosts usually capture into a log file.

What does server/discover return and when is it used?

The protocol versions the server supports, its capabilities (tools, resources, prompts, extensions), its identity and optional instructions. Clients can call it first to choose a version, and SDKs use it as a probe: if a server doesn't understand it, the client falls back to the older initialize handshake.

Practice

  • Add a prompts/list and prompts/get handler with one prompt, apology_email(customer, issue).
  • Make ask_user decline. Check that A100 is kept and the model gets a readable message.
  • Add an amount_inr integer argument to a new refund_order tool and extend check_args to validate integers.

Next: 3. Building servers with the Python SDK Back to: MCP overview · Notes overview