DEV Community

dev_olivers
dev_olivers

Posted on

I Built a GitHub MCP Server in 20 Minutes and Never Pasted a Token Again

A pattern for self-hosted MCP connectors when first-party options don't exist


The Problem

If you use Claude.ai for development work, this is for you. Every session starts the same way:

  1. Go to GitHub, create a fine-grained PAT
  2. Paste it into the chat
  3. Have Claude gives you an earnest "[INSERT NAME] I have to flag something..."
  4. Fight back the urge to scream because you've been through this before
  5. Do your work
  6. Go back to GitHub, revoke the PAT
  7. Repeat tomorrow

Multiply that by every service you touch β€” GitHub, Vercel, Supabase β€” and you're spending time on the ritual that truly adds up.

The gods have solved this for some services: Supabase,and Vercel, to name a few. Connect once...and that's it.

GitHub doesn't have one(!!!). Not for Claude.ai's web interface, anyway. Their official MCP server (github/github-mcp-server) is designed for local tools like Claude Desktop and Claude Code β€” it reads a PAT from your environment. There's no hosted set and forget solution connector for Claude.ai.

So here it is.

GitHub repo β†’

The Solution

It is simple: host a tiny MCP server on infrastructure you already pay for (in my case, a Next.js app on Vercel), store the GitHub PAT as a Vercel environment variable, and expose git operations as MCP tools. Then add it as a custom connector in Claude.ai.

The entire implementation is one file and three dependencies.

What You Need

  • A Next.js project deployed on Vercel (or any serverless platform)
  • A GitHub PAT with repo access
  • About 20 minutes

The Implementation

1. Install dependencies:

npm install mcp-handler @octokit/rest zod
Enter fullscreen mode Exit fullscreen mode

2. Create the MCP route:

Create src/app/api/mcp/[transport]/route.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpHandler } from "mcp-handler/nextjs";
import { z } from "zod";
import { Octokit } from "@octokit/rest";

const handler = createMcpHandler(
  (server: McpServer) => {
    const octokit = new Octokit({
      auth: process.env.GITHUB_PAT,
    });

    // Read a file or list a directory
    server.tool(
      "git_read",
      "Read a file or list a directory from a GitHub repository",
      {
        owner: z.string().describe("Repository owner"),
        repo: z.string().describe("Repository name"),
        path: z.string().describe("File path"),
        branch: z.string().default("main").describe("Branch name"),
      },
      async ({ owner, repo, path, branch }) => {
        const { data } = await octokit.repos.getContent({
          owner, repo, path, ref: branch,
        });
        if (Array.isArray(data)) {
          const listing = data.map((f) =>
            `${f.type === "dir" ? "πŸ“" : "πŸ“„"} ${f.name}`
          ).join("\n");
          return { content: [{ type: "text", text: listing }] };
        }
        if ("content" in data && data.content) {
          const decoded = Buffer.from(data.content, "base64").toString("utf-8");
          return { content: [{ type: "text", text: decoded }] };
        }
        return { content: [{ type: "text", text: JSON.stringify(data) }] };
      }
    );

    // Push a file
    server.tool(
      "git_push",
      "Create or update a file in a GitHub repository",
      {
        owner: z.string(),
        repo: z.string(),
        path: z.string(),
        content: z.string().describe("File content"),
        message: z.string().describe("Commit message"),
        branch: z.string().default("main"),
      },
      async ({ owner, repo, path, content, message, branch }) => {
        let sha: string | undefined;
        try {
          const { data } = await octokit.repos.getContent({
            owner, repo, path, ref: branch,
          });
          if (!Array.isArray(data) && "sha" in data) sha = data.sha;
        } catch { /* new file */ }

        const { data } = await octokit.repos.createOrUpdateFileContents({
          owner, repo, path, branch, message,
          content: Buffer.from(content).toString("base64"),
          sha,
          committer: {
            name: "your-git-username",
            email: "your-email@users.noreply.github.com",
          },
        });
        return {
          content: [{
            type: "text",
            text: `Committed ${path} to ${owner}/${repo}@${branch}: ${data.commit.sha?.slice(0, 7)}`,
          }],
        };
      }
    );

    // Add more tools as needed: git_log, git_list_branches, 
    // git_create_branch, git_push_multi, etc.
  },
  { capabilities: { tools: {} } }
);

export { handler as GET, handler as POST, handler as DELETE };
Enter fullscreen mode Exit fullscreen mode

3. Make the route publicly accessible:

If your app has auth middleware or a proxy that protects routes, add /api/mcp to the public paths. MCP handles its own transport β€” it doesn't need your app's auth layer.

4. Store the PAT:

In your Vercel project settings, add GITHUB_PAT as an environment variable (Production + Preview + Development). The PAT never touches your code, never enters a chat, and lives in those special places where secrets belong.

5. Deploy and connect:

Push the code, let Vercel deploy, then go to Claude.ai β†’ Settings β†’ Connectors β†’ Add custom connector:

URL: https://yourdomain.com/api/mcp/mcp
Enter fullscreen mode Exit fullscreen mode

The double /mcp was not a typo. Don't forget it!

Success

A connector, and zero tokens in chat:

Service Connector Type Token Management
Vercel First-party MCP OAuth β€” zero tokens
GitHub Self-hosted MCP PAT in Vercel env var β€” zero chat exposure

Reading files, pushing commits, listing branches, and viewing commit history across all my repos now lives safely in Claude's purview without sharing my PAT. Rotating the PAT means updating one Vercel env var and redeploying. No code changes, no chat exposure, no ritual (okay, you can still have rituals--I enjoy a good sun salutation before attempting anything important).

Why?

This isn't just about convenience (though it is very convenient). It's about a pattern:

When a service you use with AI tools doesn't offer a hosted MCP connector, you can build one in minutes on infrastructure you already have.

The MCP server is stateless. It reads credentials from env vars at runtime. It costs nothing beyond your existing hosting. And it turns a per-session security ritual into a one-time setup.

The priority order for any service:

  1. First-party MCP connector β€” use it if it exists (Supabase, Vercel, etc.)
  2. Self-hosted MCP server β€” build one if it doesn't (GitHub, any service with an API + SDK)
  3. Token pasting β€” last resort, with mandatory rotation

I'd love to see Anthropic or GitHub ship an official hosted connector for this. But until they do, you can have the same experience in about 100 lines of TypeScript.

What I'd Add

A few tools I included in my implementation that you might want:

  • git_push_multi β€” commit multiple files atomically (essential for coordinated changes across config + source files)
  • git_log β€” recent commit history with author and message
  • git_list_branches β€” see all branches with protection status
  • git_create_branch β€” branch from any ref

The full implementation covers six tools and handles edge cases like new file creation (no SHA needed) vs. updates (SHA required for the GitHub API).


Built with Claude on Vercel. The MCP server that powers this workflow was written, deployed, and tested in a single session β€” using the very tools it was designed to replace.

Top comments (0)