Guide · SaaS Integration
MCP server GitHub API integration
GitHub is one of the richest MCP tool targets: agents can search repositories, create and triage issues, review pull requests, read file contents, trigger workflows, and query commit history through natural language. The GitHub API has two patterns (REST and GraphQL) and three auth models (GitHub App, OAuth App, PAT) — and two distinct rate limit systems that have caught many integrations off guard. This guide covers the right choices for MCP tool wrappers.
TL;DR
Use @octokit/rest for REST operations and @octokit/graphql when you need to batch related fields or access GraphQL-only APIs. GitHub Apps give 5,000 req/hr per installation and scale better than PATs for multi-user deployments. Watch for secondary rate limits (not just the primary 5,000 req/hr): mutation-heavy tools (issue creation, PR comments) are subject to per-minute concurrency limits with Retry-After headers. Paginate with Octokit's paginate helper and set result caps to avoid blowing LLM context budgets. Wire AliveMCP to monitor your MCP server — a GitHub API outage means your tools return errors, and you need to know before your users do.
Setup and authentication options
npm install @octokit/rest @octokit/graphql zod
| Auth type | Rate limit | Best for | Expiry |
|---|---|---|---|
| GitHub App (Installation token) | 5,000 req/hr per installation | Multi-user SaaS MCP servers, organization access | 1 hour (auto-refresh) |
| OAuth App (User access token) | 5,000 req/hr per user | User-authorized access to their own repos | Varies (can be long-lived) |
| Fine-grained PAT | 5,000 req/hr | Personal tools, single-user MCP servers | Up to 1 year |
| Classic PAT | 5,000 req/hr | Legacy; prefer fine-grained PATs | Up to 1 year |
GitHub App (recommended for production)
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
// App-level client — used to get installation tokens
const appOctokit = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!.replace(/\\n/g, "\n"),
},
});
// Get an installation token for a specific org/user installation
async function octokitForInstallation(installationId: number) {
const { token } = await appOctokit.auth({
type: "installation",
installationId,
}) as { token: string };
return new Octokit({ auth: token });
}
PAT (simple single-user setup)
import { Octokit } from "@octokit/rest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
userAgent: "my-mcp-server/1.0",
// Throttle retries on secondary rate limits automatically
throttle: {
onRateLimit: (retryAfter: number, options: { method: string; url: string }, _octokit: unknown, retryCount: number) => {
console.warn(`Rate limit hit for ${options.method} ${options.url}, retrying after ${retryAfter}s`);
return retryCount < 3; // retry up to 3 times
},
onSecondaryRateLimit: (retryAfter: number, options: { method: string; url: string }) => {
console.warn(`Secondary rate limit for ${options.method} ${options.url}`);
return true; // always retry secondary limits
},
},
});
// Note: throttle plugin requires @octokit/plugin-throttling
const server = new McpServer({ name: "github-tools", version: "1.0.0" });
Repository and file tools
server.tool(
"search_repositories",
"Search GitHub repositories by keyword, language, and stars",
{
query: z.string().describe("Search query — supports GitHub search syntax (language:ts stars:>100)"),
sort: z.enum(["stars", "forks", "help-wanted-issues", "updated"]).default("stars"),
order: z.enum(["asc", "desc"]).default("desc"),
per_page: z.number().int().min(1).max(30).default(10),
},
async ({ query, sort, order, per_page }) => {
try {
const result = await octokit.search.repos({ q: query, sort, order, per_page });
const repos = result.data.items.map(repo => ({
full_name: repo.full_name,
description: repo.description,
stars: repo.stargazers_count,
language: repo.language,
url: repo.html_url,
updated_at: repo.updated_at,
topics: repo.topics,
open_issues: repo.open_issues_count,
}));
return {
content: [{
type: "text",
text: JSON.stringify({ total_count: result.data.total_count, repos }),
}],
};
} catch (err) {
return githubErrorToToolResult(err);
}
}
);
server.tool(
"get_file_contents",
"Read a file's contents from a GitHub repository",
{
owner: z.string(),
repo: z.string(),
path: z.string().describe("File path relative to repo root"),
ref: z.string().default("HEAD").describe("Branch, tag, or commit SHA"),
},
async ({ owner, repo, path, ref }) => {
try {
const result = await octokit.repos.getContent({ owner, repo, path, ref });
if (Array.isArray(result.data)) {
// It's a directory listing, not a file
const entries = result.data.map(e => ({ name: e.name, type: e.type, size: e.size, path: e.path }));
return { content: [{ type: "text", text: JSON.stringify({ type: "directory", entries }) }] };
}
const file = result.data as { type: string; content?: string; encoding?: string; size: number; name: string };
if (file.type !== "file") {
return { isError: true, content: [{ type: "text", text: `Not a file: ${path} is type ${file.type}` }] };
}
if (file.size > 500_000) {
return { isError: true, content: [{ type: "text", text: `File too large (${file.size} bytes) to return in a single tool response` }] };
}
const content = file.content
? Buffer.from(file.content, (file.encoding ?? "base64") as BufferEncoding).toString("utf-8")
: "(no content)";
return {
content: [{ type: "text", text: JSON.stringify({ path: file.name, size: file.size, content: content.slice(0, 50000) }) }],
};
} catch (err) {
return githubErrorToToolResult(err);
}
}
);
Issue and PR tools
server.tool(
"create_github_issue",
"Create a GitHub issue in a repository",
{
owner: z.string(),
repo: z.string(),
title: z.string().max(256),
body: z.string().max(65536).optional(),
labels: z.array(z.string()).max(10).optional(),
assignees: z.array(z.string()).max(10).optional(),
milestone: z.number().int().optional().describe("Milestone number"),
},
async ({ owner, repo, title, body, labels, assignees, milestone }) => {
try {
const issue = await octokit.issues.create({
owner, repo, title, body, labels, assignees, milestone,
});
return {
content: [{
type: "text",
text: JSON.stringify({
number: issue.data.number,
html_url: issue.data.html_url,
state: issue.data.state,
created_at: issue.data.created_at,
}),
}],
};
} catch (err) {
return githubErrorToToolResult(err);
}
}
);
server.tool(
"list_pull_requests",
"List open pull requests with optional filtering",
{
owner: z.string(),
repo: z.string(),
state: z.enum(["open", "closed", "all"]).default("open"),
base: z.string().optional().describe("Filter by base branch (e.g. 'main')"),
per_page: z.number().int().min(1).max(50).default(20),
},
async ({ owner, repo, state, base, per_page }) => {
try {
const result = await octokit.pulls.list({ owner, repo, state, base, per_page });
const prs = result.data.map(pr => ({
number: pr.number,
title: pr.title,
state: pr.state,
author: pr.user?.login,
created_at: pr.created_at,
updated_at: pr.updated_at,
base: pr.base.ref,
head: pr.head.ref,
draft: pr.draft,
url: pr.html_url,
mergeable_state: pr.mergeable_state,
review_comments: pr.review_comments,
labels: pr.labels.map(l => l.name),
}));
return {
content: [{ type: "text", text: JSON.stringify({ prs, count: prs.length }) }],
};
} catch (err) {
return githubErrorToToolResult(err);
}
}
);
Code search tool
GitHub's code search is powerful but has specific constraints: results are limited to 1,000 total, only the default branch is indexed for most repositories, and forked repos may have reduced coverage. Use qualifier syntax to narrow results:
server.tool(
"search_github_code",
"Search code across GitHub repositories",
{
query: z.string().describe(
"Search query with optional qualifiers: repo:owner/name, language:typescript, path:src/, extension:ts"
),
per_page: z.number().int().min(1).max(20).default(10),
},
async ({ query, per_page }) => {
try {
// Code search requires authenticated request — unauthenticated gets 10 req/min
const result = await octokit.search.code({ q: query, per_page });
const items = result.data.items.map(item => ({
repository: item.repository.full_name,
path: item.path,
name: item.name,
url: item.html_url,
// text_matches only available with Accept: application/vnd.github.text-match+json
sha: item.sha,
}));
return {
content: [{
type: "text",
text: JSON.stringify({
total_count: result.data.total_count,
items,
note: result.data.total_count > 1000 ? "GitHub limits code search to 1000 results — refine the query" : undefined,
}),
}],
};
} catch (err) {
return githubErrorToToolResult(err);
}
}
);
Code search has stricter rate limits than the general API: 10 requests per minute for authenticated users. Use the repo: qualifier to restrict to a specific repository when possible — it's faster and less likely to hit limits than broad searches.
Rate limits: primary vs secondary
GitHub has two distinct rate limit systems that behave differently:
| Limit type | Limit | Resets | Response header |
|---|---|---|---|
| Primary (REST) | 5,000 req/hr (GitHub App: per installation) | After 1 hour window | X-RateLimit-Remaining |
| Primary (GraphQL) | 5,000 points/hr (queries cost 1, mutations cost more) | After 1 hour window | X-RateLimit-Remaining |
| Secondary (concurrency) | No fixed number — based on CPU time and concurrency | After Retry-After seconds | Retry-After |
| Code search | 10 req/min authenticated, 1 req/min unauthenticated | After 1 minute | X-RateLimit-Remaining |
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { RequestError } from "@octokit/request-error";
function githubErrorToToolResult(err: unknown): CallToolResult {
if (err instanceof RequestError) {
if (err.status === 403) {
const retryAfter = err.response?.headers?.["retry-after"];
if (retryAfter) {
return {
isError: true,
content: [{ type: "text", text: `GitHub secondary rate limit hit — retry after ${retryAfter} seconds` }],
};
}
return { isError: true, content: [{ type: "text", text: `GitHub permission denied: ${err.message}` }] };
}
if (err.status === 429) {
const retryAfter = err.response?.headers?.["retry-after"] ?? "60";
return { isError: true, content: [{ type: "text", text: `GitHub rate limit exceeded — retry after ${retryAfter} seconds` }] };
}
if (err.status === 404) {
return { isError: true, content: [{ type: "text", text: `GitHub resource not found — check the owner, repo, and resource ID. Also verify the token has access to private repos if needed.` }] };
}
if (err.status === 422) {
return { isError: true, content: [{ type: "text", text: `GitHub validation failed: ${err.message}` }] };
}
return { isError: true, content: [{ type: "text", text: `GitHub API error (${err.status}): ${err.message}` }] };
}
throw err;
}
Pagination with Octokit
GitHub paginates most list endpoints with 30 items by default (up to 100 per page). Octokit's paginate helper auto-follows Link headers, but for MCP tools you should cap the result set to avoid context overflows:
server.tool(
"list_repo_issues",
"List issues in a repository with filtering",
{
owner: z.string(),
repo: z.string(),
state: z.enum(["open", "closed", "all"]).default("open"),
labels: z.string().optional().describe("Comma-separated label names to filter by"),
assignee: z.string().optional().describe("Username of the assignee"),
max_results: z.number().int().min(1).max(100).default(20),
},
async ({ owner, repo, state, labels, assignee, max_results }) => {
try {
// paginate auto-fetches until we hit max_results
const issues = await octokit.paginate(
octokit.issues.listForRepo,
{ owner, repo, state, labels, assignee, per_page: Math.min(max_results, 100) },
(response, done) => {
if (response.data.length >= max_results) done();
return response.data;
}
);
const trimmed = issues.slice(0, max_results).map(issue => ({
number: issue.number,
title: issue.title,
state: issue.state,
author: issue.user?.login,
labels: issue.labels.map(l => typeof l === "string" ? l : l.name),
created_at: issue.created_at,
updated_at: issue.updated_at,
url: issue.html_url,
comments: issue.comments,
// Exclude body — it can be very long and flood context
}));
return {
content: [{ type: "text", text: JSON.stringify({ issues: trimmed, count: trimmed.length }) }],
};
} catch (err) {
return githubErrorToToolResult(err);
}
}
);
Never auto-paginate without a cap in MCP tools. A repository with 3,000 open issues would exhaust rate limits, take minutes to fetch, and produce a response far too large for any LLM context window. Instead, return the first N results with a has_more flag and let the agent decide whether to fetch more.
GraphQL for batched queries
When you need multiple related fields in one request (e.g., PR + reviews + comments + check status), GraphQL saves round trips:
import { graphql } from "@octokit/graphql";
const graphqlWithAuth = graphql.defaults({
headers: { authorization: `token ${process.env.GITHUB_TOKEN}` },
});
server.tool(
"get_pr_summary",
"Get a pull request's full summary including review status and CI checks",
{
owner: z.string(),
repo: z.string(),
pr_number: z.number().int().positive(),
},
async ({ owner, repo, pr_number }) => {
try {
const data = await graphqlWithAuth<{
repository: {
pullRequest: {
title: string;
state: string;
author: { login: string };
reviews: { nodes: Array<{ state: string; author: { login: string } }> };
commits: { nodes: Array<{ commit: { statusCheckRollup: { state: string } | null } }> };
mergeable: string;
};
};
}>(`
query PrSummary($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
title
state
mergeable
author { login }
reviews(last: 10) {
nodes { state author { login } }
}
commits(last: 1) {
nodes {
commit {
statusCheckRollup { state }
}
}
}
}
}
}
`, { owner, repo, number: pr_number });
const pr = data.repository.pullRequest;
return {
content: [{
type: "text",
text: JSON.stringify({
title: pr.title,
state: pr.state,
mergeable: pr.mergeable,
author: pr.author.login,
reviews: pr.reviews.nodes.map(r => ({ reviewer: r.author.login, state: r.state })),
ci_status: pr.commits.nodes[0]?.commit.statusCheckRollup?.state ?? "unknown",
}),
}],
};
} catch (err) {
return githubErrorToToolResult(err);
}
}
);
Frequently asked questions
When should I use a GitHub App vs a PAT for an MCP server?
Use a GitHub App when your MCP server will serve multiple users (each user authorizes the App for their account or organization) or when you need higher rate limits (GitHub Apps get 5,000 req/hr per installation, not shared across users). Use a fine-grained PAT for personal tools or single-user MCP servers where deployment complexity isn't worth the App setup overhead. Never use a classic PAT for new projects — fine-grained PATs let you scope permissions to specific repositories and actions, reducing the blast radius if the token is leaked.
Why am I getting 403 errors that aren't rate limit exceeded?
Two common causes: (1) the token doesn't have the required scope — fine-grained PATs need explicit repository permissions (Contents, Issues, Pull Requests, etc.) for each operation. (2) Secondary rate limits — these also return 403 with a Retry-After header, not 429. Check the response headers: if Retry-After is present, it's a secondary rate limit; if X-RateLimit-Remaining is 0, it's the primary limit. If neither, it's a genuine permission error. Common permission gaps: creating issues requires the Issues write permission; reading private repo contents requires the Contents read permission.
How do I handle GitHub's 422 Unprocessable Entity errors?
422 errors come from validation failures — typically invalid field values, referencing non-existent labels/milestones/assignees, or using the wrong format for a field. The response body contains an errors array with field and code for each failure. Surface the errors array to the LLM so it can correct the tool arguments. Common causes: label that doesn't exist in the repo (create it first or fix the name), assignee who isn't a collaborator, milestone number that doesn't exist.
Can I add commit comments or PR review comments via MCP tools?
Yes. Use octokit.pulls.createReviewComment for inline PR review comments (requires specifying path, line, and commit_id). Use octokit.issues.createComment for general PR comments (PRs are also issues — use the PR number as the issue number). Creating a full review with approve/request-changes uses octokit.pulls.createReview. Be careful with agent-automated reviews: if the agent submits a review with "APPROVE" without human oversight, you've automated away a safety gate. Consider defaulting to "COMMENT" review type and requiring explicit human confirmation for APPROVE or REQUEST_CHANGES.
How do I monitor for webhook events (new PR, new issue) in an MCP tool context?
MCP tools are pull-based — they respond to requests, not push events. The correct pattern is polling: expose a tool like list_recent_events that queries recent activity (new issues, new PRs since a timestamp) and returns them. Store the last-checked timestamp between calls. For real-time webhook processing, receive webhooks in a separate endpoint, store events in a database, and expose a polling tool that reads from that database. See the webhook-to-polling pattern in the Stripe integration guide — the same pattern applies here.
Further reading
- MCP server GitHub Actions integration — triggering workflows and reading CI status
- MCP server authentication — OAuth2 and API key patterns
- MCP server pagination — cursor and offset patterns for large result sets
- MCP server Jira integration — JQL queries and issue management
- MCP server rate limiting — per-user limits and backpressure
- AliveMCP — production protocol monitoring for MCP servers