GitHub is one of the most-integrated systems in modern engineering — issues, pull requests, code search, releases. Connecting it to your AI assistant via Model Context Protocol (MCP) is one of those changes that, after a week of use, feels like you can never go back.
This tutorial walks through building a custom GitHub MCP server in Node.js with proper authentication, real GitHub API calls via Octokit, pagination, and rate-limit handling. By the end you will have a server that exposes a useful subset of GitHub's surface to any MCP-aware AI client.
What we will build
A server with four tools:
search_issues— find issues matching a query across one or many reposget_pull_request— fetch the full body and metadata of a specific PRlist_recent_commits— list the most recent commits on a branchcreate_issue— open a new issue (write access — guarded)
Not a complete GitHub clone, but enough to demonstrate every pattern you will need to extend.
Setup
mkdir github-mcp
cd github-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod @octokit/rest
Add "type": "module" to package.json. Octokit is GitHub's official JavaScript client — handles auth, pagination, retries, rate limits.
Authentication: Personal Access Token
For a personal-use MCP server, the simplest auth scheme is a GitHub Personal Access Token (PAT) stored in the server's environment.
Create a PAT at https://github.com/settings/tokens with these scopes (adjust to taste):
repo— read/write to repos you have access toread:org— read org dataread:user— read user profile data
For a server that only reads public repos, you do not need a PAT at all (unauthenticated calls work, but with a low rate limit of 60/hr). With a PAT you get 5,000 requests/hr.
Store the token in your Claude Desktop config:
{
"mcpServers": {
"github": {
"command": "node",
"args": ["/absolute/path/to/server.js"],
"env": { "GITHUB_TOKEN": "ghp_yourTokenHere" }
}
}
}
The server reads it once at startup. The token never lives in your code.
The server skeleton
Create server.js:
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { Octokit } from '@octokit/rest';
import { z } from 'zod';
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const server = new McpServer({ name: 'github-helper', version: '1.0.0' });
// Tools go here
await server.connect(new StdioServerTransport());
If GITHUB_TOKEN is undefined, Octokit just makes unauthenticated requests. We will accept that for read tools but block write tools.
Tool 1: search_issues
GitHub has a powerful issue search syntax (is:open, label:bug, etc.). Expose it directly:
server.tool(
'search_issues',
'Search GitHub issues and pull requests using GitHub search syntax. Examples: "is:open label:bug repo:owner/repo", "author:octocat type:pr".',
{
query: z.string().describe('GitHub issue search query'),
limit: z.number().int().min(1).max(50).default(10),
},
async ({ query, limit }) => {
const { data } = await octokit.search.issuesAndPullRequests({ q: query, per_page: limit });
if (!data.items.length) {
return { content: [{ type: 'text', text: `No matches for "${query}".` }] };
}
const lines = data.items.map(i =>
`#${i.number} [${i.state}] ${i.title}\n ${i.html_url}\n by ${i.user.login} · ${new Date(i.created_at).toISOString().slice(0, 10)}`
);
return {
content: [{ type: 'text', text: `${data.total_count} total results (showing ${data.items.length}):\n\n${lines.join('\n\n')}` }],
};
}
);
Three things worth noting:
- The description teaches the LLM the query syntax by including examples. This single change dramatically improves how well the LLM constructs queries.
per_pageis the limit — Octokit accepts the same parameter GitHub's REST API uses.- Formatted text output with URLs, dates, and authors is far more useful to the LLM than raw JSON.
Tool 2: get_pull_request
server.tool(
'get_pull_request',
'Fetch a pull request with its full description, files changed, and review status.',
{
owner: z.string().describe('Repo owner, e.g. "facebook"'),
repo: z.string().describe('Repo name, e.g. "react"'),
number: z.number().int().positive(),
},
async ({ owner, repo, number }) => {
const [{ data: pr }, { data: files }] = await Promise.all([
octokit.pulls.get({ owner, repo, pull_number: number }),
octokit.pulls.listFiles({ owner, repo, pull_number: number, per_page: 50 }),
]);
const fileLines = files.map(f => ` ${f.status.padEnd(8)} ${f.filename} (+${f.additions}/-${f.deletions})`);
const text = [
`PR #${pr.number}: ${pr.title}`,
`Author: ${pr.user.login} · State: ${pr.state} · Merged: ${pr.merged}`,
`Branch: ${pr.head.ref} → ${pr.base.ref}`,
`URL: ${pr.html_url}`,
'',
`Description:\n${pr.body || '(empty)'}`,
'',
`Files changed (${files.length}):`,
...fileLines,
].join('\n');
return { content: [{ type: 'text', text }] };
}
);
Notice the parallel Promise.all([pulls.get, pulls.listFiles]) — both API calls fire concurrently. Saves a round-trip per invocation.
Tool 3: list_recent_commits
Demonstrates pagination, since recent commits can span many pages:
server.tool(
'list_recent_commits',
'List the most recent commits on a branch.',
{
owner: z.string(),
repo: z.string(),
branch: z.string().default('main'),
limit: z.number().int().min(1).max(100).default(20),
},
async ({ owner, repo, branch, limit }) => {
const { data } = await octokit.repos.listCommits({ owner, repo, sha: branch, per_page: limit });
const lines = data.map(c =>
`${c.sha.slice(0, 7)} ${c.commit.message.split('\n')[0]}\n by ${c.commit.author.name} · ${new Date(c.commit.author.date).toISOString().slice(0, 10)}`
);
return { content: [{ type: 'text', text: `${data.length} recent commits on ${branch}:\n\n${lines.join('\n\n')}` }] };
}
);
Note how the description is concise but precise — the LLM does not need to know that GitHub paginates 30 by default; the tool's limit parameter handles it.
Tool 4: create_issue (write access)
This is the dangerous one. Write tools deserve extra care:
server.tool(
'create_issue',
'Open a new issue in a repository. Requires GITHUB_TOKEN with repo scope.',
{
owner: z.string(),
repo: z.string(),
title: z.string().min(1).max(256),
body: z.string().optional(),
labels: z.array(z.string()).optional(),
},
async ({ owner, repo, title, body, labels }) => {
if (!process.env.GITHUB_TOKEN) {
return {
content: [{ type: 'text', text: 'Cannot create issue: GITHUB_TOKEN is not set.' }],
isError: true,
};
}
const { data } = await octokit.issues.create({ owner, repo, title, body, labels });
return {
content: [{
type: 'text',
text: `Created issue #${data.number}: ${data.title}\n${data.html_url}`,
}],
};
}
);
Three write-tool safeguards in play:
- Explicit token check — fails fast with a clear error if auth is missing.
- Strict input bounds —
title.max(256)mirrors GitHub's own limit; do not let the LLM submit something the API will reject. - Returns the created URL — gives the user (and the LLM) a verifiable artifact so they know exactly what was created.
Rate limit handling
GitHub's API rate-limits aggressively — 5,000 requests/hour for authenticated users, 60/hour unauthenticated. With heavy use you can hit it. Octokit returns headers x-ratelimit-remaining and x-ratelimit-reset on every response; surface them to the LLM:
octokit.hook.after('request', async (response) => {
const remaining = parseInt(response.headers['x-ratelimit-remaining'] || '0', 10);
if (remaining < 100) {
console.error(`⚠️ GitHub rate limit low: ${remaining} requests remaining`);
}
});
For production you would also use Octokit's @octokit/plugin-throttling plugin, which auto-retries with exponential backoff when 429s come back.
A larger pattern: per-conversation auth
What we built uses a single token baked into the server config. That is great for personal use but wrong for multi-tenant deployments. For shared servers (e.g., a hosted MCP server that multiple users connect to), GitHub's OAuth App or GitHub App flow is the right move:
- Server registers as a GitHub OAuth/App
- MCP client initiates the OAuth dance via Streamable HTTP
- Each user gets their own token, isolated from other users' data
The scaffolding is more involved (token storage, refresh logic, scope management), and is more naturally its own article — but the tool definitions above stay the same. You just swap the octokit instance out for a per-request one keyed off the OAuth token.
Testing locally
Launch the MCP Inspector to sanity-check before hooking up Claude:
GITHUB_TOKEN=ghp_yourToken npx @modelcontextprotocol/inspector node server.js
Open the localhost URL it prints. Try:
search_issueswith queryis:open label:bug repo:nodejs/nodeget_pull_requestwith ownerfacebook, reporeact, number 28000 (or any open PR)create_issuein a throwaway repo only — do not test this against production projects
If each call returns sensible output, plug the server into Claude Desktop and use it for real.
Connecting to Claude Desktop
{
"mcpServers": {
"github": {
"command": "node",
"args": ["/absolute/path/to/github-mcp/server.js"],
"env": { "GITHUB_TOKEN": "ghp_yourTokenHere" }
}
}
}
Restart Claude Desktop. In a new chat, try:
"Show me the open PRs in the typescript-eslint repo labeled "bug"."
Claude calls search_issues with the right query and shows the results. From there you can drill into specific PRs, ask for a summary, paste the description into a Slack message — all without leaving the chat.
What to add next
Natural extensions of the four-tool foundation:
get_file_contents— fetch a specific file from a repo at a given reflist_repo_workflows— show the GitHub Actions workflows on a repoget_workflow_run— fetch the status of a specific CI runadd_pr_comment— post a comment on a pull request (be careful — it is a write tool)merge_pull_request— merge a PR (high-risk write — apply confirmation patterns)
Every one of those is the same shape as the tools we already wrote: Octokit call, format the response, return text.
Conclusion
A custom GitHub MCP server is the difference between an AI assistant that talks about your codebase and one that can operate on it. The auth is straightforward (a single PAT), the API is well-documented, Octokit handles most of the friction, and the MCP SDK ties it all together in a few dozen lines per tool.
Start with read-only tools that match the searches you do every day. Add write tools sparingly, with input limits and clear preview/confirmation patterns. The result is a server you will use every single working hour.
Try it yourself
With the GitHub MCP server connected and a PAT in place, you can run real GitHub queries from a chat:
search_issues7 open bug PRs from the last week:
• #11242 Fix: no-unused-vars false positive with destructured generics
• #11231 Bug: prefer-readonly-parameter-types crashes on intersection types
• #11219 Fix: parser hangs on deeply nested template literals
• #11204 Fix: incorrect error location for missing return types
• … and 3 more
Want me to pull the full description of any of them?
The LLM constructed the query is:pr is:open label:bug repo:typescript-eslint/typescript-eslint created:>2026-05-05 on its own because the tool description includes example syntax. Good descriptions equal an LLM that uses your tool well.