2025-12-10
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:
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);
});
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:
// 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,
};
}
}
);
}
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:
// 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 }>();
}
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:
// 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 and Lessons Learned
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,
}),
}],
};
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:
// 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}%`,
}],
};
});
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: ~50, EC2 instances 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.
Related posts
Enterprise-grade patterns for Model Context Protocol implementations including tool composition, multi-agent orchestration, role-based access control, and production observability.
A practical comparison of TypeScript AI SDKs for building AI agents - Vercel AI SDK, OpenAI Agents SDK, and AWS Bedrock integration. Includes code examples, decision frameworks, and production patterns.
A comprehensive technical guide comparing AWS Secrets Manager and Systems Manager Parameter Store, demonstrating when to use each service with real-world implementation patterns.
Learn to build maintainable, type-safe Lambda middleware using Middy's builder pattern, Zod validation, feature flags, and secrets management for enterprise serverless applications.
Implementing custom domains, bulk operations, URL expiration, and comprehensive security measures. Defense-in-depth protection strategies for production link shortener services.