AI Engineering Playbook
MCP

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

FlowSequenceWhen it appliesWhat breaks it
Initialize / capability negotiationClient initialize with protocolVersion, capabilities, clientInfo → server response with negotiated version, server capabilities, serverInfo (+ optional instructions) → client notifications/initializedEvery new connection, before any tool trafficIncompatible versions (client must disconnect); using undeclared capabilities later; on HTTP, missing MCP-Protocol-Version / session header after negotiation
Tool discoveryClient tools/list (optionally paginated) → server returns name, description, inputSchema, optional outputSchema → host flattens into model tool definitionsAfter init; again after list_changedMounting every server’s full catalog into one prompt (token bloat + worse tool selection); schema drift without re-list
Tool invocationModel emits function call → host maps to server → client tools/call → result with content / optional structuredContent / isError → host returns as tool result to modelModel-controlled actions with side effects or external dataTreating tool failures as JSON-RPC errors (model never sees them); unbounded result payloads; no progress/cancel for long ops
Notification / change pathServer that declared tools.listChanged emits notifications/tools/list_changed → client re-fetches tools/list (same pattern for prompts/resources; resources may also subscribenotifications/resources/updated)Hot-reload of tool surfaces without reconnectEmitting change notifications without the capability flag; rug-pull description swaps without host pinning/diff UI
ShutdownNo protocol teardown message — stdio: close stdin, then SIGTERM→SIGKILL; HTTP: close connections / drop sessionProcess exit, host unload, idle teardownStray stdout after partial close on stdio; assuming graceful shutdown RPC exists

Happy path (stdio, one tool call) in order:

  1. Host launches server subprocess; client opens newline-delimited JSON-RPC on stdin/stdout.
  2. Initialize + capability negotiation complete; notifications/initialized sent.
  3. Client calls tools/list; host injects schemas into the next model request.
  4. Model returns a structured tool call; host dispatches tools/call with matching id.
  5. Server returns in-band result (isError: false or true); host appends tool result; loop continues.
  6. 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).

StepWire / headerApprox. cost
POST initializeBody: 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
NotificationClient POST notifications/initialized → 202 Accepted~20–40 ms
Discoverytools/list → one tool search_tickets with ~120-token description + JSON Schema (~400–600 tokens once injected into the model prompt)~50–100 ms
Model turnHost sends chat + tool defs; model emits function call search_tickets({site:"LON-WH", severity:"Sev1", status:"open"})model latency (hundreds of ms–seconds)
Invocationtools/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 turnHost returns tool result; model answers in proseanother 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:

FailureWire shapeWho sees it
Typo method tool/callJSON-RPC error code -32601Client / host only
Valid call, limit over maxResult with isError: true, text "limit must be ≤ 100"Model (can retry)
Handler throwsSame 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 listChanged and 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-Id pins 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-26 when 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

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.
Production

On this page