tutorial15 min read

How to Build an MCP Server in Python (Step-by-Step)

Build a working MCP server in Python using FastMCP. Covers tools, resources, prompts, testing with MCP Inspector, and configuring in Claude Code, Claude Desktop, and Cursor.

WI
William Wang · Apr 9, 2026

William Wang — Founder of TokRepo & GEOScore AI. Building tools for AI developer productivity and search visibility.

How to Build an MCP Server in Python (Step-by-Step)
Table of Contents

Learn how to build a production-ready MCP server in Python using the official FastMCP SDK, test it with MCP Inspector, and configure it in Claude Code, Claude Desktop, and Cursor.

Prerequisites

  • Python 3.10 or higher
  • uv package manager (recommended) or pip
  • Basic Python knowledge
  • One of: Claude Code, Claude Desktop, or Cursor installed

What is MCP?

The Model Context Protocol (MCP) is an open standard created by Anthropic that lets you build servers exposing data and functionality to LLM applications. Think of it like a web API designed for AI interactions. MCP servers provide three capabilities:

  • Tools — Functions the LLM can call (like POST endpoints)
  • Resources — Read-only data the LLM can access (like GET endpoints)
  • Prompts — Reusable templates for LLM interactions

MCP is now an open standard under the Linux Foundation, supported by Claude Code, Cursor, Codex CLI, Gemini CLI, and VS Code. Browse MCP server configs on TokRepo to see what's already available.

Step 1 — Set Up Your Environment

Install uv (the recommended Python package manager for MCP development):

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

Create your project:

uv init my-mcp-server
cd my-mcp-server
uv venv && source .venv/bin/activate
uv add "mcp[cli]"
touch server.py

The mcp[cli] extra installs the MCP CLI tools for testing. The current SDK version is v1.27.0.

Step 2 — Create a Basic MCP Server

Create server.py:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-first-server")


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers together."""
    return a + b


@mcp.tool()
def multiply(a: int, b: int) -> int:
    """Multiply two numbers together."""
    return a * b


if __name__ == "__main__":
    mcp.run(transport="stdio")

Key points:

  • FastMCP uses Python type hints and docstrings to auto-generate tool schemas
  • The @mcp.tool() decorator registers a function as an MCP tool
  • transport="stdio" communicates via stdin/stdout (default for local dev)

Step 3 — Add Resources

Resources expose read-only data:

@mcp.resource("config://app-settings")
def get_settings() -> str:
    """Return application settings."""
    return '{"theme": "dark", "language": "en", "version": "2.1.0"}'


@mcp.resource("file://documents/{name}")
def read_document(name: str) -> str:
    """Read a document by name."""
    return f"Content of document: {name}"

The first resource has a static URI. The second uses a URI template with {name} as a dynamic parameter.

Step 4 — Add Prompts

Prompts are reusable templates:

@mcp.prompt()
def code_review(code: str, language: str = "python") -> str:
    """Generate a code review prompt."""
    return f"""Please review this {language} code for:
- Bugs and potential issues
- Performance improvements
- Code style and best practices

Code to review:
```{language}
{code}
```"""

Step 5 — Use Context for Progress and Logging

from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession

mcp = FastMCP("context-demo")


@mcp.tool()
async def process_data(
    file_path: str,
    ctx: Context[ServerSession, None],
) -> str:
    """Process a data file with progress updates."""
    await ctx.info(f"Starting to process: {file_path}")

    total_steps = 3
    for i in range(total_steps):
        await ctx.report_progress(
            progress=(i + 1) / total_steps,
            total=1.0,
            message=f"Step {i + 1}/{total_steps}",
        )

    return f"Successfully processed {file_path}"

Step 6 — Add Lifespan Management

For shared resources like database connections or HTTP clients:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

import httpx
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession


@dataclass
class AppContext:
    http_client: httpx.AsyncClient


@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
    async with httpx.AsyncClient() as client:
        yield AppContext(http_client=client)


mcp = FastMCP("lifespan-demo", lifespan=app_lifespan)


@mcp.tool()
async def fetch_url(
    url: str,
    ctx: Context[ServerSession, AppContext],
) -> str:
    """Fetch content from a URL."""
    client = ctx.request_context.lifespan_context.http_client
    response = await client.get(url, timeout=30.0)
    return response.text[:5000]

Step 7 — Complete Working Example: GitHub Stars Server

"""MCP server that fetches GitHub repository information."""
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("github-stars")
GITHUB_API = "https://api.github.com"


async def github_request(path: str) -> dict[str, Any] | None:
    headers = {"Accept": "application/vnd.github.v3+json"}
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(
                f"{GITHUB_API}{path}", headers=headers, timeout=30.0
            )
            response.raise_for_status()
            return response.json()
        except Exception:
            return None


@mcp.tool()
async def get_repo_info(owner: str, repo: str) -> str:
    """Get information about a GitHub repository.

    Args:
        owner: Repository owner (e.g. 'anthropics')
        repo: Repository name (e.g. 'claude-code')
    """
    data = await github_request(f"/repos/{owner}/{repo}")
    if not data:
        return "Failed to fetch repository information."

    return f"""Repository: {data.get('full_name')}
Description: {data.get('description', 'No description')}
Stars: {data.get('stargazers_count', 0):,}
Forks: {data.get('forks_count', 0):,}
Language: {data.get('language', 'Unknown')}"""


@mcp.tool()
async def search_repos(query: str, limit: int = 5) -> str:
    """Search GitHub repositories.

    Args:
        query: Search query string
        limit: Maximum results (1-10)
    """
    limit = max(1, min(10, limit))
    data = await github_request(f"/search/repositories?q={query}&per_page={limit}")
    if not data:
        return "Search failed."

    results = []
    for item in data.get("items", [])[:limit]:
        results.append(
            f"- {item['full_name']} ({item.get('stargazers_count', 0):,} stars): "
            f"{item.get('description', 'No description')}"
        )
    return "\n".join(results) if results else "No repositories found."


if __name__ == "__main__":
    mcp.run(transport="stdio")

Step 8 — Test with MCP Inspector

The MCP Inspector is Anthropic's official visual debugging tool:

npx @modelcontextprotocol/inspector uv run server.py

This opens a web UI at http://localhost:6274 where you can browse tools, invoke them with custom parameters, and see raw JSON-RPC messages.

You can also use the MCP CLI dev mode:

mcp dev server.py

Step 9 — Configure in Claude Code

claude mcp add --transport stdio my-server \
  -- uv --directory /absolute/path/to/my-mcp-server run server.py

Or create .mcp.json in your project root:

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

Step 10 — Configure in Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):

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

Step 11 — Configure in Cursor

Create .cursor/mcp.json:

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

Transport Options

TransportUse CaseCode
stdioLocal development, Claude Desktopmcp.run(transport="stdio")
streamable-httpRemote/deployed serversmcp.run(transport="streamable-http")
sseLegacy (deprecated)mcp.run(transport="sse")

FAQ

Q: What Python version do I need for MCP? A: Python 3.10 or higher. The MCP Python SDK (v1.27.0) uses modern Python features like type unions and async/await.

Q: Can I use pip instead of uv? A: Yes. Run pip install "mcp[cli]" instead. uv is recommended for faster installs and better dependency resolution.

Q: How do I add authentication to my MCP server? A: Use environment variables for API keys. In your MCP config, add an "env" section with key-value pairs that get injected at server startup.

Next Steps