How to Build Production MCP Servers with FastMCP 3.0 and Python

Learn how to build production-ready MCP servers using FastMCP 3.0 and Python. Covers tools, resources, providers, transforms, database integration, authentication, OpenTelemetry observability, and deployment.

The Model Context Protocol (MCP) has quickly become the standard way to connect AI models to external tools and data. Think of it this way: if REST APIs let two programs talk to each other, MCP lets an LLM talk to your systems — databases, file storage, SaaS platforms, internal services — through a clean, standardized interface.

And honestly, FastMCP 3.0 has changed the game. Since hitting general availability on February 18, 2026, it's gone from a handy prototyping tool to a genuinely production-grade framework. The new Provider/Transform architecture, native OpenTelemetry instrumentation, and granular authorization are all real upgrades. Some version of FastMCP now powers roughly 70% of MCP servers across all languages, which tells you something about how well the API design landed.

This guide walks you through building MCP servers with FastMCP 3.0 in Python — from your first tool definition to production deployment with auth, observability, and enterprise data integration.

Prerequisites and Project Setup

You'll need Python 3.10 or later and uv, the fast Python package manager that handles dependencies and virtual environments. Here's how to install FastMCP 3.0 and scaffold your project:

pip install uv
uv init my-mcp-server
cd my-mcp-server
uv add fastmcp

Or, if you prefer a more structured starting point, use the official scaffolding tool:

uvx create-mcp-server

This gives you a ready-to-run project layout:

my-mcp-server/
├── pyproject.toml
├── README.md
└── src/
    └── my_mcp_server/
        ├── __init__.py
        ├── __main__.py
        └── server.py

Building Your First MCP Tool

MCP servers expose three types of capabilities: Tools (functions the LLM can call), Resources (read-only data the LLM can access), and Prompts (reusable templates for LLM interactions). Most people start with tools — they're the most immediately useful.

FastMCP uses Python decorators and type hints to auto-generate tool schemas, which is genuinely one of its best features. Here's a practical example — a tool that queries a product inventory:

from fastmcp import FastMCP

mcp = FastMCP(name="Inventory Server")

@mcp.tool
def check_stock(product_id: str, warehouse: str = "main") -> dict:
    """Check current stock level for a product in a specific warehouse.
    
    Args:
        product_id: The unique product identifier (e.g., SKU-12345)
        warehouse: Warehouse location to check. Defaults to main warehouse.
    """
    # In production, this queries your actual database
    stock_data = query_inventory_db(product_id, warehouse)
    return {
        "product_id": product_id,
        "warehouse": warehouse,
        "quantity": stock_data.quantity,
        "last_updated": stock_data.updated_at.isoformat()
    }

What's happening under the hood? FastMCP inspects the function signature and docstring to generate the JSON Schema that LLMs use to understand the tool. Your type hints (str, dict) become schema types, default values become optional parameters, and the docstring becomes the tool description. No manual schema writing needed — which, if you've ever hand-written JSON Schema, you'll appreciate.

Resources: Exposing Data to LLMs

Resources provide read-only data that LLMs can pull into their context. Use them when the AI needs reference information rather than an action to perform.

@mcp.resource("config://app-settings")
def get_app_settings() -> dict:
    """Current application configuration settings."""
    return {
        "version": "3.2.1",
        "environment": os.getenv("APP_ENV", "development"),
        "features": load_feature_flags()
    }

For data that varies by parameter, you can use dynamic resource templates:

@mcp.resource("users://{user_id}/profile")
def get_user_profile(user_id: str) -> dict:
    """Retrieve profile data for a specific user."""
    user = db.users.find_one({"id": user_id})
    return {
        "name": user["name"],
        "email": user["email"],
        "role": user["role"],
        "created_at": user["created_at"]
    }

When a client requests users://usr-42/profile, FastMCP extracts usr-42 and passes it as the user_id argument. The function only runs when the resource is actually requested — so expensive data fetches don't happen until they're needed.

Prompts: Reusable LLM Interaction Patterns

Prompts let you define pre-built templates that guide how the LLM interacts with your tools and resources. They're essentially a way to encode domain expertise into reusable patterns:

from fastmcp.prompts import Message

@mcp.prompt
def analyze_inventory_report(warehouse: str) -> list[Message]:
    """Generate an inventory analysis report for a warehouse."""
    return [
        Message(
            role="user",
            content=f"""Analyze the inventory status for warehouse "{warehouse}".
            
Check stock levels for all products, identify items below 
reorder threshold, and suggest restocking priorities based 
on sales velocity data. Format the results as a structured 
report with sections for critical, low, and adequate stock."""
        )
    ]

FastMCP 3.0: Providers and Transforms

This is where things get really interesting. The biggest architectural change in FastMCP 3.0 is the Provider/Transform system. In version 2, all tools lived in a single server file. Version 3 decouples where components come from (Providers) from how they're modified (Transforms).

Providers: Multiple Component Sources

A Provider answers one simple question: "Where do the tools, resources, and prompts come from?" They can come from Python decorators (the default), a directory of files, an OpenAPI spec, or even a remote MCP server.

FileSystemProvider discovers tools automatically from a directory, with hot reload during development:

from fastmcp.providers import FileSystemProvider

mcp = FastMCP(name="Plugin Server")
mcp.add_provider(FileSystemProvider("./plugins"))

Every Python file in ./plugins/ with @mcp.tool decorators gets automatically registered. Drop in a new file, and the tool appears without restarting the server. Pretty convenient for plugin-style architectures.

OpenAPIProvider wraps any existing REST API as MCP tools — and this is honestly a game-changer for enterprises sitting on hundreds of microservices:

from fastmcp.providers import OpenAPIProvider

mcp = FastMCP(name="API Gateway")
mcp.add_provider(
    OpenAPIProvider("https://api.internal.com/openapi.json")
)

Every endpoint in the OpenAPI spec becomes an MCP tool automatically. Your existing API documentation becomes AI-accessible without writing a single tool definition.

Transforms: Middleware for Components

Transforms modify components as they flow from Providers to clients. They let you rename, filter, namespace, or secure tools without touching the original code:

from fastmcp.transforms import NamespaceTransform, FilterTransform

# Add prefixes to avoid name collisions when composing servers
mcp.add_transform(NamespaceTransform(prefix="inventory"))

# Only expose tools matching a pattern
mcp.add_transform(FilterTransform(include=["inventory_*"]))

The composition power here is significant. Person A can build a Provider that sources tools from an internal API. Person B applies Transforms to namespace, filter, and add authorization — all without modifying Person A's code. It's the kind of separation of concerns that makes large teams actually productive.

Server Composition

Mounting sub-servers in FastMCP 3.0 is trivially simple — it's just a Provider plus a namespace Transform under the hood:

main = FastMCP(name="Gateway")

inventory_server = FastMCP(name="Inventory")
orders_server = FastMCP(name="Orders")

# Mount sub-servers with automatic namespacing
main.mount("inventory", inventory_server)
main.mount("orders", orders_server)

# Tools are now available as inventory_check_stock, orders_create_order, etc.

Database Integration: A Practical Example

Let's put it all together. Here's a complete example of an MCP server that connects to a PostgreSQL database, showing tools, resources, error handling, and async operations all working together:

import asyncpg
from fastmcp import FastMCP, Context

mcp = FastMCP(name="Database MCP Server")

# Connection pool managed via lifespan
@mcp.lifespan
async def setup(server):
    pool = await asyncpg.create_pool(
        "postgresql://user:pass@localhost/mydb",
        min_size=2,
        max_size=10
    )
    try:
        yield {"db_pool": pool}
    finally:
        await pool.close()

@mcp.tool
async def query_customers(
    ctx: Context,
    search_term: str,
    limit: int = 10
) -> list[dict]:
    """Search customers by name or email.
    
    Args:
        search_term: Partial name or email to search for
        limit: Maximum number of results (default 10, max 100)
    """
    limit = min(limit, 100)  # Enforce safety cap
    pool = ctx.state["db_pool"]
    
    rows = await pool.fetch(
        """
        SELECT id, name, email, created_at 
        FROM customers 
        WHERE name ILIKE $1 OR email ILIKE $1
        ORDER BY created_at DESC
        LIMIT $2
        """,
        f"%{search_term}%",
        limit
    )
    
    return [dict(row) for row in rows]

@mcp.tool
async def get_order_summary(
    ctx: Context,
    customer_id: int
) -> dict:
    """Get order summary statistics for a customer.
    
    Args:
        customer_id: The customer ID to look up
    """
    pool = ctx.state["db_pool"]
    
    stats = await pool.fetchrow(
        """
        SELECT 
            COUNT(*) as total_orders,
            SUM(total_amount) as lifetime_value,
            MAX(order_date) as last_order_date
        FROM orders 
        WHERE customer_id = $1
        """,
        customer_id
    )
    
    return dict(stats) if stats else {"error": "Customer not found"}

A couple things worth noting here. The lifespan decorator manages the database connection pool across the server's lifecycle — connections are created at startup and cleanly closed at shutdown. And the Context parameter gives tools access to shared state (including the connection pool) without resorting to global variables.

Authentication and Security

Any MCP server heading to production needs authentication. FastMCP 3.0 provides granular authorization at the component level and supports OAuth 2.1 flows.

Component-Level Authorization

from fastmcp.auth import AuthContext

async def require_admin(ctx: AuthContext) -> bool:
    """Only allow admin users to access this tool."""
    return ctx.user.role == "admin"

@mcp.tool(auth=require_admin)
async def delete_customer(ctx: Context, customer_id: int) -> dict:
    """Delete a customer record. Requires admin privileges."""
    pool = ctx.state["db_pool"]
    await pool.execute("DELETE FROM customers WHERE id = $1", customer_id)
    return {"deleted": customer_id}

Server-Wide Security Policies

from fastmcp.auth import AuthMiddleware, BearerTokenValidator

mcp = FastMCP(
    name="Secure Server",
    auth=AuthMiddleware(
        validator=BearerTokenValidator(
            issuer="https://auth.company.com",
            audience="mcp-server"
        )
    )
)

For STDIO-based servers running locally (like Claude Desktop integrations), authentication is handled by the host process. But for HTTP-based servers deployed remotely? Always implement authentication. An unsecured MCP server is essentially an open door to your data.

Security Best Practices

  • Validate all inputs — Use Pydantic models for complex tool parameters to enforce type safety and constraints
  • Restrict file system access — If tools interact with files, whitelist allowed directories and block path traversal
  • Implement rate limiting — Prevent abuse by capping requests per client
  • Audit logging — Log every tool invocation with caller identity, parameters, and results for compliance
  • Principle of least privilege — Each tool should only access the data and systems it absolutely needs

Observability with OpenTelemetry

One of the things I really like about FastMCP 3.0 is the built-in OpenTelemetry instrumentation. Every tool call, resource read, and prompt render generates traces with standardized attributes — no manual instrumentation required:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

# Configure OpenTelemetry
provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)
trace.set_tracer_provider(provider)

# FastMCP automatically instruments all operations
mcp = FastMCP(name="Observable Server")

@mcp.tool
async def process_order(order_id: str) -> dict:
    """Process a pending order."""
    # This tool call automatically generates a span with:
    # - component key, provider type
    # - session ID, auth context
    # - execution duration, result status
    return await order_service.process(order_id)

Server spans include component key, provider type, session ID, and auth context. Client spans wrap outgoing calls with W3C trace context propagation. You can send traces to Jaeger, Grafana Tempo, Datadog, or any OpenTelemetry-compatible backend to visualize exactly where latency is happening in your MCP server operations.

Testing Your MCP Server

Testing is refreshingly straightforward in FastMCP 3.0. Decorated functions remain normal Python functions, so you can import and test them directly:

import pytest
from my_mcp_server.server import check_stock, mcp

# Unit test: call the function directly
def test_check_stock_returns_quantity():
    result = check_stock("SKU-12345", "main")
    assert "quantity" in result
    assert "product_id" in result
    assert result["product_id"] == "SKU-12345"

# Integration test: test via MCP protocol  
@pytest.mark.asyncio
async def test_mcp_tool_call():
    async with mcp.test_client() as client:
        result = await client.call_tool(
            "check_stock",
            {"product_id": "SKU-12345", "warehouse": "main"}
        )
        assert result is not None

The test_client() method spins up a temporary in-process server, so you can test the full MCP protocol flow without launching a subprocess or configuring transport layers. That's a huge win for CI/CD pipelines.

For quick manual verification, there's also the CLI:

# List all tools on any MCP server
fastmcp list tools --server ./server.py

# Call a specific tool with arguments
fastmcp call check_stock --product-id SKU-12345 --warehouse main

Deployment Options

FastMCP servers can be deployed in several ways depending on your infrastructure needs. Let's go through the main ones.

Local Development with Claude Desktop

Add your server to the Claude Desktop configuration file:

{
  "mcpServers": {
    "inventory": {
      "command": "uv",
      "args": [
        "--directory", "/path/to/my-mcp-server",
        "run", "server.py"
      ]
    }
  }
}

Docker for Team Deployment

FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen
COPY src/ ./src/
EXPOSE 8000
CMD ["uv", "run", "fastmcp", "run", "src/server.py", "--transport", "http", "--port", "8000"]

Managed Hosting

Prefect Horizon offers free hosting for FastMCP servers. You can register and deploy with a single command:

fastmcp install --platform horizon src/server.py

For cloud deployments, the HTTP transport with SSE (Server-Sent Events) is the standard choice. One thing to watch out for: MCP connections are stateful, so a traditional round-robin load balancer will break sessions. Use sticky sessions or deploy behind an application-aware proxy that routes related requests to the same server instance.

Common Pitfalls and How to Avoid Them

  • STDIO logging corruption — For STDIO-based servers, never write to stdout directly. Use stderr or FastMCP's built-in logging, because stdout carries JSON-RPC messages and any stray print statement will corrupt the protocol stream. (This one bites almost everyone at least once.)
  • Missing docstrings — FastMCP uses your docstring as the tool description the LLM sees. A tool without a clear docstring is a tool the AI won't use correctly. Write descriptions that explain when and why to use the tool, not just what it does.
  • Blocking sync code in async servers — FastMCP 3.0 automatically wraps synchronous tools in a threadpool, but if you mix sync database drivers with an async server, you can hit unexpected bottlenecks. Stick with async drivers like asyncpg or aiosqlite when running async servers.
  • No input validation — LLMs can (and will) send unexpected parameter values. Always validate and sanitize inputs, especially for tools that execute database queries or interact with the file system.

Frequently Asked Questions

What is the difference between MCP tools and REST APIs?

REST APIs are designed for program-to-program communication with rigid request/response patterns. MCP tools are designed for LLM-to-system communication — they include rich descriptions, type information, and usage context that help AI models understand when and how to use them. An MCP server can actually wrap existing REST APIs (especially with FastMCP's OpenAPIProvider) to make them AI-accessible without changing the underlying service.

Can I use FastMCP with OpenAI, Gemini, or other LLMs besides Claude?

Absolutely. MCP is an open standard, not Anthropic-specific. OpenAI adopted MCP support in their Agents SDK, and Google added MCP support through the Agent Development Kit (ADK). Any LLM client that implements the MCP client protocol can connect to your FastMCP server — the server doesn't know or care which LLM is calling it.

How do I connect an MCP server to Claude Desktop?

Add a server entry to the claude_desktop_config.json file (located at ~/Library/Application Support/Claude/ on macOS or %APPDATA%\Claude\ on Windows). Each entry needs a command to run your server and its arguments. Save the file and restart Claude Desktop to pick up the new server. FastMCP also offers fastmcp install to register servers with Claude Desktop, Cursor, or Goose automatically.

What is the difference between MCP tools and MCP resources?

Tools are functions the LLM invokes to perform actions — query a database, send a message, create a record. Resources are read-only data endpoints the LLM pulls into its context — configuration files, documentation, reference data. The simple rule: use tools when the LLM needs to do something and resources when it needs to know something.

How do I secure an MCP server for production deployment?

For local STDIO servers (Claude Desktop, Cursor), the host process handles security. For remote HTTP servers, implement OAuth 2.1 or bearer token authentication using FastMCP's AuthMiddleware. Apply component-level authorization to restrict sensitive tools to specific roles. Add rate limiting, audit logging, and input validation. And I can't stress this enough: never deploy an HTTP-based MCP server without authentication — it exposes your data and systems to anyone who can reach the endpoint.

About the Author Editorial Team

Our team of expert writers and editors.