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"

Quick Summary

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
4 toolsin one MCP server
GitHub Personal Access Token

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

1

Create the project

npx mcp-framework create github-mcp-server
cd github-mcp-server
2

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;
Scope Your Tokens

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"
      }
    }
  }
}
Token Security

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."

Frequently Asked Questions