Why Build Your Own MCP Server?
The Model Context Protocol (MCP) has become the universal standard for connecting AI assistants to external tools, APIs, and data sources. With Claude Code, DeepSeek-TUI, Cursor, and dozens of other AI coding platforms all adopting MCP in 2026, knowing how to build a custom server is one of the highest-leverage skills a developer can have right now.
In this tutorial, you will build a fully functional MCP server from scratch using Node.js and TypeScript. The server will expose a GitHub project management toolkit — letting any connected AI assistant search repositories, list issues, and create labels without you copying and pasting data between tools.
What Is MCP, Exactly?
MCP is an open protocol that standardizes how AI applications communicate with external services. Think of it as "USB-C for AI tools" — one interface that works across Claude Code, Cursor, Windsurf, and any other MCP-compatible client.
An MCP server exposes tools (functions the AI can call), resources (data the AI can read), and prompts (pre-built conversation templates). For this tutorial, we focus on tools — the most common and useful capability.
Prerequisites
- Node.js 20+ installed
- A GitHub Personal Access Token (classic, with
reposcope) - Basic TypeScript knowledge
Step 1: Project Setup
Create a new project and install the official MCP SDK:
mkdir github-mcp-server && cd github-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node
npx tsc --init
Update your tsconfig.json to use ESM modules and strict mode:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
Add a build script and start command to package.json:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Step 2: Create the Server Boilerplate
Create src/index.ts with the basic MCP server structure:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (!GITHUB_TOKEN) {
console.error("Error: GITHUB_TOKEN environment variable is required");
process.exit(1);
}
const server = new McpServer({
name: "github-tools",
version: "1.0.0",
});
// Tools will be registered here
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("GitHub MCP Server running on stdio");
}
main().catch(console.error);
We use stdio transport, which means the server communicates with the client over standard input/output — ideal for local tools like Claude Code and Cursor.
Step 3: Add a GitHub API Helper
Before registering tools, create a reusable function for authenticated GitHub API calls:
async function githubRequest(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
"Accept": "application/vnd.github.v3+json",
"Authorization": `Bearer ${GITHUB_TOKEN}`,
"User-Agent": "github-mcp-server/1.0",
...options.headers,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`GitHub API error ${response.status}: ${error}`);
}
return response.json();
}
Step 4: Register the Search Repositories Tool
Now register your first tool. Each tool needs a name, description, input schema (using Zod), and a handler function:
server.tool(
"search_repositories",
"Search GitHub repositories by query",
{
query: z.string().describe("Search query (e.g., 'mcp server typescript')"),
per_page: z.number().default(10).describe("Number of results (max 100)"),
sort: z.enum(["stars", "forks", "help-wanted-issues", "updated"])
.default("stars")
.describe("Sort order"),
},
async ({ query, per_page, sort }) => {
const data = await githubRequest(
`/search/repositories?q=${encodeURIComponent(query)}&per_page=${per_page}&sort=${sort}`
);
const results = data.items.map((repo: any) => ({
name: repo.full_name,
description: repo.description || "No description",
stars: repo.stargazers_count,
language: repo.language || "Unknown",
url: repo.html_url,
}));
return {
content: [
{
type: "text",
text: JSON.stringify(results, null, 2),
},
],
};
}
);
Step 5: Register the List Issues Tool
Add a second tool for listing repository issues:
server.tool(
"list_issues",
"List open issues for a GitHub repository",
{
owner: z.string().describe("Repository owner (e.g., 'microsoft')"),
repo: z.string().describe("Repository name (e.g., 'vscode')"),
state: z.enum(["open", "closed", "all"]).default("open"),
labels: z.string().optional().describe("Comma-separated label names to filter"),
},
async ({ owner, repo, state, labels }) => {
const params = new URLSearchParams({ state, per_page: "30" });
if (labels) params.set("labels", labels);
const data = await githubRequest(
`/repos/${owner}/${repo}/issues?${params}`
);
const issues = data.map((issue: any) => ({
number: issue.number,
title: issue.title,
state: issue.state,
labels: issue.labels.map((l: any) => l.name),
url: issue.html_url,
}));
return {
content: [
{
type: "text",
text: JSON.stringify(issues, null, 2),
},
],
};
}
);
Step 6: Register the Create Label Tool
Add a mutation tool for creating repository labels:
server.tool(
"create_label",
"Create a new label on a GitHub repository",
{
owner: z.string().describe("Repository owner"),
repo: z.string().describe("Repository name"),
name: z.string().describe("Label name"),
color: z.string().default("ededed").describe("Hex color without # (e.g., 'ff0000')"),
description: z.string().optional().describe("Label description"),
},
async ({ owner, repo, name, color, description }) => {
const body: Record = { name, color };
if (description) body.description = description;
const label = await githubRequest(`/repos/${owner}/${repo}/labels`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return {
content: [
{
type: "text",
text: `Label created: "${label.name}" (#${label.color}) — ${label.html_url}`,
},
],
};
}
);
Step 7: Connect to Your AI Client
Build and test the server:
npm run build
GITHUB_TOKEN=your_token_here npm start
To connect it in Claude Code, add it to your project's .mcp.json:
{
"mcpServers": {
"github-tools": {
"command": "node",
"args": ["/absolute/path/to/github-mcp-server/dist/index.js"],
"env": {
"GITHUB_TOKEN": "your_token_here"
}
}
}
}
Or use the CLI command:
claude mcp add github-tools --env GITHUB_TOKEN=your_token_here \
-- node /absolute/path/to/github-mcp-server/dist/index.js
For Cursor, add the same configuration to your .cursor/mcp.json file in the project root.
Step 8: Test It Out
Once connected, you can ask your AI assistant natural language questions like:
- "Find trending TypeScript MCP servers on GitHub" — uses search_repositories
- "Show me open bugs in the microsoft/vscode repo" — uses list_issues
- "Create a 'priority-high' label in red on my repo" — uses create_label
The AI assistant will call your MCP tools automatically and surface the results in context.
Complete Tool Comparison
| Tool | Type | GitHub Endpoint | HTTP Method |
|---|---|---|---|
| search_repositories | Read | /search/repositories | GET |
| list_issues | Read | /repos/{owner}/{repo}/issues | GET |
| create_label | Write | /repos/{owner}/{repo}/labels | POST |
Best Practices for Production MCP Servers
- Validate all inputs — Zod schemas handle this automatically; never trust raw input from the AI layer
- Rate limit awareness — GitHub API has a 5,000 request/hour limit for authenticated requests; add caching where possible
- Handle errors gracefully — Return useful error messages in the
contentarray rather than crashing - Use resources for large data — If you need to expose documentation or config files, use MCP resources instead of tools
- Version your server — Update the
versionfield and communicate breaking changes - Log to stderr — The stdio transport uses stdout for protocol messages; all logging must go to stderr
What's Next?
From here, you can extend this server with more tools — pull request management, workflow triggers, webhook handling, or integration with other APIs like Jira, Slack, or your own internal services. The MCP SDK supports multiple transports (stdio, HTTP, SSE), so the same server code can serve Claude Code locally or a cloud-based AI assistant remotely.
The MCP ecosystem is growing fast. In 2026, building custom MCP servers is becoming the standard way to give AI agents access to your unique tooling and data. Start with one tool, test it thoroughly, and expand from there.