← All guides
Interactive guide Β· 10 levels

Build your own MCP server

From your first tool exposed with FastMCP, to a stateless production server with OAuth authentication and load balancing across replicas.

0 / 10 completed
Level 1 Β· Foundations

Your first MCP server

Goal: understand what MCP solves and install your first server with FastMCP.

Model Context Protocol (MCP) standardizes how an AI model connects to external tools and data sources. Before MCP, connecting an LLM to "your" tools meant a custom integration for every model-tool combination β€” N models Γ— M tools, each with its own format. MCP defines a single protocol: you build your server once, and any compatible client (Claude Desktop, Claude Code, other agents) can use it with no extra integration work.

Install FastMCP

TERMINAL
pip install fastmcp

Your first server

server.py
from mcp.server.fastmcp import FastMCP mcp = FastMCP("my-first-server") @mcp.tool() def greet(name: str) -> str: """Greets a person by name.""" return f"Hello, {name}! This is your first MCP server." if __name__ == "__main__": mcp.run(transport="stdio")

Run python server.py and you already have a working MCP server over stdio β€” the simplest transport, meant for a local client (like Claude Desktop) to launch it as a subprocess.

Challenge

Add a second tool, farewell(name: str), to the same server and confirm both show up when you inspect it (Level 9 shows you how).

Level 2 Β· Tools

Tools with real validation

Goal: build tools with typed parameters, validation, and error handling.

The @mcp.tool() decorator turns your function's type hints into a JSON Schema automatically β€” the model sees that schema to know which parameters to send and of what type. The docstring is what the model reads to decide when to use the tool.

server.py
from pydantic import Field from typing import Annotated @mcp.tool() def get_weather( city: Annotated[str, Field(description="City name, e.g. 'Guatemala City'")], unit: Annotated[str, Field(description="'celsius' or 'fahrenheit'")] = "celsius", ) -> dict: """Looks up the current weather for a city. Use when the user asks about weather or temperature.""" data = {"Guatemala City": 22} if city not in data: raise ValueError(f"No weather data for '{city}'") temp = data[city] if unit == "fahrenheit": temp = temp * 9 / 5 + 32 return {"city": city, "temperature": temp, "unit": unit}

When your tool raises an exception (like the ValueError above), FastMCP converts it into a structured MCP error the model can read and interpret β€” better than returning a generic "something went wrong" string.

Challenge

Add a tool that accepts a list of cities and returns the weather for all of them, and test what happens when the model sends a city that isn't in your data.

Level 3 Β· Resources

Data addressable by URI

Goal: expose read-only data as resources, accessible by URI without an explicit tool call.

A resource is different from a tool: it isn't an action the model "decides to run" β€” it's addressable data the client can read at any time, like a file or a read-only endpoint, identified by a URI.

server.py
@mcp.resource("config://company") def company_config() -> str: """General company configuration, available to the model as context.""" return """ Name: Guatemalia AI Support hours: 8am - 6pm GMT-6 Supported languages: Spanish, English """ @mcp.resource("documents://{doc_id}") def get_document(doc_id: str) -> str: """Returns a document's content by ID β€” a resource with a URI parameter.""" documents = {"manual-01": "User manual content..."} return documents.get(doc_id, "Document not found")

The second example uses a URI template (documents://{doc_id}) β€” the client can request documents://manual-01 and FastMCP resolves the parameter automatically.

Challenge

Turn one of your blog articles into a resource addressable by slug, e.g. article://what-is-an-orchestrator.

Level 4 Β· Prompts

Reusable templates

Goal: define parameterized prompts the client can expose as quick commands.

A prompt in MCP is a parameterized message template β€” meant for the host (the MCP client) to surface as something like a slash command ("/"), so the user doesn't have to type out the full prompt every time.

server.py
@mcp.prompt() def review_code(language: str, code: str) -> str: """Generates a structured code review prompt.""" return f"""Review the following {language} code and report: 1. Potential bugs 2. Security issues 3. Readability suggestions ```{language} {code} ```"""

The practical difference from a tool: a prompt doesn't execute anything on its own, it just returns the assembled text for the model to process β€” it's a way to standardize and reuse well-designed prompts instead of every user rewriting them from scratch.

Challenge

Create a prompt summarize_ticket(description: str, priority: str) tailored to your own customer support case.

Level 5 Β· Connecting a client

Test your server with Claude

Goal: connect your MCP server to a real client and test it end to end.

To test your server with Claude Desktop or Claude Code, you register it in the client's configuration β€” the client takes care of launching it as a subprocess over stdio when needed.

claude_desktop_config.json
{ "mcpServers": { "my-first-server": { "command": "python", "args": ["/full/path/to/server.py"] } } }

Restart the client, and your server's tools, resources, and prompts are already available in the conversation. This is the fastest dev loop: edit the server, restart the client, test in a real conversation.

Challenge

Connect your Level 3 server and ask Claude directly "what are the support hours?" β€” confirm it reads the resource without you having to paste it into the message.

Level 6 Β· Stateless HTTP

From stdio to an HTTP server that scales

Goal: migrate your server to stateless HTTP transport, the current standard for production.

stdio works for local development, but a production server needs to live on the network, reachable by multiple clients. The 2026-07-28 MCP spec made a core change here: the protocol went from session-based state (which forced sticky sessions to scale) to a fully stateless core.

http_server.py
from mcp.server.fastmcp import FastMCP mcp = FastMCP("production-server") @mcp.tool() def get_weather(city: str) -> dict: """Looks up the current weather for a city.""" return {"city": city, "temperature": 22} if __name__ == "__main__": mcp.run( transport="streamable-http", stateless_http=True, # no protocol session: any instance can serve any request json_response=True, ) # no need to wire up uvicorn separately: FastMCP exposes the ASGI server directly

With stateless_http=True, every HTTP request is self-contained β€” the same client doesn't have to keep hitting the same server instance, which is exactly what lets you put it behind a standard load balancer (Level 10).

Challenge

Run your server with HTTP transport and curl the endpoint directly to see the raw response before connecting it to a client.

Level 7 Β· Authentication

OAuth and Resource Indicators

Goal: understand why an MCP server exposed on the network needs to validate tokens with a specific audience.

A production MCP server acts as an OAuth 2.1 resource server: it must validate that each access token was actually issued for it, not reused from another service. This is where RFC 8707 (Resource Indicators) comes in, which the MCP spec explicitly requires.

auth.py
from mcp.server.auth import TokenVerifier class MyTokenVerifier(TokenVerifier): async def verify_token(self, token: str) -> dict: claims = decode_and_validate_jwt(token) # your signature/expiration validation logic # Critical point: the token must have been issued specifically # for THIS MCP server, not reused from another one expected_audience = "https://mcp.guatemalia.com" if expected_audience not in claims.get("aud", []): raise ValueError("Token not valid for this MCP server") return claims mcp = FastMCP("production-server", token_verifier=MyTokenVerifier())

Without this audience check, a token stolen from a legitimate MCP server could be replayed against a different server that trusts the same identity provider β€” exactly the scenario Resource Indicators is designed to block.

Challenge

Simulate a token with the wrong audience and confirm your server rejects it before running any tool.

Level 8 Β· Guardrails

Explicit per-tool limits

Goal: add permission controls and usage limits at the tool level, not just at the server level.

Not every tool in a server should be allowed the same level of risk. A read-only tool ("check ticket status") doesn't need the same protections as a tool that modifies data ("cancel a subscription").

guardrails.py
from functools import wraps import time call_counter = {} def rate_limit(max_per_minute: int): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): now = time.time() history = call_counter.setdefault(func.__name__, []) history[:] = [t for t in history if now - t < 60] if len(history) >= max_per_minute: raise ValueError(f"Limit of {max_per_minute} calls/minute exceeded for {func.__name__}") history.append(now) return func(*args, **kwargs) return wrapper return decorator @mcp.tool() @rate_limit(max_per_minute=5) def cancel_subscription(user_id: str) -> dict: """Cancels a user's subscription. Irreversible action β€” use with caution.""" # In production: this should also require explicit human confirmation return {"status": "cancelled", "user_id": user_id}

For truly irreversible actions (canceling something, deleting data, moving money), the best practice isn't just a rate limit β€” it's requiring explicit human confirmation before executing, instead of trusting that the model "decided correctly".

Challenge

Add a tool that requires a second parameter, confirm: bool = False, and that fails explicitly unless it's True, forcing an explicit second call.

Level 9 Β· Testing

Debug with MCP Inspector

Goal: test tools, resources, and prompts in isolation, without depending on a full chat client.
TERMINAL
npx @modelcontextprotocol/inspector python server.py # Opens a local web UI where you can: # - See all registered tools, resources, and prompts # - Invoke each tool manually with different parameters # - See the JSON Schema FastMCP generated automatically # - Inspect errors without needing a chat client

Automated tests

test_server.py
import pytest from server import get_weather def test_get_weather_valid_city(): result = get_weather("Guatemala City") assert result["temperature"] == 22 def test_get_weather_invalid_city(): with pytest.raises(ValueError): get_weather("Nonexistent City")

Since tools are just plain Python functions under the decorator, you can test them directly with pytest without spinning up the full MCP server β€” save MCP Inspector for end-to-end integration testing, not for testing each tool's logic individually.

Challenge

Write tests for the Level 2 and Level 8 tools, including the rate-limit-exceeded case.

Level 10 Β· Final architecture

A load-balanced MCP server in production

Goal: deploy multiple replicas of your server behind a load balancer, now that it's stateless.

Because your server already runs in stateless_http=True mode (Level 6), any replica can handle any request β€” no sticky sessions, no shared state store. This turns an MCP server into an ordinary HTTP service as far as infrastructure is concerned.

1. Package the server

Dockerfile
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . EXPOSE 8000 CMD ["python", "http_server.py"]

2. Load balancer across replicas

nginx.conf
upstream mcp_server { least_conn; server mcp-1:8000; server mcp-2:8000; server mcp-3:8000; } server { listen 443 ssl; server_name mcp.guatemalia.com; location / { proxy_pass http://mcp_server; proxy_http_version 1.1; proxy_set_header Host $host; } }

3. Full architecture

final architecture
MCP client (Claude Desktop, Claude Code, your own agent) β”‚ β–Ό Load Balancer (nginx / ALB) β€” any replica can serve the request β”‚ β”œβ”€β”€β–Ά [MCP server replica 1] ──▢ validates OAuth (Level 7) β”œβ”€β”€β–Ά [MCP server replica 2] ──▢ applies guardrails (Level 8) └──▢ [MCP server replica 3] ──▢ runs tools/resources/prompts β”‚ β–Ό OpenTelemetry (traces of every tool call)

With that, you've got the full path: from a single tool running over stdio in Level 1, to an authenticated production MCP server with per-tool guardrails, scaled horizontally without friction thanks to the new stateless spec.

Final challenge

Deploy 2 replicas of your Level 8 server in separate containers, put them behind nginx with least_conn, and confirm the same client can get responses from either replica with no noticeable difference.