Model Context Protocol from the ground up
Writing a server
A server is a dispatch table plus a schema. Start with one read-only tool, validate at the edge and again inside, and let the SDK do the framing while you keep the effects honest.
A dispatch table with a schema
The official Python SDK gives you a decorator that turns a function into a tool. The parameter names and type hints become the input schema, the docstring becomes the description the model reads, and the return value becomes a content block. Registration is the easy part; deciding what the function is allowed to do is the work.
Under the decorator the server is still a dispatch table: a name maps to a callable, a request maps to a result. That is worth remembering when something goes wrong, because the SDK is not doing anything you could not debug by hand. It parses frames, validates the envelope and serialises the answer, and the rest is your function.
The SDK also lets you run the same server over different transports without changing the handler. That is the previous chapter seen from the server side: the function is the capability, the transport is the deployment, and the two should be separable in your head before they are separable in code.
Validate at the edge, then again inside
The schema checks the shape: this property is a string, that one is required, no extras. It cannot check the domain. A note id can be a perfectly valid string and still not exist; an amount can be a valid integer and still be negative; a date can parse and still be in the past when the tool needs a future one. The second check belongs inside the tool, where the data is.
When the second check fails, return a tool error rather than raising through the transport. The message should say what was wrong in terms the model can use: which id was missing, which limit was exceeded, what a valid value looks like. It should not contain a stack trace, a connection string or the name of an internal table.
Return the smallest useful error. A message that names the argument and the rule is enough; a message that explains the database schema is a leak. If the model needs more context, put it in the tool description rather than in a failure it will only see after guessing.
A server in twenty lines
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("notes")
NOTES = {"n1": "Refunds take five business days."}
@mcp.tool()
def read_note(note_id: str) -> str:
"""Read one note by id. Read-only; raises a tool error if absent."""
if note_id not in NOTES:
raise ValueError(f"no note with id {note_id}")
return NOTES[note_id]
@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.resource("notes://{note_id}")
def note_resource(note_id: str) -> str:
"""The same note, addressed as a resource."""
return NOTES[note_id]
if __name__ == "__main__":
mcp.run(transport="stdio")A server you can call directly
Run the requests and read the responses. Then add a prompts/list method that returns one prompt with an argument, and confirm a notification produces no response.
Side effects need more than a schema
A read can be repeated safely; a write cannot. A client that times out may retry, and the retry may arrive after the first call already succeeded. Give writes an idempotency key, or make the operation naturally idempotent by setting a value rather than incrementing one. A refund endpoint that adds the amount every time is a bug waiting for a slow network.
The rest is ordinary service hygiene applied to a new surface: a timeout on every outbound call, pagination on every list, a rate limit per session, no secrets in tool output, and logs on stderr with the request id. None of it is specific to MCP, and all of it is missing from most example servers.
A tool that writes should say so in its name and its description, and it should carry the destructive hint. The hint does not stop anything, but it gives the host a reason to ask, and the host is where the asking belongs. A server that polices its own writes is a server trusting itself.
Ship the read-only tool first. A server that can only look is useful on day one, reviewable in an afternoon, and impossible to turn into an incident while you learn the protocol.
Test it without a transport
If the server has a handle method, a test can call it with a dictionary and assert on the dictionary that comes back. No subprocess, no pipe, no timing. That is how the framing, the error codes and the validation get covered, and it runs in milliseconds.
Keep one integration test that starts the real process and speaks real frames, because the thing most likely to break is the part the unit test skipped: startup, the handshake, stdout hygiene. A golden transcript of initialize, list and one call is enough to catch a stray print or a changed schema.
Fixtures are worth more than mocks here. A recorded request and its response is a contract test that survives a refactor, while a mock of your own handler only proves that the mock was called. Keep one fixture per method, including the failure.
Reject bad arguments with a useful message
Run the cases and fix the one that wrongly accepts a boolean as an integer. Then add a maximum length to the query and a case that exceeds it.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What does a server do beneath the decorators?
