Production MCP Systems
Capstone: Build Your Own GitHub MCP Server
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-blogrepo, find every post that mentionsKubernetespublished 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
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
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_tooldecorators (Module 2 Lesson 1)TextContentreturn 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:
- 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, andX-RateLimit-Reset. Add a token bucket ingithub_client.pythat backs off whenX-RateLimit-Remaininghits zero and resumes after the reset timestamp. Current figures are in GitHub's rate limit documentation. - 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) - Scope minimization — for a read-only MCP, use a PAT with only
public_reposcope. Dropcreate_issuefrom the tool list if write isn't needed. - Caching —
read_fileresults for the same(repo, path, ref)don't change often. Add a 5-minute LRU cache to cut latency.
Part 7 — Troubleshooting matrix
| Symptom | First check | Typical cause |
|---|---|---|
| Claude Desktop shows 0 tools | restart Claude Desktop after editing config | JSON syntax error or wrong absolute path |
401 Unauthorized on search/read | token in env vs config | token expired, or missing repo scope |
404 Not Found on read_file | check repo name + default owner | GITHUB_OWNER not set when passing bare repo name |
| Claude hallucinates file contents | look at stdio stderr logs | server crashed early — handshake failed silently |
search_code returns nothing but you know matches exist | GitHub's code search excludes forks & private by default | check the repo is indexed; wait for fresh GitHub indexing |
Build checkpoint — finish this before claiming the certificate
- Ship the server. Claude Desktop shows 4 tools + 1 prompt when you click the hammer icon.
- Ship a real call. Ask: "Search my
my-blogrepo for 'Kubernetes'." Confirm Claude callssearch_codeand returns actual file paths. - Ship a multi-step call. Ask: "Find the oldest post about MCP and summarize it." Claude should chain
search_code→read_file→ summarize. - Ship the write path. Ask Claude to file an issue in a scratch repo. Confirm the new issue appears on github.com.
- 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. :::
Sign in to rate