GitHub MCP Server
Overview
Section titled “Overview”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
Scope design
Section titled “Scope design”| Scope | Grants |
|---|---|
github:read | List repos, issues, PRs, read files, search code |
github:write | Create issues, create PRs |
github:repo:OWNER/REPO | Restrict 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:
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:*"] }'Complete server implementation
Section titled “Complete server implementation”// server.ts — GitHub MCP Server with Authgentimport 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}`);});Available tools
Section titled “Available tools”| Tool | Scope | Description |
|---|---|---|
list_repos | github:read | List repositories for the user or an organization |
get_repo | github:read | Get repository details (stars, forks, topics) |
list_issues | github:read | List issues with state filter |
create_issue | github:write | Create a new issue with title, body, labels |
list_pull_requests | github:read | List pull requests with state filter |
create_pull_request | github:write | Create a PR from a branch |
search_code | github:read | Search code across repositories |
get_file_contents | github:read | Read a file from a repository |
Connecting to Claude Desktop and Cursor
Section titled “Connecting to Claude Desktop and Cursor”Claude Desktop
Section titled “Claude Desktop”Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{ "mcpServers": { "github": { "url": "http://localhost:3000/mcp", "transport": "http" } }}Cursor
Section titled “Cursor”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”
GitHub App vs PAT for authentication
Section titled “GitHub App vs PAT for authentication”Your MCP server needs a GitHub credential to call the GitHub API on behalf of users. There are two approaches:
Personal Access Token (simple)
Section titled “Personal Access Token (simple)”Set a PAT as an environment variable. Every MCP request uses this single token.
GITHUB_TOKEN=ghp_xxxxxxxxxxxx node server.jsPros: Simple setup, works immediately. Cons: Single token for all users, broad permissions, token rotation is manual.
GitHub App + Token Vault (recommended for production)
Section titled “GitHub App + Token Vault (recommended for production)”Use Authgent’s Token Vault to manage per-user GitHub tokens via a GitHub App.
- Create a GitHub App in your org settings with the required permissions (repo, issues, pull requests)
- Configure Token Vault in Authgent:
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"] }'- 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).