← All posts
AIApril 17, 2026·9 min read

Build a Custom MCP Server in Python in 2026

I’ve built several MCP servers in Python: WordPress publishing, Skool community management, YouTube analytics. They all follow the same pattern, and the point of each one was to stop copy-pasting data into prompts and let Claude Code call the system directly. Here’s how to build one from scratch with the official SDK, including the parts…

mcp server in python

I’ve built several MCP servers in Python: WordPress publishing, Skool community management, YouTube analytics. They all follow the same pattern. The point of each one was to stop copy-pasting data into prompts and let Claude Code read the system directly from the terminal.

Here’s how to build one from scratch with the official SDK.

What Is an MCP Server?

MCP (Model Context Protocol) is an open standard from Anthropic that lets AI assistants call external tools. You define tools with typed inputs and outputs. Claude Code calls them mid-conversation like native functions.

The protocol runs over JSON-RPC through stdio. Your server starts as a subprocess that Claude Code spawns and talks to via stdin/stdout. There is no HTTP layer and no webhook to register, just a Python process reading and writing JSON.

If you’ve built a CLI tool before, an MCP server is simpler than that.

Set Up the Project

Start with a clean directory and install the MCP Python SDK:

mkdir my-mcp-server && cd my-mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install mcp[cli]

The mcp[cli] package includes the server framework and a dev inspector for testing. That’s the only dependency you need.

Build Your First Tool

Create server.py. Every MCP server follows the same structure: create a server instance, define tools with decorators, and run the stdio transport.

from mcp.server.fastmcp import FastMCP
 
mcp = FastMCP("my-tools")
 
@mcp.tool()
def get_system_info() -> str:
    """Return basic system information."""
    import platform
 
    return (
        f"OS: {platform.system()} {platform.release()}\n"
        f"Python: {platform.python_version()}\n"
        f"Machine: {platform.machine()}"
    )
 
@mcp.tool()
def count_files(directory: str, extension: str = "") -> str:
    """Count files in a directory, optionally filtered by extension.
 
    Args:
        directory: Path to the directory to scan
        extension: File extension to filter by (e.g. '.py', '.json')
    """
    from pathlib import Path
 
    path = Path(directory).expanduser()
    if not path.is_dir():
        return f"Error: {directory} is not a valid directory"
 
    if extension:
        files = list(path.rglob(f"*{extension}"))
    else:
        files = [f for f in path.rglob("*") if f.is_file()]
 
    return f"Found {len(files)} files in {directory}" + (
        f" matching {extension}" if extension else ""
    )
 
if __name__ == "__main__":
    mcp.run(transport="stdio")

Things worth noticing:

  • The @mcp.tool() decorator registers a function as a callable tool, and the function name becomes the tool name.
  • Type hints on parameters are required. MCP uses them to generate the JSON schema that Claude reads.
  • The docstring becomes the tool description, which is what Claude uses to decide when to call it. Write it like you’re explaining the tool to a colleague.

Add a Tool With External API Access

Most useful MCP servers talk to external services. Here’s a tool that checks the status of a website:

import httpx
 
@mcp.tool()
async def check_website(url: str) -> str:
    """Check if a website is reachable and return its status code and response time.
 
    Args:
        url: Full URL to check (e.g. https://example.com)
    """
    async with httpx.AsyncClient(timeout=10) as client:
        try:
            response = await client.get(url)
            return (
                f"Status: {response.status_code}\n"
                f"Response time: {response.elapsed.total_seconds():.2f}s\n"
                f"Content-Type: {response.headers.get('content-type', 'unknown')}"
            )
        except httpx.RequestError as e:
            return f"Error reaching {url}: {str(e)}"

Async tools work out of the box, since the MCP SDK handles the event loop for you. I use httpx for the HTTP calls because it’s async-native and has timeout handling built in.

Install it alongside the SDK:

pip install httpx

Test Before Connecting to Claude

The MCP CLI includes a dev inspector that lets you test tools without Claude:

mcp dev server.py

This opens a web UI where you can call each tool, inspect the JSON schema, and verify outputs. Fix issues here before connecting to Claude Code. Debugging inside a conversation is slower.

Connect to Claude Code

Add your server to Claude Code’s MCP configuration. Open .claude/settings.json in your project root:

{
  "mcpServers": {
    "my-tools": {
      "command": "/path/to/my-mcp-server/.venv/bin/python",
      "args": ["server.py"],
      "cwd": "/path/to/my-mcp-server"
    }
  }
}

Use the full path to the Python binary inside your venv. Claude Code spawns this as a subprocess. If you use a relative path or the system Python, it won’t find your installed packages.

Restart Claude Code. Your tools appear as mcp__my-tools__get_system_info, mcp__my-tools__count_files, and mcp__my-tools__check_website.

Add Resources for Context

Tools handle actions. Resources handle data that Claude reads as context. Use them for configuration, reference data, or live state:

@mcp.resource("config://settings")
def get_settings() -> str:
    """Current server configuration."""
    import json
 
    settings = {
        "version": "1.0.0",
        "tools_registered": 3,
        "max_timeout": 10,
    }
    return json.dumps(settings, indent=2)

Claude can read this resource to understand your server’s capabilities before calling tools.

Error Handling in MCP Tools

MCP tools should never raise unhandled exceptions. A crash kills the server process and disconnects all tools mid-conversation. Wrap external calls and return error strings:

@mcp.tool()
def read_config(path: str) -> str:
    """Read a configuration file and return its contents.
 
    Args:
        path: Path to the config file
    """
    from pathlib import Path
 
    try:
        config_path = Path(path).expanduser()
        if not config_path.exists():
            return f"Error: {path} does not exist"
        if config_path.stat().st_size > 100_000:
            return f"Error: {path} is too large (>100KB)"
        return config_path.read_text()
    except PermissionError:
        return f"Error: no permission to read {path}"
    except Exception as e:
        return f"Error reading {path}: {str(e)}"

Claude reads the error string and adjusts. It retries with a different path, asks the user for help, or moves on to something else.

Stdio vs HTTP: Which Transport to Pick

MCP supports two transports: stdio (local child process: Claude spawns your script and pipes JSON-RPC over stdin/stdout) and HTTP (your server runs as a long-lived web service that Claude connects to via Streamable HTTP). The choice is worth more thought than it usually gets.

Use stdio when:

  • The server is for your local machine only (Claude Desktop, Claude Code CLI on your laptop)
  • Tools need access to your local filesystem, local network, or local processes
  • You don’t want to manage a hosted service
  • Auth is implicitly your OS-level access, because the script runs as you

Use HTTP when:

  • Multiple clients (Claude Desktop, Cursor, custom agents, teammates) need the same tools
  • Tools rely on a centralized resource (production database, CRM, internal API)
  • You want auditability, so every call goes through one server that writes one log
  • The server holds long-lived state, like cached embeddings or connection pools, that’s wasteful to recreate on every stdio invocation

Switching from stdio to HTTP looks like this. Same FastMCP server, different transport:

from mcp.server.fastmcp import FastMCP
 
mcp = FastMCP("my-server")
 
@mcp.tool()
def search_docs(query: str) -> str:
    """Search internal documentation."""
    # ... same tool implementation as stdio version ...
    return results
 
if __name__ == "__main__":
    # Stdio (default): Claude spawns this script as a child process
    # mcp.run()
 
    # HTTP / Streamable HTTP: serve on a port, multiple clients connect
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)

The tool definitions stay identical. That separation is intentional in the spec, so you write the tools once and pick the deployment shape later.

Deploy MCP Server for Team / Multi-Client Use

Once you switch to HTTP transport, your MCP server is just a normal Python web service. The deployment patterns are the same ones you’d use for any FastAPI/Flask app. Three options ordered by setup effort:

1. Single Docker container behind a reverse proxy

Wrap the server in a Dockerfile, run it on a small VPS, put Caddy or nginx in front for HTTPS:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "server.py"]

Clients connect with {"type": "streamable-http", "url": "https://mcp.example.com/", "headers": {"x-api-key": "..."}}. A small Hetzner CX11 handles dozens of tools comfortably.

2. Serverless (Cloud Run, Fly.io, Lambda + API Gateway)

If tool calls are infrequent and you don’t want to manage a VPS, serverless works. The catch is that Streamable HTTP needs a platform that supports long-lived connections. Cloud Run and Fly.io handle that fine. Lambda has a 15-minute execution cap, so I’d keep streaming sessions off it.

3. Behind your existing API gateway

If you already run an internal API (FastAPI app, etc.), mount the MCP server as a sub-route. You then reuse whatever auth middleware, logging and rate limiting that app already has. The MCP Python SDK exposes an ASGI app you can mount with app.mount("/mcp", mcp.streamable_http_app()).

Auth on an HTTP server

Stdio servers inherit your user’s privileges automatically. An HTTP server is reachable from the public internet, so it needs its own answer. At minimum, require an x-api-key header on every tool call and generate a separate key per client or teammate, which keeps revocation scoped to one key. Log which key called which tool. And keep secrets out of tool responses, since those end up in conversation history Claude may serialize.

For tools that read or modify shared state (production DB, CRM), add scope checks inside each tool function. The MCP layer only authenticates the request. Authorizing the specific action is the tool’s job.

Project Structure for Production

Once your server grows past 3-4 tools, split into modules:

my-mcp-server/
  server.py          # Entry point — imports and registers tools
  tools/
    system.py        # System info tools
    web.py           # HTTP/API tools
    files.py         # File operation tools
  requirements.txt

Keep server.py minimal. Import tools from modules so each file stays focused:

from mcp.server.fastmcp import FastMCP
 
mcp = FastMCP("my-tools")
 
# Import tool modules — decorators register on import
import tools.system  # noqa: F401
import tools.web     # noqa: F401
import tools.files   # noqa: F401
 
if __name__ == "__main__":
    mcp.run(transport="stdio")

Each tool module imports the shared mcp instance and uses @mcp.tool() as usual. This pattern scales to 20+ tools without the main file becoming unreadable.

Common Mistakes

Printing to stdout. MCP uses stdout for JSON-RPC. Any print() statement corrupts the protocol and crashes the connection. Use logging to stderr instead:

import logging
import sys
 
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logger = logging.getLogger(__name__)

Missing type hints. Every parameter needs a type annotation. Without one, the MCP SDK can’t generate the JSON schema, and Claude won’t know what arguments to pass.

Blocking the event loop. If you have a CPU-heavy tool, run it in an executor. The MCP server is single-threaded. A blocking call freezes all tools:

import asyncio
 
@mcp.tool()
async def heavy_computation(data: str) -> str:
    """Run a CPU-intensive task without blocking other tools."""
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, process_data, data)
    return result

FAQ

Do I need to restart Claude Code after changing my MCP server?

Yes. Claude Code loads the MCP configuration at startup and spawns servers as subprocesses. After editing server.py, restart Claude Code to pick up changes. The mcp dev inspector is faster for iterating.

Can one MCP server connect to multiple AI clients?

Yes. The protocol is client-agnostic, so the same server works with Claude Code, Claude Desktop and anything else that speaks MCP. Point each client at your server’s entry point and change nothing in the code.

What’s the difference between tools and resources?

A tool takes inputs, does something and returns a result. A resource is read-only context that Claude pulls in for background: configuration, reference data, live state.

Is there a limit on how many tools one server can expose?

No hard limit in the protocol. I run servers with 10-15 tools without issues. If you go past 20, consider splitting into multiple servers by domain (one for database ops, one for API calls, etc.) to keep tool descriptions focused.


I’m documenting the full build process (MCP servers, Claude Code agent patterns, and autonomous workflows) in my Build & Automate community. The step-by-step modules with the real production code live there.

AIautomationclaude-codedeveloper-toolsmcp