Skip to content
Ayhan Sipahi Ayhan Sipahi

Building Custom MCP Servers: A Production-Ready Guide

Build, secure, and deploy custom Model Context Protocol servers for internal systems in TypeScript, with authentication, monitoring, and Kubernetes deployment.

The Model Context Protocol (MCP) has become a common integration surface across major AI providers. Pre-built servers cover public services like GitHub or Slack well. Internal APIs, proprietary databases, and legacy systems have no such server, and a generic one cannot encode your validation rules, security requirements, or compliance needs. For those systems the default is to build your own, and the build is smaller than it looks: a TypeScript project, a few well-scoped tools, and the same production discipline you already apply to any internal service.

The running example below is an internal deployment system. Its workflow reads prerequisites from a custom configuration service, validates user permissions via LDAP, triggers deployments through an internal API behind a circuit breaker, logs every action for compliance, and coordinates across regions. No pre-built server understands that workflow, and encoding it is what a custom server buys: tailored integration, security enforcement at the protocol level, responses trimmed for context window efficiency, and audit records your compliance team can query.

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:

mkdir custom-mcp-server && cd custom-mcp-server
npm init -y

# Core dependencies
npm install @modelcontextprotocol/sdk zod axios

# Development dependencies
npm install -D typescript @types/node vitest tsx

Configure TypeScript for modern Node.js:

{
  "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:

// src/index.ts
import { 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 tools
checkPrerequisitesTool(server);
triggerDeploymentTool(server);

// Register resources
deploymentConfigResource(server);

// Error handling
process.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);
});

Three details carry weight here. Modular tool registration keeps the codebase maintainable as the tool count grows, and the unhandledRejection handler prevents silent failures. Logging through console.error() is mandatory, because 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:

// src/tools/check-prerequisites.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { ConfigService } from "../lib/config-service.js";

// Domain-specific validation schema
const 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,
        };
      }
    }
  );
}

Four patterns are worth copying. The Zod regex enforces semantic versioning at the schema boundary, so a malformed version never reaches the API. The prerequisite checks collect every failure instead of stopping at the first one, which gives the model a complete picture in a single call. Environment-specific rules, such as the production deployment window, live in the tool rather than in a prompt. And the output is written for a reader, so the model can relay it without paraphrasing.

API Integration with Resilience

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

// src/lib/api-client.ts
import 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 }>();
}

The client combines a circuit breaker that stops cascading failures, exponential backoff capped at 10 seconds, token caching that keeps OAuth round trips off the hot path, error transformation that turns a 403 into a sentence the model can act on, and a request timeout underneath all of it. The circuit opens after five consecutive failures against an endpoint and stays open for a minute before the next call is allowed through as a half-open probe. Without that breaker, an unhealthy backend turns every tool call into a 30-second wait, and the model keeps retrying because nothing tells it to stop.

Security: Authentication and Audit Logging

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

// src/lib/auth.ts
import { 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:

// src/http-server.ts
import 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 middleware
app.use(helmet());
app.use(express.json({ limit: "1mb" }));

// Rate limiting
const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  message: "Too many requests from this IP, please try again later",
});

app.use("/mcp", limiter);

// Authentication middleware
app.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 transport
const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => randomUUID(),
  enableJsonResponse: true,
});

// MCP message endpoint
app.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 endpoint
app.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
FROM 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 ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --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:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: deployment-mcp-server
  namespace: ai-tools
spec:
  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: v1
kind: Service
metadata:
  name: deployment-mcp-server
  namespace: ai-tools
spec:
  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:

// tests/tools/check-prerequisites.test.ts
import { 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

Stdout/Stderr Confusion

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

// WRONG - breaks protocol
console.log("Processing deployment...");

// CORRECT - all logs to stderr
console.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:

// WRONG - too permissive
server.tool(
  "delete_service",
  z.object({ service: z.string() }),
  async ({ service }) => {
    await api.delete(`/services/${service}`); // Path traversal risk
  }
);

// CORRECT - strict validation
server.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:

// WRONG - returns 100+ fields
const user = await api.getUser(id);
return { content: [{ type: "text", text: JSON.stringify(user) }] };

// CORRECT - return only essential fields
const user = await api.getUser(id);
return {
  content: [{
    type: "text",
    text: JSON.stringify({
      id: user.id,
      name: user.name,
      email: user.email,
      status: user.status,
    }),
  }],
};

The saving compounds. A model that calls the same tool ten times in one conversation pays for the field list ten times, so projecting the response at the tool boundary is cheaper than trimming it in a prompt.

Synchronous Long Operations

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

// WRONG - blocks for minutes
server.tool("deploy_service", schema, async (params) => {
  await runDeployment(params); // Takes 3-5 minutes
  return { content: [{ type: "text", text: "Deployment complete" }] };
});

// CORRECT - async task pattern
server.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}%`,
    }],
  };
});

Custom or Pre-Built

Build a custom server when the system behind it is internal or proprietary, when security and compliance rules have to be enforced at the protocol boundary, when validation is domain-specific, or when response shape matters enough that context window efficiency becomes a design goal. High integration volume pushes the same way, since every hand-written tool pays for itself again each time the model calls it. Infrastructure is rarely the deciding factor: a few replicas of a small Node process cost what any other internal service costs on the cluster you already run.

Reach for a pre-built server when the target is a public service with an official implementation, when you are prototyping and the integration may not outlive the week, or when nobody will own the server after it ships. An unmaintained custom server is the worse of the two options, because it holds credentials to an internal system and no one is watching it.

Either way, sequence the build the same way. Start with stdio transport and two or three tools, get them working against a real backend, then add authentication, audit logging, and HTTP transport as usage grows. The one thing not to defer is authorization: adding it to a server that already has users means auditing every tool a second time.

References

Related posts