Skip to main content
Lesson 10 of 14 · MCP for Developers: Servers and Integrations

Architecture of a Custom MCP Server

Lesson 10 of 14 Updated November 2, 2026 Developer
On this page
  1. Designing a production MCP server
  2. Choosing your framework
  3. A project structure that lasts
  4. The entry point: server.py
  5. One tools module per domain
  6. Centralized configuration
  7. Errors the model can read
  8. Stateful or stateless
  9. Key takeaways

Updated November 2, 2026 — MCP Python SDK v2. This course now follows version 2 of the mcp SDK (2.3.0 as of October 2, 2026): MCPServer replaces FastMCP, transport settings move to run() or streamable_http_app(), and an application exposed under a real hostname must declare that hostname (transport_security). This is also the version required by the Mistral SDK: the mistralai[agents] extra requires mcp>=2.2.0,<3.0. Sources: What’s new in v2 · Migration Guide · Deploy & scale.

Designing a production MCP server

An MCP prototype fits in a forty-line file, and that’s often enough to convince a team. Moving to real-world usage changes everything: multiple people call the server, tools multiply, configuration varies across environments, and the single file becomes impossible to maintain. This lesson covers the architectural decisions that prevent hitting that wall—choosing a framework, structuring your project, managing configuration and errors, and deciding between stateful and stateless servers.

Choosing your framework

In Python, the MCPServer class from the official SDK (formerly FastMCP) is the standard tool. It exposes a decorator-based API and handles the MCP protocol for you, so a server springs to life in three lines:

from mcp.server.mcpserver import MCPServer

app = MCPServer(
    "My Server",
    instructions="MCP Server for project management",
)

JavaScript and TypeScript teams have the official MCP SDK published by Anthropic. The logic is comparable even if the syntax differs:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const server = new McpServer({
  name: "My Server",
  version: "1.0.0",
});

The choice depends less on capabilities and more on the ecosystem where your server will fetch its data. A server querying a database via SQLAlchemy or reusing existing processing scripts will naturally be in Python; a server backed by an existing Express application will stay in TypeScript. The table below summarizes the practical differences.

CriterionMCPServer (Python SDK)MCP SDK (TypeScript)
MaturityVery matureMature
Decorators@app.tool()server.tool() method
HTTP TransportBuilt-in (FastAPI)Built-in (Express)
CommunityLargeLarge

A project structure that lasts

The recommended directory structure separates four responsibilities: what the model sees (tools/), what accesses data (services/), what controls access (auth/), and what describes the environment (config.py, .env).

my-mcp-server/
├── server.py              # FastAPI entry point + MCP mounting
├── tools/
│   ├── __init__.py
│   ├── project_tools.py   # Project management tools
│   ├── file_tools.py      # File management tools
│   └── search_tools.py    # Search tools
├── services/
│   ├── __init__.py
│   ├── database.py        # Database access
│   └── external_api.py    # External API calls
├── auth/
│   ├── __init__.py
│   └── middleware.py       # Authentication middleware
├── config.py              # Configuration (env vars)
├── Dockerfile             # Containerization
├── requirements.txt       # Python dependencies
├── .env                   # Environment variables (not committed)
└── tests/
    ├── test_tools.py
    └── test_integration.py

This separation has a direct, concrete consequence: your tools no longer contain SQL or HTTP calls, only validation and delegation to a service. You can then test business logic without going through MCP, and switch databases without touching a single docstring the model sees.

The entry point: server.py

The main file does nothing but assemble components: it creates the FastAPI application, stacks middlewares, and mounts each MCP server on its own route.

# server.py
"""MCP server entry point."""

from contextlib import asynccontextmanager

import os

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from mcp.server.transport_security import TransportSecuritySettings
from tools.project_tools import project_mcp
from tools.search_tools import search_mcp
from auth.middleware import AuthMiddleware

# Each MCP server must start its session manager
@asynccontextmanager
async def lifespan(app: FastAPI):
    async with project_mcp.session_manager.run(), search_mcp.session_manager.run():
        yield

# FastAPI application
api = FastAPI(
    title="My MCP Server",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS for web clients
api.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# Authentication middleware
api.add_middleware(AuthMiddleware)

# Public hostname under which servers are served (otherwise: 421 Invalid Host header)
PUBLIC_HOST = os.environ.get("MCP_PUBLIC_HOST", "localhost")
security = TransportSecuritySettings(allowed_hosts=[PUBLIC_HOST, f"{PUBLIC_HOST}:*"])

# Mount MCP servers on distinct routes
api.mount("/projects", project_mcp.streamable_http_app(transport_security=security))
api.mount("/search", search_mcp.streamable_http_app(transport_security=security))

# Home page (optional but useful)
@api.get("/")
async def root():
    return {
        "service": "My MCP Server",
        "version": "1.0.0",
        "endpoints": {
            "projects": "/projects/mcp",
            "search": "/search/mcp",
        },
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(api, host="0.0.0.0", port=8000)

Mounting on distinct routes lets users enable only the domain they need: someone looking for documentation connects to /search/mcp without being offered project management tools. As for the home page, it seems trivial until a colleague asks for the exact endpoint URL; it answers for you and serves as a health check.

One tools module per domain

Each file in the tools/ folder declares its own MCPServer object and groups tools from the same functional domain. Here’s what the project management module looks like, imported earlier by server.py:

# tools/project_tools.py
"""MCP tools for project management."""

from mcp.server.mcpserver import MCPServer
from services.database import db

project_mcp = MCPServer(
    "Project Manager",
    instructions="Tools for project and task management",
)

@project_mcp.tool()
async def create_project(name: str, description: str = "") -> str:
    """Creates a new project.

    Args:
        name: Project name (2-100 characters)
        description: Project description (optional)

    Returns:
        JSON with created project details
    """
    project = await db.projects.create(name=name, description=description)
    return project.to_json()

@project_mcp.tool()
async def list_projects(status: str = "active") -> str:
    """Lists projects by status.

    Args:
        status: Filter by status: active, archived, all (default: active)

    Returns:
        JSON with list of projects
    """
    projects = await db.projects.list(status=status)
    return projects.to_json()

Notice how brief the function bodies are: just two lines each, because data access lives in services/database.py. The tool simply describes its contract to the model and delegates.

Centralized configuration

Any value that changes between your machine and the production server must come from the environment, never from code. A single config.py file gathers these variables with their default values:

# config.py
"""MCP server configuration."""

import os
from dataclasses import dataclass

@dataclass
class Config:
    api_token: str = os.getenv("MCP_API_TOKEN", "")
    database_url: str = os.getenv("DATABASE_URL", "sqlite:///data.db")
    debug: bool = os.getenv("DEBUG", "false").lower() == "true"
    port: int = int(os.getenv("PORT", "8000"))
    max_results: int = int(os.getenv("MAX_RESULTS", "50"))

config = Config()

The max_results parameter illustrates the benefit of this approach: the day a tool returns too many results and saturates the model’s context, you adjust an environment variable and restart, without redeploying code.

Errors the model can read

A Python exception that bubbles up abruptly tells the model nothing: it sees a failure without understanding why, and often retries identically. Instead, standardize an explicit error response format with a code and a message.

import json

def error_response(message: str, code: str = "error") -> str:
    """Formats a standardized error message."""
    return json.dumps({
        "success": False,
        "error": {"code": code, "message": message}
    }, ensure_ascii=False)

@project_mcp.tool()
async def get_project(project_id: str) -> str:
    """Retrieves project details by ID."""
    try:
        project = await db.projects.get(project_id)
        if not project:
            return error_response(f"Project {project_id} not found", "not_found")
        return project.to_json()
    except Exception as e:
        return error_response(f"Server error: {str(e)}", "server_error")

The difference is decisive in conversation. Faced with not_found, the model understands it must first list projects to find the right ID; faced with a silent exception, it gives up or loops.

Stateful or stateless

A stateful server retains information between calls: in-memory database, user sessions, work context. This is common for management servers, like the task manager from the previous lesson. A stateless server handles each request independently, remembering nothing—recommended for pure computation or transformation, and allowing horizontal scaling since any instance can handle any request. SDK v2 enables this with an HTTP application parameter (no longer passed to the constructor). Note that with the 2026-07-28 specification, the protocol itself no longer knows sessions: this setting only concerns clients negotiating an earlier protocol version.

app = MCPServer("Stateless Server")

# Setting is passed when building the HTTP application
mcp_app = app.streamable_http_app(stateless_http=True)

Decide this question before writing your tools, not after: moving from a stateful to a stateless server means moving all state to an external database, which rewrites a good portion of the code.

Key takeaways

  • Separate your tools into thematic modules in a tools/ folder
  • Use FastAPI as the HTTP base, mount MCP servers on dedicated routes, and pass their lifespan
  • Centralize configuration via environment variables
  • Standardize error response formats so the model can use them
  • Choose between stateful (data management) and stateless (pure computation)
  • Add a / home page to document your MCP endpoints