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.
- Comfortable writing basic TypeScript and running npm commands
- New to MCP itself? Read What Is an MCP Server? Complete Guide for Developers (2026) first — this post assumes you know what a tool, resource, and prompt are
- Node.js 18 or later installed (
node -vto check)
- The official
@modelcontextprotocol/sdkpackage (v1.29.0) is what you should build on today — a v2 rewrite exists but is still pre-alpha. registerTooltakes a raw Zod shape object, notz.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.
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.
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 if tools/resources/prompts are still fuzzy
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:
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 --initThis 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:
// 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:
// 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.
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.
Common mistake: forgetting
app.use(express.json()). Without it,req.bodyisundefined.transport.handleRequestthen 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:
npx tsx src/index.tsIn a second terminal, send a raw JSON-RPC tools/call request:
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
Acceptheader on the client. Streamable HTTP can respond with either JSON or an SSE stream; some clients need bothapplication/jsonandtext/event-streamlisted or the server rejects the request. - Binding
0.0.0.0with 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/serverinstead 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 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.
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) — the concept primer this guide assumes. Related: 7 Best MCP Servers to Connect to Claude in 2026.
- New to MCP concepts? Start with What is an MCP server?
- Want the production-hardening pattern? See the OAuth 2.1 setup in the Python production build
- Curious what’s already out there? Browse the best MCP servers for Claude

