Let’s build an MCP server for an invented café, Café Aurora, with a menu and orders. All the data is fictional and stored in memory, so you don’t need a database.

1. What MCP is and what FastMCP brings
MCP (Model Context Protocol) is the protocol with which a client (Claude, an IDE, or your own app) discovers and uses external capabilities. An MCP server exposes three types of things:
FastMCP is the Python framework that saves you from the protocol. Decorate regular functions and it generates JSON schema from type hints and docstrings.
2. Installation
You need Python 3.10 or higher.
mkdir aurora-mcp && cd aurora-mcp uv init && uv add fastmcp # or, with pip: # python -m venv .venv && source .venv/bin/activate # pip install fastmcp
3. Minimum server
Create server.py
from fastmcp import FastMCP mcp = FastMCP("Cafeteria Aurora") @mcp.tool def greet(name: str) -> str: """Greets a cafeteria customer.""" return f"Welcome to Aurora, {name}!" if __name__ == "__main__": mcp.run()
With mcp.run(), the server uses the stdio transport by default, which is used by local clients like Claude Desktop. The docstring is now the description that the model reads to decide when to use the tool, so write it with that in mind.
4. Sample data and real tools
Replace the content of server.py with:
from typing import Annotated, Literal from itertools import count from pydantic import Field from fastmcp import FastMCP, Context from fastmcp.exceptions import ToolError mcp = FastMCP( name="Cafetería Aurora", instructions=( "Server of the Café Aurora. Allows to consult the menu, " "create orders and view their status. Prices are in euros." ), ) # --- Dummy data --------------------------------------------------- MENU = { "cafe-solo": {"name": "Café solo", "category": "drinks", "price": 1.30, "stock": 100}, "capuchino": {"name": "Capuchino", "category": "drinks", "price": 2.20, "stock": 80}, "te-matcha": {"name": "Té matcha latte", "category": "drinks", "price": 3.10, "stock": 25}, "tostada-tom": {"name": "Tostada of tomato", "category": "food", "price": 2.50, "stock": 40}, "croissant": {"name": "Croissant", "category": "food", "price": 1.80, "stock": 3}, "tarta-queso": {"name": "Cheese tart", "category": "desserts", "price": 4.20, "stock": 12}, } PEDIDOS: dict[int, dict] = {} _ids = count(1001)
Validation Tool
@mcp.tool def search_product( text: Annotated[str, Field(description="Text to search in the product name, e.g. 'coffee'")], category: Literal["drinks", "food", "desserts"] | None = None ) -> list[dict]: """Searches products from the menu by name and, optionally, by category.""" result = [] for pid, p in MENU.items(): if text.lower() in p["name"].lower() and (category is None or p["category"] == category): result.append({"id": pid, **p}) return result
Two things that FastMCP does for you:
Tool that modifies status and handles errors
@mcp.tool def create_order(products: Annotated[dict[str, int], Field(description="Mapa id_producto -> cantidad, p. ej. {'capuchino': 2, 'croissant': 1}"), cliente: str], ) -> dict:"""Creates an order and discounts the stock. Returns the order number and the total."""if not products: raise ToolError("The order cannot be empty.")total = 0.0for pid, cantidad in products.items(): if pid not in MENU: raise ToolError(f"The product '{pid}' does not exist in the menu.") if cantidad <= 0: raise ToolError(f"Invalid quantity for '{pid}': {cantidad}.") if MENU[pid]["stock"] < cantidad: raise ToolError(f"Not enough stock for '{MENU[pid]['nombre']}': {MENU[pid]['stock']} and ordered {cantidad}.") total += MENU[pid]["precio"] * cantidadPEDIDOS[next(_ids)] = {"cliente": cliente, "productos": products, "total": round(total, 2), "estado": "preparing"}return {"pedido": next(_ids), "total": round(total, 2), "estado": "preparing"}
ToolError sends the message to the client as is. With other exceptions, FastMCP can hide internal details, which is recommended in production.
Async tool with context (logs and progress)
import [email protected] async def prepare_order(number: int, ctx: Context) -> str:"""Simulates the preparation of an order, informing of the progress."""order = PEDIDOS.get(number)if not order: raise ToolError(f"No order {number}.")await ctx.info(f"Starting to prepare order {number} of {order['cliente']}")steps = ["Grinding coffee", "Heating milk", "Assembling the tray"]for i, step in enumerate(steps, start=1): await ctx.report_progress(progress=i, total=len(steps)) await ctx.info(step) await asyncio.sleep(0.5)order["estado"] = "done"return f"Order {number} ready for {order['cliente']} ✔️"
The parameter ctx: Context is injected by FastMCP and does not appear in the model.
5. Resources
Resources are for read data, not action execution.
@mcp.resource("menu://completo") def menu_completo() -> dict: """Menu complete with prices and stock.""" return MENU @mcp.resource("menu://category/{category}") def menu_por_categoria(category: str) -> list[dict]: """Products of a category (drinks, food, desserts).""" return [{"id": k, **v} for k, v in MENU.items() if v["category"] == category] @mcp.resource("order://{number}") def ver_pedido(number: int) -> dict: """Status of a concrete order.""" if number not in PEDIDOS: raise ValueError(f"No order {number} exists") return PEDIDOS[number]
URIs with {keys} are resource templates: the client fills in the parameters when requesting them.
6. Prompts
@mcp.prompt def recomendar_desayuno(presupuesto: float, vegetariano: bool = False) -> str: """Template for requesting breakfast recommendation.""" extra = " Only vegetarian options." if vegetariano else "" return ( f"Eres the barista of the Cafetería Aurora. Recommend a breakfast " f"that does not exceed {presupuesto:.2f} €, using only the menu " f"available.{extra} Indicate the total." )
7. Close the file
At the end of server.py:
if __name__ == "__main__": mcp.run() # stdio
8. Test the server
a) With a FastMCP client (quick test)
Create test_client.py:
import asyncio from fastmcp import Client async def main(): async with Client("server.py") as client: tools = await client.list_tools() print("Tools:", [t.name for t in tools]) r = await client.call_tool("buscar_producto", {"texto": "café"}) print("Search:", r.data) r = await client.call_tool("crear_pedido", { "productos": {"capuchino": 2, "croissant": 1}, "cliente": "Lucía", }) print("Order:", r.data) menu = await client.read_resource("menu://categoria/postres") print("Desserts:", menu[0].text) try: await client.call_tool("crear_pedido", { "productos": {"croissant": 50}, "cliente": "Pablo" }) except Exception as e: print("Expected error:", e) asyncio.run(main())
uv run python test_client.py
b) With the visual inspector
uv run fastmcp dev server.py
Opens a web interface where you can list and execute tools, read resources, and test prompts by hand. It's the most comfortable way to debug.
9. Connect it to a real client
Claude Desktop (stdio, local)
Edit claude_desktop_config.json:
{ "mcpServers": { "aurora": { "command": "uv", "args": ["--directory", "/ruta/a/aurora-mcp", "run", "server.py"] } } }
Restart Claude Desktop and ask, for example: «Put two lattes and a croissant for Lucy».
Over HTTP (remote or shared)
Change the end of the file.
if __name__ == "__main__": mcp.run(transport="http", host="127.0.0.1", port=8000)
The server is available at http://127.0.0.1:8000/mcp. To connect to it with Claude Code:
claude mcp add --transport http aurora http://127.0.0.1:8000/mcp
And from Python, Client("http://127.0.0.1:8000/mcp").
Connecting the server to OpenCode
OpenCode reads the MCP servers from the key mcp in its configuration file. You can place it in opencode.json in the project root, or in ~/.config/opencode/opencode.json to have it available for all your projects.
Option A: local server (stdio)
OpenCode launches the process itself, just like Claude Desktop:
{ "$schema": "https://opencode.ai/config.json", "mcp": { "aurora": { "type": "local", "command": ["uv", "--directory", "/path/a/aurora-mcp", "run", "server.py"], "enabled": true } } }
Differences with Claude Desktop: here command is a single array that includes the executable and its arguments, and you need to specify "type": "local". If your server needs environment variables, add "environment": { "KEY": "value" }.
<
p class="wp-block-paragraph">
Option B: remote server (HTTP)
With the server started using mcp.run(transport="http", host="127.0.0.1", port=8000):
{ "$schema": "https://opencode.ai/config.json", "mcp": { "aurora": { "type": "remote", "url": "http://127.0.0.1:8000/mcp", "enabled": true } } }
Check the connection
opencode mcp list
The aurora should appear as connected. Then open OpenCode and ask something like «Search products with coffee at the Aurora Café and order two cappuccinos for Lucia». The tools are exposed with the prefix of the server name (for example aurora_crear_pedido). If you want the model to use them explicitly, you can mention them in the prompt or in your project rules.
If something doesn't connect, check that the --directory path is absolute, that uv is in the PATH of the environment you're running OpenCode from, and that the port is not taken (in HTTP mode).
Authentication
For the stdio mode, authentication is not necessary: the process runs on your machine with your permissions and only communicates with the client that launched it. When the server is exposed via HTTP, anyone who reaches the URL can execute your tools, so it's convenient to protect it. FastMCP allows you to configure an authentication provider when creating the server, and there are three common levels: a static token verifier for development, a JWT verifier when you already have an identity provider that emits tokens (Auth0, Keycloak, etc.), and complete OAuth providers (GitHub, Google, etc.) for user login flows. To get started, this example uses fixed tokens, only valid for development or testing::
from fastmcp import FastMCP from fastmcp.server.auth.providers.jwt import StaticTokenVerifier auth = StaticTokenVerifier( tokens={ "aurora-dev-token-123": { "client_id": "opencode-local", "scopes": ["read", "write"], } } ) mcp = FastMCP("Cafetería Aurora", auth=auth)
With this, requests without the Authorization: Bearer header are rejected with a 401. In OpenCode, you pass the token as a header, reading it from an environment variable to avoid leaving it written in the file (which is likely to end up in Git):
{ "$schema": "https://opencode.ai/config.json", "mcp": { "aurora": { "type": "remote", "url": "http://127.0.0.1:8000/mcp", "headers": { "Authorization": "Bearer {env:AURORA_TOKEN}" }, "enabled": true } } }
Start:
export AURORA_TOKEN="aurora-dev-token-123" opencode
If you use an OAuth provider on the server, OpenCode can detect the flow and manage the login for your account; in that case, authenticate with opencode mcp auth aurora and you don't need the manual header. For production, don't use static tokens: go to JWT or OAuth, always serve over HTTPS and limit the scopes of each tool. The configuration details change between versions of FastMCP and OpenCode, so check the exact class names and options in their documentation before deploying.
