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 repo scope)
  • 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

ToolTypeGitHub EndpointHTTP Method
search_repositoriesRead/search/repositoriesGET
list_issuesRead/repos/{owner}/{repo}/issuesGET
create_labelWrite/repos/{owner}/{repo}/labelsPOST

Best Practices for Production MCP Servers

  1. Validate all inputs — Zod schemas handle this automatically; never trust raw input from the AI layer
  2. Rate limit awareness — GitHub API has a 5,000 request/hour limit for authenticated requests; add caching where possible
  3. Handle errors gracefully — Return useful error messages in the content array rather than crashing
  4. Use resources for large data — If you need to expose documentation or config files, use MCP resources instead of tools
  5. Version your server — Update the version field and communicate breaking changes
  6. 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.