MCP Architecture: Clients, Servers, Transports

The Kitebase server from article 01: What Is MCP? works on your machine. Then two reports come in. A teammate’s host lists it as failed, with a JSON parse error pointing at the text Kitebase server starting.... Another teammate, on an older host, gets an error about initialize, a word that appears nowhere in your code.

Both problems live below your tools, in the messages the host and server exchange. To fix them you need to see what’s on the wire: the exact messages, how the two sides agree on what they support, and how the bytes travel.

What you’ll build: a hand-rolled MCP client of about 100 lines that launches the Kitebase server and prints every message that crosses the pipe, from discovery to looking up ticket KITE-142 to shutdown. It runs offline, with no API keys.

Where the messages flow

Article 01 introduced the roles. On the wire, what matters is how they pair up: the host (Claude Desktop, Claude Code, your own app) creates one client per configured server, and each client holds one connection. A crash in one server can’t take down another, so when a server goes missing, check that one connection first.

A local server runs on your machine as a subprocess of the host and usually serves one client. A remote server runs elsewhere and serves many. Only the transport differs.

Every message is JSON-RPC

Clients and servers talk in JSON-RPC 2.0, a small standard for calling functions by sending JSON. Nothing about it is specific to AI, so any language with a JSON library can speak it.

There are three kinds of message:

  • A request has an id and a method, and expects exactly one reply.
  • A response echoes that id with either a result or an error.
  • A notification has a method but no id. Nobody replies to it.

Here’s a real request from the companion example, asking the Kitebase server to run get_ticket:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_ticket",
    "arguments": { "ticket_id": "KITE-142" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "kitebase-demo-client", "version": "0.1.0" },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

And the reply, trimmed. The ticket comes back as JSON text inside a content block:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{ "type": "text", "text": "{\n  \"id\": \"KITE-142\",\n  \"title\": \"Customer locked out after SSO change\", ..." }],
    "isError": false,
    "resultType": "complete"
  }
}

The id is how the client pairs a reply with its request. A client can have several requests in flight, and a slow one can finish after a fast one, so replies are matched by id, never by arrival order. MCP adds one rule to plain JSON-RPC: the id must be a string or an integer, never null. resultType: "complete" marks a final answer.

When the request itself is wrong, the reply has an error instead of a result: an integer code, a message, and sometimes data. The codes worth knowing:

CodeMeaning
-32602Invalid params, including a request with no _meta
-32601Method not found
-32022Unsupported protocol version; data.supported lists the ones the server speaks
-32021The request needs a client capability the client didn’t declare
-32020HTTP only: a header doesn’t match the body

A tool that runs and fails (ticket KITE-999 doesn’t exist) is not a protocol error. It’s a normal result with isError: true, as article 01 showed, so the model can read the message and try again.

Why JSON-RPC and not REST?

REST maps operations onto URLs and HTTP verbs, so it only works over HTTP. MCP also has to work over a pipe to a subprocess, where there are only lines of text.

A JSON-RPC message carries everything inside itself: method, arguments, id. The same tools/call goes down a pipe or into an HTTP POST body unchanged. It also has notifications built in, for one-way messages like “cancel that request”.

The conversation, message by message

A client that knows nothing about a server can learn everything it needs in three requests. The companion example sends exactly these:

CLIENT main.py SERVER server.py id 1 server/discover optional _meta: protocolVersion "2026-07-28", clientInfo, clientCapabilities {} id 1 result supportedVersions ["2026-07-28"], capabilities {tools, resources, prompts} id 2 tools/list _meta: the same three fields again id 2 result tools [search_help, {name "get_ticket", inputSchema {ticket_id: string}}] id 3 tools/call name "get_ticket", arguments {ticket_id: "KITE-142"}, _meta again id 3 result content [{type "text", text: the KITE-142 ticket as JSON}] closes stdin server exits, code 0 No handshake first. The server remembers nothing between requests, so each one carries its own version and capabilities, and replies match by id.
Every line the companion example sends and receives, trimmed.
  1. server/discover: “which protocol versions do you speak, and what can you do?” Every server on the current revision must answer it. Clients may skip it and go straight to other requests.
  2. tools/list: “what tools do you have?” The reply has each tool’s name, description and input schema, the JSON Schema for its arguments.
  3. tools/call: “run get_ticket with these arguments.”

The reply to server/discover looks like this (trimmed):

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "supportedVersions": ["2026-07-28"],
    "capabilities": {
      "prompts": { "listChanged": true },
      "resources": { "listChanged": true, "subscribe": true },
      "tools": { "listChanged": true }
    },
    "ttlMs": 0,
    "cacheScope": "private",
    "resultType": "complete",
    "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "kitebase", "version": "" } }
  }
}

ttlMs and cacheScope are caching hints: how long the client may reuse this answer (0: ask again whenever you need it) and whether it may share it across users. You never write any of this in a server; the SDK generates it from the functions in server.py.

_meta: every request stands alone

Every request the client sent carried the same three _meta fields. That’s because MCP is stateless: the server keeps no memory of earlier requests, even ones from the same pipe a millisecond ago, so each request says which protocol version it’s written in and what the client supports.

The version and capabilities are required; send a tools/call without them and the Kitebase server replies -32602, naming the two missing keys. clientInfo is optional, for logs. The companion builds the block with key names from the SDK:

from mcp.types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY
from mcp.types.version import LATEST_MODERN_VERSION  # "2026-07-28"

def envelope(version: str = LATEST_MODERN_VERSION) -> dict:
    return {
        PROTOCOL_VERSION_META_KEY: version,
        CLIENT_INFO_META_KEY: CLIENT_INFO,
        CLIENT_CAPABILITIES_META_KEY: {},
    }

Why go stateless? A server that remembers nothing can run as ten copies behind a load balancer (a proxy that spreads requests across copies of a service), and any copy can answer any request. A server that remembers a session needs every request from one client routed to the same copy, and forgets everything if that copy restarts.

Capabilities: what each side can do

A capability is an optional feature one side says it supports. There are two sets.

The server’s capabilities come back from server/discover. Kitebase has tools, resources and prompts, and can report when those lists change. The client’s rule: don’t call what wasn’t advertised. A server with no prompts key won’t answer prompts/list.

The client’s capabilities travel in _meta on every request, and {} means “nothing optional”. A client that can ask the user a follow-up question for the server declares "elicitation": {}. The server must not rely on anything the client didn’t declare on that request; if it needs something missing, it replies -32021 and names it.

Versions: agreed per request

With no handshake, version agreement happens on every request. Run python main.py --version 2099-01-01 and each request comes back as:

{"jsonrpc":"2.0","id":3,"error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28"],"requested":"2099-01-01"}}}

The client picks a version from supported and retries. That’s the whole negotiation.

The old way: the initialize handshake

That initialize error from the opening comes from the previous design. Before revision 2026-07-28, every connection opened with a handshake, and hosts and servers written before then still speak the last of those revisions, 2025-11-25. Run python main.py --legacy to see it:

client -> server
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {...}}}

server -> client
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{...},"protocolVersion":"2025-11-25","serverInfo":{"name":"kitebase","version":""}}}

client -> server
{"jsonrpc": "2.0", "method": "notifications/initialized"}

... tools/list and its reply ...

client -> server
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_ticket", "arguments": {"ticket_id": "KITE-142"}}}

The client proposes a version and its capabilities, the server answers with its own, and the client sends notifications/initialized (no id, so no reply) to say it’s ready. After that, requests carry no _meta: the connection remembers what was agreed.

2025-11-25 AND EARLIER: HANDSHAKE client server initialize protocolVersion "2025-11-25" result protocolVersion, capabilities notifications/initialized no id, so no reply tools/call name, arguments (no _meta) result Agreed once, at the start. The connection is the session. 2026-07-28: EVERY REQUEST CARRIES _META client server tools/call _meta protocolVersion "2099-01-01" error -32022 data.supported ["2026-07-28"] tools/call (retry) _meta protocolVersion "2026-07-28" result content, resultType "complete" Checked on every request. Any request can go to any server process.
The same Kitebase server, spoken to both ways.

The Kitebase server answers both ways because SDK v2 serves both: if the first thing it sees is initialize, it behaves like a 2025-11-25 server. Clients that must work with either kind probe first. The official Python client sends server/discover and falls back to initialize if the reply isn’t a recognisable modern answer or error.

So the teammate’s initialize error means their host speaks only the old revision, and the handshake failed. A server built with SDK v2 accepts it. A server that only speaks 2026-07-28 rejects it, and should name the versions it does support in that error, since an old host has no other way to find out.

Transports: how the messages travel

A transport is the channel the JSON-RPC messages move over. MCP defines two, and the message itself is identical on both.

The same tools/call JSON, carried two ways STDIO: A SUBPROCESS AND A PIPE HOST launches the server itself SERVER python server.py stdin requests, one per line stdout replies, MCP only stderr logs, not protocol Only the host can reach the pipe: no ports, no login. Credentials come from env vars the host sets. STREAMABLE HTTP: ONE ENDPOINT CLIENT in the host SERVER /mcp POST /mcp MCP-* headers + JSON application/json one reply text/event-stream progress, then the reply Anyone with the URL can reach it: check Origin, bind to 127.0.0.1 locally, require auth (OAuth).
Same JSON, different envelope.

stdio: a subprocess and a pipe

With stdio, the host launches the server as a child process and talks through the process’s standard streams. Requests go to the server’s stdin, replies come from its stdout, and each message is one line of JSON with no newlines inside. That’s why the companion’s send is so short:

def send(self, message: dict) -> str:
    line = json.dumps(message)  # one message per line, no newlines inside
    self.proc.stdin.write(line + "\n")
    self.proc.stdin.flush()
    return line

Three rules follow:

  • stdout carries only MCP messages. The spec says a server must not write anything else there.
  • stderr is for logs. The host may show, save or ignore it, and never parses it as protocol. The companion’s copy of server.py logs get_ticket called with 'KITE-142' there.
  • Shutdown is closing stdin. The client closes the server’s input, waits for it to exit, and kills it only if it hangs. The companion ends every run with closed stdin, server exited with code 0.

Now the first teammate’s error makes sense. Run python main.py --noisy, which makes the server print one line to stdout at startup:

Connection broken: server wrote something that isn't JSON-RPC to stdout: 'Kitebase server starting...'

When the Python SDK’s client meets a line like that, it logs a parse error and skips it. The teammate’s host dropped the connection instead. The culprit isn’t always your own print; a library can print a banner or warning on import. Run the server by hand, the way the host does, and look at the first thing on stdout.

Only the host can reach the pipe, so stdio needs no ports and no login. Secrets like a Kitebase API token come from environment variables the host sets when it launches the process.

Streamable HTTP: one endpoint, one POST per message

With Streamable HTTP, the server is a long-running web service with a single endpoint, usually /mcp. Every request is its own HTTP POST with the JSON-RPC message as the body. Start the server with python server.py --http and call it with curl:

curl -X POST http://127.0.0.1:8765/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: get_ticket' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_ticket","arguments":{"ticket_id":"KITE-142"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
HTTP/1.1 200 OK (trimmed)
content-type: application/json

{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"{\n  \"id\": \"KITE-142\", ...","type":"text"}],"isError":false,...}}

The JSON is the same as over stdio. HTTP adds three things.

Headers that repeat the body. MCP-Protocol-Version, Mcp-Method and, for tool calls, Mcp-Name copy fields from the JSON so load balancers and gateways can route without parsing it. They must match: leave out Mcp-Name and the Kitebase server returns 400 Bad Request with -32020, “mcp-name header does not match the request body’s ‘name’ parameter”.

Two reply shapes. The server answers with either one JSON object or a stream of server-sent events (SSE), a standard way to send several messages down one HTTP response. The stream carries notifications about that request, such as progress, and then the final reply. The client’s Accept header has to allow both.

Security is your job. Anyone who can reach the URL can call it. The server must check the Origin header, so a web page in someone’s browser can’t call a server on their laptop, should bind to 127.0.0.1 when local, and should require auth. MCP’s authorization spec is built on OAuth, the standard behind “Sign in with Google” buttons.

An Mcp-Session-Id header belongs to the 2025-11-25 version of this transport. The current one has no sessions, for the same reason as _meta.

Default to stdio for a server on your own machine: nothing to deploy, and you debug your tool logic instead of a web server. Switch to HTTP when many people need one shared server, like Kitebase hosting a single server at https://mcp.kitebase.example/mcp for all its customers.

The life of a connection

Here’s the Kitebase server from launch to shutdown, as a host runs it. (The model’s side of a tool call, where the host translates tools and results for Claude, is in article 01.)

  1. Launch. Claude Desktop runs the command from its config, say python server.py, and holds the child’s stdin and stdout.

  2. Discover. The client sends server/discover. A modern server answers; an old one errors or stays silent, and the client falls back to initialize.

  3. List. tools/list returns search_help and get_ticket, which the host offers to the model.

  4. Call. You ask about KITE-142, the model asks for get_ticket, and the client sends tools/call with id 3. If the client asked for progress updates, the server can send notifications while it works.

  5. Cancel, sometimes. If you press stop first, the client sends a notification and ignores any late reply:

    {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 3, "reason": "user pressed stop"}}
    

    On HTTP there’s no such message; closing that request’s SSE stream is the cancellation.

  6. Reply. The server returns a result for id 3, or isError: true if the tool failed, or a JSON-RPC error if the request was malformed.

  7. Shut down. When you quit, the client closes stdin and waits. If the server crashes instead, the client restarts it. Since nothing was stored in the connection, it can simply resend whatever was in flight.

In a real host you write none of this protocol code. The official client does steps 2 to 7:

from mcp import Client, StdioServerParameters

params = StdioServerParameters(command=sys.executable, args=[str(SERVER)])
async with Client(params) as client:  # launches the server and probes it
    print("negotiated protocol version:", client.protocol_version)
    tools = await client.list_tools()
    print("tools:", [tool.name for tool in tools.tools])
    result = await client.call_tool("get_ticket", {"ticket_id": TICKET})
    print("is_error:", result.is_error)
    print("result:", result.content[0].text)

python main.py --sdk prints:

get_ticket called with 'KITE-142'
negotiated protocol version: 2026-07-28
tools: ['search_help', 'get_ticket']
is_error: False
result: {
  "id": "KITE-142",
  "title": "Customer locked out after SSO change",
  ...

The first line is the server’s stderr log, passed through to your terminal and never mixed into the protocol.

Try it yourself

The companion example is a copy of the article 01 Kitebase server with two extra switches, plus the hand-rolled client that printed every message in this article.

Download the runnable example (zip)

cd 02-mcp-architecture
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python main.py

Then try these:

  1. python main.py --legacy. Compare it with the default run: an extra round trip at the start, a notification with no reply, and no _meta on later requests.
  2. python main.py --noisy. The client stops with “isn’t JSON-RPC”. Find the print in server.py, add file=sys.stderr, and run it again.
  3. python main.py KITE-999. The protocol succeeds and the tool fails: a result with isError: true, not an error. Then python main.py --version 2099-01-01 for the opposite, a protocol error on every request.

pip install pytest && pytest -q runs the tests. They start the server as a local subprocess and need no keys and no network.

Common beginner mistakes

  • Printing to stdout in a stdio server. One stray line corrupts the stream. Log to stderr.
  • Matching replies by order. Replies to concurrent requests can arrive in any order. Match on id.
  • Assuming the server remembers you. On 2026-07-28, every request needs its own _meta. Code written for the handshake that skips it gets -32602.
  • Following an old tutorial with the new SDK. mcp v2 replaced FastMCP with MCPServer (from mcp.server import MCPServer). An error importing mcp.server.fastmcp means v1 code met v2.
  • Putting an HTTP server online with no auth. A stdio server can only be reached by its host. An HTTP one can be reached by anyone who finds the URL.

Questions you will face in production

“The server runs fine in my terminal, but the host says it failed to connect.” Run it with the exact command and interpreter from the host’s config and look at the first line on stdout. If it isn’t JSON, that’s the bug. If it’s clean, check the host’s log for initialize: an old host fails there against a server that only speaks the new revision.

“stdio or HTTP for a tool the whole team uses?” HTTP. With stdio, everyone runs and updates their own copy with their own credentials. A shared HTTP server is one deployment, with real auth in front of it.

Check your understanding

A reply comes back as {"jsonrpc":"2.0","id":7,"error":{"code":-32602,...}} complaining about _meta. What went wrong, and who fixes it?

The client sent a request without the required protocol version and capabilities in _meta, probably because it was written for the handshake era. It’s a client bug: attach _meta to every request, or use the official client, which does.

A tools/call for KITE-999 returns a result with isError: true. A tools/call with protocolVersion "2099-01-01" returns an error with code -32022. Which one does the model see?

The first. A tool failure is a normal result, and the host passes its message to the model so it can fix the input and retry. A protocol error means the request itself was wrong; the client handles it (here, by retrying with a supported version) and the model never sees it.

You want three copies of the Kitebase HTTP server behind a load balancer. Why is that easy on 2026-07-28 and awkward on 2025-11-25?

On 2026-07-28 every request carries its own version and capabilities, so any copy can answer it. On 2025-11-25 the handshake created a session in one copy’s memory (the Mcp-Session-Id header), so every later request from that client had to reach that copy.

What to remember

  • A host runs one client per server, each on its own connection. Check that one connection first when a server goes missing.
  • Every message is JSON-RPC 2.0: requests have an id, replies echo it, notifications have none. Match replies by id.
  • On 2026-07-28 there’s no handshake. Every request carries its version and capabilities in _meta, and server/discover tells you what a server supports.
  • Older hosts and servers use initialize and notifications/initialized. The official client probes and falls back; SDK v2 servers answer both.
  • stdio for local servers: stdout is the protocol, logs go to stderr, closing stdin shuts it down. Streamable HTTP for shared servers, with headers that match the body and real auth.
  • A tool failure is a result with isError: true. A broken request is a JSON-RPC error.

What to study next

You’ve watched the protocol from the client’s side. Article 03: Setting Up Your First MCP Server switches to the server’s side: you build the Kitebase server from scratch, add search_tickets and create_ticket, test it without a model, and connect it to Claude Desktop and Claude Code, which then send the same messages you printed here.

Further reading

Where this article comes from. This is a synthesis of the MCP specification and common practice as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.


Auto-marks when you reach the end. Click to toggle.