Snippets.

February 4, 2026

AIClaude API25 min read

Building a Custom MCP Server in TypeScript

Custom MCP server in TypeScript

Updated September 24, 2026: The code in this article has been updated for the latest MCP SDK and TypeScript versions. There's also a new GitHub link with the complete, working project you can clone and run: github.com/PaulliDev/github-issues-mcp

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.

A quick note before we start: GitHub already ships an official MCP server, and for day-to-day GitHub work you should use it. We build our own here because GitHub's API is familiar and free to test, which lets us focus on how MCP works instead of on a new domain. Swap the GitHub client for your own product's API and everything else stays the same.

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. You'll need Node.js 20.12 or newer.

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

npm pkg set type=module marks the project as an ES module. The MCP SDK ships ES modules, and without this line TypeScript treats your code as CommonJS and fails to compile against the SDK's types.

Replace the generated tsconfig.json with strict settings:

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

The "types": ["node"] line matters. Recent TypeScript versions no longer load @types/node automatically, so without it the compiler doesn't know about process or Buffer.

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

src/
├── index.ts                  # stdio entry point
├── http-server.ts            # Streamable HTTP entry point
├── server.ts                 # Registers tools and resources
├── configuration.ts          # Loads environment variables
├── github-client.ts          # GitHub API wrapper
├── types.ts                  # Shared type definitions
├── tools/
│   ├── list-issues.ts
│   ├── create-issue.ts
│   ├── add-comment.ts
│   └── search-issues.ts
└── security/
    ├── sanitize-tool-output.ts
    └── create-authentication-middleware.ts

Defining our types

Before writing any tool logic, let's define the types we'll work with. There are two groups. The first is what our server returns to the AI, plus its configuration. The second describes the raw GitHub API responses we read, so the rest of the code stays fully typed instead of casting every field. The GitHub client converts the raw shapes (snake_case, nested user objects) into the simpler ones the AI sees. The allowed issue states are defined once, as ISSUE_STATE_FILTERS, so the Zod schema and the client can't drift apart.

// src/types.ts

// The states an issue can be in, and the filters list_issues accepts.
// Defined once here and reused by the Zod schema and the GitHub client.
export const ISSUE_STATE_FILTERS = ["open", "closed", "all"] as const;

export type IssueStateFilter = (typeof ISSUE_STATE_FILTERS)[number];

export type IssueState = Exclude<IssueStateFilter, "all">;

// An issue as our tools return it to the AI: only the fields it needs, in camelCase.
export interface GitHubIssue {
  number: number;
  title: string;
  body: string | null;
  state: IssueState;
  author: string;
  labels: string[];
  createdAt: string;
  updatedAt: string;
}

// A comment as the add_comment tool returns it to the AI.
export interface GitHubComment {
  id: number;
  body: string;
  author: string;
  createdAt: string;
}

// Settings loaded from environment variables and passed to the GitHub client.
export interface ServerConfiguration {
  githubToken: string;
  owner: string;
  repository: string;
}

// The subset of GitHub REST API response fields this server reads.

// The author of an issue or comment, as GitHub returns it.
interface GitHubApiUser {
  login: string;
}

// A label attached to an issue, as GitHub returns it.
export interface GitHubApiLabel {
  name: string;
}

// A raw issue from GitHub. The client maps it to GitHubIssue.
// Pull requests come back from the same endpoint and carry a `pull_request` field.
export interface GitHubApiIssue {
  number: number;
  title: string;
  body: string | null;
  state: IssueState;
  user: GitHubApiUser;
  labels: GitHubApiLabel[];
  created_at: string;
  updated_at: string;
  pull_request?: unknown;
}

// A raw comment from GitHub. The client maps it to GitHubComment.
export interface GitHubApiComment {
  id: number;
  body: string;
  user: GitHubApiUser;
  created_at: string;
}

// The response of GitHub's search endpoint, used by search_issues.
export interface GitHubApiSearchResult {
  items: GitHubApiIssue[];
}

// The README endpoint response. `content` is base64 encoded.
export interface GitHubApiReadme {
  content: 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. A private request() helper does the fetch and the error check once, instead of repeating them in every method.

// src/github-client.ts

import type {
  GitHubApiComment,
  GitHubApiIssue,
  GitHubApiLabel,
  GitHubApiReadme,
  GitHubApiSearchResult,
  GitHubComment,
  GitHubIssue,
  IssueStateFilter,
  ServerConfiguration,
} from "./types.js";

const GITHUB_API_BASE_URL = "https://api.github.com";
const LIST_ISSUES_PAGE_SIZE = 30;
const SEARCH_ISSUES_PAGE_SIZE = 20;
const HTTP_STATUS_NOT_FOUND = 404;

// A thin wrapper around the GitHub REST API. Nothing in this file knows about MCP:
// the MCP tools in server.ts call these methods, so the tools stay small and the
// HTTP details live in one place.
export class GitHubClient {
  private readonly headers: Record<string, string>;
  private readonly owner: string;
  private readonly repository: string;
  private readonly repositoryPath: string;

  // One client per repository. The token is sent with every request.
  public constructor(configuration: ServerConfiguration) {
    this.owner = configuration.owner;
    this.repository = configuration.repository;
    // Encoded once, so a stray character in the configuration can't change the URL path.
    this.repositoryPath = `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repository)}`;
    this.headers = {
      Authorization: `Bearer ${configuration.githubToken}`,
      Accept: "application/vnd.github+json",
      "User-Agent": "github-issues-mcp-server",
    };
  }

  // Used by the list_issues tool and the recent-issues resource.
  // Returns up to 30 issues, most recently updated first.
  public async listIssues(state: IssueStateFilter): Promise<GitHubIssue[]> {
    const url = this.buildUrl(`${this.repositoryPath}/issues`, {
      state: state,
      sort: "updated",
      direction: "desc",
      per_page: String(LIST_ISSUES_PAGE_SIZE),
    });
    const rawIssues = await this.request<GitHubApiIssue[]>(url);

    // The issues endpoint also returns pull requests. They carry a `pull_request` field.
    return rawIssues
      .filter((rawIssue: GitHubApiIssue) => rawIssue.pull_request === undefined)
      .map(mapToGitHubIssue);
  }

  // Used by the create_issue tool. Returns the issue GitHub created, including its new number.
  public async createIssue(title: string, body: string, labels: string[]): Promise<GitHubIssue> {
    const url = this.buildUrl(`${this.repositoryPath}/issues`);
    const rawIssue = await this.request<GitHubApiIssue>(url, {
      method: "POST",
      body: JSON.stringify({ title: title, body: body, labels: labels }),
    });

    return mapToGitHubIssue(rawIssue);
  }

  // Used by the add_comment tool. Returns the comment GitHub created.
  public async addComment(issueNumber: number, body: string): Promise<GitHubComment> {
    const url = this.buildUrl(`${this.repositoryPath}/issues/${issueNumber}/comments`);
    const rawComment = await this.request<GitHubApiComment>(url, {
      method: "POST",
      body: JSON.stringify({ body: body }),
    });

    return mapToGitHubComment(rawComment);
  }

  // Used by the search_issues tool. The query is limited to this repository and to
  // issues only. The Zod schema rejects repo:, org:, and user: qualifiers, which
  // GitHub would otherwise combine with ours to search other repositories too.
  public async searchIssues(query: string): Promise<GitHubIssue[]> {
    const url = this.buildUrl("/search/issues", {
      q: `${query} repo:${this.owner}/${this.repository} is:issue`,
      per_page: String(SEARCH_ISSUES_PAGE_SIZE),
    });
    const result = await this.request<GitHubApiSearchResult>(url);

    return result.items.map(mapToGitHubIssue);
  }

  // Used by the repository-readme resource. A repository without a README (404) is
  // normal, so that case returns a message. Any other failure, such as a bad token,
  // is a real error and throws.
  public async getReadme(): Promise<string> {
    const url = this.buildUrl(`${this.repositoryPath}/readme`);
    const response = await fetch(url, { headers: this.headers });

    if (response.status === HTTP_STATUS_NOT_FOUND) {
      return "README not found for this repository.";
    }
    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
    }

    // Trusted boundary: the shape is defined by the GitHub REST API.
    const readme = (await response.json()) as GitHubApiReadme;

    // GitHub sends file contents base64 encoded.
    return Buffer.from(readme.content, "base64").toString("utf-8");
  }

  // Builds a full API URL. URLSearchParams encodes every query value for us.
  private buildUrl(path: string, queryParameters: Record<string, string> = {}): string {
    const url = new URL(path, GITHUB_API_BASE_URL);

    url.search = new URLSearchParams(queryParameters).toString();

    return url.toString();
  }

  // Shared by every method above: sends the request with our headers, turns an HTTP
  // error into an exception, and parses the JSON body. The MCP SDK catches the
  // exception and reports it to the AI as a failed tool call.
  private async request<TResponse>(url: string, init: RequestInit = {}): Promise<TResponse> {
    const response = await fetch(url, { ...init, headers: this.headers });

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

    // Trusted boundary: the shape is defined by the GitHub REST API.
    return (await response.json()) as TResponse;
  }
}

// Converts GitHub's raw issue into the smaller, camelCase shape our tools return to the AI.
function mapToGitHubIssue(rawIssue: GitHubApiIssue): GitHubIssue {
  return {
    number: rawIssue.number,
    title: rawIssue.title,
    body: rawIssue.body,
    state: rawIssue.state,
    author: rawIssue.user.login,
    labels: rawIssue.labels.map((label: GitHubApiLabel) => label.name),
    createdAt: rawIssue.created_at,
    updatedAt: rawIssue.updated_at,
  };
}

// Converts GitHub's raw comment into the shape the add_comment tool returns to the AI.
function mapToGitHubComment(rawComment: GitHubApiComment): GitHubComment {
  return {
    id: rawComment.id,
    body: rawComment.body,
    author: rawComment.user.login,
    createdAt: rawComment.created_at,
  };
}

Two details are easy to miss. GitHub's /issues endpoint returns pull requests as well as issues (a PR is an issue with a pull_request field), so listIssues filters them out. And it asks for sort=updated, because the API sorts by creation date by default. URLs are built with URL and URLSearchParams, so every value is encoded properly, and getReadme treats only a 404 as "no README". A bad token or a GitHub outage is a real error and gets reported as one.

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";
import { ISSUE_STATE_FILTERS } from "../types.js";

export const listIssuesSchema = z.object({
  state: z
    .enum(ISSUE_STATE_FILTERS)
    .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";

// GitHub combines several repo: qualifiers with OR, so "bug repo:someone/else" would
// widen the search beyond our repository. Qualifiers that change the scope are rejected.
const SCOPE_QUALIFIER_PATTERN = /(^|\s)-?(repo|org|user):/i;

export const searchIssuesSchema = z.object({
  query: z
    .string()
    .min(1)
    .max(256)
    .refine((query: string) => !SCOPE_QUALIFIER_PATTERN.test(query), {
      message: "The repo:, org:, and user: qualifiers are not allowed.",
    })
    .describe(
      "Search query to find issues in this repository. Supports GitHub search syntax such as label:bug or is:open, but not repo:, org:, or user:."
    ),
});

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.

The search schema also shows why validation has to understand the API behind it. Our client appends repo:owner/name to every query to keep searches inside one repository. But GitHub combines several repo: qualifiers with OR, so a query like bug repo:someone/else would quietly search a second repository too. The .refine() check rejects repo:, org:, and user: before the request is ever made.

Loading configuration

Both of our entry points (stdio and HTTP) need the same settings, so configuration lives in one place and fails fast when something is missing. loadEnvironmentFile uses Node's built-in process.loadEnvFile() to read a local .env file during development, with no extra package. Variables that are already set win, so when Claude Desktop passes them in its config, those are used:

// src/configuration.ts

import { existsSync } from "node:fs";
import type { ServerConfiguration } from "./types.js";

const ENVIRONMENT_FILE_PATH = ".env";

// Loads .env from the working directory when present. Variables already set
// (for example by Claude Desktop's "env" block) are not overwritten.
export function loadEnvironmentFile(): void {
  if (existsSync(ENVIRONMENT_FILE_PATH)) {
    process.loadEnvFile(ENVIRONMENT_FILE_PATH);
  }
}

export 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: githubToken, owner: owner, repository: repository };
}

Filtering tool output

Every issue title, body, and comment our tools return was written by someone on the internet. That text goes straight into the AI's context, so a malicious issue could contain instructions aimed at the agent. This is called prompt injection, and we'll come back to it in the security section. For now, add a small filter that every tool result will pass through:

// src/security/sanitize-tool-output.ts

const SUSPICIOUS_PATTERNS: RegExp[] = [
  /ignore (all )?previous instructions/gi,
  /you are now/gi,
  /system prompt/gi,
];

// A cheap first filter, not a guarantee: the client must still treat tool results as untrusted.
export function sanitizeToolOutput(content: string): string {
  let sanitizedContent = content;

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

  return sanitizedContent;
}

Registering tools and resources

Now let's wire everything together. The McpServer class from the SDK makes this straightforward: you register tools with registerTool() and resources with registerResource(). We put this in its own createServer function so the stdio server and the HTTP server can share it.

// src/server.ts

import { readFileSync } from "node:fs";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { GitHubClient } from "./github-client.js";
import { sanitizeToolOutput } from "./security/sanitize-tool-output.js";
import { addCommentSchema, type AddCommentInput } from "./tools/add-comment.js";
import { createIssueSchema, type CreateIssueInput } from "./tools/create-issue.js";
import { listIssuesSchema, type ListIssuesInput } from "./tools/list-issues.js";
import { searchIssuesSchema, type SearchIssuesInput } from "./tools/search-issues.js";

const SERVER_NAME = "github-issues";
const RECENT_ISSUES_LIMIT = 10;

// Reads the version from package.json so the server never reports a stale one.
function readPackageVersion(): string {
  const packageJsonUrl = new URL("../package.json", import.meta.url);
  const packageJson: unknown = JSON.parse(readFileSync(packageJsonUrl, "utf-8"));

  if (
    typeof packageJson === "object" &&
    packageJson !== null &&
    "version" in packageJson &&
    typeof packageJson.version === "string"
  ) {
    return packageJson.version;
  }

  throw new Error("package.json is missing a version");
}

// Every tool returns its data as pretty-printed JSON text, filtered for prompt injection.
function toTextResult(data: unknown): CallToolResult {
  return {
    content: [{ type: "text", text: sanitizeToolOutput(JSON.stringify(data, null, 2)) }],
  };
}

function registerTools(server: McpServer, githubClient: GitHubClient): void {
  server.registerTool(
    "list_issues",
    {
      description:
        "List issues in the GitHub repository, most recently updated first. Returns issue number, title, state, author, and labels.",
      inputSchema: listIssuesSchema.shape,
    },
    async (input: ListIssuesInput) => toTextResult(await githubClient.listIssues(input.state))
  );

  server.registerTool(
    "create_issue",
    {
      description: "Create a new issue in the GitHub repository.",
      inputSchema: createIssueSchema.shape,
    },
    async (input: CreateIssueInput) =>
      toTextResult(await githubClient.createIssue(input.title, input.body, input.labels))
  );

  server.registerTool(
    "add_comment",
    {
      description: "Add a comment to an existing issue.",
      inputSchema: addCommentSchema.shape,
    },
    async (input: AddCommentInput) =>
      toTextResult(await githubClient.addComment(input.issueNumber, input.body))
  );

  server.registerTool(
    "search_issues",
    {
      description:
        "Search for issues in this repository using GitHub search syntax (for example label:bug is:open).",
      inputSchema: searchIssuesSchema.shape,
    },
    async (input: SearchIssuesInput) => toTextResult(await githubClient.searchIssues(input.query))
  );
}

function registerResources(server: McpServer, githubClient: GitHubClient): void {
  server.registerResource(
    "repository-readme",
    "repo://readme",
    { description: "The repository README file", mimeType: "text/markdown" },
    async (uri: URL) => ({
      contents: [
        {
          uri: uri.href,
          mimeType: "text/markdown",
          text: sanitizeToolOutput(await githubClient.getReadme()),
        },
      ],
    })
  );

  server.registerResource(
    "recent-issues",
    "repo://issues/recent",
    {
      description: `The ${RECENT_ISSUES_LIMIT} most recently updated open issues`,
      mimeType: "application/json",
    },
    async (uri: URL) => {
      const issues = await githubClient.listIssues("open");
      const recentIssues = issues.slice(0, RECENT_ISSUES_LIMIT);

      return {
        contents: [
          {
            uri: uri.href,
            mimeType: "application/json",
            text: sanitizeToolOutput(JSON.stringify(recentIssues, null, 2)),
          },
        ],
      };
    }
  );
}

export function createServer(githubClient: GitHubClient): McpServer {
  const server = new McpServer({
    name: SERVER_NAME,
    version: readPackageVersion(),
  });

  registerTools(server, githubClient);
  registerResources(server, githubClient);

  return server;
}

Each tool handler receives the validated input and returns a content array. The MCP SDK handles serialization, validation, and transport for you. If a handler throws (for example, GitHub returns a 404), the SDK sends the error back to the client as a tool error instead of crashing the server.

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. 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.

The stdio entry point is now tiny:

// src/index.ts

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { loadConfiguration, loadEnvironmentFile } from "./configuration.js";
import { GitHubClient } from "./github-client.js";
import { createServer } from "./server.js";

async function main(): Promise<void> {
  loadEnvironmentFile();

  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);
});

Add build and run scripts:

npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/index.js"
npm pkg set scripts.inspect="npx @modelcontextprotocol/inspector node dist/index.js"

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.

First, create a GitHub fine-grained personal access token with Issues: Read and write access to a test repository. Then create a .env file in the project root (and add .env to your .gitignore so the token never gets committed):

GITHUB_TOKEN=github_pat_your_token_here
GITHUB_OWNER=your-org
GITHUB_REPO=your-repo

Build and start the inspector:

npm run build
npm run inspect

The inspector does not pass your terminal's environment variables to the server it starts, so export GITHUB_TOKEN=... in your shell isn't enough. That's why the server reads .env itself. The inspector opens 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. Try invalid input too, like a negative issue number: Zod rejects it before your handler ever runs.

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": "github_pat_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. Install Express and a rate limiter:

npm install express express-rate-limit
npm install -D @types/express

A network-facing MCP server must never run without authentication, so we write the auth middleware first. It takes the expected token as an argument, which forces the caller to decide what happens when the token isn't configured:

// src/security/create-authentication-middleware.ts

import { createHash, timingSafeEqual } from "node:crypto";
import type { NextFunction, Request, RequestHandler, Response } from "express";

function hashToken(token: string): Buffer {
  return createHash("sha256").update(token).digest();
}

// Takes the expected token as an argument so the caller must decide what happens when it is missing.
export function createAuthenticationMiddleware(expectedToken: string): RequestHandler {
  const expectedHeaderHash = hashToken(`Bearer ${expectedToken}`);

  return (request: Request, response: Response, next: NextFunction): void => {
    const authorizationHeader = request.headers.authorization ?? "";
    // Hashing both sides gives equal-length buffers, which timingSafeEqual requires.
    const isAuthorized = timingSafeEqual(hashToken(authorizationHeader), expectedHeaderHash);

    if (!isAuthorized) {
      response.status(401).json({ error: "Unauthorized" });
      return;
    }

    next();
  };
}

Now the HTTP entry point. It runs in stateless mode: every request gets a fresh server and transport, which are closed when the response ends. That keeps it simple to scale horizontally, since no session state lives in memory.

// src/http-server.ts

import express, { type Request, type Response } from "express";
import { rateLimit } from "express-rate-limit";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { loadConfiguration, loadEnvironmentFile } from "./configuration.js";
import { GitHubClient } from "./github-client.js";
import { createAuthenticationMiddleware } from "./security/create-authentication-middleware.js";
import { createServer } from "./server.js";

const RATE_LIMIT_WINDOW_MILLISECONDS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 60;
const DEFAULT_PORT = 3001;
const MAXIMUM_PORT = 65535;

function loadAuthenticationToken(): string {
  const authenticationToken = process.env.MCP_AUTH_TOKEN;

  if (!authenticationToken) {
    throw new Error("Missing required environment variable: MCP_AUTH_TOKEN");
  }

  return authenticationToken;
}

// Reads PORT from the environment. Rejects values like "abc" or "70000" instead of
// silently starting on NaN or an invalid port.
function loadPort(): number {
  const rawPort = process.env.PORT ?? String(DEFAULT_PORT);
  const port = Number(rawPort);

  if (!Number.isInteger(port) || port < 1 || port > MAXIMUM_PORT) {
    throw new Error(`Invalid PORT: "${rawPort}". Use a whole number between 1 and ${MAXIMUM_PORT}.`);
  }

  return port;
}

function sendJsonRpcError(response: Response, status: number, code: number, message: string): void {
  response.status(status).json({ jsonrpc: "2.0", error: { code: code, message: message }, id: null });
}

async function handleMcpRequest(
  githubClient: GitHubClient,
  request: Request,
  response: Response
): Promise<void> {
  // Stateless mode: a fresh server and transport per request, closed when the response ends.
  const server = createServer(githubClient);
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });

  response.on("close", () => {
    void transport.close();
    void server.close();
  });

  try {
    await server.connect(transport);
    await transport.handleRequest(request, response, request.body);
  } catch (error: unknown) {
    process.stderr.write(`Error handling MCP request: ${String(error)}\n`);

    if (!response.headersSent) {
      sendJsonRpcError(response, 500, -32603, "Internal server error");
    }
  }
}

async function startHttpServer(): Promise<void> {
  loadEnvironmentFile();

  const configuration = loadConfiguration();
  const authenticationToken = loadAuthenticationToken();
  const githubClient = new GitHubClient(configuration);
  const application = express();
  const port = loadPort();

  application.use(express.json());
  application.use(
    rateLimit({ windowMs: RATE_LIMIT_WINDOW_MILLISECONDS, limit: RATE_LIMIT_MAX_REQUESTS })
  );
  application.use(createAuthenticationMiddleware(authenticationToken));

  application.post("/mcp", (request: Request, response: Response) =>
    handleMcpRequest(githubClient, request, response)
  );
  // Stateless servers have no session stream to open (GET) or end (DELETE).
  application.get("/mcp", (_request: Request, response: Response) =>
    sendJsonRpcError(response, 405, -32000, "Method not allowed.")
  );
  application.delete("/mcp", (_request: Request, response: Response) =>
    sendJsonRpcError(response, 405, -32000, "Method not allowed.")
  );

  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);
});

Add MCP_AUTH_TOKEN (a long random string) and optionally PORT to your .env, add a start script, and run it:

npm pkg set scripts.start:http="node dist/http-server.js"
npm run build
npm run start:http

Now remote MCP clients can connect to http://your-server:3001/mcp with the header Authorization: Bearer <your MCP_AUTH_TOKEN> instead of spawning a local process. Put it behind HTTPS in production.

Calling the HTTP server

Streamable HTTP is regular HTTP with one twist. Every message is a POST to the same /mcp URL, and what you want goes in the JSON body using the JSON-RPC format. The server can answer with plain JSON or keep the response open as a stream of Server-Sent Events, which is why replies look like event: message followed by data: {...}.

That means you can test it with nothing but curl. List the tools:

curl -s http://localhost:3001/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Authorization: Bearer <your MCP_AUTH_TOKEN>' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Call a tool:

curl -s http://localhost:3001/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Authorization: Bearer <your MCP_AUTH_TOKEN>' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_issues","arguments":{"state":"open"}}}'

A few things to check while you're at it:

  • Leave out the Authorization header and you get 401. A GET returns 405, because a stateless server has no session stream to open.
  • Because the server is stateless, you can call tools/list directly. A full MCP client sends an initialize request first, and the server handles that too.
  • The MCP Inspector works here as well: pick Streamable HTTP as the transport, enter http://localhost:3001/mcp, and add the Authorization header.

Once these calls work on localhost, publishing the server online is a hosting question, not a code question. Run it on any host that can run Node, put it behind HTTPS, and give your team the URL and a token.

Security hardening

An MCP server is essentially an API that an AI agent calls autonomously, which means security is critical. Here's what we built in.

Validate inputs against how the API really behaves. Zod schemas are the first line of defense: types, length limits, positive issue numbers. The search qualifier check is the reminder that a schema is only as good as your understanding of the API behind it.

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. That's why every tool result and resource in server.ts goes through sanitizeToolOutput. Be honest about what it is, though: a pattern list catches the lazy attacks, not a determined one. Always treat tool results as untrusted on the client side too, and require human confirmation for destructive actions.

Add authentication for HTTP deployments. Our middleware compares tokens with timingSafeEqual so response times don't leak how much of a guessed token was correct, and the server refuses to start without MCP_AUTH_TOKEN. That second part matters more than it looks. A common version of this check compares the header against `Bearer ${process.env.MCP_AUTH_TOKEN}`. If the variable is unset, that string becomes "Bearer undefined", and anyone who sends exactly that header gets in. For multi-user deployments, move to OAuth.

Implement rate limiting. AI agents can be chatty. We applied express-rate-limit at 60 requests per minute; tune it for your traffic.

Use least-privilege tokens. Your GitHub token should have the minimum permissions needed. A fine-grained token limited to one repository with Issues: Read and write is enough for this server. Don't use tokens with admin or delete permissions unless your tools specifically need them.

Next steps before production

The server is deliberately small. Before you put it in front of a team, add:

  • Audit logging. Log every tool invocation with its inputs and the identity of the caller, so you have a trail when something goes wrong. Log to stderr or a logging service, never stdout: in stdio mode, stdout is the MCP protocol channel.
  • Stricter content rules. If your domain has them, enforce them in the handlers: allowed labels, blocked words in titles, maximum comments per issue.
  • Human confirmation for writes. create_issue and add_comment change real data. Mark them with tool annotations (destructiveHint) and make sure your client asks before running them.
  • OAuth for multi-user HTTP deployments, so each caller acts with their own identity instead of one shared token.

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.
  • Share one createServer. Every transport should expose the same tools from the same code.
  • 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.