Building an MCP Server from Scratch
The Problem
We manage multiple projects across different tools. Planning happens in Claude on the web — brainstorming features, breaking down work, prioritising. Implementation happens in Claude Code on the CLI — writing code, running tests, committing. But the project board lives on GitHub.
The disconnect was constant. We'd plan a feature in Claude, then manually go to GitHub to create the issue. We'd finish implementing something in Claude Code, then switch to the browser to move the ticket to "Done". Every context switch broke our flow.
What we wanted: Claude — whether we're in the web app or the CLI — should be able to read our project board, create issues, update them, and move them between columns. Both Claudes should work with the same board, in real time.
Why Not the Official GitHub MCP?
GitHub has an official MCP connector through api.githubcopilot.com. It requires a GitHub App installation with org-level permissions. For personal repos under a free account, it doesn't reliably work — and it's a black box you can't debug or modify.
We wanted something we could understand completely, host ourselves, and trust with our private repos. So we built one.
What is MCP?
The Model Context Protocol is an open standard that lets AI assistants like Claude use external tools. Think of it as a plugin system — your server exposes tools (functions with descriptions and input schemas), and Claude can call them during a conversation.
The protocol is JSON-RPC over HTTP. Claude sends a request like "call the create_issue tool with these arguments", your server executes it, and returns the result. Claude uses the result to continue the conversation.
The Architecture
The entire server is a single file — server.js, about 500 lines. Here's how the pieces fit together:
Claude (Web App / Desktop / CLI)
|
|-- OAuth 2.0 flow (one-time setup)
| |-- GET /.well-known/oauth-authorization-server
| |-- POST /register (dynamic client registration)
| |-- GET /authorize --> consent screen
| +-- POST /token --> access token
|
+-- MCP requests (Bearer token)
+-- POST /mcp --> tools/list, tools/call
|
|-- GitHub REST API (issues, labels)
+-- GitHub GraphQL API (projects, kanban)
Two APIs talk to GitHub: REST for issues and labels (simple CRUD), GraphQL for Projects V2 (the kanban board requires GitHub's newer API).
OAuth 2.0 with PKCE
Claude Web App uses custom connectors that require OAuth 2.0. This isn't optional — it's how Claude authenticates with your server. The flow uses PKCE (Proof Key for Code Exchange), which is the secure way to do OAuth without exposing secrets in the browser.
The implementation boils down to four endpoints:
// Discovery — tells Claude where to find everything
GET /.well-known/oauth-authorization-server
→ { authorization_endpoint, token_endpoint, registration_endpoint, ... }
// Dynamic client registration — Claude registers itself
POST /register
→ { client_id, client_secret }
// Authorization — shows a consent screen, you enter your admin secret
GET /authorize → consent page
POST /authorize → verify secret, issue auth code, redirect
// Token exchange — Claude swaps the code for an access token
POST /token
→ { access_token, token_type: "Bearer", expires_in: 86400 }
PKCE adds one twist: Claude generates a random code_verifier, hashes it to create a code_challenge, and sends the challenge during authorization. When exchanging the code for a token, Claude sends the original verifier. The server hashes it and compares — if they match, the exchange is legitimate.
function verifyPkce(verifier, challenge) {
const hash = createHash('sha256').update(verifier).digest('base64url');
return hash === challenge;
}
All state lives in memory — auth codes, access tokens, registered clients. No database, no files. Restart the server and everyone re-authenticates. For a personal tool, this is fine.
The Tools
MCP tools are just function definitions with JSON Schema inputs. Claude reads the descriptions to decide when and how to use them. The server exposes 10 tools in two categories:
Issues (REST API):
list_issues— list open/closed issues, filter by labelsget_issue— get full issue details with body and commentscreate_issue— create with title, body, and labelsupdate_issue— update title, body, state, or labelsadd_comment— add a markdown commentlist_labels— list all repo labels
Projects / Kanban (GraphQL API):
get_project— get board with columns and field IDslist_project_items— list items with their current columnadd_to_project— add an issue to the boardmove_project_item— move between columns (Todo → In Progress → Done)
Each tool is defined with a clear description and input schema so Claude knows exactly what to pass:
{
name: 'create_issue',
description: 'Create a new issue',
inputSchema: {
type: 'object',
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' } },
},
required: ['owner', 'repo', 'title'],
},
}
GitHub Projects V2: The GraphQL Part
GitHub's kanban boards (Projects V2) are only accessible via GraphQL. The REST API doesn't support them. This is the trickiest part of the server — not because GraphQL is hard, but because the data model is deep.
To move a card between columns, you need four IDs: the project ID, the item ID, the status field ID, and the target column's option ID. Getting these requires chained queries:
// 1. Get the project (returns field IDs and column option IDs)
query { user(login: "you") {
projectV2(number: 1) {
id
fields(first: 30) { nodes {
... on ProjectV2SingleSelectField {
id name options { id name }
}
}}
}
}}
// 2. List items (returns item IDs with their current column)
query { node(id: "PROJECT_ID") {
... on ProjectV2 { items(first: 100) { nodes {
id
fieldValues(first: 10) { nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name field { ... on ProjectV2FieldCommon { name } }
}
}}
content { ... on Issue { number title state } }
}}}
}}
// 3. Move the item
mutation { updateProjectV2ItemFieldValue(input: {
projectId: "...", itemId: "...",
fieldId: "...", value: { singleSelectOptionId: "..." }
}) { projectV2Item { id } } }
Claude handles this chain naturally. Ask it to "move issue #5 to Done" and it figures out the sequence — get the project, find the item, identify the target column, execute the mutation.
How We Actually Use It
This is where it gets interesting. We have the MCP server connected to both Claude Web App and Claude Code CLI. They share the same GitHub project board, and the workflow looks like this:
Planning (Claude Web App): We open claude.ai and start a conversation about what we want to build next. Claude reads the current board state, sees what's in progress, and helps us break down the next feature into issues. It creates the tickets directly — with descriptions, labels, and places them in the "Todo" column.
Implementation (Claude Code CLI): When we sit down to code, Claude Code reads the board, picks up the next ticket from "Todo", moves it to "In Progress", and starts working on it. As it implements, it adds comments to the issue with progress notes. When the code is done and committed, it moves the ticket to "Done" and closes the issue.
The key insight: both Claudes are working with the same board. We can plan in the morning on the web, code in the afternoon in the terminal, and never once open GitHub's UI. The board stays in sync because both clients hit the same MCP server, which talks to the same GitHub API.
A typical session looks like this:
Me: "What's on the board right now?"
Claude: [calls list_project_items] "You have 3 items in Todo, 1 In Progress (#12 — Add rate limiting), and 5 Done."
Me: "Let's work on #12."
Claude: [calls get_issue, reads the description, starts implementing, calls move_project_item to In Progress]
... coding happens ...
Claude: [calls add_comment with implementation notes, calls update_issue to close it, calls move_project_item to Done]
No tab switching. No copy-pasting issue numbers. The project management happens inline with the actual work.
Hosting It
The server binds to 127.0.0.1:3232 by default — localhost only. For Claude Web App to reach it, you need HTTPS on a public domain. We use Caddy as a reverse proxy on our free Oracle Cloud VPS:
mcp.yourdomain.com {
reverse_proxy localhost:3232
}
That's it. Caddy handles TLS certificates automatically. The server runs behind it with a systemd unit for auto-restart.
For Claude Code CLI, you can also connect directly without HTTPS:
claude mcp add --transport http github-issues https://mcp.yourdomain.com/mcp
Zero Dependencies, For Real
The entire server uses two Node.js built-in modules: http and crypto. No Express, no Passport, no GraphQL client library, no JWT library. The OAuth flow is hand-rolled. The GraphQL queries are template strings. The HTTP routing is a switch statement on the pathname.
Why? Because when something breaks at 2 AM, we want to open one file and understand every line between our code and the network socket. With zero dependencies, the entire stack is visible. There's nothing hiding in node_modules.
It also means the server starts instantly, deploys in seconds (just copy one file), and has zero supply chain risk. No npm audit needed when there's no package.json to audit.
Try It
The full source is on GitHub: github-issues-mcp. Clone it, set three environment variables, and you have a working MCP server in under a minute.
GITHUB_TOKEN=ghp_your_token \
MCP_ADMIN_SECRET=your_secret \
MCP_BASE_URL=https://mcp.yourdomain.com \
node server.js
If you're managing projects with Claude, having it directly manipulate your issue board changes the workflow completely. Planning and execution happen in the same conversation. The board becomes a living document that updates as the work happens — not something you update after the fact.