Snippets.

February 4, 2026

AI13 min read

Building a Custom MCP Server in TypeScript

Custom MCP server in TypeScript

If you've been building with AI agents recently, you've probably noticed a common pain point: every tool integration is a one-off. You write custom glue code for Claude, different glue code for Cursor, and something else entirely for your own agent. Model Context Protocol (MCP) fixes this by giving us a universal standard for connecting AI agents to tools and data.

Think of MCP as HTTP for AI agents. Just like HTTP standardized how browsers talk to servers, MCP standardizes how AI clients discover and invoke tools. Originally introduced by Anthropic in November 2024, it was quickly adopted by OpenAI and Mistral in 2025, and is now governed by the Agentic AI Foundation (AAIF) under the Linux Foundation. It's the real deal.

In this article, we'll build a practical MCP server in TypeScript that lets any AI agent interact with GitHub Issues. By the end, you'll have a working server that Claude, Cursor, or any MCP-compatible client can use to list, create, search, and comment on GitHub issues.

How MCP works

MCP follows a client-server architecture with three core primitives:

  • Tools - Functions the AI can call. Think API endpoints. "Create an issue", "search issues", etc.
  • Resources - Read-only data the AI can access. Like a GET endpoint. Repository README, recent issues, etc.
  • Prompts - Reusable prompt templates. We won't cover these today, but they're useful for standardizing how agents interact with your domain.

The client (Claude Desktop, Cursor, your custom agent) connects to your server over a transport. There are two transport options:

  • stdio - For local servers. The client spawns your server as a child process and communicates over stdin/stdout. Simple, secure, no network needed.
  • Streamable HTTP - For network-accessible servers. Uses HTTP with server-sent events for streaming. This is what you'd deploy to a remote server.

The client first asks the server what tools and resources it exposes, then calls them as needed during a conversation. The AI decides when and how to use them based on the user's request.

Setting up the project

Let's start with a clean TypeScript project. We need the MCP SDK and Zod for schema validation.

mkdir github-issues-mcp
cd github-issues-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init

Update your tsconfig.json with strict settings:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true
  },
  "include": ["src/**/*"]
}

Here's the project structure we're aiming for:

src/
├── index.ts              # Server entry point
├── tools/
│   ├── list-issues.ts
│   ├── create-issue.ts
│   ├── add-comment.ts
│   └── search-issues.ts
├── resources/
│   └── repository.ts
├── github-client.ts      # GitHub API wrapper
└── types.ts              # Shared type definitions

Defining our types

Before writing any tool logic, let's define the types we'll work with. This keeps everything clean and centralized.

// src/types.ts

export interface GitHubIssue {
  number: number;
  title: string;
  body: string | null;
  state: "open" | "closed";
  author: string;
  labels: string[];
  createdAt: string;
  updatedAt: string;
}

export interface GitHubComment {
  id: number;
  body: string;
  author: string;
  createdAt: string;
}

export interface GitHubRepository {
  owner: string;
  name: string;
  fullName: string;
  description: string | null;
  defaultBranch: string;
}

export interface ServerConfiguration {
  githubToken: string;
  owner: string;
  repository: string;
}

Building the GitHub client

We wrap the GitHub API in a clean client class. This keeps our tool handlers focused on MCP logic rather than HTTP details.

// src/github-client.ts

import type {
  GitHubIssue,
  GitHubComment,
  ServerConfiguration,
} from "./types.js";

export class GitHubClient {
  private readonly baseUrl = "https://api.github.com";
  private readonly headers: Record<string, string>;
  private readonly owner: string;
  private readonly repository: string;

  constructor(configuration: ServerConfiguration) {
    this.owner = configuration.owner;
    this.repository = configuration.repository;
    this.headers = {
      Authorization: `Bearer ${configuration.githubToken}`,
      Accept: "application/vnd.github.v3+json",
      "User-Agent": "github-issues-mcp-server",
    };
  }

  async listIssues(state: "open" | "closed" | "all"): Promise<GitHubIssue[]> {
    const url = `${this.baseUrl}/repos/${this.owner}/${this.repository}/issues?state=${state}&per_page=30`;
    const response = await fetch(url, { headers: this.headers });

    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
    }

    const rawIssues = await response.json();
    return rawIssues.map(this.mapToGitHubIssue);
  }

  async createIssue(title: string, body: string, labels: string[]): Promise<GitHubIssue> {
    const url = `${this.baseUrl}/repos/${this.owner}/${this.repository}/issues`;
    const response = await fetch(url, {
      method: "POST",
      headers: this.headers,
      body: JSON.stringify({ title, body, labels }),
    });

    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
    }

    const rawIssue = await response.json();
    return this.mapToGitHubIssue(rawIssue);
  }

  async addComment(issueNumber: number, body: string): Promise<GitHubComment> {
    const url = `${this.baseUrl}/repos/${this.owner}/${this.repository}/issues/${issueNumber}/comments`;
    const response = await fetch(url, {
      method: "POST",
      headers: this.headers,
      body: JSON.stringify({ body }),
    });

    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
    }

    const rawComment = await response.json();
    return {
      id: rawComment.id,
      body: rawComment.body,
      author: rawComment.user.login,
      createdAt: rawComment.created_at,
    };
  }

  async searchIssues(query: string): Promise<GitHubIssue[]> {
    const searchQuery = `${query} repo:${this.owner}/${this.repository} is:issue`;
    const url = `${this.baseUrl}/search/issues?q=${encodeURIComponent(searchQuery)}&per_page=20`;
    const response = await fetch(url, { headers: this.headers });

    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
    }

    const result = await response.json();
    return result.items.map(this.mapToGitHubIssue);
  }

  async getReadme(): Promise<string> {
    const url = `${this.baseUrl}/repos/${this.owner}/${this.repository}/readme`;
    const response = await fetch(url, { headers: this.headers });

    if (!response.ok) {
      return "README not found for this repository.";
    }

    const data = await response.json();
    return Buffer.from(data.content, "base64").toString("utf-8");
  }

  private mapToGitHubIssue(raw: Record<string, unknown>): GitHubIssue {
    const user = raw.user as Record<string, unknown>;
    const labels = raw.labels as Array<Record<string, unknown>>;

    return {
      number: raw.number as number,
      title: raw.title as string,
      body: (raw.body as string) ?? null,
      state: raw.state as "open" | "closed",
      author: user.login as string,
      labels: labels.map((label) => label.name as string),
      createdAt: raw.created_at as string,
      updatedAt: raw.updated_at as string,
    };
  }
}

Defining tools with Zod schemas

This is where MCP really shines. Each tool gets a Zod schema that defines its inputs. The MCP SDK uses this both for validation and to tell the AI client what parameters are available. The AI reads these descriptions to understand how to call your tools.

// src/tools/list-issues.ts

import { z } from "zod";

export const listIssuesSchema = z.object({
  state: z
    .enum(["open", "closed", "all"])
    .default("open")
    .describe("Filter issues by state. Defaults to open issues."),
});

export type ListIssuesInput = z.infer<typeof listIssuesSchema>;
// src/tools/create-issue.ts

import { z } from "zod";

export const createIssueSchema = z.object({
  title: z
    .string()
    .min(1)
    .max(256)
    .describe("The title of the issue to create."),
  body: z
    .string()
    .max(65536)
    .describe("The body content of the issue. Supports markdown."),
  labels: z
    .array(z.string())
    .default([])
    .describe("Labels to apply to the issue."),
});

export type CreateIssueInput = z.infer<typeof createIssueSchema>;
// src/tools/search-issues.ts

import { z } from "zod";

export const searchIssuesSchema = z.object({
  query: z
    .string()
    .min(1)
    .max(256)
    .describe("Search query to find issues. Supports GitHub search syntax."),
});

export type SearchIssuesInput = z.infer<typeof searchIssuesSchema>;
// src/tools/add-comment.ts

import { z } from "zod";

export const addCommentSchema = z.object({
  issueNumber: z
    .number()
    .int()
    .positive()
    .describe("The issue number to comment on."),
  body: z
    .string()
    .min(1)
    .max(65536)
    .describe("The comment body. Supports markdown."),
});

export type AddCommentInput = z.infer<typeof addCommentSchema>;

Notice how each field has a .describe() call. These descriptions are sent to the AI client and directly influence how well the agent uses your tools. Be clear and specific.

Implementing tool handlers

Now let's wire everything together in the server entry point. The McpServer class from the SDK makes this straightforward. You register tools with their schemas and handler functions.

// src/index.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { GitHubClient } from "./github-client.js";
import { listIssuesSchema } from "./tools/list-issues.js";
import { createIssueSchema } from "./tools/create-issue.js";
import { addCommentSchema } from "./tools/add-comment.js";
import { searchIssuesSchema } from "./tools/search-issues.js";
import type { ServerConfiguration } from "./types.js";

function loadConfiguration(): ServerConfiguration {
  const githubToken = process.env.GITHUB_TOKEN;
  const owner = process.env.GITHUB_OWNER;
  const repository = process.env.GITHUB_REPO;

  if (!githubToken || !owner || !repository) {
    throw new Error(
      "Missing required environment variables: GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO"
    );
  }

  return { githubToken, owner, repository };
}

function createServer(githubClient: GitHubClient): McpServer {
  const server = new McpServer({
    name: "github-issues",
    version: "1.0.0",
  });

  server.tool(
    "list_issues",
    "List issues in the GitHub repository. Returns issue number, title, state, author, and labels.",
    listIssuesSchema.shape,
    async ({ state }) => {
      const issues = await githubClient.listIssues(state);
      return {
        content: [{ type: "text", text: JSON.stringify(issues, null, 2) }],
      };
    }
  );

  server.tool(
    "create_issue",
    "Create a new issue in the GitHub repository.",
    createIssueSchema.shape,
    async ({ title, body, labels }) => {
      const issue = await githubClient.createIssue(title, body, labels);
      return {
        content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
      };
    }
  );

  server.tool(
    "add_comment",
    "Add a comment to an existing issue.",
    addCommentSchema.shape,
    async ({ issueNumber, body }) => {
      const comment = await githubClient.addComment(issueNumber, body);
      return {
        content: [{ type: "text", text: JSON.stringify(comment, null, 2) }],
      };
    }
  );

  server.tool(
    "search_issues",
    "Search for issues using GitHub search syntax.",
    searchIssuesSchema.shape,
    async ({ query }) => {
      const issues = await githubClient.searchIssues(query);
      return {
        content: [{ type: "text", text: JSON.stringify(issues, null, 2) }],
      };
    }
  );

  return server;
}

async function main(): Promise<void> {
  const configuration = loadConfiguration();
  const githubClient = new GitHubClient(configuration);
  const server = createServer(githubClient);
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch((error: unknown) => {
  process.stderr.write(`Fatal error: ${String(error)}\n`);
  process.exit(1);
});

Each tool handler receives the validated input and returns a content array. The MCP SDK handles serialization, validation, and transport for you.

Adding resources

Resources give the AI read-only access to data without needing to call a tool. They're perfect for context the agent might need, like a repository README or a summary of recent issues.

Add these resource registrations inside the createServer function, right after the tool registrations:

server.resource(
  "repository-readme",
  "repo://readme",
  { description: "The repository README file" },
  async () => {
    const readmeContent = await githubClient.getReadme();
    return {
      contents: [
        {
          uri: "repo://readme",
          mimeType: "text/markdown",
          text: readmeContent,
        },
      ],
    };
  }
);

server.resource(
  "recent-issues",
  "repo://issues/recent",
  { description: "The 10 most recently updated open issues" },
  async () => {
    const issues = await githubClient.listIssues("open");
    const recentIssues = issues.slice(0, 10);
    return {
      contents: [
        {
          uri: "repo://issues/recent",
          mimeType: "application/json",
          text: JSON.stringify(recentIssues, null, 2),
        },
      ],
    };
  }
);

When an AI client connects, it can discover these resources and read them to build context before making tool calls. This is particularly useful for agents that need to understand the project before taking actions.

Testing with MCP Inspector

Before connecting to Claude or any other client, you should test your server with the MCP Inspector. It's a browser-based tool that lets you interact with your server directly.

npx @modelcontextprotocol/inspector node dist/index.js

Make sure your environment variables are set before running. The inspector will open in your browser, showing all registered tools and resources. You can call each tool with test inputs and see the raw responses. This is invaluable for debugging, you don't need an LLM in the loop to verify your server works.

Connecting to Claude Desktop

To use your server with Claude Desktop, add it to the configuration file. On macOS it lives at ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "github-issues": {
      "command": "node",
      "args": ["/absolute/path/to/github-issues-mcp/dist/index.js"],
      "env": {
        "GITHUB_TOKEN": "ghp_your_token_here",
        "GITHUB_OWNER": "your-org",
        "GITHUB_REPO": "your-repo"
      }
    }
  }
}

Restart Claude Desktop, and you'll see your tools appear in the tools menu. Now you can say things like "show me all open issues labeled bug" or "create an issue for the login page crash" and Claude will use your MCP server to interact with GitHub directly.

Deploying as HTTP server

For team-wide access, you'll want to expose your server over HTTP instead of stdio. The SDK provides Express middleware for this. Install Express first:

npm install express
npm install -D @types/express

Then create a separate entry point:

// src/http-server.ts

import express, { type Request, type Response } from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { GitHubClient } from "./github-client.js";
import type { ServerConfiguration } from "./types.js";

function loadConfiguration(): ServerConfiguration {
  const githubToken = process.env.GITHUB_TOKEN;
  const owner = process.env.GITHUB_OWNER;
  const repository = process.env.GITHUB_REPO;

  if (!githubToken || !owner || !repository) {
    throw new Error(
      "Missing required environment variables: GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO"
    );
  }

  return { githubToken, owner, repository };
}

async function startHttpServer(): Promise<void> {
  const configuration = loadConfiguration();
  const githubClient = new GitHubClient(configuration);
  const application = express();
  const port = parseInt(process.env.PORT ?? "3001", 10);

  application.use(express.json());

  application.post("/mcp", async (request: Request, response: Response) => {
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
    });

    const server = new McpServer({
      name: "github-issues",
      version: "1.0.0",
    });

    // Register tools and resources here (same as before)

    await server.connect(transport);
    await transport.handleRequest(request, response, request.body);
  });

  application.listen(port, () => {
    process.stderr.write(`MCP HTTP server listening on port ${port}\n`);
  });
}

startHttpServer().catch((error: unknown) => {
  process.stderr.write(`Fatal error: ${String(error)}\n`);
  process.exit(1);
});

Now remote MCP clients can connect to http://your-server:3001/mcp instead of spawning a local process.

Security hardening

An MCP server is essentially an API that an AI agent calls autonomously, which means security is critical. Here's what you should do before deploying to production.

Validate all inputs aggressively. Zod schemas are your first line of defense, but add additional validation in your handlers. Sanitize strings, enforce length limits, and reject unexpected patterns.

Protect against prompt injection through tool results. This is the big one. If your tool returns data from external sources (like GitHub issue bodies), a malicious user could embed instructions in that data that trick the AI into taking unintended actions. Always treat tool results as untrusted. Consider stripping or escaping suspicious patterns in returned content:

function sanitizeToolOutput(content: string): string {
  const suspiciousPatterns = [
    /ignore previous instructions/gi,
    /you are now/gi,
    /system prompt/gi,
  ];

  let sanitizedContent = content;
  for (const pattern of suspiciousPatterns) {
    sanitizedContent = sanitizedContent.replace(pattern, "[filtered]");
  }

  return sanitizedContent;
}

Add authentication for HTTP deployments. Use Bearer tokens or OAuth. Never expose an MCP server to the network without auth:

function authenticateRequest(
  request: Request,
  response: Response,
  next: () => void
): void {
  const authorizationHeader = request.headers.authorization;
  const expectedToken = process.env.MCP_AUTH_TOKEN;

  if (!authorizationHeader || authorizationHeader !== `Bearer ${expectedToken}`) {
    response.status(401).json({ error: "Unauthorized" });
    return;
  }

  next();
}

Implement rate limiting. AI agents can be chatty. Use a middleware like express-rate-limit to prevent abuse.

Use least-privilege tokens. Your GitHub token should have the minimum scopes needed. For this server, repo scope is sufficient. Don't use tokens with admin or delete permissions unless your tools specifically need them.

Log everything. Every tool invocation should be logged with its inputs and the identity of the caller. This gives you an audit trail when things go wrong.

Conclusion

MCP is still early, but the trajectory is clear. With Anthropic, OpenAI, and Mistral all on board and governance under the Linux Foundation, this is becoming the standard way AI agents interact with the world.

What we built here is a solid foundation. A typed, validated, testable MCP server that exposes real functionality to any compatible AI client. The same patterns apply whether you're wrapping a database, a CI/CD pipeline, a CMS, or any other system your AI agents need to work with.

The key takeaways:

  • Type everything. Zod schemas serve double duty as validation and AI documentation.
  • Keep handlers thin. Business logic belongs in dedicated service classes, not tool handlers.
  • Test without LLMs. The MCP Inspector lets you verify behavior deterministically.
  • Secure by default. Treat every tool result as untrusted content, validate inputs aggressively, and authenticate network access.

The GitHub Issues server is a starting point. Fork it, swap in your own API, and give your AI agents the tools they need.