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.
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.
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.
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?
