How MCP WorksIntermediate6 min05 / 10

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.

Think of it like

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.

Quick check

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.

tools-call.jsonrpc
// → 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.

Note

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.

Quick check

In the handshake above, how does the model learn what arguments `query_sales` accepts?

Tip

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.
Try it yourself · The JSON-RPC handshake
Step through the messages that flow between the client and your server.
MCP ClientJSON-RPC 2.0MCP Server
tools/list
What tools do you have?
message 1 / 4
Practice challenges
Test yourself · earn XP
0/4
Reorder the lines#1

Put the four JSON-RPC messages of a tool call in the order they travel across the wire.

1
→ client sends { "method": "tools/list" }
2
← server returns the tool catalog (name + inputSchema)
3
← server returns { "result": { "content": [...] } }
4
→ model sends { "method": "tools/call", "params": { name, arguments } }
Predict the output#2

The client just sent this request. Which message will the server send back?

predict-output
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
  "params": { "name": "query_sales",
    "arguments": { "metric": "revenue", "limit": 3 } } }
Fill in the blank#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": [] } }
Fix the bug#4

This tools/call request fails — the model can't invoke the tool. What's wrong?

fix-bug
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
  "params": { "arguments": { "metric": "revenue", "limit": 3 } } }
Your turn
Practice exercise

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.

  1. Write the JSON-RPC request the client sends to discover tools.
  2. Sketch the entry for top_products as it would appear in the server's tools/list result (name, description, and an inputSchema with a limit integer property).
  3. Write the tools/call request 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:

starter.py
# Write your solution here