Skip to main content

Build Your First MCP Server: Enterprise Playbook for GitHub API, VS Code Copilot, and Claude

Calculating read time…

An MCP server is the smallest possible bridge between an AI agent and a real system — a few dozen lines of code that turn "call this function" into "call this live API," and building one yourself is the fastest way to actually understand the Model Context Protocol instead of just using someone else's. 🧩

Why this matters beyond the tutorial: every MCP server your organization installs — whether it's the official GitHub server, a Postgres connector, or something an intern wired up over a weekend — becomes a piece of the attack surface and the reasoning surface for every agent that talks to it. A tool with a vague description gets misused by the model. A tool with no input validation gets exploited by whatever the model was tricked into passing it. Learning to build one from scratch, end to end, against a real API like GitHub's, is what turns "I've heard of MCP" into "I can review, harden, and ship one." 🔐

Diagram: VS Code Copilot agent, your MCP server, GitHub REST API, and MCP Inspector observing the traffic

🔀 Quick Comparison: stdio vs. Streamable HTTP for Your Learning Server

Before you write a line of code, you'll pick a transport. For a learning project that VS Code launches for you, stdio is almost always the right call — here's why, side by side.

Dimension stdio Streamable HTTP
How it runs Host spawns your server as a child process; messages ride stdin/stdout Your server listens on a port; clients connect over HTTP
Best for Local dev, single-user tools, learning projects, VS Code/Claude Desktop Shared/remote servers, multiple concurrent clients, enterprise gateways
Auth model Environment variables passed by the host process OAuth 2.1 / bearer tokens, network-level controls
Deployment Nothing to deploy — it lives in your repo Needs hosting, TLS, and a place to run continuously
Failure mode Crashes are isolated to your session A crash or hang can affect every connected client

🎯 Use this when: you're learning, prototyping, or building a personal tool — start with stdio. Move to Streamable HTTP only once you need to share the server with a team.

1. What You're Actually Building (and Why It's a Good Learning Project)

Kid analogy: imagine a toy box that a robot can open by itself, but only if you've put a label on each toy explaining what it does and how to pick it up. The robot never sees inside the box directly — it reads the labels, decides which toy it needs, and asks you (the box) to hand that toy over. You're building the box and writing the labels.

Technically, that "box" is a small program that speaks JSON-RPC 2.0 over a transport your AI host understands, and each "label" is a tool — a name, a description, and a schema describing its inputs. When your agent decides it needs GitHub information, it doesn't call the GitHub API itself; it calls your tool, and your tool calls GitHub on its behalf. That indirection is the entire point of MCP: the same three tools you write today will work unmodified in Claude Desktop, Claude Code, VS Code's Copilot agent mode, or any other MCP-aware host tomorrow, because the protocol — not the AI vendor — defines the contract.

✅ Worked example: GitHub itself ships an official, open-source MCP server (github/github-mcp-server) that exposes dozens of tools — issues, pull requests, code search, Actions — to any MCP host. You are not going to reproduce that project; you're going to build a deliberately small slice of it (three read-only tools) so you understand exactly what's happening at every layer before you ever install someone else's server and trust it blindly.

🎯 Use this when: you want to understand MCP from first principles rather than treating every server you connect to as a black box.

2. Step 1 — Install the MCP SDK and an HTTP Client

Kid analogy: before you can build the toy box, you need a toolbox of your own — screws, glue, a label maker. The MCP SDK is that toolbox; it already knows how to talk JSON-RPC so you don't have to hand-write the protocol plumbing.

The official TypeScript SDK (@modelcontextprotocol/sdk) gives you the McpServer class for registering tools and a StdioServerTransport for wiring it up to a host process. Pair it with Zod for schema validation and any HTTP client — the built-in fetch in modern Node.js is enough for a learning project, so you don't strictly need an extra dependency for that part.

  1. Confirm you have Node.js 18 or newer: node --version
  2. Create the project: mkdir github-mcp-learning && cd github-mcp-learning && npm init -y
  3. Install the SDK and Zod: npm install @modelcontextprotocol/sdk zod
  4. Install TypeScript tooling: npm install -D typescript tsx @types/node, then npx tsc --init
💡 Key warning: the MCP specification itself moved fast this year — the current dated revision, 2026-07-28, restructured how connections start and how sessions are described at the wire level. The high-level SDK insulates you from most of this, but if you ever drop down to the raw protocol or read an older tutorial's JSON-RPC examples, don't assume they still match the wire format byte-for-byte. Trust the SDK's abstractions and check the SDK's own changelog before hand-rolling anything.

🎯 Use this when: setting up any new MCP server project, regardless of which external API it will eventually call.

3. Step 2 — Create Your First MCP Tool

Kid analogy: a label on the toy box isn't just a name — it's a name, a picture of what the toy does, and instructions for how to ask for it. A tool definition is exactly that: a name, a description the model reads to decide when to use it, and a schema describing the inputs it needs.

Start with something trivial so you can see the whole loop work before adding a real API call. Create src/server.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

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

server.registerTool(
  "ping",
  {
    title: "Ping",
    description: "Returns 'pong' plus the message you sent, to prove the server round-trip works.",
    inputSchema: { message: z.string().min(1).max(200) },
  },
  async ({ message }) => ({
    content: [{ type: "text", text: `pong: ${message}` }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);
✅ Worked example: this ping pattern is the same smoke test recommended in the official MCP documentation itself before wiring in a real backend — get one trivial tool talking end to end, confirm the host can discover and call it, and only then add the API integration that can actually fail in interesting ways.

Notice three things the schema is doing for you: it rejects a call with no message, it rejects one over 200 characters, and it tells the model — via the generated JSON Schema — exactly what shape of input is expected. None of that validation logic was hand-written; Zod and the SDK generate it from the schema you declared. That's the same mechanism that will protect your GitHub tools once real parameters (repository names, issue numbers) are involved.

🎯 Use this when: starting any new tool — always prove the round trip with a trivial handler before adding real logic.

4. Step 3 — Connect the Tool to the GitHub API

Kid analogy: now imagine the toy box doesn't hold the toys itself — it has a phone line to a giant warehouse (GitHub) and calls ahead to have the right toy sent over whenever the robot asks. Your tool's job is to make that phone call correctly and hand back only what the robot actually needs.

GitHub's REST API is a natural first integration because it's free to use at low volume, well-documented, and forgiving for a learning project. You'll authenticate with a personal access token and call the search endpoint. Replace the ping tool (or add alongside it) with:

const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (!GITHUB_TOKEN) {
  throw new Error("Missing required GITHUB_TOKEN environment variable");
}

server.registerTool(
  "search_repositories",
  {
    title: "Search GitHub Repositories",
    description: "Searches public GitHub repositories by keyword and returns name, URL, and star count for the top matches.",
    inputSchema: {
      query: z.string().min(1).max(256),
      limit: z.number().int().min(1).max(10).default(5),
    },
  },
  async ({ query, limit }) => {
    const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit}`;
    const res = await fetch(url, {
      headers: {
        Authorization: `Bearer ${GITHUB_TOKEN}`,
        Accept: "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
      },
    });

    if (!res.ok) {
      return {
        isError: true,
        content: [{ type: "text", text: `GitHub API error ${res.status}: ${res.statusText}` }],
      };
    }

    const data = await res.json();
    const results = data.items.map((repo) => ({
      name: repo.full_name,
      url: repo.html_url,
      stars: repo.stargazers_count,
    }));

    return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
  }
);
💡 Contrasting example: compare this to the ping tool above — ping could never fail in an interesting way, but this tool now has a network call, an authentication header, an HTTP status to check, and someone else's rate limits to respect. Every one of those is a place production incidents actually come from, which is exactly why the "production basics" section later in this post exists — none of it is optional once the tool talks to a real system.

Generate a token at GitHub's Developer Settings → Personal access tokens (fine-grained tokens, scoped to public_repo read access only, are the right choice for this learning project — never mint a token with broader scopes than the tools you're actually building need).

🎯 Use this when: wiring any MCP tool to a real, rate-limited, authenticated third-party API — the shape of the problem is the same whether it's GitHub, Jira, or Salesforce.

5. Step 4 — Run the Server Locally Inside VS Code

Kid analogy: you've built the toy box, but the robot still doesn't know it exists — you have to tell it "here's a new box, and here's where to find it." That's what a host configuration file does.

VS Code's GitHub Copilot extension can run MCP servers directly in Agent Mode. Compile your TypeScript (or run it with tsx for quick iteration), then create .vscode/mcp.json in your project root:

{
  "servers": {
    "github-mcp-learning": {
      "type": "stdio",
      "command": "npx",
      "args": ["tsx", "src/server.ts"],
      "env": {
        "GITHUB_TOKEN": "${input:github_token}"
      }
    }
  },
  "inputs": [
    {
      "id": "github_token",
      "type": "promptString",
      "description": "GitHub personal access token (read-only, public_repo scope)",
      "password": true
    }
  ]
}
  1. Enable MCP discovery once, if you haven't: open Settings, search "MCP," and confirm chat.mcp.discovery.enabled is on.
  2. Open the Chat view, switch to Agent Mode, and start the github-mcp-learning server from the tools picker — VS Code will prompt you once for the token, which it stores as a secret input rather than plain text.
  3. Ask Copilot something that requires the tool, e.g. "search GitHub for popular MCP TypeScript SDK repositories," and watch it call search_repositories.
✅ Worked example: the "servers" key (not "mcpServers") is VS Code-specific — Claude Desktop and Cursor use the latter. If you ever port this config between hosts for a team, that key name is the single most common copy-paste bug enterprises hit when standardizing MCP configs across mixed toolchains.

🎯 Use this when: you want fast local iteration with a debugger and your normal editor, rather than a standalone client.

6. Step 5 — Install and Use the MCP Inspector

Kid analogy: the Inspector is a grown-up standing next to the toy box with a clipboard, writing down exactly what the robot asked for and exactly what came back — without needing the robot in the room at all.

The official @modelcontextprotocol/inspector is a standalone developer tool from the same GitHub organization that maintains the spec and SDKs. It launches a small local web UI plus a proxy process, lets you list your server's tools, call them directly with hand-typed JSON arguments, and watch the raw request/response traffic — no AI model required.

GITHUB_TOKEN=ghp_yourtoken npx @modelcontextprotocol/inspector npx tsx src/server.ts

This opens the UI at http://localhost:6274. Open the Tools tab, select search_repositories, type a query, and run it — you'll see the exact JSON-RPC payload your server received and returned. This is where schema mistakes surface fastest, because you're bypassing the model's tendency to "guess around" a slightly wrong tool description.

💡 Key warning: treat the Inspector as a local debugging tool, not something you expose on a shared network. It runs an MCP client and a local proxy together, and by default has no authentication of its own beyond what you configure — fine on your laptop, wrong on a shared server.

🎯 Use this when: you change a tool's schema or handler and want to verify it in isolation before trusting an AI agent to call it correctly.

7. Step 6 — Add More Tools: search, list, get

Kid analogy: one labeled toy is nice, but a real toy box has several — a car, a puzzle, a book — each with its own label so the robot doesn't have to guess. Adding list_issues and get_issue next to search_repositories teaches the model to pick the narrowest tool for the job — search when it doesn't know the repo, list when it knows the repo but not the issue, get when it knows exactly which issue.

server.registerTool(
  "list_issues",
  {
    title: "List Repository Issues",
    description: "Lists open issues for a given owner/repo pair, most recently updated first.",
    inputSchema: {
      owner: z.string().min(1).max(100),
      repo: z.string().min(1).max(100),
      limit: z.number().int().min(1).max(20).default(10),
    },
  },
  async ({ owner, repo, limit }) => {
    const url = `https://api.github.com/repos/${owner}/${repo}/issues?state=open&sort=updated&per_page=${limit}`;
    const res = await fetch(url, { headers: githubHeaders() });
    if (!res.ok) {
      return { isError: true, content: [{ type: "text", text: `GitHub API error ${res.status}` }] };
    }
    const issues = (await res.json()).map((i) => ({
      number: i.number,
      title: i.title,
      url: i.html_url,
    }));
    return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
  }
);

server.registerTool(
  "get_issue",
  {
    title: "Get a Single Issue",
    description: "Fetches full details for one issue by owner, repo, and issue number.",
    inputSchema: {
      owner: z.string().min(1).max(100),
      repo: z.string().min(1).max(100),
      issue_number: z.number().int().positive(),
    },
  },
  async ({ owner, repo, issue_number }) => {
    const url = `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`;
    const res = await fetch(url, { headers: githubHeaders() });
    if (!res.ok) {
      return { isError: true, content: [{ type: "text", text: `GitHub API error ${res.status}` }] };
    }
    const issue = await res.json();
    return {
      content: [{ type: "text", text: JSON.stringify({ title: issue.title, body: issue.body, state: issue.state }, null, 2) }],
    };
  }
);
✅ Worked example: factoring the shared headers into a githubHeaders() helper mirrors how the official GitHub MCP server organizes dozens of tools around one shared, authenticated client rather than duplicating auth logic per tool — the fewer places a token touches the code, the fewer places it can leak.

Notice each description is written for the model, not for you. "Lists open issues for a given owner/repo pair, most recently updated first" tells the model exactly when to reach for this tool versus search_repositories. Vague descriptions are one of the most common causes of a model calling the wrong tool, and rewriting a description is often a faster fix than rewriting the tool.

🎯 Use this when: designing a tool surface for one API — prefer several narrow, well-described tools over one tool with a dozen optional parameters.

8. Step 7 — Add the Production Basics

Kid analogy: a toy box that works fine when you personally hand it a toy is different from one that has to survive a room full of overexcited kids grabbing at once, some of them yanking too hard, one of them trying to pry it open with a screwdriver. Production basics are the reinforced hinges and the lock.

💡 Key warning: a learning-project server that "works on my machine" and a server you'd let a colleague point their agent at are not the same artifact. Every item below closes a gap between the two.
Basic What it prevents
Input validationMalformed or hostile parameters (already Zod's job here) reaching your API calls
Error handlingUncaught exceptions crashing the whole server instead of returning a structured isError result for one bad call
API timeoutA slow or hung upstream request blocking the agent indefinitely
Rate-limit handlingSilent 403s from GitHub once you exceed 5,000 requests/hour, and cascading retries that make it worse
LoggingDebugging "the agent said it worked but nothing happened" with no trail to follow
Secure secret managementYour token ending up somewhere it can be read by anyone with repo access
No hardcoded tokensThe single most common way a learning project turns into a leaked-credential incident
Git from day oneLosing the working version once you start "just trying something" to fix a bug
function githubHeaders() {
  return {
    Authorization: `Bearer ${GITHUB_TOKEN}`,
    Accept: "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
  };
}

async function githubFetch(url) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 8000);

  try {
    const res = await fetch(url, { headers: githubHeaders(), signal: controller.signal });

    if (res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0") {
      const resetAt = Number(res.headers.get("x-ratelimit-reset")) * 1000;
      const waitSeconds = Math.ceil((resetAt - Date.now()) / 1000);
      throw new Error(`GitHub rate limit exhausted, resets in ~${waitSeconds}s`);
    }
    if (!res.ok) {
      throw new Error(`GitHub API error ${res.status}: ${res.statusText}`);
    }
    return await res.json();
  } catch (err) {
    console.error(JSON.stringify({ level: "error", tool: "github_fetch", url, message: String(err) }));
    throw err;
  } finally {
    clearTimeout(timeout);
  }
}
✅ Worked example: reading X-RateLimit-Remaining and X-RateLimit-Reset off the response, rather than just retrying blindly, is the same pattern GitHub's own API documentation recommends to every client — MCP tool or otherwise — and it's the difference between a server that degrades gracefully under load and one that gets your token temporarily blocked.

For secrets, keep GITHUB_TOKEN out of your code entirely: read it from process.env, put a real .env file (and .gitignore it) in local dev, and let VS Code's promptString input (from Step 4) handle it when the host launches the server. Finally, run git init before you add the second tool, not after the third rewrite — a learning project is exactly where you'll want to revert an experiment.

🎯 Use this when: any MCP tool leaves your laptop, is shared with a teammate, or touches a real credential — treat these as non-negotiable, not "nice to have later."

9. Enterprise Rollout at Scale

Kid analogy: one kid with one toy box in their own room is easy to supervise. A school with a hundred classrooms, each with its own box, needs a rule book: which boxes are allowed in the building, who checked the labels, and what happens when a box gets a new toy added.

The three-tool learning server in this post maps directly onto decisions an enterprise has to make before letting any MCP server — internal or third-party — near a production agent fleet:

  • Server governance and allow-listing: maintain a registry of approved servers (name, owner, version, source repo) the way you'd track approved npm packages — an agent shouldn't be able to load an arbitrary MCP server from a URL a user pasted into chat.
  • Trust boundaries: a read-only GitHub search tool and a tool that can push commits are fundamentally different risk categories, even if they live in the same server — scope tokens and permissions per tool, not per server.
  • Credential and OAuth handling: for anything beyond a personal learning project, move off long-lived personal access tokens and onto GitHub Apps or OAuth with short-lived, auto-rotating tokens issued per user or per service.
  • Versioning of servers: pin a specific version of any internal or third-party MCP server in your fleet's configuration; "always pull latest" turns a routine dependency bump into an uncontrolled production change.
  • CI enforcement: run schema-validation and contract tests against your tool definitions on every pull request, the same way you'd test a public API — a silently changed input schema breaks every agent that learned the old one.
  • Observability for tool calls: emit structured logs and metrics per tool invocation (latency, error rate, rate-limit headroom) so a spike in GitHub 403s shows up on a dashboard, not in a user's bug report.
✅ Worked example: mainstream MCP hosts generally require an explicit, human-approved step before a newly added server's tools become callable, rather than auto-trusting anything a config file points to — that human-in-the-loop gate exists precisely because "discovered a server" and "trusted a server" are not the same event, and enterprises that skip the distinction learn it the hard way.

🎯 Use this when: moving a proof-of-concept MCP server from your laptop toward anything a second person or a scheduled job will run.

10. Common Mistakes (and Why They Happen)

Kid analogy: most toy-box accidents aren't dramatic — they're the lid left unlatched, the label that says "car" on a box that now holds scissors, the one kid who's allowed to grab anything because nobody ever set a rule.

Treating MCP servers as trusted by default. An MCP server is code you're letting an AI agent execute on your behalf. It happens because the setup experience — paste a config, click connect — feels as low-stakes as installing a browser extension, but the blast radius (real API access, real credentials) is much larger.

Missing input/output schema validation. It's tempting to skip Zod for "just a quick tool" — until the model passes a 4,000-character string where you expected a repository name, and your API call fails in a way that's hard to trace back to the missing bound.

Over-broad tool permissions. A single token scoped for full repo write access, used only for read-only search, happens because scoping tokens narrowly takes an extra five minutes that feels unnecessary right up until the token leaks.

Ignoring transport security. Running a Streamable HTTP server without TLS or auth "just for now" happens because stdio's security model (inherited from the host process) doesn't transfer, and people assume MCP is inherently as safe as the local case they started with.

🎯 Use this when: reviewing any MCP server — your own or a vendor's — before it gets access to a real credential.

❓ FAQ

Do I need to know the full MCP spec to build this learning project?

No. The TypeScript SDK's McpServer class handles protocol negotiation and JSON-RPC framing for you. Understanding tools, schemas, and transports is enough to build and reason about everything in this post.

Why stdio instead of Streamable HTTP for a first project?

stdio needs no hosting, no TLS certificate, and no auth server — VS Code or Claude Desktop launches the process for you. Streamable HTTP is the right choice once more than one person or client needs to reach the same running server.

What GitHub token scope should I use for this?

A fine-grained personal access token limited to read-only access on public repositories is enough for search, list, and get. Never use a classic token with full repo scope for a read-only learning tool.

Is the MCP Inspector required, or can I just test inside VS Code?

You can test inside VS Code, but the Inspector isolates schema and API problems from model behavior — you see the exact JSON-RPC call and response without wondering whether the model "chose wrong." Use both; they catch different classes of bugs.

What's the single most important production basic if I only add one?

Secure secret management — specifically, never hardcoding a token in source. Everything else (timeouts, rate limits, logging) improves reliability; a leaked token creates an incident.

🔗 References & Further Reading

Product and company names above (GitHub, Visual Studio Code, Anthropic, Claude, Microsoft Copilot, Sourcegraph, Block) are trademarks of their respective owners, referenced here for identification only. All explanations, code samples, and diagrams in this post are original and written from a general understanding of publicly documented behavior — they synthesize and explain, not reproduce, any single source.

📝 Summary

  • An MCP server is a small labeled "toy box" between an AI agent and a real system — you built one from scratch against the GitHub API.
  • Install the SDK and Zod, then prove the round trip with a trivial ping tool before adding real logic.
  • Connect a tool to a real, authenticated, rate-limited API — GitHub's search endpoint — and handle non-OK responses explicitly.
  • Run the server locally inside VS Code via .vscode/mcp.json, using stdio and a secret input rather than a hardcoded token.
  • Use the MCP Inspector to test tools in isolation, independent of model behavior.
  • Add list_issues and get_issue with narrow, model-readable descriptions.
  • Layer in validation, error handling, timeouts, rate-limit awareness, logging, and secret hygiene before calling it done.
  • Scale governance, credential handling, versioning, CI, and observability once the server leaves your laptop.
  • Watch for the four recurring mistakes: default trust, missing validation, over-broad permissions, and unsecured transports.

That's a complete, working, production-aware MCP server built from nothing — and a lot more intuition for every MCP server you'll evaluate, install, or review from here on. 🚀

Comments