Breaking
Build an MCP server in Node.js and TypeScript

Build an MCP server in Node.js and TypeScript

How this was written

Drafted in plain Markdown by Ethan Laurent and edited against current Node.js, framework and tooling docs. Every command, code block and benchmark in this article was run on Node.js 24 LTS before publish; if a step does not work on your machine the post is wrong, not you — email and I will fix it.

AI is used as a research and outline assistant only — never as a single-source author. Full editorial policy: About / How nodewire is written.

Tested on Node.js 24 LTS · last reviewed June 2026

A client asked me to wire Claude into their internal inventory system. They had a Postgres database, a couple of REST endpoints, and a support team that kept asking the model questions it had no way to answer. Copy-pasting query results into a chat window was their actual workflow. So I built them an MCP server in Node.js, and the model could suddenly read stock levels and flag low-inventory SKUs on its own. That is the job an MCP server in Node.js does well: it hands an LLM client a typed set of tools and data, over a wire protocol that does not care which model is on the other end.

What you are building

To build an MCP server in Node.js, create a TypeScript project with the official @modelcontextprotocol/sdk, expose one focused tool with a strict input schema, run it over stdio for local desktop clients, and add Streamable HTTP only when a remote client actually needs it. The production version should validate every input, keep tools narrow, and log enough request context to debug failed client calls.

The Model Context Protocol is a standard, published by Anthropic, for connecting LLM clients to external tools and data. Instead of every app inventing its own plugin format, MCP defines tools (actions the model can call), resources (read-only data), and prompts, plus transports to carry them. The full spec and concept docs live at modelcontextprotocol.io. Write one server, and any MCP-aware client — Claude Desktop, Claude Code, and a growing list of others — can use it.

A warning before you write any code. Most “I need an MCP server” requests are really “I need a function.” If your code calls one API and you own both ends, you do not need a protocol. MCP earns its weight when an LLM client you do not control needs to discover and call your capabilities at runtime. Keep that in mind; I come back to it at the end.

The MCP TypeScript SDK moves quickly, so pin the exact @modelcontextprotocol/sdk version you build against and check the SDK repo before upgrading. The examples below target the v1-style server API; if the package layout changes in a later major, upgrade deliberately instead of tracking latest in production.

Set up a TypeScript MCP project on Node 20

The SDK is ESM-only and assumes modern Node. Get this wrong and you will spend an hour fighting ERR_REQUIRE_ESM instead of writing tools. Start clean.

bash
mkdir inventory-mcp && cd inventory-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Two real dependencies, and that is on purpose. zod is a required peer dependency — the SDK uses it to validate tool inputs, so it is not optional. tsx lets you run TypeScript directly while developing.

Now mark the package as ESM and give yourself run scripts. Open package.json and set "type": "module":

JSON
{
  "type": "module",
  "bin": { "inventory-mcp": "./dist/index.js" },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts"
  }
}

A tsconfig.json tuned for Node 20:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

The detail that trips people: SDK imports carry a .js extension even in TypeScript source — @modelcontextprotocol/sdk/server/mcp.js, not .../mcp. That is correct ESM behavior under Node16 resolution, not a typo. If you want a deeper walk through strict TypeScript config for Node, I wrote one in TypeScript Node.js setup for 2026.

Add one callable MCP tool with a typed input schema

A server with no tools is a handshake and nothing else. Register one. You describe a tool with a name, a Zod input schema, and an async handler that returns content. Here is a real one — look up a SKU’s stock level.

TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({
  name: "inventory-mcp",
  version: "1.0.0",
});

// pretend this hits your database
async function getStock(sku: string): Promise<number> {
  const fake: Record<string, number> = { "ABC-1": 42, "XYZ-9": 0 };
  return fake[sku] ?? 0;
}

server.registerTool(
  "check-stock",
  {
    title: "Check Stock",
    description: "Return the current stock count for a SKU",
    inputSchema: {
      sku: z.string().describe("Stock keeping unit, e.g. ABC-1"),
    },
  },
  async ({ sku }) => {
    const count = await getStock(sku);
    return {
      content: [{ type: "text", text: `${sku}: ${count} units in stock` }],
    };
  }
);

Notice inputSchema is a plain object of Zod validators, not z.object({...}). The SDK wraps it and turns it into the JSON Schema the client reads to know how to call your tool. A good description on the tool and each field is not decoration — it is what the model uses to decide when and how to call you. Vague descriptions produce wrong calls.

The handler returns a content array. Text is the common case; the SDK also supports image and audio items (base64 plus a MIME type) and embedded resources. For structured data, add an outputSchema and return a structuredContent field alongside the text, and clients that support it get typed output.

Run the MCP server over stdio

You have a server object and zero ways to talk to it. The simplest transport is stdio: the client spawns your server as a child process and they speak JSON-RPC over stdin and stdout. No HTTP, no ports. This is what Claude Desktop uses for local servers.

TypeScript
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const transport = new StdioServerTransport();
await server.connect(transport);

Append that to the file, build, and you have a runnable binary. One rule that bites everyone exactly once: never console.log to stdout in a stdio server. Stdout is the JSON-RPC channel. A stray log line corrupts the stream and the client drops the connection with a parse error. Send diagnostics to stderr instead — console.error is safe, since the SDK keeps stderr clear for exactly this.

bash
npm run build
node dist/index.js   # it'll sit there waiting for a client — that's correct

It looks hung. It is not — it is blocked on stdin waiting for a client to speak.

Connect Claude Desktop to your MCP server

A built server does nothing until a client is told to launch it. For Claude Desktop, that is a JSON config file. On macOS it lives at ~/Library/Application Support/Claude/claude_desktop_config.json; on Windows, %APPDATA%Claudeclaude_desktop_config.json. Add your server under mcpServers:

JSON
{
  "mcpServers": {
    "inventory": {
      "command": "node",
      "args": ["/absolute/path/to/inventory-mcp/dist/index.js"]
    }
  }
}

Use an absolute path — the launcher does not run from your project directory, and a relative path silently fails. Pass secrets through an env block rather than baking them into the file:

JSON
{
  "mcpServers": {
    "inventory": {
      "command": "node",
      "args": ["/absolute/path/to/inventory-mcp/dist/index.js"],
      "env": { "DATABASE_URL": "postgres://localhost/inventory" }
    }
  }
}

Restart Claude Desktop fully after editing — it reads the config once at launch. Claude Code uses the same server, registered via its own mcp command or settings; the server binary is identical. If you are also calling the Claude API directly from Node elsewhere in your stack, my Claude API Node.js tutorial covers that side.

Add an MCP resource for read-only data

Tools are for doing. When you want to expose reference data the model can read without side effects — a config file, a document, a row from a table — use a resource. Resources are addressed by URI.

TypeScript
server.registerResource(
  "config",
  "config://app",
  {
    title: "App Config",
    description: "Inventory service configuration",
    mimeType: "application/json",
  },
  async (uri) => ({
    contents: [
      { uri: uri.href, text: JSON.stringify({ warehouse: "TX-1", lowStock: 5 }) },
    ],
  })
);

For dynamic data, use a ResourceTemplate with a URI pattern. The placeholders get parsed and handed to your callback:

TypeScript
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";

server.registerResource(
  "sku-detail",
  new ResourceTemplate("inventory://sku/{sku}", { list: undefined }),
  { title: "SKU Detail", mimeType: "application/json" },
  async (uri, { sku }) => ({
    contents: [
      { uri: uri.href, text: JSON.stringify({ sku, stock: await getStock(String(sku)) }) },
    ],
  })
);

Rule of thumb: if it changes the world, it is a tool; if it just reports the world, it is a resource. Don’t run an expensive query inside a resource read — keep those cheap.

Add HTTP transport for remote MCP clients

stdio is for local, single-user, spawn-as-child setups. When the server lives on a machine the client connects to over the network — a shared internal tool, a hosted service — you want the Streamable HTTP transport. The SDK ships StreamableHTTPServerTransport and an Express helper that turns on DNS-rebinding protection by default.

TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { Request, Response } from "express";

function buildServer(): McpServer {
  const server = new McpServer({ name: "inventory-mcp", version: "1.0.0" });
  // register the same tools/resources as above
  return server;
}

const app = createMcpExpressApp();

app.post("/mcp", async (req: Request, res: Response) => {
  const server = buildServer();
  try {
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined, // stateless: one server per request
    });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
    res.on("close", () => {
      transport.close();
      server.close();
    });
  } catch (err) {
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: "2.0",
        error: { code: -32603, message: "Internal server error" },
        id: null,
      });
    }
  }
});

app.listen(3000, () => console.error("MCP server on http://localhost:3000/mcp"));

sessionIdGenerator: undefined makes this stateless — a fresh server per request, which is the easiest mode to reason about and to scale behind a load balancer. Drop in a real generator and you get sessions and resumability, at the cost of having to keep state. Use createMcpExpressApp() rather than a bare Express app: it validates the Host header so a malicious page in someone’s browser cannot rebind DNS and poke your localhost server. The moment this thing faces a network, the usual rules apply — auth, rate limits, TLS at the edge. My Node API security checklist is the same checklist I run for an MCP endpoint.

Validate bad input instead of crashing

Two failure modes, two different fixes, and people conflate them. Schema validation is the first line: because inputSchema is Zod, the SDK rejects malformed arguments before your handler runs, so you never see a sku that is a number. Tighten the schema and a whole class of bugs disappears:

TypeScript
inputSchema: {
  sku: z.string().regex(/^[A-Z]{3}-d+$/, "format: ABC-1"),
  includeHistory: z.boolean().default(false),
}

The second mode is a tool that runs but fails — the database is down, the SKU does not exist. Do not throw raw. Return content with isError: true so the model gets a message it can reason about and relay, instead of an opaque protocol fault:

TypeScript
async ({ sku }) => {
  try {
    const count = await getStock(sku);
    return { content: [{ type: "text", text: `${sku}: ${count} units` }] };
  } catch (err) {
    return {
      content: [{ type: "text", text: `Lookup failed: ${(err as Error).message}` }],
      isError: true,
    };
  }
}

The distinction matters: validation errors mean the caller sent garbage, isError means your tool tried and could not finish. Mixing them produces confusing client behavior.

Debug MCP calls with logs and the Inspector

Do not debug an MCP server by staring at Claude Desktop logs. Use the MCP Inspector — a UI that connects to your server, lists tools and resources, and lets you fire tool calls by hand and read the raw responses.

bash
npx @modelcontextprotocol/inspector node dist/index.js

It opens a browser tab, spawns your server over stdio, and you click “check-stock,” type a SKU, and see what comes back. Most of my MCP bugs surface here in two minutes: a tool that did not register, a Zod schema that rejects valid input, a stray stdout write garbling the stream. Get it green in the Inspector before you touch a client config.

Where building an MCP server is overkill

I promised to come back here. Skip MCP when:

  • You control both ends. If your own Node service calls your own API, write a function and call it. A protocol between code you fully own is pure overhead.
  • It is a one-shot script. Fetch, transform, done? That is a CLI, not a server. MCP is for capabilities a client discovers and calls repeatedly at runtime.
  • A plain HTTP API already fits. If the consumer is another program over REST and no LLM is choosing which endpoint to hit, you already have the right tool.

MCP pays off in exactly one shape: an LLM client you do not control needs to discover and invoke your tools and data dynamically. The inventory server fit because the support team’s Claude needed to call into a system I had no other clean way to expose. If your situation does not look like that, a function is the better engineering. Reach for the protocol when the protocol is the point.

FAQ

What is an MCP server in plain terms?

It is a small program that exposes tools (actions) and resources (read-only data) to an LLM client over the Model Context Protocol. The client — Claude Desktop, Claude Code, or another MCP-aware app — connects, discovers what your server offers, and lets the model call it. You write the server once and any compliant client can use it.

Which package and version should I install?

Use @modelcontextprotocol/sdk with zod as a peer dependency, then pin the exact SDK version in package.json. These examples target the v1-style TypeScript SDK API. Read the changelog before upgrading a production MCP server rather than tracking latest.

Do I need both stdio and HTTP transports?

No — pick the one that matches your deployment. stdio is for local servers the client spawns as a child process, which is how Claude Desktop runs local integrations. The Streamable HTTP transport is for servers that live on another machine and accept network connections. You can support both, but most servers only ever need one.

Why does the SDK require Zod?

The SDK validates tool inputs through Zod and generates the JSON Schema clients use to understand how to call your tools. That is why you define inputSchema as an object of Zod validators rather than a separate schema format. It also means malformed arguments are rejected before your handler runs, which removes a lot of defensive code.

How do I connect my server to Claude Desktop?

Add an entry under mcpServers in claude_desktop_config.json (on macOS, ~/Library/Application Support/Claude/claude_desktop_config.json) with the command to run and an args array pointing at your built file by absolute path. Pass secrets through an env block, then restart Claude Desktop fully so it re-reads the config at launch.

My stdio server connects then immediately drops — why?

Almost always a stray write to stdout. In a stdio server, stdout is the JSON-RPC channel, so any console.log corrupts the stream and the client disconnects with a parse error. Route all logging to stderr with console.error, and confirm the server in the MCP Inspector before wiring it into a client.

When is an MCP server overkill?

When you control both ends of the call, when it is a one-shot script, or when a plain HTTP API already serves the consumer. MCP is worth its complexity only when an LLM client you do not control needs to discover and invoke your capabilities at runtime. If no model is deciding which tool to call, a normal function or REST endpoint is the better choice.