The Three Stages
Every MCP connection that has ever existed moves through exactly three stages, in exactly this order. No exceptions.
Initialization
Client and server meet, agree on a version, and declare what they can do.
Operation
The normal back-and-forth — discovering what's available, then calling things.
Shutdown
The connection ends, cleanly or otherwise.
Initialization is the handshake and introductions. Operation is the actual conversation. Shutdown is saying goodbye and leaving the room — and just like real life, you can't skip straight to the conversation before anyone's been introduced.
"Every MCP connection that has ever existed followed exactly these three stages, in exactly this order. No exceptions."
Live Demo Setup
Claude Desktop's own native logging — corrected against a real captured log, no jq required.
Claude Desktop's log only shows a summary line per message (method, id, whether params/result are present) — not the full JSON body. An earlier jq-based version of this command assumed full JSON and failed. This version matches reality.
Add Filesystem to Claude Desktop:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/folder"]
}
}
}
Watch every connected server live (macOS):
tail -n0 -f "$HOME/Library/Logs/Claude"/mcp-server-*.log | awk '
/Message from client:/ {
t = substr($1, 12, 8); srv = $2; gsub(/[\[\]]/, "", srv);
sub(/^.*Message from client: /, ""); sub(/ \{ metadata:.*$/, "");
printf "\033[1;33m%s [%s] CLIENT -> SERVER\033[0m %s\n", t, srv, $0; next
}
/Message from server:/ {
t = substr($1, 12, 8); srv = $2; gsub(/[\[\]]/, "", srv);
sub(/^.*Message from server: /, ""); sub(/ \{ metadata:.*$/, "");
printf "\033[1;36m%s [%s] SERVER -> CLIENT\033[0m %s\n", t, srv, $0; next
}
'
No jq needed — pure awk, works with macOS's built-in version. Watches every connected server at once and tags each line with which one it came from.
That file is only created once Claude Desktop actually connects to a server with that exact name. Fully quit (Cmd+Q, not just closing the window) and reopen Claude Desktop after saving your config. The watch-mcp-logs.sh script in this course's files diagnoses this and waits automatically.
No log-tailing setup at all? MCP Inspector (npx @modelcontextprotocol/inspector) shows the same traffic in a browser, with full JSON bodies.
Real Servers Reference
Servers you can actually connect to today, verified current.
Local (stdio)
| Server | What it does | Run it |
|---|---|---|
| Filesystem | Read/write files in allowed directories | npx -y @modelcontextprotocol/server-filesystem <dir> |
| Everything | All 3 primitives — tools, resources, prompts | npx -y @modelcontextprotocol/server-everything |
| Git | Status, diff, log on a local repo | uvx mcp-server-git |
| Memory | Persistent knowledge-graph across sessions | npx -y @modelcontextprotocol/server-memory |
| Fetch | Fetch a URL, convert to markdown | uvx mcp-server-fetch |
| Time | Timezone conversion | uvx mcp-server-time |
Remote (HTTP)
| Server | URL | Auth |
|---|---|---|
| DeepWiki | https://mcp.deepwiki.com/mcp | None — free, no signup |
| Semgrep | https://mcp.semgrep.ai/mcp | None for public surface |
"DeepWiki publishes both the old SSE URL and the new Streamable HTTP URL for the same service — point at both, and you're watching the transport migration this course describes, live, on a real production system."
JSON-RPC Basics
Every message — in either direction — shares one shape.
{ "jsonrpc": "2.0", "id": 1, "method": "search_recipes", "params": { "tag": "quick" } }
{ "jsonrpc": "2.0", "id": 1, "result": ["Weeknight Pasta", "Five-Minute Salsa"] }
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method not found" } }
| Field | What it does |
|---|---|
jsonrpc | Always "2.0" — the shared format version |
id | Matches a response/error to the exact request |
method | What's being asked for (requests only) |
params / result / error | The payload — exactly one, never both |
- Lightweight — plain JSON, human-readable at a glance
- Transport-agnostic — same shape over stdio or HTTP
- Two-way by design — either side can send a request
- Notifications built in — no id, fires and expects nothing back
Just like HTTP has 404 and 500, JSON-RPC has its own small set of standard codes — MCP inherits them wholesale rather than inventing new ones.
"JSON-RPC isn't MCP's invention — it's MCP borrowing a format that was already simple, already two-way, and already boring in the best possible sense."
The Handshake
Three steps, every time — plus the two rules that protect them.
The client speaks first, sending exactly three things.
{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": { "roots": { "listChanged": true } },
"clientInfo": { "name": "RecipeBoxDesktopClient", "version": "1.0.0" }
}
}
"This is the very first sentence of the entire relationship. Everything after this assumes both sides heard it correctly."
The server answers, matching the same id, with its own three things.
{
"jsonrpc": "2.0", "id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": { "tools": { "listChanged": true } },
"serverInfo": { "name": "RecipeBox", "version": "1.0.0" }
}
}
Same id: 1 — the matched reply.
The client seals the handshake with a notification — no reply expected.
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
No id — an announcement, not a question.
"Three messages. One sentence each: 'here's who I am,' 'here's who I am back,' 'good, let's begin.' That's the entire handshake."
| Rule | In plain terms |
|---|---|
| Client SHOULD NOT send anything but a ping before the server responds to initialize | No jumping ahead |
| Server SHOULD NOT send anything but a ping/log line before receiving initialized | The server waits for step 3 |
"Skip either rule and you're not talking to a stranger politely — you're talking over them."
Version Negotiation
What actually happens when client and server don't agree the first time.
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "...": "..." } }
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "...": "..." } }
What the client does next:
SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2024-11-05"] if server_version in SUPPORTED_PROTOCOL_VERSIONS: send_initialized_notification() # proceed else: disconnect() # no further communication
"Version negotiation isn't a retry loop. It's exactly one counter-offer, checked exactly once, with disconnect as the only fallback."
Capability Negotiation
Both sides declare exactly what they're each capable of — an honest menu, both ways.
| Category | Capability | What it means |
|---|---|---|
| Client | roots | Grants server access to specific directories |
| Client | sampling | Lets the server borrow the client's own AI model |
| Server | tools | Exposes callable tools |
| Server | resources | Provides readable resources |
| Server | prompts | Offers prompt templates |
| Server | logging | Emits structured log messages back |
roots, sampling, and logging are being formally deprecated in the newest protocol revision (SEP-2577).
A real, observed case: a client asks for something the server never declared.
{ "jsonrpc": "2.0", "id": 99, "method": "prompts/list" }
{ "jsonrpc": "2.0", "id": 99, "error": { "code": -32601, "message": "Method not found" } }
The server doesn't crash — it just says, correctly, "I never told you I could do that."
"Capability negotiation is both sides handing over an honest menu before anyone orders."
Discovery & Calling
Discovery fires automatically the instant the handshake completes — before the user even asks a question.
Part 1 — Discovery
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
{ "jsonrpc": "2.0", "id": 2, "result": { "tools": [
{ "name": "list_recipes" }, { "name": "get_recipe" }, { "name": "search_recipes" }
]}}
Part 2 — Calling
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "search_recipes", "arguments": { "tag": "quick" } } }
{ "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "Weeknight Pasta, Five-Minute Salsa" }] } }
"Discovery isn't something you triggered. It's something that already happened, quietly, the moment the handshake finished."
Transport Layer
The road every message above actually travelled on.
- Client launches the server as a subprocess
- Server reads stdin, writes stdout — newline-delimited
- stderr is free for logging
- No separate auth layer — credentials from the environment
Practical proof, live:
python3 -c "
import subprocess, json
proc = subprocess.Popen(['python3','server.py'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
proc.stdin.write(json.dumps({'jsonrpc':'2.0','id':1,'method':'initialize',
'params':{'protocolVersion':'2025-11-25','capabilities':{},'clientInfo':{'name':'x','version':'1'}}}) + '\n')
proc.stdin.flush()
print(proc.stdout.readline())
"
"There's no hidden magic in stdio. It's a subprocess, a pipe in, a pipe out, and a newline between messages."
- One endpoint (commonly
/mcp), POST + GET - Plain JSON or upgrades to SSE for long calls
- Optional
Mcp-Session-Idheader - Replaces the older, deprecated HTTP+SSE transport
Point at DeepWiki's /mcp and /sse — same service, both transports, side by side.
"Streamable HTTP is the same conversation as stdio, wearing a different coat."
Shutdown
The one phase with no message format of its own.
No JSON-RPC message is exchanged during shutdown at all. The entire responsibility shifts to the transport layer.
| Transport | Client-initiated (common) | Server-initiated (rare) |
|---|---|---|
| stdio | Close stdin, wait; SIGTERM if it doesn't; SIGKILL as last resort | Server closes its output stream and exits |
| Streamable HTTP | Close the HTTP connection | Server closes unexpectedly — client should reconnect gracefully |
SIGTERM is a polite knock — "please finish up and leave." SIGKILL is the door being forced open — no chance to clean up.
"Shutdown has no goodbye message of its own. The transport closing IS the goodbye."
Pings
Checking whether anyone's still there.
{ "jsonrpc": "2.0", "id": 42, "method": "ping" }
{ "jsonrpc": "2.0", "id": 42, "result": {} }
A quiet connection can get silently dropped by a firewall or proxy that assumes "no traffic" means "not needed." Periodic pings keep that from happening.
"A ping carries no information at all. That's the whole point — a knock on the door, not a conversation."
Error Handling
When it happens, and what it looks like.
- Protocol version mismatch during initialization
- Calling a method never negotiated
- Invalid arguments to a real tool
- An internal failure on the server's side
- A timeout being exceeded
- A syntactically malformed JSON-RPC message
{ "jsonrpc": "2.0", "id": 4, "error": {
"code": -32602, "message": "Unsupported protocol version",
"data": { "supported": ["2025-11-25"], "requested": "1.0.0" }
}}
| Code | Name | Cause |
|---|---|---|
| -32700 | Parse error | Unreadable JSON |
| -32600 | Invalid request | Well-formed, not valid |
| -32601 | Method not found | Never advertised |
| -32602 | Invalid params | Wrong/missing args |
| -32603 | Internal error | Server logic failed |
| -32000+ | Server-defined | Custom to that server |
"An error isn't a crash. It's the exact same message envelope as a success, just carrying different news."
Timeouts
- Every sent request should have a timeout
- Hit with no response → send a cancellation notification, stop waiting
- A progress notification may reset the clock — a hard maximum should still apply
"A timeout isn't giving up. It's refusing to wait forever for an answer that might genuinely never come."
Cancellation
{ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "name": "search_recipes", "arguments": { "tag": "sunday" },
"_meta": { "progressToken": "tok-7" } } }
{ "jsonrpc": "2.0", "method": "notifications/cancelled",
"params": { "requestId": "7", "reason": "Timeout exceeded (30s)" } }
No id — a notification referencing the original request by its requestId.
"Cancellation doesn't ask permission. It announces a decision that's already been made."
Progress Notifications
Let the client know a long-running request is still alive.
{ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "name": "search_recipes", "arguments": { "tag": "sunday" },
"_meta": { "progressToken": "tok-7" } } }
{ "jsonrpc": "2.0", "method": "notifications/progress",
"params": { "progressToken": "tok-7", "progress": 60, "total": 100,
"message": "Searching 60 of 100 recipes" } }
"Progress notifications are how a slow answer stays honest instead of just going quiet."