Most MCP server examples you'll find online are 40 lines long, have no auth, and crash the moment a tool call throws an exception. That's fine for a demo. It's not fine for something a real agent will call thousands of times a day on behalf of real users.
This walkthrough builds up a minimal but genuinely production-ready MCP server, piece by piece, focusing on the three things toy examples always skip:
- Authentication — so random clients can't call your tools
- Session handling — so state doesn't leak between users or die on restart
- Error recovery — so one bad tool call doesn't take down the whole server
If you're still deciding whether you need MCP at all versus a simple script, that's a separate question worth answering first — this post assumes you've already decided MCP is the right shape for your problem and now you need to ship it safely.
The bare-minimum server (what most tutorials stop at)
A typical "hello world" MCP server looks like this:
from mcp.server import Server
from mcp.server.stdio import stdio_server
app = Server("demo-server")
@app.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "get_weather":
return [{"type": "text", "text": f"Sunny in {arguments['city']}"}]
async def main():
async with stdio_server() as (read, write):
await app.run(read, write, app.create_initialization_options())
This works great locally over stdio with one trusted client. It has no concept of who is calling, no memory between calls, and if arguments['city'] is missing it just crashes the process. Let's fix all three.
1. Authentication: don't trust the transport
If you're exposing your MCP server over HTTP/SSE (which you need to do the moment more than one client or a hosted agent talks to it), stdio's implicit trust disappears. Every request needs to prove who it is.
The simplest production-safe pattern is a bearer token checked on every request, validated against a store you control (not hardcoded):
from fastapi import FastAPI, Header, HTTPException
import hashlib, hmac
VALID_TOKEN_HASHES = {
# store hashed tokens, never raw tokens
"a3f8...": {"client_id": "agent-prod-1", "scopes": ["read:tools"]},
}
def verify_token(token: str) -> dict:
token_hash = hashlib.sha256(token.encode()).hexdigest()
client = VALID_TOKEN_HASHES.get(token_hash)
if not client:
raise HTTPException(status_code=401, detail="invalid token")
return client
app = FastAPI()
@app.post("/mcp/call")
async def call_tool_endpoint(
payload: dict,
authorization: str = Header(None),
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")
token = authorization.removeprefix("Bearer ")
client = verify_token(token)
# client["scopes"] now gates which tools this caller can invoke
return await dispatch_tool_call(payload, client)
Two things matter here that toy examples never mention:
- Hash tokens at rest. If your token store leaks, raw tokens are game over. Hashed tokens are not.
- Scope tokens per client, not per server. A CI agent and a customer-facing agent should not share a token with identical permissions. This is exactly the kind of permission-scoping gap that causes real incidents when an agent gets access it was never meant to have.
If you need real user-level auth (not just service-to-service), OAuth 2.1 with PKCE is becoming the de facto standard for hosted MCP servers — but bearer tokens are a completely legitimate starting point for most internal or B2B agent deployments, and you can add OAuth later without rearchitecting the tool layer.
2. Session handling: state that survives more than one request
Toy servers keep everything in a global dict or, worse, in-process variables that reset the moment the server restarts. In production you need sessions that:
- are isolated per client/user
- survive a server restart or redeploy
- expire automatically so old sessions don't pile up
import time
import json
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
SESSION_TTL_SECONDS = 60 * 30 # 30 minutes
def get_session(session_id: str) -> dict:
raw = r.get(f"session:{session_id}")
if raw is None:
return {"created_at": time.time(), "history": []}
return json.loads(raw)
def save_session(session_id: str, session: dict):
r.setex(
f"session:{session_id}",
SESSION_TTL_SECONDS,
json.dumps(session),
)
async def dispatch_tool_call(payload: dict, client: dict):
session_id = payload.get("session_id") or f"{client['client_id']}-{int(time.time())}"
session = get_session(session_id)
session["history"].append({"tool": payload["tool"], "args": payload["arguments"]})
result = await run_tool(payload["tool"], payload["arguments"], session)
save_session(session_id, session)
return {"result": result, "session_id": session_id}
Redis here is a stand-in — a small SQLite table works fine at low volume. The point isn't the storage engine, it's the pattern: session state lives outside the process, has a TTL, and is keyed so one client can never read another's session. That last part is easy to get wrong when you're moving fast, and it's the kind of thing worth putting on a checklist rather than trusting memory to catch.
3. Error recovery: one bad tool call shouldn't kill the server
This is the gap that hurts the most in practice. An agent will eventually call a tool with malformed arguments, a downstream API will time out, or a tool will throw an unhandled exception. A toy server crashes. A production server catches it, logs it usefully, and returns something the calling agent can actually reason about.
import logging
import traceback
logger = logging.getLogger("mcp-server")
class ToolExecutionError(Exception):
def __init__(self, message: str, retryable: bool = False):
self.message = message
self.retryable = retryable
super().__init__(message)
async def run_tool(name: str, arguments: dict, session: dict):
try:
tool_fn = TOOL_REGISTRY.get(name)
if tool_fn is None:
raise ToolExecutionError(f"unknown tool: {name}", retryable=False)
return await tool_fn(arguments, session)
except ToolExecutionError as e:
logger.warning(f"tool_error tool={name} retryable={e.retryable} msg={e.message}")
return {
"error": True,
"message": e.message,
"retryable": e.retryable,
}
except Exception as e:
# unexpected error — log full trace, return a safe generic message
logger.error(f"unexpected_error tool={name}\n{traceback.format_exc()}")
return {
"error": True,
"message": "internal error, try again",
"retryable": True,
}
Two design choices here matter more than they look:
-
retryableis part of the response contract. An agent orchestrator can decide to auto-retry a timeout but not auto-retry a bad-arguments error. Without this flag, agents tend to either retry everything (wasteful, sometimes dangerous) or nothing (brittle). - Never leak stack traces to the caller. Log them server-side, return a generic message to the client. Stack traces in tool responses are a surprisingly common accidental information leak.
Putting it together
The full request path now looks like: bearer token verified → session loaded from durable storage → tool executed inside a try/except that classifies errors → session saved → structured response returned. None of this is exotic engineering — it's the same auth/session/error-handling discipline you'd apply to any API. MCP just makes it easy to skip because the demos never show it.
Where to go from here
Writing this layer from scratch for every new MCP server is exactly the kind of repeated setup work that's easy to get subtly wrong under deadline pressure — a missing TTL here, an unscoped token there. If you'd rather start from a server that already has this wired up correctly, the AgentKitLab MCP Production Checklist pack includes a minimal working server template with auth, sessions, and error recovery already implemented, plus a checklist to audit an existing server against, and agent-eval test templates to catch regressions before they ship. It's on Gumroad for $9–$29 depending on the tier: check it out here.
If you're building agent tooling more broadly and want a second pair of eyes on where things tend to break, the failure-mode patterns in autonomous coding agents are worth a read too — a lot of the same "it worked in the demo" gaps show up there.
Written with AI assistance and reviewed for accuracy.
Top comments (0)