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.

Nobody tells it that order. It works out the chain from your tool descriptions alone, which is why Module 2's insistence on writing them carefully pays off here:

One prompt, four tool calls, no instructions about order

which files?one pathfull textUser prompt"Find posts mentioning Kubernet…search_codeReturns paths only — cheap, and…read_filePulls the full text of the one …draft-followup-postPrompt template supplies the ho…Draft postWritten from your actual file, …

Note what search_code deliberately does not do: return file contents. Thirty matching files returned in full would exhaust the context window before the model reached the reading step. Search returns addresses; read_file fetches exactly one. That division is the single most important design decision in this server.

The architecture you're building

GitHub MCP Server — Component Flow

Host (Claude Desktop)
Your MCP Server (github-mcp)
External

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>=2,<3",
  "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
from typing import Literal
from dotenv import load_dotenv
from mcp.server import MCPServer
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"),
)

mcp = MCPServer(name="github-mcp")


# ─── Tools ─────────────────────────────────────────────────────────────────
@mcp.tool()
async def search_code(repo: str, query: str, limit: int = 10) -> str:
    """Search for text/code matches inside a GitHub repository.

    Use this to find where concepts, functions, or phrases appear.
    `repo` is a slug like 'my-blog' or 'owner/repo'.
    """
    results = await gh.search_code(repo=repo, query=query, limit=limit)
    if not results:
        return "No matches."
    return "\n".join(
        f"- `{r['path']}` ({r['repo']}, score {r['score']:.1f}): {r['url']}"
        for r in results
    )


@mcp.tool()
async def read_file(repo: str, path: str, ref: str = "HEAD") -> str:
    """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.
    """
    return await gh.read_file(repo=repo, path=path, ref=ref)


@mcp.tool()
async def list_issues(
    repo: str,
    state: Literal["open", "closed", "all"] = "open",
    limit: int = 20,
) -> str:
    """List issues in a GitHub repository."""
    issues = await gh.list_issues(repo=repo, state=state, limit=limit)
    return "\n".join(
        f"#{i['number']} {i['title']} [{', '.join(i['labels'])}] — {i['url']}"
        for i in issues
    ) or "No issues."


@mcp.tool()
async def create_issue(repo: str, title: str, body: str) -> str:
    """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.
    """
    created = await gh.create_issue(repo=repo, title=title, body=body)
    return f"Created issue #{created['number']}: {created['url']}"


if __name__ == "__main__":
    mcp.run()

Compare this against the schemas you would have hand-written: the Literal on state becomes the enum, the defaults become the defaults, and the docstrings become the descriptions the model reads. There is nothing left that can drift between the schema and the function, because there is only one of them now.

On error handling, note what changed from the earlier draft of this server. It wrapped every tool in one try/except Exception that returned the error as ordinary text. That looks defensive, and it costs you the isError flag: the host cannot tell a failed call from a successful one whose answer happens to start with "Error:". Let genuine failures raise — the SDK marks the result as an error and still hands the message to the model, which is what you wanted. Catch only the exceptions you can say something useful about:

@mcp.tool()
async def read_file(repo: str, path: str, ref: str = "HEAD") -> str:
    """Read the full contents of a file from a GitHub repository."""
    try:
        return await gh.read_file(repo=repo, path=path, ref=ref)
    except FileNotFoundError:
        # Actionable: tell the model how to recover rather than just failing
        raise MCPError(
            code=-32602,
            message=f"No file at {path} on {ref}. Run search_code first to find the real path.",
        )

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:

@mcp.prompt()
async def draft_followup_post(repo: str, original_path: str, angle: str) -> str:
    """Draft a follow-up blog post based on an existing post in a repo."""
    return (
        f"Read the post at {original_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."
    )

The prompt's three arguments come straight from the signature, all required because none has a default. Note that the prompt does not fetch the post itself — it instructs the model to use your tool. That is the division of labour worth internalising: prompts compose tools, they do not replace them.

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 — authenticated GitHub REST requests are subject to a primary hourly rate limit, and the search endpoints have their own tighter secondary limit. Rather than hardcoding a number that GitHub can revise, read it off the response: every reply carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Add a token bucket in github_client.py that backs off when X-RateLimit-Remaining hits zero and resumes after the reset timestamp. Current figures are in GitHub's rate limit documentation.
  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
Was this lesson helpful?

Sign in to rate