← All articles

TypeScript MCP Starter: Build a Production-Ready MCP Server

June 25, 2026·35 min read·MCPForge

What Is a TypeScript MCP Server?

A TypeScript MCP server is a Node.js application that implements the Model Context Protocol (MCP) and exposes tools, resources, and capabilities to AI agents like Claude, Cursor, or Windsurf.

Instead of manually switching context between your codebase, APIs, and AI tools, a TypeScript MCP server gives AI assistants direct, controlled access to your systems through structured tool invocations.

Building an MCP server is easy.

Building one that can safely run in production is not.

Most examples stop after exposing a few tools. They rarely discuss authentication, permissions, structured logging, deployment, monitoring, configuration management, or long-term maintenance.

That works for demos.

Want to analyze your API security?

Import your OpenAPI spec and generate a Security Report automatically.

It does not work for production.

This guide walks through building a complete TypeScript MCP starter designed for real-world deployments — from project setup and architecture decisions through deployment, scaling, and enterprise governance.

Whether you are building an internal AI platform, exposing existing APIs to Claude, integrating with Cursor, or creating your own MCP products, this starter provides a foundation that scales.

What You Will Build

By the end of this guide you will have a production-ready TypeScript MCP server including:

  • Complete TypeScript project structure
  • MCP SDK integration
  • Modular tool architecture
  • Environment-based configuration
  • Authentication and authorization layer
  • Request validation with Zod
  • Structured logging with Pino
  • Centralized error handling
  • Health monitoring endpoints
  • Docker containerization
  • CI/CD deployment pipeline
  • Security hardening
  • Enterprise governance patterns

The goal is not simply to make MCP work.

The goal is to create an MCP server that remains maintainable after months of development and dozens of integrations.

Why Another MCP Starter?

Many repositories look like this:

typescript
server.tool(...)
server.tool(...)
server.tool(...)
server.listen()

After a few weeks they become impossible to maintain, difficult to extend, hard to secure, impossible to test, and filled with duplicated logic.

Production systems require a different architecture.

Instead of placing everything inside one file, we will separate responsibilities into reusable modules — each with a single, well-defined purpose.

High-Level Architecture

Before writing any code, it helps to understand how a production TypeScript MCP server is structured.

                    Claude Desktop
                          │
                     MCP Protocol
                          │
                  TypeScript MCP Server
                          │
        ┌─────────────────┼──────────────────┐
        │                 │                  │
 Authentication      Tool Registry      Health Check
        │                 │                  │
 Validation         Business Logic       Monitoring
        │                 │                  │
 Structured Logs     External APIs      Metrics
        │                 │                  │
     Database       Third-party APIs     Cache

Each layer has a single responsibility.

That makes the project easier to maintain, easier to test, and significantly easier to secure.

Project Structure

The recommended folder layout looks like this:

src/
  server.ts
  config/
    env.ts
    constants.ts
  middleware/
    auth.ts
    validation.ts
    logger.ts
  tools/
    index.ts
    github.ts
    clerk.ts
    users.ts
  services/
    github.service.ts
    clerk.service.ts
  utils/
    errors.ts
    responses.ts
  health/
    health.ts
  types/
    index.ts

package.json
tsconfig.json
Dockerfile
.env.example
README.md

Notice what is missing.

There is no giant `server.ts` containing two thousand lines of code.

Each feature lives inside its own module.

Why This Structure Scales

Imagine six months from now.

You add GitHub integration, Clerk integration, PostgreSQL, Stripe, audit logs, OAuth, RBAC, approval workflows, and analytics.

If everything lives inside a single file, every new feature increases complexity for every other feature.

With modular architecture, each feature remains isolated. That means fewer merge conflicts, easier debugging, simpler testing, reusable components, and predictable project organization.

Core Design Principles

This starter follows several principles that separate demo projects from production systems.

Configuration Lives Outside Code

Never hardcode API keys, secrets, endpoints, or credentials. Everything should come from environment variables.

Every Tool Is Independent

Each MCP tool should own its business logic. Adding a new tool should not require modifying existing ones.

tools/
  github.ts
  clerk.ts
  slack.ts
  stripe.ts

Business Logic Never Lives Inside Tool Registration

Bad pattern:

typescript
server.tool("createUser", async () => {
  // 200 lines of mixed concerns
});

Good pattern:

typescript
server.tool("createUser", createUserSchema, createUserHandler)

The handler contains the business logic. The MCP server simply registers it.

External APIs Stay Isolated

Never mix API calls with MCP infrastructure.

Tool
  ↓
Service
  ↓
External API
  ↓
Response

This makes changing providers significantly easier and enables testing with mocks.

Technologies Used

TechnologyPurpose
TypeScriptType safety and maintainability
Node.js 20+Runtime
MCP SDKProtocol implementation
ZodInput validation
dotenvEnvironment configuration
PinoStructured logging
tsxDevelopment hot reload
DockerContainerization

Additional technologies like Redis, PostgreSQL, OAuth, or Railway can be layered on later without changing the overall architecture.

Prerequisites

Before starting you will need:

  • Node.js 20 or newer
  • npm, pnpm, or yarn
  • Git
  • Visual Studio Code (recommended)
  • An MCP client for testing (Claude Desktop, Claude Code, Cursor, or Windsurf)

Create the Project

Create a new project directory and initialize it.

bash
mkdir typescript-mcp-starter
cd typescript-mcp-starter
npm init -y

Install Dependencies

Install the required runtime packages.

bash
npm install @modelcontextprotocol/sdk zod dotenv pino

Install development dependencies.

bash
npm install -D typescript tsx @types/node

Why These Packages?

MCP SDK — implements the Model Context Protocol itself. Without it, there is no MCP server.

Zod — validates every incoming request before business logic executes. Never trust incoming data, even from AI agents.

dotenv — separates configuration from code, allowing different settings for development, staging, and production without changing the codebase.

Pino — provides structured logging with timestamps, severity levels, and searchable JSON output. Console.log becomes impossible to manage in production.

tsx — enables hot reload during development so you can iterate quickly without restarting manually.

Configure package.json

Replace the default file with a production-ready configuration.

json
{
  "name": "typescript-mcp-starter",
  "version": "1.0.0",
  "description": "Production-ready TypeScript MCP Server",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "check": "tsc --noEmit",
    "lint": "eslint src/"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "latest",
    "dotenv": "latest",
    "pino": "latest",
    "pino-pretty": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "@types/node": "latest",
    "tsx": "latest",
    "typescript": "latest"
  }
}

Development uses hot reload. Production uses compiled JavaScript. This ensures the deployed server runs the optimized output rather than TypeScript source directly.

Configure TypeScript

Create `tsconfig.json` with strict settings.

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Do not disable strict mode. Many examples skip it to reduce friction. Strict typing catches mistakes before deployment and significantly reduces runtime bugs in production MCP servers.

Environment Variables

Create `.env.example` as the committed reference file.

bash
PORT=3000
NODE_ENV=development
LOG_LEVEL=info

# MCP Server
MCP_SERVER_NAME=typescript-mcp-starter
MCP_SERVER_VERSION=1.0.0

# Authentication
API_KEY=

# External APIs
GITHUB_TOKEN=
GITHUB_ORG=

# Database (optional)
DATABASE_URL=

# Redis (optional)
REDIS_URL=

Never commit `.env`. Commit only `.env.example`. This allows other developers to understand which variables are required without exposing secrets.

Add .gitignore

text
node_modules/
dist/
.env
coverage/
*.log
.DS_Store

Configuration Module

Create `src/config/env.ts` to centralize environment access.

typescript
import "dotenv/config"

function required(key: string): string {
  const value = process.env[key]
  if (!value) throw new Error(\`Missing required environment variable: \${key}\`)
  return value
}

function optional(key: string, fallback: string): string {
  return process.env[key] ?? fallback
}

export const config = {
  port: parseInt(optional("PORT", "3000")),
  nodeEnv: optional("NODE_ENV", "development"),
  logLevel: optional("LOG_LEVEL", "info"),
  isProd: process.env.NODE_ENV === "production",
  isDev: process.env.NODE_ENV === "development",

  server: {
    name: optional("MCP_SERVER_NAME", "typescript-mcp-starter"),
    version: optional("MCP_SERVER_VERSION", "1.0.0"),
  },

  auth: {
    apiKey: optional("API_KEY", ""),
  },

  github: {
    token: optional("GITHUB_TOKEN", ""),
    org: optional("GITHUB_ORG", ""),
  },
} as const

Now configuration lives in one place. If a variable name changes, only one file changes — not twenty scattered `process.env` calls throughout the codebase.

Structured Logging

Create `src/middleware/logger.ts`.

typescript
import pino from "pino"
import { config } from "../config/env.js"

export const logger = pino({
  level: config.logLevel,
  transport: config.isDev
    ? {
        target: "pino-pretty",
        options: { colorize: true, translateTime: "HH:MM:ss" },
      }
    : undefined,
  base: {
    service: config.server.name,
    version: config.server.version,
    env: config.nodeEnv,
  },
})

export function createRequestLogger(toolName: string) {
  return logger.child({ tool: toolName })
}

Compare these two log outputs.

Unstructured (bad):

text
Creating GitHub issue...

Structured (good):

json
{
  "timestamp": "2026-06-25T13:22:14Z",
  "level": "info",
  "tool": "github.createIssue",
  "duration": 184,
  "status": "success",
  "requestId": "req_8a12f4c9"
}

The second can be searched, filtered, aggregated, monitored, and visualized. Production observability starts with structured logs.

Never log API keys, OAuth tokens, passwords, session cookies, database credentials, or bearer tokens. Logs should contain enough information to debug problems — but never enough to compromise security.

Error Handling

Create `src/utils/errors.ts` with typed error categories.

typescript
export type ErrorCode =
  | "AUTHENTICATION_ERROR"
  | "AUTHORIZATION_ERROR"
  | "VALIDATION_ERROR"
  | "NOT_FOUND"
  | "RATE_LIMIT_EXCEEDED"
  | "SERVICE_UNAVAILABLE"
  | "INTERNAL_SERVER_ERROR"

export class McpError extends Error {
  constructor(
    public readonly code: ErrorCode,
    message: string,
    public readonly details?: unknown
  ) {
    super(message)
    this.name = "McpError"
  }
}

export function toMcpResponse(error: unknown) {
  if (error instanceof McpError) {
    return {
      success: false,
      code: error.code,
      message: error.message,
    }
  }

  return {
    success: false,
    code: "INTERNAL_SERVER_ERROR" as ErrorCode,
    message: "An unexpected error occurred.",
  }
}

Never expose raw exceptions to callers. A TypeError with a stack trace is useless to an AI agent. A structured error with a code and message is actionable.

Define which errors should trigger retries. Good retry candidates are HTTP 429, temporary network issues, and timeouts. Never retry validation failures, authentication failures, or permission errors.

Standard Response Format

Create `src/utils/responses.ts`.

typescript
export function success<T>(data: T) {
  return {
    success: true as const,
    data,
  }
}

export function failure(code: string, message: string) {
  return {
    success: false as const,
    code,
    message,
  }
}

Consistent response shapes make integrations dramatically easier to build and debug.

Input Validation

Never trust incoming data — even if Claude generated it, Cursor generated it, or another AI agent generated it. Every parameter must be validated.

typescript
import { z } from "zod"

export const CreateIssueSchema = z.object({
  owner: z.string().min(1, "Owner is required"),
  repository: z.string().min(1, "Repository is required"),
  title: z.string().min(1).max(256),
  body: z.string().optional(),
  labels: z.array(z.string()).optional().default([]),
  assignees: z.array(z.string()).optional().default([]),
})

export type CreateIssueInput = z.infer<typeof CreateIssueSchema>

Validation flow:

Incoming Request
      ↓
Schema Validation
      ↓
   Valid?
  ↙     ↘
Yes      No
 ↓        ↓
Logic   Return Error
         (no API call)

This prevents many runtime errors and protects downstream APIs from receiving malformed requests.

Authentication

Create `src/middleware/auth.ts`.

typescript
import { config } from "../config/env.js"
import { McpError } from "../utils/errors.js"

export function verifyApiKey(providedKey: string | undefined): void {
  if (!config.auth.apiKey) return // auth disabled in development

  if (!providedKey || providedKey !== config.auth.apiKey) {
    throw new McpError(
      "AUTHENTICATION_ERROR",
      "Invalid or missing API key."
    )
  }
}

export function requirePermission(
  userPermissions: string[],
  required: string
): void {
  if (!userPermissions.includes(required)) {
    throw new McpError(
      "AUTHORIZATION_ERROR",
      \`Permission required: \${required}\`
    )
  }
}

Authentication answers: Who are you?

Authorization answers: What are you allowed to do?

Both must be enforced independently. A user who is authenticated is not automatically authorized to delete repositories or rotate credentials.

Building the MCP Server

Create `src/server.ts`. This file should remain small — its only job is orchestration.

typescript
import "dotenv/config"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { config } from "./config/env.js"
import { logger } from "./middleware/logger.js"
import { registerTools } from "./tools/index.js"
import { runHealthCheck } from "./health/health.js"

async function main() {
  logger.info({ name: config.server.name, version: config.server.version }, "Starting MCP server")

  const server = new Server(
    {
      name: config.server.name,
      version: config.server.version,
    },
    {
      capabilities: {
        tools: {},
      },
    }
  )

  // Register all tools
  await registerTools(server)
  logger.info("Tools registered")

  // Health check before accepting connections
  const health = await runHealthCheck()
  if (!health.ok) {
    logger.error({ health }, "Health check failed — aborting startup")
    process.exit(1)
  }

  // Connect transport
  const transport = new StdioServerTransport()
  await server.connect(transport)

  logger.info("MCP server ready")
}

main().catch((err) => {
  logger.fatal({ err }, "Fatal startup error")
  process.exit(1)
})

Notice what is absent from `server.ts`. There are no tool implementations, no GitHub calls, no database queries, no business logic. Its only responsibilities are initialization, orchestration, and startup.

Tool Registry

Create `src/tools/index.ts` as the single entry point for tool registration.

typescript
import type { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { registerGitHubTools } from "./github.js"
import { registerUserTools } from "./users.js"

export async function registerTools(server: Server): Promise<void> {
  registerGitHubTools(server)
  registerUserTools(server)
  // registerStripeTools(server)
  // registerSlackTools(server)
  // registerNotionTools(server)
}

Adding a new integration means importing one additional function. Nothing else changes.

Building an Individual Tool

Create `src/tools/github.ts`.

typescript
import type { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { z } from "zod"
import { GitHubService } from "../services/github.service.js"
import { createRequestLogger } from "../middleware/logger.js"
import { toMcpResponse } from "../utils/errors.js"
import { success } from "../utils/responses.js"

const CreateIssueSchema = z.object({
  owner: z.string().min(1),
  repository: z.string().min(1),
  title: z.string().min(1).max(256),
  body: z.string().optional(),
  labels: z.array(z.string()).optional().default([]),
})

export type CreateIssueInput = z.infer<typeof CreateIssueSchema>

export function registerGitHubTools(server: Server): void {
  const log = createRequestLogger("github")
  const github = new GitHubService()

  server.tool(
    "github_create_issue",
    "Creates a GitHub issue inside the specified repository using the authenticated account. Use this tool whenever the user wants to report a bug, create a task, open a feature request, or track a piece of work. Requires the owner and repository name.",
    {
      owner: z.string().min(1),
      repository: z.string().min(1),
      title: z.string().min(1).max(256),
      body: z.string().optional(),
      labels: z.array(z.string()).optional().default([]),
    },
    async (input) => {
      const start = Date.now()

      try {
        const parsed = CreateIssueSchema.parse(input)

        log.info({ owner: parsed.owner, repo: parsed.repository }, "Creating issue")

        const issue = await github.createIssue(parsed)

        log.info(
          { issueNumber: issue.number, duration: Date.now() - start },
          "Issue created"
        )

        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(success(issue), null, 2),
            },
          ],
        }
      } catch (err) {
        log.error({ err, duration: Date.now() - start }, "Failed to create issue")
        return {
          content: [{ type: "text", text: JSON.stringify(toMcpResponse(err)) }],
          isError: true,
        }
      }
    }
  )
}

Every tool follows the same lifecycle:

Client Request
      ↓
Schema Validation
      ↓
Authentication
      ↓
Permission Check
      ↓
Business Logic
      ↓
External API
      ↓
Transform Response
      ↓
Structured Logging
      ↓
Return Result

Keeping this flow consistent makes every tool easier to understand, debug, and audit.

Service Layer

Create `src/services/github.service.ts` to isolate all GitHub API communication.

typescript
import { config } from "../config/env.js"
import { McpError } from "../utils/errors.js"

export interface CreateIssueInput {
  owner: string
  repository: string
  title: string
  body?: string
  labels?: string[]
}

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

  constructor() {
    this.headers = {
      Authorization: \`Bearer \${config.github.token}\`,
      Accept: "application/vnd.github+json",
      "X-GitHub-Api-Version": "2022-11-28",
    }
  }

  async createIssue(input: CreateIssueInput) {
    const response = await fetch(
      \`\${this.baseUrl}/repos/\${input.owner}/\${input.repository}/issues\`,
      {
        method: "POST",
        headers: { ...this.headers, "Content-Type": "application/json" },
        body: JSON.stringify({
          title: input.title,
          body: input.body,
          labels: input.labels,
        }),
        signal: AbortSignal.timeout(15_000),
      }
    )

    if (response.status === 401) {
      throw new McpError("AUTHENTICATION_ERROR", "GitHub token is invalid or expired.")
    }

    if (response.status === 403) {
      throw new McpError("AUTHORIZATION_ERROR", "Insufficient permissions for this repository.")
    }

    if (response.status === 429) {
      throw new McpError("RATE_LIMIT_EXCEEDED", "GitHub rate limit reached. Try again shortly.")
    }

    if (!response.ok) {
      throw new McpError(
        "SERVICE_UNAVAILABLE",
        \`GitHub returned \${response.status}\`
      )
    }

    return response.json()
  }
}

Why isolate the service layer?

If you ever need to replace GitHub with GitLab, you change one file. Without this separation you search through twenty files looking for scattered API calls.

The service layer also makes mocking trivial during testing — inject a mock `GitHubService` that returns fixtures instead of making real network calls.

Tool Descriptions Matter

Good tool descriptions dramatically improve AI agent behavior.

Bad description:

text
Creates issue.

Better description:

text
Creates a GitHub issue inside the specified repository using the authenticated account.
Use this tool whenever the user wants to report a bug, create a task, open a feature
request, or track a piece of work. Requires the owner and repository name.

The second description provides intent, context, and guidance — not just implementation details. This helps Claude, Cursor, and other AI agents choose the right tool at the right time.

Health Monitoring

Create `src/health/health.ts`.

typescript
import { config } from "../config/env.js"

interface HealthResult {
  ok: boolean
  status: "healthy" | "degraded" | "unhealthy"
  version: string
  uptime: number
  checks: Record<string, boolean>
}

export async function runHealthCheck(): Promise<HealthResult> {
  const checks: Record<string, boolean> = {
    configuration: !!config.server.name,
    githubToken: !!config.github.token,
  }

  const allPassed = Object.values(checks).every(Boolean)

  return {
    ok: allPassed,
    status: allPassed ? "healthy" : "unhealthy",
    version: config.server.version,
    uptime: process.uptime(),
    checks,
  }
}

A server that refuses to start is much easier to debug than one that starts and crashes later.

Health checks should grow to include database connectivity, Redis, third-party API reachability, cache health, queue health, and latency measurements as the project matures.

FastMCP vs Official MCP SDK

Two primary options exist for building TypeScript MCP servers.

FeatureOfficial SDKFastMCP
Maintained by AnthropicCommunity
Protocol compliance
TypeScript support
Minimal boilerplate
Enterprise support
CustomizationHighMedium
Streaming
Best forProductionPrototypes

Use the official SDK when building production systems, enterprise deployments, or anything where long-term stability and Anthropic's support matter.

Use FastMCP when you need to prototype quickly or explore ideas before committing to a full architecture.

This guide uses the official SDK throughout.

Common Mistakes

These patterns consistently cause problems in production TypeScript MCP servers.

Mistake 1: Everything inside server.ts

A 2,000-line server.ts becomes impossible to maintain. Extract tools, services, middleware, and configuration into separate modules from day one.

Mistake 2: Hardcoded API keys

typescript
// Never do this
const GITHUB_TOKEN = "ghp_abc123xyz"

Use environment variables. Use a secrets manager in production. Never commit credentials to Git.

Mistake 3: No input validation

AI agents can send unexpected parameters. Validate every tool input with Zod before business logic executes.

Mistake 4: console.log debugging

Console.log has no timestamps, no severity levels, and no structure. Replace it with Pino from the start.

Mistake 5: Business logic inside tool registration

typescript
// This grows to 500 lines and becomes unmaintainable
server.tool("createIssue", async (args) => {
  // all logic here
})

Move logic into handlers and services.

Mistake 6: Shared global state

Multiple tool invocations can execute concurrently. Avoid mutable global variables. Use request-scoped state instead.

Mistake 7: No error handling

External APIs fail. Networks timeout. Rate limits trigger. Every tool must handle failures gracefully without crashing the server.

Mistake 8: Missing health checks

Deploy without health checks and your deployment platform has no way to detect failures.

Production Checklist

Before adding your first real integration, verify these foundations are in place.

  • ✅ TypeScript with strict mode enabled
  • ✅ Environment variables — no hardcoded secrets
  • ✅ Modular architecture — no monolithic server.ts
  • ✅ Input validation with Zod
  • ✅ Structured logging with Pino
  • ✅ Centralized error handling
  • ✅ Standard response format
  • ✅ Request IDs for tracing
  • ✅ Health check endpoint
  • ✅ .gitignore covering .env and dist/

These capabilities become shared infrastructure for every future tool.

Security Checklist

Security is easier to build in than to bolt on later.

  • ✅ API key authentication implemented
  • ✅ Authorization enforced per tool
  • ✅ Input validated before execution
  • ✅ No secrets logged
  • ✅ No secrets in source code
  • ✅ HTTPS enforced in production
  • ✅ Rate limiting configured
  • ✅ Error messages sanitized
  • ✅ Dependencies reviewed and updated
  • ✅ High-risk tools require approval workflows

Tool Permission Classification

A mature MCP server classifies every tool by risk level.

LevelExamplesControls
SafelistRepositories, readUserNone required
WritecreateIssue, updateRecordAuthentication
AdmindeleteRepository, rotateCredsAuthorization + logging
High RiskdeleteOrganization, transferFundsApproval + audit trail

This classification becomes critical as the number of tools grows. Never expose high-risk tools without explicit governance controls.

For production deployments, refer to the MCP security best practices guide and the how to secure an MCP server guide.

Containerizing the Application

Every production MCP server should be containerized for reproducible builds and consistent environments.

Create `Dockerfile`:

dockerfile
FROM node:22-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci --frozen-lockfile

COPY . .
RUN npm run build

FROM node:22-alpine AS runner

WORKDIR /app

RUN addgroup -S mcpuser && adduser -S mcpuser -G mcpuser

COPY package*.json ./
RUN npm ci --frozen-lockfile --omit=dev

COPY --from=builder /app/dist ./dist

USER mcpuser

HEALTHCHECK --interval=30s --timeout=5s \
  CMD node --input-type=module -e "import('./dist/health/health.js').then(m => m.runHealthCheck()).then(h => process.exit(h.ok ? 0 : 1))"

CMD ["node", "dist/server.js"]

The multi-stage build keeps the production image small. Smaller containers deploy faster, start faster, and consume less storage.

Run as a non-root user. This is a minimal security improvement that takes two lines to implement.

Deployment Options

Several platforms are excellent choices for MCP servers.

PlatformBest ForComplexity
RailwayStartups, prototypes, internal toolsLow
Fly.ioLow latency, global deploymentsMedium
RenderSimple deployments with free tierLow
CoolifySelf-hosted, full controlMedium
KubernetesEnterprise, many services, auto-scalingHigh

Railway is the fastest path from code to production. It detects Node.js automatically, provides environment variable management, automatic HTTPS, and GitHub integration with zero configuration. Most MCP servers do not need Kubernetes.

Deploying to Railway

bash
# Install Railway CLI
bash <(curl -fsSL railway.com/install.sh)

# Login and deploy
railway login
railway init
railway up

Or connect your GitHub repository and Railway will deploy automatically on every push to main.

Deploying with Docker

Build and run the container locally.

bash
docker build -t typescript-mcp-starter .
docker run --env-file .env typescript-mcp-starter

CI/CD Pipeline

A typical GitHub Actions pipeline:

yaml
name: Deploy MCP Server

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run check
      - run: npm run build

      - name: Deploy to Railway
        run: railway up --detach
        env:
          RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}

If any step fails, deployment stops immediately. Automation reduces human error and creates a predictable, auditable deployment history.

Monitoring and Observability

Production systems require continuous visibility.

Recommended metrics to track:

  • Requests per minute
  • Average and p95 latency
  • Error rate by tool
  • External API response times
  • Authentication failure rate
  • Memory and CPU usage

If you only discover problems through customer reports, you are monitoring too late.

Structured logs from Pino integrate well with DataDog, Grafana, Elastic, and most cloud logging platforms. The JSON format makes filtering and alerting straightforward.

Scaling Strategies

As adoption grows, new scaling challenges appear.

Stateless Servers

A scalable MCP server should remain stateless. Do not store sessions, user state, or caches in server memory. Use Redis or PostgreSQL for shared state. This allows new instances to start instantly and enables horizontal scaling without session affinity.

Rate Limiting

Protect the server and downstream APIs from abuse.

Anonymous:     5 requests/hour
Authenticated: 100 requests/hour
Enterprise:    Custom limits

Timeouts

Never allow requests to run forever.

External API:  10–20 seconds
AI model:      30–60 seconds
Database:      5–10 seconds

Fail fast rather than exhausting server resources.

Horizontal Scaling

Load Balancer
      ↓
┌─────┬─────┬─────┐
│  A  │  B  │  C  │
└─────┴─────┴─────┘

Stateless servers scale horizontally without coordination. Add instances, remove instances, and the load balancer handles distribution automatically.

Background Jobs

Long-running operations should not block MCP requests.

User Request
      ↓
    Queue
      ↓
   Worker
      ↓
   Result

Suitable background tasks include repository analysis, documentation generation, security scans, and AI summaries. Popular queue options include BullMQ, RabbitMQ, and Redis Streams.

Circuit Breakers

If a dependency fails repeatedly, stop sending requests temporarily.

External API
      ↓
   Failure
      ↓
   Failure
      ↓
Circuit Open (requests blocked temporarily)
      ↓
   Recovery

This prevents cascading failures when a downstream service degrades.

Deployment Strategies

Rolling Deployment

Replace servers one at a time. Zero downtime but requires backward-compatible changes.

Blue-Green Deployment

Maintain two environments. Switch traffic instantly. Rollback is nearly immediate.

Blue (active)
      ↓
  Switch
      ↓
Green (new)

Canary Deployment

Deploy to a small percentage of users first to catch issues before full rollout.

5% → 20% → 50% → 100%

This minimizes production risk for significant changes.

Versioning

Never break existing clients unexpectedly. Use versioned tool names or versioned server endpoints as the tool catalog evolves. Deprecate old behaviors gradually with clear timelines and migration guides.

Enterprise Governance

As deployments grow, governance becomes increasingly important.

Implement role-based permissions so different AI agents receive access to different tool subsets. Enable approval workflows for high-risk operations. Maintain audit logs capturing every tool invocation, argument, and result. Review permissions regularly — they tend to expand over time.

Large organizations evaluate governance as carefully as functionality. A well-governed MCP server gets security approval faster than one that works but cannot be audited.

For complete governance guidance, see Running MCP in Production and the MCP security checklist.

Performance Considerations

Startup time — avoid eager initialization of unused services. Use lazy loading for integrations that are rarely invoked.

Tool count — very large tool catalogs increase token usage during tool discovery. Organize tools logically and expose only what is needed.

Connection reuse — reuse HTTP clients across tool invocations. Creating a new connection for every request adds unnecessary latency.

Caching — cache configuration, documentation, and repository metadata. Avoid caching authentication tokens or permissions.

Streaming — the MCP SDK supports streaming responses. Use it for long-running operations to provide incremental feedback rather than blocking until completion.

Troubleshooting

Server fails to start — check that all required environment variables are set. The `required()` helper in `config/env.ts` will throw a clear error message identifying the missing variable.

Tools not appearing in Claude Desktop — restart Claude Desktop after updating the MCP configuration. Verify the configuration file path matches your operating system. Check that the server process starts without errors.

Authentication failures — verify the API key in your `.env` matches what the client is sending. Check that the `AUTH_REQUIRED` configuration is correct for your environment.

External API timeouts — check your `AbortSignal.timeout()` value. Verify network connectivity from your deployment environment. Review rate limit headers in the API response.

TypeScript build errors — run `npm run check` to see all errors at once. Strict mode errors are intentional — fix them rather than disabling strict checks.

High memory usage — audit for memory leaks in service classes. Ensure event listeners are cleaned up. Use Node.js built-in memory profiling to identify the source.

To continue building on this foundation:

Long-Term Maintenance

Plan for dependency updates and SDK upgrades on a regular schedule. The MCP protocol is still evolving — follow the Anthropic changelog and update the SDK when new capabilities are released.

Schedule permission reviews quarterly. Review the tool catalog for unused or overly broad capabilities. Remove what you no longer need.

Document every incident. A post-incident review after each production issue is the fastest way to improve the system's reliability over time.

Define an incident response process before incidents occur. Know who responds, how rollback works, how recovery is validated, and how users are communicated with.

Production Deployment Checklist

Before considering a TypeScript MCP server production-ready, confirm:

  • ✅ Docker image builds successfully
  • ✅ Health endpoint responds correctly
  • ✅ Environment variables configured and validated
  • ✅ Structured logging enabled and tested
  • ✅ Monitoring and alerting configured
  • ✅ HTTPS enforced
  • ✅ Secrets stored securely, not in source code
  • ✅ Automatic deployment configured from main branch
  • ✅ Rollback strategy documented and tested
  • ✅ Authentication implemented
  • ✅ Authorization enforced per tool
  • ✅ Input validation covering all tool parameters
  • ✅ Error handling tested for common failure modes
  • ✅ Rate limiting configured
  • ✅ CI/CD pipeline passing
  • ✅ Tool descriptions accurate and useful for AI agents
  • ✅ High-risk tools classified and governed

Key Takeaways

Building an MCP server is relatively straightforward.

Operating one reliably in production is where engineering discipline matters.

A production-ready TypeScript MCP server is:

  • secure — authentication, authorization, input validation, and secret management
  • observable — structured logs, metrics, and health checks that reveal problems before users notice
  • maintainable — modular architecture that grows without rewrites
  • governed — tool classification, audit trails, and approval workflows
  • deployable — containerized, with CI/CD, rollback, and monitoring from day one

By following the practices in this guide, you will be prepared not only to build TypeScript MCP servers, but to operate them confidently in real-world environments — whether for personal automation, SaaS products, or enterprise AI platforms.

Frequently Asked Questions

What is a TypeScript MCP server?

A TypeScript MCP server is a Node.js application that implements the Model Context Protocol (MCP), exposing tools and capabilities to AI agents like Claude, Cursor, or Windsurf through a standardized interface.

What packages do I need to build a TypeScript MCP server?

The core packages are @modelcontextprotocol/sdk for the protocol, zod for input validation, dotenv for environment configuration, and pino for structured logging. For development, add typescript, tsx, and @types/node.

Should I use the official MCP SDK or FastMCP?

Use the official MCP SDK for production systems and enterprise deployments. Use FastMCP for rapid prototyping. The official SDK is maintained by Anthropic and provides the most reliable foundation for long-term projects.

How do I add authentication to an MCP server?

Create an auth middleware that validates API keys, JWT tokens, or OAuth credentials before tool execution. Authentication should happen at the middleware level so every tool benefits automatically.

How do I validate MCP tool inputs?

Use Zod to define schemas for every tool parameter. Parse incoming arguments with the schema before business logic executes. This prevents invalid data from reaching external APIs and provides clear error messages.

How do I deploy a TypeScript MCP server?

Containerize the server with Docker, then deploy to Railway, Fly.io, Render, or any platform that supports Node.js containers. Railway is the fastest option for most teams. Connect your GitHub repository for automatic deployments on push.

How many tools should one MCP server expose?

Group related functionality together in one server. A GitHub MCP server might expose 20 GitHub tools. Avoid creating hundreds of tiny single-tool servers. Very large tool catalogs (100+) can increase token usage during tool discovery, so organize logically.

How do I structure a production TypeScript MCP server?

Use a modular structure with separate directories for config, middleware, tools, services, utils, health, and types. Keep server.ts small — it should only orchestrate initialization. Business logic belongs in services. Tool registration belongs in tool modules.

What is the difference between local and remote MCP servers?

Local MCP servers run as a process on the developer's machine and communicate via stdio. Remote MCP servers are hosted over HTTP and can serve multiple users, support centralized authentication, enable monitoring, and be updated without changing client configuration.

How do I handle errors in MCP tools?

Create typed error classes with error codes (AUTHENTICATION_ERROR, RATE_LIMIT_EXCEEDED, etc.). Catch all errors in tool handlers, log them with structured logging, and return a consistent error response format. Never expose raw exceptions or stack traces.

Check your MCP security posture

Generate a Security Score, detect risky tools, and review permissions before exposing APIs to AI agents.

Related Articles

What Is Model Context Protocol (MCP)?

OpenAPI to MCP: Complete Guide

How to Connect Claude to Any API Using MCP

Coming soon

GitHub MCP Server Explained

Coming soon