A hands-on guide to the Model Context Protocol: what MCP is and why it won, a minimal FastMCP server, tools vs resources vs prompts, stdio vs streamable HTTP, OAuth 2.1 for remote servers, testing with the inspector, and deploying safely — with copy-paste code.
Eighteen months ago the Model Context Protocol was an obscure Anthropic spec. In 2026 it is the connective tissue of the entire AI ecosystem — adopted natively by Anthropic, OpenAI, Google, and Microsoft, with SDK downloads up roughly 970× and installs measured in the tens of millions. If you build anything that touches an LLM, the question is no longer whether you will write an MCP server, but how to write one that is correct, secure, and pleasant for an agent to use. This guide is the practical path from "what is MCP?" to a deployed, hardened server, with copy-paste code at every step.
MCP is often called "the USB-C for AI," and the analogy is apt: before it, every assistant integrated every tool with a bespoke connector, an N×M explosion that nobody could maintain. MCP standardizes the plug. Write your capability once as an MCP server and any MCP-compatible client — Claude, Cursor, an internal agent — can use it. That standardization is exactly why adoption went vertical, and why a well-built server is now high-leverage infrastructure.
An MCP server exposes three kinds of capability, and using 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.
The fastest correct start in 2026 is the official Python SDK's FastMCP. Decorate a function, describe it well, and the SDK handles the protocol.
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
@mcp.tool()
def get_forecast(city: str, days: int = 3) -> dict:
"""Return a short weather forecast for a city.
Args:
city: City name, e.g. "Vilnius".
days: Number of days to forecast (1-7).
"""
# ... call a real weather API here ...
return {"city": city, "days": days, "summary": "Sunny, light wind"}
if __name__ == "__main__":
mcp.run() # stdio transport by default
The docstring is not decoration — the client shows it to the model as the tool's description, and the type hints become the input schema. Treat both as part of your API's user interface, because to the agent, they are.
What FastMCP buys you is the elimination of protocol boilerplate: the JSON-RPC message handling, the capability negotiation with the client, the schema generation from your type hints, and the transport plumbing all happen behind that one decorator. That lets you spend your effort where it actually matters — on tool design and safety — instead of on framing bytes. The lower-level SDK is still there when you need fine control over the server lifecycle or custom capabilities, but reach for it deliberately; for the overwhelming majority of servers, staying on FastMCP keeps the surface small and the bugs few.
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.
@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. In 2026 the SDK supports structured output directly via typed return values or Pydantic models.
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. An unhandled exception that dumps a stack trace teaches the model nothing. Return a clear, actionable error result instead.
@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}."}
Notice 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.
MCP servers speak over a transport, and the choice depends on where the server runs.
# Run the same server over streamable HTTP for remote clients
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
Rule of thumb: local, single-user, filesystem-adjacent → stdio. Shared, multi-tenant, behind a URL → streamable HTTP (and read the next section, because now you need auth).
The security implications of the two transports differ sharply and are easy to underestimate. A stdio server inherits the trust boundary of the user who launched it and never touches the network, which is why local developer tools favor it. A streamable-HTTP server, by contrast, is an internet-facing service that any number of clients — and any number of attackers who find the URL — can reach, so everything you would do for a normal web API applies and then some. If you find yourself reaching for HTTP only because "it's easier to test remotely," prefer stdio plus the Inspector locally, and adopt HTTP only when genuinely serving multiple clients. Choosing the transport is choosing a threat model, not just a connection type.
The moment your server is reachable over the network, it needs authentication and authorization. The 2026 MCP spec standardizes on OAuth 2.1: the server acts as a resource server, validates a bearer token on every request, 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"],
),
)
@mcp.tool()
def get_order(order_id: str) -> dict:
"""Requires the 'orders:read' scope (enforced by the auth layer)."""
...
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.
MCP introduces threat models beyond ordinary APIs, because an autonomous model is the caller. Design against them explicitly:
run_sql(query) tool hands the model your whole database. Prefer narrow, purpose-built tools (get_order, search_orders) 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 will permanently delete '{p['name']}' and {p['asset_count']} assets. Re-call with confirm=true to proceed."}
db.delete_project(project_id)
return {"ok": True, "message": f"Deleted {project_id}."}
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/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.
A local stdio server ships as a command your client config points at. A remote server ships like any other service — containerized, health-checked, and observable.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
EXPOSE 8000
CMD ["python", "server.py"] # runs streamable-http on :8000
// Claude Desktop config for a local stdio server
{
"mcpServers": {
"orders": { "command": "python", "args": ["/abs/path/to/server.py"] }
}
}
For remote deployments, put the server behind TLS, enforce the OAuth layer from section 7, log every tool invocation with the calling identity for audit, and rate-limit per client. Version your tool surface: renaming or changing a tool's arguments is a breaking change for every agent that learned it, so treat the tool list like a public API.
run_sql/run_shell tool. Prefer narrow tools with a bounded blast radius.In 2026, MCP is the interface between your systems and every AI agent that will ever use them. A server that is well-described, structured, least-privileged, and versioned turns your capabilities into safe, reusable building blocks for the entire agent ecosystem — which is exactly why building good ones is one of the highest-leverage things an engineer can do this year.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
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.
Explore more articles in this category
The LLM stack is a maze of APIs, GPU clouds, gateways, and serving tools. This is the map to what each layer is for and how to keep the bill sane.
Once you call more than one LLM provider, a gateway saves you from reinventing routing, fallback, caching, and spend limits in every service.
A practitioner comparison of the RAG frameworks worth using in 2026, from LlamaIndex and LangChain to Haystack, DSPy, and raw code.
Evergreen posts worth revisiting.