Skip to content

GitHub MCP Server

The GitHub MCP server exposes GitHub’s API — repositories, pull requests, issues, and code search — as MCP tools. Built with TypeScript and @octokit/rest, it validates Authgent JWTs and maps scopes to GitHub operations.

What agents can do:

  • Browse repositories, issues, pull requests, and file contents
  • Create issues and pull requests
  • Search code across repositories
  • All operations are scoped by Authgent JWT claims
ScopeGrants
github:readList repos, issues, PRs, read files, search code
github:writeCreate issues, create PRs
github:repo:OWNER/REPORestrict access to a specific repository

The github:repo:OWNER/REPO pattern enables fine-grained access. An agent with github:read github:repo:acme/api can only read from the acme/api repository.

Register scopes in Authgent:

Terminal window
curl -X POST http://localhost:8080/admin/resources \
-H "Content-Type: application/json" \
-d '{
"resource": "http://localhost:3000",
"name": "GitHub MCP Server",
"allowed_scopes": ["github:read", "github:write", "github:repo:*"]
}'
// server.ts — GitHub MCP Server with Authgent
import express from "express";
import { Octokit } from "@octokit/rest";
import { createRemoteJWKSet, jwtVerify } from "jose";
const AUTHGENT_ISSUER = process.env.AUTHGENT_ISSUER || "http://localhost:8080";
const MCP_AUDIENCE = process.env.MCP_AUDIENCE || "http://localhost:3000";
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const JWKS = createRemoteJWKSet(
new URL(`${AUTHGENT_ISSUER}/.well-known/jwks.json`)
);
const octokit = new Octokit({ auth: GITHUB_TOKEN });
const app = express();
app.use(express.json());
// ── Types ────────────────────────────────────────────────
interface AuthClaims {
sub: string;
scope: string;
email?: string;
name?: string;
iss: string;
aud: string;
exp: number;
}
interface MCPRequest {
method: string;
params?: Record<string, unknown>;
}
// ── OAuth Protected Resource Metadata (RFC 9728) ────────
app.get("/.well-known/oauth-protected-resource", (req, res) => {
res.json({
resource: MCP_AUDIENCE,
authorization_servers: [AUTHGENT_ISSUER],
});
});
// ── Auth middleware ──────────────────────────────────────
function getScopes(claims: AuthClaims): string[] {
return (claims.scope || "").split(" ").filter(Boolean);
}
function hasScope(claims: AuthClaims, required: string): boolean {
return getScopes(claims).includes(required);
}
function hasRepoAccess(claims: AuthClaims, owner: string, repo: string): boolean {
const scopes = getScopes(claims);
// Check for exact repo scope or wildcard
return (
scopes.includes(`github:repo:${owner}/${repo}`) ||
!scopes.some((s) => s.startsWith("github:repo:"))
);
}
function requireScope(claims: AuthClaims, scope: string): void {
if (!hasScope(claims, scope)) {
throw new Error(`insufficient_scope: required "${scope}"`);
}
}
function requireRepoAccess(claims: AuthClaims, owner: string, repo: string): void {
if (!hasRepoAccess(claims, owner, repo)) {
throw new Error(
`insufficient_scope: no access to ${owner}/${repo}`
);
}
}
app.use("/mcp", async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "missing_token" });
return;
}
try {
const token = authHeader.slice(7);
const { payload } = await jwtVerify(token, JWKS, {
issuer: AUTHGENT_ISSUER,
audience: MCP_AUDIENCE,
});
(req as any).auth = payload as unknown as AuthClaims;
next();
} catch {
res.status(401).json({ error: "invalid_token" });
}
});
// ── Tool definitions ────────────────────────────────────
const TOOLS = [
{
name: "list_repos",
description: "List repositories for the authenticated user or an organization. Requires github:read.",
inputSchema: {
type: "object" as const,
properties: {
org: { type: "string", description: "Organization name (optional, lists user repos if omitted)" },
limit: { type: "number", description: "Max results (default 30)" },
},
},
},
{
name: "get_repo",
description: "Get details of a specific repository. Requires github:read.",
inputSchema: {
type: "object" as const,
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
},
required: ["owner", "repo"],
},
},
{
name: "list_issues",
description: "List issues for a repository. Requires github:read.",
inputSchema: {
type: "object" as const,
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
state: { type: "string", enum: ["open", "closed", "all"], description: "Filter by state" },
limit: { type: "number", description: "Max results (default 30)" },
},
required: ["owner", "repo"],
},
},
{
name: "create_issue",
description: "Create a new issue. Requires github:write.",
inputSchema: {
type: "object" as const,
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
title: { type: "string", description: "Issue title" },
body: { type: "string", description: "Issue body (markdown)" },
labels: { type: "array", items: { type: "string" }, description: "Labels to add" },
},
required: ["owner", "repo", "title"],
},
},
{
name: "list_pull_requests",
description: "List pull requests for a repository. Requires github:read.",
inputSchema: {
type: "object" as const,
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
state: { type: "string", enum: ["open", "closed", "all"], description: "Filter by state" },
limit: { type: "number", description: "Max results (default 30)" },
},
required: ["owner", "repo"],
},
},
{
name: "create_pull_request",
description: "Create a new pull request. Requires github:write.",
inputSchema: {
type: "object" as const,
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
title: { type: "string", description: "PR title" },
body: { type: "string", description: "PR description (markdown)" },
head: { type: "string", description: "Branch containing changes" },
base: { type: "string", description: "Branch to merge into (default: main)" },
},
required: ["owner", "repo", "title", "head"],
},
},
{
name: "search_code",
description: "Search for code across repositories. Requires github:read.",
inputSchema: {
type: "object" as const,
properties: {
query: { type: "string", description: "Search query (GitHub code search syntax)" },
limit: { type: "number", description: "Max results (default 30)" },
},
required: ["query"],
},
},
{
name: "get_file_contents",
description: "Get the contents of a file from a repository. Requires github:read.",
inputSchema: {
type: "object" as const,
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
path: { type: "string", description: "File path within the repository" },
ref: { type: "string", description: "Branch or commit SHA (default: main)" },
},
required: ["owner", "repo", "path"],
},
},
];
// ── Tool handlers ───────────────────────────────────────
async function handleTool(
name: string,
args: Record<string, any>,
claims: AuthClaims
): Promise<{ content: { type: string; text: string }[] }> {
switch (name) {
case "list_repos": {
requireScope(claims, "github:read");
const response = args.org
? await octokit.repos.listForOrg({
org: args.org,
per_page: args.limit || 30,
})
: await octokit.repos.listForAuthenticatedUser({
per_page: args.limit || 30,
sort: "updated",
});
const repos = response.data.map((r) => ({
full_name: r.full_name,
description: r.description,
language: r.language,
stars: r.stargazers_count,
updated_at: r.updated_at,
private: r.private,
url: r.html_url,
}));
return { content: [{ type: "text", text: JSON.stringify(repos, null, 2) }] };
}
case "get_repo": {
requireScope(claims, "github:read");
requireRepoAccess(claims, args.owner, args.repo);
const { data } = await octokit.repos.get({
owner: args.owner,
repo: args.repo,
});
return {
content: [
{
type: "text",
text: JSON.stringify(
{
full_name: data.full_name,
description: data.description,
language: data.language,
stars: data.stargazers_count,
forks: data.forks_count,
open_issues: data.open_issues_count,
default_branch: data.default_branch,
created_at: data.created_at,
updated_at: data.updated_at,
topics: data.topics,
url: data.html_url,
},
null,
2
),
},
],
};
}
case "list_issues": {
requireScope(claims, "github:read");
requireRepoAccess(claims, args.owner, args.repo);
const { data } = await octokit.issues.listForRepo({
owner: args.owner,
repo: args.repo,
state: args.state || "open",
per_page: args.limit || 30,
});
const issues = data.map((i) => ({
number: i.number,
title: i.title,
state: i.state,
author: i.user?.login,
labels: i.labels.map((l) => (typeof l === "string" ? l : l.name)),
created_at: i.created_at,
url: i.html_url,
}));
return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
}
case "create_issue": {
requireScope(claims, "github:write");
requireRepoAccess(claims, args.owner, args.repo);
const { data } = await octokit.issues.create({
owner: args.owner,
repo: args.repo,
title: args.title,
body: args.body,
labels: args.labels,
});
return {
content: [
{
type: "text",
text: JSON.stringify(
{ number: data.number, title: data.title, url: data.html_url },
null,
2
),
},
],
};
}
case "list_pull_requests": {
requireScope(claims, "github:read");
requireRepoAccess(claims, args.owner, args.repo);
const { data } = await octokit.pulls.list({
owner: args.owner,
repo: args.repo,
state: args.state || "open",
per_page: args.limit || 30,
});
const prs = data.map((p) => ({
number: p.number,
title: p.title,
state: p.state,
author: p.user?.login,
head: p.head.ref,
base: p.base.ref,
created_at: p.created_at,
url: p.html_url,
}));
return { content: [{ type: "text", text: JSON.stringify(prs, null, 2) }] };
}
case "create_pull_request": {
requireScope(claims, "github:write");
requireRepoAccess(claims, args.owner, args.repo);
const { data } = await octokit.pulls.create({
owner: args.owner,
repo: args.repo,
title: args.title,
body: args.body,
head: args.head,
base: args.base || "main",
});
return {
content: [
{
type: "text",
text: JSON.stringify(
{ number: data.number, title: data.title, url: data.html_url },
null,
2
),
},
],
};
}
case "search_code": {
requireScope(claims, "github:read");
const { data } = await octokit.search.code({
q: args.query,
per_page: args.limit || 30,
});
const results = data.items.map((item) => ({
repository: item.repository.full_name,
path: item.path,
url: item.html_url,
}));
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
}
case "get_file_contents": {
requireScope(claims, "github:read");
requireRepoAccess(claims, args.owner, args.repo);
const { data } = await octokit.repos.getContent({
owner: args.owner,
repo: args.repo,
path: args.path,
ref: args.ref,
});
if (Array.isArray(data)) {
return {
content: [
{
type: "text",
text: JSON.stringify(
data.map((f) => ({ name: f.name, type: f.type, path: f.path })),
null,
2
),
},
],
};
}
const content =
"content" in data && data.content
? Buffer.from(data.content, "base64").toString("utf-8")
: "";
return { content: [{ type: "text", text: content }] };
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
// ── MCP endpoint ────────────────────────────────────────
app.post("/mcp", async (req, res) => {
const claims = (req as any).auth as AuthClaims;
const body = req.body as MCPRequest;
if (body.method === "tools/list") {
res.json({ tools: TOOLS });
return;
}
if (body.method === "tools/call") {
const params = body.params as { name: string; arguments: Record<string, any> };
try {
const result = await handleTool(params.name, params.arguments, claims);
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "tool_error";
const status = message.includes("insufficient_scope") ? 403 : 500;
res.status(status).json({ error: message });
}
return;
}
res.status(400).json({ error: "unknown_method" });
});
// ── Start server ────────────────────────────────────────
const PORT = parseInt(process.env.PORT || "3000", 10);
app.listen(PORT, () => {
console.log(`GitHub MCP server running on http://localhost:${PORT}`);
console.log(`Authgent issuer: ${AUTHGENT_ISSUER}`);
});
ToolScopeDescription
list_reposgithub:readList repositories for the user or an organization
get_repogithub:readGet repository details (stars, forks, topics)
list_issuesgithub:readList issues with state filter
create_issuegithub:writeCreate a new issue with title, body, labels
list_pull_requestsgithub:readList pull requests with state filter
create_pull_requestgithub:writeCreate a PR from a branch
search_codegithub:readSearch code across repositories
get_file_contentsgithub:readRead a file from a repository

Add to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
"mcpServers": {
"github": {
"url": "http://localhost:3000/mcp",
"transport": "http"
}
}
}

In Cursor settings → MCP → Add server:

  • URL: http://localhost:3000/mcp
  • Auth: OAuth (auto-detected from /.well-known/oauth-protected-resource)

Example prompts:

  • “List all open issues in acme/api”
  • “Create a bug report for the auth timeout issue”
  • “Show me the contents of src/index.ts in acme/api”
  • “Search for uses of deprecated API calls across our repos”

Your MCP server needs a GitHub credential to call the GitHub API on behalf of users. There are two approaches:

Set a PAT as an environment variable. Every MCP request uses this single token.

Terminal window
GITHUB_TOKEN=ghp_xxxxxxxxxxxx node server.js

Pros: Simple setup, works immediately. Cons: Single token for all users, broad permissions, token rotation is manual.

Section titled “GitHub App + Token Vault (recommended for production)”

Use Authgent’s Token Vault to manage per-user GitHub tokens via a GitHub App.

  1. Create a GitHub App in your org settings with the required permissions (repo, issues, pull requests)
  2. Configure Token Vault in Authgent:
Terminal window
curl -X POST http://localhost:8080/admin/token-vault/providers \
-H "Content-Type: application/json" \
-d '{
"provider": "github",
"client_id": "YOUR_GITHUB_APP_CLIENT_ID",
"client_secret": "YOUR_GITHUB_APP_CLIENT_SECRET",
"authorization_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"scopes": ["repo", "read:org"]
}'
  1. Access per-user tokens in your MCP server — Token Vault injects the user’s GitHub token into the request context, so each user’s actions use their own GitHub identity.

Pros: Per-user identity, fine-grained permissions, automatic token refresh. Cons: Requires GitHub App setup and Token Vault (enterprise feature).