Production MCP Systems

Capstone: Build Your Own GitHub MCP Server

50 min read

Outcome: By the end of this lesson you will have a working MCP server running in Claude Desktop that exposes your own GitHub repos as tools — Claude can search your code, read any file, list open issues, and draft pull requests, grounded in your actual codebase.

This capstone ties together every module: the JSON-RPC handshake (M1), server basics (M2), tools and resources (M3), authentication (M4), and production hardening (M5).

What you'll ship — this prompt works in Claude Desktop when you're done:

"In my my-blog repo, find every post that mentions Kubernetes published after 2026-03-01, read the oldest one, and draft a follow-up post incorporating the new 1.30 CSI changes."

Claude will call search_code, read_file, and create_draft_post on your server — grounded in your repos, not hallucination.

The architecture you're building

{
  "type": "architecture",
  "title": "GitHub MCP Server — Component Flow",
  "direction": "top-down",
  "layers": [
    {
      "label": "Host (Claude Desktop)",
      "color": "blue",
      "components": [
        { "label": "Claude", "description": "The LLM — decides which tool to call" },
        { "label": "MCP Client", "description": "Built into Claude Desktop; speaks JSON-RPC over stdio" }
      ]
    },
    {
      "label": "Your MCP Server (github-mcp)",
      "color": "pink",
      "components": [
        { "label": "Tool dispatcher", "description": "@server.list_tools + @server.call_tool" },
        { "label": "search_code", "description": "Text search across your repos" },
        { "label": "read_file", "description": "Read any file at any ref" },
        { "label": "list_issues / create_issue", "description": "Read + write issue tracker" },
        { "label": "draft-followup-post prompt", "description": "Reusable prompt template" }
      ]
    },
    {
      "label": "External",
      "color": "amber",
      "components": [
        { "label": "GitHub REST API", "description": "api.github.com — auth via Personal Access Token" },
        { "label": "Your repositories", "description": "Code + issues + PRs" }
      ]
    }
  ]
}

Part 1 — Project setup (5 min)

github-mcp/
├── server.py           # The MCP server
├── github_client.py    # GitHub API wrapper
├── pyproject.toml
├── .env.example
└── README.md

pyproject.toml:

[project]
name = "github-mcp"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
  "mcp>=1.1.0",
  "httpx>=0.27",
  "python-dotenv>=1.0",
  "pydantic>=2",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

.env.example:

GITHUB_TOKEN=ghp_...           # Personal access token with repo scope
GITHUB_OWNER=your-username     # Default owner for tool calls

Create a GitHub PAT with repo scope (read/write on private repos). Paste it into .env.

Install:

pip install -e .

Part 2 — The GitHub client (10 min)

GitHub's REST API is straightforward. No SDK needed; httpx is enough.

github_client.py:

import os
import httpx
from typing import Any

GITHUB_API = "https://api.github.com"


class GitHubClient:
    def __init__(self, token: str, default_owner: str | None = None):
        self._token = token
        self._default_owner = default_owner

    def _headers(self) -> dict[str, str]:
        return {
            "Authorization": f"Bearer {self._token}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
        }

    async def search_code(self, repo: str, query: str, limit: int = 10) -> list[dict[str, Any]]:
        """Search for code/text inside a repo. Returns snippet matches."""
        q = f"{query} repo:{self._resolve_repo(repo)}"
        async with httpx.AsyncClient(timeout=15) as c:
            r = await c.get(f"{GITHUB_API}/search/code",
                            params={"q": q, "per_page": limit},
                            headers=self._headers())
            r.raise_for_status()
            items = r.json().get("items", [])
            return [{
                "path": i["path"],
                "repo": i["repository"]["full_name"],
                "url": i["html_url"],
                "score": i["score"],
            } for i in items]

    async def read_file(self, repo: str, path: str, ref: str = "HEAD") -> str:
        """Read a file's raw contents."""
        repo = self._resolve_repo(repo)
        async with httpx.AsyncClient(timeout=15) as c:
            r = await c.get(f"{GITHUB_API}/repos/{repo}/contents/{path}",
                            params={"ref": ref},
                            headers=self._headers())
            r.raise_for_status()
            import base64
            data = r.json()
            return base64.b64decode(data["content"]).decode("utf-8", errors="replace")

    async def list_issues(self, repo: str, state: str = "open", limit: int = 20) -> list[dict[str, Any]]:
        repo = self._resolve_repo(repo)
        async with httpx.AsyncClient(timeout=15) as c:
            r = await c.get(f"{GITHUB_API}/repos/{repo}/issues",
                            params={"state": state, "per_page": limit},
                            headers=self._headers())
            r.raise_for_status()
            return [{
                "number": i["number"],
                "title": i["title"],
                "body": (i.get("body") or "")[:1000],
                "labels": [l["name"] for l in i.get("labels", [])],
                "url": i["html_url"],
            } for i in r.json() if "pull_request" not in i]

    async def create_issue(self, repo: str, title: str, body: str) -> dict[str, Any]:
        """Create a new issue. Requires `repo` scope on the token."""
        repo = self._resolve_repo(repo)
        async with httpx.AsyncClient(timeout=15) as c:
            r = await c.post(f"{GITHUB_API}/repos/{repo}/issues",
                             json={"title": title, "body": body},
                             headers=self._headers())
            r.raise_for_status()
            i = r.json()
            return {"number": i["number"], "url": i["html_url"]}

    def _resolve_repo(self, repo: str) -> str:
        """Accepts 'owner/repo' or 'repo' (uses default owner)."""
        if "/" in repo:
            return repo
        if not self._default_owner:
            raise ValueError(f"Repo '{repo}' has no owner and no GITHUB_OWNER set")
        return f"{self._default_owner}/{repo}"

Why no PyGithub? Two reasons: (1) the MCP server needs to run in Claude Desktop's stdio process, and lighter dependencies = faster cold start; (2) the REST API surface we need is 4 endpoints — adding a 200KB SDK for that is overkill.


Part 3 — The MCP server (15 min)

server.py:

import os
import asyncio
from dotenv import load_dotenv
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from github_client import GitHubClient

load_dotenv()

# ─── Initialize the GitHub client once at startup ──────────────────────────
gh = GitHubClient(
    token=os.environ["GITHUB_TOKEN"],
    default_owner=os.environ.get("GITHUB_OWNER"),
)

server = Server("github-mcp")


# ─── Tool schemas ──────────────────────────────────────────────────────────
@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="search_code",
            description=(
                "Search for text/code matches inside a GitHub repository. "
                "Use this to find where concepts, functions, or phrases appear."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "repo": {"type": "string", "description": "repo slug, e.g. 'my-blog' or 'owner/repo'"},
                    "query": {"type": "string", "description": "text to search for"},
                    "limit": {"type": "integer", "description": "max results (default 10)"},
                },
                "required": ["repo", "query"],
            },
        ),
        Tool(
            name="read_file",
            description=(
                "Read the full contents of a file from a GitHub repository. "
                "Use this after search_code to pull the actual text. "
                "`ref` can be a branch, tag, or commit SHA."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "repo": {"type": "string"},
                    "path": {"type": "string", "description": "file path relative to repo root"},
                    "ref": {"type": "string", "description": "branch/tag/SHA (default HEAD)"},
                },
                "required": ["repo", "path"],
            },
        ),
        Tool(
            name="list_issues",
            description="List open (or closed) issues in a GitHub repository.",
            inputSchema={
                "type": "object",
                "properties": {
                    "repo": {"type": "string"},
                    "state": {"type": "string", "enum": ["open", "closed", "all"]},
                    "limit": {"type": "integer"},
                },
                "required": ["repo"],
            },
        ),
        Tool(
            name="create_issue",
            description=(
                "Create a new GitHub issue. Requires write access on the token. "
                "Use only when the user explicitly asks to file an issue or draft a task."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "repo": {"type": "string"},
                    "title": {"type": "string"},
                    "body": {"type": "string", "description": "issue body (markdown supported)"},
                },
                "required": ["repo", "title", "body"],
            },
        ),
    ]


# ─── Tool dispatcher ───────────────────────────────────────────────────────
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    try:
        if name == "search_code":
            results = await gh.search_code(
                repo=arguments["repo"],
                query=arguments["query"],
                limit=arguments.get("limit", 10),
            )
            if not results:
                return [TextContent(type="text", text="No matches.")]
            lines = [f"- `{r['path']}` ({r['repo']}, score {r['score']:.1f}): {r['url']}" for r in results]
            return [TextContent(type="text", text="\n".join(lines))]

        if name == "read_file":
            text = await gh.read_file(
                repo=arguments["repo"],
                path=arguments["path"],
                ref=arguments.get("ref", "HEAD"),
            )
            return [TextContent(type="text", text=text)]

        if name == "list_issues":
            issues = await gh.list_issues(
                repo=arguments["repo"],
                state=arguments.get("state", "open"),
                limit=arguments.get("limit", 20),
            )
            lines = [f"#{i['number']} {i['title']} [{', '.join(i['labels'])}] — {i['url']}" for i in issues]
            return [TextContent(type="text", text="\n".join(lines) or "No issues.")]

        if name == "create_issue":
            created = await gh.create_issue(
                repo=arguments["repo"],
                title=arguments["title"],
                body=arguments["body"],
            )
            return [TextContent(type="text", text=f"Created issue #{created['number']}: {created['url']}")]

        return [TextContent(type="text", text=f"Unknown tool: {name}")]

    except Exception as e:
        # Return errors as tool_result content so Claude can recover,
        # not as Python exceptions that crash the server.
        return [TextContent(type="text", text=f"Error: {type(e).__name__}: {e}")]


async def main():
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream, server.create_initialization_options())


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

Key patterns from the earlier modules:

  • list_tools + call_tool decorators (Module 2 Lesson 1)
  • TextContent return shape (Module 2 Lesson 2)
  • Errors returned as tool_result, not raised (Module 2 Lesson 3)
  • stdio transport for local Claude Desktop (Module 4 Lesson 1)

Part 4 — Wire it into Claude Desktop (5 min)

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or the equivalent on Windows/Linux:

{
  "mcpServers": {
    "github": {
      "command": "python",
      "args": ["/absolute/path/to/github-mcp/server.py"],
      "env": {
        "GITHUB_TOKEN": "ghp_...",
        "GITHUB_OWNER": "your-username"
      }
    }
  }
}

Restart Claude Desktop. You should see a new hammer icon indicating your MCP server is connected.

Quick sanity check: ask Claude: "Use the list_issues tool on my my-blog repo." If it returns your actual issues, handshake + transport are working.


Part 5 — Add a prompt template (5 min)

MCP also exposes prompts — reusable templates Claude can offer the user. Add one for drafting follow-up posts:

from mcp.types import Prompt, PromptArgument, GetPromptResult, PromptMessage, TextContent as PromptTextContent


@server.list_prompts()
async def list_prompts():
    return [
        Prompt(
            name="draft-followup-post",
            description="Draft a follow-up blog post based on an existing post in a repo",
            arguments=[
                PromptArgument(name="repo", description="Repo containing the original post", required=True),
                PromptArgument(name="original_path", description="Path to the original .md file", required=True),
                PromptArgument(name="angle", description="The new angle/update to cover", required=True),
            ],
        ),
    ]


@server.get_prompt()
async def get_prompt(name: str, arguments: dict) -> GetPromptResult:
    if name != "draft-followup-post":
        raise ValueError(f"Unknown prompt: {name}")

    repo = arguments["repo"]
    path = arguments["original_path"]
    angle = arguments["angle"]

    return GetPromptResult(
        description=f"Draft follow-up to {path} about: {angle}",
        messages=[
            PromptMessage(
                role="user",
                content=PromptTextContent(
                    type="text",
                    text=(
                        f"Read the post at {path} in the {repo} repo using the read_file tool. "
                        f"Then write a follow-up post that covers: {angle}. "
                        f"Match the tone of the original. Keep frontmatter YAML compatible."
                    ),
                ),
            ),
        ],
    )

Now in Claude Desktop the user can pick this prompt from the UI, fill in the three arguments, and Claude does the rest — reading the original, generating the follow-up — all via your server.


Part 6 — Production hardening (optional, 10 min)

Everything from Module 5 lessons 1–3 applies here:

  1. Rate limiting — GitHub's REST API allows 5000 requests/hour with a PAT. For heavier use, add a simple token bucket in github_client.py that sleeps when headers say X-RateLimit-Remaining: 0.
  2. Logging — pipe stdio stderr to a file so you can debug without spamming Claude Desktop logs:
    import logging
    logging.basicConfig(filename="/tmp/github-mcp.log", level=logging.INFO)
    
  3. Scope minimization — for a read-only MCP, use a PAT with only public_repo scope. Drop create_issue from the tool list if write isn't needed.
  4. Cachingread_file results for the same (repo, path, ref) don't change often. Add a 5-minute LRU cache to cut latency.

Part 7 — Troubleshooting matrix

SymptomFirst checkTypical cause
Claude Desktop shows 0 toolsrestart Claude Desktop after editing configJSON syntax error or wrong absolute path
401 Unauthorized on search/readtoken in env vs configtoken expired, or missing repo scope
404 Not Found on read_filecheck repo name + default ownerGITHUB_OWNER not set when passing bare repo name
Claude hallucinates file contentslook at stdio stderr logsserver crashed early — handshake failed silently
search_code returns nothing but you know matches existGitHub's code search excludes forks & private by defaultcheck the repo is indexed; wait for fresh GitHub indexing

Build checkpoint — finish this before claiming the certificate

  1. Ship the server. Claude Desktop shows 4 tools + 1 prompt when you click the hammer icon.
  2. Ship a real call. Ask: "Search my my-blog repo for 'Kubernetes'." Confirm Claude calls search_code and returns actual file paths.
  3. Ship a multi-step call. Ask: "Find the oldest post about MCP and summarize it." Claude should chain search_coderead_file → summarize.
  4. Ship the write path. Ask Claude to file an issue in a scratch repo. Confirm the new issue appears on github.com.
  5. Screenshot Claude Desktop showing the hammer icon + a successful tool call. That's your proof of work.

You've just shipped an MCP server that gives Claude structured, authenticated access to your own code. Every pattern from the last five modules is now running in production.

Next (optional): extend this server with a second transport (Streamable HTTP) so it works from a hosted chat, not just Claude Desktop. :::

Quiz

Module 5 Quiz: Production MCP Systems

Take Quiz
FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.