The Wire Protocol
Peek under the hood of MCP: it's just JSON-RPC 2.0 messages flowing over stdio or HTTP, and here's the exact handshake a tool call rides on.
You've seen tools, resources, and prompts as concepts. But when an agent actually talks to your connector, what physically travels across the connection? No magic — just plain text messages in a well-known format. Once you can read those messages, MCP stops feeling like a framework and starts feeling like a phone call you can eavesdrop on.
#It's JSON-RPC 2.0 all the way down
MCP doesn't invent a new message format. It standardizes on JSON-RPC 2.0 — a tiny, decades-old convention for "call a method on the other side and get a result back." Every message is a small JSON object with a jsonrpc version, an id to match requests with their replies, and either a method (for requests) or a result (for responses).
Those messages need a pipe to travel through. MCP supports a couple of transports:
- stdio — for local servers. The host launches your server as a subprocess and pipes messages over standard input/output. Fast, private, no network.
- Streamable HTTP / SSE — for remote servers reachable over the network.
The messages are identical either way; only the delivery truck changes.
The protocol vs. the pipe
Think of JSON-RPC 2.0 as the language two parties speak, and the transport (stdio or HTTP) as the channel they speak over — a phone line vs. a walkie-talkie. The words don't change when you switch channels. That separation is why the same server code works locally and remotely.
What message format does MCP use on the wire?
#The handshake, message by message
Here are the four messages that actually flow between the agent's MCP client and your server when a tool gets called. Read the arrows: → is client-to-server, ← is server-to-client. Notice how each request and its response share the same id.
// → Agent's MCP client asks the server what tools exist
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
// ← Server advertises its tools (name + JSON schema)
{ "jsonrpc": "2.0", "id": 1, "result": {
"tools": [{
"name": "query_sales",
"description": "Aggregate revenue by customer or by month.",
"inputSchema": { "type": "object",
"properties": { "metric": { "type": "string" } } }
}]
} }
// → The model chose to call the tool with these arguments
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "query_sales",
"arguments": { "metric": "revenue", "groupBy": "customer", "limit": 3 } } }
// ← Server runs the query and returns the result content
{ "jsonrpc": "2.0", "id": 2, "result": {
"content": [{ "type": "text",
"text": "[{\"customer\":\"Northwind Traders\",\"total\":48210}]" }]
} }Message 1 — `tools/list` (→). The client's opening question: "What can you do?" It's a bare request — a method name and an id, no arguments needed.
Message 2 — the tool catalog (←). The server answers request 1 with a list of tools. Each entry carries a name, a description, and — crucially — an inputSchema. That schema is how the model learns how to call the tool: which arguments exist and what types they are. This is the tool advertising itself.
Message 3 — `tools/call` (→). Now the model has decided to act. It sends a new request with id: 2, method tools/call, and params holding the tool's name plus the concrete arguments it picked (metric: "revenue", groupBy: "customer", limit: 3). The model chooses when and with what; your server still decides what actually happens.
Message 4 — the result (←). The server runs the query and replies to request 2 with result.content — an array of typed content parts. Here it's a single text part carrying the JSON rows. The model reads that text and folds it into its answer.
Why the `id` matters
JSON-RPC is asynchronous — several requests can be in flight at once. The matching id on a request and its response is how the client knows which reply belongs to which question. Request 1 pairs with result 1; request 2 pairs with result 2.
In the handshake above, how does the model learn what arguments `query_sales` accepts?
Read it, don't run it
You won't hand-type these JSON-RPC messages in real life — the SDK serializes and matches them for you. The value here is being able to read the wire when you're debugging: if a tool call misbehaves, dumping the raw tools/call and its result almost always shows you exactly where things went sideways.
Key takeaways
- Under the hood, MCP is just JSON-RPC 2.0 messages — each with a `jsonrpc` version, an `id`, and a `method` (request) or `result` (response).
- The same messages ride over different transports: stdio for local servers, streamable HTTP/SSE for remote ones.
- The handshake is `tools/list` → server advertises tools with `name` + `inputSchema` → model sends `tools/call` with name + arguments → server returns `result.content`.
- The `inputSchema` from `tools/list` is what teaches the model how to construct a valid tool call.
- Matching `id` values pair each response with the request that asked for it.
Put the four JSON-RPC messages of a tool call in the order they travel across the wire.
→ client sends { "method": "tools/list" }← server returns the tool catalog (name + inputSchema)
← server returns { "result": { "content": [...] } }→ model sends { "method": "tools/call", "params": { name, arguments } }The client just sent this request. Which message will the server send back?
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "query_sales",
"arguments": { "metric": "revenue", "limit": 3 } } }Complete the server's reply. The client must be able to match this response to the request it answered.
// → { "jsonrpc": "2.0", "id": 7, "method": "tools/list" } // ← server responds { "jsonrpc": "2.0", "": 7, "result": { "tools": [] } }
This tools/call request fails — the model can't invoke the tool. What's wrong?
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "arguments": { "metric": "revenue", "limit": 3 } } }You've added a second tool to the sales-connector server called top_products, which takes one argument: limit (an integer). Trace the wire protocol by hand.
- Write the JSON-RPC request the client sends to discover tools.
- Sketch the entry for
top_productsas it would appear in the server'stools/listresult (name, description, and aninputSchemawith alimitinteger property). - Write the
tools/callrequest the model would send to fetch the top 5 products.
Use matching id values and correct arrow direction in comments.
Try it yourself — a starting point to build on:
# Write your solution here