Three roles, every time
Every MCP deployment has the same three roles, whether it is a desktop chat app, a coding agent in your terminal, or a backend job with no UI.
- Host: the AI application the user actually runs. It owns the model, the conversation and the user's consent.
- Client: a component inside the host that speaks MCP to exactly one server. It owns the connection.
- Server: a program that exposes capabilities (tools, resources and prompts). It owns what those capabilities do.
If you have written function-calling code against a model API, you have already built a crude host. Your loop sent tool definitions to the model, caught the tool request and ran a Python function. MCP moves that function out of your process into a server, and puts a standard protocol between the two.
Naming gotcha: people often say "client" when they mean the whole application ("which MCP clients support X?"). In the specification, the application is the host and the client is the connector inside it. This course uses the precise terms, because the security reasoning depends on them.
One client per server
A host connected to three servers runs three clients. The specification states it as a 1:1 relationship: each client communicates with exactly one server.
+-------------------- Host (the AI app) ---------------------+
| model access - conversation history - consent UI |
| |
| [Client A] [Client B] [Client C] |
+-------|------------------|-------------------|-------------+
| stdio | stdio | HTTP
[Server A] [Server B] [Server C]
local files local database remote issue tracker
The host does not use one shared connection because the client doubles as an isolation boundary:
- Servers cannot see each other. The files server never receives the issue tracker's traffic. Anything that crosses from one to the other goes through the host, which can apply policy.
- Each connection has its own settings. Protocol version, capabilities and credentials can differ per server.
- Failures stay local. If Server B crashes, the host drops Client B and the others carry on.
The mapping is not symmetrical. A local server launched over stdio typically serves the one client that started it. A remote server over HTTP typically serves many clients from many hosts. So it is one client to one server, but often one server to many clients.
Most hosts let you declare servers in configuration. The exact file and keys vary by host, so treat this as an illustration:
{
"mcpServers": {
// the host starts this process and creates one client for it
"files": { "command": "python", "args": ["files_server.py"] },
// the host creates a second, separate client for this URL
"issues": { "url": "https://issues.example.com/mcp" }
}
}
Two entries give you two clients and two isolated connections.
Who owns what, and who trusts whom
| Role | Owns | Does not own |
|---|---|---|
| Host | The model, the full conversation, user consent, security policy, which servers are connected | What a tool does when it runs |
| Client | One connection: framing messages, attaching protocol version and capabilities, routing replies | Any decision about whether to call |
| Server | Its capabilities and the systems behind them | The model, the conversation, the user interface |
The trust relationships follow from that split:
- The user trusts the host. It is the only component that sees everything.
- The host should not blindly trust a server. Tool descriptions and tool results are text written by someone else, and they end up in front of your model. Treat them as untrusted input unless you trust the server's author.
- The server should not trust its callers' arguments. They were written by a model. Validate every input and enforce your own access control.
- The server sees very little. By design it gets the arguments of a call, not the conversation, and cannot see into other servers.
The specification says there should always be a human able to deny a tool invocation. The host is where that happens, because it is the only role with a UI and the user's attention.
One tool call, end to end
The user types: "What's the weather in Leeds?" The host has a weather server connected.
- Discovery (earlier). The client sent
tools/list, and the host stored the tool definitions. - Prompt. The host sends the user's message and the tool definitions to the model.
- Model decides. The model replies with a request to call
get_weatherwith{"location": "Leeds"}. The model only produces text asking for a call. It cannot reach the server. - Consent. The host applies its policy: auto-approve, or ask the user.
- Routing. The host picks the client that owns that tool, and the client sends:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "get_weather", "arguments": { "location": "Leeds" } }
}
- Execution. The server validates the input, runs its code and replies with the same
id:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Leeds: 11°C, light rain" }],
"isError": false
}
}
- Back to the model. The client hands the result to the host, the host adds it to the conversation, and the model writes the answer the user sees.
(Real requests also carry a _meta block with the protocol version and client capabilities. It is left out here to keep the shape visible.)
A host's core loop looks like this in Python. It is a sketch of the shape, not SDK code to copy; the real calls come when you build one.
# one client per configured server, keyed by server name
clients = {name: connect(cfg) for name, cfg in config.items()}
# discovery: remember which client owns which tool
owner = {}
for name, client in clients.items():
for tool in await client.list_tools():
owner[f"{name}.{tool.name}"] = (client, tool.name) # prefix avoids clashes
# when the model asks for a tool
client, tool_name = owner[requested_name]
if await user_approves(requested_name, args): # the host's job, nobody else's
result = await client.call_tool(tool_name, args) # client -> server -> client
conversation.add_tool_result(result) # the model sees it next turn
The prefix matters because tool names are only unique within a single server. Two servers can both expose search, so the host has to disambiguate. The TypeScript SDK follows the same pattern: one client object per server connection, managed by the host.
Key takeaways
- The host is the application. It owns the model, the conversation and consent.
- A client lives inside the host and talks to exactly one server. Three servers means three clients.
- A server owns its capabilities and must validate what it receives. It does not see the conversation or other servers.
- The model never contacts a server. It asks, the host decides, the client sends, the server runs, and the result comes back the same way.
- Tool descriptions and results cross a trust boundary into your model's context, so the host should treat them as untrusted input.