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.
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_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:
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:
- Rate limiting — GitHub's REST API allows 5000 requests/hour with a PAT. For heavier use, add a simple token bucket in
github_client.pythat sleeps when headers sayX-RateLimit-Remaining: 0. - 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. :::