Build a GitHub MCP Server
Create an MCP server that integrates with the GitHub API. Build tools for managing issues, pull requests, and repositories, plus resources for repo metadata.
title: "Build a GitHub MCP Server" description: "Create an MCP server that integrates with the GitHub API. Build tools for managing issues, pull requests, and repositories, plus resources for repo metadata." order: 6 keywords:
- github mcp server
- github api mcp integration
- mcp server github issues
- github pull request mcp
- github tools ai assistant date: "2026-04-01" level: "intermediate" duration: "35 min"
Build a GitHub MCP server that lets AI assistants search repositories, list issues, create issues, and get pull request details. This tutorial covers GitHub API authentication, pagination, and structuring multiple tools in a single server.
What You Will Build
A GitHub integration server with:
- search_repos -- Search GitHub repositories
- list_issues -- List issues for a repository
- create_issue -- Create a new issue
- get_pull_request -- Get PR details with diff stats
A token generated in your GitHub settings that grants API access. For MCP servers, a fine-grained token with read access to repositories and issues is recommended. Avoid using tokens with write access unless needed.
Project Setup
Create the project
npx mcp-framework create github-mcp-server
cd github-mcp-server
Create a GitHub API client
Create src/utils/github.ts:
const GITHUB_API = "https://api.github.com";
export class GitHubClient {
private token: string;
constructor(token: string) {
this.token = token;
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const url = `${GITHUB_API}${path}`;
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "mcp-github-server",
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
`GitHub API error ${response.status}: ${(error as { message?: string }).message || response.statusText}`
);
}
return (await response.json()) as T;
}
async searchRepos(query: string, limit = 10) {
const data = await this.request<{
total_count: number;
items: Array<{
full_name: string;
description: string | null;
stargazers_count: number;
language: string | null;
html_url: string;
updated_at: string;
}>;
}>(`/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit}`);
return {
totalCount: data.total_count,
repos: data.items.map((r) => ({
name: r.full_name,
description: r.description,
stars: r.stargazers_count,
language: r.language,
url: r.html_url,
updatedAt: r.updated_at,
})),
};
}
async listIssues(owner: string, repo: string, state = "open", limit = 20) {
const issues = await this.request<Array<{
number: number;
title: string;
state: string;
user: { login: string };
labels: Array<{ name: string }>;
created_at: string;
html_url: string;
}>>(`/repos/${owner}/${repo}/issues?state=${state}&per_page=${limit}`);
return issues.map((issue) => ({
number: issue.number,
title: issue.title,
state: issue.state,
author: issue.user.login,
labels: issue.labels.map((l) => l.name),
createdAt: issue.created_at,
url: issue.html_url,
}));
}
async createIssue(owner: string, repo: string, title: string, body?: string, labels?: string[]) {
return this.request<{ number: number; html_url: string }>(
`/repos/${owner}/${repo}/issues`,
{
method: "POST",
body: JSON.stringify({ title, body, labels }),
}
);
}
async getPullRequest(owner: string, repo: string, number: number) {
return this.request<{
number: number;
title: string;
state: string;
user: { login: string };
merged: boolean;
additions: number;
deletions: number;
changed_files: number;
html_url: string;
body: string | null;
created_at: string;
}>(`/repos/${owner}/${repo}/pulls/${number}`);
}
}
export function getGitHubClient(): GitHubClient {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("GITHUB_TOKEN environment variable is required");
}
return new GitHubClient(token);
}
Building the Tools
SearchReposTool
Create src/tools/SearchReposTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { getGitHubClient } from "../utils/github.js";
class SearchReposTool extends MCPTool<typeof inputSchema> {
name = "search_repos";
description = "Search GitHub repositories by keyword, language, or topic";
schema = {
query: {
type: z.string().min(1),
description: "Search query (e.g., 'mcp-framework language:typescript')",
},
limit: {
type: z.number().min(1).max(30).optional(),
description: "Max results to return (default: 10)",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const client = getGitHubClient();
const results = await client.searchRepos(input.query, input.limit || 10);
return JSON.stringify(results, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
query: z.string().min(1),
limit: z.number().min(1).max(30).optional(),
});
export default SearchReposTool;
ListIssuesTool
Create src/tools/ListIssuesTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { getGitHubClient } from "../utils/github.js";
class ListIssuesTool extends MCPTool<typeof inputSchema> {
name = "list_issues";
description = "List issues for a GitHub repository with optional state filter";
schema = {
owner: {
type: z.string().min(1),
description: "Repository owner (e.g., 'QuantGeekDev')",
},
repo: {
type: z.string().min(1),
description: "Repository name (e.g., 'mcp-framework')",
},
state: {
type: z.enum(["open", "closed", "all"]).optional(),
description: "Issue state filter (default: open)",
},
limit: {
type: z.number().min(1).max(100).optional(),
description: "Maximum issues to return (default: 20)",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const client = getGitHubClient();
const issues = await client.listIssues(
input.owner,
input.repo,
input.state || "open",
input.limit || 20
);
return JSON.stringify({
repository: `${input.owner}/${input.repo}`,
count: issues.length,
issues,
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
owner: z.string().min(1),
repo: z.string().min(1),
state: z.enum(["open", "closed", "all"]).optional(),
limit: z.number().min(1).max(100).optional(),
});
export default ListIssuesTool;
CreateIssueTool
Create src/tools/CreateIssueTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { getGitHubClient } from "../utils/github.js";
class CreateIssueTool extends MCPTool<typeof inputSchema> {
name = "create_issue";
description = "Create a new issue in a GitHub repository";
schema = {
owner: {
type: z.string().min(1),
description: "Repository owner",
},
repo: {
type: z.string().min(1),
description: "Repository name",
},
title: {
type: z.string().min(1).max(256),
description: "Issue title",
},
body: {
type: z.string().optional(),
description: "Issue body in Markdown",
},
labels: {
type: z.array(z.string()).optional(),
description: "Labels to apply to the issue",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const client = getGitHubClient();
const result = await client.createIssue(
input.owner,
input.repo,
input.title,
input.body,
input.labels
);
return JSON.stringify({
success: true,
issueNumber: result.number,
url: result.html_url,
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
owner: z.string().min(1),
repo: z.string().min(1),
title: z.string().min(1).max(256),
body: z.string().optional(),
labels: z.array(z.string()).optional(),
});
export default CreateIssueTool;
GetPullRequestTool
Create src/tools/GetPullRequestTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { getGitHubClient } from "../utils/github.js";
class GetPullRequestTool extends MCPTool<typeof inputSchema> {
name = "get_pull_request";
description = "Get details of a pull request including diff statistics";
schema = {
owner: {
type: z.string().min(1),
description: "Repository owner",
},
repo: {
type: z.string().min(1),
description: "Repository name",
},
number: {
type: z.number().min(1),
description: "Pull request number",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const client = getGitHubClient();
const pr = await client.getPullRequest(input.owner, input.repo, input.number);
return JSON.stringify({
number: pr.number,
title: pr.title,
state: pr.state,
author: pr.user.login,
merged: pr.merged,
stats: {
additions: pr.additions,
deletions: pr.deletions,
changedFiles: pr.changed_files,
},
description: pr.body,
createdAt: pr.created_at,
url: pr.html_url,
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
owner: z.string().min(1),
repo: z.string().min(1),
number: z.number().min(1),
});
export default GetPullRequestTool;
Use fine-grained GitHub personal access tokens. Grant only the permissions your tools actually need. For read-only tools, use a token with read access. Only grant write access for tools like create_issue.
Claude Desktop Configuration
{
"mcpServers": {
"github": {
"command": "node",
"args": ["/path/to/github-mcp-server/dist/index.js"],
"env": {
"GITHUB_TOKEN": "ghp_your_token_here"
}
}
}
}
Never commit your GitHub token. Use the env field in the Claude Desktop configuration to pass it securely. For team deployments, use a secrets manager.
Official SDK Alternative
Here is the search_repos tool using the official SDK:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "github-server", version: "1.0.0" });
server.tool(
"search_repos",
"Search GitHub repositories",
{ query: z.string(), limit: z.number().max(30).optional() },
async ({ query, limit }) => {
const response = await fetch(
`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit || 10}`,
{ headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } }
);
const data = await response.json();
return {
content: [{ type: "text" as const, text: JSON.stringify(data.items.map((r: any) => ({
name: r.full_name, stars: r.stargazers_count, url: r.html_url
})), null, 2) }],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Testing
npm run build
GITHUB_TOKEN=ghp_xxx npx @modelcontextprotocol/inspector node dist/index.js
Ask Claude: "Search for MCP server repositories in TypeScript and show me the top 5 results."