07 · Wrapping a Real API: GitHub as an MCP Server¶
Most MCP servers you'll build at work aren't inventing new functionality — they're exposing an API that already exists, in a shape a model can call safely. GitHub's REST API is a good real target: well-documented, widely useful, and realistic about the things you actually have to handle (auth, rate limits, pagination).
Setup¶
pip install "mcp[cli]" httpx
export GITHUB_TOKEN="ghp_..." # a personal access token with repo scope
# github_server.py
import os
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("github-tools")
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
BASE_URL = "https://api.github.com"
def gh_headers() -> dict:
return {
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
Pulling the token from an environment variable, not a hardcoded string or a tool parameter, keeps the credential out of chat context entirely — the model never sees it, and it never ends up in a conversation log.
Listing issues¶
@mcp.tool()
async def list_issues(owner: str, repo: str, state: str = "open") -> str:
"""Lists issues for a GitHub repo. state is 'open', 'closed', or 'all'."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{BASE_URL}/repos/{owner}/{repo}/issues",
headers=gh_headers(),
params={"state": state, "per_page": 20},
)
resp.raise_for_status()
issues = resp.json()
if not issues:
return f"No {state} issues in {owner}/{repo}."
lines = [f"#{i['number']}: {i['title']}" for i in issues]
return "\n".join(lines)
Creating an issue¶
@mcp.tool()
async def create_issue(owner: str, repo: str, title: str, body: str = "") -> str:
"""Creates a new issue in a GitHub repo."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{BASE_URL}/repos/{owner}/{repo}/issues",
headers=gh_headers(),
json={"title": title, "body": body},
)
resp.raise_for_status()
issue = resp.json()
return f"Created issue #{issue['number']}: {issue['html_url']}"
Handling rate limits¶
GitHub returns rate-limit information in response headers on every request — don't wait to hit a 403 to find out you're close to the limit:
async def gh_request(client: httpx.AsyncClient, method: str, url: str, **kwargs):
resp = await client.request(method, url, headers=gh_headers(), **kwargs)
remaining = int(resp.headers.get("x-ratelimit-remaining", "1"))
if remaining < 5:
reset_at = resp.headers.get("x-ratelimit-reset")
raise RuntimeError(
f"GitHub rate limit nearly exhausted ({remaining} left, resets at {reset_at})."
)
resp.raise_for_status()
return resp
Route both tools above through this helper instead of calling client.get/
client.post directly, and a model that tries to hammer the API in a tight
loop gets a clear error — "rate limit nearly exhausted" — instead of GitHub
silently throttling or blocking the underlying token.
What makes this a good API wrapper, not just a thin proxy¶
- Tool names and descriptions describe intent (
create_issue), not raw endpoints (POST /repos/{owner}/{repo}/issues) — the model shouldn't need to know GitHub's URL structure to use this server. - Only expose what's actually safe to expose. This server deliberately
has no
delete_repotool — not every endpoint an API offers belongs in front of a model. - Summarized, not raw, responses.
list_issuesreturns a short text summary instead of dumping GitHub's full, deeply nested JSON response — that raw payload would burn a lot of the model's context for little benefit.
Exercise¶
Add a comment_on_issue(owner: str, repo: str, issue_number: int, body: str)
tool using GitHub's POST /repos/{owner}/{repo}/issues/{issue_number}/comments
endpoint, routed through the gh_request rate-limit helper. Test it end to
end against a real (preferably disposable/test) repository through the
Inspector: list issues, pick one, comment on it, then list issues again and
confirm the comment count changed.