Tag: TypeScript

  • How to Build a Production-Ready MCP Server with Node.js

    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.

    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.

    AreaDemo ServerProduction Server
    TransportWhatever runs locallyTransport selected for the deployment model
    AuthenticationOften noneVerified identity where protected access is required
    AuthorizationUsually skippedChecked for every sensitive operation
    ValidationBasic schemaStrict schema plus business validation
    ErrorsThrown exceptionsPredictable, sanitized responses
    StateProcess memoryStateless or explicitly managed durable state
    ReliabilityBest effortTimeouts, retries, idempotency, failure handling
    Loggingconsole.log()Structured logs, metrics, traces
    SecurityLimitedLeast privilege, validation, rate limits, auditability
    DeploymentDeveloper machineManaged 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:

    1. Choose a supported Node.js runtime and install MCP TypeScript SDK v2.
    2. Separate protocol handlers from your business and integration logic.
    3. Register well-described tools, resources, and prompts.
    4. Validate every incoming argument.
    5. Expose the server through stdio or Streamable HTTP based on how clients connect.
    6. Add authorization before exposing protected functionality remotely.
    7. Add timeouts, safe retries, logging, rate limiting, and health checks.
    8. Test through a real MCP client and the MCP Inspector.
    9. 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 lookup
    • get_customer — read-only customer retrieval
    • create_support_ticket — write operation
    • support://knowledge-base — support resource
    • summarize_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 FeaturePurposeControl ModelExample
    ToolPerform an operationTypically model-controlledCreate a support ticket
    ResourceExpose read-only data/contextApplication-controlledSupport knowledge base
    PromptExpose a reusable message templateUser-controlled through the clientSummarize 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.

    AreastdioStreamable HTTP
    Typical deploymentLocal machineRemote service
    Connectionstdin/stdoutHTTP endpoint
    Remote accessNot its normal use caseYes
    HTTPS infrastructureNoYes in production
    OAuth resource-server modelNormally noApplicable for protected servers
    Horizontal scalingUsually irrelevantNatural 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 Unauthorized for missing, invalid, or expired credentials.
    • Return 403 Forbidden when 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.

    ThreatRecommended Control
    Unauthorized tool invocationAuthentication plus per-operation authorization
    Cross-tenant object accessObject-level authorization on every lookup/write
    Malicious argumentsStrict schema and business validation
    SQL injectionParameterized queries
    Command injectionAvoid shell execution; never interpolate model input into commands
    SSRFDo not let tool input freely select arbitrary destinations; use destination allowlists
    Path traversalRestrict file roots and normalize/validate paths
    Token leakageRedact authorization data from logs
    Credential exposureRuntime secret management
    DNS rebindingHost and Origin validation, especially for local HTTP servers
    Destructive operation abuseLeast privilege, explicit authorization, and confirmation UX where appropriate
    Prompt injection from external contentTreat 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:

    FailureTypical Handling
    Invalid tool argumentsSchema validation failure
    Unauthenticated caller401 at HTTP authorization layer
    Insufficient permission403 or safe tool-level authorization failure
    Upstream 4xxTranslate into a safe business error
    Upstream timeoutReturn temporary-unavailability message
    Rate limitRespect retry guidance where appropriate
    Dependency outageFail safely and record operational detail internally
    Unexpected exceptionGeneric 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:

    1. Mark the instance unready.
    2. Stop accepting new requests.
    3. Allow bounded time for in-flight calls.
    4. Close the MCP handler.
    5. Close database and Redis pools.
    6. Flush logs and traces.
    7. 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.

    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 TextSuggested Related Article
    what is Model Context ProtocolWhat Is Model Context Protocol?
    MCP server vs REST APIMCP Server vs REST API: What’s the Difference?
    build MCP toolsHow to Build MCP Tools with TypeScript
    MCP authentication with OAuth 2.1MCP Authentication with OAuth 2.1
    MCP security best practicesMCP Security Best Practices
    connect ChatGPT to an MCP serverHow to Connect ChatGPT to an MCP Server
    deploy a Node.js APIHow to Deploy a Node.js API to Production
    MCP vs function callingMCP vs Function Calling: When to Use Each

    References