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 limitgithub://user/{username}— A user's profilegithub://repo/{owner}/{repo}— Information about a repository
Tools:
search_repos— Searches repositories by query, language, starsget_repo_details— Complete details of a repository (languages, contributors)list_user_repos— Lists a user's repositoriesget_repo_readme— Fetches a repository's READMEcompare_repos— Compares the statistics of two repositories
Prompts:
repo_analysis— Template to analyze a repositorydeveloper_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:
- Go to GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
- Generate a token with the
public_reposcope (read-only for public repos) - 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:
- Tools tab: You should see
search_repos,get_repo_details,get_repo_readme,compare_repos,list_user_repos - Resources tab: You should see
github://rate-limit - Test a tool: Select
search_repos, enter{"query": "fastapi", "language": "python", "max_results": 5}. You should see FastAPI repos. - 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 API | Real database (SQLite/PostgreSQL) |
| 5 tools | 8-12 tools |
| 3 resources | 5-8 resources |
| 2 prompts | 3-5 prompts |
| Manual tests | Automated test suite (pytest) |
| No cache | Cache with TTL and invalidation |
| Basic error handling | Retry, circuit breaker, logging |
| No formal documentation | README + 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_reposreturns real results from GitHub -
get_repo_detailsreturns complete information about a repo -
get_repo_readmereturns the README's content -
compare_reposcompares two repos correctly -
list_user_reposlists a user's repos -
github://rate-limitshows 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:
- Capsule 01: Why Python for MCP, comparison with TypeScript, module roadmap
- Capsule 02: Complete setup with FastMCP, decorators, MCP Inspector, connection to Claude Code
- Capsule 03: Advanced tools and resources, Pydantic vs Zod, design patterns, idiomatic differences
- Capsule 04: Async patterns (httpx, context managers, gather, retry, locks), async error handling
- 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
- GitHub REST API Documentation — Complete API reference
- MCP Python SDK — Official SDK
- httpx Documentation — Async HTTP client
- Pydantic v2 Documentation — Validation and serialization
- MCP Inspector — Visual debugging
- FastMCP Examples — Official examples
- GitHub Personal Access Tokens — Create tokens
- Claude Code MCP Configuration — MCP configuration in Claude Code