Python 31 min read

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.

MR

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

  1. The Problem MCP Solves
  2. Understanding MCP Architecture
  3. What We’re Building
  4. Prerequisites
  5. Project Setup
  6. Building the MCP Server
  7. Adding MCP Resources
  8. Adding MCP Prompts
  9. Connecting to Claude Desktop
  10. Real-World Use Cases
  11. Advanced Patterns
  12. Debugging & Troubleshooting
  13. Performance Optimization
  14. Security Best Practices
  15. 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

PrimitiveDirectionUse Case
ToolsClaude → Server → External APITaking actions, making changes
ResourcesExternal Data → Server → ClaudeReading data, getting context
PromptsTemplates → ClaudeStandardized workflows

What We’re Building

A comprehensive GitHub MCP server with:

Tools (Actions)

ToolWhat It DoesExample Use
list_reposList repositories for a user/org”Show me my repos”
get_fileRead any file from a repository”Read the README”
create_issueCreate a new issue”Create a bug report”
list_pull_requestsList PRs with filters”Show open PRs”
get_pull_requestGet detailed PR info”Summarize PR #42”
search_codeSearch code across repos”Find all TODO comments”
list_commitsGet commit history”Show last 10 commits”
get_repo_statsRepository analytics”How active is this repo?”

Resources (Data)

ResourceWhat It Provides
repo://{owner}/{repo}Repository metadata and stats
readme://{owner}/{repo}README content
issues://{owner}/{repo}Open issues list

Prompts (Templates)

PromptPurpose
code_reviewStructured code review workflow
release_notesGenerate release notes from commits
bug_reportStandardized 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

RequirementWhere to Get It
Python 3.10+python.org or brew install python
GitHub Accountgithub.com
GitHub Personal Access Tokengithub.com/settings/tokens
Claude Desktopclaude.ai/download

Creating a GitHub Personal Access Token

  1. Go to GitHub SettingsDeveloper settingsPersonal access tokensTokens (classic)
  2. Click Generate new token (classic)
  3. 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                    │
│                                                              │
└─────────────────────────────────────────────────────────────┘
  1. 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!
FieldValue
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

FieldValue
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

MetricValue
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

EventDate
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

  1. Title: A clear, concise title for the bug
  2. Environment: OS, browser, version, etc.
  3. Steps to Reproduce: Numbered list of steps
  4. Expected Behavior: What should happen
  5. Actual Behavior: What actually happens
  6. 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

SettingRequirement
PathsMust be absolute, not relative
TokenCan be in config OR in .env file
RestartFully quit Claude (Cmd+Q / Alt+F4)
LogsCheck ~/Library/Logs/Claude/mcp*.log

Verify Connection

After restarting Claude:

  1. Look for the hammer 🔨 icon in Claude’s toolbar
  2. Click it to see available MCP tools
  3. 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

IssueCauseSolution
Server not appearingBad config JSONValidate with python -c "import json; json.load(open('config.json'))"
”Resource not found”Wrong pathUse absolute paths, not relative
Auth errorsBad tokenTest: curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user
Rate limitingToo many requestsCheck X-RateLimit-Remaining header
TimeoutSlow networkIncrease 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

PracticeImplementation
Environment variablesNever hardcode tokens
Minimal scopesOnly request needed permissions
Token rotationRegenerate tokens periodically
Audit logsLog 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 BuiltDetails
8 Toolslist_repos, get_file, create_issue, list_pull_requests, get_pull_request, search_code, list_commits, get_repo_stats
3 Resourcesrepo://, readme://, issues://
3 Promptscode_review, release_notes, bug_report
FeaturesError handling, rate limiting, caching, parallel requests

MCP Concepts Covered

ConceptWhat You Learned
ProtocolJSON-RPC 2.0 over stdio/SSE
ToolsFunctions Claude can execute
ResourcesData sources Claude can read
PromptsReusable workflow templates
FastMCPPython framework for MCP servers

Key Takeaways

  1. MCP standardizes AI-tool integration - Build once, use anywhere
  2. Tools vs Resources vs Prompts - Actions vs Data vs Templates
  3. Security first - Environment variables, token scopes, validation
  4. Production ready - Error handling, caching, rate limiting

Next Steps

  1. Extend the server - Add more GitHub tools (releases, actions, gists)
  2. Build more servers - Slack, Notion, Jira, databases
  3. Explore MCP ecosystem - modelcontextprotocol.io
  4. 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

ResourceLink
MCP Specificationmodelcontextprotocol.io/specification
MCP Python SDKgithub.com/modelcontextprotocol/python-sdk
FastMCP Docsgithub.com/jlowin/fastmcp
Example Serversgithub.com/modelcontextprotocol/servers
Claude Desktopclaude.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

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

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

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.