A colleague pinged me last spring, quietly furious. He’d spent three days building an MCP server to give an internal agent access to the team’s Snowflake warehouse. The protocol part worked on day one. What ate the other two and a half days was everything the tutorials skip: the agent kept picking the wrong tool, the responses ballooned the token bill, and a stray print() statement silently corrupted every message the server sent. The server was “done” in ten lines. It was useful about a week later.
That gap — between a server that runs and a server that actually helps a model do its job — is the entire subject of this article. The Model Context Protocol has become the default way to plug LLMs into real systems, and building a basic server in Python is genuinely a ten-minute job. But the ten-minute version is a trap if you think you’re finished when it starts. The protocol is the easy part. Tool design is the hard part, and nobody warns you.

Every MCP interaction is this loop: discover, call, execute, return, use — the model never touches your Python directly.
TL;DR
- → An MCP server is a passive provider: it advertises tools, resources, and prompts, then waits for an AI client to call them over JSON-RPC. Your code never calls the model.
- → With FastMCP (version 3.4.0 as of June 2026, Python 3.10+), a working server is a decorated Python function plus
mcp.run()— roughly ten lines. - → There are exactly two standard transports: stdio for local servers the client launches as a subprocess, and Streamable HTTP for remote servers reachable over a URL.
- → A tool’s docstring and type hints are its prompt — the model chooses tools from that text, so vague descriptions cause wrong-tool selection more often than bad logic does.
- → Every tool schema is re-sent to the model on effectively every turn, so dumping forty tools into one server quietly inflates token cost and degrades tool selection.
- → For stdio servers, writing anything that isn’t a valid MCP message to
stdoutcorrupts the stream — the single most common “it worked yesterday” bug. - → Remote servers on Streamable HTTP need real auth; the 2025-06-18 spec standardized OAuth 2.1 for exactly this, and it is the part that turns a demo into a project.
What an MCP server actually is
Strip away the acronym and the mental model is simple: an MCP server is a small program that exposes capabilities in a shape an AI model understands, and then sits still. It does not orchestrate anything. It does not call the LLM. It answers a discovery request (“what can you do?”) and then runs whatever the client asks it to run. If you’ve read my walkthrough of building a Databricks AI agent where tools are just Python functions the LLM decides to call, MCP is that same idea lifted into an open standard so any client can use your tools without a custom integration.
The protocol defines three primitives. Tools are actions the model can invoke — run a query, send a message, create a ticket. Resources are read-only data the model can pull in for context — a file, a schema, a config. Prompts are reusable templates a user can trigger. Messages travel as JSON-RPC 2.0. The current protocol revision is 2025-06-18, and it is worth pinning that number because the transport story changed underneath it — more on that below.
Here’s the versioning landmine, since half the tutorials online get it wrong: FastMCP and “the MCP Python SDK” are related but not identical. FastMCP 1.0 was folded into the official SDK back in 2024, and then the standalone project kept going on its own track. Today the standalone FastMCP on PyPI is at 3.4.0, while blog posts still confidently reference “2.x” or a mythical “3.0 rewrite” as if that’s current. When you read example code, check which one it’s for — the decorator API differs in small, breaking ways between versions.
The ten-line server
Install it with FastMCP (uv is the fast path, plain pip works fine):
uv pip install fastmcp
# or: pip install fastmcp
The canonical first server is an addition tool. This is genuinely the whole file:
from fastmcp import FastMCP
mcp = FastMCP("Demo")
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two integers and return the sum."""
return a + b
if __name__ == "__main__":
mcp.run() # stdio transport by default
Notice what you did not write: no JSON-RPC handlers, no schema definitions, no transport plumbing. FastMCP reads your type hints to build the tool’s input schema and your docstring to describe it to the model. That’s the whole pitch of the framework, and it’s why it powers the large majority of MCP servers in the wild.
To try it before wiring it into a client, run the dev inspector, which launches a local UI that shows the raw JSON-RPC traffic:
fastmcp dev server.py
Call the tool from the inspector and you’ll see something like this — the request the client sends, and the structured result your function returned:
tools/call add {"a": 2, "b": 3}
result:
content: [{ "type": "text", "text": "5" }]
isError: false
✓ 1 tool available · transport: stdio · 0.4 ms
That’s it. You have a real, spec-compliant MCP server. Now let’s make one worth shipping.
From toy to useful: a data tool
An addition tool proves the wiring. A data engineer wants the model to answer questions against real tables — safely. The realistic pattern is a small, sharp toolset: one tool to inspect structure, one to run a constrained query, and a resource for the schema so the model has context before it writes any SQL. Here it is against a local SQLite database so it actually runs:
import sqlite3
from fastmcp import FastMCP
mcp = FastMCP("Warehouse")
DB = "analytics.db"
@mcp.resource("schema://tables")
def list_tables() -> str:
"""List every table name in the warehouse."""
con = sqlite3.connect(DB)
rows = con.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
con.close()
return "\n".join(r[0] for r in rows)
@mcp.tool
def run_query(sql: str) -> list[dict]:
"""Run a read-only SELECT and return rows as a list of dicts.
Only SELECT statements are permitted; anything else is rejected."""
if not sql.strip().lower().startswith("select"):
raise ValueError("Only SELECT queries are allowed.")
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
rows = [dict(r) for r in con.execute(sql).fetchall()]
con.close()
return rows
if __name__ == "__main__":
mcp.run()
Two design choices are doing real work here. The run_query docstring explicitly states the guardrail (“Only SELECT statements are permitted”) because the model reads that line and will lean on it. And the schema lives in a resource, not a tool, because it’s context the model should be able to pull in cheaply rather than an action it has to spend a tool call on. That distinction is the difference between an agent that guesses column names and one that doesn’t.
This is also exactly where the ecosystem is converging in the data world. dbt Labs shipped an open-source dbt MCP server so agents can query governed dbt assets instead of hallucinating them — if you already run dbt, that’s a ready-made server before you write a line, and it slots neatly next to the patterns in my Snowflake native dbt integration guide and the everyday commands in the dbt commands cheat sheet. The same logic applies to pipeline operations: the primitives you built in the Streams and Tasks pipeline guide or an OpenFlow ingestion flow are all candidates to expose as narrow, well-named tools.
stdio or Streamable HTTP: pick deliberately
MCP defines two standard transports, and choosing between them is mostly a question of where the server lives.
stdio — local, subprocess
The client launches your server as a child process and talks to it over standard input and output. This is what powers desktop integrations: the client owns the process, so there’s no network and no per-connection auth to speak of. It’s the default for mcp.run() and the right choice for anything that runs on the same machine as the client.
Streamable HTTP — remote, multi-client
Here the server is an independent process exposing a single HTTP endpoint (conventionally /mcp) that handles many clients at once, optionally streaming responses via Server-Sent Events. This replaced the older HTTP+SSE transport from the 2024-11-05 spec — if you find a tutorial wiring up a separate /sse endpoint, it’s aimed at the deprecated design. You switch transports through FastMCP’s run configuration (the exact argument is version-specific, so confirm it against the current docs rather than trusting a copied snippet):
if __name__ == "__main__":
mcp.run(transport="http", host="127.0.0.1", port=8000)
The moment you go remote, authentication stops being optional. The 2025-06-18 revision standardized OAuth 2.1 for exactly this case, and it is — every practitioner I’ve compared notes with agrees — the least fun part of the whole exercise. A universal protocol only helps if access to it is governed, which is the same argument behind industry standards efforts like the Open Semantic Interchange: agreeing on the interface is step one; controlling who gets through it is the real work.
The cost math nobody does upfront
Here’s the number that surprises people. Every tool your server exposes carries a schema — name, description, parameter types — and that schema is sent to the model as part of its context on essentially every turn where the tool is available. Tools aren’t free at rest; they’re a standing tax on your context window.
Say each tool’s schema and docstring run about 200 tokens once you account for parameter descriptions. A tight server with 5 tools costs roughly 1,000 tokens of overhead per call. A kitchen-sink server with 40 tools costs about 8,000 tokens per call before the model has read a single word of the user’s actual question. Across a 50-turn agent session, that’s the difference between ~50,000 and ~400,000 tokens spent purely on tool definitions — 350,000 wasted tokens per session, multiplied by every session, every day. There’s a documented case of a popular server pushing 43 tools into context and measurably degrading the agent’s performance before it did anything at all. The cost shows up twice: on your bill, and in worse tool selection, because the model now has to discriminate among forty near-identical options.
The fix is boring and effective: fewer tools, sharper boundaries. One run_query beats ten single-purpose query wrappers.
The gotchas nobody warns you about
A single print() will corrupt a stdio server. On the stdio transport, stdout is the message channel and must contain only valid MCP messages. A debug print(), a stray library log, a warning banner — any of it injected into stdout garbles the JSON-RPC stream and the client fails in ways that look like anything but the real cause. Log to stderr or a file, never stdout.
Your docstring is the model’s instruction manual, so treat it like one. The model selects tools by reading their descriptions. “Runs a query” and “Run a read-only SELECT against the analytics warehouse; rejects writes” produce measurably different behavior from the same underlying function. Vague docstrings are a correctness bug, not a style nit.
Blocking I/O stalls everything. A synchronous tool that makes a slow network or database call blocks the server’s event loop and freezes concurrent requests. For anything that waits on I/O, write an async tool (async def) so the server stays responsive under more than one client.
Return structured data, not stringified blobs. It’s tempting to json.dumps() everything into a text field. Let FastMCP serialize real Python objects — lists, dicts, dataclasses — so the model receives typed, parseable results instead of having to re-parse your string.
“It works in the inspector” is not “it works in the client.” The dev inspector is forgiving. Real clients enforce protocol version negotiation and transport details more strictly. Test against the actual client you intend to ship into before calling it done.
The one principle: design the tool, not the server
An MCP server is a UX problem wearing a protocol costume — the user just happens to be a language model. FastMCP makes the protocol disappear in ten lines precisely so you can spend your effort on the thing that actually determines whether the server is good: which tools exist, what they’re named, how their descriptions read, and what they refuse to do. Get the protocol working in the first ten minutes, then spend the real time on the interface. The teams whose agents feel sharp aren’t the ones who implemented MCP most cleverly. They’re the ones who designed the smallest, clearest set of tools and wrote docstrings like they meant them.
Related reading: Build a Databricks AI Agent with GPT-5 · Snowflake Native dbt Integration · dbt Commands Cheat Sheet · Snowflake Interview Questions 2026 · MCP specification (2025-06-18) · FastMCP docs · dbt Labs blog (dbt MCP server)