# MCP Server TypeScript: Build One in Node.js (2026)

> Build an MCP server TypeScript developers can deploy — typed tools with Zod, Streamable HTTP in Node.js, and a curl test that proves it works.

*Source: https://www.infowok.com/build-mcp-server-typescript-2026/ · Sukhveer Kaur · Published July 6, 2026*

---

Every MCP (Model Context Protocol — the open standard that lets AI agents call your tools) build tutorial on this site so far has been Python. That's not an accident — FastMCP made Python the path of least resistance. But it leaves out developers who live in Node and TypeScript. Here's the gap closed: a real **MCP server TypeScript** project, typed with Zod and served over Streamable HTTP.

By the end, you'll have a working `.ts` server you can test with one `curl` command. You'll also see the two mistakes that trip up almost everyone moving from a Python MCP tutorial to the TypeScript SDK.

<Prerequisites>

- Comfortable writing basic TypeScript and running npm commands
- New to MCP itself? Read [What Is an MCP Server? Complete Guide for Developers (2026)](/what-is-mcp-server-complete-guide-2026/) first — this post assumes you know what a tool, resource, and prompt are
- Node.js 18 or later installed (`node -v` to check)

</Prerequisites>

<KeyTakeaways>

- **The official `@modelcontextprotocol/sdk` package (v1.29.0) is what you should build on today** — a v2 rewrite exists but is still pre-alpha.
- **`registerTool` takes a raw Zod shape object, not `z.object()`** — the single most common porting mistake.
- **Streamable HTTP, not stdio, is the transport for a server a remote agent can call.**
- **A tool call is just a validated function call over JSON-RPC** — Zod rejects bad arguments before your code ever runs.

</KeyTakeaways>

## What an MCP Server TypeScript Build Looks Like

A working MCP server TypeScript build needs three pieces: a typed tool, a transport that speaks HTTP, and a client that can reach it. This guide builds all three in about 25 minutes.

![MCP server TypeScript request path: Claude or Cursor calls a Node.js server over Streamable HTTP, which validates arguments with Zod before running your function](./build-mcp-server-typescript-2026-architecture.svg)

The diagram shows the request path. A client sends a `POST /mcp` request. `StreamableHTTPServerTransport` hands it to your `McpServer`. Zod validates the arguments before your function ever runs. **That validation step is the whole reason to use the SDK instead of writing a bare HTTP endpoint yourself** — a malformed request never reaches your code.

## What You Need Before You Start

Before writing any code, get the pieces in place — this checklist is everything the rest of the guide assumes:

- [ ] **Node.js 18+** — the SDK's supported minimum
- [ ] **A package manager** — this guide uses npm, but Bun and Deno both work
- [ ] **A terminal for two things at once** — running the server and testing it with curl
- [ ] **Five minutes with the [Model Context Protocol docs](https://modelcontextprotocol.io/)** if tools/resources/prompts are still fuzzy

![Five-step flow for building an MCP server in TypeScript: install dependencies, define a Zod schema, register the tool, wire up Streamable HTTP, then test it](./build-mcp-server-typescript-2026-flow.svg)

That five-step sequence is the whole post. The first and last steps are quick; the middle two are where the actual server takes shape.

## Step 1 — Scaffold the Project and Register a Typed Tool

Start a plain TypeScript project and install the SDK plus its one required dependency:

```bash
mkdir mcp-ts-server && cd mcp-ts-server
npm init -y
npm install @modelcontextprotocol/sdk zod express
npm install -D typescript @types/node @types/express tsx
npx tsc --init
```

This guide used `@modelcontextprotocol/sdk` **1.29.0**, the current stable release. A v2 rewrite (split into separate server and client packages) is in progress on the SDK's `main` branch, but it's pre-alpha — v1.x is what production servers should run today, and it keeps receiving fixes.

Here's a real gotcha from writing this guide: the SDK's own repo README and top-level docs describe the **v2** API by default, because that's what lives on `main`. If you go looking for docs and land there first, you'll copy `@modelcontextprotocol/server` / `@modelcontextprotocol/client` imports that don't match the stable package you just installed. **Always check you're reading the `v1.x` branch docs, not `main`,** until v2 actually ships as stable. The SDK's GitHub repo has crossed 12,000 stars, so it's not short on tutorials — just short on ones that flag which version they're written against.

Now the server itself. A tool is a typed function with a name, a description, and a Zod schema:

```typescript
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { statfsSync } from "node:fs";
import { z } from "zod";

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

server.registerTool(
  "disk_usage",
  {
    title: "Disk Usage",
    description: "Return disk usage stats for a path on the host",
    inputSchema: { path: z.string().default("/") },
  },
  async ({ path }) => {
    const stats = statfsSync(path);
    const gb = (n: number) => Math.round((n / 1024 ** 3) * 10) / 10;
    const total = gb(stats.blocks * stats.bsize);
    const free = gb(stats.bfree * stats.bsize);
    return {
      content: [
        { type: "text", text: `${path}: ${total - free}GB used of ${total}GB` },
      ],
    };
  }
);
```

**`inputSchema` takes a raw Zod shape — `{ path: z.string() }` — not `z.object({ path: z.string() })`.** The first time I ported a tool from a v2 preview example, I wrapped it in `z.object()` out of habit. I got a confusing type error at compile time before I traced it back to this exact difference. It's the single most common mistake moving between SDK versions.

Zod isn't optional polish here — it's a **required peer dependency**. The SDK uses the schema to build the tool's JSON schema for the client. It also validates every incoming call before your function runs.

## Step 2 — Serve It Over Streamable HTTP

A server with no transport can't be called by anything. **Streamable HTTP is the transport for a remote server.** Stdio only works when the client spawns your process on the same machine — that rules out anything deployed. Wire it up behind Express:

```typescript
// src/index.ts (continued)
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless — simplest mode, no resumability
    enableJsonResponse: true,      // plain JSON instead of an SSE stream
  });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

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

`sessionIdGenerator: undefined` puts the transport in **stateless mode** — every request is independent. That's the simplest option, and it's fine for a single tool like this one. Pass a generator function (`() => randomUUID()`) instead if you need multi-turn session state later.

<Callout type="warning">

MCP servers on `localhost` are exposed to **DNS rebinding attacks** — a malicious site can send a request that looks same-origin but actually reaches your local server. The SDK ships a `hostHeaderValidation` middleware for exactly this. Add it before you bind to anything beyond `localhost`. Never bind `0.0.0.0` without an allow-list of hosts.

</Callout>

> **Common mistake:** forgetting `app.use(express.json())`. Without it, `req.body` is `undefined`. `transport.handleRequest` then fails silently on every call — an easy five minutes lost the first time it happens.

## Step 3 — Test It With curl and the MCP Inspector

Run the server, then prove it responds before wiring up a real client:

```bash
npx tsx src/index.ts
```

In a second terminal, send a raw JSON-RPC `tools/call` request:

```bash
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": { "name": "disk_usage", "arguments": { "path": "/" } }
  }'
```

A working server replies with a `result` object containing your tool's `content` array. **If you get an HTML error page instead of JSON, the transport isn't mounted on the route you're posting to.** Double-check the path matches your `app.post()` call.

For anything past a single curl command, run the official **`@modelcontextprotocol/inspector`** against your server. It gives you a browser UI to list tools and call them with a form. Reading the raw JSON-RPC traffic there is faster than hand-writing curl payloads for every new tool.

## Testing It + Common Errors

Beyond the two mistakes already covered — the Zod shape and the missing JSON middleware — three more errors cause most of the reported friction:

- **Stdio in a deployed server.** If a remote agent can't reach your server at all, this is almost always why — stdio has no network listener.
- **Missing `Accept` header on the client.** Streamable HTTP can respond with either JSON or an SSE stream; some clients need both `application/json` and `text/event-stream` listed or the server rejects the request.
- **Binding `0.0.0.0` with no host validation.** Same risk as the DNS rebinding warning above — treat it as a deploy blocker, not an afterthought.
- **Reading `main`-branch docs for a v1.x install.** Covered above, but it's common enough to repeat: if an example imports from `@modelcontextprotocol/server` instead of `@modelcontextprotocol/sdk/server/mcp.js`, you're looking at v2 pre-alpha syntax.

## What to Build Next

You now have a typed tool, served over a transport a real agent can reach, and verified with a raw request. Two additions make this genuinely production-ready instead of a demo.

First, **resources and prompts** — the same `register*` pattern as tools, for read-only data and reusable templates. If you later need more than one server instance handling traffic, the SDK documents three multi-node patterns: stateless (any node handles any request), persistent storage (a shared database holds session state), and message routing (a queue routes requests to the node holding that session) — worth knowing before you reach for a session ID generator in production.

Second, and more important: **authentication**. This guide skipped it to stay focused on the TypeScript build itself. But an unauthenticated server on the open internet is a real risk. [Build an MCP Server in Python: Production-Ready in 2026](/build-mcp-server-python-production/) walks through the OAuth 2.1 + JWT pattern in detail, and the same shape ports cleanly to the TypeScript SDK's own auth helpers. For the audit data behind why this matters, see [Are MCP Servers Safe? Security Risks & How to Lock Them Down](/are-mcp-servers-safe-security-2026/).

## Conclusion

You've built what most Python-only MCP tutorials skip: a typed tool, validated with Zod, served over Streamable HTTP, and proven to work with one curl request. Remember two things — the raw-shape `inputSchema`, and the choice between stdio and Streamable HTTP. Get those right and the rest of the SDK is straightforward.

Have you hit the `z.object()` mistake yourself, or a different one porting from a Python MCP tutorial? Tell me in the comments.

**Read next:** [What Is an MCP Server? Complete Guide for Developers (2026)](/what-is-mcp-server-complete-guide-2026/) — the concept primer this guide assumes. Related: [7 Best MCP Servers to Connect to Claude in 2026](/best-mcp-servers-connect-claude-2026/).

<NextSteps>

- **New to MCP concepts?** Start with [What is an MCP server?](/what-is-mcp-server-complete-guide-2026/)
- **Want the production-hardening pattern?** See the OAuth 2.1 setup in [the Python production build](/build-mcp-server-python-production/)
- **Curious what's already out there?** Browse [the best MCP servers for Claude](/best-mcp-servers-connect-claude-2026/)

</NextSteps>
