Module 5: MCP Server in Python

Project: Python MCP Server with an External REST API

Project: Python MCP Server with an External REST API

Capsule description

The moment to bring everything together has arrived. In the previous capsules you learned the FastMCP setup, implemented tools and resources with decorators and Pydantic, and mastered async patterns for I/O. Now you're going to build a complete Python MCP server that connects with the GitHub API — a real service Claude Code can use to query repositories, search code, and analyze user profiles.

This isn't a theoretical exercise. By the end of this capsule, you'll have a functional MCP server connected to Claude Code. You'll be able to tell it "show me the most popular Python repos on GitHub" or "analyze this developer's profile" and Claude Code will use your server to get real data from GitHub.


What you're going to build

GitHub Explorer MCP Server

A server that exposes the GitHub API through MCP:

github-explorer/
├── .venv/
├── src/
│   ├── __init__.py
│   ├── server.py          # Entry point and registrations
│   ├── tools/
│   │   ├── __init__.py
│   │   ├── repos.py       # Tools for repositories
│   │   └── users.py       # Tools for users
│   ├── resources/
│   │   ├── __init__.py
│   │   └── github.py      # GitHub resources
│   └── models/
│       ├── __init__.py
│       └── schemas.py      # Pydantic models
├── tests/
│   ├── __init__.py
│   └── test_server.py
├── requirements.txt
└── README.md

The server's capabilities

Resources:

  • github://rate-limit — Status of the API's rate limit
  • github://user/{username} — A user's profile
  • github://repo/{owner}/{repo} — Information about a repository

Tools:

  • search_repos — Searches repositories by query, language, stars
  • get_repo_details — Complete details of a repository (languages, contributors)
  • list_user_repos — Lists a user's repositories
  • get_repo_readme — Fetches a repository's README
  • compare_repos — Compares the statistics of two repositories

Prompts:

  • repo_analysis — Template to analyze a repository
  • developer_profile — Template to analyze a developer's profile

Step 1: Project setup

Create the structure

mkdir github-explorer
cd github-explorer
python -m venv .venv
source .venv/bin/activate

pip install "mcp[cli]" httpx pydantic

mkdir -p src/tools src/resources src/models tests
touch src/__init__.py src/tools/__init__.py src/resources/__init__.py src/models/__init__.py tests/__init__.py

requirements.txt

mcp[cli]>=1.0.0
httpx>=0.27.0
pydantic>=2.0.0

Configure the GitHub token (optional but recommended)

Without a token, GitHub limits you to 60 requests/hour. With a token, 5000/hour:

export GITHUB_TOKEN="ghp_your_token_here"

To get a token:

  1. Go to GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
  2. Generate a token with the public_repo scope (read-only for public repos)
  3. Copy the token and export it as an environment variable

The server works without a token, but you'll hit the rate limit quickly.


Step 2: Pydantic Models

src/models/schemas.py

from pydantic import BaseModel, Field


class RepoSearchInput(BaseModel):
    query: str = Field(min_length=1, description="Search term")
    language: str | None = Field(default=None, description="Filter by language (e.g., 'python', 'typescript')")
    min_stars: int = Field(default=0, ge=0, description="Minimum stars")
    sort: str = Field(default="stars", description="Sort by: 'stars', 'forks', 'updated'")
    max_results: int = Field(default=10, ge=1, le=30, description="Maximum results")


class RepoCompareInput(BaseModel):
    repo1: str = Field(description="First repo in 'owner/repo' format")
    repo2: str = Field(description="Second repo in 'owner/repo' format")


class RepoSummary(BaseModel):
    name: str
    full_name: str
    description: str | None
    stars: int
    forks: int
    language: str | None
    url: str
    updated_at: str


class UserSummary(BaseModel):
    login: str
    name: str | None
    bio: str | None
    public_repos: int
    followers: int
    following: int
    url: str
    created_at: str

These models serve two purposes: (1) validating tool inputs with constraints, and (2) structuring the response data from the GitHub API.


Step 3: GitHub API Client

src/tools/repos.py

import os
import json
import httpx
from src.models.schemas import RepoSearchInput, RepoCompareInput, RepoSummary


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


BASE_URL = "https://api.github.com"


async def search_repos(input: RepoSearchInput) -> str:
    """Searches repositories on GitHub.

    You can filter by language, minimum stars, and choose the sorting.
    """
    query_parts = [input.query]
    if input.language:
        query_parts.append(f"language:{input.language}")
    if input.min_stars > 0:
        query_parts.append(f"stars:>={input.min_stars}")

    params = {
        "q": " ".join(query_parts),
        "sort": input.sort,
        "order": "desc",
        "per_page": input.max_results,
    }

    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{BASE_URL}/search/repositories",
                params=params,
                headers=_get_headers(),
                timeout=15,
            )
            response.raise_for_status()
            data = response.json()

        repos = []
        for item in data.get("items", []):
            repo = RepoSummary(
                name=item["name"],
                full_name=item["full_name"],
                description=item.get("description"),
                stars=item["stargazers_count"],
                forks=item["forks_count"],
                language=item.get("language"),
                url=item["html_url"],
                updated_at=item["updated_at"],
            )
            repos.append(repo.model_dump())

        return json.dumps({
            "total_count": data.get("total_count", 0),
            "showing": len(repos),
            "repos": repos,
        }, indent=2, ensure_ascii=False)

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 403:
            return json.dumps({"error": "Rate limit reached. Wait a few minutes or configure GITHUB_TOKEN."})
        return json.dumps({"error": f"HTTP error {e.response.status_code}: {e.response.text[:500]}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Connection error: {e}"})


async def get_repo_details(owner: str, repo: str) -> str:
    """Gets complete details of a repository.

    Includes general information, languages, and latest releases.
    """
    try:
        async with httpx.AsyncClient() as client:
            headers = _get_headers()

            repo_resp = await client.get(
                f"{BASE_URL}/repos/{owner}/{repo}",
                headers=headers,
                timeout=15,
            )
            repo_resp.raise_for_status()
            repo_data = repo_resp.json()

            langs_resp = await client.get(
                f"{BASE_URL}/repos/{owner}/{repo}/languages",
                headers=headers,
                timeout=15,
            )
            langs_data = langs_resp.json() if langs_resp.status_code == 200 else {}

        total_bytes = sum(langs_data.values()) if langs_data else 1
        languages = {
            lang: f"{(bytes_count / total_bytes * 100):.1f}%"
            for lang, bytes_count in sorted(langs_data.items(), key=lambda x: x[1], reverse=True)
        }

        result = {
            "name": repo_data["name"],
            "full_name": repo_data["full_name"],
            "description": repo_data.get("description"),
            "stars": repo_data["stargazers_count"],
            "forks": repo_data["forks_count"],
            "open_issues": repo_data["open_issues_count"],
            "watchers": repo_data["watchers_count"],
            "language": repo_data.get("language"),
            "languages_breakdown": languages,
            "license": repo_data.get("license", {}).get("name") if repo_data.get("license") else None,
            "created_at": repo_data["created_at"],
            "updated_at": repo_data["updated_at"],
            "default_branch": repo_data["default_branch"],
            "topics": repo_data.get("topics", []),
            "url": repo_data["html_url"],
            "is_fork": repo_data["fork"],
            "is_archived": repo_data["archived"],
            "size_kb": repo_data["size"],
        }

        return json.dumps(result, indent=2, ensure_ascii=False)

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return json.dumps({"error": f"Repository '{owner}/{repo}' not found."})
        return json.dumps({"error": f"HTTP error {e.response.status_code}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Connection error: {e}"})


async def get_repo_readme(owner: str, repo: str) -> str:
    """Fetches the content of a repository's README."""
    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{BASE_URL}/repos/{owner}/{repo}/readme",
                headers={**_get_headers(), "Accept": "application/vnd.github.raw+json"},
                timeout=15,
            )
            response.raise_for_status()
            content = response.text
            if len(content) > 8000:
                content = content[:8000] + "\n\n... (README truncated by length)"
            return content

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return f"The repository {owner}/{repo} doesn't have a README."
        return f"Error fetching README: HTTP {e.response.status_code}"
    except httpx.RequestError as e:
        return f"Connection error: {e}"


async def compare_repos(input: RepoCompareInput) -> str:
    """Compares the statistics of two repositories side by side."""
    import asyncio

    async def fetch_repo(full_name: str) -> dict:
        owner, repo = full_name.split("/", 1)
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{BASE_URL}/repos/{owner}/{repo}",
                headers=_get_headers(),
                timeout=15,
            )
            response.raise_for_status()
            return response.json()

    try:
        repo1_data, repo2_data = await asyncio.gather(
            fetch_repo(input.repo1),
            fetch_repo(input.repo2),
        )

        def extract_metrics(data: dict) -> dict:
            return {
                "name": data["full_name"],
                "stars": data["stargazers_count"],
                "forks": data["forks_count"],
                "open_issues": data["open_issues_count"],
                "watchers": data["watchers_count"],
                "language": data.get("language"),
                "size_kb": data["size"],
                "created_at": data["created_at"],
                "updated_at": data["updated_at"],
                "license": data.get("license", {}).get("name") if data.get("license") else None,
            }

        r1 = extract_metrics(repo1_data)
        r2 = extract_metrics(repo2_data)

        comparison = {
            "repo1": r1,
            "repo2": r2,
            "comparison": {
                "more_stars": input.repo1 if r1["stars"] > r2["stars"] else input.repo2,
                "more_forks": input.repo1 if r1["forks"] > r2["forks"] else input.repo2,
                "more_issues": input.repo1 if r1["open_issues"] > r2["open_issues"] else input.repo2,
                "larger": input.repo1 if r1["size_kb"] > r2["size_kb"] else input.repo2,
                "newer": input.repo1 if r1["created_at"] > r2["created_at"] else input.repo2,
                "recently_updated": input.repo1 if r1["updated_at"] > r2["updated_at"] else input.repo2,
            },
        }

        return json.dumps(comparison, indent=2, ensure_ascii=False)

    except httpx.HTTPStatusError as e:
        return json.dumps({"error": f"Error fetching repos: HTTP {e.response.status_code}"})
    except ValueError:
        return json.dumps({"error": "Invalid format. Use 'owner/repo' for both repositories."})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Connection error: {e}"})

src/tools/users.py

import os
import json
import httpx


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


BASE_URL = "https://api.github.com"


async def list_user_repos(
    username: str,
    sort: str = "updated",
    max_results: int = 10,
) -> str:
    """Lists a GitHub user's public repositories.

    sort: 'updated', 'stars', 'name'.
    """
    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{BASE_URL}/users/{username}/repos",
                params={
                    "sort": sort if sort != "stars" else "pushed",
                    "direction": "desc",
                    "per_page": max_results,
                    "type": "owner",
                },
                headers=_get_headers(),
                timeout=15,
            )
            response.raise_for_status()
            repos = response.json()

        result = []
        for repo in repos:
            result.append({
                "name": repo["name"],
                "description": repo.get("description"),
                "stars": repo["stargazers_count"],
                "forks": repo["forks_count"],
                "language": repo.get("language"),
                "updated_at": repo["updated_at"],
                "url": repo["html_url"],
            })

        if sort == "stars":
            result.sort(key=lambda r: r["stars"], reverse=True)

        return json.dumps({
            "username": username,
            "total_shown": len(result),
            "repos": result,
        }, indent=2, ensure_ascii=False)

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return json.dumps({"error": f"User '{username}' not found."})
        return json.dumps({"error": f"HTTP error {e.response.status_code}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Connection error: {e}"})

Step 4: Resources

src/resources/github.py

import os
import json
import httpx


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


BASE_URL = "https://api.github.com"


async def get_rate_limit() -> str:
    """Current status of the GitHub API rate limit."""
    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{BASE_URL}/rate_limit",
                headers=_get_headers(),
                timeout=10,
            )
            response.raise_for_status()
            data = response.json()

        core = data["rate"]
        return json.dumps({
            "limit": core["limit"],
            "remaining": core["remaining"],
            "reset_at": core["reset"],
            "has_token": "GITHUB_TOKEN" in os.environ,
        }, indent=2)

    except httpx.RequestError as e:
        return json.dumps({"error": f"Could not query the rate limit: {e}"})


async def get_user_profile(username: str) -> str:
    """Complete profile of a GitHub user."""
    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{BASE_URL}/users/{username}",
                headers=_get_headers(),
                timeout=15,
            )
            response.raise_for_status()
            data = response.json()

        return json.dumps({
            "login": data["login"],
            "name": data.get("name"),
            "bio": data.get("bio"),
            "company": data.get("company"),
            "location": data.get("location"),
            "blog": data.get("blog"),
            "public_repos": data["public_repos"],
            "public_gists": data["public_gists"],
            "followers": data["followers"],
            "following": data["following"],
            "created_at": data["created_at"],
            "updated_at": data["updated_at"],
            "url": data["html_url"],
        }, indent=2, ensure_ascii=False)

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return json.dumps({"error": f"User '{username}' not found."})
        return json.dumps({"error": f"HTTP error {e.response.status_code}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Connection error: {e}"})


async def get_repo_info(owner: str, repo: str) -> str:
    """General information about a repository."""
    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{BASE_URL}/repos/{owner}/{repo}",
                headers=_get_headers(),
                timeout=15,
            )
            response.raise_for_status()
            data = response.json()

        return json.dumps({
            "full_name": data["full_name"],
            "description": data.get("description"),
            "stars": data["stargazers_count"],
            "forks": data["forks_count"],
            "language": data.get("language"),
            "topics": data.get("topics", []),
            "license": data.get("license", {}).get("name") if data.get("license") else None,
            "url": data["html_url"],
        }, indent=2, ensure_ascii=False)

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return json.dumps({"error": f"Repository '{owner}/{repo}' not found."})
        return json.dumps({"error": f"HTTP error {e.response.status_code}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Connection error: {e}"})

Step 5: Main server

src/server.py

from mcp.server.fastmcp import FastMCP
from src.tools.repos import (
    search_repos,
    get_repo_details,
    get_repo_readme,
    compare_repos,
)
from src.tools.users import list_user_repos
from src.resources.github import get_rate_limit, get_user_profile, get_repo_info

mcp = FastMCP(
    "github-explorer",
    version="1.0.0",
    instructions=(
        "MCP server to explore GitHub. You can search repositories, "
        "query user profiles, compare repos, and read READMEs. "
        "Configure GITHUB_TOKEN as an environment variable for a higher rate limit."
    ),
)

# --- Tools ---
mcp.tool()(search_repos)
mcp.tool()(get_repo_details)
mcp.tool()(get_repo_readme)
mcp.tool()(compare_repos)
mcp.tool()(list_user_repos)

# --- Resources ---
mcp.resource("github://rate-limit")(get_rate_limit)
mcp.resource("github://user/{username}")(get_user_profile)
mcp.resource("github://repo/{owner}/{repo}")(get_repo_info)


# --- Prompts ---
@mcp.prompt()
async def repo_analysis(owner: str, repo: str) -> str:
    """Generates a complete analysis of a GitHub repository."""
    return f"""Analyze the repository {owner}/{repo} on GitHub.

Please:
1. Use the get_repo_details tool to get complete information
2. Use the get_repo_readme tool to read the README
3. Query the github://repo/{owner}/{repo} resource for basic data

With that information, generate an analysis that includes:
- Project summary (what it does, for whom)
- Key metrics (stars, forks, recent activity)
- Technology stack (languages, visible dependencies)
- Project health (open issues, last update)
- Documentation quality (based on the README)
- Recommendation: is it worth using/contributing to this project?"""


@mcp.prompt()
async def developer_profile(username: str) -> str:
    """Generates an analysis of a developer's GitHub profile."""
    return f"""Analyze the profile of the developer {username} on GitHub.

Please:
1. Query the github://user/{username} resource for the profile data
2. Use the list_user_repos tool to see their most recent repositories
3. Optionally use get_repo_details on their most popular repos

With that information, generate a profile that includes:
- Professional summary (bio, location, company)
- GitHub activity (repos, followers, account created)
- Technology stack (languages most used in their repos)
- Notable projects (repos with the most stars)
- Areas of expertise (based on repos and languages)"""


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

Step 6: Test the server

With MCP Inspector

cd github-explorer
source .venv/bin/activate
export GITHUB_TOKEN="ghp_your_token"

mcp dev src/server.py

MCP Inspector opens in your browser. Verify:

  1. Tools tab: You should see search_repos, get_repo_details, get_repo_readme, compare_repos, list_user_repos
  2. Resources tab: You should see github://rate-limit
  3. Test a tool: Select search_repos, enter {"query": "fastapi", "language": "python", "max_results": 5}. You should see FastAPI repos.
  4. Test a resource: Read github://rate-limit. You should see your remaining limit.

Manual test with Python

Create tests/test_server.py:

import asyncio
import json
from src.tools.repos import search_repos, get_repo_details
from src.tools.users import list_user_repos
from src.resources.github import get_rate_limit, get_user_profile
from src.models.schemas import RepoSearchInput


async def test_search_repos():
    input_data = RepoSearchInput(query="mcp", language="python", max_results=3)
    result = await search_repos(input_data)
    data = json.loads(result)
    assert "repos" in data, "Should have a 'repos' field"
    assert len(data["repos"]) <= 3, "Shouldn't exceed max_results"
    print(f"✅ search_repos: found {len(data['repos'])} repos")


async def test_get_repo_details():
    result = await get_repo_details("modelcontextprotocol", "python-sdk")
    data = json.loads(result)
    assert "name" in data, "Should have a 'name' field"
    assert data["name"] == "python-sdk", f"Incorrect name: {data['name']}"
    print(f"✅ get_repo_details: {data['full_name']} ({data['stars']} ⭐)")


async def test_list_user_repos():
    result = await list_user_repos("octocat", max_results=3)
    data = json.loads(result)
    assert "repos" in data, "Should have a 'repos' field"
    print(f"✅ list_user_repos: {data['total_shown']} repos of {data['username']}")


async def test_rate_limit():
    result = await get_rate_limit()
    data = json.loads(result)
    assert "remaining" in data, "Should have a 'remaining' field"
    print(f"✅ rate_limit: {data['remaining']}/{data['limit']} requests remaining")


async def test_user_profile():
    result = await get_user_profile("octocat")
    data = json.loads(result)
    assert data["login"] == "octocat", "Should be octocat"
    print(f"✅ user_profile: {data['login']} ({data['followers']} followers)")


async def main():
    print("Running tests...\n")
    await test_search_repos()
    await test_get_repo_details()
    await test_list_user_repos()
    await test_rate_limit()
    await test_user_profile()
    print("\n✅ All tests passed")


if __name__ == "__main__":
    asyncio.run(main())

Run the tests:

PYTHONPATH=. python tests/test_server.py

You should see all the checks green if you have an internet connection.


Step 7: Connect to Claude Code

Register the server

claude mcp add github-explorer \
  /full/path/to/github-explorer/.venv/bin/python \
  /full/path/to/github-explorer/src/server.py

If you have the GITHUB_TOKEN, add it as an environment variable:

claude mcp add github-explorer \
  -e GITHUB_TOKEN=ghp_your_token \
  /full/path/to/github-explorer/.venv/bin/python \
  /full/path/to/github-explorer/src/server.py

Verify the connection

claude

Inside Claude Code:

> /mcp

You should see github-explorer with the status "connected" and the list of available tools and resources.

Test with real requests

Try these interactions with Claude Code:

Repo search:

Search for the 5 Python repositories with the most stars related to "machine learning"

Repo details:

Give me the complete details of the repository modelcontextprotocol/python-sdk

Compare repos:

Compare fastapi/fastapi vs pallets/flask — which has more activity?

Developer profile:

Analyze the GitHub profile of the user "tiangolo"

Using prompts:

Use the repo_analysis prompt to analyze anthropics/anthropic-sdk-python

Each of these requests should activate tools from your MCP server. Claude Code asks for approval before executing each tool — approve and you'll see real GitHub data.


Step 8: Optional improvements

Add a cache to reduce API calls

import time

_cache: dict[str, tuple[float, str]] = {}
CACHE_TTL = 300  # 5 minutes


async def cached_github_request(url: str, headers: dict) -> str:
    """Request to GitHub with a cache."""
    if url in _cache:
        timestamp, data = _cache[url]
        if time.monotonic() - timestamp < CACHE_TTL:
            return data

    async with httpx.AsyncClient() as client:
        response = await client.get(url, headers=headers, timeout=15)
        response.raise_for_status()
        result = response.text

    _cache[url] = (time.monotonic(), result)
    return result

Add a tool to clear the cache

@mcp.tool()
async def clear_github_cache() -> str:
    """Clears the GitHub data cache."""
    count = len(_cache)
    _cache.clear()
    return f"Cache cleared: {count} entries removed."

Add a resource for the server's metrics

@mcp.resource("github://server/stats")
async def server_stats() -> str:
    """MCP server statistics."""
    import json

    return json.dumps({
        "cache_entries": len(_cache),
        "cache_ttl_seconds": CACHE_TTL,
        "has_github_token": "GITHUB_TOKEN" in os.environ,
    }, indent=2)

Connection to Module 8

This project is the base for the capstone project in module 8. If you choose Python for the final project, what you built here scales up:

This module (M5)Capstone project (M8)
Public GitHub APIReal database (SQLite/PostgreSQL)
5 tools8-12 tools
3 resources5-8 resources
2 prompts3-5 prompts
Manual testsAutomated test suite (pytest)
No cacheCache with TTL and invalidation
Basic error handlingRetry, circuit breaker, logging
No formal documentationREADME + API docs

The patterns are the same — only the scope grows. What you learned here (decorators, Pydantic, async, error handling) applies directly.


Final project checklist

Before considering the project complete, verify:

  • The server runs without errors: python src/server.py
  • MCP Inspector shows all the tools and resources: mcp dev src/server.py
  • search_repos returns real results from GitHub
  • get_repo_details returns complete information about a repo
  • get_repo_readme returns the README's content
  • compare_repos compares two repos correctly
  • list_user_repos lists a user's repos
  • github://rate-limit shows the current rate limit
  • github://user/{username} returns a user's profile
  • github://repo/{owner}/{repo} returns a repo's info
  • The server is connected to Claude Code via claude mcp add
  • Claude Code can use the server's tools and resources
  • The manual tests pass: PYTHONPATH=. python tests/test_server.py
  • Error handling works (test with a repo/user that doesn't exist)

Troubleshooting

"Error: Rate limit reached"

Cause: You exceeded the GitHub API limit (60/hour without a token, 5000/hour with one).

Solution:

export GITHUB_TOKEN="ghp_your_token_here"

# Check the current rate limit
curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/rate_limit

"ModuleNotFoundError: No module named 'src'"

Cause: Python can't find the src package because you're not running from the correct directory.

Solution:

cd /path/to/github-explorer
PYTHONPATH=. python src/server.py

# Or for Claude Code, use an absolute path:
claude mcp add github-explorer \
  /path/.venv/bin/python \
  /path/src/server.py

"The server connects but Claude Code doesn't use the tools"

Cause: Claude Code doesn't see the tools as relevant to your request.

Solution: Be explicit in your requests:

Use the search_repos tool to search Python repositories about "web framework"

The instructions field in FastMCP() also helps — it tells the model what your server can do.

"httpx.ConnectError when querying GitHub"

Cause: Network or firewall problems.

Solution:

# Check connectivity
curl https://api.github.com

# If you use a proxy:
export HTTPS_PROXY="http://your-proxy:8080"

"The tests fail with 'AssertionError'"

Cause: The GitHub response changed or the repo/user doesn't exist.

Solution:

# Check the raw response
result = await search_repos(RepoSearchInput(query="test"))
print(result)  # See what it's actually returning

Module summary

Throughout this module's 5 capsules, you learned:

  1. Capsule 01: Why Python for MCP, comparison with TypeScript, module roadmap
  2. Capsule 02: Complete setup with FastMCP, decorators, MCP Inspector, connection to Claude Code
  3. Capsule 03: Advanced tools and resources, Pydantic vs Zod, design patterns, idiomatic differences
  4. Capsule 04: Async patterns (httpx, context managers, gather, retry, locks), async error handling
  5. Capsule 05: Complete project — an MCP server that connects with the real GitHub API

What you can now do

  • ✅ Create MCP servers in Python and TypeScript
  • ✅ Choose the correct language based on the context
  • ✅ Implement tools, resources, and prompts in Python with decorators
  • ✅ Validate inputs with Pydantic
  • ✅ Handle async/await for network I/O
  • ✅ Connect MCP servers to real external APIs
  • ✅ Test and connect servers to Claude Code

What's coming

  • Module 6: MCP Apps and Interactive UI — servers that return visual interfaces
  • Module 7: Testing, Debugging, and Integration — automated testing, advanced MCP Inspector
  • Module 8: Capstone Project — a production-ready MCP server with a real database

Additional resources

  1. GitHub REST API Documentation — Complete API reference
  2. MCP Python SDK — Official SDK
  3. httpx Documentation — Async HTTP client
  4. Pydantic v2 Documentation — Validation and serialization
  5. MCP Inspector — Visual debugging
  6. FastMCP Examples — Official examples
  7. GitHub Personal Access Tokens — Create tokens
  8. Claude Code MCP Configuration — MCP configuration in Claude Code