the official @modelcontextprotocol/sdk package, which handles JSON-RPC 2.0 serialization, transport abstraction, and capability negotiation automatically.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Initialize server with explicit versioning and capability declaration
const fleetServer = new McpServer({
name: "FleetManagementServer",
version: "1.2.0",
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true, listChanged: true },
prompts: { listChanged: false }
}
});
Architecture Rationale: We declare listChanged: true for tools and resources because fleet telemetry schemas and maintenance manifests update frequently. This allows the server to push notifications to connected clients when definitions evolve, preventing stale schema errors. Capability negotiation happens during the initialize handshake, ensuring the client only requests features the server actually supports.
Tools require strict JSON Schema 2020-12 validation. We use Zod for runtime validation and automatic schema generation. The tool executes asynchronously and returns structured results that the host injects back into the LLM context.
const telemetrySchema = z.object({
vehicleId: z.string().uuid(),
metricType: z.enum(["fuel_level", "engine_temp", "gps_location"]),
timeframe: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
});
fleetServer.tool(
"queryVehicleTelemetry",
"Retrieves real-time or historical telemetry data for a specific fleet vehicle",
telemetrySchema.shape,
async (params) => {
const validated = telemetrySchema.parse(params);
// Simulate async data fetch with proper error boundaries
try {
const response = await fetch(
`https://api.fleet-ops.internal/v2/telemetry/${validated.vehicleId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
metric: validated.metricType,
window: validated.timeframe
})
}
);
if (!response.ok) throw new Error(`Telemetry API returned ${response.status}`);
const data = await response.json();
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
isError: false
};
} catch (err) {
return {
content: [{ type: "text", text: `Fetch failed: ${(err as Error).message}` }],
isError: true
};
}
}
);
Why this structure: MCP requires tools to return a standardized content array. We wrap external API calls in try/catch blocks and explicitly set isError: true when failures occur. This prevents the LLM from receiving malformed JSON or stack traces that could corrupt the conversation context. The schema validation happens before the network call, failing fast on invalid inputs.
Step 3: Expose Contextual Resources
Resources provide static or semi-static context without requiring execution. They are identified by URIs and support subscription-based updates.
fleetServer.resource(
"fleet://maintenance-manifest",
"Current vehicle maintenance schedules and compliance deadlines",
async (uri) => {
const manifest = await loadMaintenanceRegistry();
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(manifest, null, 2)
}]
};
}
);
Architecture Decision: Resources are read-only by design. They solve the "context window starvation" problem by allowing agents to reference structured data without consuming token budget on tool execution. The subscribe: true capability enables the server to push resources/updated notifications when the registry changes, keeping the host's context fresh without polling.
Step 4: Transport Configuration
The same server logic runs identically across transports. For local development, we bind to STDIO. For production, we switch to Streamable HTTP with OAuth 2.1 bearer token validation.
// Local development
const stdioTransport = new StdioTransport();
await fleetServer.connect(stdioTransport);
// Production deployment (conceptual)
// const httpTransport = new StreamableHttpTransport({
// port: 3001,
// auth: { strategy: "oauth2", issuer: "https://auth.internal" }
// });
// await fleetServer.connect(httpTransport);
The transport layer is deliberately decoupled from the business logic. JSON-RPC 2.0 messages flow identically whether piped through STDIO or routed over HTTP/SSE. This abstraction allows teams to prototype locally and deploy remotely without modifying tool or resource handlers.
Pitfall Guide
1. STDOUT Pollution in STDIO Mode
Explanation: STDIO transport uses standard input/output streams for JSON-RPC communication. Any console.log() or unhandled stdout write corrupts the message stream, causing deserialization failures.
Fix: Route all logging to stderr or external observability pipelines. Use process.stderr.write() or configure your logger to target 2 (stderr file descriptor).
2. Ignoring Capability Negotiation
Explanation: Clients and servers must exchange initialize requests containing supported features. Skipping this or hardcoding capabilities leads to version mismatches and runtime crashes.
Fix: Always declare capabilities explicitly during server instantiation. Respect the protocolVersion field and gracefully degrade features the client doesn't support.
3. Bypassing Host-Level User Approval
Explanation: MCP enforces a security boundary where hosts must present tool invocations to users for confirmation. Servers cannot auto-execute privileged operations.
Fix: Design tools to be idempotent and safe for preview. Never assume silent execution. Document required permissions in tool descriptions so hosts can display accurate approval prompts.
4. Hardcoding Credentials in Remote Deployments
Explanation: Streamable HTTP servers often require API keys or database passwords. Embedding them in source code or environment variables without rotation policies creates severe attack surfaces.
Fix: Use OAuth 2.1 bearer tokens, short-lived JWTs, or secret managers (HashiCorp Vault, AWS Secrets Manager). Validate tokens at the transport layer before routing to business logic.
5. Neglecting listChanged Notifications
Explanation: When tool or resource definitions update, clients cache stale schemas. Without notifications, agents attempt to call deprecated parameters.
Fix: Emit notifications/tools/listChanged or notifications/resources/listChanged immediately after schema updates. Implement versioned URIs (e.g., fleet://v2/manifest) to support gradual rollout.
6. Schema Drift Without Versioning
Explanation: JSON Schema 2020-12 is strict. Adding optional fields is safe, but changing types or removing required fields breaks existing clients.
Fix: Treat tool schemas like public APIs. Use semantic versioning, maintain backward-compatible additions, and deprecate old tools explicitly rather than deleting them.
7. Blocking the Event Loop with Synchronous I/O
Explanation: Node.js single-threaded architecture means synchronous database queries or heavy computations freeze the JSON-RPC message pump, causing timeouts.
Fix: Always use async/await for I/O. Offload CPU-heavy tasks to worker threads. Implement connection pooling for database resources to prevent exhaustion under concurrent agent requests.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Local AI assistant / IDE plugin | STDIO Transport | Zero network overhead, single-tenant isolation, native process lifecycle | Minimal (dev machine resources) |
| Enterprise SaaS integration | Streamable HTTP + OAuth 2.1 | Multi-tenant scalability, centralized auth, audit logging, firewall compatibility | Moderate (load balancer, auth service, monitoring) |
| High-frequency telemetry ingestion | HTTP + SSE streaming | Server-Sent Events enable real-time push without polling overhead | Higher (bandwidth, connection management) |
| Internal tooling with strict air-gap | STDIO + local secret manager | No external network exposure, complies with zero-trust internal policies | Low (infrastructure cost) |
| Multi-host deployment (Claude, Copilot, Cursor) | Single MCP Server | Eliminates redundant adapter development, enforces consistent governance | High upfront savings (reduced dev/maintenance hours) |
Configuration Template
// mcp-server.config.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHttpTransport } from "@modelcontextprotocol/sdk/server/http.js";
import { z } from "zod";
export function createProductionServer() {
const server = new McpServer({
name: "EnterpriseDataBridge",
version: "2.1.0",
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true, listChanged: true },
prompts: { listChanged: false },
logging: {}
}
});
// Tool definition with strict validation
server.tool(
"executeReportQuery",
"Runs parameterized SQL against the analytics warehouse",
{
dataset: z.enum(["sales", "inventory", "logistics"]),
filters: z.record(z.string(), z.unknown()),
limit: z.number().int().min(1).max(1000).default(100)
},
async (params) => {
// Implementation with connection pooling & query sanitization
return { content: [{ type: "text", text: "Query executed successfully" }], isError: false };
}
);
// Resource with subscription support
server.resource(
"warehouse://schema-dictionary",
"Current table definitions and column mappings",
async () => ({
contents: [{ uri: "warehouse://schema-dictionary", mimeType: "application/json", text: "{}" }]
})
);
return server;
}
// Transport binding
export async function bindTransport(server: McpServer, mode: "stdio" | "http") {
if (mode === "stdio") {
const { StdioTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
await server.connect(new StdioTransport());
} else {
const transport = new StreamableHttpTransport({
port: parseInt(process.env.MCP_PORT || "3001"),
auth: {
strategy: "oauth2",
issuer: process.env.AUTH_ISSUER,
audience: "mcp-server"
}
});
await server.connect(transport);
}
}
Quick Start Guide
- Initialize the project: Run
npm init -y && npm install @modelcontextprotocol/sdk zod to install the official SDK and validation library.
- Create the server file: Copy the configuration template into
server.ts. Replace placeholder logic with your actual data source calls.
- Start in STDIO mode: Execute
node --loader ts-node/esm server.ts stdio to launch the server locally. Connect it to Claude Desktop or VS Code by adding the executable path to your host's MCP configuration.
- Switch to HTTP for testing: Change the transport argument to
http and run node --loader ts-node/esm server.ts http. Use curl or a REST client to verify the initialize handshake and capability negotiation.
- Validate with a compliant client: Open any MCP-supported host, trigger a tool call, and confirm that schema validation, error handling, and response formatting align with the JSON-RPC 2.0 specification.