Skip to content

Building Custom MCP Servers: A Production-Ready Guide

Learn how to build, secure, and deploy custom Model Context Protocol servers for your organization's internal systems with TypeScript, including authentication, monitoring, and Kubernetes deployment.

Abstract

The Model Context Protocol (MCP) has rapidly become the standard for AI integration across major providers. While pre-built servers work well for common services like GitHub or Slack, organizations need custom servers to integrate internal APIs, enforce security policies, and encode domain-specific logic. This guide walks through building a production-ready custom MCP server using TypeScript, from initial setup to Kubernetes deployment, with working code examples for authentication, circuit breakers, audit logging, and monitoring.

The Custom Integration Challenge

Organizations adopting AI-assisted workflows quickly hit limitations with pre-built MCP servers. Your team uses proprietary internal APIs, custom databases, and legacy systems that lack public MCP servers. Generic servers can't encode your validation rules, security requirements, or compliance needs.

Consider an internal deployment system. The workflow requires checking prerequisites from a custom configuration service, validating user permissions via LDAP, triggering deployments through an internal API with circuit breakers, logging all actions for compliance, and handling multi-region coordination. No pre-built server understands this workflow.

Building custom MCP servers enables tailored integration, security enforcement at the protocol level, optimized responses that maximize context window efficiency, and compliance integration with audit systems.

Project Structure and Setup

Start with a well-organized project structure that separates concerns:

deployment-mcp-server/├── src/│  ├── index.ts  # Server initialization│  ├── tools/  # Tool implementations│  │  ├── check-prerequisites.ts│  │  └── trigger-deployment.ts│  ├── resources/  # Resource implementations│  │  └── deployment-config.ts│  ├── lib/│  │  ├── api-client.ts  # Internal API wrapper│  │  ├── auth.ts  # Authentication logic│  │  └── validation.ts  # Shared validation│  └── types/│  └── deployment.ts  # TypeScript types├── tests/├── tsconfig.json└── package.json

Initialize the project with required dependencies:

bash
mkdir custom-mcp-server && cd custom-mcp-servernpm init -y
# Core dependenciesnpm install @modelcontextprotocol/sdk zod axios
# Development dependenciesnpm install -D typescript @types/node vitest tsx

Configure TypeScript for modern Node.js:

json
{  "compilerOptions": {    "target": "ES2022",    "module": "NodeNext",    "moduleResolution": "NodeNext",    "outDir": "./dist",    "rootDir": "./src",    "strict": true,    "esModuleInterop": true,    "skipLibCheck": true,    "forceConsistentCasingInFileNames": true,    "resolveJsonModule": true,    "declaration": true  },  "include": ["src/**/*"],  "exclude": ["node_modules", "dist", "tests"]}

Core Server Implementation

The server initialization handles transport setup, tool registration, and graceful shutdown:

typescript
// src/index.tsimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { checkPrerequisitesTool } from "./tools/check-prerequisites.js";import { triggerDeploymentTool } from "./tools/trigger-deployment.js";import { deploymentConfigResource } from "./resources/deployment-config.js";
const server = new McpServer({  name: "deployment-server",  version: "1.0.0",});
// Register toolscheckPrerequisitesTool(server);triggerDeploymentTool(server);
// Register resourcesdeploymentConfigResource(server);
// Error handlingprocess.on('SIGINT', async () => {  console.error('Shutting down gracefully...');  await server.close();  process.exit(0);});
process.on('unhandledRejection', (error) => {  console.error('Unhandled rejection:', error);  process.exit(1);});
async function main() {  const transport = new StdioServerTransport();  await server.connect(transport);  console.error('Deployment MCP server running on stdio');}
main().catch((error) => {  console.error('Fatal error:', error);  process.exit(1);});

Key patterns here: modular tool registration keeps the codebase maintainable, proper error handling prevents silent failures, and using console.error() for logging is critical: stdout is reserved for protocol messages, and any other output corrupts the JSON-RPC stream.

Implementing Tools with Domain Logic

Tools encode your organization's business rules. Here's a comprehensive example that validates deployment prerequisites:

typescript
// src/tools/check-prerequisites.tsimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { z } from "zod";import { ConfigService } from "../lib/config-service.js";
// Domain-specific validation schemaconst CheckPrerequisitesSchema = z.object({  service: z.string().describe("Service name to deploy"),  environment: z.enum(["development", "staging", "production"])    .describe("Target environment"),  version: z.string()    .regex(/^\d+\.\d+\.\d+$/, "Must be semantic version (e.g., 1.2.3)")    .describe("Version to deploy"),});
export function checkPrerequisitesTool(server: McpServer) {  server.tool(    "check_deployment_prerequisites",    CheckPrerequisitesSchema,    async ({ service, environment, version }) => {      const results: string[] = [];      const errors: string[] = [];
      try {        // 1. Check service configuration exists        const config = await ConfigService.getServiceConfig(service, environment);        if (!config) {          errors.push(`No configuration found for ${service} in ${environment}`);        } else {          results.push(`Configuration found for ${service}`);        }
        // 2. Validate version compatibility        const compatibilityCheck = await ConfigService.checkVersionCompatibility(          service,          version,          environment        );
        if (!compatibilityCheck.compatible) {          errors.push(            `Version ${version} incompatible: ${compatibilityCheck.reason}`          );        } else {          results.push(`Version ${version} is compatible`);        }
        // 3. Check dependent services are healthy        const dependencies = await ConfigService.getDependencies(service);        for (const dep of dependencies) {          const health = await ConfigService.checkServiceHealth(dep, environment);          if (!health.healthy) {            errors.push(`Dependency ${dep} is unhealthy: ${health.status}`);          } else {            results.push(`Dependency ${dep} is healthy`);          }        }
        // 4. Validate deployment window (production only)        if (environment === "production") {          const inWindow = await ConfigService.isInDeploymentWindow();          if (!inWindow) {            errors.push(              "Outside deployment window (Mon-Thu 10AM-4PM EST)"            );          } else {            results.push("Within deployment window");          }        }
        // Format response        const hasErrors = errors.length > 0;        const summary = hasErrors          ? `Prerequisites check FAILED (${errors.length} issues)`          : `All prerequisites passed (${results.length} checks)`;
        return {          content: [            {              type: "text",              text: [                summary,                "",                "Checks Passed:",                ...results.map(r => `  ${r}`),                ...(hasErrors ? ["", "Issues Found:", ...errors.map(e => `  ${e}`)] : []),                "",                hasErrors ? " Deployment should NOT proceed" : "Safe to proceed with deployment",              ].join("\n"),            },          ],          isError: hasErrors,        };      } catch (error) {        console.error("Prerequisites check failed:", error);        return {          content: [            {              type: "text",              text: `Error checking prerequisites: ${error.message}`,            },          ],          isError: true,        };      }    }  );}

This demonstrates several domain logic patterns: semantic version validation using Zod regex, multi-step prerequisite checks with clear pass/fail feedback, environment-specific rules like deployment windows for production, dependency health validation, and human-readable output optimized for AI consumption.

API Integration with Resilience

Robust integration with internal APIs requires retries, circuit breaking, and proper error handling:

typescript
// src/lib/api-client.tsimport axios, { AxiosInstance, AxiosError } from "axios";
interface CircuitBreakerState {  failures: number;  lastFailureTime: number;  state: "closed" | "open" | "half-open";}
export class InternalAPIClient {  private client: AxiosInstance;  private circuitBreaker: Map<string, CircuitBreakerState> = new Map();  private readonly FAILURE_THRESHOLD = 5;  private readonly TIMEOUT_MS = 30000;  private readonly RESET_TIMEOUT_MS = 60000;
  constructor() {    this.client = axios.create({      baseURL: process.env.INTERNAL_API_URL,      timeout: this.TIMEOUT_MS,      headers: {        "User-Agent": "deployment-mcp-server/1.0.0",      },    });
    // Add authentication interceptor    this.client.interceptors.request.use(async (config) => {      const token = await this.getAuthToken();      config.headers.Authorization = `Bearer ${token}`;      return config;    });
    // Add retry interceptor    this.client.interceptors.response.use(      (response) => response,      async (error: AxiosError) => {        const config = error.config;
        // Don't retry if circuit is open        if (this.isCircuitOpen(config.url)) {          throw new Error(`Circuit breaker open for ${config.url}`);        }
        // Retry on 5xx errors or network issues        if (          error.response?.status >= 500 ||          error.code === "ECONNABORTED" ||          error.code === "ENOTFOUND"        ) {          const retryCount = (config as any).__retryCount || 0;
          if (retryCount < 3) {            (config as any).__retryCount = retryCount + 1;
            // Exponential backoff            const delay = Math.min(1000 * Math.pow(2, retryCount), 10000);            await new Promise((resolve) => setTimeout(resolve, delay));
            console.error(              `Retrying ${config.url} (attempt ${retryCount + 1}/3)`            );
            return this.client.request(config);          }
          // Max retries exceeded - record failure          this.recordFailure(config.url);        }
        throw error;      }    );  }
  private isCircuitOpen(endpoint: string): boolean {    const state = this.circuitBreaker.get(endpoint);    if (!state || state.state === "closed") return false;
    // Check if timeout has elapsed    const elapsed = Date.now() - state.lastFailureTime;    if (elapsed > this.RESET_TIMEOUT_MS) {      // Try half-open      state.state = "half-open";      state.failures = 0;      return false;    }
    return state.state === "open";  }
  private recordFailure(endpoint: string): void {    const state = this.circuitBreaker.get(endpoint) || {      failures: 0,      lastFailureTime: 0,      state: "closed",    };
    state.failures++;    state.lastFailureTime = Date.now();
    if (state.failures >= this.FAILURE_THRESHOLD) {      state.state = "open";      console.error(        `Circuit breaker OPEN for ${endpoint} (${state.failures} failures)`      );    }
    this.circuitBreaker.set(endpoint, state);  }
  private async getAuthToken(): Promise<string> {    // Cache token until expiry    const cached = this.tokenCache.get("access_token");    if (cached && cached.expiresAt > Date.now()) {      return cached.token;    }
    // Obtain new token (OAuth2 client credentials)    const response = await axios.post(      `${process.env.AUTH_URL}/oauth/token`,      {        grant_type: "client_credentials",        client_id: process.env.API_CLIENT_ID,        client_secret: process.env.API_CLIENT_SECRET,        scope: "deployments:read deployments:write",      }    );
    const token = response.data.access_token;    const expiresIn = response.data.expires_in;
    this.tokenCache.set("access_token", {      token,      expiresAt: Date.now() + expiresIn * 1000,    });
    return token;  }
  async triggerDeployment(params: {    service: string;    version: string;    environment: string;  }): Promise<{ deploymentId: string; status: string }> {    try {      const response = await this.client.post("/deployments", params);      return response.data;    } catch (error) {      if (axios.isAxiosError(error)) {        // Transform API errors to user-friendly messages        if (error.response?.status === 403) {          throw new Error(            "Insufficient permissions for deployment. Contact DevOps team."          );        }        if (error.response?.status === 409) {          throw new Error(            `Deployment conflict: ${error.response.data.message}`          );        }        throw new Error(          `Deployment API error: ${error.response?.data?.message || error.message}`        );      }      throw error;    }  }
  private tokenCache = new Map<string, { token: string; expiresAt: number }>();}

This implementation includes circuit breakers preventing cascading failures, exponential backoff with jitter, token caching to reduce auth overhead, user-friendly error transformation, and timeout protection. I learned the importance of circuit breakers during a backend outage that took down the MCP server for 20 minutes before these patterns were implemented.

Security: Authentication and Audit Logging

Security cannot be bolted on later. Design authentication, authorization, and audit logging from the start:

typescript
// src/lib/auth.tsimport { z } from "zod";
interface UserContext {  userId: string;  email: string;  groups: string[];  permissions: Set<string>;}
export class AuthService {  private static ldapClient: LDAPClient;  private static userCache = new Map<string, { user: UserContext; expiresAt: number }>();
  static async authenticateUser(token: string): Promise<UserContext> {    // Check cache    const cached = this.userCache.get(token);    if (cached && cached.expiresAt > Date.now()) {      return cached.user;    }
    // Validate token with your auth provider    const decoded = await this.verifyJWT(token);
    // Load LDAP groups and permissions    const ldapUser = await this.ldapClient.search({      filter: `(mail=${decoded.email})`,      attributes: ["cn", "memberOf"],    });
    const groups = ldapUser.memberOf.map(dn => this.extractGroupName(dn));    const permissions = await this.loadPermissionsForGroups(groups);
    const user: UserContext = {      userId: decoded.sub,      email: decoded.email,      groups,      permissions: new Set(permissions),    };
    // Cache for 5 minutes    this.userCache.set(token, {      user,      expiresAt: Date.now() + 5 * 60 * 1000,    });
    return user;  }
  static authorizeEnvironment(    user: UserContext,    environment: "development" | "staging" | "production"  ): void {    const permissionMap = {      development: "deploy:dev",      staging: "deploy:staging",      production: "deploy:production",    };
    if (!user.permissions.has(permissionMap[environment])) {      throw new Error(        `Access denied: missing permission '${permissionMap[environment]}'`      );    }
    // Production requires additional group membership    if (environment === "production") {      if (!user.groups.includes("production-deployers")) {        throw new Error(          "Production deployments require 'production-deployers' group membership"        );      }    }  }}
export class AuditLogger {  private static auditQueue: AuditEvent[] = [];  private static flushInterval: NodeJS.Timeout;
  static init() {    // Flush audit logs every 10 seconds    this.flushInterval = setInterval(() => this.flush(), 10000);  }
  static log(event: {    action: string;    user: UserContext;    resource: string;    result: "success" | "failure" | "denied";    metadata?: Record<string, any>;  }): void {    const auditEvent: AuditEvent = {      timestamp: new Date().toISOString(),      userId: event.user.userId,      userEmail: event.user.email,      action: event.action,      resource: event.resource,      result: event.result,      metadata: event.metadata,      ipAddress: process.env.CLIENT_IP || "unknown",      serverVersion: "1.0.0",    };
    this.auditQueue.push(auditEvent);
    console.error(      `AUDIT: ${auditEvent.action} on ${auditEvent.resource} by ${auditEvent.userEmail}: ${auditEvent.result}`    );
    // Flush immediately for critical events    if (event.result === "denied" || event.action.includes("production")) {      this.flush();    }  }
  private static async flush(): Promise<void> {    if (this.auditQueue.length === 0) return;
    const batch = [...this.auditQueue];    this.auditQueue = [];
    try {      await axios.post(process.env.AUDIT_API_URL, {        events: batch,        source: "deployment-mcp-server",      });    } catch (error) {      console.error("Failed to flush audit logs:", error);      // Re-queue failed events      this.auditQueue.unshift(...batch);    }  }
  static shutdown(): void {    clearInterval(this.flushInterval);    this.flush();  }}
interface AuditEvent {  timestamp: string;  userId: string;  userEmail: string;  action: string;  resource: string;  result: "success" | "failure" | "denied";  metadata?: Record<string, any>;  ipAddress: string;  serverVersion: string;}

HTTP Transport for Production

While stdio transport works for local development, production deployments need HTTP transport for multiple concurrent clients, independent server lifecycle, load balancing, and standard monitoring:

typescript
// src/http-server.tsimport express from "express";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";import { randomUUID } from "crypto";import rateLimit from "express-rate-limit";import helmet from "helmet";
const app = express();const mcpServer = new McpServer({  name: "deployment-server",  version: "1.0.0",});
// Security middlewareapp.use(helmet());app.use(express.json({ limit: "1mb" }));
// Rate limitingconst limiter = rateLimit({  windowMs: 60 * 1000,  max: 100,  message: "Too many requests from this IP, please try again later",});
app.use("/mcp", limiter);
// Authentication middlewareapp.use("/mcp", async (req, res, next) => {  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer ")) {    return res.status(401).json({ error: "Missing or invalid authorization header" });  }
  const token = authHeader.substring(7);
  try {    const user = await AuthService.authenticateUser(token);    req.user = user;    next();  } catch (error) {    console.error("Authentication failed:", error);    return res.status(401).json({ error: "Invalid token" });  }});
// Create HTTP transportconst transport = new StreamableHTTPServerTransport({  sessionIdGenerator: () => randomUUID(),  enableJsonResponse: true,});
// MCP message endpointapp.post("/mcp/message", async (req, res) => {  const sessionId = req.headers["mcp-session-id"] as string;
  AuditLogger.log({    action: "mcp_message",    user: req.user,    resource: "mcp-server",    result: "success",    metadata: { sessionId, method: req.body.method },  });
  await transport.handleMessage(req, res);});
// Health check endpointapp.get("/health", (req, res) => {  res.json({    status: "healthy",    version: "1.0.0",    uptime: process.uptime(),  });});
// Readiness check (for Kubernetes)app.get("/ready", async (req, res) => {  try {    await Promise.race([      ConfigService.healthCheck(),      new Promise((_, reject) =>        setTimeout(() => reject(new Error("Timeout")), 5000)      ),    ]);
    res.json({ ready: true });  } catch (error) {    res.status(503).json({ ready: false, error: error.message });  }});
const PORT = process.env.PORT || 3000;
async function startServer() {  await mcpServer.connect(transport);
  app.listen(PORT, () => {    console.error(`MCP HTTP server listening on port ${PORT}`);    console.error(`Health check: http://localhost:${PORT}/health`);  });}
startServer().catch((error) => {  console.error("Failed to start server:", error);  process.exit(1);});

Kubernetes Deployment

Container deployment with Kubernetes provides high availability and scalability:

dockerfile
# DockerfileFROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./RUN npm ci --only=production
COPY . .RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/package.json ./
RUN addgroup -g 1001 -S mcp && \    adduser -S -u 1001 -G mcp mcp
USER mcp
EXPOSE 3000
CMD ["node", "dist/http-server.js"]

Kubernetes manifest for production deployment:

yaml
apiVersion: apps/v1kind: Deploymentmetadata:  name: deployment-mcp-server  namespace: ai-toolsspec:  replicas: 3  selector:    matchLabels:      app: deployment-mcp-server  template:    metadata:      labels:        app: deployment-mcp-server    spec:      containers:      - name: server        image: your-registry/deployment-mcp-server:1.0.0        ports:        - containerPort: 3000          name: http        env:        - name: NODE_ENV          value: production        - name: INTERNAL_API_URL          valueFrom:            configMapKeyRef:              name: mcp-config              key: api_url        - name: API_CLIENT_SECRET          valueFrom:            secretKeyRef:              name: mcp-secrets              key: api_client_secret        resources:          requests:            cpu: 100m            memory: 256Mi          limits:            cpu: 500m            memory: 512Mi        livenessProbe:          httpGet:            path: /health            port: 3000          initialDelaySeconds: 10          periodSeconds: 30        readinessProbe:          httpGet:            path: /ready            port: 3000          initialDelaySeconds: 5          periodSeconds: 10---apiVersion: v1kind: Servicemetadata:  name: deployment-mcp-server  namespace: ai-toolsspec:  selector:    app: deployment-mcp-server  ports:  - port: 80    targetPort: 3000    name: http  type: ClusterIP

Testing Strategy

Test MCP tools effectively with unit tests that mock external dependencies:

typescript
// tests/tools/check-prerequisites.test.tsimport { describe, it, expect, beforeEach, vi } from "vitest";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { checkPrerequisitesTool } from "../../src/tools/check-prerequisites.js";import { ConfigService } from "../../src/lib/config-service.js";
vi.mock("../../src/lib/config-service.js");
describe("check_deployment_prerequisites tool", () => {  let server: McpServer;
  beforeEach(() => {    server = new McpServer({ name: "test", version: "1.0.0" });    checkPrerequisitesTool(server);    vi.clearAllMocks();  });
  it("should pass all checks for valid deployment", async () => {    vi.mocked(ConfigService.getServiceConfig).mockResolvedValue({      name: "api-service",      currentVersion: "1.0.0",    });
    vi.mocked(ConfigService.checkVersionCompatibility).mockResolvedValue({      compatible: true,      reason: "",    });
    vi.mocked(ConfigService.getDependencies).mockResolvedValue([      "database-service",    ]);
    vi.mocked(ConfigService.checkServiceHealth).mockResolvedValue({      healthy: true,      status: "operational",    });
    const result = await server._callTool("check_deployment_prerequisites", {      service: "api-service",      environment: "staging",      version: "1.2.3",    });
    expect(result.isError).toBe(false);    expect(result.content[0].text).toContain("All prerequisites passed");  });
  it("should fail when dependency is unhealthy", async () => {    vi.mocked(ConfigService.getServiceConfig).mockResolvedValue({      name: "api-service",    });
    vi.mocked(ConfigService.checkVersionCompatibility).mockResolvedValue({      compatible: true,    });
    vi.mocked(ConfigService.getDependencies).mockResolvedValue([      "database-service",    ]);
    vi.mocked(ConfigService.checkServiceHealth).mockResolvedValue({      healthy: false,      status: "degraded",    });
    const result = await server._callTool("check_deployment_prerequisites", {      service: "api-service",      environment: "staging",      version: "1.2.3",    });
    expect(result.isError).toBe(true);    expect(result.content[0].text).toContain("database-service is unhealthy");  });});

Common Pitfalls and Lessons Learned

Stdout/Stderr Confusion

MCP uses stdout exclusively for JSON-RPC messages. Any other output corrupts the protocol stream:

typescript
// WRONG - breaks protocolconsole.log("Processing deployment...");
// CORRECT - all logs to stderrconsole.error("Processing deployment...");

If your client shows "Protocol error" or "Invalid JSON", check for stdout pollution.

Missing Input Validation

AI models can generate unexpected or malicious inputs. Use Zod refinements for strict validation:

typescript
// WRONG - too permissiveserver.tool(  "delete_service",  z.object({ service: z.string() }),  async ({ service }) => {    await api.delete(`/services/${service}`); // Path traversal risk  });
// CORRECT - strict validationserver.tool(  "delete_service",  z.object({    service: z.string()      .regex(/^[a-z0-9-]+$/)      .min(3)      .max(50),    confirmation: z.literal("DELETE"),  }),  async ({ service, confirmation }) => {    const exists = await api.serviceExists(service);    if (!exists) {      throw new Error(`Service ${service} not found`);    }    await api.delete(`/services/${service}`);  });

Context Window Bloat

Every token consumes context window. AI can invoke tools dozens of times per conversation. Return only relevant fields:

typescript
// WRONG - returns 100+ fieldsconst user = await api.getUser(id);return { content: [{ type: "text", text: JSON.stringify(user) }] };
// CORRECT - return only essential fieldsconst user = await api.getUser(id);return {  content: [{    type: "text",    text: JSON.stringify({      id: user.id,      name: user.name,      email: user.email,      status: user.status,    }),  }],};

This approach reduced token usage by approximately 70% in testing.

Synchronous Long Operations

Tools should return within 5-10 seconds. For longer operations, use a task-based pattern:

typescript
// WRONG - blocks for minutesserver.tool("deploy_service", schema, async (params) => {  await runDeployment(params); // Takes 3-5 minutes  return { content: [{ type: "text", text: "Deployment complete" }] };});
// CORRECT - async task patternserver.tool("start_deployment", schema, async (params) => {  const taskId = await deploymentQueue.enqueue(params);
  return {    content: [{      type: "text",      text: `Deployment started with ID: ${taskId}\nUse check_deployment_status to monitor progress`,    }],  };});
server.tool("check_deployment_status", z.object({ taskId: z.string() }), async ({ taskId }) => {  const status = await deploymentQueue.getStatus(taskId);  return {    content: [{      type: "text",      text: `Deployment ${taskId}: ${status.state}\nProgress: ${status.progress}%`,    }],  };});

Cost Analysis

Development: 1-2 weeks for production-ready server (server development 3-5 days, security 1-2 days, testing 1 day, deployment 1-2 days, documentation 1 day).

Infrastructure (AWS example):

  • Kubernetes: ~130/month(EKSallocation130/month (EKS allocation 50, EC2 instances 45,loadbalancer45, load balancer 20, secrets/logs $25)
  • Serverless (Fargate): ~$50/month (lower cost, higher cold start latency)

When to build custom: Internal/proprietary systems, strict security/compliance requirements, domain-specific validation needed, context optimization critical, high integration volume.

When to use pre-built: Standard integrations (GitHub, Slack), quick prototyping, low security requirements, limited development resources.

Key Takeaways

Start simple with stdio transport and basic tools, then add security and production features incrementally. Design authentication, authorization, and audit logging from the start. Retrofitting security is painful.

Optimize every response. Return only relevant fields to maximize context window efficiency. Use Zod for strict input validation and validate backend responses. Don't trust AI-generated inputs or backend APIs.

Circuit breakers are essential for preventing cascading failures when backends become unhealthy. Build small, focused tools that are reusable and testable, letting AI orchestrate complex workflows through tool composition.

Production requires observability: metrics, logging, and alerting are not optional. Test in production-like environments using the same code with different configurations to catch issues before production.

MCP server development takes 1-2 weeks compared to 2-3 weeks for custom REST APIs. The standardization pays off through multi-provider support and growing ecosystem.

References

Related Posts