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:
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:
server.tool("createUser", async () => {
// 200 lines of mixed concerns
});
Good pattern:
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
| Technology | Purpose |
|---|---|
| TypeScript | Type safety and maintainability |
| Node.js 20+ | Runtime |
| MCP SDK | Protocol implementation |
| Zod | Input validation |
| dotenv | Environment configuration |
| Pino | Structured logging |
| tsx | Development hot reload |
| Docker | Containerization |
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.
mkdir typescript-mcp-starter
cd typescript-mcp-starter
npm init -y
Install Dependencies
Install the required runtime packages.
npm install @modelcontextprotocol/sdk zod dotenv pino
Install development dependencies.
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.
{
"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.
{
"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.
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
node_modules/
dist/
.env
coverage/
*.log
.DS_Store
Configuration Module
Create `src/config/env.ts` to centralize environment access.
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`.
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):
Creating GitHub issue...
Structured (good):
{
"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.
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`.
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.
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`.
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.
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.
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`.
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.
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:
Creates issue.
Better description:
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`.
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.
| Feature | Official SDK | FastMCP |
|---|---|---|
| Maintained by Anthropic | ✓ | Community |
| Protocol compliance | ✓ | ✓ |
| TypeScript support | ✓ | ✓ |
| Minimal boilerplate | — | ✓ |
| Enterprise support | ✓ | — |
| Customization | High | Medium |
| Streaming | ✓ | ✓ |
| Best for | Production | Prototypes |
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
// 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
// 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.
| Level | Examples | Controls |
|---|---|---|
| Safe | listRepositories, readUser | None required |
| Write | createIssue, updateRecord | Authentication |
| Admin | deleteRepository, rotateCreds | Authorization + logging |
| High Risk | deleteOrganization, transferFunds | Approval + 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`:
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.
| Platform | Best For | Complexity |
|---|---|---|
| Railway | Startups, prototypes, internal tools | Low |
| Fly.io | Low latency, global deployments | Medium |
| Render | Simple deployments with free tier | Low |
| Coolify | Self-hosted, full control | Medium |
| Kubernetes | Enterprise, many services, auto-scaling | High |
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
# 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.
docker build -t typescript-mcp-starter .
docker run --env-file .env typescript-mcp-starter
CI/CD Pipeline
A typical GitHub Actions pipeline:
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.
Related Resources
To continue building on this foundation:
- How to Secure an MCP Server — authentication, permissions, and governance in depth
- MCP Resources vs Tools — understanding when to use each primitive
- Running MCP in Production — deployment, monitoring, and reliability
- MCP Security Best Practices — security checklists and patterns
- OpenAPI to MCP Complete Guide — converting existing APIs to MCP tools
- GitHub MCP Setup Guide — GitHub integration patterns
- GitLab MCP Setup Guide — GitLab integration patterns
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.