How to Build an MCP Server (Step by Step)

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.

Prerequisites #

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:

bash.bash

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.

The three primitives: tools, resources, prompts #

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.

Tools are actions the model can invoke: query a database, file a ticket, send a request. They have side effects and typed inputs. This is what most people mean by "an MCP server."

Resources are read-only data the client can load into context, addressed by a URI. They inform, they do not act.

Prompts are reusable, parameterized templates the user can invoke deliberately, like a slash command.

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.

Define a tool #

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.

python.python

# 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.

Write tool descriptions for a model, not a human #

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.

python.python

@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 typed, structured results #

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.

python.python

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.

Handle errors as data, not exceptions #

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.

python.python

@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.

Choose a transport #

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:

python.python

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.

Connect a host #

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.

json.json

{
"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.

Test with the MCP Inspector before wiring an agent #

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.

bash.bash

# 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.

Secure remote servers with OAuth 2.1 #

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.

python.python

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.

Guard against MCP-specific attacks #

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.

Prompt injection via tool output (tool poisoning): Data your tool returns can contain instructions that hijack the agent. Treat all tool output as untrusted, and consider marking or sanitizing free-text fields.

The confused deputy: Your server acts with its own credentials on behalf of a model driven by a possibly malicious user. Enforce authorization on every call against the end user's scopes, not just the server's.

Over-broad tools: A single run_sql(query) tool hands the model your whole database. Prefer narrow, purpose-built tools whose blast radius is bounded by design.

Destructive actions without confirmation: For irreversible operations, return a plan and require an explicit confirmation flag rather than acting immediately.

python.python

@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}."}

Going to production #

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.

Common anti-patterns to avoid #

Modeling read-only data as tools instead of resources.

Terse tool descriptions the model cannot route on.

Returning prose instead of structured, typed data.

Throwing raw exceptions where an actionable error result would let the model recover.

A remote HTTP server with no auth or per-call authorization.

One giant run_sql or run_shell tool with an unbounded blast radius.

Irreversible actions with no confirmation gate.

Debugging through an LLM instead of isolating the server with the Inspector.

Silently changing tool names or arguments, which breaks every agent that learned them.

Production readiness checklist #

Capabilities are split correctly across tools (actions), resources (data), and prompts (templates).

Every tool has a model-facing description covering when to use it and when not to.

Inputs are typed; outputs are structured.

Recoverable failures return actionable error data, not exceptions.

Transport matches deployment: stdio for local, Streamable HTTP for remote.

Remote servers enforce OAuth 2.1 with least-privilege scopes and audience validation.

Tools are narrow; destructive actions require explicit confirmation.

Tool output is treated as untrusted and prompt-injection aware.

The server is verified end to end with the MCP Inspector before any agent connects.

Production servers are containerized, TLS-terminated, audit-logged, rate-limited, and versioned.

The call we'd make #

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.