Hosts: connecting servers to a model
Many servers, one agent: namespacing, routing and approvals.
Create a free account Try part of it here
Why it matters
The host is where MCP meets the model and the user. Claude Code, Claude's apps and IDE assistants are all hosts, and so is any agent you build that loads MCP servers. Everything from the agent loop in Phase 1 comes together here: tool definitions, tool_use blocks, tool results and approvals, now fed by any number of servers. Getting the host right is what makes plugging in a new server safe.
What you'll be able to do
- Describe a host's job: many clients, one model, one user to protect
- Merge tools from several servers without name clashes, within the model API's naming rules
- Route a model's tool_use to the right server and turn MCP results into tool_result blocks
- Decide when to ask the user for approval, using annotations only from trusted servers
What it covers
- The host's job
- From MCP tool to model tool, and back
- Approvals
- Keeping context under control
- Converting one result
- Where people go wrong with hosts
The host's job
Picture an executive assistant with a phone line to every department. The boss says "get me last month's sales". The assistant knows who to call, checks that the request is fine, makes the call and brings back the answer. The boss never dials anyone.
A host works the same way, with the model as the boss. It holds one MCP client per server, and one conversation with a model. Each turn it:
- Gathers tools from every connected server, and adds the servers'
instructionsto the system prompt. - Sends the conversation and the merged tool list to the model.
- When the model replies with
tool_useblocks, finds the server for each tool, checks whether to ask the user first, callstools/call, and turns each MCP result into atool_result. - Repeats until the model stops asking for tools.
The model never learns which server a tool came from. To the model, they're all tools.
Names: clashes and rules
Two people called John in one office become "John in sales" and "John in accounting". Two servers can both offer search too. The host must give each tool a unique name, usually by prefixing the server's name: crm__search and wiki__search. It keeps a route table from that name back to the server and the tool's original name.
The model API has its own naming rules, too. Claude's tool names must match ^[a-zA-Z0-9_-]{1,64}$, while MCP allows dots and up to 128 characters. So the host also cleans names, swapping disallowed characters for _ and shortening long ones, and refuses to load two tools whose cleaned names collide rather than guessing.
import re def clean(server, tool): name = re.sub(r"[^a-zA-Z0-9_-]", "_", f"{server}__{tool}") return name[:64] for server, tool in [("crm", "search"), ("wiki", "search"), ("files", "fs.read_file"), ("big", "x" * 80)]: print(f"{server}/{tool[:20]:20} -> {clean(server, tool)}")
tools = [("shop", "search"), ("docs", "search")] names = [f"{s}__{t}" for s, t in tools] print(len(set(names)))
Show the answer
2
The names become shop__search and docs__search, which differ, so the set holds 2. A server prefix keeps the two search tools apart.
Show the answer
Two servers can offer tools with the same name, and the host needs to route each call to the right server
The prefix makes names unique across servers, and the route table maps them back to the server and original name.
From MCP tool to model tool, and back
The host also acts as a translator, because MCP and the model API describe the same things in slightly different words. Going out, an MCP tool becomes a Claude tool: the cleaned name, the description, and inputSchema as input_schema. Coming back, a tools/call result becomes a tool_result block for the next user turn:
| MCP result | Claude tool_result |
|---|---|
content text blocks | content: the text |
structuredContent with no text | content: the JSON as text |
isError: true | is_error: true, so the model knows to correct itself |
| image blocks | image content blocks, so the model can see them |
| the call itself failed (server down, protocol error) | is_error: true with a short explanation |
Every tool_use must get a tool_result with the same tool_use_id, even when the call failed or the user said no. Otherwise the next model request is rejected.
In plain words: every question the model asks gets an answer, even if the answer is "that failed" or "the user said no".
The host stands between the model and every server, and it's where safety lives.
Approvals
The host stands between the model and the world. A good assistant signs for the small things and checks with you before writing a large check. Before running a tool, the host decides whether to ask the user. A reasonable policy:
- A tool from a trusted server marked
readOnlyHint: truemay run without asking. - Anything else asks first, and the host shows the arguments, because data leaks out through arguments (an email address, a document's contents).
- If the user declines, return a
tool_resultwithis_error: truesaying so. The model can then explain, or try something else.
Annotations from an untrusted server don't relax anything. A malicious server can label its "exfiltrate everything" tool read-only.
Show the answer
A tool_result for that tool_use_id, with is_error true and a note that the user declined
Every tool_use needs a matching tool_result. Saying the user declined lets the model respond sensibly.
Keeping context under control
Every tool definition goes into the model's context on every turn. Five servers with twenty tools each is a hundred definitions, maybe tens of thousands of tokens, before the user has said anything. Too many tools also make the model choose worse, the way a 300-dish menu slows everyone down.
Hosts fight this by letting users turn servers and tools on and off, and by loading tool definitions on demand: the model first sees short names or a search tool, and the full schema is added when it's needed. Phase 10 builds this.
Some model APIs can also act as the MCP client themselves: you pass a list of remote server URLs in the request, and the provider calls the servers during generation. That's convenient, but your code then gives up the routing, approval and logging this lesson builds, so check what control you still have.
Show the answer
Load only the servers and tools a task needs, defer the rest, and keep results short
Every definition costs tokens on every call, and long lists make choosing harder. Fewer, relevant tools work better.
Converting one result
Three MCP results, converted into the tool_result blocks the model sees.
import json def to_tool_result(tool_use_id, result): texts = [b["text"] for b in result.get("content", []) if b.get("type") == "text"] if not texts and "structuredContent" in result: texts = [json.dumps(result["structuredContent"])] return {"type": "tool_result", "tool_use_id": tool_use_id, "content": "\n".join(texts), "is_error": bool(result.get("isError"))} ok = {"resultType": "complete", "content": [{"type": "text", "text": "Retargeting: 3,940 clicks"}]} data_only = {"resultType": "complete", "content": [], "structuredContent": {"stock": 12}} failed = {"resultType": "complete", "content": [{"type": "text", "text": "No campaign cmp_999."}], "isError": True} for i, r in enumerate([ok, data_only, failed]): print(to_tool_result(f"toolu_{i}", r))
Text passes through, structured-only results become JSON text, and isError becomes is_error. A full host would also pass images through as image blocks. The model reads these on its next turn exactly as it would the results of your own Python tools.
Where people go wrong with hosts
The model never talks to a server. People often picture the model calling MCP directly. It can't. It only ever sees tool definitions and asks for calls. The host decides what to do, makes the call and passes back the result. That's why the host is the right place for every safety rule.
Names can clash. Two servers can both offer search. Without a prefix, one silently hides the other, and the model may send private data to the wrong one. Add the server's name to each tool, and keep a table that maps back to the real name.
A declined call still needs an answer. If a person says no, the conversation still contains a tool_use with no result, so the next API call fails. Send a tool_result that says the user declined. The model reads that and can take another approach.
Annotations come from the server. A tool that claims to be read-only might not be. Only skip approval for servers you trust. This is also a place to be strict: asking one extra time costs a click, while wrongly skipping can cost a lot more.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
In MCP, what is the host?