Back to all posts
Guide
14 min read

How to Build an MCP Server in Python: From Zero to Claude-Ready

DevToolLab Team

DevToolLab Team

June 2, 2026

How to Build an MCP Server in Python: From Zero to Claude-Ready

The Model Context Protocol went from 100,000 SDK downloads per month to 97 million in eighteen months. Every major AI coding tool - Claude Code, Cursor, Windsurf, VS Code Copilot, and JetBrains AI - now speaks MCP natively. Anthropic donated the spec to the Linux Foundation in December 2025, confirming that MCP is infrastructure, not a product.

But nearly all the tutorials that exist are either "here is a list of servers to install" or a dry reading of the official spec. Very few walk you through actually building one from scratch - deciding which tools to expose, writing the handlers, testing with a live client, and shipping something that works reliably inside Claude.

This guide does exactly that. By the end, you will have built a real Python MCP server with tools, resources, and prompts, tested it with the MCP Inspector, and connected it to Claude Desktop or Claude Code. The final server calls the GitHub API - a practical, reusable starting point for any project that needs to surface external data inside an AI assistant.

What you need: Python 3.10+, uv (recommended) or pip, and either Claude Desktop or Claude Code installed.

What Is an MCP Server, Really?

MCP (Model Context Protocol) is a JSON-RPC protocol that lets an AI client - Claude, Cursor, or any other compliant tool - call external services through a standardized interface. The client does not know or care whether your server is written in Python, TypeScript, or Go. It sends a request, your server handles it, and the response flows back.

An MCP server exposes three primitives:

Tools are functions the AI can invoke. When you ask Claude "how many open issues does this repo have?", it calls a tool, your server makes an API request, and returns the answer. Tools are the most commonly used primitive.

Resources are data the AI can read but not directly invoke - think of them as files or endpoints the AI can fetch. A resource might expose the contents of a config file, a database record, or a URL.

Prompts are reusable templates. You define a prompt once in the server, and any client can reference it by name. This is useful for standardizing common instructions across a team.

The two transport modes you will encounter:

TransportWhen to useHow it works
stdioLocal development, Claude Desktop, Claude CodeClient spawns your script as a subprocess; communication over stdin/stdout
Streamable HTTPRemote or production serversYour server listens on a port; client connects over HTTP

For this guide, we use stdio - it is simpler to set up and works immediately with Claude Desktop and Claude Code.

Project Setup

Create a new directory and install the official MCP SDK. The [cli] extra installs the mcp dev development server and inspector launcher:

Bash
mkdir github-mcp-server
cd github-mcp-server
uv init .
uv add "mcp[cli]" httpx

If you prefer pip:

Bash
mkdir github-mcp-server && cd github-mcp-server
pip install "mcp[cli]" httpx
Terminal output of uv init and uv add mcp httpx - installing the MCP SDK and httpx into a new Python project
Terminal output of uv init and uv add mcp httpx - installing the MCP SDK and httpx into a new Python project

Your project structure will look like this:

Bash
github-mcp-server/
├── server.py
├── pyproject.toml   (uv only)
└── .env             (optional, for GITHUB_TOKEN)
VS Code file explorer showing the github-mcp-server project structure with pyproject.toml, uv.lock, and .venv
VS Code file explorer showing the github-mcp-server project structure with pyproject.toml, uv.lock, and .venv

Your First Tool in 10 Lines

Before the real-world server, here is the minimal working example. Create server.py:

Python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("hello-mcp")

@mcp.tool()
def greet(name: str) -> str:
    """Return a personalized greeting. Use this when asked to greet someone."""
    return f"Hello, {name}! Your MCP server is working."

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

Test it immediately with the built-in MCP Inspector - no Claude required:

uv run mcp dev server.py
Terminal output of uv run mcp dev server.py - MCP Inspector starting on localhost with a session token
Terminal output of uv run mcp dev server.py - MCP Inspector starting on localhost with a session token

This opens an interactive web UI at http://localhost:5173. Navigate to Tools, find greet, enter a name, and click Run Tool. You should see your greeting returned.

MCP Inspector Tools tab showing the greet tool with name input field and output schema - testing a FastMCP server
MCP Inspector Tools tab showing the greet tool with name input field and output schema - testing a FastMCP server

Three things to notice in the code above:

  1. FastMCP is the high-level API inside the official mcp package. It handles all the JSON-RPC plumbing.
  2. The @mcp.tool() decorator registers a Python function as a callable MCP tool. The function's type hints become the tool's input schema automatically.
  3. The docstring is critical - it is what the AI reads to decide whether to call this tool. Write it like you are explaining the tool to a developer who has never seen your code.

Building a Real Server: GitHub Tools

Now build something useful. This server exposes two GitHub tools: one to fetch repository metadata and one to list open issues. Both make real HTTP requests using httpx.

Create or replace server.py with the following:

Python
import os
import logging
import sys
import httpx
from pydantic import BaseModel
from mcp.server.fastmcp import FastMCP

# Log to stderr - never stdout (stdout is the stdio transport channel)
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logger = logging.getLogger(__name__)

mcp = FastMCP(
    "github-tools",
    instructions=(
        "This server provides tools to interact with the GitHub API. "
        "Use get_repo_info to fetch repository metadata. "
        "Use list_open_issues to retrieve open issues for a repository."
    ),
)

GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")


def _github_headers() -> dict[str, str]:
    headers = {
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
    }
    if GITHUB_TOKEN:
        headers["Authorization"] = f"Bearer {GITHUB_TOKEN}"
    return headers


class RepoInfo(BaseModel):
    full_name: str
    description: str | None
    stars: int
    forks: int
    open_issues: int
    language: str | None
    url: str


@mcp.tool()
async def get_repo_info(owner: str, repo: str) -> RepoInfo:
    """
    Fetch metadata for a GitHub repository including stars, forks, and open issue count.

    Args:
        owner: GitHub username or organization name (e.g. 'anthropics')
        repo: Repository name (e.g. 'claude-code')
    """
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(
                f"https://api.github.com/repos/{owner}/{repo}",
                headers=_github_headers(),
                timeout=10.0,
            )
            response.raise_for_status()
        except httpx.TimeoutException:
            raise ValueError(f"Request timed out fetching {owner}/{repo}")
        except httpx.HTTPStatusError as e:
            raise ValueError(
                f"GitHub API error {e.response.status_code} for {owner}/{repo}"
            )

    data = response.json()
    return RepoInfo(
        full_name=data["full_name"],
        description=data.get("description"),
        stars=data["stargazers_count"],
        forks=data["forks_count"],
        open_issues=data["open_issues_count"],
        language=data.get("language"),
        url=data["html_url"],
    )


@mcp.tool()
async def list_open_issues(owner: str, repo: str, limit: int = 10) -> str:
    """
    List open issues for a GitHub repository, ordered by most recently updated.

    Args:
        owner: GitHub username or organization name
        repo: Repository name
        limit: Maximum number of issues to return (1–30, default 10)
    """
    limit = max(1, min(limit, 30))  # clamp to safe range

    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(
                f"https://api.github.com/repos/{owner}/{repo}/issues",
                headers=_github_headers(),
                params={"state": "open", "per_page": limit, "sort": "updated"},
                timeout=10.0,
            )
            response.raise_for_status()
        except httpx.TimeoutException:
            return "Error: Request timed out."
        except httpx.HTTPStatusError as e:
            return f"Error: GitHub API returned HTTP {e.response.status_code}."

    issues = response.json()
    if not issues:
        return f"No open issues found for {owner}/{repo}."

    lines = [f"Open issues in **{owner}/{repo}** (showing {len(issues)}):\n"]
    for issue in issues:
        lines.append(f"- #{issue['number']}: {issue['title']}")
    return "\n".join(lines)


if __name__ == "__main__":
    mcp.run(transport="stdio")
VS Code showing the full GitHub MCP server code - FastMCP setup, RepoInfo Pydantic model, and GitHub API headers
VS Code showing the full GitHub MCP server code - FastMCP setup, RepoInfo Pydantic model, and GitHub API headers

Several deliberate choices in this code are worth explaining:

Pydantic return types on tools. When a tool returns a Pydantic model, MCP automatically serializes it and the AI receives a structured, typed response. This is far more reliable than returning a formatted string - the AI can reference individual fields (repo.stars, repo.language) rather than parsing text.

Error handling that returns, not raises. For tools that return strings, catching exceptions and returning an error message is preferable to letting exceptions propagate. An unhandled exception in a stdio server can terminate the connection. Tools that return Pydantic models can raise ValueError - the MCP server catches these and returns a structured error response to the client.

Clamping inputs. The limit parameter is clamped to a safe range (1–30) before use. Never trust that the AI will pass a valid value - it will occasionally pass 0, 100, or a string.

PYTHONUNBUFFERED and stderr logging. Python buffers stdout by default. Under stdio transport, this means MCP responses sit in the buffer and the client hangs. Always log to sys.stderr, never sys.stdout, and set PYTHONUNBUFFERED=1 in your environment or client config.

Adding Resources and Prompts

Resources

Resources expose data the AI can read without invoking a tool. Add a resource that returns the server's configuration as a string:

Python
@mcp.resource("config://github-tools/status")
def server_status() -> str:
    """Report whether the server has a GitHub token configured."""
    auth_status = "authenticated" if GITHUB_TOKEN else "unauthenticated (rate-limited to 60 req/hr)"
    return f"GitHub Tools MCP Server\nStatus: {auth_status}"

Resources use URI-style identifiers (scheme://path). You can also define dynamic resources with URI template parameters:

Python
@mcp.resource("github://repos/{owner}/{repo}/readme")
async def get_readme(owner: str, repo: str) -> str:
    """Fetch the raw README content for a repository."""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"https://api.github.com/repos/{owner}/{repo}/readme",
            headers={**_github_headers(), "Accept": "application/vnd.github.raw+json"},
            timeout=10.0,
        )
        if response.status_code == 404:
            return "No README found for this repository."
        response.raise_for_status()
        return response.text

Prompts

Prompts are reusable templates that any MCP client can reference by name. Add a prompt that helps structure code review requests:

Python
@mcp.prompt()
def review_pull_request(owner: str, repo: str, pr_number: int) -> str:
    """Prompt template for reviewing a GitHub pull request."""
    return (
        f"Please review pull request #{pr_number} in {owner}/{repo}. "
        f"Start by fetching the repository info with get_repo_info, "
        f"then list the open issues to understand the project context. "
        f"Focus your review on correctness, performance, and adherence to the project's patterns."
    )

When a user invokes this prompt in Claude, Claude receives the formatted instruction along with access to the tools it references. Prompts are particularly useful for standardizing common workflows across a team.

Testing with the MCP Inspector

The MCP Inspector is the fastest way to validate your server without needing a live Claude instance:

uv run mcp dev server.py

This launches the inspector UI at http://localhost:5173.

MCP Inspector v0.21.2 connection screen - STDIO transport selected, ready to connect to a local Python MCP server
MCP Inspector v0.21.2 connection screen - STDIO transport selected, ready to connect to a local Python MCP server

Set your GITHUB_TOKEN in the Environment Variables section before connecting, then test each tool in the Tools tab, verify resources in the Resources tab, and confirm your prompts appear in the Prompts tab.

If a tool fails, check the Logs panel - it shows the raw JSON-RPC messages exchanged between the inspector and your server, which is invaluable for debugging.

MCP Inspector showing get_repo_info and list_open_issues tools with a successful JSON result for a GitHub repository
MCP Inspector showing get_repo_info and list_open_issues tools with a successful JSON result for a GitHub repository

Connecting to Claude Desktop

Edit the Claude Desktop configuration file:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your server under mcpServers. Use an absolute path to server.py:

JSON
{
  "mcpServers": {
    "github-tools": {
      "command": "uv",
      "args": [
        "run",
        "--with",
        "mcp[cli]",
        "--with",
        "httpx",
        "python",
        "/absolute/path/to/github-mcp-server/server.py"
      ],
      "env": {
        "PYTHONUNBUFFERED": "1",
        "GITHUB_TOKEN": "your_github_token_here"
      }
    }
  }
}

Using uv run here is deliberate: it ensures the correct virtual environment and dependencies are used regardless of what Python is active in your shell. Using a bare python command often fails because Claude Desktop spawns its own shell environment without your shell's PATH.

Fully quit and restart Claude Desktop after saving the config. When the server connects, a small plug icon appears in the bottom-right corner of the input box. Click it to confirm your tools are listed.

To debug connection issues, check the Claude Desktop logs:

# macOS
tail -f ~/Library/Logs/Claude/mcp*.log

Connecting to Claude Code

If you use Claude Code instead of Claude Desktop, use the claude mcp add command. The -- separator is required - it separates the server name from the command that launches it:

Bash
claude mcp add github-tools \
  -e GITHUB_TOKEN=your_token \
  -e PYTHONUNBUFFERED=1 \
  -- uv run --with "mcp[cli]" --with httpx python /absolute/path/to/server.py

To make the server available across all your projects:

Bash
claude mcp add --scope user github-tools \
  -e GITHUB_TOKEN=your_token \
  -e PYTHONUNBUFFERED=1 \
  -- uv run --with "mcp[cli]" --with httpx python /absolute/path/to/server.py

To share the server configuration with your team by committing it to the repository:

Bash
claude mcp add --scope project github-tools \
  -e PYTHONUNBUFFERED=1 \
  -- uv run --with "mcp[cli]" --with httpx python /absolute/path/to/server.py

The project scope writes an .mcp.json file to your repository root. Team members who open the project in Claude Code will be prompted to trust and activate the server.

Verify the server is registered:

claude mcp list

Conclusion

MCP gives AI models a structured, auditable surface to act on your systems - not just answer questions about them. FastMCP makes the entry cost low: a working server is an afternoon's work. The real leverage is what you expose. Typed tools backed by your internal knowledge turn an assistant into something closer to a peer. Build it, test it with Claude Desktop, and publish it - well-scoped servers for specific domains are exactly what the registry needs more of.

Related Tools on DevToolLab

  • JSON Formatter - format and validate the JSON responses your MCP server produces
  • Swagger Viewer - explore the GitHub REST API spec before building tools against it
  • cURL Command Generator - prototype API requests before writing Python handlers
  • JWT Decoder - inspect GitHub personal access tokens to confirm their scopes

Related Posts

ElevenLabs Alternatives in 2026, Priced

1M characters in a cloned voice costs $80 on Eleven v4. Cartesia, Fish Audio, Murf, Deepgram, Rime, Google, Chatterbox and Kokoro priced on the same job.

By DevToolLab Team•

LangChain Alternatives in 2026: 7 Tested

A one-tool LangChain agent installs 44 packages. Pydantic AI, LlamaIndex, Haystack, DSPy, OpenAI Agents SDK, Vercel AI SDK and Mastra measured the same way.

By DevToolLab Team•

7 Best Manus AI Alternatives in 2026

Manus charges $20 for 4,000 credits. ChatGPT Work, Claude, Perplexity Computer, Genspark, DeerFlow, OpenManus and Kortix compared on price and self-hosting.

By DevToolLab Team•