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

Capstone: a small server plus client

One file, two ends: a server with tools and a resource, a client that negotiates, lists, calls and blocks, and a transcript of every frame between them.

Part 10 of 10

Advanced18 min readBack to Courses

Two ends in one file

The capstone puts the server handler and the client object in the same process and connects them with a function call. That is not a shortcut around the protocol; the frames are the same dictionaries, the ids are still matched, and the client still has to initialize before it lists. The only thing missing is the pipe, which is the part the transport chapters already covered.

Building it this way makes the contract visible. You can read the request the client wrote and the response the server produced without a log file or a packet capture. When something is wrong, the question is not what happened on the wire but which side wrote the wrong field, and that is a much better question.

The handler and the client are the same objects the earlier chapters built, with the transports removed. That is the test of whether the separation was real: if the capstone needs a rewrite, the earlier chapters hid a dependency. Here they do not.

The transcript is the test

Every exchange is recorded with the method, the id, the decision and the result. The transcript is what a reviewer reads and what a regression test compares. It is also what tells you whether the client blocked a call before it left or the server refused it after it arrived, which are different bugs with different fixes.

A good transcript is boring: initialize, list, two calls, one refusal. The interesting part is the refusal, because it shows the policy in the place where policy belongs. If the transcript has no refusal, the client has no policy, only a connection.

The transcript should include the refusal even though no frame was sent. A record of what did not happen is the only evidence that the policy ran, and it is the first thing to check when a tool seems to have been called twice. Absence needs a record too.

The same server with the SDK

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("notes")
NOTES = {"n1": "Refunds take five business days.", "n2": "Support replies in two days."}


@mcp.tool()
def search_notes(query: str) -> list[str]:
    """Find note ids whose text contains the query. Read-only."""
    return [key for key, text in NOTES.items() if query.lower() in text.lower()]


@mcp.tool()
def read_note(note_id: str) -> str:
    """Read one note by id. Read-only."""
    return NOTES[note_id]


@mcp.resource("notes://{note_id}")
def note(note_id: str) -> str:
    """The note as a resource."""
    return NOTES[note_id]


if __name__ == "__main__":
    mcp.run(transport="stdio")

The whole exchange

Run the transcript, then make the client call a tool that is not on its allow-list and confirm the server never sees it. Then add read_note to the allow-list.

Output
Click "Run" to execute your code

What the capstone leaves out

A production server adds authentication, a real transport, progress for slow calls, cancellation, pagination and subscriptions. Each is a method or a field on a frame that already exists, which is why the small version is worth finishing first. The shape does not change when the features arrive; it only gets more branches.

The client side adds retries with backoff, a timeout per call, a cache invalidated by list_changed, and a confirmation UI. Those are the same concerns any networked client has. MCP does not remove them, and a client that ignores them is not simpler, only less honest about where it will fail.

Progress and cancellation are the two features that change how the client feels. A long call with no progress looks hung; a cancel that does not stop the work looks broken. Both are notifications, and both are cheap to add once the session exists.

Keep the transcript of a good run and a blocked run as fixtures. They are the cheapest regression tests a protocol implementation can have, and they survive a refactor.

Where to go next

The guardrails course is the natural next step: it takes the client’s allow-list and turns it into a policy with provenance, approvals and an audit record. The AI agents course puts this client inside a loop with a budget and three exits. Neither needs a different protocol; both need the discipline this capstone practices.

If you read only one more thing, read the specification revision you are targeting. It is short, it is dated, and it is the only description that cannot be out of date with the wire you are actually sending.

The specification is the source, but the SDK’s changelog is where the practical differences live. Read both when you upgrade, and pin the revision in your own tests so an upgrade is a decision rather than a surprise. A protocol you cannot name is a protocol you cannot debug.

A resource template and a progress notice

Read a note and its history, then request a file URI and confirm it is refused. Add a resources/templates/list method that advertises notes://{id}/history.

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 does the in-memory transport leave out?