Chapter 1 of 7 · 15 min read
Hosts, clients and servers
On this page
MCP, the Model Context Protocol, is the standard that lets software built around a language model reach tools and data it does not itself contain, and its architecture names three roles. The host is the application the user actually opened: the desktop app, the IDE, the CLI agent, the backend service that runs an agent loop. A server is a separate process exposing a bounded set of tools, resources and prompts — this one reads files under one directory, that one runs queries against staging — and it can see neither the user nor any other server. A client is the connection object that lives inside the host and speaks the protocol to exactly one server for exactly as long as that session lasts, over a transport, which is the channel underneath it: a pipe to a child process on the same machine, or an HTTP connection to a remote one.
The roles are split along that line because the two ends are written by different people on different schedules. A server author cannot know which application will connect or what its user is permitted to do, and an application author cannot know what any given server will offer, so everything situational — who the user is, what they have consented to, which model is behind the conversation, which of several servers is worth asking — stays on the host's side, and the server is left with the work it can do without any of that context. Read the roles as an answer to "who could possibly know this?" and most of the ownership questions in this topic answer themselves.
You meet all three the first time you edit a configuration file listing MCP servers: every entry in it is a server, the application reading the file is the host, and each live connection it opens is a client. What the file does not show is how many clients that last part implies, and getting that count right is the cheapest correction available — because everything else in this topic, the handshake, the capability set, what has to be released at shutdown, is scoped to a thing whose boundaries you have to be able to draw.
Four processes, four connections, four clients
The one-to-one part of the definition is the piece that gets dropped. A host with four configured servers holds four clients, because a client is not a component of your application that knows how to speak MCP — it is one live session with one server, with its own negotiated protocol version, its own capability set, its own subscriptions and its own space of in-flight request ids.
The step from "a connection carries state" to "therefore one object per connection" is worth walking, because it is a claim about necessity rather than about tidiness. Take any one of those four pieces of state and read it as a sentence. A negotiated protocol version means "the version this server and I settled on". A capability set means "what this server offers and what I offered it". A subscription means "this server will notify me about this URI". An in-flight request id means "this server owes me an answer to number seven". Every one of them has a particular server as its subject, so none of them can be stored anywhere that does not also record which server it is about. A single object holding four connections must therefore keep each of those four things in a map keyed by server, and must keep every future piece of session state that way too. One object per connection is simply the arrangement where that key is the object's own identity, rather than a field somebody has to remember to add.
The comparison worth holding is a database driver rather than an HTTP library. Nobody
writes one Connection object and points it at four Postgres instances by keeping a map
from database name to socket, because a connection carries session state — the transaction
it is inside, the prepared statements it has named, the search path someone set — and none
of that is meaningful across instances. An MCP client carries the same kind of state for
the same reason. An HTTP client is the misleading analogy, since it is stateless enough
that one object serving many hosts is exactly right, and that is the mental model people
import when they draw a single client box with four arrows leaving it.
The database-driver comparison is a comparison and not an argument, so it is worth saying where it stops holding before it does any damage. It holds on the part being borrowed: both kinds of connection carry state negotiated at open time, and in both cases that state is meaningless against a different instance. It stops holding almost immediately afterwards. Database connections are interchangeable within a pool, which is the entire point of pooling, whereas two MCP clients bound to two different servers expose different tools and are never substitutable for one another. Database drivers also multiplex: many application objects share a small number of connections, and no MCP host works that way, because a client is bound to one server for the life of the session. Borrow the fact that the state is per-connection. Do not borrow anything about interchangeability: a set of MCP clients can be kept and reused, but only ever one per server, and never drawn from as though any of them would do.
The labels reverse just as often as the counts, usually in the phrase "the server's host". A host is not something a server has; it is the application that decided to open a connection in the first place, and the same server binary can be connected to by three different hosts on three different machines without knowing or caring. An IDE plugin with arrows to three servers is one host containing three clients, and each of those clients is joined to one server. Written the other way around, the diagram implies the server sits at the centre and hosts orbit it, which quietly assigns the server responsibilities — policy, aggregation, deciding when to connect — that it has no way to discharge because it cannot see the user or the other two servers.
Consent is the second thing the four-arrow picture gets wrong, and it is the one with a
user-visible cost. When a tool call is about to delete a branch or drop a table, something
has to decide whether the user sees a dialog first. That decision belongs to the host,
across all four connections, and it belongs there because the host is the only participant
that has a user. The server can describe a tool as destructive — the protocol has
annotations for exactly this, destructiveHint, readOnlyHint, idempotentHint — but an
annotation is a claim made by the same party that would be lying if it wanted to do damage,
so it is an input to the host's policy and never a substitute for it.
That sentence is the one to hold on to, because the belief it displaces is comfortable and
almost every reader arrives with it: that a tool marked readOnlyHint is safe to run
without asking. Follow where the annotation comes from. It is a field in the tool
declaration, and the tool declaration is written by the server author, and the server author
is the same party whose tool would be doing the damage. So a server that is malicious, or
merely wrong about its own tool, produces exactly the same declaration as an honest and
correct one — the annotation carries no evidence, because there is no independent party
attesting to it. A hint of this kind is useful for the case it was designed for, which is a
correct server helping a host give the user a better experience by not interrupting them
over a read. It is worthless for the case people reach for it in, which is deciding whether
something can be trusted. Those two uses look identical in code and differ entirely in what
happens when the claim is false.
Which column owns what
Splitting responsibilities across the three roles is easier to keep straight as a table than as a paragraph, because each row is a concrete thing that has to live somewhere and each column is a process that could plausibly own it.
| Concern | Host | Client | Server |
|---|---|---|---|
| Which servers exist at all, and when to connect | owns | — | — |
| Launching a local server, or dialling a remote URL | decides | performs and holds it | — |
| The transport itself: pipes, socket, child process handle | — | owns | owns its end |
initialize, the initialized notification, teardown | — | owns | responds |
| The negotiated protocol version and capability set | reads | owns, per connection | owns its half |
| Consent dialogs and policy before a destructive call | owns | — | may hint only |
| Aggregating tools from all servers for the provider request | owns | — | — |
| Deciding what a tool does when it runs | — | — | owns |
| Sampling: whether a model call is allowed, and which model | owns | declares the capability | may request |
Two rows deserve reading twice. The transport row splits across host and client because the
decision and the handle sit in different places: the host knows that the user configured a
filesystem server and that it should be running, and the client is what actually spawned
npx and is holding the child process handle and its stdin. When the user removes that
server from the configuration, the host makes the decision and the client is the only
object in a position to carry it out, which is the shape of every teardown problem later in
this topic.
The aggregation row is where the count of clients becomes visible to the model. Four connections produce four tool lists, and the host is what merges them into the single array of tool definitions it sends the provider on each turn, resolving name collisions and deciding what to include. That merge is a host concern because no client can see the other three, and it is the reason a stale connection is not a quiet problem: a tool that no longer exists stays in the array until the host removes it.
The sampling row is the one that surprises server authors. A server can ask the client for a model completion, but whether that request is legal at all was settled at connection time by a capability the client declared, and whether it is allowed is a host policy question about cost and data flow.
Every row in that table is decidable by one question, which is why the table is worth learning as a rule rather than memorising as a list: who is the only participant that could possibly know this? Consent needs a user, and only the host has one, so consent is the host's. The negotiated version is the outcome of one handshake with one server, and only the object that ran that handshake holds it, so it is the client's. What a tool actually does is known only to the process that implements it, so it is the server's. Aggregation across servers requires seeing more than one server, and only the host does, so it is the host's. Run that question down the column and the answers reproduce the table, including the two rows that split — those are the rows where the knowing and the doing sit in different places, which is a real distinction rather than an ambiguity in the model.
A minimal host, annotated
Here is the smallest thing that deserves the name. It is TypeScript against the official SDK, and the entire point of it is which object holds what.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
type ServerSpec = { id: string; command: string; args: string[] };
class Host {
// The host owns the set of connections and their identities.
private clients = new Map<string, Client>();
async connect(spec: ServerSpec): Promise<void> {
// The client owns the transport: it spawns the process and holds the pipes.
const transport = new StdioClientTransport({
command: spec.command,
args: spec.args,
});
const client = new Client(
{ name: "acme-desktop", version: "3.4.1" },
// This capability object is what THIS connection offers THIS server.
{ capabilities: { roots: { listChanged: true }, sampling: {} } },
);
// connect() runs the handshake and stores the negotiated result
// on the client, not on the host.
await client.connect(transport);
this.clients.set(spec.id, client);
}
}Three annotations fall out of nine lines. The map is the host's, because "which servers is
this application joined to" is a fact about the application. The transport is the client's,
because after connect() returns, the only object holding the child process handle and the
writable end of its stdin is that StdioClientTransport, and there is one of them per
server. The capability object passed to the constructor is a per-connection declaration;
the same host can hand { sampling: {} } to a server it trusts and an empty object to one
it does not, which is a per-client decision that has nowhere to live if there is only one
client.
Consent then sits above all of it, in the host, where it can see the user:
async callTool(serverId: string, name: string, args: Record<string, unknown>) {
const client = this.clients.get(serverId);
if (!client) throw new Error(`no connection for server ${serverId}`);
const def = this.toolIndex.get(`${serverId}::${name}`);
const risky = def?.annotations?.destructiveHint ?? true; // absent means assume yes
if (risky && !(await this.ui.confirmDestructive(serverId, name, args))) {
return { isError: true, content: [{ type: "text", text: "Cancelled by the user." }] };
}
return client.callTool({ name, arguments: args });
}The ?? true is the whole policy in one operator. A server that says nothing about a tool
gets treated as dangerous, and a server that says destructiveHint: false has made a claim
the host chose to believe. Notice also that the tool index is keyed by server id and tool
name together, because two servers can both expose a search and the host is the only
place that collision can be resolved.
None of this is isolation
The rule is about state, not about safety, and conflating the two leads to a false sense of containment. Four separate clients do not sandbox four servers from each other in any meaningful sense: they all run inside one host process, they read the same configuration, and — most importantly — the results they return are all concatenated into the same model context on the next turn. A filesystem server that returns a document containing instructions aimed at the model is not contained by the fact that the GitHub connection is a different object. Isolation of that kind is a host design problem involving what gets forwarded to the provider and what the host will act on without asking, and per-connection clients are a precondition for it rather than a solution to it.
Nor does the rule say anything about processes or threads. Four clients in a Node host are four objects on one event loop, and four clients in a Python host may be four tasks on one asyncio loop. The count is a count of sessions, and the reason the count matters is that each session holds state which is only valid against the server it was negotiated with.
What the rule does buy is that every piece of session state has an obvious owner, and that a question like "which protocol version am I speaking" always has one answer rather than four. It also makes the shutdown story tractable, because closing a connection becomes disposing of one object with a known set of resources hanging off it instead of unpicking one entry from four interleaved maps.
Three roles and one client per server connection is therefore a counting rule with consequences rather than a naming convention. The host is the only role that can see the user, so consent, policy and the aggregated tool list live there and nowhere else. The server is the only role that knows what a tool does, and it can see neither the user nor its siblings. The client is one session, and everything negotiated during that session — version, capabilities, subscriptions, ids — belongs to it and dies with it.
The practical value of holding the counts is that it makes ownership questions decidable without a meeting. A bug report about a server behaving inconsistently is a question about which client's state was consulted. A feature request to remember a user's "always allow" answer is a question about the host, since the server has no user to remember anything about. A proposal to have one server aggregate several others is a proposal to give the server a job it cannot do, because it cannot see what the host can see. In each case the answer comes from the columns rather than from an argument about layering.
The counts and the columns are static structure: what exists, and who owns which piece of
it. None of it says when a connection actually becomes usable. In the code above,
client.connect(transport) is a single await that hides a specific message sequence, and a
host that writes its own client instead of using an SDK routinely gets that sequence wrong
in a way that produces errors indistinguishable from a broken tool.
Architecture — read the current version at https://daviddominguez.dev/learn/en/architecture/hosts-clients-and-servers