A hands-on tutorial for building a Model Context Protocol server that exposes tools, resources, and prompts to any LLM host.
The Model Context Protocol gives your LLM host a standard way to call tools, read data, and reuse prompts without bespoke glue code for every integration. If you have wired up a function-calling loop before, MCP will feel familiar, except the wiring is now a protocol that any compliant client can speak. Write a capability once as a server and any MCP client, whether Claude, Cursor, or your own agent, can use it. This tutorial walks through a working server, a client connection, and the parts people usually skip until an incident forces them to care.
If you want the wider context first, read what is the Model Context Protocol. For where servers fit in a larger system, see the building AI agents guide.
One note before code: the SDKs move fast. Treat every snippet here as a starting shape and verify against current SDK versions before you rely on exact imports or signatures.
You need Python 3.10+ (or Node 18+ if you prefer the TypeScript SDK), a working virtual environment, and a host to test against. Claude Desktop is the easiest local target because it reads a config file and spawns your server for you.
Install the official Python SDK, which ships FastMCP:
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]"
The TypeScript equivalent is npm install @modelcontextprotocol/sdk. The concepts below map one to one; the Python version is more compact for a tutorial.
An MCP server exposes three kinds of capability, and picking the right one is the difference between an agent that works and one that flails.
The common beginner mistake is to model everything as a tool. If the model only needs to read something, expose it as a resource so it does not consume a tool call or risk an unintended side effect. Reserve tools for genuine actions, and your permission model stays simple.
A tool needs a name, a description the model reads to decide when to use it, a typed input schema, and a handler. FastMCP derives the schema from your type hints, so validation is mostly free. It also eliminates the protocol boilerplate: JSON-RPC handling, capability negotiation, schema generation, and transport plumbing all happen behind one decorator, so you spend your effort on tool design and safety instead of framing bytes.
# server.py
import logging
from mcp.server.fastmcp import FastMCP
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("weather-mcp")
SERVER_VERSION = "1.2.0"
mcp = FastMCP("weather-mcp")
@mcp.tool()
def get_forecast(city: str, days: int = 3) -> dict:
"""Return a short forecast for a city. days must be 1-7."""
if not city.strip():
raise ValueError("city must not be empty")
if not 1 <= days <= 7:
raise ValueError("days must be between 1 and 7")
# Real call would hit a weather API here.
log.info("get_forecast city=%s days=%s", city, days)
return {
"city": city,
"days": days,
"summary": f"Clear skies in {city} for {days} days",
}
@mcp.resource("config://units")
def units() -> str:
"""Expose the unit system as a readable resource."""
return "metric"
@mcp.prompt()
def trip_check(city: str) -> str:
"""Reusable prompt template for a packing suggestion."""
return f"Given the forecast for {city}, suggest what to pack."
if __name__ == "__main__":
mcp.run(transport="stdio")
That single file already exposes a tool, a resource, and a prompt. A Resource is read-only context addressed by a URI like config://units: use it for config, a file listing, or a cached document the model should read but not trigger. A Prompt is a named, parameterized template the host can surface as a slash command. Neither executes side effects, which is exactly why they are separate from tools.
An LLM chooses tools purely from their names, descriptions, and parameter docs. Vague descriptions produce wrong tool choices and hallucinated arguments. Be explicit about what the tool does, when to use it, what each parameter means, and what it returns. To the agent, the docstring and type hints are the tool's user interface.
@mcp.tool()
def search_orders(customer_email: str, status: str = "any") -> list[dict]:
"""Search a customer's orders by email address.
Use this when the user asks about the status or history of their orders.
Do NOT use it for product catalog questions; use search_catalog instead.
Args:
customer_email: The customer's exact email address.
status: One of "any", "open", "shipped", "cancelled". Defaults to "any".
Returns:
A list of orders, each with id, status, total, and placed_at.
"""
...
Naming the wrong uses ("do NOT use it for...") is as valuable as naming the right ones. It stops the model from reaching for the tool in situations it does not fit.
Return structured data, not prose. A tool that returns a typed object lets the client validate the shape and lets the model reason over fields reliably. Current SDKs support structured output via typed return values or Pydantic models; verify the exact API against current SDK versions.
from pydantic import BaseModel
class Order(BaseModel):
id: str
status: str
total_cents: int
placed_at: str
@mcp.tool()
def get_order(order_id: str) -> Order:
"""Fetch a single order by its ID."""
row = db.fetch_order(order_id)
return Order(**row)
Structured returns also make your server testable: you assert on fields, not on the wording of a sentence.
When a tool fails, the model needs to understand the failure so it can recover: retry, ask the user, or pick a different path. Raise a normal exception on bad input and the SDK returns a structured tool error rather than crashing the process, which is the right behavior for validation. But for expected, recoverable failures, return an actionable error result the model can act on. A raw stack trace teaches the model nothing.
@mcp.tool()
def refund_order(order_id: str) -> dict:
"""Issue a refund for an order. Returns {ok, message}."""
order = db.fetch_order(order_id)
if order is None:
return {"ok": False, "message": f"No order found with id {order_id}. Ask the user to confirm the ID."}
if order["status"] == "refunded":
return {"ok": False, "message": "Order is already refunded; no action taken."}
db.refund(order_id)
return {"ok": True, "message": f"Refunded order {order_id}."}
The messages tell the model what to do next. That is what turns a failed call into a recovered conversation instead of a dead end. Whatever you return, do not leak stack traces or secrets in error text, because it goes back to the model and often into logs. Keep messages specific enough to be actionable and generic enough to be safe.
stdio: The server runs as a subprocess of the host and they talk over stdin/stdout. This is the right default for local development and desktop clients. No ports, no auth, no network surface. It inherits the trust boundary of the user who launched it.
Streamable HTTP: The server runs as a long-lived HTTP service, and is the modern replacement for the older HTTP+SSE transport. This is what you want for remote or shared deployments, multiple concurrent clients, or anything behind a load balancer. Switch by changing the run call:
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
Same tools, same handlers. Only the wire changes, but the threat model changes with it. A streamable-HTTP server is an internet-facing service that any client, and any attacker who finds the URL, can reach. If you are reaching for HTTP only because it is easier to test remotely, prefer stdio plus the Inspector locally and adopt HTTP only when you genuinely serve multiple clients. Choosing the transport is choosing a threat model.
For Claude Desktop, add your server to its config file (claude_desktop_config.json on macOS lives under ~/Library/Application Support/Claude/). Restart the app and the tools appear.
{
"mcpServers": {
"weather": {
"command": "/path/to/.venv/bin/python",
"args": ["/path/to/server.py"]
}
}
}
For a code-driven agent, most frameworks now ship an MCP client adapter. A LangGraph or CrewAI agent connects to the HTTP endpoint, lists the tools, and binds them to the model the same way it would native functions. The point of the protocol is that this glue is written once, not per server.
Do not debug your server by talking to it through an LLM; that mixes two sources of error. The MCP Inspector is a local UI that connects directly to your server so you can list tools, call them with test inputs, and read raw responses.
# Launch the inspector against your server (no client or LLM involved)
npx @modelcontextprotocol/inspector python server.py
Verify every tool's schema renders correctly, each call returns the structured shape you expect, and error cases produce helpful messages. Only once the server is correct in isolation should you connect it to Claude or Cursor.
The moment you expose Streamable HTTP, you own an authenticated endpoint. The current MCP spec standardizes on OAuth 2.1: the server acts as a resource server, validates a bearer token on every request before any handler runs, and scopes what the caller may do. Never expose a mutating HTTP MCP server without it.
from mcp.server.fastmcp import FastMCP
from mcp.server.auth.settings import AuthSettings
mcp = FastMCP(
"orders",
auth=AuthSettings(
issuer_url="https://auth.example.com",
resource_server_url="https://mcp.example.com",
required_scopes=["orders:read"],
),
)
Scope tools to the least privilege that works: a read-only integration should never hold a token that can issue refunds. Validate the token's audience so a token minted for another service cannot be replayed against yours. Rate-limit per caller so one client cannot exhaust a downstream API on everyone's behalf. Confirm the exact AuthSettings shape against current SDK versions.
MCP introduces threat models beyond ordinary APIs, because an autonomous model is the caller. Treat every tool argument as untrusted input, and design against these explicitly. For the broader picture, see prompt injection defense.
run_sql(query) tool hands the model your whole database. Prefer narrow, purpose-built tools whose blast radius is bounded by design.@mcp.tool()
def delete_project(project_id: str, confirm: bool = False) -> dict:
"""Delete a project. Must be called with confirm=True to actually delete."""
if not confirm:
p = db.fetch_project(project_id)
return {"ok": False, "requires_confirmation": True,
"message": f"This deletes '{p['name']}' and {p['asset_count']} assets. Re-call with confirm=true."}
db.delete_project(project_id)
return {"ok": True, "message": f"Deleted {project_id}."}
Local correctness is not production readiness. A few things earn their keep the first time something breaks.
Observability: Tag every tool call with a correlation id and thread it through your downstream calls, so one slow request is traceable end to end. Log the server version and a hash of each tool's schema at startup. When a host reports weird behavior, the schema hash tells you in seconds whether you are both looking at the same tool definition. Log every invocation with the calling identity for audit.
Partial failures: A tool that fans out to three services should not return all-or-nothing. Return the results you have plus a clear per-item error, and let the model decide how to proceed. Silent partial success is the failure mode that wastes the most debugging time.
Versioning: Renaming or changing a tool's arguments is a breaking change for every host bound to it. Bump SERVER_VERSION, log it, and roll schema changes out deliberately rather than mid-conversation. Treat the tool list like a public API.
Health and limits: Add a health check, set timeouts on every outbound call, and cap concurrency so a burst of tool calls degrades gracefully. Remote servers ship like any other service: containerized, TLS-terminated, health-checked, and rate-limited.
run_sql or run_shell tool with an unbounded blast radius.Start on stdio with a single tool and a real host. Get the description and validation right before you add a second tool, because a vague description costs you more than a missing feature. Verify it in isolation with the Inspector, then connect a host. Move to Streamable HTTP only when you actually need remote access, and add OAuth in the same change, not later. Ship the observability hooks on day one; correlation ids and a schema hash are cheap now and priceless during an incident. Then verify the whole thing against the current SDK before you call it done.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A practical guide to AIOps and self-healing infrastructure: the maturity ladder from alert to autonomous remediation, observability as the foundation, anomaly detection, Kubernetes-native self-healing, an operator-driven remediation loop, agentic SRE, and the guardrails that keep automation safe.
A practitioner's guide to tracing, evaluating, and debugging LLM agents in production with the tools that actually earn their keep.
Explore more articles in this category
Small language models now handle most agent steps at a fraction of the cost, so pick per step instead of defaulting to a frontier model.
AI agents went from demos to production this year. This is the map: the frameworks, the protocol tying them together, and the patterns that actually ship.
A practitioner's tour of the reusable patterns for building reliable LLM agents, and when each one earns its keep.
Evergreen posts worth revisiting.