Chapter 2 of 8 · 13 min read
Building a minimal MCP server
On this page
An MCP server — a process that exposes tools over the Model Context Protocol so that any
compliant application can discover and call them — is not a framework and it is not a
service mesh. It is a process that, when spoken to in a particular way, can answer two
questions: what tools do you have, and here are arguments, please run one. The smallest
thing that deserves the name is built below around a ticket search shrunk until it fits on
a laptop: a SQLite file with 4,000 synthetic tickets, an index on created_at, and a
LIKE query that runs in about 3 ms.
That query is deliberately unimpressive, because the interesting part is not the query.
The interesting part is that once this thing is running, an MCP client written by somebody
else, in a language you did not choose, can find out that search_tickets exists and call
it correctly, and you will not have written a line of code for that client. Everything the
rest of this topic argues about is measured against this artefact. When a later chapter
claims the same server runs under two clients unchanged, this is the server; when it puts
a stopwatch on the protocol boundary and gets 31 ms against 4 ms, these are the two paths
being timed.
Four declarations and a handler
The official Python SDK reduces the whole thing to a decorator, and the server is short enough to read in one pass:
# server.py
import sqlite3
from typing import Literal
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("support-tickets")
db = sqlite3.connect("tickets.db", check_same_thread=False)
@mcp.tool()
def search_tickets(
query: str,
status: Literal["open", "pending", "closed"] | None = None,
limit: int = 10,
) -> list[dict]:
"""Search the support ticket database by free text.
Returns at most 20 tickets, newest first, each with id, title,
status and a 200-character snippet of the description.
"""
limit = max(1, min(limit, 20))
sql = "SELECT id, title, status, substr(body, 1, 200) AS snippet FROM tickets WHERE body LIKE ?"
params: list[object] = [f"%{query}%"]
if status:
sql += " AND status = ?"
params.append(status)
sql += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
rows = db.execute(sql, params).fetchall()
return [
{"id": r[0], "title": r[1], "status": r[2], "snippet": r[3]}
for r in rows
]
if __name__ == "__main__":
mcp.run(transport="stdio")Four things in that file are load-bearing and the rest is ordinary Python. The tool's
name comes from the function name, so renaming the function is a breaking change to
every consumer. The description comes from the docstring, and it is read by a model
rather than by a person, which is why it says what the tool returns and what the cap is
instead of saying "searches tickets". The input schema is derived from the type
annotations, so status becomes a string with three allowed values and limit becomes an
integer with a default of 10. The transport is the last line, and it decides how bytes
get in and out of the process.
The docstring deserves a second look, because it is the only one of the four that has no
compiler behind it and it does more work than any of the others. It is the text a model
reads when deciding whether this tool is the right one for a request, so it is judged by
whether it prevents a wrong call. "Returns at most 20 tickets" tells the model not to
expect exhaustive results and to narrow the query when it needs more. "Newest first" tells
it that ordering is already handled. Naming the fields tells it what it can do with the
output without guessing. A docstring that reads "Searches the ticket database" leaves all
three of those to inference, and inference is where tools get called with limit=500 and
their results misread. Nothing in the protocol enforces any of this; the description is a
free-text field, and its quality is entirely on you.
Deriving the schema from annotations is convenient and slightly dangerous, because it
means a refactor changes your public interface. Widening status to str because a test
was awkward silently deletes the enum from the schema that every client reads, and the
first symptom is a model passing status="resolved" and the LIKE query returning
nothing. The generated declaration is the contract, so it is worth printing it once and
checking it says what you meant:
{
"name": "search_tickets",
"description": "Search the support ticket database by free text.\n\nReturns at most 20 tickets, newest first, each with id, title,\nstatus and a 200-character snippet of the description.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "title": "Query", "type": "string" },
"status": {
"anyOf": [
{ "enum": ["open", "pending", "closed"], "type": "string" },
{ "type": "null" }
],
"default": null
},
"limit": { "default": 10, "title": "Limit", "type": "integer" }
},
"required": ["query"]
}
}Reading that output back against the annotations that produced it is worth doing once by
hand, because each of the three parameters was translated differently. query: str has no
default and no constraint, so it becomes a plain string and lands in the required list —
a parameter with no default is one the caller must supply. limit: int = 10 becomes an
integer with a default and stays out of required for the mirror reason: the annotation
says what type it holds, and the default says the caller may leave it out. status is the
interesting one, because the annotation is doing two jobs at once.
Literal["open", "pending", "closed"] is Python's way of saying the value must be one of
exactly those three strings, which is precisely what a JSON Schema enum says. The
| None then widens that to admit no value at all, and there is no single JSON Schema type
meaning "one of three strings, or nothing" — so the generator emits an anyOf holding two
alternatives, the enum and the null type, and = None supplies the default beside them.
Three annotations, three unlike translations, and not one of them was a decision anybody
made on purpose.
One detail there is worth noticing before it bites: the limit clamp to 20 is enforced in
Python and is invisible in the schema, which advertises an unbounded integer. Every
consumer therefore believes it may ask for 500 rows, and the server will quietly return
20. That gap between what the schema promises and what the handler does is a schema
disagreement of the exact kind that later shows up as a failure boundary (chapter 5, on
where a call is rejected before your handler runs), and the fix is to put the bound in the
annotation so the declaration and the behaviour cannot drift apart.
The reason that gap is easy to leave in place is that the clamp genuinely does work, and it
is worth being exact about what it works at. max(1, min(limit, 20)) guarantees the
query never asks the database for more than twenty rows, so the server cannot be made to do
unbounded work by a caller asking for a million. That is a real defence and it holds. What
it does not do is tell anybody. The clamp runs inside the handler, after the argument has
already been accepted, and nothing about it appears in the declaration that every client
reads before deciding what to send. So a model reading the schema sees an integer with no
upper bound, concludes that asking for five hundred is a reasonable thing to do, asks for
five hundred, receives twenty, and has no way to tell whether twenty is all that matched or
all it was allowed. The next thing it does is narrow the query and search again for results
it already had. A defence that protects the server and misinforms the caller is the shape
this whole file is prone to, and it is exactly why the four declarations are worth reading
as a public contract rather than as decoration on a function.
Choosing how the bytes travel
The transport is the part that has no analogue in a normal function call, and the choice is between two that matter in practice. A stdio transport means the client launches your server as a child process and speaks to it over the process's standard input and output, one JSON message per line. A streamable HTTP transport means your server is a long-running web service at a URL and the client makes HTTP requests to it.
| stdio | streamable HTTP | |
|---|---|---|
| Who starts the process | the client, per session | you, ahead of time |
| Where it runs | the user's machine | your infrastructure |
| Reaching private data | local files, local network | whatever the service can reach |
| Auth | inherits the user's environment | you build it |
| Concurrent users | one per launched process | many, on one deployment |
| Startup cost | paid per session, ~180 ms for Python | paid once |
The row about who starts the process is the one that surprises people coming from web
services, because it inverts the usual lifecycle. With stdio there is no server running
before a client wants one and none running after it disconnects; the client spawns
python server.py, holds the pipes, and kills the process on exit. Your server has no
port, no uptime, no health check and no deployment, and it has exactly one user for its
entire life. That makes a whole category of production concerns disappear, and it makes
another category appear in their place — a crash is now a client-visible session failure,
logs go wherever the launching application decides to put them, and a slow import at
module scope is 400 ms the user waits for on every session start.
Each of those three follows from the same property rather than being three separate facts to memorise, and the property is that the client owns the process. Because the client started it, the process's lifetime is bounded by the session, so a crash cannot be absorbed by a supervisor restarting it in the background — there is no supervisor, and the only observer is the application whose session just ended. Because the client started it, the process inherited its file descriptors from the client, so where stderr goes was decided by the launching application and not by your logging configuration. And because the client starts it per session rather than once, every cost paid at import time is paid again on every session rather than amortised across the process's life, which is what turns a slow module-scope import from a startup detail into a latency the user sits through. One sentence about who owns the process predicts all three, which is what makes the row worth more than the others in that table.
The protocol above the transport is identical either way, which is the property that lets a later measurement compare timings without the transport confounding the result. In this file the choice is one argument on the last line, and moving the same tool to a long-running service changes that argument and nothing above it — the decorator, the docstring, the annotations and the query are untouched, and what you acquire instead is a deployment, a URL and an authentication story you are now responsible for. What differs between the two is where the data is and who is allowed to reach it, and that is a question about the capability rather than about the protocol.
Read that script for what it does not contain. It does not import anything from the server
module, it does not know the tool is Python, and the only string in it that comes from the
server's codebase is the tool name — which it did not have to hardcode, since list_tools
returned it a moment earlier. A client that picks its tool by searching the discovered list
for one whose schema it can satisfy has no coupling to the server at all. Swap the server
for a Go implementation over HTTP, change stdio_client to the HTTP transport, and every
other line stands.
The three calls also have a strict order that the SDK will enforce for you and that is
worth knowing is there. initialize has to complete before anything else is legal,
because it is where the two sides agree on a protocol version and tell each other what
they support; list_tools cannot be answered before that agreement exists; and
call_tool on a name the server never declared is an error rather than a lookup failure
in your handler. Each of those three lines is more than one message on the wire, and the
difference between three calls and the messages they expand into is what makes a trace
readable when something goes wrong.
That the order is enforced rather than merely conventional follows from what each step
produces. initialize is the only step that settles which protocol version both sides will
speak, and the shape of every later message depends on that answer, so no later message can
be written before it is known. list_tools is the only step that produces the set of legal
tool names and the argument shapes they accept, so call_tool before it would be a call
made against no declaration at all. Reading the dependencies that way, the sequence is not
ceremony imposed by a specification; it is the order in which the information each step
needs becomes available. Which is also why a call_tool on a name the server never
declared is refused at the protocol layer rather than reaching your function and failing
there — the declarations were exchanged first precisely so that a name outside them could
be rejected without running anything.
The reverse is equally true and easier to forget: the handler does not know who called it. There is no client identity in the arguments, no branch on which application is connected, and no place where such a branch could naturally go. If you ever find yourself wanting one, that is the moment the decoupling is being given away, and the portability test later in this topic (chapter 6, running one server under two clients that disagree) turns exactly that instinct into an observation that either passes or fails.
Auth, concurrency and structured errors
This server is a demonstration and not a deployment, and the gaps are worth naming so that nobody ships it. It has no authentication, because stdio inherits whatever the user already has; move it to HTTP and you own that problem entirely. It has one connection to SQLite shared across whatever concurrency the transport allows, which is fine for a single desktop session and wrong for a shared service. It raises Python exceptions on bad input rather than returning structured errors, so a malformed argument produces a stack trace where a client would prefer a message. It exposes only tools, while the protocol also defines resources for read-only content and prompts for reusable templates, both of which are out of scope here.
It is also not, in any sense yet demonstrated, faster or better than the function it wraps.
Calling search_tickets directly in the same process would return the same three rows
using none of this machinery. What has been bought is the property that the second and
third callers cost nothing, and whether that is worth its price is a measurement rather
than an opinion.
There is one more absence in the client script that is easy to miss and matters more than any of the above: no model appears in it anywhere. There was no prompt, no completion, no tool-calling loop. The connection, the discovery and the invocation all happened in ordinary application code, which is a strong hint about who is actually talking to the server and who is not.
Four declarations and a handler is the whole of it, and the four are unevenly guarded. The transport is a line you can change without anyone else noticing, because it decides how bytes reach the process and nothing about what the process says. The name, the description and the schema are the opposite: they are read at runtime by clients that have no way to ask you what you meant, cannot be checked against anything at build time, and are the only information a model has when it decides whether to call this tool and with what.
That asymmetry is what makes the schema printed above worth looking at once with intent
rather than trusting the decorator to have got it right. It is also why the drift between
an advertised unbounded limit and a handler that clamps at 20 is a defect rather than a
detail — it is the one place in this file where a consumer is told something untrue by
code that compiles, and neither the type checker nor the protocol has any way to notice.
What MCP is and why it exists — read the current version at https://daviddominguez.dev/learn/en/what-mcp-is-and-why-it-exists/mcp-client