The MCP Protocol
Client/host/server architecture, transports, tools vs resources vs prompts, lifecycle.
Prerequisites
- Tool Calling — MCP is a distribution and lifecycle layer around tool calling, not a replacement for how the model emits structured calls.
- Agent Foundations — the agent loop is what mounts MCP tools and consumes their results as observations.
- Agent Reliability — human approval, tool hallucination, and error compounding all apply once MCP tools enter the loop.
The intuition
Think of MCP the way USB-C works for peripherals. Before a common connector, every laptop brand needed a custom cable for every accessory — N devices × M accessories meant a drawer full of adapters. USB-C made each side implement one plug: any host talks to any device. MCP does the same for LLM applications and external capabilities. The host (Claude Desktop, an IDE, your agent product) is the laptop; each MCP server is a peripheral that exposes tools, documents, or prompt templates; the protocol is the shared plug and handshake.
The critical subtlety is that the plug is not the same as the model’s native function-calling API. Function calling is how the model asks for a tool inside one vendor’s chat API. MCP is how the application discovers, connects to, and talks to tool providers that may live in other processes or on other machines. Once the host has a tool list from MCP, it usually flattens those definitions into ordinary function-calling schemas for the model. The model still never speaks JSON-RPC directly.
Key insight
MCP standardizes the boundary between app and tool provider (discovery, transport, lifecycle, capability negotiation). It does not replace model-side function calling — it feeds it. The N×M integration problem collapses to N+M only when both sides speak the same protocol.
Why it exists
Without a shared protocol, every AI product reinvents connectors.
- The integration matrix explodes. N applications each needing custom adapters to M tools means N×M bespoke integrations — five agent apps × Jira, GitHub, Postgres, Slack is twenty adapters, twenty auth stories, twenty places to fix the same bug.
- Runtime discovery is hard. Hard-coded function lists in each app cannot pick up a new server shipped by another team, or tools that appear and disappear during a session.
- Trust and isolation need a process boundary. In-process tool functions share memory and credentials with the host. Separate server processes (local subprocess or remote) give the host a place to enforce consent, sandboxing, and per-server sessions.
- Transports differ by deployment. A filesystem server on the developer’s laptop wants stdin/stdout with no network surface; a multi-tenant ticket service wants HTTP, sessions, and OAuth. One protocol must cover both without forking the conceptual model.
- The protocol must evolve without breaking the world. Optional capabilities and date-based version negotiation let features ship incrementally — sampling, elicitation, list-changed notifications — without forcing every client and server to upgrade on the same day.
Plain function calling loses on reuse and third-party supply: it is the right choice inside one app you fully own. Custom REST or gRPC bridges lose on interoperability — every host reimplements discovery, lifecycle, and error semantics. MCP is the USB-C / Language Server Protocol move for this domain: write the server once; any MCP host can use it.
The core idea
The Model Context Protocol (MCP) is an open standard, released by Anthropic in November 2024, that standardizes how LLM applications connect to external tools and data. The problem it solves is the integration matrix: N AI applications each needing custom connectors to M tools means N×M bespoke integrations. With a shared protocol, each app implements MCP once and each tool exposes MCP once — N+M implementations, and any client can talk to any server. It's the same move USB-C or the Language Server Protocol made for their domains.
Architecturally there are three roles, and the host/client distinction is the one that is easy to get wrong. The host is the LLM application itself (Claude Desktop, an IDE, your agent product): it owns the conversation, the model, consent UI, and security policy. The host spawns one client per server connection — a protocol adapter object living inside the host process that maintains a 1:1 stateful session with one server. The server is a separate process (local or remote) that exposes capabilities. So a host with three integrations runs three clients talking to three servers. This isolation is deliberate: servers never see the whole conversation and can't see into each other; the host aggregates context and enforces boundaries.
Servers expose three primitives, distinguished by who controls invocation: tools are model-controlled (the LLM decides to call them — executable actions with JSON Schema inputs), resources are application-controlled (the host decides which documents/data to inject as context), and prompts are user-controlled (templates a user explicitly picks, like slash commands). Clients expose primitives back to servers too — sampling (server asks the host's LLM to complete something, so servers don't need their own API keys), roots (filesystem scope), and elicitation (server requests structured input from the user mid-operation).
Messages are JSON-RPC 2.0 over one of two standard transports: stdio for local subprocess
servers, and Streamable HTTP for remote ones. A session starts with an initialize handshake
that negotiates protocol version and capabilities, so both sides know exactly which optional
features are on the table.
How it actually works
Wire format. Everything is JSON-RPC 2.0, UTF-8: requests (have id, expect a response),
responses (result or error with matching id), and notifications (no id, no reply). Methods
are namespaced: initialize, tools/list, tools/call, resources/read, prompts/get,
notifications/initialized, notifications/tools/list_changed. A tool call:
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "get_weather", "arguments": {"city": "Hyderabad"}}}The result carries a content array (text/image/resource blocks), optional structuredContent
matching the tool's declared outputSchema, and isError — tool execution failures are in-band
results (so the model can read the error and self-correct), while protocol failures (unknown method,
invalid params) are JSON-RPC errors with codes like -32601/-32602.
Lifecycle. Three phases. (1) Initialization: client sends initialize with
protocolVersion (a date string, e.g. "2025-11-25"), its capabilities, and clientInfo; server
replies with the agreed version, its own capabilities, serverInfo, and optional instructions;
client then fires the notifications/initialized notification. Version negotiation: client sends
its latest supported version; if the server supports it, it echoes it back, otherwise it proposes
its own latest, and the client disconnects if incompatible. (2) Operation: both sides may only use
negotiated capabilities — e.g. a server that didn't declare tools.listChanged must not emit that
notification; a server can only request sampling if the client declared it. (3) Shutdown: no
protocol message — stdio closes stdin then escalates SIGTERM→SIGKILL; HTTP just closes connections.
Capability negotiation is what lets the protocol evolve without breaking: servers declare
tools, resources (with subscribe, listChanged sub-flags), prompts, logging,
completions; clients declare sampling, roots, elicitation. Features are additive and opt-in.
Key insight
After initialize, both sides must treat capability flags as a contract, not a wishlist.
Emitting notifications/tools/list_changed without having declared tools.listChanged, or
calling sampling/createMessage when the client never advertised sampling, is a protocol
violation — hosts and servers that ignore this create silent half-features that only fail in
mixed-version fleets.
stdio transport. Client launches the server as a subprocess. Newline-delimited JSON-RPC on
stdin/stdout — messages must not contain embedded newlines, and the server must write nothing
non-protocol to stdout (a stray print() corrupts the stream — the classic first bug). Logging
goes to stderr.
Streamable HTTP transport. One endpoint (e.g. /mcp) handling POST and GET. Every
client→server message is a POST; the server answers with either a single application/json response
or upgrades to a text/event-stream SSE stream, on which it can push progress notifications and
server→client requests before the final response. A client may also GET to open a standing SSE
stream for unsolicited server messages. Sessions are optional: the server may return an
MCP-Session-Id header at initialize, which the client echoes on every request; the client must
also send MCP-Protocol-Version on every request post-negotiation. Streams are resumable — SSE
event IDs act as per-stream cursors, and a client reconnects with Last-Event-ID to get missed
messages replayed.
Common misconception
Session IDs are not authentication. The spec forbids treating Mcp-Session-Id as proof of
identity. They bind resumable streams and optional server-side state; credentials (or the
absence of a network attack surface for stdio) are a separate concern. Predictable session IDs
enable hijacking via resumable streams even when the attacker cannot forge auth tokens.
Transport history (worth understanding one level down). 2024-11-05 shipped "HTTP+SSE": two endpoints, a
mandatory long-lived SSE connection, no resumability — painful behind load balancers and
serverless. 2025-03-26 replaced it with Streamable HTTP: one endpoint, plain request/response when
streaming isn't needed, optional sessions, resumable streams. HTTP+SSE is deprecated but detectable
for backwards compatibility (new clients POST initialize first; on 4xx they fall back to GET
expecting the old endpoint event). Spec revisions since: 2025-06-18 (OAuth resource-server model,
structured tool output, elicitation), 2025-11-25 (current stable: OpenID Connect discovery, URL
elicitation, experimental tasks for durable long-running requests). A 2026-07-28 release is landing
now whose headline is a stateless core: the initialize handshake and session header move into
per-request _meta, so any server instance behind a round-robin load balancer can serve any request
— fixing the sticky-session scaling pain of stateful Streamable HTTP.
On the governed enterprise platform, agent mode typically mounts several MCP servers (ticketing, internal docs, write-capable ops tools) under a host that owns consent UI and model access through the gateway. The protocol is what keeps those servers isolated from each other while the host still presents a unified tool surface to the model.
The flows
These are the operational sequences to hold in your head. Each is a named path you can trace on the wire.
| Flow | Sequence | When it applies | What breaks it |
|---|---|---|---|
| Initialize / capability negotiation | Client initialize with protocolVersion, capabilities, clientInfo → server response with negotiated version, server capabilities, serverInfo (+ optional instructions) → client notifications/initialized | Every new connection, before any tool traffic | Incompatible versions (client must disconnect); using undeclared capabilities later; on HTTP, missing MCP-Protocol-Version / session header after negotiation |
| Tool discovery | Client tools/list (optionally paginated) → server returns name, description, inputSchema, optional outputSchema → host flattens into model tool definitions | After init; again after list_changed | Mounting every server’s full catalog into one prompt (token bloat + worse tool selection); schema drift without re-list |
| Tool invocation | Model emits function call → host maps to server → client tools/call → result with content / optional structuredContent / isError → host returns as tool result to model | Model-controlled actions with side effects or external data | Treating tool failures as JSON-RPC errors (model never sees them); unbounded result payloads; no progress/cancel for long ops |
| Notification / change path | Server that declared tools.listChanged emits notifications/tools/list_changed → client re-fetches tools/list (same pattern for prompts/resources; resources may also subscribe → notifications/resources/updated) | Hot-reload of tool surfaces without reconnect | Emitting change notifications without the capability flag; rug-pull description swaps without host pinning/diff UI |
| Shutdown | No protocol teardown message — stdio: close stdin, then SIGTERM→SIGKILL; HTTP: close connections / drop session | Process exit, host unload, idle teardown | Stray stdout after partial close on stdio; assuming graceful shutdown RPC exists |
Happy path (stdio, one tool call) in order:
- Host launches server subprocess; client opens newline-delimited JSON-RPC on stdin/stdout.
- Initialize + capability negotiation complete;
notifications/initializedsent. - Client calls
tools/list; host injects schemas into the next model request. - Model returns a structured tool call; host dispatches
tools/callwith matchingid. - Server returns in-band result (
isError: falseortrue); host appends tool result; loop continues. - On unload: stdin closed; process signaled if needed.
Failure branches worth designing for: version mismatch at step 2 (disconnect, surface a clear
host error); unknown tool name at step 4 (JSON-RPC protocol error to the client — model does not
see it unless the host translates); handler exception at step 5 (isError: true with guidance so
the model can self-correct); network drop mid-SSE on HTTP (reconnect with Last-Event-ID if the
stream was resumable).
A worked example
Consider the governed enterprise platform mounting a remote ticket search MCP server over Streamable HTTP for an employee query:
"Show open Sev1 tickets for the London warehouse."
1. Connection lifecycle (illustrative timings).
| Step | Wire / header | Approx. cost |
|---|---|---|
POST initialize | Body: protocolVersion: "2025-11-25", client caps include nothing exotic; response: server caps { tools: { listChanged: true } }, MCP-Session-Id: sess_9f3a…, serverInfo.name: ticket-server | ~80–150 ms RTT + handshake |
| Notification | Client POST notifications/initialized → 202 Accepted | ~20–40 ms |
| Discovery | tools/list → one tool search_tickets with ~120-token description + JSON Schema (~400–600 tokens once injected into the model prompt) | ~50–100 ms |
| Model turn | Host sends chat + tool defs; model emits function call search_tickets({site:"LON-WH", severity:"Sev1", status:"open"}) | model latency (hundreds of ms–seconds) |
| Invocation | tools/call id=7; server may SSE progress if the DB is slow; final result ~15 tickets ≈ 400–800 tokens of JSON in content | ~100–300 ms tool + network |
| Next model turn | Host returns tool result; model answers in prose | another model turn |
2. What the tools/call request looks like (shape, abbreviated):
{"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {"name": "search_tickets",
"arguments": {"site": "LON-WH", "severity": "Sev1", "status": "open"}}}3. Two error layers, deliberately different:
| Failure | Wire shape | Who sees it |
|---|---|---|
Typo method tool/call | JSON-RPC error code -32601 | Client / host only |
Valid call, limit over max | Result with isError: true, text "limit must be ≤ 100" | Model (can retry) |
| Handler throws | Same in-band isError: true path (not a protocol error) | Model |
4. Mid-session tool surface change: ops deploys a new search_tickets_v2. Server emits
notifications/tools/list_changed; client re-lists; host refreshes model tool defs on the next
request (~one extra RTT, schemas may grow by a few hundred tokens).
What omitted stages look like in production
- Skip initialize / capability discipline — clients assume
listChangedand hang waiting for notifications that never come; or servers emit them and clients ignore them. - Skip discovery refresh — model keeps calling a renamed tool; host gets protocol errors; user sees a broken agent with no clear cause.
- Collapse tool errors into protocol errors — model never reads
"limit must be ≤ 100"; it either loops or gives up instead of self-correcting. - Mount every internal server’s full catalog — tens of servers × dozens of tools floods the context window and measurably hurts tool-selection accuracy.
- Treat session ID as auth on HTTP — predictable or leaked IDs let attackers inject events on resumable streams; real auth belongs in OAuth (see Building MCP Servers).
Common drill-downs
These are the questions a careful engineer asks one level down — the details that separate a working sketch from a production integration.
What problem does MCP solve that function calling doesn't?
Function calling defines how a model requests a call within one API; the app author still writes
every integration. MCP standardizes the boundary between app and tool provider: N×M custom
integrations become N+M protocol implementations, tools are discoverable at runtime
(tools/list), and one server works across Claude Desktop, IDEs, and your own agent unchanged.
Host vs client vs server? Host = the LLM application; owns the model, conversation, consent, and policy. Client = a per-connection protocol object inside the host, 1:1 with one server. Server = the external process exposing tools/resources/prompts. One host, many clients, each to one server; servers are isolated from each other and from the conversation.
Tools vs resources vs prompts — why three primitives? Different invocation authority. Tools: model-controlled, executable, side-effecting. Resources: application-controlled context data addressed by URI, read not executed. Prompts: user-controlled templates explicitly invoked. Collapsing them into "just tools" loses the consent model — a host can auto-inject resources but require human approval for tool calls.
What is sampling and why does it exist?
A server→client request (sampling/createMessage) asking the host's LLM to generate a completion.
The server gets intelligence without embedding an API key or model dependency; the host keeps
control (model choice, human-in-the-loop review, billing). Inverts the usual direction — proof that
MCP is bidirectional, not client-server RPC.
Walk through what happens on the wire when a session starts.
initialize request (protocolVersion, capabilities, clientInfo) → server response (negotiated
version, its capabilities, serverInfo) → notifications/initialized from client. Only pings are
allowed before the response; then normal operation typically opens with tools/list. On HTTP the
initialize response may also set MCP-Session-Id, echoed on all later requests along with
MCP-Protocol-Version.
Why did Streamable HTTP replace HTTP+SSE?
Old transport forced a permanently open SSE connection on a second endpoint: no resumability, poor
fit for serverless and load-balanced deployments, connection overhead even for simple
request/response servers. Streamable HTTP uses one endpoint, streams only when needed, adds
optional sessions and Last-Event-ID resumption. The 2026 revision goes further — fully stateless
requests so plain round-robin scaling works.
A request is a JSON-RPC notification vs request — what's the difference and where does it matter?
Requests carry an id and demand a response; notifications don't and can't be replied to.
initialized, cancelled, progress, and list_changed are notifications. Over Streamable HTTP the
distinction is visible: notifications POST → 202 Accepted, requests POST → JSON body or SSE stream.
How does a client learn a server's tools changed at runtime?
Server declares tools.listChanged capability, emits notifications/tools/list_changed, client
re-fetches tools/list. Same pattern for prompts and resources; resources additionally support
per-URI subscribe/notifications/resources/updated.
How do MCP errors reach the model?
Two layers. Protocol errors (JSON-RPC error: bad method/params) target the client — the model
never sees them. Tool execution errors return a normal result with isError: true and the error
text in content, deliberately, so the model can read the failure and retry with corrected
arguments.
Production concerns
- Stateful sessions don't horizontally scale by default.
Mcp-Session-Idpins logical state to a server instance; behind a load balancer you need sticky routing or a shared session store (Redis) — this operational pain is exactly what the 2026 stateless revision removes. Until then: keep remote servers stateless where possible and treat sessions as optional. See also Reliability for timeouts, cancellation, and fallback patterns around remote tool hops. - Session security. Session IDs must be cryptographically random and are not authentication — the spec forbids using sessions for auth. Predictable IDs enable hijacking: an attacker who guesses one can inject events that reach the victim's client via resumable streams. Full auth design for remote servers is in Building MCP Servers and Security.
- DNS rebinding. HTTP servers must validate
Origin(403 on mismatch) and bind localhost servers to 127.0.0.1, not 0.0.0.0 — otherwise a malicious webpage can drive a local MCP server. - Context-window cost. Every connected server's tool definitions are serialized into the model prompt. Tens of servers × dozens of tools bloats tokens and measurably degrades tool-selection accuracy; production hosts filter, namespace, or dynamically load toolsets rather than mounting everything. Token accounting and caching sit in Cost.
- Latency. stdio is in-machine and effectively free; remote HTTP adds a network round trip per
tool call inside an agent loop that already pays model latency per step. Long operations should
stream progress notifications (which can reset client timeouts) — clients must enforce a hard
timeout ceiling regardless and cancel via
notifications/cancelled. Budget tool hops under Latency. - Version skew. Clients and servers in the wild span 2024-11-05 through 2025-11-25.
Date-based versioning plus capability flags make this survivable, but you must handle a server
that negotiates down, and HTTP servers assume
2025-03-26when no version header arrives. - Trust boundary. Every server you mount is code with real permissions. Tool descriptions enter the prompt, so a malicious server can attempt prompt injection through them; hosts mitigate with consent-per-tool-call UI, allowlists, and sandboxing local servers (full treatment in building-mcp-servers.md).
Test yourself
A host mounts three MCP servers. After initialize, the model calls a tool that only exists on server B — but the host routes the call to server A. What went wrong in the architecture, and what should the host have done at discovery time?
You see JSON-RPC error `-32602` (invalid params) on a `tools/call` in host logs, but the model keeps arguing that the ticket search 'failed mysteriously.' Diagnose the layering bug.
A remote MCP deployment works in staging with one replica but flakes under load with sticky sessions disabled. Initialize succeeds; later `tools/call` returns unknown session. What design assumption broke, and what are the two remediation paths?
Why is collapsing resources into tools a consent-model regression, even if the model could 'just call read_document'?
A local stdio server logs with print() for debugging and 'randomly' fails to parse on the client. Why is this failure mode structural, not flaky?
Go deeper
- MCP Specification (2025-11-25) — the authoritative spec; start with Architecture, Lifecycle, and Transports
- Spec changelog — what changed per revision; useful for the version-history question
- The 2026-07-28 release candidate — the stateless-core redesign, extensions framework, tasks
- Anthropic's MCP announcement — the original framing of the N×M problem (Nov 2024)
- MCP Python SDK — official SDK; reading its transport code is the fastest way to internalize the wire protocol
- Building Agents with Model Context Protocol — Full Workshop with Mahesh Murag of Anthropic — AI Engineer — the canonical deep walkthrough by MCP's co-creator team
- Model Context Protocol (MCP), clearly explained (why it matters) — Greg Isenberg — widely-shared conceptual explainer of the why
Where this connects
- Building MCP Servers — once the wire protocol is clear, the next problem is implementing tools, choosing stdio vs HTTP, and getting remote OAuth right.
- Tool Calling — how the host presents MCP-discovered schemas to the model and handles parallel calls and tool-result errors inside the agent loop.
- Security — prompt injection via tool descriptions and results, and why untrusted servers are code inside your decision loop.
- Design: LLM Gateway — governed model access pairs naturally with governed tool access; the same quotas, audit, and policy instincts apply.