What you are building and what to install
An MCP server exposes tools, resources, and prompts to an AI client like Claude Desktop, Claude Code, or Cursor. As of the 2026-07-28 spec revision, the TypeScript SDK comes in two lines: the split @modelcontextprotocol/server//client//core//node packages (2.0.0, released 2026-07-27, requires Node 20+), which implement 2026-07-28, and the original monolithic @modelcontextprotocol/sdk (1.30.0, also released 2026-07-27), which remains npm's latest tag and is still fully supported. This guide builds against that 1.x monolith, @modelcontextprotocol/sdk, since it's still the more widely deployed line and its McpServer API is what most existing servers use — pin the version explicitly so it's clear which line you're on. It gives you a high-level McpServer API for defining tools and wiring up a transport in one package.
You'll want Node 18 or newer. Create a project and install:
$npm init -y$npm install @modelcontextprotocol/sdk@1.30.0 zod$npm install -D typescript tsx @types/node
The examples in this guide use ES modules and top-level await, so set "type": "module" in your package.json. Without it, Node and TypeScript treat your .ts files as CommonJS and the top-level await server.connect(...) won't compile (TS1309):
{
"type": "module"
}Because the SDK ships its subpath imports with explicit .js extensions, your tsconfig.json must use a Node resolution mode that expects them:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true
},
"include": ["src/**/*"]
}This is the one detail that quietly breaks first-time builds. Under NodeNext, relative and package-subpath imports must carry the .js extension, and the SDK's own documented imports do (@modelcontextprotocol/sdk/server/mcp.js, not .../server/mcp) (SDK docs/server.md, 1.30.0). Drop the extension and the module won't resolve at runtime.
The smallest server that actually does something
You define a server with the McpServer class and hang tools off it with registerTool. A tool takes a name, a config object (title, description, and an inputSchema written as a Zod shape), and an async handler that returns a content array. This one looks up a value instead of adding two numbers, because a real tool should wrap something the model can't do on its own.
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "weather-server", version: "1.0.0" });
server.registerTool(
"get_forecast",
{
title: "Get forecast",
description: "Current forecast for a city",
inputSchema: { city: z.string() },
},
async ({ city }) => {
const res = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=j1`);
const data = await res.json();
const now = data.current_condition[0];
return { content: [{ type: "text", text: `${city}: ${now.temp_C}C, ${now.weatherDesc[0].value}` }] };
},
);
await server.connect(new StdioServerTransport());Run it in development with npx tsx src/index.ts. registerTool is the current way to register a tool (SDK docs/server.md, 1.30.0). You'll see an older server.tool(name, schema, handler) form in some examples, but it's deprecated, so stick with registerTool.
Structured output: return data, not just a string
Sometimes a tool needs to hand back real data, not just a line of text. Declare an outputSchema next to your inputSchema, return a structuredContent object alongside the text, and the SDK validates it against the schema for you. Clients that support structured output then get typed data instead of having to parse a string back out.
server.registerTool(
"bmi",
{
title: "BMI calculator",
description: "Body mass index from weight and height",
inputSchema: { weightKg: z.number(), heightM: z.number() },
outputSchema: { bmi: z.number() },
},
async ({ weightKg, heightM }) => {
const output = { bmi: weightKg / (heightM * heightM) };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
},
);That paired return, a human-readable content block plus a structuredContent object matching your outputSchema, is what the SDK expects (SDK docs/server.md, 1.30.0). Keep the text block either way, since clients that don't do structured output still need something to show.
Resources and prompts
Tools are actions. Resources are addressable data the client can read, registered with registerResource and a URI (static, or a ResourceTemplate with parameters). Prompts are reusable message templates, registered with registerPrompt and a Zod argsSchema.
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
server.registerResource(
"user-profile",
new ResourceTemplate("users://{userId}/profile", { list: undefined }),
{ title: "User profile", mimeType: "application/json" },
async (uri, { userId }) => ({
contents: [{ uri: uri.href, text: JSON.stringify(await getUser(userId)) }],
}),
);
server.registerPrompt(
"review-code",
{ title: "Code review", argsSchema: { code: z.string() } },
({ code }) => ({
messages: [{ role: "user", content: { type: "text", text: `Review this code:\n\n${code}` } }],
}),
);One easy thing to trip on: a prompt's content is a single object, not the array you return from a tool (SDK docs/server.md, 1.30.0).
The two transports
The MCP spec defines exactly two transports: stdio and Streamable HTTP (MCP spec, Transports). The old two-endpoint HTTP+SSE transport from the 2024-11-05 spec is deprecated and kept only for backwards compatibility, so this guide doesn't use it.
stdio is for local use: the client launches your server as a subprocess over stdin/stdout, which is the new StdioServerTransport() line in the first example. It's fine for a desktop integration or a quick local test, but it serves one client per process.
Streamable HTTP is the transport for remote, multi-client, and production servers, and it's the one most real deployments use. It runs on a single endpoint (commonly /mcp). Under the 2026-07-28 spec revision, MCP is stateless: the initialize handshake and the MCP-Session-Id header are both gone, and cross-call state is passed as an explicit handle in ordinary tool arguments instead. The pattern below predates that — it's the 1.x/legacy-era approach that @modelcontextprotocol/sdk and today's clients still speak, which ties requests together into sessions with an MCP-Session-Id header, minted on an initialize request. The way it works: you keep a map from session ID to a per-client StreamableHTTPServerTransport, mint a new one only when an initialize request comes in, and give each session its own McpServer (wrap the setup above in a buildServer() factory) rather than sharing one. Here are the two branches that matter:
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (sessionId && transports[sessionId]) {
return transports[sessionId].handleRequest(req, res, req.body);
}
if (!sessionId && isInitializeRequest(req.body)) {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => { transports[id] = transport; },
});
const server = buildServer(); // one McpServer per session, not a shared instance
await server.connect(transport);
return transport.handleRequest(req, res, req.body);
}
res.status(400).json({ error: "No valid session" });
});isInitializeRequest is exported from @modelcontextprotocol/sdk/types.js, and handleRequest is called with the parsed body on POST and without it on GET/DELETE (SDK simpleStreamableHttp example, 1.30.0). The full runnable server, including the GET stream and DELETE teardown, is in that example file; the session map and the isInitializeRequest branch above are the part worth understanding. When you're ready to move to the split 2.0 packages and the stateless 2026-07-28 model, the SDK team ships a codemod: npx @modelcontextprotocol/codemod@latest v1-to-v2 ..
Connecting it to a client
Test the server in isolation first with the MCP Inspector (npx @modelcontextprotocol/inspector npx tsx src/index.ts), which lists your tools and lets you call them by hand. The Inspector's own latest tag is now the 2.0 line, which requires Node >=22.19.0 — a full major above the Node 18 this guide's SDK needs. If you're still on an older Node, pin the 1.x line instead: npx @modelcontextprotocol/inspector@v1-latest. To run it locally over stdio, register the entrypoint with your client, for example in Claude Code:
$claude mcp add weather -- npx tsx /absolute/path/to/src/index.ts
Use an absolute path, since the client sets its own working directory. A Streamable HTTP server is reached by URL instead, which is how you wire up a deployed server.
Two things that will bite you
Never write to stdout on a stdio server. stdout is the JSON-RPC channel, and the spec says the server must never put anything there that isn't a valid MCP message. A stray console.log drops a non-JSON line into the stream; the client hits it as a parse error, and depending on the client that can surface as a broken connection (often -32000: Connection closed). Send diagnostics to stderr (console.error) instead, which clients capture safely. It's one of the most common reasons a server "starts but the client sees nothing."
Match the import extensions to your resolution mode. With moduleResolution: "NodeNext", every SDK import needs its .js suffix, as shown throughout this guide. A missing extension surfaces as a runtime "cannot find module" even though the type-checker was happy, which is a confusing failure the first time you hit it.
Where to go next
The SDK's src/examples directory has runnable servers for each transport, plus structured output and elicitation (typescript-sdk examples, 1.30.0). For the concurrency details of a multi-client HTTP deployment (session lifecycle, stateless mode, the full request handler), see Configuring MCP servers for multiple simultaneous connections.
Related Guides
Building an MCP server in Python using FastMCP
Build an MCP server in Python with FastMCP, with a clear answer to the question that trips everyone up: the in-SDK MCPServer (called FastMCP before the 2026-07-28 rename) versus the standalone fastmcp package, and which one to install.
Building an MCP server in Go
Build an MCP server with the official Go SDK: mcp.NewServer plus the generic AddTool that infers schemas from your structs, resources, prompts, and both transports (stdio and Streamable HTTP), including the stateless model the 2026-07-28 spec revision introduced and how to turn it on in go-sdk v1.7.0.
Configuring MCP servers for multiple simultaneous connections
How MCP servers actually serve concurrent clients under the 2026-07-28 spec revision: a stateless protocol with per-request metadata, no session IDs to track, and the dual-era support production servers still need for older clients.