Skip to main content

MCP Architecture: Hosts, Clients, Servers

Introduction

Last lesson you saw why MCP exists — it collapses the N×M integration explosion into M+N (L137). This lesson opens the hood: how is the protocol actually structured? Three roles, two layers, one handshake — and once you see them, every MCP tutorial, error message, and config file suddenly makes sense.

The whole architecture rests on three participantsHost, Client, Server — and the single most-confused point in all of MCP: the client is not the app.

Get this lesson right and you'll never be lost in an MCP setup again — you'll know exactly what's talking to what, over which channel, and in what sequence.

In this lesson:

  • The three participants — host, client, server — and the 1:1 rule that trips everyone up (hands-on)
  • Two layers — the data layer (JSON-RPC) and the transport layer (stdio vs HTTP, local vs remote)
  • The connection lifecycle — the initialize handshake, capability negotiation, and the JSON-RPC messages that flow
  • That MCP is a two-way street — servers can call back into the host

Scope: this is the architecture and the wire protocol. What a server actually exposestools, resources, and prompts — is the next lesson (L139 — Tools, Resources & Prompts in MCP); you'll build a server in L140 (Hands-On: Build Your First MCP Server), connect an agent in L141 (Connecting an Agent to MCP Servers), and cover security in L142 (The MCP Ecosystem & Security Considerations).

An infographic titled 'MCP Architecture: Hosts, Clients, Servers' showing the structure of the Model Context Protocol. On the left is a large HOST box, the AI application such as Claude Desktop, VS Code, or your own agent, which contains the LLM and several MCP CLIENT connectors. On the right are several MCP SERVER boxes. The defining rule is drawn as one client per server: the host creates exactly one client for each server, and each client holds a dedicated one-to-one connection to its server, so one host manages many clients connected to many servers. Each connection is a stateful JSON-RPC line. Servers can be local, running as a subprocess over the stdio transport, such as a filesystem or database server, or remote, running over the Streamable HTTP transport, such as a Sentry or GitHub server. The protocol is built in two layers: the inner data layer defines the JSON-RPC 2.0 messages, the connection lifecycle, and the primitives (tools, resources, prompts); the outer transport layer defines the communication channel (stdio or HTTP) and authentication. A connection opens with a lifecycle handshake: the client sends an initialize request with its protocol version and capabilities, the server responds with its own capabilities, and the client sends an initialized notification; then the client can discover and call primitives with messages like tools/list and tools/call, and the server can push notifications when things change. The three cards summarize the architecture: the one-to-one rule that a host creates one client per server; the two layers, data (JSON-RPC plus lifecycle plus primitives) and transport (stdio for local, Streamable HTTP for remote); and the connection lifecycle of initialize, capability negotiation, operation, and shutdown. The key clarification is that the client is not the application; it is a per-server connector inside the host, one for each server. Takeaway: MCP is one host orchestrating many clients, each holding a one-to-one JSON-RPC connection to a server over stdio or HTTP, opened with a capability handshake, one protocol cleanly layered.

The Three Participants

MCP follows a client–server architecture, but with a twist in the naming that you must get straight. There are three roles (per the official spec):

  • MCP Host"the AI application that coordinates and manages one or multiple MCP clients." It's the app the user runs (Claude Desktop, VS Code, your agent), it holds the LLM, and it's in charge.
  • MCP Client"a component that maintains a connection to an MCP server and obtains context from [it] for the host to use." The host creates one client per server.
  • MCP Server"a program that provides context to MCP clients" — exposing tools, resources, and prompts. Local or remote.

The official example makes it concrete: VS Code is a host. When it connects to the Sentry server, the VS Code runtime instantiates an MCP client for it. When it also connects to the filesystem server, it instantiates another client. One host, two clients, two servers.

Click through the three roles — pay attention to who creates whom:

MCP's three roles — click each. The HOST is the AI app (Claude Desktop, VS Code, your agent): it runs the LLM and creates one client per server. A CLIENT is the connector the host spins up — exactly one per server — holding a single stateful JSON-RPC connection. A SERVER exposes capabilities (tools, resources, prompts) and can run locally (stdio) or remotely (HTTP). The rule that trips everyone up: the client is NOT the app — it's a per-server connector that lives inside the host, 1:1 with a server. One host → many clients → many servers.

If only one thing sticks, make it this: the client is not the application. The application is the host; the client is a small connector the host spins up — one for each server — to manage that one connection. Mixing up host and client is the single most common MCP misconception.

The 1:1 Rule — One Client Per Server

Here's the structural heart of MCP, and it's worth stating as a rule:

One host → many clients → many servers, where each client maintains a dedicated, 1:1 connection to exactly one server.

Want to connect your agent to GitHub, Postgres, and Slack? The host creates three clients — one per server — each holding its own stateful connection. Add a fourth server → a fourth client. This isolation is deliberate and buys you a lot:

  • Fault isolation — if the Slack server crashes or hangs, only its client is affected; GitHub and Postgres keep working.
  • Independent state & security — each connection has its own capabilities, permissions, and session. The host can grant the filesystem server access to one folder and the GitHub server a read-only token, separately.
  • Clean lifecycle — connecting or disconnecting one server is just creating or tearing down one client; nothing else is touched.

And the relationship runs both ways across the ecosystem: a local server (stdio) usually serves one client, while a remote server (HTTP) can serve many clients from many hosts at once. That's how "M+N" from L137 (The Integration Problem MCP Solves) physically happens: one GitHub MCP server serves Claude, Cursor, and your agent — each via its own client.

Two Layers — Data & Transport

MCP is built as two layers, and separating them is what lets the same protocol run locally and over the internet:

  • The data layer (inner) — a JSON-RPC 2.0 protocol that defines the actual messages and meaning: the lifecycle (handshake, capability negotiation), the primitives (tools, resources, prompts), and notifications. This is the part you reason about — what is said.
  • The transport layer (outer)how the bytes move: connection setup, message framing, and authentication. It abstracts the channel so the identical JSON-RPC messages work over any transport.

There are two transports, and the choice maps exactly to local vs remote:

stdioStreamable HTTP
Server runslocally, as a subprocessremotely, behind a URL
Channelstdin / stdout pipesHTTP POST (+ optional SSE stream)
Auth(none — same machine)bearer tokens / API keys / OAuth
Use it forlocal files, local DBs, dev toolshosted SaaS, shared multi-user servers

So a "local MCP server" is one your host launches as a subprocess and talks to over stdio (e.g., the filesystem server); a "remote MCP server" is one running on someone's platform that you reach over Streamable HTTP (e.g., the Sentry server). Same JSON-RPC underneath — different pipe.

The Connection Lifecycle — The Handshake

MCP is a stateful protocol: a connection isn't just fire-and-forget requests — it's opened, operated, and closed, and it begins with a mandatory capability-negotiation handshake. The purpose: before doing any work, the client and server agree on a protocol version and tell each other what they each support (so neither calls something the other can't do). The opening sequence:

// 1) INITIALIZE — the client opens with a capability-negotiation handshake.
// client → server: "here's my version + what I can do"
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
  "params": { "protocolVersion": "2025-11-25",
              "capabilities": { "elicitation": {} },
              "clientInfo": { "name": "example-client", "version": "1.0.0" } } }

// server → client: "agreed — and here's what I expose"
{ "jsonrpc": "2.0", "id": 1, "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": { "tools": { "listChanged": true }, "resources": {} },
    "serverInfo": { "name": "example-server", "version": "1.0.0" } } }

// client → server: "ready" (a notification — no id, no response)
{ "jsonrpc": "2.0", "method": "notifications/initialized" }

Three things are happening in that exchange: (1) version negotiation — both sides confirm a compatible protocolVersion; if they can't agree, the connection is terminated. (2) capability discovery — the server declares it supports tools (and can send listChanged notifications) and resources; the client declares its capabilities (here, elicitation). Now each side knows what's safe to call. (3) identity exchange — names and versions, for debugging. After the server replies, the client sends a notifications/initialized message and the connection is ready.

The Messages — JSON-RPC in Action

With the handshake done, everything else is JSON-RPC 2.0: requests (carry an id, expect a response), responses (echo that id), and notifications (no id, no response). Every MCP primitive follows the same pattern — a */list to discover, then an action like tools/call to use:

// 2) OPERATE — discover, then call. Requests carry an `id`; the response echoes it.
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }          // "what tools do you have?"

{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",            // "run this one"
  "params": { "name": "weather_current",
              "arguments": { "location": "San Francisco", "units": "imperial" } } }

// server → client (id 3 → correlates to the request above)
{ "jsonrpc": "2.0", "id": 3, "result": {
    "content": [ { "type": "text", "text": "68°F, partly cloudy, winds 8 mph." } ] } }

// later, server → client: "my tools changed" (notification → client re-lists)
{ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }

Notice the id correlation — the response to request id: 3 carries id: 3, so the client can match replies to requests even with many in flight. And notice the discover-then-use pattern: the client first asks tools/list (so the toolset can be dynamic), then calls a tool by its exact name. The last message is a notificationtools/list_changed, sent by the server (because it declared listChanged: true at init) when its tools change; the client reacts by re-listing. This is how an MCP connection stays live and current, not just request-reply.

A Two-Way Street

One more architectural surprise that shapes everything later: MCP is bidirectional. It's not just client asks, server answersservers can call back into the host, through client-side primitives. The two important ones:

  • Sampling (sampling/createMessage) — a server can ask the host's LLM to generate a completion. Why? So a server can use a language model without bundling its own — it stays model-independent and lets the host (and the user's chosen model + billing) do the inference. A clever inversion: the tool borrows the agent's brain.
  • Elicitation (elicitation/create) — a server can ask the user for more input or confirmation mid-operation ("which repo?", "approve this delete?"). This is how a server gets human-in-the-loop without owning a UI.

(There's also roots — the client tells servers which filesystem boundaries (directory URIs) they're allowed to operate within — and logging, for servers to emit debug messages to the host.) You won't wire these up today, but hold the shape: the host is the hub, and capabilities flow both directions through it — servers expose tools/resources/prompts to the host, and can request sampling/elicitation from it. That two-way design is exactly why the host must own consent and security — it sits in the middle of everything.

🧪 Try It Yourself

Reason it through, then check with the role explorer:

  1. Your agent connects to 4 MCP servers. How many hosts, clients, and servers are in play?
  2. A teammate says "the MCP client is the app the user opens." Correct them in one sentence.
  3. You're connecting to a local filesystem server and a hosted Sentry server. Which transport does each use, and which is "local" vs "remote"?
  4. Before any tool is called, what does the initialize handshake accomplish (name two things)?
  5. A server wants to summarize text but doesn't want to ship its own LLM. Which client primitive does it use, and what does that reveal about MCP's direction?

(1) 1 host (your agent), 4 clients (one per server), 4 servers. (2) The app is the host; the client is a per-server connector the host creates inside itself — one for each server — not the app. (3) Filesystem = stdio (a local subprocess → "local"); Sentry = Streamable HTTP (reached over a URL → "remote"). Same JSON-RPC, different pipe. (4) Any two of: protocol-version negotiation (compatible versions or terminate), capability discovery (what each side supports), identity exchange (names/versions). (5) Sampling (sampling/createMessage) — the server asks the host's LLM to generate, staying model-independent. It reveals MCP is bidirectional: servers can call back into the host, not just answer it.

Mental-Model Corrections

  • "The client is the app I open." No — the host is the app; the client is a per-server connector the host creates inside itself, one per server. This is the MCP gotcha.
  • "One client talks to all my servers." Each client is 1:1 with one server. Four servers → four clients. The isolation gives you fault-tolerance and per-server security.
  • "Local vs remote is a different protocol." Same JSON-RPC 2.0 data layer — only the transport differs: stdio (local subprocess) vs Streamable HTTP (remote URL).
  • "You just send requests." MCP is stateful: a connection initializes (version + capability handshake) before any work, and stays live with notifications.
  • "Servers only answer the client." It's two-way — servers can request sampling (use the host's LLM) and elicitation (ask the user) back through the host.
  • "MCP decides how the LLM uses tools." No — MCP only standardizes context exchange; how the host/LLM uses what it gets is up to the application.

Key Takeaways

  • Three participants: Host (the AI app — runs the LLM, manages clients, owns consent) · Client (a per-server connector the host creates, 1:1 with a server) · Server (exposes tools/resources/prompts; local or remote). The client is not the app.
  • The 1:1 rule: one host → many clients → many servers, each client a dedicated connection to one server — giving fault isolation and per-server security. (A remote server can serve many clients/hosts.)
  • Two layers: the data layer (JSON-RPC 2.0 — lifecycle, primitives, notifications — what is said) over the transport layer (how it travels): stdio (local subprocess) or Streamable HTTP (remote, with OAuth/keys).
  • Stateful lifecycle: every connection opens with an initialize handshakeversion negotiation + capability discovery + identity — then notifications/initialized, then operation.
  • Messages are JSON-RPC: requests (with id) ↔ responses (echo id), plus notifications (no id). The pattern is */list to discover, tools/call to use, with live */list_changed updates.
  • It's two-way: servers can call back via sampling (use the host's LLM) and elicitation (ask the user) — which is why the host owns security.
  • Next — L139: Tools, Resources & Prompts in MCP — the three things a server actually exposes, in depth.