The lifecycle of a session
An MCP session is not a bag of independent calls — it is a conversation with a strict opening, a working middle, and an end. The opening is where version and capabilities are agreed; skip understanding it and every "my server doesn't show up" bug looks like magic. This lesson walks the full arc using real frames from a Paper Trail session.
Phase one: initialize
The client speaks first:
→ {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "raw-client", "version": "0.1"}}}It proposes a protocol version and declares its own capabilities (this minimal client honestly declares none — a real host would advertise sampling, roots, elicitation). The server answers with its side of the contract:
← {"jsonrpc": "2.0", "id": 1, "result": {
"protocolVersion": "2025-06-18",
"capabilities": {"logging": {}, "prompts": {"listChanged": false},
"resources": {"subscribe": false, "listChanged": false},
"tools": {"listChanged": true}, "experimental": {}},
"serverInfo": {"name": "paper-trail", "version": "3.4.5"}}}This one frame answers four questions. Which spec revision governs the session (2025-06-18 — the server accepted the proposal; a server that cannot speak it counter-offers the version it can, and the client proceeds or disconnects). Which primitives exist at all — this server has tools, resources, and prompts, so the client need never probe for what was not declared. Which sub-features exist — tools.listChanged: true means "I will notify you if my tool list changes mid-session", while resources.subscribe: false means "don't ask to watch a resource for updates". And who is on the other end, for logs and debugging.
Capability negotiation is the load-bearing idea. It is why a two-year-old client and a brand-new server can hold a useful session — each side simply never uses what the other did not declare — and it is how the protocol evolves without flag days.
The client then closes the handshake with a notification — no id, no reply — after which, and only after which, normal traffic may flow:
→ {"jsonrpc": "2.0", "method": "notifications/initialized"}Phase two: discovery
The client now asks what is actually on offer — tools/list, and for a server that declared them, resources/list and prompts/list:
→ {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
← {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{
"name": "order_status",
"description": "Look up one order by its id (format PT-NNNN). Returns status, carrier, and ETA.",
"inputSchema": {"type": "object", "additionalProperties": false,
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
"outputSchema": {"type": "object", "additionalProperties": true}}]}}Everything the model will ever know about this tool is in that frame — the name, the prose description, the JSON Schema for arguments. The host renders it into context, and that rendering is the model's entire universe. Two consequences follow. First, quality of description is quality of tool use — lesson 5 treats the docstring as a load-bearing artifact, because it is one. Second, the model reads text the user never sees, which is the seed of the tool-poisoning attacks in lesson 13.
Discovery is why MCP beats hardcoded function calling on maintenance: add a tool to the server tonight and tomorrow's sessions discover it. No client changes, no redeploy — the capability list is data, fetched fresh each session.
Phase three: operation
The working middle: tools/call, resources/read, prompts/get — request, response, repeat, in any order, several in flight if the client likes. Alongside, notifications flow both ways: the server may push notifications/progress during a slow call (the client passed a progress token to ask for it), log messages, or notifications/tools/list_changed — at which point a good client re-runs discovery, because the list it cached is stale. Everything in sections 2 and 3 lives in this phase.
Phase four: shutdown
Undramatic by design: no goodbye method. For stdio, the client closes the server's stdin and the process exits (a stubborn one gets SIGTERM). For HTTP, the session is torn down or simply expires. What matters is what a server must assume: the session can end at any moment, between any two frames. A well-built server holds no state that matters if the lights go out mid-call — and lesson 9 returns to what that implies for tool design.
Reading real sessions yourself
Everything above came from a forty-line script that spawns a server and prints each frame — you will build its bigger sibling in lesson 10, and meet the point-and-click version (the MCP Inspector) in lesson 11. When a session misbehaves, the failure is almost always visible in the first three frames: a version mismatch in initialize, a capability never declared, or a tool description that does not say what you think it says. Look at the wire before you theorise.
Try this: from your host's logs (Claude Desktop and Cursor both expose MCP logs), find one real initialize response and read its capabilities object line by line. Every later lesson gets easier once you have decoded one handshake from the wild.