Templatepythonbeginnerโœ“ Production ReadyFree

Python FastAPI MCP Server Starter

A beginner-friendly Python MCP server built with FastAPI and HTTP transport, secured with API key authentication. Features structlog-powered JSON logging with request correlation IDs, multi-stage Docker builds, and three realistic tool implementations covering text analysis, data transformation, and system utilities.

๐Ÿ“– 12 min readโš™๏ธ Setup: 15 minutesutilities
pythonfastapihttpapi-keydockerstructlogbeginnermcp

Files(9)

"""Python FastAPI MCP Server Starter.

Main application entry point. Wires together FastAPI, the MCP SDK,
authentication middleware, and structured logging into a single server
that exposes three realistic tools over HTTP transport.
"""

from __future__ import annotations

import asyncio
import json
import platform
import re
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from typing import Any

import uvicorn
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from mcp.server import Server
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, Tool
from pydantic import BaseModel, Field, field_validator

from auth import APIKeyMiddleware
from config import settings
from logger import get_logger, setup_logging, RequestLoggingMiddleware

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------

setup_logging()
log = get_logger(__name__)

# ---------------------------------------------------------------------------
# MCP Server
# ---------------------------------------------------------------------------

mcp = FastMCP(
    name=settings.server_name,
    version=settings.server_version,
)

# ---------------------------------------------------------------------------
# Input schemas
# ---------------------------------------------------------------------------


class AnalyzeTextInput(BaseModel):
    """Input schema for the analyze_text tool."""

    text: str = Field(
        ...,
        min_length=1,
        max_length=50_000,
        description="The text to analyse.",
    )
    include_word_frequency: bool = Field(
        default=False,
        description="Include a word frequency map in the response.",
    )
    top_n_words: int = Field(
        default=10,
        ge=1,
        le=100,
        description="Number of most-frequent words to return when include_word_frequency is true.",
    )


class TransformJsonInput(BaseModel):
    """Input schema for the transform_json tool."""

    data: dict[str, Any] = Field(
        ...,
        description="The JSON object to transform.",
    )
    operation: str = Field(
        ...,
        description=(
            "Transformation to apply. One of: "
            "'flatten_keys' (snake_case keys), "
            "'keys_to_uppercase', "
            "'remove_nulls', "
            "'sort_keys'."
        ),
    )

    @field_validator("operation")
    @classmethod
    def validate_operation(cls, v: str) -> str:
        allowed = {"flatten_keys", "keys_to_uppercase", "remove_nulls", "sort_keys"}
        if v not in allowed:
            raise ValueError(f"operation must be one of {sorted(allowed)}, got '{v}'")
        return v


class SystemInfoInput(BaseModel):
    """Input schema for the system_info tool (no required fields)."""

    include_environment: bool = Field(
        default=False,
        description="Include non-sensitive environment metadata (Python path, prefix).",
    )


# ---------------------------------------------------------------------------
# Tool implementations
# ---------------------------------------------------------------------------


@mcp.tool()
async def analyze_text(
    text: str,
    include_word_frequency: bool = False,
    top_n_words: int = 10,
) -> str:
    """Analyse a block of text and return statistics.

    Returns character count, word count, sentence count, average word
    length, reading time estimate, and optionally a word frequency map.
    """
    tool_log = get_logger("tool.analyze_text")
    tool_log.info("analyze_text called", text_length=len(text))

    # Validate via Pydantic
    try:
        params = AnalyzeTextInput(
            text=text,
            include_word_frequency=include_word_frequency,
            top_n_words=top_n_words,
        )
    except Exception as exc:
        tool_log.warning("validation error", error=str(exc))
        return json.dumps({"error": f"Validation failed: {exc}"})

    t = params.text

    # Character stats
    char_count = len(t)
    char_count_no_spaces = len(t.replace(" ", ""))

    # Word stats
    words_raw = t.split()
    word_count = len(words_raw)
    avg_word_length = (
        round(sum(len(w.strip(".,!?;:\"'")) for w in words_raw) / word_count, 2)
        if word_count
        else 0
    )

    # Sentence stats (split on . ! ?)
    sentences = [s.strip() for s in re.split(r"[.!?]+", t) if s.strip()]
    sentence_count = len(sentences)

    # Reading time (average adult reads ~238 wpm)
    reading_time_seconds = round((word_count / 238) * 60, 1)

    # Paragraph count
    paragraphs = [p.strip() for p in t.split("\n\n") if p.strip()]
    paragraph_count = len(paragraphs)

    result: dict[str, Any] = {
        "character_count": char_count,
        "character_count_no_spaces": char_count_no_spaces,
        "word_count": word_count,
        "sentence_count": sentence_count,
        "paragraph_count": paragraph_count,
        "average_word_length": avg_word_length,
        "estimated_reading_time_seconds": reading_time_seconds,
    }

    if params.include_word_frequency:
        # Normalise: lowercase, strip punctuation
        normalised = [
            re.sub(r"[^\w]", "", w.lower()) for w in words_raw
        ]
        normalised = [w for w in normalised if w]
        frequency = Counter(normalised).most_common(params.top_n_words)
        result["word_frequency"] = [
            {"word": word, "count": count} for word, count in frequency
        ]

    tool_log.info("analyze_text completed", word_count=word_count)
    return json.dumps(result, indent=2)


@mcp.tool()
async def transform_json(
    data: dict[str, Any],
    operation: str,
) -> str:
    """Apply a named transformation to a JSON object.

    Supported operations:
    - flatten_keys: convert all keys to snake_case
    - keys_to_uppercase: convert all keys to UPPER_CASE
    - remove_nulls: recursively remove keys whose value is null/None
    - sort_keys: return the object with keys sorted alphabetically
    """
    tool_log = get_logger("tool.transform_json")
    tool_log.info("transform_json called", operation=operation, key_count=len(data))

    try:
        params = TransformJsonInput(data=data, operation=operation)
    except Exception as exc:
        tool_log.warning("validation error", error=str(exc))
        return json.dumps({"error": f"Validation failed: {exc}"})

    def to_snake_case(name: str) -> str:
        # Insert underscore before uppercase letters, then lowercase
        s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
        return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()

    def flatten_keys(obj: Any) -> Any:
        if isinstance(obj, dict):
            return {to_snake_case(k): flatten_keys(v) for k, v in obj.items()}
        if isinstance(obj, list):
            return [flatten_keys(item) for item in obj]
        return obj

    def keys_to_uppercase(obj: Any) -> Any:
        if isinstance(obj, dict):
            return {k.upper(): keys_to_uppercase(v) for k, v in obj.items()}
        if isinstance(obj, list):
            return [keys_to_uppercase(item) for item in obj]
        return obj

    def remove_nulls(obj: Any) -> Any:
        if isinstance(obj, dict):
            return {
                k: remove_nulls(v)
                for k, v in obj.items()
                if v is not None
            }
        if isinstance(obj, list):
            return [remove_nulls(item) for item in obj if item is not None]
        return obj

    def sort_keys(obj: Any) -> Any:
        if isinstance(obj, dict):
            return {k: sort_keys(v) for k in sorted(obj.keys()) for _ in [None] if (v := obj[k]) is not None or True}
        if isinstance(obj, list):
            return [sort_keys(item) for item in obj]
        return obj

    ops = {
        "flatten_keys": flatten_keys,
        "keys_to_uppercase": keys_to_uppercase,
        "remove_nulls": remove_nulls,
        "sort_keys": sort_keys,
    }

    transformed = ops[params.operation](params.data)
    tool_log.info("transform_json completed", operation=params.operation)
    return json.dumps(
        {"operation": params.operation, "result": transformed},
        indent=2,
    )


@mcp.tool()
async def system_info(include_environment: bool = False) -> str:
    """Return metadata about the MCP server runtime environment.

    Useful for debugging connectivity, verifying the deployed version,
    and understanding the execution context.
    """
    tool_log = get_logger("tool.system_info")
    tool_log.info("system_info called")

    try:
        params = SystemInfoInput(include_environment=include_environment)
    except Exception as exc:
        return json.dumps({"error": f"Validation failed: {exc}"})

    info: dict[str, Any] = {
        "server_name": settings.server_name,
        "server_version": settings.server_version,
        "timestamp_utc": datetime.now(timezone.utc).isoformat(),
        "python_version": sys.version,
        "platform": {
            "system": platform.system(),
            "release": platform.release(),
            "machine": platform.machine(),
            "processor": platform.processor() or "unknown",
        },
        "uptime_seconds": round(time.monotonic() - _start_time, 2),
    }

    if params.include_environment:
        info["environment"] = {
            "python_executable": sys.executable,
            "python_prefix": sys.prefix,
            "log_level": settings.log_level,
            "log_format": settings.log_format,
        }

    tool_log.info("system_info completed")
    return json.dumps(info, indent=2)


# ---------------------------------------------------------------------------
# FastAPI application
# ---------------------------------------------------------------------------

_start_time = time.monotonic()

app = FastAPI(
    title=settings.server_name,
    version=settings.server_version,
    docs_url=None,   # Disable Swagger UI in production
    redoc_url=None,
)

# Register middleware (order matters โ€” outermost first)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(APIKeyMiddleware, api_key=settings.mcp_api_key)

# Mount the MCP HTTP app under /mcp
app.mount("/mcp", mcp.streamable_http_app())


@app.get("/health", include_in_schema=False)
async def health_check() -> JSONResponse:
    """Liveness probe for load balancers and container orchestrators."""
    return JSONResponse(
        content={
            "status": "ok",
            "server": settings.server_name,
            "version": settings.server_version,
            "uptime_seconds": round(time.monotonic() - _start_time, 2),
            "timestamp_utc": datetime.now(timezone.utc).isoformat(),
        }
    )


@app.on_event("startup")
async def on_startup() -> None:
    log.info(
        "MCP server starting",
        name=settings.server_name,
        version=settings.server_version,
        host=settings.host,
        port=settings.port,
        log_level=settings.log_level,
    )


@app.on_event("shutdown")
async def on_shutdown() -> None:
    log.info("MCP server shutting down", name=settings.server_name)


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    uvicorn.run(
        "server:app",
        host=settings.host,
        port=settings.port,
        log_level=settings.log_level.lower(),
        reload=settings.log_format == "console",  # Enable reload in dev mode
        access_log=False,  # structlog handles request logging
    )

Python FastAPI MCP Server Starter

Overview

This template gives you a production-ready foundation for building Model Context Protocol (MCP) servers in Python using FastAPI as the HTTP transport layer. It is designed for developers who are new to MCP and want a clean, well-structured starting point that follows real-world best practices rather than toy examples.

The server implements three realistic tools โ€” a text analyser, a JSON transformer, and a system information utility โ€” demonstrating how to handle input validation, structured error responses, and async execution in an MCP context. Every component is wired together with API key authentication and structured JSON logging so you can deploy with confidence from day one.

This template is ideal for Python developers building internal tooling, AI-assisted workflows, or API-backed MCP servers that need to run reliably in containerised environments. Whether you are connecting to Claude Desktop for local experimentation or deploying to Railway or Fly.io for team use, this template handles the boilerplate so you can focus on your domain logic.

What You'll Learn

  • How the Model Context Protocol (MCP) works over HTTP transport and why it differs from stdio
  • How to register and implement MCP tools using the mcp Python SDK with FastAPI
  • How to protect an MCP server with API key authentication using FastAPI middleware
  • How to configure structlog for JSON-formatted logs with per-request correlation IDs
  • How to load and validate environment-based configuration using Pydantic Settings
  • How to write a multi-stage Dockerfile that produces a minimal, secure production image
  • How to use docker-compose for local development with live reload and environment injection
  • How to validate tool inputs with Pydantic models and return structured error responses
  • How to implement async tool handlers that are safe for concurrent MCP clients
  • How to expose a health check endpoint alongside MCP routes for deployment monitoring

Architecture

Claude Desktop / MCP Client
        โ”‚
        โ”‚  HTTP POST /mcp  (Authorization: Bearer <api-key>)
        โ–ผ
  FastAPI Application
        โ”‚
        โ”œโ”€โ”€ API Key Auth Middleware  (auth.py)
        โ”‚         โ”‚  reject โ†’ 401 Unauthorized
        โ”‚         โ–ผ
        โ”œโ”€โ”€ Request Logger Middleware (logger.py)
        โ”‚         โ”‚  attaches correlation_id to every log line
        โ”‚         โ–ผ
        โ””โ”€โ”€ MCP Router  (server.py)
                  โ”‚
                  โ”œโ”€โ”€ tool: analyze_text    โ†’ NLP utilities
                  โ”œโ”€โ”€ tool: transform_json  โ†’ Data reshaping
                  โ””โ”€โ”€ tool: system_info     โ†’ Runtime metadata

Project Structure

python-fastapi-mcp-starter/
โ”œโ”€โ”€ server.py                  # Main FastAPI + MCP application
โ”œโ”€โ”€ auth.py                    # API key authentication middleware
โ”œโ”€โ”€ config.py                  # Pydantic Settings configuration
โ”œโ”€โ”€ logger.py                  # structlog setup and middleware
โ”œโ”€โ”€ requirements.txt           # Pinned Python dependencies
โ”œโ”€โ”€ Dockerfile                 # Multi-stage production image
โ”œโ”€โ”€ docker-compose.yml         # Local development stack
โ”œโ”€โ”€ .env.example               # Environment variable reference
โ””โ”€โ”€ .gitignore                 # Python / Docker ignores

Prerequisites

  • Python 3.11 or higher
  • Docker and Docker Compose (for containerised development)
  • An MCP client such as Claude Desktop, Cursor, or Windsurf
  • Basic familiarity with Python async/await and REST APIs

Quick Start

# 1. Clone or download this template
git clone https://github.com/your-org/python-fastapi-mcp-starter.git
cd python-fastapi-mcp-starter

# 2. Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate

# 3. Install dependencies
pip install -r requirements.txt

# 4. Copy and edit environment variables
cp .env.example .env
# Edit .env โ€” set MCP_API_KEY to a strong secret

# 5. Run the server locally
python server.py
# Server starts on http://localhost:8000

# 6. Verify the health endpoint
curl http://localhost:8000/health

# 7. Test an MCP tool call (replace YOUR_KEY)
curl -X POST http://localhost:8000/mcp \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_info","arguments":{}}}'

Running with Docker Compose:

# Start the full stack
docker compose up --build

# Run in background
docker compose up -d --build

# View logs
docker compose logs -f mcp-server

# Stop
docker compose down

Features

  • HTTP Transport โ€” Runs as a standard HTTP server; easy to reverse-proxy, load-balance, and monitor
  • API Key Authentication โ€” Every request validated against a configurable secret key via Bearer token
  • Three Realistic Tools โ€” analyze_text, transform_json, and system_info with full input validation
  • Structured JSON Logging โ€” structlog with ISO timestamps, log levels, and per-request correlation IDs
  • Pydantic Configuration โ€” Type-safe settings loaded from environment variables with validation on startup
  • Multi-stage Docker Build โ€” Separate builder and runtime stages; non-root user, minimal final image
  • Docker Compose โ€” Single command local development with volume mounts for live code changes
  • Health Endpoint โ€” GET /health returns server status and version for load balancer checks
  • Async Tool Handlers โ€” All tools implemented as async functions safe for concurrent execution
  • Graceful Error Responses โ€” MCP-compliant error objects returned for validation failures and exceptions

Works With

ClientSupportedNotes
Claude Desktopโœ…Configure via claude_desktop_config.json with HTTP transport
Claude Codeโœ…Pass --mcp-server flag with server URL and API key
Cursorโœ…Add under Settings โ†’ MCP Servers
Windsurfโœ…Add under Cascade MCP configuration
Continueโœ…Add as a custom MCP provider in config.json

Claude Desktop configuration (~/Library/Application Support/Claude/claude_desktop_config.json):

{
  "mcpServers": {
    "fastapi-starter": {
      "command": "curl",
      "args": ["-s", "http://localhost:8000/mcp"],
      "env": {
        "MCP_SERVER_URL": "http://localhost:8000",
        "MCP_API_KEY": "your-secret-key-here"
      }
    }
  }
}

For HTTP-native MCP clients, point them directly at http://localhost:8000/mcp with the Authorization: Bearer <key> header.

Configuration

VariableDescriptionRequiredDefault
MCP_API_KEYSecret key clients must send as Bearer tokenโœ… Requiredโ€”
HOSTInterface the server binds toOptional0.0.0.0
PORTTCP port the server listens onOptional8000
LOG_LEVELLogging verbosity (DEBUG, INFO, WARNING, ERROR)OptionalINFO
LOG_FORMATOutput format: json for production, console for developmentOptionaljson
SERVER_NAMEDisplay name returned in server info responsesOptionalfastapi-mcp-starter
SERVER_VERSIONSemantic version returned in server infoOptional1.0.0
ALLOWED_ORIGINSComma-separated CORS origins (e.g. https://app.example.com)Optional*
REQUEST_TIMEOUTMaximum seconds before a tool call is cancelledOptional30

Deployment

Docker (standalone)

# Build the production image
docker build -t mcp-fastapi-starter:latest .

# Run with environment variables
docker run -d \
  --name mcp-server \
  -p 8000:8000 \
  -e MCP_API_KEY=your-strong-secret \
  -e LOG_LEVEL=INFO \
  mcp-fastapi-starter:latest

Railway

# Install Railway CLI
npm install -g @railway/cli

# Login and create project
railway login
railway init

# Set secrets
railway variables set MCP_API_KEY=your-strong-secret
railway variables set LOG_LEVEL=INFO

# Deploy
railway up

Fly.io

# Install flyctl and login
curl -L https://fly.io/install.sh | sh
fly auth login

# Launch (follow prompts, select Dockerfile)
fly launch

# Set secrets
fly secrets set MCP_API_KEY=your-strong-secret
fly secrets set LOG_LEVEL=INFO

# Deploy
fly deploy

# Check logs
fly logs

Production Checklist

  • MCP_API_KEY is set to a cryptographically random string (minimum 32 characters)
  • LOG_FORMAT is set to json for machine-parseable logs
  • LOG_LEVEL is set to INFO or WARNING (not DEBUG) in production
  • Server is running behind a TLS-terminating reverse proxy (nginx, Caddy, or cloud load balancer)
  • Health endpoint (/health) is configured in your load balancer or container orchestrator
  • Docker image is built from the runtime stage only (no build tools in production)
  • Container runs as non-root user (appuser) โ€” verify with docker inspect
  • ALLOWED_ORIGINS is restricted to known client domains (not *)
  • Resource limits (--memory, --cpus) are set on the container
  • Secrets are injected via environment variables or a secrets manager โ€” never baked into the image
  • Log output is shipped to a centralised system (Datadog, Logtail, CloudWatch)
  • REQUEST_TIMEOUT is tuned to match the slowest expected tool execution time
  • A liveness probe hits /health every 30 seconds with a failure threshold of 3
  • The image is regularly rebuilt to pick up base image security patches
  • API key rotation procedure is documented for your team

Testing

Manual testing with curl

# List available tools
curl -s -X POST http://localhost:8000/mcp \
  -H "Authorization: Bearer your-key" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | python -m json.tool

# Call analyze_text
curl -s -X POST http://localhost:8000/mcp \
  -H "Authorization: Bearer your-key" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"analyze_text","arguments":{"text":"The quick brown fox jumps over the lazy dog.","include_word_frequency":true}}}' | python -m json.tool

# Call transform_json
curl -s -X POST http://localhost:8000/mcp \
  -H "Authorization: Bearer your-key" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"transform_json","arguments":{"data":{"first_name":"Ada","last_name":"Lovelace","birth_year":1815},"operation":"flatten_keys"}}}' | python -m json.tool

# Verify auth rejection
curl -s -X POST http://localhost:8000/mcp \
  -H "Authorization: Bearer wrong-key" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/list","params":{}}'
# Expect: 401 Unauthorized

Testing with Claude Desktop

  1. Start the server: python server.py or docker compose up
  2. Open Claude Desktop and go to Settings โ†’ Developer โ†’ Edit Config
  3. Add the server configuration shown in the Works With section above
  4. Restart Claude Desktop
  5. In a new conversation, type: "Use the analyze_text tool to analyse this sentence: The MCP protocol enables powerful AI integrations."
  6. Claude should invoke the tool and return word count, character count, and frequency data