MCP (Model Context Protocol) · lesson 6 of 13
Beginner
4Intermediate
4Advanced
5Also part of MCP (Model Context Protocol)
5The 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
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. Itsdatalists what the server supports and echoes what was asked for. A client retries with a version fromsupported, 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
- In
raw_call.py, change"arguments"to{"a": 1}. Before running it, predict whether the response hasresultorerror. - Send two
tools/callrequests with ids 1 and 2 before reading anything, then read two lines. How does the client know which answer is which? - Connect the SDK’s
Clienttoserver.pywithmode="legacy"and printclient.protocol_version. - Remove the
notifications/initializedline fromlegacy.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.