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 APIgithub://user/{username}— Perfil de un usuariogithub://repo/{owner}/{repo}— Información de un repositorio
Tools:
search_repos— Busca repositorios por query, lenguaje, estrellasget_repo_details— Detalles completos de un repositorio (lenguajes, contribuidores)list_user_repos— Lista repositorios de un usuarioget_repo_readme— Obtiene el README de un repositoriocompare_repos— Compara estadísticas de dos repositorios
Prompts:
repo_analysis— Template para analizar un repositoriodeveloper_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:
- Ve a GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
- Genera un token con scope
public_repo(solo lectura de repos públicos) - 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:
- Tools tab: Deberías ver
search_repos,get_repo_details,get_repo_readme,compare_repos,list_user_repos - Resources tab: Deberías ver
github://rate-limit - Prueba un tool: Selecciona
search_repos, ingresa{"query": "fastapi", "language": "python", "max_results": 5}. Deberías ver repos de FastAPI. - 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ública | Database real (SQLite/PostgreSQL) |
| 5 tools | 8-12 tools |
| 3 resources | 5-8 resources |
| 2 prompts | 3-5 prompts |
| Tests manuales | Test suite automatizado (pytest) |
| Sin cache | Cache con TTL y invalidación |
| Error handling básico | Retry, circuit breaker, logging |
| Sin documentación formal | README + 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_reposretorna resultados reales de GitHub -
get_repo_detailsretorna información completa de un repo -
get_repo_readmeretorna el contenido del README -
compare_reposcompara dos repos correctamente -
list_user_reposlista repos de un usuario -
github://rate-limitmuestra 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:
- Cápsula 01: Por qué Python para MCP, comparación con TypeScript, roadmap del módulo
- Cápsula 02: Setup completo con FastMCP, decoradores, MCP Inspector, conexión a Claude Code
- Cápsula 03: Tools y resources avanzados, Pydantic vs Zod, patrones de diseño, diferencias idiomáticas
- Cápsula 04: Patrones async (httpx, context managers, gather, retry, locks), error handling async
- 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
- GitHub REST API Documentation — Referencia completa de la API
- MCP Python SDK — SDK oficial
- httpx Documentation — Cliente HTTP async
- Pydantic v2 Documentation — Validación y serialización
- MCP Inspector — Debugging visual
- FastMCP Examples — Ejemplos oficiales
- GitHub Personal Access Tokens — Crear tokens
- Claude Code MCP Configuration — Configuración de MCP en Claude Code