Model Context Protocol from the ground up
Writing a client
The client owns the connection, the capabilities and the policy. It is the place where a tool call becomes a decision rather than a reflex, and where a hostile server meets its limits.
The client owns the connection
A client starts or reaches a server, sends initialize, waits for the result, sends the initialized notification and only then discovers what exists. The order matters: a server may refuse requests before the handshake, and a client that lists tools first is relying on behaviour the specification does not promise. When the work is done, the client closes the session and, for stdio, the process.
Every step needs a deadline. A server that accepts a connection and then says nothing will hang the host forever if the client waits without a timeout. The same is true of a tool call that runs for an hour. A timeout is not rudeness; it is the only way the host can tell a slow answer from a dead one.
The client also owns the shutdown. Closing the session before killing the process gives the server a chance to flush; killing first is how a write gets lost and a lock stays held. A clean close is part of the protocol, not a nicety at the end of the program.
The client is the guardrail
The server publishes a tool list, and the model chooses from it. Neither of those is a permission. The client is the component that knows the user, the tenant and the task, so it is the component that decides which tools may be called, with which arguments, and whether a person has to confirm. A server cannot grant itself a capability by describing one.
The practical controls are unglamorous: an allow-list per task, a confirmation for anything with a destructive hint, a cap on result size before it enters the context, redaction of anything that looks like a secret, a per-session rate limit and an audit line for every call. Each one is a few lines, and together they turn an open tool list into a bounded one.
An allow-list per task is stronger than one per user. The same person may use a coding agent with a shell and a support agent with no shell at all, and the difference should be the task, not the account. A permission that follows the person is a permission that follows them everywhere.
A session and a call
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = [tool.name for tool in tools.tools]
if "read_note" not in names:
raise RuntimeError("server does not expose read_note")
result = await session.call_tool("read_note", {"note_id": "n1"})
print(result.content[0].text)A client with an allow-list
Call a permitted tool and a blocked one. Then add a confirm callback that must return True before a destructive call proceeds, and show both answers.
Servers can ask the client for things
The protocol runs in both directions. A server may ask the client to sample from the model, to ask the user a question, or to list the roots it is allowed to work in. These are the client capabilities, and a server may only use one if the client declared it during the handshake. Declaring nothing is a valid and useful configuration.
Each request is a decision, not a formality. Sampling spends the user’s tokens and may send context the user did not intend to share. Elicitation puts a server-written question in front of a person, which is a phishing surface. Roots tell a server which paths are in scope, which is a permission. A client that auto-approves all three has handed the server its own authority.
Roots are the quiet one. A server that lists roots learns which directories are in scope, and a client that returns the home directory has just widened the server’s world. Return the project, not the disk, and return it only when the server asked.
Never auto-approve a sampling request. The server wrote the prompt, the host pays for it, and the context it may expose belongs to the user.
Errors, retries and cancellation
A protocol error means the client and server disagree about the exchange; retrying the same frame will usually fail the same way. A tool error means the tool ran and refused; the model may be able to fix the arguments. A transport error means the connection is gone. Three kinds, three responses, and conflating them produces a retry loop that hides the real fault.
Retry only what is safe to repeat: reads, and writes that carry an idempotency key. Use a bounded backoff, and give up rather than retrying forever. When a person cancels, send notifications/cancelled with the request id and treat a late response as an orphan. If the server violates the framing, close the connection instead of trying to resynchronise.
Timeouts should be per method, not one global number. Initialize is fast, a search may be slow, and a write may be slower. A single timeout either kills the slow read or waits forever on the dead handshake, and neither is a policy anyone chose.
Cap the result before it enters the context
Run a call that returns far too much text. Truncate it at the client, record the original size, and confirm the model only ever sees the capped version.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What must happen before a client lists tools?
