Skip to main content
MCP (Model Context Protocol) · lesson 6 of 13
MCP (Model Context Protocol) intermediate Lesson 6 of 13

The MCP Wire Protocol: JSON-RPC by Hand

Talk to an MCP server with raw JSON lines: the 2026-07-28 _meta envelope, resultType, server/discover, error codes -32602 and -32022, and legacy clients.

By SkillByExample · Published

mcp model-context-protocol json-rpc protocol python

What the SDK Sends for You

Every call in the earlier lessons was a JSON object on a pipe. The SDK hides it, which is convenient until something breaks between two implementations. Then you need to know what a correct message looks like. This lesson talks to the first lesson’s server.py with nothing but subprocess and json: no MCP client at all.

A Tool Call as One Line of JSON

Over stdio, a request is one line of JSON on the server’s stdin, and the response is one line on its stdout.

# raw_call.py - call a tool by writing one JSON line to the server's stdin
import json
import subprocess

server = subprocess.Popen(["python", "server.py"], text=True,
                          stdin=subprocess.PIPE, stdout=subprocess.PIPE)

request = {
    "jsonrpc": "2.0", "id": 1, "method": "tools/call",
    "params": {
        "name": "add", "arguments": {"a": 2, "b": 3},
        "_meta": {
            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientCapabilities": {},
        },
    },
}
server.stdin.write(json.dumps(request) + "\n")
server.stdin.flush()
response = json.loads(server.stdout.readline())
print(json.dumps(response, indent=2))
server.stdin.close()
server.wait()
$ python raw_call.py
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "text": "5",
        "type": "text"
      }
    ],
    "isError": false,
    "resultType": "complete",
    "structuredContent": {
      "result": 5
    },
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "calc",
        "version": ""
      }
    }
  }
}

This was the server’s very first message, and it worked. There was no handshake, because from protocol version 2026-07-28 each request is self-contained. The _meta envelope says which protocol version the request speaks and which client features (capabilities) it supports. The server needs nothing from earlier messages to answer, which is what lets a load balancer send each HTTP request to a different server instance.

The response echoes "id": 1, which is how a client matches responses to requests. Two fields are new in this version. resultType: "complete" says this is a final answer. The other value, input_required, means the server needs something from the client first; the client features lesson covers it. The _meta on the way back carries serverInfo, so every response says which server produced it. content, isError and structuredContent are the fields the SDK turned into result.content, result.is_error and result.structured_content.

Discovery

A client that wants to know what a server offers before using it sends server/discover.

# discover.py - ask the server what it supports with server/discover
import json
import subprocess

server = subprocess.Popen(["python", "server.py"], text=True,
                          stdin=subprocess.PIPE, stdout=subprocess.PIPE)
META = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientCapabilities": {}}

server.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "server/discover",
                               "params": {"_meta": META}}) + "\n")
server.stdin.flush()
result = json.loads(server.stdout.readline())["result"]
print("supportedVersions:", result["supportedVersions"])
print("capabilities:", json.dumps(result["capabilities"]))
print("serverInfo:", result["_meta"]["io.modelcontextprotocol/serverInfo"])
print("resultType:", result["resultType"])
server.stdin.close()
server.wait()
$ python discover.py
supportedVersions: ['2026-07-28']
capabilities: {"prompts": {"listChanged": true}, "resources": {"listChanged": true, "subscribe": true}, "tools": {"listChanged": true}}
serverInfo: {'name': 'calc', 'version': ''}
resultType: complete

capabilities lists the features the server supports and their options. server.py defines only a tool, yet MCPServer advertises prompts and resources too, so a client listing them just gets empty lists. listChanged: true says the server can tell clients when a list changes, and subscribe: true says a client can watch a single resource for updates.

This is the request the SDK’s Client sends first. Entering async with Client(...) in the earlier lessons sent server/discover, and the result is where client.protocol_version and client.server_info came from.

When a Request Is Wrong

Each case below starts a fresh server. A connection’s protocol era is fixed by its first request, as the last section shows.

# bad_requests.py - what the server says to malformed requests
import json
import subprocess

V = "io.modelcontextprotocol/protocolVersion"
CAPS = "io.modelcontextprotocol/clientCapabilities"
cases = {
    "no client capabilities": {V: "2026-07-28"},
    "future version": {V: "2030-01-01", CAPS: {}},
}

for label, meta in cases.items():
    server = subprocess.Popen(["python", "server.py"], text=True,
                              stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    request = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}}
    server.stdin.write(json.dumps(request) + "\n")
    server.stdin.flush()
    print(label, "->", server.stdout.readline().strip())
    server.stdin.close()
    server.wait()
$ python bad_requests.py
no client capabilities -> {"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"params._meta is missing the required envelope key(s): io.modelcontextprotocol/clientCapabilities"}}
future version -> {"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28"],"requested":"2030-01-01"}}}

Both are JSON-RPC errors: an error object replaces result, and id still matches the request.

  • -32602 (invalid params) is a standard JSON-RPC code. The envelope needs both keys, even if the capabilities object is empty, and the message names the one that’s missing.
  • -32022 (unsupported protocol version) is MCP’s own code. Its data lists what the server supports and echoes what was asked for. A client retries with a version from supported, and that retry is how version negotiation works without a handshake.

These are the codes you saw as MCPError exceptions in the tools and resources lessons. The SDK raises them because they’re protocol failures; a tool that fails is a normal result.

Older Clients Still Connect

Clients built for earlier protocol versions open with initialize. MCPServer still serves them.

# legacy.py - a pre-2026 client: initialize first, then plain requests
import json
import subprocess

server = subprocess.Popen(["python", "server.py"], text=True,
                          stdin=subprocess.PIPE, stdout=subprocess.PIPE)
MODERN = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
          "io.modelcontextprotocol/clientCapabilities": {}}
messages = [
    {"jsonrpc": "2.0", "id": 1, "method": "initialize",
     "params": {"protocolVersion": "2025-11-25", "capabilities": {},
                "clientInfo": {"name": "old-client", "version": "1.0"}}},
    {"jsonrpc": "2.0", "method": "notifications/initialized"},
    {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "add", "arguments": {"a": 1, "b": 2}}},
    {"jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {"_meta": MODERN}},
]
for msg in messages:
    server.stdin.write(json.dumps(msg) + "\n")
    server.stdin.flush()
    if "id" in msg:  # notifications get no response
        print(msg["id"], server.stdout.readline().strip())
server.stdin.close()
server.wait()
$ python legacy.py
1 {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"prompts":{"listChanged":false},"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},"protocolVersion":"2025-11-25","serverInfo":{"name":"calc","version":""}}}
2 {"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"3","type":"text"}],"isError":false,"structuredContent":{"result":3}}}
3 {"jsonrpc":"2.0","id":3,"error":{"code":-32600,"message":"this connection serves the handshake protocol era; requests carrying the 2026-07-28 envelope are not accepted on it"}}

The handshake is the older model. initialize agreed on version 2025-11-25 and returned the capabilities and serverInfo that server/discover gives now. notifications/initialized has no id, which makes it a notification: the server must not reply, so the loop doesn’t wait for one. After that, tools/call needed no _meta, because the version was agreed once for the whole connection. Its result has no resultType either, since that field is new in 2026-07-28.

Request 3 shows the catch. Once a connection starts the old way, modern requests on it get -32600 (invalid request). A connection serves one era from its first message to its last. The SDK’s Client handles this choice: its default mode="auto" sends server/discover and falls back to initialize when the server doesn’t support it.

Practice

  1. In raw_call.py, change "arguments" to {"a": 1}. Before running it, predict whether the response has result or error.
  2. Send two tools/call requests with ids 1 and 2 before reading anything, then read two lines. How does the client know which answer is which?
  3. Connect the SDK’s Client to server.py with mode="legacy" and print client.protocol_version.
  4. Remove the notifications/initialized line from legacy.py’s messages, keeping the rest. Does the tool call still work?
Solution 1

It’s a result, with "isError": true and a validation message saying b is required. A missing tool argument is a tool error, not a protocol error, so it travels in a normal result for the model to read. Only problems with the request itself, like the envelope, become a JSON-RPC error.

Solution 2
for i, (a, b) in enumerate([(1, 2), (10, 20)], start=1):
    request = {"jsonrpc": "2.0", "id": i, "method": "tools/call",
               "params": {"name": "add", "arguments": {"a": a, "b": b}, "_meta": META}}
    server.stdin.write(json.dumps(request) + "\n")
server.stdin.flush()
for _ in range(2):
    response = json.loads(server.stdout.readline())
    print(response["id"], response["result"]["structuredContent"])

It prints 1 {'result': 3} and 2 {'result': 30}. Each response carries its request’s id. This server answered in order, but JSON-RPC doesn’t promise that, so always match on id rather than position.

Solution 3
async with Client(StdioServerParameters(command="python", args=["server.py"]), mode="legacy") as client:
    print(client.protocol_version)

It prints 2025-11-25. The client sent initialize and notifications/initialized for you, and later requests carried no envelope, exactly like legacy.py.

Solution 4

With MCPServer it does: the output is the same three lines, so this server is lenient. The 2025 protocol said a client should send notifications/initialized before other requests, and a stricter server may refuse to work until it arrives. If you’re writing a client, always send it. If you’re debugging one against a different server, a missing initialized is among the first things to check.

Frequently Asked Questions

What format do MCP messages use?
MCP messages are JSON-RPC 2.0 objects. A request has jsonrpc, an id, a method such as tools/call and params; a response echoes the id with either a result or an error. Over the stdio transport each message is one line of JSON, so you can talk to a server by writing lines to its stdin and reading lines from its stdout.
Does MCP still need an initialize handshake?
Not from protocol version 2026-07-28. Each request carries the protocol version and the client's capabilities in params._meta, so a server can answer any request on its own, including a tools/call sent as the very first message. Earlier versions opened every connection with initialize and notifications/initialized, and servers can still accept that for older clients.
What does MCP error -32022 mean?
-32022 means the server doesn't support the protocol version the request asked for. The error's data lists the versions the server does support and echoes the requested one, so a client can retry with a version both sides understand. A client probing with server/discover uses exactly this to negotiate.
What is server/discover in MCP?
server/discover is the 2026-07-28 request a client sends to learn what a server supports before using it. The result lists supportedVersions, the server's capabilities (tools, resources, prompts and their options) and serverInfo with its name and version. It replaces the information initialize used to return.