JSON-RPC, transports, and the connection lifecycle.
MCP Servers Module 1
MCP is JSON-RPC 2.0 underneath. Every request is a JSON object with three load-bearing fields:
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
id — correlates request ↔ response. The client picks it; the server echoes it back. Without it, you can't match async replies to the call that triggered them.method — what you're asking for. Namespaced: tools/*, resources/*, prompts/*, initialize.params — the call's arguments. A JSON object (sometimes empty); the shape depends on the method.The handful of methods you'll see on the wire every day:
| Method | Params shape | Who calls it |
|---|---|---|
initialize | {protocolVersion, capabilities, clientInfo} | client → server (first message) |
tools/list | {} | client → server |
tools/call | {name, arguments} | client → server |
resources/list | {} | client → server |
resources/read | {uri} | client → server |
prompts/list | {} | client → server |
prompts/get | {name, arguments} | client → server |
Every response carries the same id the request used. It is either a result or an error — never both.
{"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}
{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"invalid params"}}
-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error.-32000). For example, a server-defined tool error attaches its own code so the client can branch on it.A notification is a JSON-RPC message without an id. The peer must not reply.
{"jsonrpc":"2.0","method":"notifications/initialized"}
notifications/initialized — client tells the server the handshake is done; the server can now accept normal requests.notifications/cancelled — either side withdraws an in-flight request by id.notifications/progress — periodic progress update tied to a long-running request.notifications/roots/list_changed — client tells the server its filesystem roots changed.Every MCP session walks through four phases, in order:
The first round-trip of every session is initialize. Client and server each declare which optional features they support; everything that follows is constrained by that intersection.
// client → server
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{
"protocolVersion":"2026-05-01",
"clientInfo":{"name":"claude-desktop","version":"1.4.0"},
"capabilities":{"roots":{"listChanged":true},"sampling":{}}
}}
// server → client
{"jsonrpc":"2.0","id":0,"result":{
"protocolVersion":"2026-05-01",
"serverInfo":{"name":"pkb","version":"0.1.0"},
"capabilities":{"tools":{"listChanged":true},"resources":{},"prompts":{}}
}}
The server is a subprocess of the client. The client spawns it, writes JSON-RPC requests to the server's stdin, and reads responses from its stdout. stderr is for logs.
The client owns the lifecycle: when the client exits, the OS reaps the server. There is no networking, no auth, no port — the security boundary is the process boundary.
Default choice for local dev and per-user installations like Claude Desktop.
→ {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\n
← {"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}\n
→ {"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"mcp"}}}\n
← {"jsonrpc":"2.0","id":2,"result":{...}}\n
The original remote transport. The client opens a long-lived Server-Sent Events stream against one endpoint to receive messages, and POSTs requests to a separate endpoint. Two URLs, two connections, kept in sync by a session id.
The 2025+ standard for remote MCP servers. One HTTP endpoint handles everything: regular request/response over POST, and an optional SSE upgrade on the same URL when the server needs to stream. Simpler to deploy, simpler to authenticate, friendlier to serverless and load balancers.
If you are writing a new remote server in 2026, this is the transport you ship.
| Use case | Transport | Why |
|---|---|---|
| Local dev / Claude Desktop | stdio | client owns lifecycle, no networking |
| One server, many users, intranet | Streamable HTTP | clean URL, easy auth header |
| Legacy clients only speak SSE | HTTP + SSE | back-compat |
MCP errors are JSON-RPC errors with structured codes — not free-form text. That structure is the contract: the client can decide its retry policy by looking at the code alone, without parsing English.
The server's job is determinism: given the same call, return the same error code. Don't retry on the server side — surface the failure and let the client choose. If a tool is idempotent, say so in its description; the client may then retry safely on transport-level failures.
The @modelcontextprotocol/inspector is the protocol-level debugger. Point it at any MCP server (stdio or HTTP), and it gives you a UI to call tools, read resources, and watch the wire:
npx @modelcontextprotocol/inspector \
npx -y @modelcontextprotocol/server-filesystem /tmp/mcp-demo
tools/, resources/, prompts/.initialize is where both sides declare their capabilities.