How to Build a Production-Ready MCP Server with Node.js
Building an MCP demo is easy. Building an MCP server that can safely sit behind a public HTTPS endpoint, handle multiple users, call production systems, survive upstream failures, and scale across multiple instances is a different problem.…

Answer first
Quick answer
Building an MCP demo is easy. Building an MCP server that can safely sit behind a public HTTPS endpoint, handle multiple users, call production systems, survive upstream failures, and scale across multiple instances is a different problem.
- Written by
- Ali Fawad Hassan
- Topic
- AI
- Reading time
- 31 minutes
- Published
- 14 Aug 2026
Building an MCP demo is easy. Building an MCP server that can safely sit behind a public HTTPS endpoint, handle multiple users, call production systems, survive upstream failures, and scale across multiple instances is a different problem.
A production-ready MCP server needs more than a few registered tools. You need to make deliberate decisions about transport, authorization, validation, state, timeouts, retries, rate limits, observability, testing, deployment, and security.
This guide uses the current Model Context Protocol specification 2026-07-28 and the stable MCP TypeScript SDK v2. That matters because many older tutorials still use SDK v1 APIs, the legacy @modelcontextprotocol/sdk package, older HTTP+SSE patterns, or protocol-level sessions that are no longer the recommended architecture.
Quick Answer: What Does a Production MCP Server Need?
For most remotely hosted Node.js MCP servers, a solid production baseline is:
- Node.js 24 LTS for a new production deployment
- MCP TypeScript SDK v2
- TypeScript with strict input schemas
- Streamable HTTP for a remote server
- stdio only when the MCP client launches the server locally
- OAuth-compatible authorization for protected remote resources
- Per-tool authorization and least-privilege access
- Stateless HTTP request handling
- Database or Redis-backed application state when state is actually required
- Timeouts, selective retries, and safe failure handling
- Rate limiting and abuse protection
- Structured logs, metrics, and distributed traces
- Automated unit and integration tests
- MCP Inspector testing
- Secure runtime secret management
- Health checks and graceful shutdown
- A container or managed Node.js runtime with HTTPS, monitoring, and autoscaling where required
You do not need every component for every server. A local stdio MCP utility has very different operational requirements from a multi-tenant SaaS MCP endpoint exposed on the internet.
What Is a Production-Ready MCP Server?
A production-ready MCP server is an MCP implementation that exposes tools, resources, or prompts through a supported transport while also providing the security, reliability, isolation, observability, testing, and operational controls required by its workload. Production readiness depends on how the server is deployed: local, private, public, single-user, or multi-tenant.
| Area | Demo Server | Production Server |
|---|---|---|
| Transport | Whatever runs locally | Transport selected for the deployment model |
| Authentication | Often none | Verified identity where protected access is required |
| Authorization | Usually skipped | Checked for every sensitive operation |
| Validation | Basic schema | Strict schema plus business validation |
| Errors | Thrown exceptions | Predictable, sanitized responses |
| State | Process memory | Stateless or explicitly managed durable state |
| Reliability | Best effort | Timeouts, retries, idempotency, failure handling |
| Logging | console.log() | Structured logs, metrics, traces |
| Security | Limited | Least privilege, validation, rate limits, auditability |
| Deployment | Developer machine | Managed runtime with TLS, health checks, monitoring |
Production MCP Server Architecture
Do not make the architecture more complicated than the workload requires. For many remote MCP servers, the following is enough:
MCP Client / Host
|
| HTTPS
v
API Gateway / Load Balancer
|
v
Node.js MCP Server
|
+-- Authentication / Authorization
|
+-- Tools / Resources / Prompts
|
+-- Service Layer
|
+-- External APIs / Database / SaaS
|
+-- Logs / Metrics / Traces
Redis, message queues, caches, and dedicated workers are optional. Add them only when you have a real requirement such as shared application state, expensive cached reads, distributed rate limiting, or long-running background work.
How Do You Build an MCP Server with Node.js?
The production-oriented process is straightforward:
- Choose a supported Node.js runtime and install MCP TypeScript SDK v2.
- Separate protocol handlers from your business and integration logic.
- Register well-described tools, resources, and prompts.
- Validate every incoming argument.
- Expose the server through stdio or Streamable HTTP based on how clients connect.
- Add authorization before exposing protected functionality remotely.
- Add timeouts, safe retries, logging, rate limiting, and health checks.
- Test through a real MCP client and the MCP Inspector.
- Containerize and deploy behind HTTPS.
Set Up the Node.js and TypeScript Project
The MCP TypeScript SDK currently supports Node.js 20 or newer. However, Node.js 20 has reached end of life. For a new production deployment in August 2026, Node.js 24 LTS is the safer baseline and also satisfies the MCP Inspector’s Node.js requirement.
The v2 SDK uses split packages. Do not copy an older tutorial that starts with @modelcontextprotocol/sdk; that package belongs to the v1 SDK line.
mkdir support-mcp-server
cd support-mcp-server
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server \
@modelcontextprotocol/express \
@modelcontextprotocol/node \
express \
zod
npm install -D typescript \
tsx \
@types/node \
@types/express \
@modelcontextprotocol/client
Create a basic TypeScript configuration:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
Add useful scripts to package.json:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
}
}
Suggested Project Structure
src/
├── server.ts
├── mcp/
│ └── create-server.ts
├── config/
│ └── index.ts
├── tools/
│ └── support-tools.ts
├── resources/
│ └── support-resources.ts
├── prompts/
│ └── support-prompts.ts
├── services/
│ └── support-service.ts
├── repositories/
├── auth/
├── middleware/
├── errors/
├── observability/
└── utils/
tests/
├── unit/
├── integration/
└── e2e/
The important principle is separation: MCP should be your protocol layer, not the place where every database query, HTTP request, authorization rule, and retry algorithm lives.
Build a Realistic MCP Server
We will use a small Customer Support MCP Server rather than another calculator example.
It exposes:
search_customers— read-only customer lookupget_customer— read-only customer retrievalcreate_support_ticket— write operationsupport://knowledge-base— support resourcesummarize_customer_issue— reusable prompt
The MCP handlers call a service interface, making the business layer easy to mock during tests.
Configuration
API_BASE_URL=https://support-api.example.com
API_KEY=replace-with-runtime-secret
PORT=3000
NODE_ENV=development
ALLOWED_HOSTS=localhost,127.0.0.1
Do not commit a real .env file containing production secrets.
// src/config/index.ts
import * as z from 'zod/v4';
const EnvironmentSchema = z.object({
API_BASE_URL: z.string().url(),
API_KEY: z.string().min(1),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
ALLOWED_HOSTS: z.string().default('localhost,127.0.0.1')
});
const env = EnvironmentSchema.parse(process.env);
export const config = {
apiBaseUrl: env.API_BASE_URL,
apiKey: env.API_KEY,
port: env.PORT,
nodeEnv: env.NODE_ENV,
allowedHosts: env.ALLOWED_HOSTS
.split(',')
.map(value => value.trim())
.filter(Boolean)
};
Keep External APIs Behind a Service Layer
// src/services/support-service.ts
import { randomUUID } from 'node:crypto';
export interface Customer {
id: string;
name: string;
email: string;
}
export interface SupportTicket {
id: string;
customerId: string;
subject: string;
priority: string;
status: string;
}
export interface SupportService {
searchCustomers(email: string, limit: number): Promise<Customer[]>;
getCustomer(id: string): Promise<Customer | null>;
createTicket(input: {
customerId: string;
subject: string;
description: string;
priority: string;
}): Promise<SupportTicket>;
}
export class HttpSupportService implements SupportService {
constructor(
private readonly baseUrl: string,
private readonly apiKey: string
) {}
private async request<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${this.apiKey}`);
headers.set('Accept', 'application/json');
const response = await fetch(new URL(path, this.baseUrl), {
...init,
headers,
signal: AbortSignal.timeout(8_000)
});
if (!response.ok) {
throw new Error(`UPSTREAM_${response.status}`);
}
return response.json() as Promise<T>;
}
async searchCustomers(
email: string,
limit: number
): Promise<Customer[]> {
const params = new URLSearchParams({
email,
limit: String(limit)
});
return this.request<Customer[]>(`/customers?${params}`);
}
async getCustomer(id: string): Promise<Customer | null> {
try {
return await this.request<Customer>(
`/customers/${encodeURIComponent(id)}`
);
} catch (error) {
if (error instanceof Error && error.message === 'UPSTREAM_404') {
return null;
}
throw error;
}
}
async createTicket(input: {
customerId: string;
subject: string;
description: string;
priority: string;
}): Promise<SupportTicket> {
return this.request<SupportTicket>('/tickets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID()
},
body: JSON.stringify(input)
});
}
}
The API endpoint names are placeholders for your own application, but notice the architecture: the model never supplies an arbitrary upstream URL, credentials stay outside tool arguments, requests have a timeout, and write operations can use idempotency when the upstream API supports it.
How Should MCP Tools Be Designed?
An MCP tool should have a precise name, a description that explains exactly what it does, a narrow schema, predictable output, and explicit authorization appropriate to the operation. Read-only and write operations should be easy for both the client and developer to distinguish.
A vague description such as “Manage customer” is weak. A description such as “Create a support ticket for an existing customer; this writes data to the support system” makes the side effect clear.
Register the Support Tools
// src/tools/support-tools.ts
import type { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import type { SupportService } from '../services/support-service.js';
const CustomerIdSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/)
.describe('Internal customer ID');
const CustomerSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string()
});
const TicketSchema = z.object({
id: z.string(),
customerId: z.string(),
subject: z.string(),
priority: z.string(),
status: z.string()
});
function safeToolError(message: string) {
return {
content: [{ type: 'text' as const, text: message }],
isError: true
};
}
export function registerSupportTools(
server: McpServer,
support: SupportService
): void {
server.registerTool(
'search_customers',
{
description:
'Search customers by exact email address. Read-only operation.',
inputSchema: z.object({
email: z
.string()
.email()
.max(254)
.describe('Exact customer email address'),
limit: z
.number()
.int()
.min(1)
.max(20)
.default(10)
.describe('Maximum number of customers to return')
}),
outputSchema: z.object({
customers: z.array(CustomerSchema),
count: z.number().int()
})
},
async ({ email, limit }) => {
try {
const customers = await support.searchCustomers(email, limit);
return {
content: [
{
type: 'text',
text: `Found ${customers.length} customer(s).`
}
],
structuredContent: {
customers,
count: customers.length
}
};
} catch {
return safeToolError(
'Customer search is temporarily unavailable.'
);
}
}
);
server.registerTool(
'get_customer',
{
description:
'Retrieve one customer by ID. Read-only operation.',
inputSchema: z.object({
customerId: CustomerIdSchema
}),
outputSchema: z.object({
customer: CustomerSchema.nullable()
})
},
async ({ customerId }) => {
try {
const customer = await support.getCustomer(customerId);
return {
content: [
{
type: 'text',
text: customer
? `Customer ${customer.name} was found.`
: 'Customer was not found.'
}
],
structuredContent: { customer }
};
} catch {
return safeToolError(
'Customer lookup is temporarily unavailable.'
);
}
}
);
server.registerTool(
'create_support_ticket',
{
description:
'Create a support ticket for an existing customer. This operation writes data and should only be used after the user intends to create a ticket.',
inputSchema: z.object({
customerId: CustomerIdSchema,
subject: z
.string()
.trim()
.min(3)
.max(160),
description: z
.string()
.trim()
.min(10)
.max(5000),
priority: z.enum([
'low',
'normal',
'high',
'urgent'
])
}),
outputSchema: TicketSchema
},
async input => {
try {
const ticket = await support.createTicket(input);
return {
content: [
{
type: 'text',
text: `Support ticket ${ticket.id} was created.`
}
],
structuredContent: ticket
};
} catch {
return safeToolError(
'The support ticket could not be created.'
);
}
}
);
}
The current SDK derives JSON Schema from the Zod schema and validates arguments before invoking the handler. An outputSchema can also describe structured tool output, with the actual value returned through structuredContent.
Why Tool Descriptions Matter
The model sees the tool definition supplied by the server. Names, descriptions, argument descriptions, and schemas therefore form part of the interface the model uses when deciding how the capability should be invoked.
Descriptions should answer:
- What does the tool do?
- Is it read-only or does it change data?
- What entity does it operate on?
- What does each argument mean?
- Are there important preconditions?
Do not depend on a good description as a security mechanism. Authorization still belongs on the server.
Tools vs Resources vs Prompts
| MCP Feature | Purpose | Control Model | Example |
|---|---|---|---|
| Tool | Perform an operation | Typically model-controlled | Create a support ticket |
| Resource | Expose read-only data/context | Application-controlled | Support knowledge base |
| Prompt | Expose a reusable message template | User-controlled through the client | Summarize a support issue |
Do not turn everything into a tool. If something is naturally read-only context, a resource is often a better fit. If it is a reusable user-selectable workflow or template, consider a prompt.
Register a Resource
// src/resources/support-resources.ts
import type { McpServer } from '@modelcontextprotocol/server';
const knowledgeBase = `
# Support Knowledge Base
## Password resets
Verify the customer's identity before initiating a password reset.
## Billing disputes
Do not alter billing data without the required account permissions.
## Escalation
Urgent incidents should be escalated according to the support policy.
`;
export function registerSupportResources(
server: McpServer
): void {
server.registerResource(
'support-knowledge-base',
'support://knowledge-base',
{
title: 'Support Knowledge Base',
description: 'Read-only customer support policies',
mimeType: 'text/markdown'
},
async uri => ({
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: knowledgeBase
}
]
})
);
}
Register a Prompt
// src/prompts/support-prompts.ts
import type { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
export function registerSupportPrompts(
server: McpServer
): void {
server.registerPrompt(
'summarize_customer_issue',
{
title: 'Summarize Customer Issue',
description:
'Create a concise internal summary of a customer support issue.',
argsSchema: z.object({
customerId: z
.string()
.min(1)
.max(64)
.describe('Customer identifier'),
issue: z
.string()
.min(10)
.max(5000)
.describe('Customer issue to summarize')
})
},
({ customerId, issue }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text:
`Summarize the following support issue for customer ` +
`${customerId}. Include the problem, impact, and recommended next step.\n\n${issue}`
}
}
]
})
);
}
Create the MCP Server Factory
For modern HTTP servers, a factory-based design is useful because the v2 HTTP handler creates a fresh MCP server for each request rather than relying on a protocol-level session.
// src/mcp/create-server.ts
import { McpServer } from '@modelcontextprotocol/server';
import { registerSupportPrompts } from '../prompts/support-prompts.js';
import { registerSupportResources } from '../resources/support-resources.js';
import type { SupportService } from '../services/support-service.js';
import { registerSupportTools } from '../tools/support-tools.js';
export function createSupportMcpServer(
support: SupportService
): McpServer {
const server = new McpServer(
{
name: 'customer-support',
version: '1.0.0'
},
{
cacheHints: {
'tools/list': {
ttlMs: 60_000,
cacheScope: 'public'
}
}
}
);
registerSupportTools(server, support);
registerSupportResources(server);
registerSupportPrompts(server);
return server;
}
stdio vs Streamable HTTP: Which Transport Should You Use?
Use stdio when an MCP client launches the server as a local child process. Use Streamable HTTP when you are exposing a shared or remotely hosted MCP server over the network.
| Area | stdio | Streamable HTTP |
|---|---|---|
| Typical deployment | Local machine | Remote service |
| Connection | stdin/stdout | HTTP endpoint |
| Remote access | Not its normal use case | Yes |
| HTTPS infrastructure | No | Yes in production |
| OAuth resource-server model | Normally no | Applicable for protected servers |
| Horizontal scaling | Usually irrelevant | Natural fit |
What Happened to SSE?
The older HTTP+SSE transport used by early MCP tutorials has been replaced by Streamable HTTP. That does not mean Server-Sent Events disappeared completely.
Under the current Streamable HTTP model, each MCP JSON-RPC request or notification is sent as its own HTTP POST. A response can be regular JSON or a request-scoped SSE stream when streaming is appropriate. The old standalone GET stream endpoint and protocol-session architecture should not be treated as the default for new 2026 servers.
Serve the MCP Server Over Streamable HTTP
The current TypeScript SDK provides createMcpHandler(). For Express, createMcpExpressApp() and toNodeHandler() provide the Node adapter and request protections.
// src/server.ts
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { config } from './config/index.js';
import { createSupportMcpServer } from './mcp/create-server.js';
import { HttpSupportService } from './services/support-service.js';
const handler = createMcpHandler(() => {
const support = new HttpSupportService(
config.apiBaseUrl,
config.apiKey
);
return createSupportMcpServer(support);
});
const app = createMcpExpressApp({
host: '0.0.0.0',
allowedHosts: config.allowedHosts
});
const nodeHandler = toNodeHandler(handler);
app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});
app.get('/ready', (_req, res) => {
res.status(200).json({ status: 'ready' });
});
app.all('/mcp', (req, res) => {
void nodeHandler(req, res, req.body);
});
const httpServer = app.listen(
config.port,
'0.0.0.0',
() => {
process.stdout.write(
JSON.stringify({
level: 'info',
event: 'server_started',
port: config.port
}) + '\n'
);
}
);
async function shutdown(signal: string): Promise<void> {
process.stdout.write(
JSON.stringify({
level: 'info',
event: 'shutdown_started',
signal
}) + '\n'
);
httpServer.close(async error => {
await handler.close();
if (error) {
process.exitCode = 1;
}
});
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
Important: the example above is suitable for local HTTP development and demonstrates the current MCP transport architecture. Do not expose it publicly until you add the authorization, TLS, rate limiting, and other controls discussed below.
Important MCP 2026 Architecture Changes
The 2026-07-28 specification materially changes how a modern production MCP server should be designed.
1. The Protocol Core Is Stateless
The previous initialization/session lifecycle is no longer the foundation of the modern protocol. Requests carry the information required for handling them rather than depending on a protocol-level server session.
2. Protocol-Level HTTP Sessions Were Removed
The old Mcp-Session-Id-based model is not the architecture to design new 2026 servers around. Any request should be able to reach any healthy MCP server instance behind a normal load balancer.
3. State Must Be Explicit When You Need It
If an operation needs application state across multiple calls, persist that state outside the process and return an explicit handle that can be supplied again later.
For example:
tool call
|
v
create export job
|
v
store state in database
|
v
return export_handle = "exp_123"
|
v
later tool call supplies "exp_123"
4. Long-Lived Notifications Changed
The previous generic GET event stream is gone from the modern Streamable HTTP design. Long-lived listening behavior uses the current subscription mechanisms rather than the old session stream.
5. HTTP Routing Became Easier for Infrastructure
The current specification provides HTTP metadata such as Mcp-Method and, where relevant, Mcp-Name, allowing gateways and infrastructure to make routing, metering, or policy decisions without parsing the entire JSON-RPC body.
6. Version Negotiation Is Modernized
Current requests are self-describing and carry protocol information and capabilities. The current protocol also defines discovery/version-negotiation behavior instead of requiring applications to depend on the old initialization exchange.
7. Extensions Are Explicitly Negotiated
Optional functionality should not be assumed to exist simply because it appears in the MCP ecosystem. Extensions must be supported by both sides where required.
8. Tasks Are an Extension, Not Universal Core Behavior
The io.modelcontextprotocol/tasks extension supports long-running asynchronous work using durable task handles, polling, and mid-flight input. It is useful for operations such as large exports or jobs that should not keep an HTTP request open, but you should not assume every MCP client supports it.
Should an MCP Server Be Stateless or Stateful?
For modern remote MCP servers, make the protocol handling stateless by default. If your application needs persistent state, keep it in an external system or represent it with an explicit handle rather than storing critical state only inside one Node.js process.
Appropriate stores include:
- PostgreSQL, MySQL, or another application database
- Redis for suitable short-lived shared state
- Object storage for large artifacts
- A durable job system for asynchronous workflows
This makes horizontal scaling significantly easier because a second request does not have to return to the same Node.js instance.
Does an MCP Server Need OAuth?
Not every MCP server needs OAuth. A local stdio server usually obtains credentials from its environment or host. A protected remote HTTP MCP server, particularly one accessing user-specific or tenant-specific data, should follow the current MCP authorization specification.
For protected remote servers, the MCP server acts as an OAuth resource server. It verifies access tokens issued by an authorization server; it should not become an ad-hoc OAuth provider simply because it exposes MCP.
Important Authorization Requirements
- Publish OAuth Protected Resource Metadata for protected MCP resources.
- Validate access-token signatures or use your identity provider’s introspection mechanism.
- Validate token expiration.
- Validate token audience for your MCP server.
- Enforce scopes and application permissions.
- Use the resource indicator behavior required by the current authorization model.
- Return
401 Unauthorizedfor missing, invalid, or expired credentials. - Return
403 Forbiddenwhen credentials are valid but insufficient. - Use HTTPS for internet-facing protected endpoints.
Current TypeScript SDK Authorization Pattern
The Express integration exposes requireBearerAuth(). Your application supplies the token verifier because token verification is identity-provider specific.
import type {
OAuthTokenVerifier
} from '@modelcontextprotocol/express';
import {
getOAuthProtectedResourceMetadataUrl,
requireBearerAuth
} from '@modelcontextprotocol/express';
const mcpServerUrl =
new URL('https://mcp.example.com/mcp');
const verifier: OAuthTokenVerifier = {
verifyAccessToken
};
const auth = requireBearerAuth({
verifier,
requiredScopes: ['support:read'],
resourceMetadataUrl:
getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
app.all('/mcp', auth, (req, res) => {
void nodeHandler(req, res, req.body);
});
verifyAccessToken should be implemented using your identity provider’s supported JWT verification or token-introspection mechanism. Do not invent your own token format or cryptography.
For a fully protected server, also expose the OAuth Protected Resource Metadata document using the SDK’s metadata helpers so compatible clients can discover the authorization server.
Never Use Token Passthrough
If an MCP client gives your server an access token intended for the MCP server, do not simply forward that same token to a downstream API.
The MCP authorization security requirements prohibit token passthrough. If your server calls a downstream OAuth-protected system, obtain or use a separate downstream token intended for that service.
How Should You Validate MCP Tool Inputs?
Treat every MCP tool argument as untrusted input. Models can produce incorrect or maliciously influenced arguments, and the client calling your MCP server may itself be untrusted.
Your schema should constrain values as tightly as the business operation permits:
- Required fields
- Allowed enums
- Minimum and maximum string lengths
- Known ID formats
- Numeric ranges
- Valid email addresses
- Valid URLs when URLs are genuinely required
- Valid date and timestamp formats
Schema validation is only the first layer. It does not prevent SQL injection if you later concatenate a valid string into a query, SSRF if you allow arbitrary URLs, or unauthorized access if the caller supplies another tenant’s valid object ID.
MCP Security Hardening
A secure MCP server combines MCP-specific controls with normal API and backend security.
| Threat | Recommended Control |
|---|---|
| Unauthorized tool invocation | Authentication plus per-operation authorization |
| Cross-tenant object access | Object-level authorization on every lookup/write |
| Malicious arguments | Strict schema and business validation |
| SQL injection | Parameterized queries |
| Command injection | Avoid shell execution; never interpolate model input into commands |
| SSRF | Do not let tool input freely select arbitrary destinations; use destination allowlists |
| Path traversal | Restrict file roots and normalize/validate paths |
| Token leakage | Redact authorization data from logs |
| Credential exposure | Runtime secret management |
| DNS rebinding | Host and Origin validation, especially for local HTTP servers |
| Destructive operation abuse | Least privilege, explicit authorization, and confirmation UX where appropriate |
| Prompt injection from external content | Treat retrieved content as data, not trusted instructions |
The official Express helper createMcpExpressApp() includes host/origin protections designed to help mitigate DNS-rebinding attacks. When binding to 0.0.0.0, explicitly configure the hosts your service is expected to serve.
If browser-based clients are part of your architecture, configure appropriate allowed origins as well. Do not use a wildcard merely to make CORS errors disappear.
Secrets and Configuration
Never put API keys, access tokens, client secrets, database passwords, or private keys directly in the source code or Dockerfile.
Environment variables are acceptable for the tutorial and many runtime environments:
API_BASE_URL=
API_KEY=
PORT=
NODE_ENV=
In production, inject those values from your deployment environment or a managed secrets system. The important requirement is that secrets are encrypted, access-controlled, rotatable, and kept out of source control, Docker image layers, logs, and error messages.
Production Error Handling
Different failures should be handled differently:
| Failure | Typical Handling |
|---|---|
| Invalid tool arguments | Schema validation failure |
| Unauthenticated caller | 401 at HTTP authorization layer |
| Insufficient permission | 403 or safe tool-level authorization failure |
| Upstream 4xx | Translate into a safe business error |
| Upstream timeout | Return temporary-unavailability message |
| Rate limit | Respect retry guidance where appropriate |
| Dependency outage | Fail safely and record operational detail internally |
| Unexpected exception | Generic client-facing error plus internal structured log |
The MCP SDK distinguishes protocol errors from tool execution errors. For an expected tool failure that the model may be able to recover from, return a normal tool result with isError: true and a useful safe message.
Never send raw stack traces, SQL statements, internal URLs, tokens, database credentials, or infrastructure details to an MCP client.
Timeouts, Retries, and Resilience
Every external dependency should have a bounded execution time. Node.js provides AbortSignal.timeout(), making HTTP request timeouts straightforward.
const response = await fetch(url, {
signal: AbortSignal.timeout(8_000)
});
Retries should be selective rather than automatic.
Reasonable candidates can include:
- 429 responses when the upstream explicitly permits retrying
- Temporary 502, 503, or 504 responses
- Network failures for idempotent operations
Avoid automatically retrying:
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- Most 404 responses
- Non-idempotent writes unless you have an idempotency strategy
Use exponential backoff with a maximum attempt count and honor Retry-After when the upstream provides it.
Rate Limiting
There is no single correct rate limit for every MCP server. Apply limits where they reflect actual risk and upstream capacity.
MCP Client
|
v
Gateway / Load Balancer
| global / IP / token limits
v
MCP Server
| user / tenant / tool limits
v
External Service
upstream provider limits
Depending on your application, limits may need to be enforced by:
- IP address
- Authenticated user
- Tenant
- Access token or client
- Specific MCP tool
- Downstream API
Write-heavy or expensive tools often deserve stricter limits than inexpensive reads.
Performance and MCP Caching
The 2026 TypeScript SDK supports cache hints using ttlMs and cacheScope for cacheable responses.
const server = new McpServer(
{
name: 'customer-support',
version: '1.0.0'
},
{
cacheHints: {
'tools/list': {
ttlMs: 60_000,
cacheScope: 'public'
},
'resources/read': {
ttlMs: 5_000,
cacheScope: 'private'
}
}
}
);
Use public only when the result is genuinely identical for every caller. Anything influenced by authentication, tenant membership, permissions, or private user data should remain private.
Other straightforward performance improvements include:
- Deterministic tool and resource definitions
- Database connection pooling
- HTTP connection reuse
- Efficient upstream queries
- Pagination for large data sets
- Avoiding unnecessarily large tool results
- Caching safe read operations rather than writes
Logging and Observability
A production MCP server should produce structured operational data rather than relying on scattered console.log() calls.
Useful log fields include:
{
"level": "info",
"requestId": "req_123",
"mcpMethod": "tools/call",
"toolName": "search_customers",
"durationMs": 84,
"status": "success",
"tenantId": "tenant_42",
"upstream": "support-api"
}
Do not log:
- Bearer tokens
- API keys
- OAuth refresh tokens
- Passwords
- Full sensitive customer payloads unless explicitly required and secured
Metrics Worth Tracking
- Total tool calls
- Calls by tool
- Tool latency
- Error rate
- Upstream API latency
- Upstream failure rate
- Authorization failures
- Rate-limit responses
- Active or queued long-running jobs where applicable
Distributed Tracing
The current MCP specification reserves traceparent, tracestate, and baggage metadata for OpenTelemetry-compatible trace propagation using the W3C Trace Context and Baggage standards.
If your architecture already uses OpenTelemetry, propagate trace context through the MCP layer and onward to your internal services so one tool call can be followed across the full request path.
How Do You Test an MCP Server?
Test business logic independently, then test the actual MCP protocol boundary using the official client implementation. Finally, inspect the running server with MCP Inspector and test selected flows through a compatible host.
1. Unit Tests
Test your service and repository logic without MCP. This is where you should verify filtering, authorization rules, transformations, retries, and business behavior.
2. Tool Handler Tests
Cover at least:
- Valid input
- Invalid input
- Missing permission
- Nonexistent objects
- Upstream failures
- Timeouts
- Write idempotency where applicable
3. Integration Tests Through a Real MCP Client
The v2 SDK can drive the HTTP handler in-process without opening a network port.
import assert from 'node:assert/strict';
import {
Client,
StreamableHTTPClientTransport
} from '@modelcontextprotocol/client';
import {
createMcpHandler
} from '@modelcontextprotocol/server';
import { createSupportMcpServer } from '../../src/mcp/create-server.js';
const fakeSupportService = {
async searchCustomers() {
return [
{
id: 'cus_1',
name: 'Jane Doe',
email: 'jane@example.com'
}
];
},
async getCustomer() {
return null;
},
async createTicket() {
return {
id: 'ticket_1',
customerId: 'cus_1',
subject: 'Login issue',
priority: 'high',
status: 'open'
};
}
};
const handler = createMcpHandler(
() => createSupportMcpServer(fakeSupportService)
);
const transport = new StreamableHTTPClientTransport(
new URL('http://test.local/mcp'),
{
fetch: (url, init) =>
handler.fetch(new Request(url, init))
}
);
const client = new Client(
{
name: 'integration-tests',
version: '1.0.0'
},
{
versionNegotiation: {
mode: 'auto'
}
}
);
await client.connect(transport);
const result = await client.callTool({
name: 'search_customers',
arguments: {
email: 'jane@example.com',
limit: 10
}
});
assert.equal(result.isError, undefined);
await client.close();
await handler.close();
4. Test with MCP Inspector
The official MCP Inspector currently provides browser, CLI, and terminal-oriented inspection workflows.
For a local server:
npx @modelcontextprotocol/inspector node dist/server.js
For a remote Streamable HTTP endpoint:
npx @modelcontextprotocol/inspector \
--server-url https://mcp.example.com/mcp \
--transport http
You can also call a remote tool through the Inspector CLI:
npx @modelcontextprotocol/inspector \
--cli https://mcp.example.com/mcp \
--transport http \
--method tools/call \
--tool-name search_customers \
--tool-arg email=jane@example.com \
--format json
As of this writing, the current Inspector documentation requires Node.js 22.19.0 or newer. Node.js 24 LTS satisfies that requirement.
5. End-to-End Client Testing
After protocol-level tests pass, test the server with one or more compatible MCP hosts that matter to your deployment. Do not make correctness dependent on a single commercial AI product.
Verify:
- Tool discovery
- Tool selection
- Authorization
- Structured results
- Failure messages
- User approval behavior for sensitive actions
- Large responses
- Expired authentication
Graceful Shutdown
Production orchestrators normally send SIGTERM before terminating a process. Your Node.js application should stop taking new work, close its HTTP listener, abort or finish in-flight work appropriately, close database connections, and flush telemetry before exiting.
A practical shutdown sequence is:
- Mark the instance unready.
- Stop accepting new requests.
- Allow bounded time for in-flight calls.
- Close the MCP handler.
- Close database and Redis pools.
- Flush logs and traces.
- Exit.
Do not let shutdown hang indefinitely. Your deployment platform will eventually terminate the process.
Dockerize the MCP Server
For a new deployment, use a supported Node.js base image. This example uses Node.js 24 LTS and a multi-stage build.
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
.dockerignore
node_modules
dist
.git
.env
.env.*
coverage
npm-debug.log*
Do not use ARG or ENV in the Dockerfile to bake production secrets into image layers. Supply secrets when the container starts.
How Do You Deploy an MCP Server?
A Streamable HTTP MCP server can generally run anywhere that can reliably host the Node.js HTTP behavior your server requires.
Common deployment categories include:
- Managed container platforms
- Kubernetes
- VMs or VPS infrastructure
- Container-based serverless platforms
- Other HTTP runtimes supported by the SDK
Before selecting a platform, verify that it supports the actual behaviors your MCP server uses, particularly request duration, streaming, concurrency, connection limits, and any subscription/listening requirements.
Production Infrastructure Requirements
- HTTPS/TLS
- Stable domain name
- Runtime secrets
- Authentication integration
- Host and Origin validation
- Health checks
- Centralized logging
- Metrics and alerts
- Rate limiting
- Autoscaling where required
- Appropriate request and streaming timeouts
Health Checks: /health vs /ready
Keep normal infrastructure health endpoints separate from the MCP protocol endpoint.
GET /health should normally answer the question:
Is this process alive?
GET /ready should answer:
Can this instance currently accept useful application traffic?
A liveness endpoint should generally remain simple. If you make liveness depend on every external service, a temporary database outage can cause your orchestrator to repeatedly restart healthy application processes and make the incident worse.
How Do MCP Servers Scale Horizontally?
The current stateless protocol model makes horizontal scaling much cleaner. Multiple Node.js instances can sit behind a normal load balancer without relying on a protocol session being pinned to one process.
+-- MCP Instance A
|
Load Balancer --+-- MCP Instance B
|
+-- MCP Instance C
|
+-- Shared Database
+-- Redis if needed
+-- External APIs
Avoid storing critical cross-request state only in:
- JavaScript global variables
- In-memory Maps
- One Express process
- One container filesystem
If a long-running operation needs durable execution, consider a queue or worker system. Where both server and client support the official MCP Tasks extension, it can provide a protocol-level abstraction for durable asynchronous work, but Tasks support must not be assumed universally.
Production-Ready MCP Server Checklist
MCP Protocol
- Use the current supported MCP specification for new functionality.
- Use the stable TypeScript SDK v2 package structure.
- Do not mix SDK v1 and v2 examples.
- Use stdio for appropriate local-process deployments.
- Use Streamable HTTP for remote HTTP deployments.
- Do not design new servers around obsolete HTTP+SSE session tutorials.
- Define clear tools, resources, and prompts.
- Keep capability lists deterministic.
- Test version negotiation and client compatibility.
- Treat extensions such as Tasks as negotiated optional capabilities.
Security
- Authenticate protected remote callers.
- Authorize every sensitive object and operation.
- Validate token audience and expiration.
- Enforce least-privilege scopes.
- Do not use token passthrough.
- Validate every tool argument.
- Use parameterized database queries.
- Protect outbound requests against SSRF.
- Protect file operations against path traversal.
- Do not interpolate tool arguments into shell commands.
- Store secrets outside source control.
- Use HTTPS.
- Configure Host and Origin protection.
- Rate-limit abusive or expensive operations.
- Require appropriate confirmation for sensitive writes.
Reliability
- Set outbound request timeouts.
- Use retries only for suitable transient failures.
- Use capped exponential backoff.
- Respect Retry-After.
- Plan idempotency for retried writes.
- Handle dependency outages safely.
- Implement graceful shutdown.
- Keep critical state out of process memory.
Observability
- Use structured logs.
- Record tool name and duration.
- Track tool error rates.
- Track upstream latency and failures.
- Track authorization failures.
- Track rate limiting.
- Use metrics and alerts.
- Propagate trace context where appropriate.
- Never log secrets or bearer tokens.
Testing
- Unit-test business logic.
- Test tool handlers.
- Test invalid inputs.
- Test authorization failures.
- Test dependency failures.
- Test timeout behavior.
- Run integration tests through the MCP client SDK.
- Run MCP Inspector.
- Run selected end-to-end tests in a compatible host.
Deployment
- Use a supported Node.js runtime.
- Build reproducibly.
- Run containers as non-root where practical.
- Inject environment and secrets at runtime.
- Expose separate liveness and readiness endpoints.
- Terminate TLS appropriately.
- Collect logs centrally.
- Configure monitoring and alerts.
- Verify proxy behavior for any streaming features you use.
- Define a horizontal-scaling strategy if traffic requires it.
Common MCP Production Mistakes
- Following outdated MCP tutorials: examples based on SDK v1, old initialization assumptions, and older HTTP+SSE sessions can lead to unnecessary or incorrect architecture.
- Putting every capability into a tool: use resources for appropriate read-only data and prompts for reusable user-driven templates.
- Writing vague tool descriptions: make the purpose, scope, and side effects explicit.
- Trusting model-generated arguments: models do not remove your need for validation and authorization.
- Hardcoding credentials: secrets belong in runtime configuration or secret stores.
- Checking authentication but not authorization: a valid user should not automatically have access to every object or tool.
- Passing inbound MCP tokens to downstream APIs: token passthrough is prohibited by the MCP authorization security model.
- Keeping critical state in process memory: the next request may reach a different server instance.
- Having no timeouts: one hanging dependency can consume workers and degrade the whole server.
- Retrying every error: permanent failures and non-idempotent writes can become worse when retried blindly.
- Logging bearer tokens: observability should never become a credential leak.
- Returning raw stack traces: clients need actionable safe errors, not your internal implementation details.
- Having no rate limits: one client or runaway agent can create substantial downstream load.
- Skipping MCP Inspector: protocol-level testing catches problems that isolated business-unit tests will not.
- Assuming localhost success means production readiness: internet-facing authentication, failure behavior, concurrency, scaling, and operations are separate concerns.
Final Recommended Architecture for a Remote Node.js MCP Server
MCP Host / Client
|
| HTTPS
v
API Gateway / Load Balancer
|
| auth / rate limits / routing
v
+----------------------------------+
| Node.js MCP Server |
| |
| Authentication / Authorization |
| | |
| Tools / Resources / Prompts |
| | |
| Business Service Layer |
| | |
| Repository / API Clients |
+---------------+------------------+
|
+-------+--------+
| |
v v
Database / Redis External APIs
|
v
Optional Queue / Workers
Logs --------> Logging Platform
Metrics -----> Monitoring
Traces ------> APM / OpenTelemetry
For many applications, you can start without Redis, queues, or Kubernetes. Add infrastructure because the workload demands it, not because the server speaks MCP.
Conclusion
A production-ready MCP server with Node.js is primarily a backend engineering problem implemented through an MCP interface.
Start with the current MCP 2026-07-28 specification and TypeScript SDK v2. Choose stdio for the local-process use case and Streamable HTTP for remote deployment. Validate every argument, authorize every sensitive operation, keep remote request handling stateless, bound external calls with timeouts, handle retries deliberately, and make failures observable.
Then test the actual protocol interface with the MCP client SDK and MCP Inspector before putting the server behind HTTPS.
The best next step is to implement one real read-only tool through the service-layer pattern above, test it end to end, and only then add authentication and write operations. That gives you a small architecture that can grow without needing to be rewritten when the MCP server moves from a developer laptop to production infrastructure.
Useful answers
Frequently Asked Questions
1. What is an MCP server in Node.js?
A Node.js MCP server is an application that implements the Model Context Protocol and exposes capabilities such as tools, resources, or prompts to compatible MCP clients. Node.js handles the application and integration logic while the MCP SDK implements the protocol interface.
2. Is Node.js good for building MCP servers?
Yes. The official MCP TypeScript SDK provides first-class server packages and integrations suitable for Node.js. Node.js is particularly practical when your MCP server primarily calls HTTP APIs, databases, SaaS platforms, and other I/O-heavy services.
3. Should I use JavaScript or TypeScript for an MCP server?
Both can work, but TypeScript is generally the stronger choice for a production MCP server. It improves maintainability around tool inputs, outputs, service interfaces, configuration, and integration contracts while working naturally with schema libraries such as Zod.
4. What is the difference between stdio and Streamable HTTP in MCP?
stdio communicates through a local process’s standard input and output and is appropriate when a host launches the MCP server locally. Streamable HTTP exposes MCP through HTTP and is the appropriate architecture for most shared remote MCP servers.
5. Does an MCP server need OAuth?
No. Local stdio servers normally do not use the MCP HTTP authorization flow. A protected remote HTTP server should follow the current MCP authorization specification, with the MCP server acting as an OAuth resource server and validating access tokens intended for it.
6. How do I secure an MCP server?
Use authentication where appropriate, enforce authorization for every sensitive operation, validate inputs, apply least privilege, protect secrets, use HTTPS, configure Host and Origin protections, rate-limit abuse, sanitize errors, and secure all downstream database/API operations. Never treat the model as a security boundary.
7. How do I test an MCP server?
Unit-test service logic, test tool handlers, run integration tests through the official MCP client SDK, and use MCP Inspector to examine the actual server interface. Add end-to-end tests through compatible MCP hosts for the workflows that matter to your users.
8. Can an MCP server be stateless?
Yes. In fact, the MCP 2026-07-28 architecture makes stateless request handling the normal model for modern remote servers. If your application needs state across calls, store it externally or expose explicit state handles rather than depending on one Node.js process.
9. How do I deploy an MCP server?
A Streamable HTTP MCP server can be deployed on an appropriate Node.js/container environment behind HTTPS. Ensure the platform supports the request duration, streaming, concurrency, authentication, health checking, and scaling behavior your particular implementation needs.
10. Can MCP servers scale horizontally?
Yes. The stateless 2026 protocol architecture is well suited to multiple server instances behind a load balancer. Persist application state in shared systems instead of assuming later requests return to the same process.
11. What is the MCP Inspector?
MCP Inspector is the official testing and inspection utility for MCP servers. It can inspect capabilities, invoke tools, work with local processes or remote HTTP servers, and help developers validate the protocol interface before integration with a production host.
12. What is the difference between MCP tools, resources, and prompts?
Tools perform operations and are typically selected by the model. Resources expose read-only context that clients can read, while prompts expose reusable message templates that clients can present to users. Choosing the correct primitive makes the server easier to understand and operate.
Internal Linking Suggestions
| Suggested Anchor Text | Suggested Related Article |
|---|---|
| what is Model Context Protocol | What Is Model Context Protocol? |
| MCP server vs REST API | MCP Server vs REST API: What’s the Difference? |
| build MCP tools | How to Build MCP Tools with TypeScript |
| MCP authentication with OAuth 2.1 | MCP Authentication with OAuth 2.1 |
| MCP security best practices | MCP Security Best Practices |
| connect ChatGPT to an MCP server | How to Connect ChatGPT to an MCP Server |
| deploy a Node.js API | How to Deploy a Node.js API to Production |
| MCP vs function calling | MCP vs Function Calling: When to Use Each |
References
- Model Context Protocol Specification — 2026-07-28
- MCP 2026-07-28 Specification Release
- MCP Streamable HTTP Specification
- MCP Authorization Specification
- MCP Authorization Security Considerations
- MCP 2026-07-28 Changelog
- MCP Extensions Overview
- MCP TypeScript SDK v2 Documentation
- MCP TypeScript SDK — Tools
- MCP TypeScript SDK — Resources
- MCP TypeScript SDK — Prompts
- MCP TypeScript SDK — Serving with Express
- MCP TypeScript SDK — Authorization
- MCP TypeScript SDK — Testing
- MCP Inspector Documentation
- Official MCP TypeScript SDK Repository
- Node.js Release Schedule
- OWASP API Security Top 10
- Google Search Central — Article Structured Data
The best technology choice creates useful momentum without adding avoidable complexity.