Módulo 5: MCP Server en Python

Proyecto: MCP Server Python con API REST Externa

Proyecto: MCP Server Python con API REST Externa

Descripción de la cápsula

Llegó el momento de unir todo. En las cápsulas anteriores aprendiste el setup de FastMCP, implementaste tools y resources con decoradores y Pydantic, y dominaste patrones async para I/O. Ahora vas a construir un MCP server Python completo que conecta con la GitHub API — un servicio real que Claude Code puede usar para consultar repositorios, buscar código, y analizar perfiles de usuarios.

Este no es un ejercicio teórico. Al terminar esta cápsula, tendrás un MCP server funcional conectado a Claude Code. Podrás decirle "muéstrame los repos más populares de Python en GitHub" o "analiza el perfil de este developer" y Claude Code usará tu server para obtener datos reales de GitHub.


Lo que vas a construir

GitHub Explorer MCP Server

Un server que expone la API de GitHub a través de MCP:

github-explorer/
├── .venv/
├── src/
│   ├── __init__.py
│   ├── server.py          # Entry point y registros
│   ├── tools/
│   │   ├── __init__.py
│   │   ├── repos.py       # Tools para repositorios
│   │   └── users.py       # Tools para usuarios
│   ├── resources/
│   │   ├── __init__.py
│   │   └── github.py      # Resources de GitHub
│   └── models/
│       ├── __init__.py
│       └── schemas.py      # Pydantic models
├── tests/
│   ├── __init__.py
│   └── test_server.py
├── requirements.txt
└── README.md

Capabilities del server

Resources:

  • github://rate-limit — Estado del rate limit de la API
  • github://user/{username} — Perfil de un usuario
  • github://repo/{owner}/{repo} — Información de un repositorio

Tools:

  • search_repos — Busca repositorios por query, lenguaje, estrellas
  • get_repo_details — Detalles completos de un repositorio (lenguajes, contribuidores)
  • list_user_repos — Lista repositorios de un usuario
  • get_repo_readme — Obtiene el README de un repositorio
  • compare_repos — Compara estadísticas de dos repositorios

Prompts:

  • repo_analysis — Template para analizar un repositorio
  • developer_profile — Template para analizar un perfil de developer

Paso 1: Setup del proyecto

Crear la estructura

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

Configurar el token de GitHub (opcional pero recomendado)

Sin token, GitHub limita a 60 requests/hora. Con token, 5000/hora:

export GITHUB_TOKEN="ghp_tu_token_aqui"

Para obtener un token:

  1. Ve a GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
  2. Genera un token con scope public_repo (solo lectura de repos públicos)
  3. Copia el token y expórtalo como variable de entorno

El server funciona sin token, pero vas a alcanzar el rate limit rápidamente.


Paso 2: Pydantic Models

src/models/schemas.py

from pydantic import BaseModel, Field


class RepoSearchInput(BaseModel):
    query: str = Field(min_length=1, description="Término de búsqueda")
    language: str | None = Field(default=None, description="Filtrar por lenguaje (e.g., 'python', 'typescript')")
    min_stars: int = Field(default=0, ge=0, description="Mínimo de estrellas")
    sort: str = Field(default="stars", description="Ordenar por: 'stars', 'forks', 'updated'")
    max_results: int = Field(default=10, ge=1, le=30, description="Máximo de resultados")


class RepoCompareInput(BaseModel):
    repo1: str = Field(description="Primer repo en formato 'owner/repo'")
    repo2: str = Field(description="Segundo repo en formato 'owner/repo'")


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

Estos models sirven para dos cosas: (1) validar inputs de tools con restricciones, y (2) estructurar los datos de respuesta de la API de GitHub.


Paso 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:
    """Busca repositorios en GitHub.

    Puedes filtrar por lenguaje, estrellas mínimas, y elegir ordenamiento.
    """
    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 alcanzado. Espera unos minutos o configura GITHUB_TOKEN."})
        return json.dumps({"error": f"Error HTTP {e.response.status_code}: {e.response.text[:500]}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Error de conexión: {e}"})


async def get_repo_details(owner: str, repo: str) -> str:
    """Obtiene detalles completos de un repositorio.

    Incluye información general, lenguajes, y últimas 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"Repositorio '{owner}/{repo}' no encontrado."})
        return json.dumps({"error": f"Error HTTP {e.response.status_code}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Error de conexión: {e}"})


async def get_repo_readme(owner: str, repo: str) -> str:
    """Obtiene el contenido del README de un repositorio."""
    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 truncado por longitud)"
            return content

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return f"El repositorio {owner}/{repo} no tiene README."
        return f"Error obteniendo README: HTTP {e.response.status_code}"
    except httpx.RequestError as e:
        return f"Error de conexión: {e}"


async def compare_repos(input: RepoCompareInput) -> str:
    """Compara estadísticas de dos repositorios lado a lado."""
    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 obteniendo repos: HTTP {e.response.status_code}"})
    except ValueError:
        return json.dumps({"error": "Formato inválido. Usa 'owner/repo' para ambos repositorios."})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Error de conexión: {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:
    """Lista repositorios públicos de un usuario de GitHub.

    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"Usuario '{username}' no encontrado."})
        return json.dumps({"error": f"Error HTTP {e.response.status_code}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Error de conexión: {e}"})

Paso 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:
    """Estado actual del rate limit de la API de GitHub."""
    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"No se pudo consultar rate limit: {e}"})


async def get_user_profile(username: str) -> str:
    """Perfil completo de un usuario de GitHub."""
    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"Usuario '{username}' no encontrado."})
        return json.dumps({"error": f"Error HTTP {e.response.status_code}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Error de conexión: {e}"})


async def get_repo_info(owner: str, repo: str) -> str:
    """Información general de un repositorio."""
    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"Repositorio '{owner}/{repo}' no encontrado."})
        return json.dumps({"error": f"Error HTTP {e.response.status_code}"})
    except httpx.RequestError as e:
        return json.dumps({"error": f"Error de conexión: {e}"})

Paso 5: Server principal

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 para explorar GitHub. Puedes buscar repositorios, "
        "consultar perfiles de usuarios, comparar repos, y leer READMEs. "
        "Configura GITHUB_TOKEN como variable de entorno para mayor 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:
    """Genera un análisis completo de un repositorio de GitHub."""
    return f"""Analiza el repositorio {owner}/{repo} en GitHub.

Por favor:
1. Usa el tool get_repo_details para obtener información completa
2. Usa el tool get_repo_readme para leer el README
3. Consulta el resource github://repo/{owner}/{repo} para datos básicos

Con esa información, genera un análisis que incluya:
- Resumen del proyecto (qué hace, para quién)
- Métricas clave (estrellas, forks, actividad reciente)
- Stack tecnológico (lenguajes, dependencias visibles)
- Salud del proyecto (issues abiertos, última actualización)
- Calidad de documentación (basado en el README)
- Recomendación: ¿vale la pena usar/contribuir a este proyecto?"""


@mcp.prompt()
async def developer_profile(username: str) -> str:
    """Genera un análisis del perfil de un developer en GitHub."""
    return f"""Analiza el perfil del developer {username} en GitHub.

Por favor:
1. Consulta el resource github://user/{username} para datos del perfil
2. Usa el tool list_user_repos para ver sus repositorios más recientes
3. Opcionalmente usa get_repo_details en sus repos más populares

Con esa información, genera un perfil que incluya:
- Resumen profesional (bio, ubicación, empresa)
- Actividad en GitHub (repos, followers, cuenta creada)
- Stack tecnológico (lenguajes más usados en sus repos)
- Proyectos destacados (repos con más estrellas)
- Áreas de expertise (basado en repos y lenguajes)"""


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

Paso 6: Testear el server

Con MCP Inspector

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

mcp dev src/server.py

MCP Inspector abre en tu navegador. Verifica:

  1. Tools tab: Deberías ver search_repos, get_repo_details, get_repo_readme, compare_repos, list_user_repos
  2. Resources tab: Deberías ver github://rate-limit
  3. Prueba un tool: Selecciona search_repos, ingresa {"query": "fastapi", "language": "python", "max_results": 5}. Deberías ver repos de FastAPI.
  4. Prueba un resource: Lee github://rate-limit. Deberías ver tu límite restante.

Test manual con Python

Crea 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, "Debería tener campo 'repos'"
    assert len(data["repos"]) <= 3, "No debería exceder max_results"
    print(f"✅ search_repos: encontró {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, "Debería tener campo 'name'"
    assert data["name"] == "python-sdk", f"Nombre incorrecto: {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, "Debería tener campo 'repos'"
    print(f"✅ list_user_repos: {data['total_shown']} repos de {data['username']}")


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


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


async def main():
    print("Ejecutando 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✅ Todos los tests pasaron")


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

Ejecutar los tests:

PYTHONPATH=. python tests/test_server.py

Deberías ver todos los checks verdes si tienes conexión a internet.


Paso 7: Conectar a Claude Code

Registrar el server

claude mcp add github-explorer \
  /ruta/completa/a/github-explorer/.venv/bin/python \
  /ruta/completa/a/github-explorer/src/server.py

Si tienes el GITHUB_TOKEN, agrégalo como variable de entorno:

claude mcp add github-explorer \
  -e GITHUB_TOKEN=ghp_tu_token \
  /ruta/completa/a/github-explorer/.venv/bin/python \
  /ruta/completa/a/github-explorer/src/server.py

Verificar la conexión

claude

Dentro de Claude Code:

> /mcp

Deberías ver github-explorer con estado "connected" y la lista de tools y resources disponibles.

Probar con requests reales

Prueba estas interacciones con Claude Code:

Búsqueda de repos:

Busca los 5 repositorios de Python con más estrellas relacionados con "machine learning"

Detalles de un repo:

Dame los detalles completos del repositorio modelcontextprotocol/python-sdk

Comparar repos:

Compara fastapi/fastapi vs pallets/flask — ¿cuál tiene más actividad?

Perfil de developer:

Analiza el perfil de GitHub del usuario "tiangolo"

Usando prompts:

Usa el prompt repo_analysis para analizar anthropics/anthropic-sdk-python

Cada uno de estos requests debería activar tools de tu MCP server. Claude Code pide aprobación antes de ejecutar cada tool — aprueba y verás datos reales de GitHub.


Paso 8: Mejoras opcionales

Agregar cache para reducir API calls

import time

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


async def cached_github_request(url: str, headers: dict) -> str:
    """Request a GitHub con 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

Agregar un tool para limpiar cache

@mcp.tool()
async def clear_github_cache() -> str:
    """Limpia el cache de datos de GitHub."""
    count = len(_cache)
    _cache.clear()
    return f"Cache limpiado: {count} entradas eliminadas."

Agregar resource para métricas del server

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

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

Conexión con el Módulo 8

Este proyecto es la base para el proyecto integrador del módulo 8. Si eliges Python para el proyecto final, lo que construiste aquí se escala:

Este módulo (M5)Proyecto integrador (M8)
GitHub API públicaDatabase real (SQLite/PostgreSQL)
5 tools8-12 tools
3 resources5-8 resources
2 prompts3-5 prompts
Tests manualesTest suite automatizado (pytest)
Sin cacheCache con TTL y invalidación
Error handling básicoRetry, circuit breaker, logging
Sin documentación formalREADME + API docs

Los patrones son los mismos — solo el scope crece. Lo que aprendiste aquí (decoradores, Pydantic, async, error handling) se aplica directamente.


Checklist final del proyecto

Antes de considerar el proyecto completo, verifica:

  • El server corre sin errores: python src/server.py
  • MCP Inspector muestra todos los tools y resources: mcp dev src/server.py
  • search_repos retorna resultados reales de GitHub
  • get_repo_details retorna información completa de un repo
  • get_repo_readme retorna el contenido del README
  • compare_repos compara dos repos correctamente
  • list_user_repos lista repos de un usuario
  • github://rate-limit muestra el rate limit actual
  • github://user/{username} retorna perfil de usuario
  • github://repo/{owner}/{repo} retorna info de repo
  • El server está conectado a Claude Code via claude mcp add
  • Claude Code puede usar los tools y resources del server
  • Los tests manuales pasan: PYTHONPATH=. python tests/test_server.py
  • El error handling funciona (prueba con un repo/usuario que no exista)

Troubleshooting

"Error: Rate limit alcanzado"

Causa: Excediste el límite de la API de GitHub (60/hora sin token, 5000/hora con token).

Solución:

export GITHUB_TOKEN="ghp_tu_token_aqui"

# Verificar rate limit actual
curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/rate_limit

"ModuleNotFoundError: No module named 'src'"

Causa: Python no encuentra el paquete src porque no estás ejecutando desde el directorio correcto.

Solución:

cd /ruta/a/github-explorer
PYTHONPATH=. python src/server.py

# O para Claude Code, usa ruta absoluta:
claude mcp add github-explorer \
  /ruta/.venv/bin/python \
  /ruta/src/server.py

"El server se conecta pero Claude Code no usa los tools"

Causa: Claude Code no ve los tools como relevantes para tu request.

Solución: Sé explícito en tus requests:

Usa el tool search_repos para buscar repositorios de Python sobre "web framework"

El campo instructions en FastMCP() también ayuda — le dice al modelo qué puede hacer tu server.

"httpx.ConnectError al consultar GitHub"

Causa: Problemas de red o firewall.

Solución:

# Verificar conectividad
curl https://api.github.com

# Si usas proxy:
export HTTPS_PROXY="http://tu-proxy:8080"

"Los tests fallan con 'AssertionError'"

Causa: La respuesta de GitHub cambió o el repo/usuario no existe.

Solución:

# Verifica la respuesta raw
result = await search_repos(RepoSearchInput(query="test"))
print(result)  # Ver qué está retornando realmente

Resumen del módulo

A lo largo de las 5 cápsulas de este módulo, aprendiste:

  1. Cápsula 01: Por qué Python para MCP, comparación con TypeScript, roadmap del módulo
  2. Cápsula 02: Setup completo con FastMCP, decoradores, MCP Inspector, conexión a Claude Code
  3. Cápsula 03: Tools y resources avanzados, Pydantic vs Zod, patrones de diseño, diferencias idiomáticas
  4. Cápsula 04: Patrones async (httpx, context managers, gather, retry, locks), error handling async
  5. Cápsula 05: Proyecto completo — MCP server que conecta con GitHub API real

Lo que ahora puedes hacer

  • ✅ Crear MCP servers en Python y TypeScript
  • ✅ Elegir el lenguaje correcto según el contexto
  • ✅ Implementar tools, resources, y prompts en Python con decoradores
  • ✅ Validar inputs con Pydantic
  • ✅ Manejar async/await para I/O de red
  • ✅ Conectar MCP servers a APIs externas reales
  • ✅ Testear y conectar servers a Claude Code

Lo que viene

  • Módulo 6: MCP Apps y UI Interactivo — servers que retornan interfaces visuales
  • Módulo 7: Testing, Debugging e Integración — testing automatizado, MCP Inspector avanzado
  • Módulo 8: Proyecto Integrador — MCP server production-ready con database real

Recursos adicionales

  1. GitHub REST API Documentation — Referencia completa de la API
  2. MCP Python SDK — SDK oficial
  3. httpx Documentation — Cliente HTTP async
  4. Pydantic v2 Documentation — Validación y serialización
  5. MCP Inspector — Debugging visual
  6. FastMCP Examples — Ejemplos oficiales
  7. GitHub Personal Access Tokens — Crear tokens
  8. Claude Code MCP Configuration — Configuración de MCP en Claude Code