Building Production-Ready MCP Servers: A Complete Guide
Complete guide to building Model Context Protocol servers in Node.js, covering tools, resources, prompts, transport options, error handling, and production deployment.
Building Production-Ready MCP Servers: A Complete Guide
Overview
The Model Context Protocol (MCP) is an open standard created by Anthropic that defines how AI models communicate with external tools, data sources, and services. If you have been building AI-powered applications and found yourself duct-taping together custom function-calling integrations, MCP replaces all of that with a single, well-defined protocol. This guide walks you through building production-ready MCP servers in Node.js, from first principles to a complete working implementation with database tools and file system resources.
Prerequisites
- Node.js 18+ installed (LTS recommended)
- npm for package management
- Basic understanding of JSON-RPC 2.0 (MCP is built on top of it)
- Familiarity with Express.js or similar Node.js server frameworks
- A working installation of Claude Desktop (for testing your server end-to-end)
What MCP Is and Why It Matters
Before MCP, every AI integration was a bespoke mess. You would write a custom function-calling layer for OpenAI, a different one for Claude, maybe another adapter for a local model. Each had its own schema format, its own error handling semantics, its own transport mechanism.
MCP standardizes this. It defines three core primitives that an AI model can interact with:
- Tools -- Functions the model can call to perform actions (query a database, send an email, create a file)
- Resources -- Data the model can read (files, database records, API responses)
- Prompts -- Reusable prompt templates that guide model behavior for specific tasks
The protocol is transport-agnostic. It works over stdio (for local integrations), Server-Sent Events over HTTP (for remote servers), and potentially any other bidirectional communication channel. The key insight is that MCP separates the what (tools, resources, prompts) from the how (transport layer), which means you write your server logic once and it works everywhere.
MCP Architecture
The architecture is straightforward. There are three participants:
MCP Host -- The application that contains the AI model. Claude Desktop is the most common example.
MCP Client -- A protocol-level client that lives inside the host. It maintains a 1:1 connection with a single MCP server.
MCP Server -- Your code. The server exposes tools, resources, and prompts to the client.
Claude Desktop (Host)
└── MCP Client
└── [Transport Layer: stdio or SSE]
└── Your MCP Server
├── Tools
├── Resources
└── Prompts
Setting Up a Node.js MCP Server from Scratch
Initialize your project and install the SDK:
mkdir mcp-server-demo
cd mcp-server-demo
npm init -y
npm install @modelcontextprotocol/sdk
Create your server entry point:
// server.js
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
const server = new McpServer({
name: "demo-server",
version: "1.0.0",
description: "A demonstration MCP server"
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
We log to stderr, not stdout. This is critical when using stdio transport because stdout is reserved for JSON-RPC messages. Anything you write to stdout that is not valid JSON-RPC will break the protocol.
Defining Tools with Schemas
Tools are the most commonly used MCP primitive. A tool is a function that the AI model can call with structured arguments.
const { z } = require("zod");
server.tool(
"search_users",
"Search for users by name or email address. Returns matching user records.",
{
query: z.string().describe("Search term to match against user names and emails"),
limit: z.number().min(1).max(100).default(10).describe("Maximum results to return"),
include_inactive: z.boolean().default(false).describe("Whether to include deactivated accounts")
},
async ({ query, limit, include_inactive }) => {
const results = await searchUsers(query, { limit, include_inactive });
return {
content: [{
type: "text",
text: JSON.stringify(results, null, 2)
}]
};
}
);
The SDK uses Zod for schema definition, which gives you runtime validation for free. The .describe() calls are sent to the model as part of the tool schema and directly influence how well the model uses your tool.
Write tool descriptions like documentation for a junior developer. Be specific about what the tool does, what it returns, and any side effects.
Implementing Resource Endpoints
Resources are read-only data that the model can access. They are identified by URIs.
// Static resource
server.resource(
"system-status",
"status://system",
"Current system health and status information",
async (uri) => {
const status = await getSystemStatus();
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(status, null, 2)
}]
};
}
);
For dynamic resources, use resource templates:
const { ResourceTemplate } = require("@modelcontextprotocol/sdk/server/mcp.js");
server.resource(
"user-profile",
new ResourceTemplate("users://{userId}/profile", { list: undefined }),
"User profile data for a specific user ID",
async (uri, { userId }) => {
const profile = await getUserProfile(userId);
if (!profile) {
return {
contents: [{
uri: uri.href,
mimeType: "text/plain",
text: "User not found"
}]
};
}
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(profile, null, 2)
}]
};
}
);
Handling Prompts
Prompts are reusable templates that help the model perform specific tasks consistently.
server.prompt(
"code-review",
"Generate a structured code review for the given code snippet",
{
language: z.string().describe("Programming language of the code"),
code: z.string().describe("The code to review"),
focus: z.enum(["security", "performance", "readability", "all"]).default("all")
},
({ language, code, focus }) => {
const focusInstructions = {
security: "Focus specifically on security vulnerabilities and injection risks.",
performance: "Focus on performance bottlenecks and algorithmic complexity.",
readability: "Focus on code clarity, naming conventions, and maintainability.",
all: "Cover security, performance, and readability equally."
};
return {
messages: [{
role: "user",
content: {
type: "text",
text: `Review the following ${language} code. ${focusInstructions[focus]}\n\n\`\`\`${language}\n${code}\n\`\`\``
}
}]
};
}
);
Stdio vs SSE Transport
Stdio Transport is the default for local integrations. Claude Desktop spawns your server as a child process and communicates over stdin/stdout. Simple, fast, zero network configuration.
SSE Transport is for remote servers. It runs your MCP server as an HTTP service that clients connect to over the network.
const { SSEServerTransport } = require("@modelcontextprotocol/sdk/server/sse.js");
const express = require("express");
const app = express();
const transports = {};
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/messages", res);
transports[transport.sessionId] = transport;
res.on("close", () => { delete transports[transport.sessionId]; });
await server.connect(transport);
});
app.post("/messages", async (req, res) => {
const sessionId = req.query.sessionId;
const transport = transports[sessionId];
if (!transport) {
res.status(404).json({ error: "Session not found" });
return;
}
await transport.handlePostMessage(req, res);
});
app.listen(3001, () => {
console.log("MCP SSE server running on port 3001");
});
Start with stdio for development and testing. Move to SSE when you need to deploy independently or share across multiple clients.
Error Handling Patterns
Production MCP servers need robust error handling:
const { McpError, ErrorCode } = require("@modelcontextprotocol/sdk/types.js");
server.tool(
"query_database",
"Execute a read-only SQL query against the application database",
{
sql: z.string().describe("SQL SELECT query to execute"),
params: z.array(z.union([z.string(), z.number(), z.null()])).default([])
},
async ({ sql, params }) => {
var normalized = sql.trim().toUpperCase();
if (!normalized.startsWith("SELECT")) {
throw new McpError(
ErrorCode.InvalidParams,
"Only SELECT queries are allowed."
);
}
var forbidden = ["DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "TRUNCATE"];
for (var i = 0; i < forbidden.length; i++) {
if (normalized.includes(forbidden[i])) {
throw new McpError(ErrorCode.InvalidParams, "Query contains forbidden keyword: " + forbidden[i]);
}
}
try {
var results = await executeQuery(sql, params);
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
} catch (err) {
console.error("Database query failed:", err);
return {
content: [{ type: "text", text: "Query failed: " + err.message }],
isError: true
};
}
}
);
Throwing McpError signals a protocol-level problem. Returning { isError: true } signals an application-level failure. Use the right one for the right situation.
Testing Your MCP Server
Test tool handlers in isolation by extracting business logic. Then test end-to-end using the SDK's InMemoryTransport:
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
const { InMemoryTransport } = require("@modelcontextprotocol/sdk/inMemory.js");
var client = new Client({ name: "test-client", version: "1.0.0" });
var [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport)
]);
var result = await client.listTools();
assert(result.tools.length > 0);
var callResult = await client.callTool("search_users", { query: "john", limit: 5 });
assert(callResult.content.length > 0);
Connecting to Claude Desktop
Edit your Claude Desktop configuration:
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/absolute/path/to/your/server.js"],
"env": {
"DB_PATH": "/path/to/database.sqlite",
"PROJECT_ROOT": "/path/to/project"
}
}
}
}
Always use absolute paths. Pass sensitive configuration through env, not command-line arguments.
Complete Working Example
Here is a full MCP server providing database query tools and file system resources:
// server.js
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
const { McpError, ErrorCode } = require("@modelcontextprotocol/sdk/types.js");
const { z } = require("zod");
const path = require("path");
const fs = require("fs").promises;
const Database = require("better-sqlite3");
var PROJECT_ROOT = process.env.PROJECT_ROOT || process.cwd();
var DB_PATH = process.env.DB_PATH || path.join(PROJECT_ROOT, "data.sqlite");
var db;
function initDatabase() {
db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
role TEXT DEFAULT 'user',
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
owner_id INTEGER REFERENCES users(id),
status TEXT DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now'))
);
`);
}
var server = new McpServer({
name: "project-manager",
version: "1.0.0"
});
server.tool(
"query_database",
"Execute a read-only SQL SELECT query against the project database.",
{
sql: z.string().describe("SQL SELECT query to execute"),
params: z.array(z.union([z.string(), z.number(), z.null()])).default([])
},
async ({ sql, params }) => {
var normalized = sql.trim().toUpperCase();
if (!normalized.startsWith("SELECT")) {
throw new McpError(ErrorCode.InvalidParams, "Only SELECT queries are permitted.");
}
try {
var rows = db.prepare(sql).all(...params);
return { content: [{ type: "text", text: JSON.stringify({ rowCount: rows.length, rows }, null, 2) }] };
} catch (err) {
return { content: [{ type: "text", text: "Query error: " + err.message }], isError: true };
}
}
);
server.tool(
"list_tables",
"List all tables in the database with their column definitions.",
{},
async () => {
var tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
var schemas = tables.map(function(table) {
var columns = db.prepare("PRAGMA table_info(" + table.name + ")").all();
return { table: table.name, columns: columns };
});
return { content: [{ type: "text", text: JSON.stringify(schemas, null, 2) }] };
}
);
server.tool(
"read_project_file",
"Read the contents of a file within the project directory.",
{
file_path: z.string().describe("Relative path from the project root")
},
async ({ file_path: filePath }) => {
if (filePath.includes("..") || path.isAbsolute(filePath)) {
throw new McpError(ErrorCode.InvalidParams, "Path traversal is not allowed.");
}
var fullPath = path.resolve(PROJECT_ROOT, filePath);
if (!fullPath.startsWith(PROJECT_ROOT)) {
throw new McpError(ErrorCode.InvalidParams, "Path resolves outside the project directory.");
}
try {
var content = await fs.readFile(fullPath, "utf-8");
return { content: [{ type: "text", text: content }] };
} catch (err) {
return { content: [{ type: "text", text: "Error: " + err.message }], isError: true };
}
}
);
server.resource(
"db-schema",
"db://schema",
"Complete database schema",
async (uri) => {
var tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
var schema = tables.map(function(t) {
return { table: t.name, columns: db.prepare("PRAGMA table_info(" + t.name + ")").all() };
});
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(schema, null, 2) }] };
}
);
async function main() {
initDatabase();
console.error("Database initialized at:", DB_PATH);
var transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Project Manager server is running");
}
main().catch(function(err) {
console.error("Fatal error:", err);
process.exit(1);
});
Install dependencies:
npm install @modelcontextprotocol/sdk better-sqlite3 zod
Common Issues and Troubleshooting
1. Server fails to start silently in Claude Desktop. Run your server manually first: echo '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"},"protocolVersion":"2024-11-05"},"id":1}' | node server.js
2. stdout pollution breaks JSON-RPC. Any dependency that writes to stdout corrupts the protocol stream. Audit your dependency chain and redirect everything to stderr.
3. Tool arguments come through as wrong types. The model sometimes sends stringified numbers. Use .preprocess() in your Zod schemas for critical type conversions.
4. Resource URIs with special characters cause failures. Always use encodeURIComponent() when constructing URIs and decodeURIComponent() when parsing them.
Best Practices
- Write tool descriptions for the model, not for humans. Be specific, include examples of valid inputs, describe what the output looks like.
- Validate all inputs as if they are user-supplied. Never trust path arguments without checking for traversal. Never execute raw SQL without sanitization.
- Use
isError: truefor recoverable failures, throwMcpErrorfor hard failures. - Keep tools focused and composable. The model is excellent at chaining multiple tool calls.
- Always log to stderr, never stdout, in stdio transport mode.
- Test with the in-memory transport before testing with Claude Desktop.
- Implement graceful shutdown. Close database connections and flush logs on SIGINT/SIGTERM.
