Vocabulary for this course

The terms the chapters assume, in plain words. You can open this from any chapter.

capability
A named feature one side declares it supports during the handshake, such as tools or resources.
client
The connector a host creates to talk to one server, sending requests and handling replies.
handshake
The opening exchange where both sides agree on a version of the rules and declare what each supports.
host
The application the user runs, which creates a client and decides what a model may reach.
JSON-RPC
A message format of requests, responses and notifications, where each request carries an id so its reply can be matched.
notification
A message that expects no reply, used to report an event or a change.
prompt
A reusable message template a server offers, which a user can choose and fill in.
resource
Read-only data a server exposes, addressed by a name or address and returned as content.
schema
A description of the arguments a tool accepts, used to check a request before it runs.
server
The program that offers tools, resources or prompts and answers requests over a transport.
stdio
A transport that carries messages over the standard input and output of a program, usually on one machine.
tool
A function a server exposes that a model may ask to run, with named arguments and a result.
transport
The channel that carries messages between client and server, such as stdio or HTTP.

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.

Part 6 of 10

Advanced16 min readBack to Courses

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.

Output
Click "Run" to execute your code

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.

Output
Click "Run" to execute your code

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?