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
- Model Context Protocol - Official Documentation - Authoritative protocol specification covering transports, tools, resources, and security requirements
- GitHub - modelcontextprotocol/servers - Reference MCP server implementations showing production patterns for common integrations
- GitHub - modelcontextprotocol/typescript-sdk - The official TypeScript SDK for implementing MCP servers and clients on Node.js
- MCP Inspector - Model Context Protocol - Interactive debugging tool for verifying protocol compliance before connecting to AI clients
- GitHub - modelcontextprotocol/python-sdk - Official Python SDK for MCP servers with full specification coverage
Related posts
Bake a single PII branded type into your observability API signatures so TypeScript rejects sensitive fields at the call site, before any runtime redactor sees them.
HMAC-SHA-256 for webhooks, signed URLs, and internal auth, with runnable code in three languages and the boundary where digital signatures take over.
Enterprise patterns for Model Context Protocol: tool composition, multi-agent orchestration, role-based access control, and production observability.
A practical comparison of TypeScript AI SDKs for building agents: Vercel AI SDK, OpenAI Agents SDK, and AWS Bedrock, with code examples and decision frameworks.
A technical guide comparing AWS Secrets Manager and Parameter Store, showing when to use each service with real-world implementation patterns and CDK examples.