An MCP server in Python is a process that exposes tools, resources, and prompts over a standard protocol, and with the official SDK the smallest useful one is about fifteen lines.
The protocol part is genuinely easy. What is not obvious from the quickstarts is that the moment your server stops running on your laptop next to the client, almost every decision changes: transport, authentication, state, and what happens when a tool call takes ninety seconds.
Table of contents
- The smallest server that does something
- Tools, resources, and prompts
- Transports, and the one that actually matters
- What changes when it leaves your laptop
- Writing tools a model will use correctly
- Deploying it
- How this fits the rest of the stack
- FAQ
The smallest server that does something
Install the SDK. It needs Python 3.10 or newer.
pip install "mcp[cli]"
Then the server itself. FastMCP handles the protocol handshake, message routing, and schema generation, so what you write is mostly ordinary Python.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("inventory")
@mcp.tool()
def check_stock(sku: str) -> dict:
"""Return current stock level and warehouse for a SKU."""
row = db.fetch_one("SELECT qty, warehouse FROM stock WHERE sku = %s", (sku,))
if row is None:
return {"sku": sku, "found": False}
return {"sku": sku, "found": True, "qty": row[0], "warehouse": row[1]}
if __name__ == "__main__":
mcp.run()
The type hints are not decoration — the SDK reads them to generate the JSON schema the model sees. The docstring becomes the tool description. Both are load-bearing, which means a vague docstring produces a tool the model calls at the wrong moments.
Tools, resources, and prompts
Three primitives, and the split is about who initiates.
- Tools — actions the model chooses to invoke. Roughly a POST. Side effects live here.
- Resources — data the client loads into context. Roughly a GET. Should be safe to fetch repeatedly.
- Prompts — reusable templates the user explicitly triggers.
@mcp.resource("config://limits")
def rate_limits() -> str:
"""Current per-plan API rate limits."""
return json.dumps(load_limits())
@mcp.prompt()
def restock_review(sku: str) -> str:
return f"Review stock history for {sku} and recommend a reorder quantity."
The distinction matters for safety. Anything that mutates state, spends money, or sends a message belongs in a tool, because tools are the primitive clients treat as requiring consent. Putting a destructive operation behind a resource is a way of getting it invoked without anyone approving it.
Transports, and the one that actually matters
The SDK speaks stdio, Streamable HTTP, and SSE. The choice is not really a preference — it follows from where the server runs.
stdio is the default and the right answer for a local server. The client launches your process and talks over stdin and stdout. No ports, no auth, no network. It also means the server only exists while that client is running, and only that client can reach it.
Streamable HTTP is what you want for a server that runs somewhere else and serves more than one client. This is the transport for anything deployed.
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
SSE is the older HTTP transport, kept for compatibility. New servers should use Streamable HTTP.
One consequence people miss: with stdio, printing to stdout corrupts the protocol stream, because stdout is the transport. Every log line has to go to stderr. If your server works over HTTP and mysteriously breaks over stdio, look for a stray print().
What changes when it leaves your laptop
A deployed MCP server is a long-running HTTP service that an AI system calls, and it inherits every ordinary concern that comes with that.
Authentication. stdio needed none because the client owned the process. An HTTP endpoint reachable on the internet with no auth is a set of tools anyone can call. Whatever your tools can reach — the database, the internal API, the filesystem — is now reachable by whoever finds the URL.
Secrets. The database password your tool uses cannot be in the source. Environment variables injected at runtime, not a config file next to the code.
Timeouts. A tool that takes ninety seconds will get abandoned by the client. Either make it fast or make it asynchronous — return a job id immediately and expose a second tool that checks status.
Concurrency. Multiple clients call the same process. Module-level mutable state that worked fine for one local client becomes a race condition. Keep per-request state in the request.
Observability. When a model calls a tool and gets a wrong answer, you need to see which tool, what arguments, and what it returned. Log every tool invocation with its inputs and outcome. Structure it so you can filter.
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
log = logging.getLogger("mcp.inventory")
@mcp.tool()
def check_stock(sku: str) -> dict:
"""Return current stock level and warehouse for a SKU."""
log.info("check_stock sku=%s", sku)
...
Writing tools a model will use correctly
The failure mode nobody warns you about is not a crash. It is a tool that works perfectly and gets called at the wrong time, because its description was ambiguous.
- One job per tool.
manage_inventorywith amodeparameter is three tools wearing a trench coat, and the model will pick the wrong mode. - Say when not to use it. “Returns cached stock, refreshed hourly. Do not use for real-time reservation checks.” That sentence prevents a class of wrong answers.
- Return structured data, not prose. A dict the model can read beats a sentence it has to parse.
- Make failures explicit.
{"found": false}is better than raising, and far better than returning an empty result that reads like a legitimate zero. - Constrain the inputs.
Literal["pending", "shipped", "cancelled"]becomes an enum in the schema, and the model stops inventing status values.
The rule of thumb: write the description for a competent new colleague who has your API docs but no context about your business. That is roughly the position the model is in.
Deploying it
A Python MCP server over Streamable HTTP is an ordinary long-running web service. It needs a runtime that stays up, environment variables for its credentials, a URL clients can reach, and logs you can read when a tool starts returning nonsense.
That is the shape RunxBuild is built for. Push the repository, set the port, inject the database URL and API keys as environment variables rather than baking them into the image, and the service gets a live route. Deploy logs show the build; runtime logs show every tool call your logging wrote. If a bad deploy ships a broken tool schema, roll back to the previous one rather than debugging live.
Agents need somewhere to run that is not a laptop with a terminal open. The protocol is the easy half. The runtime, the secrets, the permission boundary, and the observability are the half that decides whether the thing is dependable.
How this fits the rest of the stack
An MCP server that anything depends on is a production service with a production bill: the runtime that stays up, the database its tools query, the storage, and the egress. The RunxBuild hosting calculator puts those line items on one page so you can model the whole shape before committing.
Useful related references:
- Python Not Equal: != vs is not, and Why the Difference Bites
- Python Integer Division: Why // Floors and Why -7 // 2 Is -4
- Python for Websites: Where It Fits and Where It Does Not
- Python services on RunxBuild
FAQ
What do I need to build an MCP server in Python?
Python 3.10 or newer and the official SDK, installed with pip install mcp[cli]. The FastMCP class handles the protocol handshake and message routing, so a working server with one tool is about fifteen lines. Type hints generate the JSON schema and the docstring becomes the tool description.
What is the difference between a tool and a resource in MCP?
Tools are actions the model chooses to invoke and are where side effects belong — clients treat them as requiring consent. Resources are data loaded into context and should be safe to fetch repeatedly. Putting a destructive operation behind a resource is a way of getting it run without approval.
Which MCP transport should I use?
stdio for a local server the client launches itself — no ports or auth needed. Streamable HTTP for anything deployed remotely or serving multiple clients. SSE exists for backwards compatibility and new servers should not use it.
Why does my MCP server break when I add a print statement?
Over stdio, stdout is the protocol stream, so anything printed there corrupts the messages. Send all logging to stderr instead — logging.basicConfig(stream=sys.stderr). The same code works fine over HTTP, which is why the bug looks transport-specific.
How do I stop a model calling the wrong tool?
Narrow the tool and sharpen the description. One job per tool rather than a mode parameter, an explicit sentence about when not to use it, constrained input types like Literal so the schema carries an enum, and structured returns with explicit failure fields instead of exceptions or ambiguous empty results.