Build Your First MCP Server in Python: Connect Claude to Any API
Learn to build a Model Context Protocol (MCP) server from scratch. Create a GitHub MCP server that lets Claude interact with repositories, issues, and pull requests.
Moshiour Rahman
Advertisement
AI assistants are powerful, but they’re isolated. They can’t access your GitHub repos, check your database, or interact with your internal tools—unless you build a bridge. That’s exactly what MCP (Model Context Protocol) does.
In this comprehensive tutorial, you’ll build a production-ready MCP server that connects Claude to the GitHub API. By the end, Claude will be able to list your repositories, read code, create issues, manage pull requests, and more—all through natural conversation.
Table of Contents
- The Problem MCP Solves
- Understanding MCP Architecture
- What We’re Building
- Prerequisites
- Project Setup
- Building the MCP Server
- Adding MCP Resources
- Adding MCP Prompts
- Connecting to Claude Desktop
- Real-World Use Cases
- Advanced Patterns
- Debugging & Troubleshooting
- Performance Optimization
- Security Best Practices
- Deploying to Production
The Problem MCP Solves
Before MCP, connecting AI to external tools was chaos. Every integration required custom code, proprietary protocols, and maintenance nightmares.
┌─────────────────────────────────────────────────────────────┐
│ BEFORE MCP │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Claude │ ? │ GitHub │ ? │ Database │ │
│ │ │ ──────► │ API │ ──────► │ │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │ │
│ │ ? ┌───────────┐ │
│ └─────────────► │ Slack │ │
│ └───────────┘ │
│ │
│ Problems: │
│ • Each tool needs custom integration code │
│ • No standard protocol or interface │
│ • Difficult to maintain and scale │
│ • Security is handled differently everywhere │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ WITH MCP │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Claude │ MCP │ MCP │ API │ GitHub │ │
│ │ (Client) │ ◄─────► │ Server │ ◄─────► │ Database │ │
│ └───────────┘ └───────────┘ │ Slack │ │
│ │ Notion │ │
│ Benefits: │ Jira │ │
│ • Standard protocol (JSON-RPC 2.0) └───────────┘ │
│ • Build once, use with any MCP client │
│ • Consistent security model │
│ • Community-driven ecosystem │
└─────────────────────────────────────────────────────────────┘
Without MCP: Every tool needs custom integration code. No standards.
With MCP: Build one server, connect to any MCP-compatible AI client.
Understanding MCP Architecture
MCP has three core primitives that work together:
┌─────────────────────────────────────────────────────────────┐
│ MCP Core Primitives │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ │
│ │ TOOLS │ Functions Claude can EXECUTE │
│ │ │ │
│ │ • list_repos() │ "List all my GitHub repos" │
│ │ • create_issue()│ "Create a bug report" │
│ │ • send_email() │ "Send this summary to team" │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ RESOURCES │ Data Claude can READ │
│ │ │ │
│ │ • repo://owner │ Repository metadata │
│ │ • file://path │ File contents │
│ │ • db://table │ Database records │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ PROMPTS │ Reusable TEMPLATES │
│ │ │ │
│ │ • code_review │ "Review this PR for issues" │
│ │ • bug_report │ "Generate bug report template" │
│ │ • release_notes │ "Create release notes from commits" │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
How They Work Together
| Primitive | Direction | Use Case |
|---|---|---|
| Tools | Claude → Server → External API | Taking actions, making changes |
| Resources | External Data → Server → Claude | Reading data, getting context |
| Prompts | Templates → Claude | Standardized workflows |
What We’re Building
A comprehensive GitHub MCP server with:
Tools (Actions)
| Tool | What It Does | Example Use |
|---|---|---|
list_repos | List repositories for a user/org | ”Show me my repos” |
get_file | Read any file from a repository | ”Read the README” |
create_issue | Create a new issue | ”Create a bug report” |
list_pull_requests | List PRs with filters | ”Show open PRs” |
get_pull_request | Get detailed PR info | ”Summarize PR #42” |
search_code | Search code across repos | ”Find all TODO comments” |
list_commits | Get commit history | ”Show last 10 commits” |
get_repo_stats | Repository analytics | ”How active is this repo?” |
Resources (Data)
| Resource | What It Provides |
|---|---|
repo://{owner}/{repo} | Repository metadata and stats |
readme://{owner}/{repo} | README content |
issues://{owner}/{repo} | Open issues list |
Prompts (Templates)
| Prompt | Purpose |
|---|---|
code_review | Structured code review workflow |
release_notes | Generate release notes from commits |
bug_report | Standardized bug report template |
Prerequisites
Before starting, ensure you have:
# Check Python version (3.10+ required)
python --version # Should be 3.10 or higher
# Check if uv is installed (recommended package manager)
uv --version # If not installed, we'll install it
Required Accounts & Tokens
| Requirement | Where to Get It |
|---|---|
| Python 3.10+ | python.org or brew install python |
| GitHub Account | github.com |
| GitHub Personal Access Token | github.com/settings/tokens |
| Claude Desktop | claude.ai/download |
Creating a GitHub Personal Access Token
- Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
- Click Generate new token (classic)
- Select scopes:
┌─────────────────────────────────────────────────────────────┐
│ Required GitHub Token Scopes │
├─────────────────────────────────────────────────────────────┤
│ │
│ ☑ repo Full control of private repositories │
│ ├─ repo:status Access commit status │
│ ├─ repo_deployment Access deployment status │
│ ├─ public_repo Access public repositories │
│ └─ repo:invite Access repository invitations │
│ │
│ ☑ read:org Read org and team membership │
│ │
│ ☑ read:user Read user profile data │
│ │
└─────────────────────────────────────────────────────────────┘
- Copy the token (starts with
ghp_)
Project Structure
github-mcp-server/
├── src/
│ └── github_mcp/
│ ├── __init__.py # Package init
│ ├── server.py # Main MCP server
│ ├── tools.py # Tool definitions
│ ├── resources.py # Resource definitions
│ ├── prompts.py # Prompt templates
│ └── github_client.py # GitHub API wrapper
├── tests/
│ ├── __init__.py
│ ├── test_tools.py
│ └── test_resources.py
├── .env # Environment variables (git-ignored)
├── .env.example # Example env file
├── pyproject.toml # Project configuration
└── README.md
Step 1: Project Setup
# Create project directory
mkdir github-mcp-server && cd github-mcp-server
# Install uv (fast Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Initialize project
uv init
# Add dependencies
uv add "mcp[cli]>=1.2.0" httpx python-dotenv pydantic
# Add dev dependencies
uv add --dev pytest pytest-asyncio
# Create source directory structure
mkdir -p src/github_mcp tests
touch src/github_mcp/__init__.py
touch src/github_mcp/server.py
touch src/github_mcp/tools.py
touch src/github_mcp/resources.py
touch src/github_mcp/prompts.py
touch src/github_mcp/github_client.py
Create environment files:
# .env.example (commit this)
GITHUB_TOKEN=ghp_your_token_here
# .env (git-ignored, your actual token)
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
Add .env to .gitignore:
echo ".env" >> .gitignore
Step 2: Build the MCP Server
GitHub API Client
First, create a reusable GitHub API client:
# src/github_mcp/github_client.py
"""
GitHub API Client - Handles all GitHub API interactions.
This module provides a clean interface for GitHub operations,
including error handling, rate limiting, and response parsing.
"""
import os
from typing import Any
from dataclasses import dataclass
from enum import Enum
import httpx
from dotenv import load_dotenv
load_dotenv()
class GitHubError(Exception):
"""Custom exception for GitHub API errors."""
def __init__(self, message: str, status_code: int = None):
self.message = message
self.status_code = status_code
super().__init__(self.message)
class HttpMethod(Enum):
GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"
@dataclass
class RateLimitInfo:
"""GitHub API rate limit information."""
limit: int
remaining: int
reset_timestamp: int
@property
def is_exceeded(self) -> bool:
return self.remaining == 0
class GitHubClient:
"""
Async GitHub API client with built-in error handling.
Features:
- Automatic authentication
- Rate limit tracking
- Comprehensive error handling
- Response parsing
"""
BASE_URL = "https://api.github.com"
def __init__(self, token: str = None):
self.token = token or os.getenv("GITHUB_TOKEN")
if not self.token:
raise ValueError(
"GitHub token required. Set GITHUB_TOKEN environment variable "
"or pass token to constructor."
)
self.headers = {
"Authorization": f"Bearer {self.token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "MCP-GitHub-Server/1.0",
"X-GitHub-Api-Version": "2022-11-28"
}
self.rate_limit: RateLimitInfo | None = None
def _update_rate_limit(self, response: httpx.Response) -> None:
"""Update rate limit info from response headers."""
try:
self.rate_limit = RateLimitInfo(
limit=int(response.headers.get("X-RateLimit-Limit", 0)),
remaining=int(response.headers.get("X-RateLimit-Remaining", 0)),
reset_timestamp=int(response.headers.get("X-RateLimit-Reset", 0))
)
except (ValueError, TypeError):
pass
async def request(
self,
endpoint: str,
method: HttpMethod = HttpMethod.GET,
data: dict | None = None,
params: dict | None = None
) -> dict[str, Any] | list:
"""
Make an authenticated request to the GitHub API.
Args:
endpoint: API endpoint (e.g., '/repos/owner/repo')
method: HTTP method
data: Request body for POST/PUT/PATCH
params: Query parameters
Returns:
Parsed JSON response
Raises:
GitHubError: On API errors
"""
url = f"{self.BASE_URL}{endpoint}"
async with httpx.AsyncClient() as client:
try:
response = await client.request(
method=method.value,
url=url,
headers=self.headers,
json=data,
params=params,
timeout=30.0
)
self._update_rate_limit(response)
# Handle specific status codes
if response.status_code == 404:
raise GitHubError("Resource not found", 404)
elif response.status_code == 401:
raise GitHubError("Invalid or expired token", 401)
elif response.status_code == 403:
if self.rate_limit and self.rate_limit.is_exceeded:
raise GitHubError(
f"Rate limit exceeded. Resets at {self.rate_limit.reset_timestamp}",
403
)
raise GitHubError("Access forbidden", 403)
elif response.status_code == 422:
error_data = response.json()
message = error_data.get("message", "Validation failed")
raise GitHubError(f"Validation error: {message}", 422)
response.raise_for_status()
# Handle empty responses
if response.status_code == 204:
return {"success": True}
return response.json()
except httpx.HTTPStatusError as e:
raise GitHubError(
f"HTTP error: {e.response.status_code}",
e.response.status_code
)
except httpx.RequestError as e:
raise GitHubError(f"Request failed: {str(e)}")
# Convenience methods
async def get(self, endpoint: str, **params) -> dict | list:
return await self.request(endpoint, HttpMethod.GET, params=params)
async def post(self, endpoint: str, data: dict) -> dict:
return await self.request(endpoint, HttpMethod.POST, data=data)
async def patch(self, endpoint: str, data: dict) -> dict:
return await self.request(endpoint, HttpMethod.PATCH, data=data)
async def delete(self, endpoint: str) -> dict:
return await self.request(endpoint, HttpMethod.DELETE)
# Global client instance
_client: GitHubClient | None = None
def get_client() -> GitHubClient:
"""Get or create the global GitHub client."""
global _client
if _client is None:
_client = GitHubClient()
return _client
Tool Definitions
Now create the tools:
# src/github_mcp/tools.py
"""
MCP Tools for GitHub API.
Tools are functions that Claude can execute to interact with GitHub.
Each tool has typed parameters and returns formatted strings.
"""
import base64
from datetime import datetime
from github_mcp.github_client import get_client, GitHubError
async def list_repos(
owner: str,
repo_type: str = "all",
sort: str = "updated",
limit: int = 10
) -> str:
"""
List GitHub repositories for a user or organization.
Args:
owner: GitHub username or organization name
repo_type: Type of repos - 'all', 'public', 'private', 'sources', 'forks'
sort: Sort by - 'updated', 'created', 'pushed', 'full_name'
limit: Maximum number of repos to return (1-100, default: 10)
Returns:
Formatted markdown list of repositories with details
"""
client = get_client()
limit = min(max(1, limit), 100)
# Try user endpoint first
try:
repos = await client.get(
f"/users/{owner}/repos",
type=repo_type,
sort=sort,
per_page=limit
)
except GitHubError:
# Try organization endpoint
repos = await client.get(
f"/orgs/{owner}/repos",
type=repo_type,
sort=sort,
per_page=limit
)
if not repos:
return f"No repositories found for **{owner}**"
output = [f"## Repositories for {owner}\n"]
output.append(f"*Showing {len(repos)} of {len(repos)}+ repositories*\n")
for repo in repos:
visibility = "🔒 Private" if repo.get("private") else "🌐 Public"
stars = repo.get("stargazers_count", 0)
forks = repo.get("forks_count", 0)
language = repo.get("language") or "Not specified"
description = repo.get("description") or "*No description*"
updated = repo.get("updated_at", "")[:10]
output.append(f"""
### [{repo['name']}]({repo['html_url']})
{visibility} • ⭐ {stars} • 🍴 {forks} • 📝 {language}
{description}
*Last updated: {updated}*
""")
return "\n".join(output)
async def get_file(
owner: str,
repo: str,
path: str,
branch: str = "main"
) -> str:
"""
Get the contents of a file from a GitHub repository.
Args:
owner: Repository owner (username or organization)
repo: Repository name
path: Path to file (e.g., 'src/index.ts' or 'README.md')
branch: Branch name (default: 'main')
Returns:
File contents with metadata, or error message
"""
client = get_client()
try:
result = await client.get(
f"/repos/{owner}/{repo}/contents/{path}",
ref=branch
)
except GitHubError as e:
if e.status_code == 404:
return f"❌ File not found: `{path}` in `{owner}/{repo}` (branch: {branch})"
raise
if isinstance(result, list):
# It's a directory
files = [f"📁 {item['name']}" if item['type'] == 'dir' else f"📄 {item['name']}"
for item in result]
return f"## Directory: {path}\n\n" + "\n".join(files)
if result.get("type") != "file":
return f"❌ Path `{path}` is not a file"
content = result.get("content", "")
encoding = result.get("encoding", "")
if encoding != "base64":
return f"❌ Unsupported encoding: {encoding}"
try:
decoded = base64.b64decode(content).decode("utf-8")
except Exception as e:
return f"❌ Failed to decode file: {e}"
size = result.get("size", 0)
sha = result.get("sha", "")[:7]
# Detect language for syntax highlighting
extension = path.split(".")[-1] if "." in path else ""
lang_map = {
"py": "python", "js": "javascript", "ts": "typescript",
"jsx": "jsx", "tsx": "tsx", "rs": "rust", "go": "go",
"java": "java", "rb": "ruby", "php": "php", "cs": "csharp",
"cpp": "cpp", "c": "c", "h": "c", "hpp": "cpp",
"md": "markdown", "json": "json", "yaml": "yaml", "yml": "yaml",
"toml": "toml", "xml": "xml", "html": "html", "css": "css",
"sql": "sql", "sh": "bash", "bash": "bash", "zsh": "bash"
}
lang = lang_map.get(extension, "")
return f"""## 📄 {path}
| Property | Value |
|----------|-------|
| **Repository** | {owner}/{repo} |
| **Branch** | {branch} |
| **Size** | {size:,} bytes |
| **SHA** | `{sha}` |
```{lang}
{decoded}
"""
async def create_issue( owner: str, repo: str, title: str, body: str, labels: list[str] | None = None, assignees: list[str] | None = None ) -> str: """ Create a new issue in a GitHub repository.
Args:
owner: Repository owner (username or organization)
repo: Repository name
title: Issue title
body: Issue description (supports Markdown)
labels: Optional list of label names to apply
assignees: Optional list of usernames to assign
Returns:
Success message with issue URL and details
"""
client = get_client()
data = {
"title": title,
"body": body
}
if labels:
data["labels"] = labels
if assignees:
data["assignees"] = assignees
result = await client.post(f"/repos/{owner}/{repo}/issues", data)
return f"""## ✅ Issue Created Successfully!
| Field | Value |
|---|---|
| Title | {result[‘title’]} |
| Number | #{result[‘number’]} |
| State | {result[‘state’]} |
| URL | {result[‘html_url’]} |
| Author | @{result[‘user’][‘login’]} |
| Created | {result[‘created_at’][:10]} |
Labels
{’, ‘.join([f”{l['name']}” for l in result.get(‘labels’, [])]) or ‘None’}
Assignees
{’, ‘.join([f”@{a[‘login’]}” for a in result.get(‘assignees’, [])]) or ‘None’} """
async def list_pull_requests( owner: str, repo: str, state: str = “open”, sort: str = “updated”, limit: int = 10 ) -> str: """ List pull requests for a repository.
Args:
owner: Repository owner
repo: Repository name
state: PR state - 'open', 'closed', 'all'
sort: Sort by - 'created', 'updated', 'popularity', 'long-running'
limit: Maximum number of PRs to return (1-100)
Returns:
Formatted list of pull requests
"""
client = get_client()
limit = min(max(1, limit), 100)
prs = await client.get(
f"/repos/{owner}/{repo}/pulls",
state=state,
sort=sort,
per_page=limit
)
if not prs:
return f"No {state} pull requests found in **{owner}/{repo}**"
output = [f"## Pull Requests: {owner}/{repo}\n"]
output.append(f"*Showing {len(prs)} {state} PRs*\n")
for pr in prs:
draft = "📝 Draft" if pr.get("draft") else ""
mergeable = ""
if pr.get("mergeable_state") == "clean":
mergeable = "✅ Ready to merge"
elif pr.get("mergeable_state") == "blocked":
mergeable = "🚫 Blocked"
created = pr.get("created_at", "")[:10]
output.append(f"""
#{pr[‘number’]} {pr[‘title’]}
Author: @{pr[‘user’][‘login’]} • Created: {created} {draft} {mergeable}
{pr['head']['ref']} → {pr['base']['ref']}
+{pr.get(‘additions’, 0)} -{pr.get(‘deletions’, 0)} • {pr.get(‘changed_files’, 0)} files """)
return "\n".join(output)
async def get_pull_request( owner: str, repo: str, pr_number: int ) -> str: """ Get detailed information about a specific pull request.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
Returns:
Detailed PR information including files, commits, and review status
"""
client = get_client()
# Get PR details
pr = await client.get(f"/repos/{owner}/{repo}/pulls/{pr_number}")
# Get files changed
files = await client.get(f"/repos/{owner}/{repo}/pulls/{pr_number}/files")
# Get reviews
reviews = await client.get(f"/repos/{owner}/{repo}/pulls/{pr_number}/reviews")
# Format file list
file_list = []
for f in files[:20]: # Limit to first 20 files
status_emoji = {"added": "➕", "removed": "➖", "modified": "📝", "renamed": "📋"}.get(f['status'], "📄")
file_list.append(f"- {status_emoji} `{f['filename']}` (+{f['additions']}/-{f['deletions']})")
if len(files) > 20:
file_list.append(f"- *...and {len(files) - 20} more files*")
# Format reviews
review_summary = {}
for r in reviews:
state = r.get("state", "PENDING")
if state not in review_summary:
review_summary[state] = []
review_summary[state].append(r['user']['login'])
review_text = []
state_emoji = {"APPROVED": "✅", "CHANGES_REQUESTED": "❌", "COMMENTED": "💬", "PENDING": "⏳"}
for state, users in review_summary.items():
emoji = state_emoji.get(state, "")
review_text.append(f"- {emoji} **{state}**: {', '.join([f'@{u}' for u in users])}")
return f"""## Pull Request #{pr['number']}: {pr['title']}
Overview
| Field | Value |
|---|---|
| State | {pr[‘state’].upper()} {‘(MERGED)’ if pr.get(‘merged’) else ”} |
| Author | @{pr[‘user’][‘login’]} |
| Branch | {pr['head']['ref']} → {pr['base']['ref']} |
| Created | {pr[‘created_at’][:10]} |
| Updated | {pr[‘updated_at’][:10]} |
| Mergeable | {pr.get(‘mergeable_state’, ‘unknown’)} |
| URL | {pr[‘html_url’]} |
Stats
- Commits: {pr.get(‘commits’, 0)}
- Changed Files: {pr.get(‘changed_files’, 0)}
- Additions: +{pr.get(‘additions’, 0)}
- Deletions: -{pr.get(‘deletions’, 0)}
Description
{pr.get(‘body’) or ‘No description provided’}
Files Changed
{chr(10).join(file_list)}
Reviews
{chr(10).join(review_text) or ‘No reviews yet’}
Labels
{’, ‘.join([f”{l['name']}” for l in pr.get(‘labels’, [])]) or ‘None’}
"""
async def search_code( query: str, owner: str | None = None, repo: str | None = None, language: str | None = None, limit: int = 10 ) -> str: """ Search for code across GitHub repositories.
Args:
query: Search query (code, filename, etc.)
owner: Optional - limit to specific user/org
repo: Optional - limit to specific repository
language: Optional - filter by programming language
limit: Maximum results (1-100)
Returns:
Formatted search results with code snippets
"""
client = get_client()
limit = min(max(1, limit), 100)
# Build search query
q = query
if owner and repo:
q += f" repo:{owner}/{repo}"
elif owner:
q += f" user:{owner}"
if language:
q += f" language:{language}"
result = await client.get("/search/code", q=q, per_page=limit)
items = result.get("items", [])
total = result.get("total_count", 0)
if not items:
return f"No code found matching: `{query}`"
output = [f"## Code Search Results\n"]
output.append(f"*Found {total:,} results, showing {len(items)}*\n")
output.append(f"**Query:** `{q}`\n")
for item in items:
repo_name = item['repository']['full_name']
path = item['path']
url = item['html_url']
output.append(f"""
{path}
📁 {repo_name}
""")
return "\n".join(output)
async def list_commits( owner: str, repo: str, branch: str = “main”, limit: int = 10, author: str | None = None ) -> str: """ Get commit history for a repository.
Args:
owner: Repository owner
repo: Repository name
branch: Branch name (default: 'main')
limit: Number of commits to return (1-100)
author: Optional - filter by author username
Returns:
Formatted list of commits with details
"""
client = get_client()
limit = min(max(1, limit), 100)
params = {"sha": branch, "per_page": limit}
if author:
params["author"] = author
commits = await client.get(f"/repos/{owner}/{repo}/commits", **params)
if not commits:
return f"No commits found on branch `{branch}`"
output = [f"## Commit History: {owner}/{repo}\n"]
output.append(f"*Branch: `{branch}` • Showing {len(commits)} commits*\n")
for commit in commits:
sha = commit['sha'][:7]
message = commit['commit']['message'].split('\n')[0] # First line only
author_name = commit['commit']['author']['name']
date = commit['commit']['author']['date'][:10]
url = commit['html_url']
# Stats (if available)
stats = commit.get('stats', {})
additions = stats.get('additions', 0)
deletions = stats.get('deletions', 0)
output.append(f"""
{sha} {message}
Author: {author_name} • Date: {date} {f’+{additions} -{deletions}’ if stats else ”} """)
return "\n".join(output)
async def get_repo_stats( owner: str, repo: str ) -> str: """ Get comprehensive statistics for a repository.
Args:
owner: Repository owner
repo: Repository name
Returns:
Repository analytics including contributors, activity, languages
"""
client = get_client()
# Get repo info
repo_info = await client.get(f"/repos/{owner}/{repo}")
# Get languages
languages = await client.get(f"/repos/{owner}/{repo}/languages")
# Get contributors (top 10)
try:
contributors = await client.get(
f"/repos/{owner}/{repo}/contributors",
per_page=10
)
except GitHubError:
contributors = []
# Format languages
total_bytes = sum(languages.values()) if languages else 0
lang_list = []
for lang, bytes_count in sorted(languages.items(), key=lambda x: -x[1])[:5]:
percentage = (bytes_count / total_bytes * 100) if total_bytes > 0 else 0
lang_list.append(f"- **{lang}**: {percentage:.1f}%")
# Format contributors
contrib_list = []
for c in contributors[:10]:
contrib_list.append(f"- @{c['login']}: {c['contributions']} commits")
created = repo_info.get('created_at', '')[:10]
updated = repo_info.get('updated_at', '')[:10]
pushed = repo_info.get('pushed_at', '')[:10]
return f"""## Repository Stats: {owner}/{repo}
Overview
| Metric | Value |
|---|---|
| Stars | ⭐ {repo_info.get(‘stargazers_count’, 0):,} |
| Forks | 🍴 {repo_info.get(‘forks_count’, 0):,} |
| Watchers | 👀 {repo_info.get(‘watchers_count’, 0):,} |
| Open Issues | 🐛 {repo_info.get(‘open_issues_count’, 0):,} |
| Size | 💾 {repo_info.get(‘size’, 0):,} KB |
| Default Branch | {repo_info.get('default_branch', 'main')} |
| License | {repo_info.get(‘license’, {}).get(‘name’, ‘None’)} |
Dates
| Event | Date |
|---|---|
| Created | {created} |
| Last Updated | {updated} |
| Last Push | {pushed} |
Languages
{chr(10).join(lang_list) or ‘No language data’}
Top Contributors
{chr(10).join(contrib_list) or ‘No contributor data’}
Description
{repo_info.get(‘description’) or ‘No description’}
Topics
{’, ‘.join([f’{t}’ for t in repo_info.get(‘topics’, [])]) or ‘None’}
"""
### Resource Definitions
```python
# src/github_mcp/resources.py
"""
MCP Resources for GitHub API.
Resources are data sources that Claude can read to get context.
They're different from tools - resources are for reading data,
tools are for taking actions.
"""
from github_mcp.github_client import get_client, GitHubError
async def get_repo_resource(owner: str, repo: str) -> str:
"""
Resource: repo://{owner}/{repo}
Provides repository metadata as a resource Claude can reference.
"""
client = get_client()
try:
data = await client.get(f"/repos/{owner}/{repo}")
except GitHubError as e:
return f"Error loading repository: {e.message}"
return f"""# Repository: {data['full_name']}
## Quick Facts
- **Description:** {data.get('description') or 'None'}
- **Primary Language:** {data.get('language') or 'Not specified'}
- **License:** {data.get('license', {}).get('name') or 'None'}
- **Default Branch:** {data.get('default_branch')}
## Stats
- Stars: {data.get('stargazers_count', 0):,}
- Forks: {data.get('forks_count', 0):,}
- Open Issues: {data.get('open_issues_count', 0):,}
- Watchers: {data.get('watchers_count', 0):,}
## URLs
- Repository: {data.get('html_url')}
- Homepage: {data.get('homepage') or 'None'}
- Clone (HTTPS): {data.get('clone_url')}
- Clone (SSH): {data.get('ssh_url')}
## Flags
- Private: {data.get('private', False)}
- Fork: {data.get('fork', False)}
- Archived: {data.get('archived', False)}
- Disabled: {data.get('disabled', False)}
- Has Issues: {data.get('has_issues', True)}
- Has Wiki: {data.get('has_wiki', True)}
- Has Pages: {data.get('has_pages', False)}
"""
async def get_readme_resource(owner: str, repo: str) -> str:
"""
Resource: readme://{owner}/{repo}
Provides repository README as a resource.
"""
client = get_client()
try:
data = await client.get(f"/repos/{owner}/{repo}/readme")
except GitHubError:
return f"No README found for {owner}/{repo}"
import base64
content = data.get("content", "")
encoding = data.get("encoding", "")
if encoding == "base64":
try:
decoded = base64.b64decode(content).decode("utf-8")
return f"# README: {owner}/{repo}\n\n{decoded}"
except Exception:
return "Error decoding README"
return "Unsupported encoding"
async def get_issues_resource(owner: str, repo: str) -> str:
"""
Resource: issues://{owner}/{repo}
Provides open issues as a resource.
"""
client = get_client()
try:
issues = await client.get(
f"/repos/{owner}/{repo}/issues",
state="open",
per_page=25
)
except GitHubError as e:
return f"Error loading issues: {e.message}"
if not issues:
return f"No open issues in {owner}/{repo}"
output = [f"# Open Issues: {owner}/{repo}\n"]
for issue in issues:
# Skip pull requests (they appear in issues endpoint too)
if issue.get("pull_request"):
continue
labels = ", ".join([l["name"] for l in issue.get("labels", [])])
output.append(f"""
## #{issue['number']}: {issue['title']}
- **Author:** @{issue['user']['login']}
- **Created:** {issue['created_at'][:10]}
- **Labels:** {labels or 'None'}
- **Comments:** {issue.get('comments', 0)}
- **URL:** {issue['html_url']}
""")
return "\n".join(output)
Prompt Templates
# src/github_mcp/prompts.py
"""
MCP Prompts for GitHub workflows.
Prompts are reusable templates that help Claude perform
common tasks in a standardized way.
"""
def code_review_prompt(owner: str, repo: str, pr_number: int) -> str:
"""
Prompt: code_review
Template for performing a thorough code review.
"""
return f"""# Code Review Request
Please review Pull Request #{pr_number} in the repository {owner}/{repo}.
## Review Checklist
1. **Functionality**
- Does the code do what it's supposed to do?
- Are there any edge cases not handled?
- Is the logic correct?
2. **Code Quality**
- Is the code readable and well-organized?
- Are variable/function names descriptive?
- Is there appropriate error handling?
3. **Performance**
- Are there any obvious performance issues?
- Are there unnecessary loops or operations?
- Is memory usage reasonable?
4. **Security**
- Are there any security vulnerabilities?
- Is user input validated?
- Are secrets handled properly?
5. **Testing**
- Are there adequate tests?
- Do tests cover edge cases?
- Are tests readable and maintainable?
6. **Documentation**
- Are functions/classes documented?
- Is the README updated if needed?
- Are complex sections commented?
## Instructions
First, use the `get_pull_request` tool to get details about PR #{pr_number}.
Then analyze the changes and provide your review following the checklist above.
Rate each category: ✅ Good, ⚠️ Needs Attention, ❌ Requires Changes
End with a summary and overall recommendation: APPROVE, REQUEST_CHANGES, or COMMENT.
"""
def release_notes_prompt(owner: str, repo: str, from_tag: str, to_tag: str = "HEAD") -> str:
"""
Prompt: release_notes
Template for generating release notes from commits.
"""
return f"""# Release Notes Generator
Generate release notes for {owner}/{repo} from {from_tag} to {to_tag}.
## Instructions
1. Use `list_commits` to get commits between the tags
2. Categorize commits by type:
- ✨ **New Features** - feat: commits
- 🐛 **Bug Fixes** - fix: commits
- 📚 **Documentation** - docs: commits
- 🔧 **Maintenance** - chore:, refactor:, style: commits
- ⚡ **Performance** - perf: commits
- 🔒 **Security** - security: commits
3. For each category, list the changes in bullet points
4. Highlight breaking changes with ⚠️ BREAKING CHANGE
5. Credit contributors with @username
## Output Format
```markdown
# Release Notes: vX.Y.Z
## ✨ New Features
- Feature description (#PR) @author
## 🐛 Bug Fixes
- Fix description (#PR) @author
## 📚 Documentation
- Doc updates
## 🔧 Maintenance
- Maintenance items
## Contributors
Thanks to all contributors: @user1, @user2
## Full Changelog
https://github.com/{owner}/{repo}/compare/{from_tag}...{to_tag}
"""
def bug_report_prompt() -> str: """ Prompt: bug_report
Template for creating a standardized bug report.
"""
return """# Bug Report Template
Please help me create a bug report. I’ll ask you some questions to gather the necessary information.
Required Information
- Title: A clear, concise title for the bug
- Environment: OS, browser, version, etc.
- Steps to Reproduce: Numbered list of steps
- Expected Behavior: What should happen
- Actual Behavior: What actually happens
- Screenshots/Logs: Any relevant evidence
Bug Report Format
## Description
[Brief description of the bug]
## Environment
- OS:
- Version:
- Browser (if applicable):
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
[What you expected to happen]
## Actual Behavior
[What actually happened]
## Additional Context
[Any other relevant information]
## Possible Solution (optional)
[If you have ideas on how to fix it]
After gathering this information, use the create_issue tool to create the bug report with appropriate labels like [“bug”, “needs-triage”].
"""
### Main Server
Now bring it all together:
```python
# src/github_mcp/server.py
"""
GitHub MCP Server - Connect Claude to GitHub API
This is the main entry point for the MCP server.
It registers all tools, resources, and prompts.
"""
import os
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
# Load environment variables
load_dotenv()
# Verify token exists
if not os.getenv("GITHUB_TOKEN"):
raise ValueError(
"GITHUB_TOKEN environment variable is required.\n"
"Create a token at: https://github.com/settings/tokens"
)
# Initialize MCP server
mcp = FastMCP(
"github",
description="GitHub API integration for Claude - manage repos, issues, PRs, and more"
)
# ============================================================
# Import and register tools
# ============================================================
from github_mcp.tools import (
list_repos,
get_file,
create_issue,
list_pull_requests,
get_pull_request,
search_code,
list_commits,
get_repo_stats
)
# Register tools with MCP
mcp.tool()(list_repos)
mcp.tool()(get_file)
mcp.tool()(create_issue)
mcp.tool()(list_pull_requests)
mcp.tool()(get_pull_request)
mcp.tool()(search_code)
mcp.tool()(list_commits)
mcp.tool()(get_repo_stats)
# ============================================================
# Register resources
# ============================================================
from github_mcp.resources import (
get_repo_resource,
get_readme_resource,
get_issues_resource
)
@mcp.resource("repo://{owner}/{repo}")
async def repo_resource(owner: str, repo: str) -> str:
"""Repository metadata and statistics."""
return await get_repo_resource(owner, repo)
@mcp.resource("readme://{owner}/{repo}")
async def readme_resource(owner: str, repo: str) -> str:
"""Repository README content."""
return await get_readme_resource(owner, repo)
@mcp.resource("issues://{owner}/{repo}")
async def issues_resource(owner: str, repo: str) -> str:
"""Open issues in a repository."""
return await get_issues_resource(owner, repo)
# ============================================================
# Register prompts
# ============================================================
from github_mcp.prompts import (
code_review_prompt,
release_notes_prompt,
bug_report_prompt
)
@mcp.prompt()
def code_review(owner: str, repo: str, pr_number: int) -> str:
"""Perform a thorough code review on a pull request."""
return code_review_prompt(owner, repo, pr_number)
@mcp.prompt()
def release_notes(owner: str, repo: str, from_tag: str, to_tag: str = "HEAD") -> str:
"""Generate release notes from commits between tags."""
return release_notes_prompt(owner, repo, from_tag, to_tag)
@mcp.prompt()
def bug_report() -> str:
"""Create a standardized bug report."""
return bug_report_prompt()
# ============================================================
# Server entry point
# ============================================================
def main():
"""Run the MCP server."""
print("Starting GitHub MCP Server...")
print(f"Tools: {len([list_repos, get_file, create_issue, list_pull_requests, get_pull_request, search_code, list_commits, get_repo_stats])}")
print(f"Resources: 3")
print(f"Prompts: 3")
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
Package Init
# src/github_mcp/__init__.py
"""GitHub MCP Server - Connect Claude to GitHub API."""
__version__ = "1.0.0"
Step 3: Configure pyproject.toml
# pyproject.toml
[project]
name = "github-mcp-server"
version = "1.0.0"
description = "MCP server for comprehensive GitHub API integration"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
{name = "Your Name", email = "you@example.com"}
]
keywords = ["mcp", "github", "claude", "ai", "automation"]
dependencies = [
"mcp[cli]>=1.2.0",
"httpx>=0.27.0",
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
]
[project.scripts]
github-mcp = "github_mcp.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/github_mcp"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
Step 4: Test Locally
# Run the server directly
uv run python -m github_mcp.server
# Or if installed as package
uv run github-mcp
# Test with MCP inspector
npx @modelcontextprotocol/inspector uv run github-mcp
The inspector opens a web UI where you can:
- See all registered tools, resources, and prompts
- Test tool calls with different parameters
- View responses and debug issues
Step 5: Connect to Claude Desktop
Edit Claude’s configuration file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
Linux: ~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"github": {
"command": "uv",
"args": [
"--directory",
"/absolute/path/to/github-mcp-server",
"run",
"github-mcp"
],
"env": {
"GITHUB_TOKEN": "ghp_your_token_here"
}
}
}
}
Important Configuration Notes
| Setting | Requirement |
|---|---|
| Paths | Must be absolute, not relative |
| Token | Can be in config OR in .env file |
| Restart | Fully quit Claude (Cmd+Q / Alt+F4) |
| Logs | Check ~/Library/Logs/Claude/mcp*.log |
Verify Connection
After restarting Claude:
- Look for the hammer 🔨 icon in Claude’s toolbar
- Click it to see available MCP tools
- You should see your GitHub tools listed
Real-World Use Cases
Use Case 1: Daily Standup Report
"Give me a summary of my GitHub activity today -
commits, PRs, and issues I've worked on"
Claude will use list_commits, list_pull_requests, and the issues resource.
Use Case 2: Code Review Assistant
"Review PR #42 in my-org/my-repo - focus on security and performance"
Claude uses the code_review prompt and get_pull_request tool.
Use Case 3: Release Management
"Generate release notes for everything since v1.2.0"
Claude uses list_commits and formats changes by category.
Use Case 4: Issue Triage
"Show me all open bugs in my-project and help me prioritize them"
Claude reads the issues resource and provides analysis.
Use Case 5: Repository Analysis
"Analyze the techyowls/techy-owls repo -
give me stats, top contributors, and recent activity"
Claude uses get_repo_stats and other tools.
Advanced Patterns
Pattern 1: Caching Responses
from functools import lru_cache
from datetime import datetime, timedelta
# Simple in-memory cache
_cache = {}
_cache_ttl = timedelta(minutes=5)
async def cached_request(endpoint: str):
now = datetime.now()
if endpoint in _cache:
data, timestamp = _cache[endpoint]
if now - timestamp < _cache_ttl:
return data
result = await client.get(endpoint)
_cache[endpoint] = (result, now)
return result
Pattern 2: Batch Operations
@mcp.tool()
async def batch_create_issues(
owner: str,
repo: str,
issues: list[dict]
) -> str:
"""
Create multiple issues at once.
Args:
owner: Repository owner
repo: Repository name
issues: List of {title, body, labels} dicts
"""
results = []
for issue in issues:
result = await create_issue(
owner, repo,
issue['title'],
issue.get('body', ''),
issue.get('labels')
)
results.append(result)
return f"Created {len(results)} issues"
Pattern 3: Webhooks Integration
# For real-time updates, combine with webhooks
@mcp.tool()
async def setup_webhook(
owner: str,
repo: str,
url: str,
events: list[str] = ["push", "pull_request"]
) -> str:
"""Set up a webhook for repository events."""
client = get_client()
result = await client.post(
f"/repos/{owner}/{repo}/hooks",
{
"name": "web",
"active": True,
"events": events,
"config": {
"url": url,
"content_type": "json"
}
}
)
return f"Webhook created: {result['id']}"
Debugging & Troubleshooting
Check MCP Logs
# macOS
tail -f ~/Library/Logs/Claude/mcp*.log
# Real-time monitoring
tail -f ~/Library/Logs/Claude/mcp-server-github.log
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Server not appearing | Bad config JSON | Validate with python -c "import json; json.load(open('config.json'))" |
| ”Resource not found” | Wrong path | Use absolute paths, not relative |
| Auth errors | Bad token | Test: curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user |
| Rate limiting | Too many requests | Check X-RateLimit-Remaining header |
| Timeout | Slow network | Increase timeout in httpx client |
Debug Mode
Add verbose logging:
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("github-mcp")
# In your tools:
logger.debug(f"Calling GitHub API: {endpoint}")
Test Token Validity
# Test your GitHub token
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/user
# Check rate limit
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/rate_limit
Performance Optimization
1. Connection Pooling
# Reuse HTTP client across requests
class GitHubClient:
_client: httpx.AsyncClient | None = None
@classmethod
async def get_client(cls) -> httpx.AsyncClient:
if cls._client is None:
cls._client = httpx.AsyncClient(
timeout=30.0,
limits=httpx.Limits(max_connections=10)
)
return cls._client
2. Parallel Requests
import asyncio
async def get_repo_with_details(owner: str, repo: str):
"""Fetch repo info, README, and issues in parallel."""
results = await asyncio.gather(
client.get(f"/repos/{owner}/{repo}"),
client.get(f"/repos/{owner}/{repo}/readme"),
client.get(f"/repos/{owner}/{repo}/issues"),
return_exceptions=True
)
return results
3. Response Streaming
For large files, stream the response:
async def get_large_file(owner: str, repo: str, path: str):
async with httpx.AsyncClient() as client:
async with client.stream("GET", url, headers=headers) as response:
async for chunk in response.aiter_bytes():
yield chunk
Security Best Practices
Token Security
| Practice | Implementation |
|---|---|
| Environment variables | Never hardcode tokens |
| Minimal scopes | Only request needed permissions |
| Token rotation | Regenerate tokens periodically |
| Audit logs | Log API usage (not tokens) |
Input Validation
from pydantic import BaseModel, validator
class RepoRequest(BaseModel):
owner: str
repo: str
@validator('owner', 'repo')
def validate_name(cls, v):
if not v or '/' in v or '..' in v:
raise ValueError('Invalid repository name')
return v
Rate Limit Handling
async def rate_limited_request(endpoint: str, max_retries: int = 3):
for attempt in range(max_retries):
try:
return await client.get(endpoint)
except GitHubError as e:
if e.status_code == 403 and "rate limit" in str(e).lower():
wait_time = 60 * (attempt + 1)
await asyncio.sleep(wait_time)
else:
raise
Deploying to Production
Option 1: Local Installation
Best for personal use on your machine.
# Install globally
uv tool install github-mcp-server
# Add to Claude config
{
"mcpServers": {
"github": {
"command": "github-mcp"
}
}
}
Option 2: Docker
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install uv
COPY . .
RUN uv sync
ENV GITHUB_TOKEN=""
CMD ["uv", "run", "github-mcp"]
docker build -t github-mcp .
docker run -e GITHUB_TOKEN=$GITHUB_TOKEN github-mcp
Option 3: Remote Server (SSE Transport)
For team deployments, use HTTP/SSE transport:
# For remote deployment
mcp.run(transport="sse", port=8080)
{
"mcpServers": {
"github": {
"url": "http://your-server:8080/sse"
}
}
}
Testing Your Server
# tests/test_tools.py
import pytest
from github_mcp.tools import list_repos, get_file
@pytest.mark.asyncio
async def test_list_repos():
result = await list_repos("octocat", limit=5)
assert "Repositories for octocat" in result
assert "Hello-World" in result or "Spoon-Knife" in result
@pytest.mark.asyncio
async def test_get_file():
result = await get_file("octocat", "Hello-World", "README")
assert "Hello World" in result or "README" in result
@pytest.mark.asyncio
async def test_get_file_not_found():
result = await get_file("octocat", "Hello-World", "nonexistent.txt")
assert "not found" in result.lower() or "error" in result.lower()
Run tests:
uv run pytest -v
Summary
| What You Built | Details |
|---|---|
| 8 Tools | list_repos, get_file, create_issue, list_pull_requests, get_pull_request, search_code, list_commits, get_repo_stats |
| 3 Resources | repo://, readme://, issues:// |
| 3 Prompts | code_review, release_notes, bug_report |
| Features | Error handling, rate limiting, caching, parallel requests |
MCP Concepts Covered
| Concept | What You Learned |
|---|---|
| Protocol | JSON-RPC 2.0 over stdio/SSE |
| Tools | Functions Claude can execute |
| Resources | Data sources Claude can read |
| Prompts | Reusable workflow templates |
| FastMCP | Python framework for MCP servers |
Key Takeaways
- MCP standardizes AI-tool integration - Build once, use anywhere
- Tools vs Resources vs Prompts - Actions vs Data vs Templates
- Security first - Environment variables, token scopes, validation
- Production ready - Error handling, caching, rate limiting
Next Steps
- Extend the server - Add more GitHub tools (releases, actions, gists)
- Build more servers - Slack, Notion, Jira, databases
- Explore MCP ecosystem - modelcontextprotocol.io
- Join the community - MCP Discord, GitHub discussions
Full source code: github.com/Moshiour027/techyowls-io-blog-public/tree/main/build-mcp-server-python-tutorial
Additional Resources
| Resource | Link |
|---|---|
| MCP Specification | modelcontextprotocol.io/specification |
| MCP Python SDK | github.com/modelcontextprotocol/python-sdk |
| FastMCP Docs | github.com/jlowin/fastmcp |
| Example Servers | github.com/modelcontextprotocol/servers |
| Claude Desktop | claude.ai/download |
Found this helpful? Check out more tutorials at techyowls.io/blog - we ship practical guides for developers who want to build real things.
Advertisement
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Model Context Protocol (MCP): Build Custom AI Tool Integrations
Master MCP to connect Claude and other LLMs to external tools, databases, and APIs. Complete guide with Python and TypeScript examples for building MCP servers.
PythonClaude AI API: Build Intelligent Applications with Anthropic
Master Claude AI API for building AI applications. Learn chat completions, tool use, vision, streaming, and production best practices.
PythonAI Agents Fundamentals: Build Your First Agent from Scratch
Master AI agents from the ground up. Learn the agent loop, build a working agent in pure Python, and understand the foundations that power LangGraph and CrewAI.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.