Building MCP Servers in Practice

13 min read

C1
Deep Dive · MCP

Building an MCP server isn't hello-world plus tools — the decisions that matter are which capability becomes a tool vs a resource vs a prompt, and what the median server actually looks like.

Every MCP tutorial teaches you to register two tools, echo back a string, and declare victory; production servers look nothing like that. The median MCP server in circulation ships five tools, zero resources, zero prompts, and no auth — a survey of 1,412 servers puts numbers on it — and the shape reflects a set of design choices most tutorials never mention. Get the tool-vs-resource-vs-prompt boundary wrong and every downstream problem — token bloat, brittle testing, unreadable agent traces — flows from it; get revision 2026-07-28's new obligations wrong — server/discover, resultType, cache hints, per-request negotiation — and none of those tools is reachable in the first place.

STEP 1

The stack you actually use: FastMCP (Python) or Standard Schema (TypeScript).

Python MCP servers are written in the FastMCP decorator style: you write a normal function, add @server.tool, the library derives the JSON Schema from your type hints, and everything below the function — including the per-request negotiation the participant model now demands on every single call — stays hidden. The Bloomberry survey of 1,412 production servers found FastMCP has the largest SDK share among sampled Python servers, which is why 2024-era tutorials that show a raw Server class and hand-registered handlers are already misleading — they're demonstrating the layer FastMCP was built to hide. On the client and test side, the Python SDK's entry point is from mcp import Client, and Client(server) is era-neutral by default: it probes the server and picks the protocol path, with mode="legacy" available when you need to pin legacy semantics.

On the TypeScript side the SDK is now two packages rather than one. @modelcontextprotocol/server gives you McpServer and createMcpHandler; @modelcontextprotocol/client gives you Client and StreamableHTTPClientTransport. McpServer still takes a Standard Schema — pass a Zod, Valibot, or ArkType schema and the SDK adapts it to MCP's inputSchema shape without you writing a JSON Schema by hand. Any example that imports both halves from a single @modelcontextprotocol/sdk predates the split. Under either SDK the message layer is still reachable, and that is what you drop to when you need direct control — implementing a novel transport, wrapping a non-standard host, debugging protocol issues — but for shipping a normal server it is the wrong altitude.

A minimal server is genuinely small: a decorator, a function, a run call. What changed under revision 2026-07-28 is that small and well-formed came apart. There is no initialize request any more and no notifications/initialized to acknowledge it, so the agreement that used to be struck once per connection — protocol version, capabilities, who is calling — is restated on every request instead, and three obligations now bind your server before a single tool you wrote is reachable. The 2026-07-28 revision essay covers the full deletion list and the migration; what follows is the part you have to type.

server/discover is not optional. Servers MUST implement it — it is what replaces the handshake as the place a client finds out what it is talking to. It returns supportedVersions, capabilities, an optional instructions string, and the caching hints ttlMs and cacheScope. The trap is serverInfo: it is not a top-level field of the result, it goes inside _meta under the key io.modelcontextprotocol/serverInfo. Check this one against the wire rather than trusting your SDK — the TypeScript SDK shipped the top-level shape, and what it produced was hard connect failures, not a deprecation warning.

{
  "jsonrpc": "2.0", "id": 1, "result": {
    "supportedVersions": ["2026-07-28", "2025-11-25"],
    "capabilities": { "tools": {} },
    "instructions": "Docs server. Search first, fetch by id second.",
    "resultType": "complete",
    "ttlMs": 3600000,
    "cacheScope": "public",
    "_meta": {
      // serverInfo lives HERE, not as a sibling of "capabilities".
      "io.modelcontextprotocol/serverInfo": {
        "name": "docs-server", "version": "2.1.0"
      }
    }
  }
}

Every request carries the negotiation, and you are the one who has to check it. On Streamable HTTP each POST carries MCP-Protocol-Version, and it MUST match the io.modelcontextprotocol/protocolVersion inside params._meta — a mismatch is a 400 with HeaderMismatch (-32020). Mcp-Method goes on all requests, carrying the value of method; Mcp-Name goes on tools/call, resources/read and prompts/get, carrying params.name or params.uri, with non-ASCII values using the Base64 sentinel format. That these are headers and not body fields is worth one sentence of a server author's attention: a gateway can route, rate-limit and authorize a call without parsing your JSON, which means something in front of you is already reading them and trusting them to be true. Inside params._meta, io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities are both REQUIRED and io.modelcontextprotocol/clientInfo SHOULD be present; a request missing a required field is malformed, and the answer is -32602 with HTTP 400.

A capability the client never declared is a capability you do not have. A server MUST NOT rely on one, and reaching for it anyway is MissingRequiredClientCapabilityError (-32021), HTTP 400. Because clientCapabilities now arrives per request rather than once per connection, this is a branch inside your handler rather than a flag you read at startup: the same tool can be fully answerable for one caller and not for the next, and the honest response to the second caller is the error — not a silently degraded result the model has no way to recognise as degraded.

None of that is what tutorials stop at, and none of it is where the design work is either. The function signature you just wrote already implies a whole set of choices about what belongs on the server at all.

STEP 2

The three-way choice: tool vs resource vs prompt.

MCP gives you three primitives to expose a capability with, and the choice is not stylistic. Tools are model-controlled actions with side effects: if the model decides when to call something, and calling it can change state or perform a query on the model's behalf, it is a tool. Resources are application-controlled context: URI-addressable, read-only data the host reads and places into the model's context on its own schedule — a file, a database row, an API response frozen at a point in time. Prompts are user-controlled templates: workflow scaffolding a server exposes and the host surfaces as slash-commands or menu entries, expanded with the user's arguments.

The mnemonic that fits on a Post-it: tool = model chooses, resource = host chooses, prompt = user chooses. That is the entire test. If the answer is "the model decides at runtime whether to fetch it," that capability is a tool even if the underlying implementation reads a file. If the answer is "the host loads it before the model even sees the turn," it is a resource. If the answer is "the user picks it from a menu," it is a prompt.

The most common failure mode is exposing everything as a tool because tools are what every tutorial teaches. Bloomberry's survey found that most servers in the wild over-index on tools and under-use resources; the median server ships zero resources and zero prompts. Some of that is legitimate — many servers are wrapping an action API where the whole surface is side-effectful — but some of it is that authors never asked the question. A "get_user_profile" tool that the model must remember to call at the start of every conversation is usually a resource in disguise: the host can read it once, place the profile in context, and the model never spends a call on it. Note that this is a read-and-cache story now rather than a subscribe story — resources/subscribe and resources/unsubscribe were removed in 2026-07-28, and the ttlMs you return on resources/read is what tells the host how long it may hold that copy.

Practical translation. If the answer to "when should this be loaded?" is "when the model asks," write it as a tool. If it is "always, at the start of the conversation," write it as a resource. If it is "when the user types /summarise," write it as a prompt. Building it the other way makes the model do work the protocol was designed to do for you.

STEP 3

What a server actually ships: shape of the median deployment.

Bloomberry's February 2026 survey — 1,412 public MCP servers, the largest data set anyone has published — gives concrete numbers for what a real server looks like. The median: 5 tools, 0 resources, 0 prompts, no auth. Not "hello-world plus a couple more"; five is genuinely what production servers converge on. The distribution has a long tail — some servers ship 30 or 40 tools — but the middle is small and side-effect-heavy.

That shape signals something important about how MCP is actually being used. Most servers are wrapping an API, a CLI, or a database with a small set of high-value operations; they are not document stores or knowledge bases, which is where resources would carry more weight. If your design has fifteen tools, the survey suggests either your capabilities really are unusually broad, or — more often — you have decomposed too finely and you would be better served by coarser-grained tools or by splitting the server in two.

The other pattern the numbers hint at is what practitioners have started calling the "eight-MCP production stack": rather than one big server that covers a whole domain, teams end up with several small servers, each focused on one system — a filesystem server, a database server, a monitoring server, a project-management server, and so on. This is a distribution decision as much as a design one, and it interacts with your host's tool-selection budget: when the host is running eight servers with five tools each, you are already at forty tools in the model's context, and the discoverability problem starts biting. The right answer is usually to keep servers narrow and let the host mediate — not to fatten a single server until it does everything.

STEP 4

Concrete: registering a search-then-fetch tool pair.

The canonical example is a content server — documentation, a knowledge base, a code index — where the naive design is one search tool that returns full matching documents. The problem is context bloat: a single call can dump megabytes of prose into the loop, and there is no way to recover from that inside the turn. The search-then-fetch pattern, named in Microsoft's post-mortem on the Microsoft Learn MCP server, splits the operation into two tools: search returns a list of {id, summary} pairs, and fetch takes an id and returns the full content of exactly one document. Two round-trips instead of one, but each is small and the model chooses which documents actually deserve the full read.

# server.py — FastMCP search-then-fetch pair
from fastmcp import FastMCP
from pydantic import BaseModel

mcp = FastMCP("docs-server")

class Hit(BaseModel):
    id: str
    title: str
    summary: str

@mcp.tool
def search(query: str, limit: int = 10) -> list[Hit]:
    """Search docs for the top matches. Returns id + summary only;
    call fetch(id) for the full document body."""
    return [Hit(id=r.id, title=r.title, summary=r.snippet)
            for r in index.search(query, k=limit)]

@mcp.tool
def fetch(id: str) -> str:
    """Return the full body of one document by id.
    Use after search() to pull only the documents you actually need."""
    return index.get(id).body

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

A few things are doing work here. The docstrings are the tool descriptions the model reads — they are structurally the same as system-prompt text (see agent-facing docs), and they explicitly cross-reference each other so the model knows search and fetch are two halves of one workflow. The Hit Pydantic model becomes a structured output schema on the tool response; a caller can rely on the shape. Type hints on the function signatures — including the default on limit — flow into the inputSchema that ships on tools/list and let the host validate arguments before the call.

What the client actually sees on tools/list:

{
  "jsonrpc": "2.0", "id": 2, "result": {
    "resultType": "complete",
    "ttlMs": 300000,
    "cacheScope": "public",
    "tools": [
      { "name": "search",
        "description": "Search docs for the top matches. Returns id + summary only; call fetch(id) for the full document body.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "query": {"type": "string"},
            "limit": {"type": "integer", "default": 10}
          },
          "required": ["query"]
        }
      },
      { "name": "fetch",
        "description": "Return the full body of one document by id. Use after search() to pull only the documents you actually need.",
        "inputSchema": {
          "type": "object",
          "properties": {"id": {"type": "string"}},
          "required": ["id"]
        }
      }
    ]
  }
}

Three fields on that result are new under 2026-07-28, and none of them is decoration. resultType sits on the result object — result.resultType, never on the JSON-RPC envelope — and carries "complete", "input_required", or an extension value such as Tasks' "task". Clients MUST treat an absent resultType as "complete", which is the hinge that keeps pre-revision servers working; it is a compatibility rule, not permission to omit the field. "input_required" is the signal that starts a multi-round-trip exchange, and deciding when a tool should emit one is a tool-design question.

ttlMs and cacheScope MUST accompany any resultType: "complete" result returned from tools/list, prompts/list, resources/list, resources/read and resources/templates/list. ttlMs is integer milliseconds and MUST be >= 0; cacheScope is "public" or "private". This is a design decision with teeth rather than a field to fill in. An absent ttlMs means the client assumes 0 — immediately stale — so a server that omits it is telling every client never to cache its tool list, and paying for a re-list on every turn. The number you do choose is how long a fixed description or a corrected schema keeps being served from cache after you ship the fix, which puts ttlMs squarely inside the versioning problem below. cacheScope: "public" marks a result identical for every caller, safe for a shared cache in front of you; "private" marks it caller-specific. If your tools/list varies with the caller's permissions, it is "private".

That is the entire contract for two tools: two names, two descriptions written for the model to read, two schemas the host can validate, and the three fields that tell the client what kind of result it is holding and for how long. Everything else — transport, framing, the per-request _meta — the SDK handles. What the SDK cannot do is choose your ttlMs for you.

STEP 5

The things you'll get wrong on your first server.

Every first server hits the same handful of mistakes, and they are consistent enough to itemise.

Forgetting annotations. MCP tools carry an optional annotations object with fields like destructiveHint, idempotentHint, and openWorldHint that the host uses to shape consent UX — whether to ask before running, whether to allow silent retries, whether a call may reach the public internet. Servers that omit annotations force the host to fall back to conservative defaults, which usually means "always prompt the user," which usually means the user turns your server off.

Descriptions written for humans. Free-text tool descriptions that read like docstrings — "Retrieves user information from the database" — are not what an agent needs. The description is text the model reads during tool selection, so it should be instruction-shaped: start with a verb, name the use case, and say when not to use it. "Get a user by id when you need their email, role, or account status. Do not use for listing users." reads awkwardly to a human and works vastly better in practice.

Unversioned schemas. Silently renaming a parameter breaks every client that cached the schema. Microsoft's post-mortem on the Learn server named a specific number — 2–5% of clients broke on a single parameter rename — because clients had cached the pre-rename tool definition — and under 2026-07-28 you are the one who told them how long to keep it. If the schema changes in a way that isn't purely additive, the tool needs a new name or the server needs a version bump; a rename in place is a silent break.

Shipping resources you never test. A common pattern is registering resources because "it feels right," then discovering your only client — the agent — never actually reads them because your host doesn't surface resources to the model. Either use them, plumb them into a real code path, and cover them in tests, or drop them. Dead capability is worse than absent capability because it looks like coverage in the manifest.

The stdio-vs-HTTP assumption. A lot of first-server work assumes stdio is always the dev transport and Streamable HTTP is always the prod transport. Both directions are wrong. Local stdio servers are shipping in production — that's what most IDE-embedded servers are — and remote HTTP servers are perfectly reasonable for development against a shared team environment. Pick the transport based on where the server needs to run and how it will be authenticated, not on whether you consider the setup "dev" or "prod."

Emitting a legacy error code. Resource-not-found moved from -32002 to -32602, and a 2026-07-28 server MUST NOT emit -32002 — though your client-side code SHOULD still accept it from older servers. The ranges matter as much as the individual move: -32000–-32019 is a legacy range and new codes MUST NOT be allocated there, -32020–-32099 is reserved to the specification and implementations MUST NOT emit codes in it that the spec does not define, and your own application codes SHOULD sit outside -32768–-32000 entirely. Grabbing a free-looking number inside the reserved band is the class of bug that works perfectly until the spec allocates it.

Treating the connection as a session. Per-connection state is gone and nothing replaced it. State that spans requests "MUST be referenced by an explicit identifier the client passes on each request," and the spec is blunt that "an open connection, such as a STDIO process, is not a conversation or session." A stdio server that stashes the logged-in user on the process, or an HTTP server that keys a cache on the socket, is building on a fact the protocol no longer supplies. The replacement — a handle you hand back in a tool result and the model returns to you as an argument — is a smaller change to your code and a much larger change to your threat model, which is why it is worked through on the tool-design side.

The through-line for every one of these: the parts that look like decoration — descriptions, annotations, versioning discipline, choosing the right primitive — are the parts that decide whether the server disappears from your debugging queue or lives there permanently. Get them right on the first five tools and everything downstream, from selection accuracy to trace readability to auth complexity, becomes cheaper.