Build a GitHub MCP Server in 30 Minutes (Complete Beginner Guide)

This guide walks you through building a real GitHub MCP server from scratch — one that lets Claude list your issues, open new ones, and check pull requests, all from a chat window. By the end you'll have a working TypeScript server, connected to Claude Desktop, that you can extend for whatever GitHub workflow you actually care about.

B
Binaya
130 min read
Build a GitHub MCP Server in 30 Minutes (Complete Beginner Guide)

I'll be honest, the first time I read the MCP spec I closed the tab. It read like every other "protocol" doc: dense, abstract, full of diagrams that don't actually explain anything. Then I sat down and built one server and it clicked in about ten minutes. Turns out it's actually much easier than it sounds

If you've used Express before, most of this will feel familiar. We're basically building a small server that Claude can talk to, except instead of handling HTTP requests from a browser, it handles tool calls from an AI model.

Table of Contents

  1. What is MCP?
  2. Why a GitHub MCP Server is Useful
  3. How MCP Works
  4. What We're Building
  5. Prerequisites
  6. Project Setup
  7. Creating a GitHub Personal Access Token
  8. Building the MCP Server
  9. Creating and Registering Tools
  10. Calling the GitHub REST API
  11. Error Handling
  12. Running the Server Locally
  13. Connecting to Claude Desktop
  14. Testing and Debugging
  15. Common Mistakes
  16. Deployment
  17. Security and Rate Limits
  18. Best Practices and Performance
  19. FAQ
  20. Conclusion
  21. References

What is MCP?

MCP stands for Model Context Protocol. Anthropic open-sourced it in late 2024, and the pitch is simple: it's a standard way for an AI model to talk to external tools and data sources, instead of every company inventing its own bespoke integration format.

Before MCP, if you wanted Claude (or any LLM) to interact with, say, your GitHub repos, someone had to write custom glue code specific to that one integration. MCP replaces that with a shared protocol. You write one MCP server for GitHub, and any MCP-compatible client — Claude Desktop, Claude Code, Cursor, whatever — can use it without any extra work on your end.

Think of it a bit like how a printer driver works. You don't write custom code for every application that wants to print; the application talks to a standard driver interface, and the driver handles the printer-specific details. MCP is that same idea, but for AI models talking to tools and data.

Under the hood, MCP servers expose three kinds of things:

  • Tools — actions the model can invoke, like "create a GitHub issue" or "list open pull requests." This is what we'll spend most of our time on.
  • Resources — read-only data the client can pull in, like a file's contents or a repo's README.
  • Prompts — reusable prompt templates that help structure how a user talks to the model about a particular task.

For a GitHub integration, tools are what actually matter, so that's where we're focusing.

Why a GitHub MCP Server is Useful

You could obviously just open GitHub in a browser tab. But there's a real difference between switching context to a UI and staying inside a conversation where Claude already has the full picture of what you're working on.

Once your MCP server is wired up, you can ask things like:

  • "What issues are open on my api-gateway repo tagged bug?"
  • "Open an issue titled 'Fix pagination bug' with this description..."
  • "Are there any pull requests waiting on my review right now?"

And Claude just does it, using the same GitHub account you'd use manually, without you copy-pasting anything between tabs. It's not magic — it's just removing the friction of switching tools mid-thought.

There's also a bigger picture reason to learn this pattern: once you understand how to build one MCP server, you understand how to build all of them. The GitHub integration in this article is really just a template — swap out the API calls and you've got a Notion MCP server, a Linear MCP server, a Stripe MCP server, whatever your actual job needs.

How MCP Works

MCP communication happens over JSON-RPC 2.0. That's it — not some proprietary binary format, just JSON messages going back and forth with a method, params, and an id.

There are two transports you'll run into:

  • stdio — the server runs as a local subprocess, and messages go over stdin/stdout. This is what Claude Desktop uses for local servers, and it's what we're building today.
  • Streamable HTTP — for servers that live on the internet somewhere, accessed over HTTP. We'll touch on this briefly in the deployment section.

Here's the flow when Claude Desktop is connected to your server over stdio:

+------------------+ +---------------------+
| Claude Desktop | | Your MCP Server |
| (MCP Client) | | (Node process) |
+------------------+ +---------------------+
| |
| 1. spawns your server as a subprocess |
|----------------------------------------------->
| |
| 2. initialize / capabilities handshake |
|<---------------------------------------------->
| |
| 3. "list available tools" |
|----------------------------------------------->
| |
| 4. returns tool schemas (name, description) |
|<-----------------------------------------------
| |
[ user asks Claude to "list open issues" ] |
| |
| 5. tools/call "list_issues" { repo, owner } |
|----------------------------------------------->
| |
| 6. server calls
| GitHub REST API
| |
| 7. returns issue data as tool result |
|<-----------------------------------------------
| |
[ Claude turns the result into a natural reply ] |
v v

[IMAGE: Architecture diagram showing Claude Desktop, the MCP server, and the GitHub API connected in a flow]

The key thing to understand: your server doesn't decide when to run a tool. Claude decides that, based on the conversation and the tool descriptions you write. Your job is just to describe the tool clearly and implement it correctly. Everything else — deciding which tool to call, extracting arguments from natural language, formatting the final answer — is handled by the model.

MCP vs Traditional APIs

Traditional REST API

MCP Server

Consumer

Frontend code, other services

AI models (via MCP clients)

Discovery

You read docs, hardcode endpoints

Client asks the server what tools exist, at runtime

Invocation

You write the exact call

The model decides when and how to call a tool

Schema

OpenAPI/Swagger (optional, often stale)

Required, validated with Zod, always current

Transport

Usually just HTTP

stdio (local) or Streamable HTTP (remote)

What We're Building

By the end of this tutorial you'll have a Node.js + TypeScript project that exposes three tools to Claude:

  • list_issues — list open issues on a given repo
  • create_issue — open a new issue with a title and body
  • list_pull_requests — list open pull requests on a repo

That's a deliberately small set. Once you see the pattern for these three, adding a fourth or fifth tool (closing issues, adding labels, merging PRs) is copy-paste-and-modify work.

[IMAGE: Claude Desktop chat window showing a response built from the list_issues tool]

Prerequisites

You'll need:

  • Node.js 22 or later (we're using native fetch, which has been stable in Node since v18, so anything reasonably recent works, but 22 is the current LTS-track version and what we'll assume throughout)
  • npm (comes with Node)
  • TypeScript — familiarity with it, we're not teaching the language here
  • VS Code (or any editor, but code snippets assume you're comfortable navigating a TS project)
  • Claude Desktop installed, since that's the client we're connecting to
  • A GitHub account

Installing Node

If you don't have Node 22 installed, grab it from nodejs.org or use a version manager like nvm:

nvm install 22
nvm use 22
node --version

You should see something like v22.x.x. If node --version comes back with something ancient like v14 or v16, update before continuing — some of the APIs we use (native fetch, top-level await in ESM) need a recent runtime.

Installing TypeScript

We'll install TypeScript locally in the project rather than globally, since that keeps versions consistent across machines:

npm install --save-dev typescript @types/node tsx

tsx is a small tool that lets us run TypeScript files directly during development without a separate compile step. We'll still compile properly for production later, but tsx makes the local dev loop faster.

Project Setup

Let's scaffold the project.

mkdir github-mcp-server
cd github-mcp-server
npm init -y

That gives you a bare package.json. Open it and set "type": "module" — the MCP SDK ships as ESM, and mixing CommonJS and ESM in the same project is one of the most common sources of confusing import errors, so let's avoid that from the start:

{
"name": "github-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}

What changed: we added "type": "module" so Node treats .js output as ES modules, and three scripts — dev for local iteration, build to compile TypeScript to JavaScript, and start to run the compiled output. This is basically the same pattern you'd use for any small Node service, nothing GitHub- or MCP-specific yet.

Now install the actual dependencies:

npm install @modelcontextprotocol/sdk zod dotenv

  • @modelcontextprotocol/sdk is the official SDK — this gives us McpServer and the stdio transport.
  • zod is a schema validation library the SDK uses to describe and validate tool inputs.
  • dotenv loads environment variables from a .env file, which is where our GitHub token will live.

Add a tsconfig.json:

npx tsc --init

Then edit it to something sane for a small Node/ESM project:

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}

Why module: "NodeNext" and not something like commonjs: NodeNext matches how Node itself resolves ESM imports, including requiring the .js extension on relative imports even though the source files are .ts. This trips people up constantly, so we'll call it out again when we hit our first import statement.

Folder Structure

Here's the layout we're aiming for:

github-mcp-server/
├── src/
│ ├── index.ts # entry point, sets up the server + transport
│ ├── github.ts # GitHub API client wrapper
│ └── tools/
│ ├── list-issues.ts
│ ├── create-issue.ts
│ └── list-pull-requests.ts
├── .env # your GitHub token, never committed
├── .gitignore
├── package.json
└── tsconfig.json

[IMAGE: VS Code file explorer showing the project folder structure above]

Create the folders and files now so the rest of this doesn't feel like it's coming out of nowhere:

mkdir -p src/tools
touch src/index.ts src/github.ts src/tools/list-issues.ts src/tools/create-issue.ts src/tools/list-pull-requests.ts
touch .env .gitignore

Put this in .gitignore immediately, before you forget:

node_modules/
dist/
.env

Environment Variables

We're keeping this simple with one variable for now:

GITHUB_TOKEN=your_token_goes_here

Don't fill in the real value yet — we haven't created the token. That's next.

Creating a GitHub Personal Access Token

GitHub has two flavors of personal access token: classic and fine-grained. Classic tokens are simpler to set up but grant broad, coarse permissions across everything you own. Fine-grained tokens take one extra step to configure but let you scope access down to specific repos and specific permissions, which is the safer default for something like this where the token is going to sit in a .env file on your machine.

We'll use a fine-grained token.

  1. Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens.
  2. Click Generate new token.
  3. Give it a name you'll recognize later, something like mcp-server-local.
  4. Set an expiration. Don't pick "no expiration" out of laziness — 90 days is a reasonable default, and you can always regenerate it.
  5. Under Repository access, choose Only select repositories and pick the repo(s) you want this server to touch. Resist the urge to grant access to everything just to save a click now.
  6. Under Permissions, expand Repository permissions and set:
    • Contents: Read-only (needed for basic repo access)
    • Issues: Read and write (needed for list_issues and create_issue)
    • Pull requests: Read-only (needed for list_pull_requests)
    • Metadata: Read-only (GitHub sets this automatically — it's required for almost every fine-grained token)
  7. Click Generate token, and copy it immediately. GitHub only shows it to you once.

[IMAGE: GitHub fine-grained personal access token permissions screen with Contents, Issues, and Pull requests highlighted]

Paste it into your .env:

GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxxxx

A quick note on permissions, because this trips up almost everyone the first time: if you forget to set Issues to read-and-write, create_issue will fail with a 403 even though your token looks totally fine otherwise. GitHub's error message for this isn't always obvious, so if you get a permissions-flavored error later, this is the first thing to double check.

GitHub Token Types Compared

Classic PAT

Fine-Grained PAT

Scope

All repos you can access

Only repos you explicitly select

Permission granularity

Broad scopes (repo, read:org, etc.)

Per-resource, read/write/none

Expiration

Optional

Encouraged, often required by org policy

Best for this tutorial

Works, but overly broad

Recommended

Building the MCP Server

Let's write the entry point. Open src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import "dotenv/config";
import { registerListIssuesTool } from "./tools/list-issues.js";
import { registerCreateIssueTool } from "./tools/create-issue.js";
import { registerListPullRequestsTool } from "./tools/list-pull-requests.js";

const server = new McpServer({
name: "github-mcp-server",
version: "1.0.0",
});

registerListIssuesTool(server);
registerCreateIssueTool(server);
registerListPullRequestsTool(server);

async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("GitHub MCP server running on stdio");
}

main().catch((error) => {
console.error("Fatal error starting server:", error);
process.exit(1);
});

A few things worth calling out here:

  • We import from "./tools/list-issues.js", not .ts. This is the NodeNext thing from earlier — TypeScript wants you to write the import path as it'll exist after compilation, which means .js, even though the actual file on disk right now is list-issues.ts. It looks wrong the first time you see it. It's not.
  • "dotenv/config" is imported purely for its side effect of loading .env into process.env. We don't use anything it exports directly.
  • We log to console.error, not console.log. This matters more than it looks like it should: over stdio, stdout is the actual protocol channel between your server and Claude Desktop. If you console.log a stray debug message, you've just injected garbage into the JSON-RPC stream and the client will likely choke on it. Anything you want to print for your own eyes goes to stderr instead.
  • main().catch(...) makes sure that if something throws during startup — a missing token, a bad import — you get an actual error message instead of the process just silently dying.

Common mistake here: forgetting the .js extension on relative imports and getting a cryptic Cannot find module error the first time you try to run this. If that happens, check every relative import in the file — TypeScript won't always point you at the exact line.

Creating and Registering Tools

Before writing the tools themselves, let's build a small shared GitHub client so we're not repeating fetch boilerplate three times. Open src/github.ts:

const GITHUB_API_BASE = "https://api.github.com";

function getToken(): string {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error(
"GITHUB_TOKEN is not set. Add it to your .env file."
);
}
return token;
}

export async function githubRequest<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const response = await fetch(`${GITHUB_API_BASE}${path}`, {
...options,
headers: {
Authorization: `Bearer ${getToken()}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
...options.headers,
},
});

if (!response.ok) {
const body = await response.text();
throw new Error(
`GitHub API error (${response.status}): ${body}`
);
}

return response.json() as Promise<T>;
}

What's going on:

  • getToken() reads the token from process.env lazily, at call time, rather than at import time. That way if the token is missing, you get a clear error the moment a tool actually tries to use it, instead of some confusing failure at startup.
  • githubRequest is a thin wrapper around fetch that adds the three headers GitHub's REST API expects: Authorization with a Bearer token, Accept set to the GitHub JSON media type, and X-GitHub-Api-Version pinning the API version so your server doesn't silently break if GitHub changes defaults later.
  • We check response.ok and throw a real error with the response body included, rather than letting a bad response quietly turn into malformed JSON parsing further down. This one detail saves you a lot of confused debugging later — GitHub's error messages are actually pretty descriptive, so surfacing them is worth it.

Common mistake: using Authorization: token ${token} instead of Bearer. The older token scheme still works for now, but Bearer is what GitHub's current docs recommend, and it's what you should default to in anything new.

Tool 1: List Issues

src/tools/list-issues.ts:

import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { githubRequest } from "../github.js";

interface GithubIssue {
number: number;
title: string;
state: string;
html_url: string;
user: { login: string } | null;
}

export function registerListIssuesTool(server: McpServer) {
server.registerTool(
"list_issues",
{
title: "List GitHub Issues",
description:
"List open issues for a GitHub repository. Returns issue number, title, state, and URL.",
inputSchema: {
owner: z.string().describe("The repository owner, e.g. 'octocat'"),
repo: z.string().describe("The repository name, e.g. 'hello-world'"),
state: z
.enum(["open", "closed", "all"])
.default("open")
.describe("Filter by issue state"),
},
},
async ({ owner, repo, state }) => {
const issues = await githubRequest<GithubIssue[]>(
`/repos/${owner}/${repo}/issues?state=${state}&per_page=20`
);

if (issues.length === 0) {
return {
content: [
{ type: "text", text: `No ${state} issues found in ${owner}/${repo}.` },
],
};
}

const summary = issues
.map(
(issue) =>
`#${issue.number} — ${issue.title} (${issue.state}) by ${issue.user?.login ?? "unknown"}\n${issue.html_url}`
)
.join("\n\n");

return {
content: [{ type: "text", text: summary }],
};
}
);
}

What changed and why:

  • inputSchema is a plain object of Zod schemas, not a z.object({...}) wrapper. This is the current SDK convention, and it's a common source of confusion if you've read older tutorials that wrap everything in z.object.
  • .describe(...) on each field isn't decoration — the model actually reads these descriptions to figure out what value to pass. Vague descriptions lead to the model guessing wrong, especially on something like state, where without a clear description the model might pass "opened" or some other reasonable-sounding value that isn't in the enum.
  • We default state to "open" so a lazy request like "show me the issues on X" doesn't require Claude to always specify a state.
  • The GitHub issues endpoint annoyingly also returns pull requests (GitHub treats PRs as a kind of issue internally). For a beginner tutorial we're not filtering those out, but if you want issues only, check for the absence of a pull_request field on each item and filter it out — worth doing once you're comfortable with the basics here.
  • The return shape — { content: [{ type: "text", text: ... }] } — is the standard MCP tool result format. content is an array because a tool can return multiple pieces of content (text, images, etc.), but for us it's almost always a single text block.

Tool 2: Create Issue

src/tools/create-issue.ts:

import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { githubRequest } from "../github.js";

interface GithubIssue {
number: number;
html_url: string;
}

export function registerCreateIssueTool(server: McpServer) {
server.registerTool(
"create_issue",
{
title: "Create GitHub Issue",
description: "Create a new issue in a GitHub repository.",
inputSchema: {
owner: z.string().describe("The repository owner"),
repo: z.string().describe("The repository name"),
title: z.string().describe("The issue title"),
body: z
.string()
.optional()
.describe("The issue description, in Markdown"),
},
},
async ({ owner, repo, title, body }) => {
const issue = await githubRequest<GithubIssue>(
`/repos/${owner}/${repo}/issues`,
{
method: "POST",
body: JSON.stringify({ title, body }),
headers: { "Content-Type": "application/json" },
}
);

return {
content: [
{
type: "text",
text: `Created issue #${issue.number}: ${issue.html_url}`,
},
],
};
}
);
}

Nothing too new here structurally, but two things worth flagging:

  • body is optional. Not every issue needs a description, and forcing the model to always invent one just to satisfy a required field produces awkward, padded-out issue bodies you didn't ask for.
  • This is the tool that fails if you skipped the "Issues: Read and write" permission on your token. If you're testing and get a 403 here specifically, that's almost always the cause — go back and check the token settings before you assume the code is broken.

Tool 3: List Pull Requests

src/tools/list-pull-requests.ts:

import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { githubRequest } from "../github.js";

interface GithubPullRequest {
number: number;
title: string;
html_url: string;
user: { login: string } | null;
draft: boolean;
}

export function registerListPullRequestsTool(server: McpServer) {
server.registerTool(
"list_pull_requests",
{
title: "List Pull Requests",
description: "List open pull requests for a GitHub repository.",
inputSchema: {
owner: z.string().describe("The repository owner"),
repo: z.string().describe("The repository name"),
},
},
async ({ owner, repo }) => {
const pulls = await githubRequest<GithubPullRequest[]>(
`/repos/${owner}/${repo}/pulls?state=open&per_page=20`
);

if (pulls.length === 0) {
return {
content: [{ type: "text", text: `No open pull requests in ${owner}/${repo}.` }],
};
}

const summary = pulls
.map(
(pr) =>
`#${pr.number} — ${pr.title}${pr.draft ? " (draft)" : ""} by ${pr.user?.login ?? "unknown"}\n${pr.html_url}`
)
.join("\n\n");

return { content: [{ type: "text", text: summary }] };
}
);
}

By this point the pattern should feel repetitive, which is the point — once your GitHub client and your first tool are solid, adding more tools is mostly copy, rename, and adjust the endpoint and fields.

Calling the GitHub REST API

We're using the REST API here rather than GraphQL, mostly because REST maps one-to-one onto small, single-purpose tools, which is exactly what MCP tools want to be. GraphQL is genuinely better if you need to fetch a bunch of related data in one round trip, but for beginner tools like "list issues" or "create an issue," REST is simpler to reason about and debug.

GitHub REST vs GraphQL

REST API

GraphQL API

Requests per operation

One endpoint per resource type

One query can fetch nested/related data

Learning curve

Lower — plain URLs and JSON

Higher — need to learn the schema and query syntax

Best fit for MCP tools

Great — matches one tool to one endpoint

Better for complex, data-heavy tools

Rate limit accounting

Simple, one request = one unit

Cost-based, more complex to reason about

If your MCP server grows and you find yourself making three or four REST calls just to answer one question — say, "show me this PR along with its review comments and linked issues" — that's usually the signal it's time to look at GraphQL instead.

Error Handling

The error handling in github.ts covers the network and API-level failures, but there's one more layer worth adding: making sure a thrown error inside a tool handler becomes a clean message back to Claude instead of crashing the whole server process.

The MCP SDK already catches exceptions thrown inside tool handlers and reports them back as a tool error rather than taking down the transport, so you don't strictly need a try/catch in every single tool. But for anything user-facing, it's worth wrapping calls where you want a friendlier message than a raw stack trace. Here's the pattern applied to list-issues.ts:

async ({ owner, repo, state }) => {
try {
const issues = await githubRequest<GithubIssue[]>(
`/repos/${owner}/${repo}/issues?state=${state}&per_page=20`
);
// ...
} catch (error) {
return {
content: [
{
type: "text",
text: `Couldn't fetch issues for ${owner}/${repo}: ${
error instanceof Error ? error.message : String(error)
}`,
},
],
isError: true,
};
}
}

What changed: we catch the error, return a normal tool result with isError: true, and put a human-readable message in the text. Claude sees isError: true and knows something went wrong, and it can relay that back to you in plain language ("looks like that repo doesn't exist, or your token can't see it") instead of surfacing a raw exception.

Common Errors

Error

Likely Cause

Fix

401 Unauthorized

Token missing or malformed

Check .env, confirm GITHUB_TOKEN is set and loaded

403 Forbidden

Token lacks the required permission

Re-check fine-grained permissions (Issues, Pull requests)

404 Not Found

Repo name typo, or token can't see that repo

Confirm repo access was granted to the token

Cannot find module './tools/...'

Missing .js extension on a relative import

Add .js even though the file is .ts

Claude Desktop shows no tools

Config JSON syntax error, or wrong path

Validate the JSON, check the config file path for your OS

Running the Server Locally

Before wiring this into Claude Desktop, run it directly to make sure it starts without errors:

npm run dev

You should see GitHub MCP server running on stdio printed to your terminal. If you see that and the process just sits there, that's correct — it's waiting for a client to connect over stdin/stdout. There's no "it's working!" webpage to check, which feels a little anticlimactic the first time, but it's expected behavior for a stdio server.

Kill it with Ctrl+C once you've confirmed it starts cleanly, then build the production version:

npm run build
npm start

Same expected output. If npm run build throws TypeScript errors, fix those before moving on — a build that doesn't compile obviously won't run correctly when Claude Desktop tries to launch it either.

Connecting to Claude Desktop

Claude Desktop reads its MCP server configuration from a file called claude_desktop_config.json. The location depends on your OS:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json

The easiest way to find it: open Claude Desktop, go to Settings, and look for a Developer section with an option to edit the MCP configuration directly. That opens the file in your default editor and creates it if it doesn't already exist.

Add your server under the mcpServers key:

{
"mcpServers": {
"github": {
"command": "node",
"args": ["/absolute/path/to/github-mcp-server/dist/index.js"],
"env": {
"GITHUB_TOKEN": "github_pat_xxxxxxxxxxxxxxxxxxxx"
}
}
}
}

A couple of details that matter more than they look like they should:

  • Use an absolute path to dist/index.js, not a relative one. Claude Desktop launches this as a subprocess from its own working directory, not yours, so a relative path will fail silently or point at the wrong file.
  • Claude Desktop launches subprocesses with a fairly minimal PATH. If you were relying on dotenv picking up your .env file, that won't happen here — Claude Desktop doesn't run this from your project folder with your shell environment loaded. That's why we're passing GITHUB_TOKEN directly in the env block of the config instead. Our github.ts code doesn't care where the variable came from, so this just works.
  • Yes, your token sits in plaintext in this config file. That's normal for local MCP setups, but it does mean this file deserves the same care as any other credentials file on your machine — don't commit it, don't screen-share with it open.

Save the file and fully quit Claude Desktop — not just close the window, actually quit it (Cmd+Q on macOS, or right-click the tray icon and Quit on Windows) — then reopen it.

[IMAGE: Claude Desktop connected to local MCP server, showing the tools icon in the chat input area]

Testing and Debugging

Once Claude Desktop restarts, open a new chat and look for a tools icon near the message input — usually a small hammer or plug icon with a number next to it. Click it to confirm your three tools (list_issues, create_issue, list_pull_requests) show up.

Then just ask, in plain language:

"List open issues on octocat/hello-world"

Claude should recognize this maps to list_issues, ask you for confirmation to run it (Claude Desktop typically confirms before executing a tool), and then return a formatted summary.

If nothing shows up in the tools list:

  1. Check the config file is valid JSON. A missing comma will silently break the whole file. Paste it into a JSON validator if you're not sure.
  2. Check the path is absolute and correct. Typos here are extremely common — copy the path directly from your terminal with pwd rather than typing it by hand.
  3. Check the logs. Claude Desktop writes MCP-related logs to a file (on macOS, typically under ~/Library/Logs/Claude/). If your server crashed on startup, the reason is usually right there.
  4. Run the server manually first. If npm start throws an error in your terminal, Claude Desktop will hit the exact same error — it's just easier to see it directly.

Common Mistakes

A few things that trip up almost everyone building their first MCP server, gathered in one place:

  • Using console.log instead of console.error for debug output. Anything written to stdout corrupts the JSON-RPC stream over stdio. Always debug to stderr.
  • Forgetting the .js extension on relative TypeScript imports. Annoying, but it's how NodeNext module resolution works.
  • Using a relative path in the Claude Desktop config. Always use the absolute path to your compiled entry point.
  • Vague tool descriptions. If the model can't tell from the description and parameter names what a tool does, it'll either not call it, or call it with wrong arguments.
  • Granting the token more access than the tool needs. It's tempting to just check every permission box so you never hit a 403, but that defeats the purpose of using a fine-grained token in the first place.
  • Forgetting to restart Claude Desktop after editing the config. It only reads the config on startup, not live.

Deployment

Everything above runs locally, which is fine for personal use. If you want your GitHub MCP server accessible remotely — for a team, or from Claude Code in a CI environment — you'll want to run it as a hosted Streamable HTTP server instead of a local stdio subprocess. That's a meaningful architecture change (a different transport, plus you'll want to add authentication in front of it, since a remote server is reachable by anyone who has the URL), so treat it as a follow-up project rather than an extension of this tutorial. That said, here's how the hosting options compare once you get there.

Deployment Options

Platform

Good For

Notes

Railway

Quick deploys, minimal config

Connects directly to a GitHub repo, auto-deploys on push

Render

Free tier for small/personal projects

Free instances spin down when idle, adding cold-start latency

Docker (self-hosted)

Full control, on-prem or any cloud

You manage the runtime, networking, and TLS yourself

For any of these, the broad shape is the same: build your project (npm run build), make sure GITHUB_TOKEN is set as an environment variable on the host rather than baked into the image, and expose the Streamable HTTP transport instead of stdio. A minimal Dockerfile for this project would look like:

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist ./dist
CMD ["node", "dist/index.js"]

Note that this Dockerfile assumes you've already run npm run build and committed the dist folder into your build context, or added a build stage — whichever fits your CI setup better.

Security and Rate Limits

A couple of things worth taking seriously before you start relying on this daily:

  • Never commit your .env file or your token. It's in .gitignore for a reason — double check before your first commit that it's actually being ignored.
  • Scope your token narrowly. Only grant access to the repos and permissions this server actually needs. If you add a "delete repo" tool later, that's the moment to revisit the token's permissions, not before.
  • Set an expiration on the token, and get in the habit of rotating it. A token that never expires is a token you'll eventually forget about.
  • Watch your rate limits. Authenticated GitHub REST API requests are capped at 5,000 per hour, versus 60 per hour unauthenticated — plenty for personal use, but worth being aware of if you start automating things that call the API in a loop. GitHub returns your remaining quota in the X-RateLimit-Remaining response header, and a 403 with X-RateLimit-Remaining: 0 means you've hit the ceiling until the reset time in X-RateLimit-Reset.

Best Practices and Performance

A short list of things that separate a "works on my machine" server from one you'd actually trust day to day:

  • Keep tool descriptions specific. "List issues" is worse than "List open issues for a GitHub repository, filtered by state." The more specific the description, the fewer wrong tool calls you'll get.
  • Return concise text, not raw JSON dumps. Claude has to read whatever you return and turn it into a reply. Formatted, human-readable summaries produce better responses than pasting a raw API payload back at it.
  • Paginate. We capped requests at per_page=20 in this tutorial. For repos with hundreds of open issues, you'll want an explicit pagination parameter on your tools rather than silently truncating results.
  • Cache sparingly, if at all, for this kind of workload. Issue and PR data changes often enough that stale caches cause more confusion than the API calls they'd save you.
  • Log to stderr, structured if you can. Once you're debugging a server running as a background subprocess, a timestamped, one-line-per-event log format saves a lot of time over unformatted console.error calls.

FAQ

Do I need to know Python to build an MCP server? No. MCP has official SDKs for TypeScript and Python, along with community SDKs for other languages. This tutorial uses TypeScript throughout.

Can I use this GitHub MCP server with something other than Claude Desktop? Yes. Any MCP-compatible client can connect to it, since the protocol is the same regardless of client. Claude Code and various MCP-compatible IDE plugins can use the same server with the same config shape.

Why does Claude ask for confirmation before running a tool? Claude Desktop confirms tool calls that could have side effects (like creating an issue) before running them, as a safety measure. This is a client behavior, not something your server controls directly.

Does my token need "repo" scope? Only if you're using a classic token. With a fine-grained token, you grant the specific repository permissions your tools need (Contents, Issues, Pull requests) rather than one broad repo scope.

Why is my server not showing up in Claude Desktop at all? Almost always a config file issue — invalid JSON, a relative instead of absolute path, or you forgot to fully restart Claude Desktop after editing the file.

Can I use octokit.js instead of raw fetch? Yes, and for a larger project it's arguably a better choice — it handles pagination, retries, and typing for you. We used native fetch here to keep the dependency list minimal for a beginner tutorial.

How many tools can one MCP server expose? There's no hard limit from the protocol itself, but in practice keep it focused. A server with fifty vaguely-described tools makes it harder for the model to pick the right one — better to build several small, focused servers than one sprawling one.

Is stdio or Streamable HTTP better for a GitHub server? For personal, local use, stdio (what we built) is simpler and doesn't require exposing anything to the network. Streamable HTTP makes sense once you need shared or remote access.

What happens if my GitHub token expires while the server is running? The next API call will fail with a 401. Our error handling surfaces that as a tool error rather than a crash, but you'll need to generate a new token and update your config.

Do I need to handle GitHub webhooks for this to work? No. This tutorial is a pull-based integration — Claude asks for data when you ask it to. Webhooks are for push-based notifications and are a separate, more advanced pattern.

Conclusion

None of this is really about GitHub specifically. The GitHub REST API is just a convenient, well-documented target to learn the actual skill, which is: describe a tool clearly, validate its inputs, call an external API, and hand a clean result back to the model. That pattern is identical whether you're wrapping GitHub, Linear, Notion, or some internal API nobody outside your company has ever heard of.

If you want to keep going from here, a natural next step is adding a fourth tool — closing an issue, adding a label, or requesting a PR review — using the exact same pattern from create-issue.ts. Once that feels easy, look at Streamable HTTP transport and think about what a shared, remote version of this server would need in terms of auth.

References

Related Posts

Top 5 Free Agentic AI Coding Alternatives (And How to Get Them)
tech

Top 5 Free Agentic AI Coding Alternatives (And How to Get Them)

These are the top 5 FREE alternatives to paid coding agents like Claude Code and Codex. Tired of spending money on coding assistants for your daily development work? In this article, I'll show you how to get many of the same benefits without paying a monthly subscription.

By Binaya

CMF Phone 2 Pro: 4-Months Later – Full Review
tech

CMF Phone 2 Pro: 4-Months Later – Full Review

After 4 months of daily use, here’s my in-depth review of the CMF Phone 2 Pro. From performance and battery life to the camera and software experience, I share what’s great, what could be better, and whether it’s still worth buying today.

By Binaya Shrestha