Back to blog
AI
Intermediate

Building Model Context Protocol Servers with Node.js and TypeScript

Model Context Protocol (MCP) lets LLM clients connect to external tools and data through a standardized interface. This guide shows you how to build a production-ready MCP server using Node.js and TypeScript.

November 20, 202420 min read

Introduction

The Model Context Protocol, commonly known as MCP, has quickly become one of the most important standards for connecting large language models to external data and tools. Instead of treating an LLM as an isolated chatbot, MCP enables developers to build servers that expose resources, tools, and prompts in a consistent, discoverable way. For teams working with Node.js and TypeScript, this means you can integrate AI capabilities directly into existing backend services without reinventing the integration layer for every new model provider.

In this guide, you will learn how to build a production-ready MCP server using Node.js and TypeScript. We will cover the core concepts behind the protocol, the architecture of an MCP server, a step-by-step implementation, real-world examples, and production code that follows current best practices. Whether you are extending an existing application or starting a new AI-powered project, this article will give you a practical foundation for shipping MCP servers with confidence.

The rise of MCP reflects a broader shift in how developers think about AI integration. Rather than hardcoding prompts and tool definitions inside application logic, MCP separates concerns: the LLM client handles reasoning, while the MCP server handles capability exposure. This separation makes your AI features easier to test, version, and maintain over time. By the end of this article, you will have a working MCP server template and the knowledge to adapt it to your own domain.

Table of Contents

Core Concepts

MCP is built around a simple client-server model. The MCP client, which can be an LLM application or an AI agent framework, connects to one or more MCP servers. Each server exposes three primary types of capabilities: resources, tools, and prompts. Resources are read-only data sources that the client can retrieve, such as file contents, database records, or API responses. Tools are executable functions that the client can invoke on behalf of the user, such as sending an email, querying a database, or creating a GitHub issue. Prompts are templated interactions that guide the model through a specific task, such as summarizing a document or generating a SQL query.

Communication between client and server happens over a transport layer. The most common transport for local development is standard input and output, often called stdio. In this mode, the MCP server runs as a child process and exchanges JSON-RPC messages with the client over stdin and stdout. For networked deployments, MCP also supports HTTP with Server-Sent Events, allowing servers to be hosted remotely and accessed over the web. Choosing the right transport depends on your deployment environment and security requirements.

Capability discovery is a key feature of MCP. When a client connects to a server, it sends an initialize request and receives a list of supported capabilities in return. This dynamic discovery means clients do not need to be hardcoded with knowledge of every tool or resource. Instead, they can query the server at runtime and adapt their behavior accordingly. This makes MCP servers highly interoperable across different LLM clients and agent frameworks.

The protocol uses JSON-RPC 2.0 as its message format. Every request includes a method name, optional parameters, and a JSON-RPC version identifier. Responses include either a result or an error object, along with the corresponding request ID. This standardized messaging format ensures that clients and servers can communicate reliably regardless of the underlying programming language.

For Node.js developers, MCP is particularly appealing because the ecosystem already has strong support for JSON-RPC, async programming, and TypeScript type safety. You can leverage existing npm packages, middleware patterns, and testing tools to build MCP servers that integrate cleanly with your current stack.

Architecture Overview

A typical MCP server built with Node.js and TypeScript consists of four logical layers: the transport layer, the protocol layer, the capability layer, and the business logic layer. The transport layer handles the low-level mechanics of reading and writing messages. If you are using stdio, this layer reads from process.stdin and writes to process.stdout. If you are using HTTP, this layer runs an HTTP server and manages SSE connections.

The protocol layer parses incoming JSON-RPC messages, validates the method and parameters, and routes the request to the appropriate capability handler. It also constructs responses and handles errors according to the JSON-RPC 2.0 specification. This layer should remain thin and focused on protocol compliance rather than business logic.

The capability layer is where you define your server's resources, tools, and prompts. Each capability is registered with a unique name, a description, and an input schema. The input schema, typically expressed as JSON Schema, tells the client what arguments are required and what types they should be. This schema-driven approach enables clients to generate user interfaces or validate calls before sending them to the server.

The business logic layer contains the actual implementation of each capability. For example, if you expose a tool to query a database, the business logic layer handles the database connection, query execution, and result formatting. Keeping this layer separate from the protocol layer makes your code easier to test and allows you to reuse the same logic in non-MCP contexts, such as REST APIs or CLI commands.

In a production deployment, you might run multiple MCP server instances behind a load balancer, especially if you are using HTTP transport. Each instance should be stateless, with any persistent data stored in an external database or cache. This architecture ensures that your MCP infrastructure can scale horizontally as the number of clients and requests grows.

Step-by-Step Guide

To get started, create a new Node.js project and install the TypeScript compiler and Node.js type definitions. Initialize your project with npm init -y, then install typescript, ts-node, and @types/node as development dependencies. Create a tsconfig.json file with strict mode enabled and target ES2020 or later to ensure modern async/await support.

Next, install the official MCP SDK for TypeScript if available, or implement the JSON-RPC message handling manually using Node.js built-in modules. For most custom implementations, you will need to set up an stdio server using process.stdin and process.stdout. Create a main entry file, typically src/index.ts, and set up event listeners for incoming data on stdin.

Implement the initialize handler first. When the client connects, it will send an initialize request with protocol version and capabilities. Your server should respond with serverInfo, including your server name and version, and capabilities, indicating which resource, tool, and prompt features you support. This handshake establishes the connection before any actual work is performed.

Register your capabilities after the initialize handshake. Define at least one tool to demonstrate the pattern. A tool definition includes a name, a human-readable description, an input schema, and an execute function. For example, you might create a tool called get-user-profile that accepts a userId parameter and returns profile data from a mock database. Use Zod or a similar library to validate input parameters against the schema before executing the business logic.

Set up the message loop to read JSON-RPC requests from stdin, parse them, route them to the correct handler, and write responses back to stdout. Handle parsing errors gracefully by returning JSON-RPC parse errors. Handle unknown methods by returning method not found errors. Ensure that every response includes the same id as the incoming request so the client can match requests to responses.

Test your server locally using an MCP inspector or a compatible LLM client. Send initialize, list tools, and call tool requests to verify that your server responds correctly. Use console.error for debug logging rather than console.log, because stdout is reserved for the protocol transport when using stdio.

Finally, add TypeScript compilation scripts to your package.json and build your project. You should be able to run your MCP server with node dist/index.js or ts-node src/index.ts, depending on your setup. Once the server runs successfully, you can connect it to any MCP-compatible client and begin exposing your capabilities.

Real-World Examples

One practical example is an MCP server for a customer support platform. The server exposes a tool called search-tickets that accepts a query string and returns matching support tickets from a helpdesk API. It also exposes a resource called ticket://{id} that returns the full details of a specific ticket. An AI agent connected to this server can help support engineers find relevant tickets, summarize conversation history, and suggest next steps without leaving their chat interface.

Another example is a database MCP server for analytics. The server exposes a tool called run-query that accepts a SQL string and returns query results. Because arbitrary SQL execution can be dangerous, the server validates the query against a read-only schema and rejects any statement that modifies data. This pattern allows data analysts to explore datasets using natural language while maintaining strict safety guardrails.

A third example is a file-system MCP server that exposes resources for reading files and tools for searching directory contents. The server restricts access to a predefined workspace directory and uses Node.js fs.promises to read files asynchronously. This is useful for AI coding assistants that need to read project files but should not have unrestricted access to the host machine.

These examples share a common pattern: they wrap existing functionality behind an MCP interface, making it accessible to LLM clients without rewriting the underlying business logic. This approach maximizes code reuse and reduces the surface area for security vulnerabilities.

Production Code Examples

import { createServer } from 'node:http';import { readFileSync } from 'node:fs';import { join } from 'node:path';// Minimal MCP stdio server skeleton in TypeScriptinterface JsonRpcRequest {  jsonrpc: '2.0';  id: string | number;  method: string;  params?: Record;}interface JsonRpcResponse {  jsonrpc: '2.0';  id: string | number;  result?: unknown;  error?: { code: number; message: string };}interface ToolDefinition {  name: string;  description: string;  inputSchema: Record;  execute: (args: Record) => Promise;}const tools: Map = new Map();function registerTool(tool: ToolDefinition): void {  tools.set(tool.name, tool);}async function handleRequest(request: JsonRpcRequest): Promise {  const baseResponse: JsonRpcResponse = {    jsonrpc: '2.0',    id: request.id,  };  switch (request.method) {    case 'initialize':      return {        ...baseResponse,        result: {          protocolVersion: '2024-11-05',          capabilities: {            tools: { listChanged: true },            resources: { listChanged: true },            prompts: { listChanged: true },          },          serverInfo: {            name: 'example-mcp-server',            version: '1.0.0',          },        },      };    case 'tools/list':      return {        ...baseResponse,        result: {          tools: Array.from(tools.values()).map(({ name, description, inputSchema }) => ({            name,            description,            inputSchema,          })),        },      };    case 'tools/call': {      const { name, arguments: args } = request.params as { name: string; arguments: Record };      const tool = tools.get(name);      if (!tool) {        return {          ...baseResponse,          error: { code: -32602, message: `Unknown tool: ${name}` },        };      }      try {        const result = await tool.execute(args ?? {});        return {          ...baseResponse,          result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] },        };      } catch (error) {        return {          ...baseResponse,          error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },        };      }    }    default:      return {        ...baseResponse,        error: { code: -32601, message: `Method not found: ${request.method}` },      };  }}function processStdin(): void {  let buffer = '';  process.stdin.on('data', (chunk: Buffer) => {    buffer += chunk.toString();    const lines = buffer.split('');    buffer = lines.pop() ?? '';    for (const line of lines) {      if (!line.trim()) continue;      try {        const request = JSON.parse(line) as JsonRpcRequest;        handleRequest(request).then((response) => {          process.stdout.write(JSON.stringify(response) + '');        }).catch((error) => {          process.stderr.write(error.message + '');        });      } catch (error) {        const errorResponse: JsonRpcResponse = {          jsonrpc: '2.0',          id: null,          error: { code: -32700, message: 'Parse error' },        };        process.stdout.write(JSON.stringify(errorResponse) + '');      }    }  });}// Example tool registrationregisterTool({  name: 'get-server-time',  description: 'Returns the current server time in ISO format',  inputSchema: {    type: 'object',    properties: {},    required: [],  },  execute: async () => {    return { time: new Date().toISOString() };  },});processStdin();process.stderr.write('MCP server started on stdio');
import { z } from 'zod';interface McpToolContext {  userId?: string;  requestId: string;}function createSecureTool(  name: string,  schema: T,  handler: (args: z.infer, ctx: McpToolContext) => Promise): ToolDefinition {  return {    name,    description: `Secure tool: ${name}`,    inputSchema: schema.shape as Record,    execute: async (args) => {      const validated = schema.parse(args);      return await handler(validated, { requestId: crypto.randomUUID() });    },  };}const queryDatabaseTool = createSecureTool(  'query-database',  z.object({    table: z.string().min(1).max(64),    limit: z.number().int().min(1).max(100).default(10),  }),  async ({ table, limit }, ctx) => {    // Business logic goes here; never interpolate user input into SQL    return { table, limit, rows: [] };  });

Comparison Table

FeatureTraditional REST APIMCP Server
Primary consumerFrontend applications, mobile clientsLLM clients, AI agents
Interface contractOpenAPI / SwaggerMCP capability discovery
TransportHTTP/HTTPSstdio, HTTP with SSE
Data formatJSONJSON-RPC 2.0
StatefulnessTypically statelessSession-aware via client context
DiscoveryDeveloper reads documentationClient queries server at runtime
Best forCRUD operations, public APIsAI-driven tool use, dynamic capabilities

Best Practices

Always validate input parameters using a strict schema before passing them to business logic. MCP clients may send malformed or unexpected arguments, and your server should reject invalid requests with clear JSON-RPC errors rather than crashing. Use TypeScript interfaces and runtime validation libraries such as Zod to enforce type safety at the boundary.

Keep your MCP server stateless whenever possible. Store session-specific data in an external cache or database rather than in memory. Stateless servers are easier to scale, easier to test, and less prone to memory leaks caused by long-running client connections.

Use structured logging for all protocol messages, but write logs to stderr when using stdio transport. Writing logs to stdout will corrupt the JSON-RPC message stream and cause clients to fail. Include correlation IDs in your logs so you can trace requests across transport, protocol, and business logic layers.

Version your MCP server capabilities explicitly. If you change a tool's input schema or remove a resource, increment your server version and communicate the change to client developers. Backward-incompatible changes can break connected AI agents and cause confusing runtime errors.

Document every capability with clear descriptions and examples in the input schema. LLM clients use these descriptions to decide whether to invoke a tool, so well-written descriptions directly improve the quality of AI-generated tool calls.

Common Mistakes

One common mistake is using console.log for debugging in an stdio-based MCP server. Because stdout is the transport channel, any log output written there will be interpreted as protocol messages by the client. Always direct debug output to process.stderr instead.

Another mistake is failing to handle partial JSON messages. When reading from stdin, data may arrive in chunks that do not align with newline boundaries. Implement a buffer that accumulates incoming data and splits on newlines before attempting JSON parsing, otherwise your server will crash on malformed input.

Some developers expose overly powerful tools without proper authorization checks. An MCP tool that can delete records or send emails must verify the caller's identity and permissions before executing. Do not assume that the LLM client has already enforced access control.

Returning raw Error objects in JSON-RPC responses is another pitfall. Always normalize errors into the JSON-RPC error structure with a numeric code and a string message. Raw objects may not serialize correctly and can confuse clients that expect strict JSON-RPC compliance.

Finally, neglecting to set appropriate timeouts on long-running tool executions can cause clients to hang indefinitely. Wrap slow operations in Promise.race with a timeout, or configure your transport layer to close idle connections after a reasonable period.

Performance Tips

Cache capability discovery responses when possible. The initialize and tools/list endpoints do not change frequently, so you can cache their results in memory and serve them quickly without re-evaluating your tool registry on every request. This reduces latency for clients that connect and disconnect frequently.

Use connection pooling for database or external API calls inside your tool handlers. Creating a new connection for every MCP tool invocation adds unnecessary overhead and can exhaust database connection limits under load.

Compress large tool responses if your transport supports it. When using HTTP with SSE, enable gzip or brotli compression on your HTTP server to reduce bandwidth for responses that contain large datasets or lengthy text content.

Avoid blocking the Node.js event loop in tool handlers. Use asynchronous APIs throughout your business logic, and offload CPU-intensive work to worker threads if necessary. A blocked event loop prevents your MCP server from handling concurrent client requests.

Monitor memory usage in long-running stdio processes. Since stdio servers remain alive for the duration of the client connection, memory leaks can accumulate over time. Periodically restart server processes or use process monitoring tools to detect and recover from leaks.

Security Considerations

Authenticate and authorize every MCP client connection. Even if your server runs locally, treat the transport layer as untrusted. Implement API keys, OAuth tokens, or mutual TLS depending on your deployment context. Never expose administrative tools without strong access controls.

Sanitize all input received through JSON-RPC parameters. MCP tools often accept strings that are passed to databases, file systems, or external APIs. Use parameterized queries, allow-lists, and strict schema validation to prevent injection attacks and path traversal vulnerabilities.

Limit the scope of each tool to the minimum necessary functionality. A tool called send-email should not also be able to read arbitrary files or execute shell commands. Fine-grained tool boundaries reduce the impact of a compromised client or a malicious prompt injection attack.

Be cautious with prompt injection. Since MCP servers are designed to be invoked by LLMs, a malicious user might attempt to embed instructions in resource content or tool descriptions that cause the LLM to behave unexpectedly. Treat all data returned by your server as untrusted from the client's perspective.

Restrict file system access to a predefined sandbox directory if your server exposes file-related tools. Use Node.js path resolution utilities to ensure that requested paths cannot escape the intended workspace, and never allow path traversal sequences such as ../ in user-supplied input.

Deployment Notes

For stdio-based MCP servers, deploy them as long-running processes managed by a process supervisor such as systemd, PM2, or Docker with a restart policy. The supervisor should capture stderr for logging while allowing stdout to remain dedicated to the protocol transport.

If you choose HTTP with SSE for remote access, place your MCP server behind a reverse proxy such as Nginx. Configure the proxy to buffer responses appropriately for SSE and to enforce rate limits per client IP. Use HTTPS in production to encrypt all traffic between clients and your server.

Set resource limits on your container or virtual machine. MCP servers that expose tools with heavy computations or large result sets can consume significant CPU and memory. Define CPU and memory limits in your orchestration configuration to prevent a single server from affecting other services.

Use environment variables for configuration rather than hardcoding values. Database connection strings, API keys, and transport settings should be injected at runtime. This practice makes it easier to promote the same build artifact across development, staging, and production environments.

Implement health check endpoints for load balancers and orchestration platforms. Even if your primary transport is stdio, a small HTTP health check can help your infrastructure determine whether the server process is healthy and ready to accept connections.

Debugging Tips

Use an MCP inspector tool to test your server independently of any LLM client. Inspectors allow you to send initialize, list tools, and call tool requests manually and view the raw JSON-RPC responses. This is invaluable for isolating protocol issues from client-specific behavior.

Enable verbose logging on stderr during development. Log every incoming request, every outgoing response, and every error with timestamps and correlation IDs. When an issue occurs, you can trace the full lifecycle of a message through your transport, protocol, and business logic layers.

Validate your JSON Schema definitions using an online validator or a unit test suite. A malformed input schema can cause clients to misinterpret required parameters or reject valid calls. Test your schemas with both valid and invalid payloads to ensure they behave as expected.

If your server appears to hang, check for unhandled promise rejections in your tool handlers. An unhandled rejection can crash the Node.js process or leave it in an inconsistent state. Wrap all async handler logic in try-catch blocks and return JSON-RPC errors for exceptions.

Compare your server's output against the MCP specification examples. The official specification includes sample messages for initialize, tool calls, and resource reads. Matching your implementation to these examples ensures compatibility with a wide range of clients.

FAQ

What is the Model Context Protocol?

The Model Context Protocol is an open standard that defines how LLM clients can connect to servers that expose resources, tools, and prompts. It enables AI models to interact with external systems in a consistent and discoverable way.

Why use Node.js for an MCP server?

Node.js offers a mature ecosystem, strong TypeScript support, and efficient asynchronous I/O. These characteristics make it well-suited for building MCP servers that need to handle many concurrent client connections and integrate with existing backend services.

Can MCP servers use HTTP instead of stdio?

Yes. MCP supports both stdio for local process communication and HTTP with Server-Sent Events for networked deployments. Choose stdio for local development and desktop integrations, and HTTP for remote or cloud-hosted servers.

How do I secure an MCP server?

Secure your MCP server by authenticating clients, validating all input, authorizing tool execution, and running the server in a restricted environment. Use HTTPS for HTTP-based transport and never expose administrative capabilities without strong access controls.

What is the difference between a resource and a tool?

A resource is a read-only data source that a client can retrieve, such as a file or database record. A tool is an executable function that performs an action, such as sending an email or updating a record. Resources return data; tools perform side effects.

How do I test my MCP server?

Test your MCP server using an MCP inspector, write unit tests for individual tool handlers, and perform integration tests with an MCP-compatible client. Validate JSON-RPC message formatting and error handling in every test.

Can I reuse existing business logic in an MCP server?

Yes. Keep your business logic separate from the protocol layer so you can reuse the same functions in REST APIs, CLI tools, and MCP servers. The MCP server should act as a thin interface over your existing application logic.

What should I do if my MCP server crashes?

Check your stderr logs for unhandled exceptions, ensure all async handlers use try-catch blocks, and verify that your stdin buffer correctly handles partial JSON messages. Use a process supervisor to automatically restart the server after a crash.

Conclusion

Building an MCP server with Node.js and TypeScript gives you a powerful way to expose your application's capabilities to AI agents and LLM clients. By following the patterns in this guide, you can create a server that is secure, performant, and easy to maintain. Start with a single tool, test it thoroughly, and expand your capability set as your AI integration needs grow.

If you are ready to add AI-driven features to your Node.js application, begin by defining your first MCP tool today. The Model Context Protocol is rapidly becoming the standard for AI connectivity, and early adoption will position your projects for long-term success.

Explore our other guides on AI integration and Node.js architecture to continue building intelligent, production-ready applications.