building-mcp-servers

Use when building an MCP server in Python, wrapping a REST API as MCP tools, designing MCP tool interfaces, or setting up FastMCP project structure - covers architecture, tool design, auth patterns, error handling, lifespan management, and deployment

Building Production MCP Servers with Python FastMCP

Overview

Build MCP servers using the Python mcp SDK (FastMCP). This skill covers idiomatic patterns for wrapping REST APIs, designing LLM-friendly tools, managing shared state, and deploying production servers.

Core principle: MCP tools should be small, single-purpose, async, and typed. Let FastMCP generate schemas from your Python annotations.

When to Use

  • Building a new MCP server from scratch
  • Wrapping an existing REST API as MCP tools
  • Designing tool parameters and descriptions for LLM consumption
  • Setting up auth token management in an MCP server
  • Choosing transport and deployment strategy (stdio vs SSE vs HTTP)
  • Reviewing or refactoring an existing MCP server

Quick Reference

DecisionRecommendation
SDKmcp[cli] (FastMCP) via pip/uv
Python version3.10+ (3.12+ for new projects)
HTTP clienthttpx (async)
Sync vs async toolsAlways async for I/O-bound tools
Transportstdio for CLI/desktop, streamable HTTP for remote
ValidationPydantic via type annotations (auto by FastMCP)
ConfigEnvironment variables + .env
Packagingpyproject.toml with [project.scripts] entry point

Project Structure

Single-file (up to ~40 tools, wrapper servers)

my-mcp-server/
├── server.py           # FastMCP instance + all tools
├── pyproject.toml      # deps + entry point
├── .env.example        # env var template
└── README.md

Modular (40+ tools or complex business logic)

my-mcp-server/
├── src/myserver/
│   ├── __init__.py
│   ├── server.py          # FastMCP instance, registers tools
│   ├── client.py          # Shared HTTP/API client
│   ├── tools/
│   │   ├── __init__.py
│   │   ├── patients.py
│   │   └── appointments.py
│   └── lifespan.py        # Shared resource init/teardown
├── tests/
├── pyproject.toml
└── README.md

Rule of thumb: Start single-file. Split only when files exceed ~1500 lines or tools have non-trivial business logic.

Core Patterns

1. Server Setup with Lifespan

Use FastMCP's @lifespan to initialize shared resources (HTTP clients, DB pools). They are available to all tools and cleaned up on shutdown.

from contextlib import asynccontextmanager
from dataclasses import dataclass, field
import httpx
from mcp.server.fastmcp import FastMCP, Context

mcp = FastMCP("my-server")

@dataclass
class AppState:
    http: httpx.AsyncClient = field(default=None)
    token: str | None = None
    base_url: str = ""

@asynccontextmanager
async def app_lifespan(server: FastMCP):
    state = AppState(
        http=httpx.AsyncClient(timeout=30.0),
        base_url=os.getenv("API_BASE_URL", "https://api.example.com"),
    )
    # Auto-login if credentials provided
    username = os.getenv("API_USERNAME")
    if username:
        # ... perform login, set state.token
        pass
    try:
        yield state
    finally:
        await state.http.aclose()

mcp = FastMCP("my-server", lifespan=app_lifespan)

Access state in tools via ctx.request_context.lifespan_context:

@mcp.tool()
async def get_thing(thing_id: int, ctx: Context) -> str:
    state: AppState = ctx.request_context.lifespan_context
    resp = await state.http.get(
        f"{state.base_url}/v1/things/{thing_id}",
        headers={"AUTH-TOKEN": state.token},
    )
    return resp.text

2. Tool Design (LLM-Friendly)

Naming: Snake_case, verb-first, single-purpose.

  • get_patient not fetchPatientData
  • create_appointment not appointmentCRUD
  • search_invoices not invoiceOperations

Parameters: Use Annotated with Field for rich descriptions. FastMCP auto-generates the MCP schema.

from typing import Annotated, Optional
from pydantic import Field

@mcp.tool()
async def search_patients(
    surname: Annotated[str, Field(description="Patient surname to search for")],
    forename: Annotated[Optional[str], Field(description="Patient forename")] = None,
    dob: Annotated[Optional[str], Field(description="Date of birth (YYYY-MM-DD)")] = None,
    ctx: Context = None,
) -> str:
    """Search for patients by name and/or date of birth."""
    state: AppState = ctx.request_context.lifespan_context
    params = {"surname": surname}
    if forename:
        params["forename"] = forename
    if dob:
        params["dob"] = dob
    resp = await state.http.get(
        f"{state.base_url}/v1/patients/search",
        params=params,
        headers={"AUTH-TOKEN": state.token},
    )
    return resp.text

Tool hints: Signal semantics to LLM clients.

from mcp.server.fastmcp import FastMCP

# GET endpoints (safe to retry, no side effects)
@mcp.tool(readOnlyHint=True)
async def get_patient(patient_id: int, ctx: Context) -> str: ...

# PUT endpoints (safe to retry, idempotent)
@mcp.tool(idempotentHint=True)
async def update_patient(patient_id: int, ..., ctx: Context) -> str: ...

# POST endpoints (creates new resource, NOT safe to retry blindly)
@mcp.tool()
async def create_patient(..., ctx: Context) -> str: ...

Descriptions: Keep docstrings short. State what the tool does and any important constraints. Never include secrets or internal implementation details.

# GOOD
async def get_patient(patient_id: int, ctx: Context) -> str:
    """Get a patient's full record by their ID."""

# BAD - too much internal detail
async def get_patient(patient_id: int, ctx: Context) -> str:
    """Calls GET /v1/patients/{id} with AERONA-AUTH-TOKEN header using httpx."""

3. Auth Token Management

Pattern A: Environment-based auto-login (recommended for most wrappers)

@asynccontextmanager
async def app_lifespan(server: FastMCP):
    state = AppState(http=httpx.AsyncClient(timeout=30.0))
    username = os.getenv("API_USERNAME")
    password = os.getenv("API_PASSWORD")
    if username and password:
        resp = await state.http.post(
            f"{state.base_url}/login",
            json={"username": username, "password": password},
        )
        state.token = resp.headers.get("AUTH-TOKEN")
    try:
        yield state
    finally:
        await state.http.aclose()

Pattern B: Explicit login tool (when manual control is needed)

@mcp.tool()
async def login(
    username: Annotated[str, Field(description="API username")],
    password: Annotated[str, Field(description="API password")],
    ctx: Context,
) -> str:
    """Authenticate and store the session token for subsequent API calls."""
    state: AppState = ctx.request_context.lifespan_context
    resp = await state.http.post(
        f"{state.base_url}/login",
        json={"username": username, "password": password},
    )
    if resp.status_code == 200:
        state.token = resp.headers.get("AUTH-TOKEN")
        return "Login successful."
    return f"Login failed: {resp.status_code} - {resp.text}"

Pattern C: Both - auto-login from env vars + explicit login tool for manual override.

4. Error Handling

Return structured errors. Never expose secrets. Use ctx.error() for server-side logging.

@mcp.tool(readOnlyHint=True)
async def get_patient(patient_id: int, ctx: Context) -> str:
    """Get a patient's full record by their ID."""
    state: AppState = ctx.request_context.lifespan_context
    if not state.token:
        return "Error: Not authenticated. Call the login tool first."
    try:
        resp = await state.http.get(
            f"{state.base_url}/v1/patients/{patient_id}",
            headers={"AUTH-TOKEN": state.token},
        )
        if resp.status_code == 200:
            return resp.text
        if resp.status_code == 401:
            return "Error: Token expired. Call the login tool to re-authenticate."
        if resp.status_code == 429:
            return "Error: Rate limit exceeded. Wait before retrying."
        return f"Error: {resp.status_code} - {resp.text}"
    except httpx.RequestError as e:
        ctx.error(f"Request failed for patient {patient_id}: {e}")
        return f"Error: Request failed - {type(e).__name__}"

Key rules:

  • Always check for token before making requests
  • Map HTTP status codes to actionable messages the LLM can understand
  • Log with ctx.error() / ctx.info() for server-side diagnostics
  • Never include raw stack traces or secrets in return values

5. Helper for Repetitive Wrapper Tools

When wrapping many similar endpoints, use a helper to reduce boilerplate:

async def _api_request(
    state: AppState,
    method: str,
    path: str,
    ctx: Context,
    params: dict | None = None,
    json_body: dict | None = None,
) -> str:
    if not state.token:
        return "Error: Not authenticated. Call the login tool first."
    try:
        resp = await state.http.request(
            method,
            f"{state.base_url}{path}",
            headers={"AUTH-TOKEN": state.token},
            params=params,
            json=json_body,
        )
        if resp.status_code == 401:
            return "Error: Token expired. Call the login tool to re-authenticate."
        if resp.status_code == 429:
            return "Error: Rate limit exceeded. Wait before retrying."
        return resp.text
    except httpx.RequestError as e:
        ctx.error(f"{method} {path} failed: {e}")
        return f"Error: Request failed - {type(e).__name__}"

Then each tool becomes thin:

@mcp.tool(readOnlyHint=True)
async def get_patient(
    patient_id: Annotated[int, Field(description="The patient's ID")],
    ctx: Context,
) -> str:
    """Get a patient's full record by their ID."""
    state: AppState = ctx.request_context.lifespan_context
    return await _api_request(state, "GET", f"/v1/patients/{patient_id}", ctx)

6. pyproject.toml

[project]
name = "my-mcp-server"
version = "0.1.0"
description = "MCP server for My API"
requires-python = ">=3.10"
dependencies = [
    "mcp[cli]",
    "httpx",
]

[project.scripts]
my-mcp-server = "server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Entry point in server.py:

def main():
    mcp.run(transport="stdio")

if __name__ == "__main__":
    main()

Transport & Deployment

TransportUse CaseCommand
stdioClaude Desktop, CLI tools, local devmcp.run(transport="stdio")
SSE / Streamable HTTPRemote servers, multi-clientmcp.run(transport="sse", host="0.0.0.0", port=8000)

For Claude Desktop claude_desktop_config.json:

{
  "mcpServers": {
    "my-server": {
      "command": "uv",
      "args": ["run", "--with", "my-mcp-server", "my-mcp-server"],
      "env": {
        "API_USERNAME": "...",
        "API_PASSWORD": "..."
      }
    }
  }
}

Production (remote): Use HTTPS with TLS termination at a reverse proxy. FastMCP enforces HTTPS in production (only localhost may use plain HTTP).

Common Mistakes

MistakeFix
Blocking sync calls in async toolsUse httpx.AsyncClient, never requests
Creating new HTTP client per tool callInitialize in lifespan, reuse across tools
Monolithic "do everything" toolsOne tool per API endpoint, single responsibility
Secrets in tool descriptions or error messagesKeep descriptions about behavior, log secrets nowhere
Weakly-typed parameters (**kwargs, dict)Use explicit typed parameters with Field descriptions
Missing tool hintsAdd readOnlyHint/idempotentHint for GET/PUT tools
Auto-retrying on rate limits inside the serverPass 429 errors through; let the LLM client decide
Huge JSON responses with no guidanceAdd descriptions noting what fields are most useful

Checklist: Before Shipping

  • All tools are async
  • Lifespan manages HTTP client lifecycle (init + teardown)
  • Every parameter has a type annotation and description
  • GET tools have readOnlyHint=True
  • PUT tools have idempotentHint=True
  • No secrets in tool descriptions, logs, or error returns
  • Auth errors return actionable messages ("call login tool")
  • pyproject.toml has correct entry point
  • .env.example documents all required env vars
  • Tested with mcp dev server.py or Claude Desktop