Skip to content

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:

python
# 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:

json
{
  "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.

stdiostreamable HTTP
Who starts the processthe client, per sessionyou, ahead of time
Where it runsthe user's machineyour infrastructure
Reaching private datalocal files, local networkwhatever the service can reach
Authinherits the user's environmentyou build it
Concurrent usersone per launched processmany, on one deployment
Startup costpaid per session, ~180 ms for Pythonpaid 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

Back to top