An MCP server is a program that exposes functions you wrote to an LLM host, so a model can call them. The SDK gets you from npm init to Claude Desktop calling one of those functions in about fifteen minutes, in TypeScript, without reading the spec first.
MCP (Model Context Protocol) is a JSON-RPC protocol that lets an LLM host (Claude Desktop, Cursor, Zed) discover and call functions you define. It works like LSP, but for AI tools instead of language servers. Your server runs as a separate process; the host launches it, sends JSON-RPC requests over stdin, reads responses from stdout. That's the stdio transport. There's also an HTTP transport for remote servers. Both use the same protocol.
MCP has three building blocks. Pick the wrong one and the model either can't call your function or has to read it like a document.
Tools are functions the model can call. They take arguments, return a result, and can have side effects. If you're wrapping an API, a database query, or anything that does something, this is what you want.
Resources are read-only data the model can access. Think file contents, config, database schemas, or any context you want available without the model explicitly calling a function. Good for slow-changing reference data.
Prompts are parameterised message templates the user can invoke from the host UI. Useful for building consistent multi-step workflows, rarely needed otherwise.
For most servers you want tools. The decision rule is simple: if it does something, it's a tool; if it is something, it's a resource. (Prompts are for product-y things you probably don't need yet.)
mkdir my-mcp && cd my-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsxThe SDK is ESM-only, so package.json needs:
{
"type": "module"
}The TypeScript config needs these compiler options:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"strict": true
}
}The Node16 module resolution is required, not optional. The SDK uses .js extensions in its imports (e.g. "./server/mcp.js"), and if TypeScript can't resolve those, you get runtime import errors that look like they're your fault.
A TypeScript MCP server is one McpServer instance, one or more registered tools, and a transport. Here it is with a single echo tool:
// 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: "my-mcp-server",
version: "1.0.0",
});
server.tool(
"echo",
"Returns whatever string you send it.",
{ message: z.string() },
async ({ message }) => ({
content: [{ type: "text", text: message }],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);Run it:
npx tsx src/index.tsNothing visible happens, because it's waiting for JSON-RPC on stdin. That's correct. Test it with the Inspector before touching Claude Desktop.
The config file lives at:
~/Library/Application Support/Claude/claude_desktop_config.json%APPDATA%\Claude\claude_desktop_config.jsonAdd your server:
{
"mcpServers": {
"my-mcp-server": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/my-mcp/src/index.ts"]
}
}
}Restart Claude Desktop. You'll see a hammer icon in the chat UI, which is your server showing up in the tool list.
Use an absolute path. Relative paths fail silently because Claude Desktop has no idea where your project is. This is the single most common "it's not showing up" bug.
This is where Zod input schemas matter. Without them, the model gets no type information about your arguments and makes bad calls.
npm install postgresWith the driver installed, register a second tool that takes a query string and a row limit:
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!);
server.tool(
"run-query",
"Run a read-only SQL query against the application database.",
{
query: z.string().describe("SELECT statement to execute"),
limit: z.number().int().min(1).max(100).default(20),
},
async ({ query, limit }) => {
if (!query.trim().toLowerCase().startsWith("select")) {
return {
isError: true,
content: [{ type: "text", text: "Only SELECT queries are allowed." }],
};
}
try {
const rows = await sql.unsafe(`${query} LIMIT ${limit}`);
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
};
} catch (err) {
return {
isError: true,
content: [{ type: "text", text: String(err) }],
};
}
},
);Three things here that will save you a debugging session:
isError: true tells the host the call failed without crashing the server process. Never throw from a tool handler. The host doesn't catch it cleanly and the server dies silently.
z.string().describe(...) puts the description into the JSON schema the model sees when it decides how to call your tool. If you skip it, the model has to guess what arguments mean.
The SDK validates input before your handler runs. If the model sends limit: "twenty", it gets rejected by Zod before you see it. You can't get bad-typed input inside the handler.
Pass the connection string in the Claude Desktop config so the spawned process has it:
{
"mcpServers": {
"my-mcp-server": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/src/index.ts"],
"env": {
"DATABASE_URL": "postgres://user:pass@localhost:5432/mydb"
}
}
}
}stdio is the right default. The host launches your server as a child process, communicates over stdin/stdout, and everything is local. No networking, no auth layer, no port management. If you're building something for yourself or one machine, stop here.
HTTP is what turns the same code into a remote MCP server: multiple hosts or users connect to one deployment, and you get a place to put auth. The official MCP spec now prefers Streamable HTTP over the older SSE transport (SSE required two endpoints; Streamable HTTP collapses it to one).
One catch: Claude Desktop currently only supports stdio out of the box. If you want it to talk to a remote HTTP server, run mcp-remote as a local proxy (Claude Desktop spawns it via stdio, it forwards over HTTPS). For anything beyond personal use, just use HTTP from the start.
Stateful versus stateless only applies to HTTP. stdio is inherently stateful, because the server process stays alive for the whole session, so anything you hold in memory persists across tool calls automatically.
With HTTP you have to decide explicitly.
Stateless means a fresh server instance per request. No memory between calls. Set sessionIdGenerator: undefined:
app.all("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});This is simple, deploys anywhere, and scales horizontally without any coordination between instances. It's the right default for tools that are pure functions: take arguments, return a result, nothing to remember.
Stateful means sessions persist between requests. The client gets a session ID on first contact and sends it back as the mcp-session-id header on subsequent requests. The server routes that request to the same transport instance it created earlier.
import { randomUUID } from "crypto";
const sessions = new Map<string, StreamableHTTPServerTransport>();
app.all("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (sessionId && sessions.has(sessionId)) {
// Resume existing session
const transport = sessions.get(sessionId)!;
await transport.handleRequest(req, res, req.body);
return;
}
// New session
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
sessions.set(id, transport);
},
});
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId);
};
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});When do you need it? When your tools accumulate context across calls: a multi-step workflow, an agent that builds up state over a conversation, tools that depend on earlier results. If each tool call is independent, stateless is much simpler.
Stateful mode has a scaling cost. In-memory sessions mean you can't run multiple instances without sticky sessions on your load balancer. If you're on a serverless platform or want to scale horizontally without configuration, stay stateless and push any needed context into the client request instead.
Don't iterate by restarting Claude Desktop. The MCP Inspector is a browser UI that talks directly to your server and shows you every JSON-RPC message in real time.
npx @modelcontextprotocol/inspector npx tsx src/index.tsOpen http://localhost:6274. The Tools panel lets you call any tool with a form. The Logs panel shows every message exchanged. It's the fastest way to confirm a tool works before wiring it into a host.
Three pitfalls that the Inspector logs will immediately surface:
stdout pollution. Any console.log in your server writes to stdout, which the host reads as JSON-RPC. It corrupts the protocol and the connection breaks. Use console.error for debug output, because stderr is ignored by the host.
Blocking the event loop. If a tool handler does synchronous heavy work (fs.readFileSync on a large file, a CPU-bound computation), the server stops responding to other requests. Keep handlers async and don't block.
Wrong module resolution. Already covered in setup, but it shows up here too if you run the compiled output rather than tsx. The symptom is import errors that don't match any file you can find.
Run the Inspector via npx rather than installing it globally. CVE-2025-49596 is an RCE in older versions, and npx ensures you get the current one.
The interesting part starts once the server is working. Your internal REST API, your database, your support inbox: all of it can become tools that agents call autonomously. The protocol is simple enough that the plumbing isn't the hard part. The interesting question is which operations are actually worth letting a model perform unsupervised.
Free checklist
The 28 checks I run before an MCP server touches real users: transport, tool design, auth, errors, testing, and deployment. Distilled from real projects in TypeScript and NestJS.
Pick stdio for local, single-user servers; Streamable HTTP for anything remote or multi-client.
Never write logs to stdout on stdio transport.
Handle SIGINT/SIGTERM and close the transport cleanly.
On Streamable HTTP, decide session policy explicitly (stateless vs session-per-client).
One tool = one decision the model can make. Split tools that take a 'mode' argument.
Write descriptions for the model, not for docs: when to use it, when not to.
Validate every input with a schema (Zod) and return typed, structured errors.
Keep results small and paginated; return references, not dumps.
Use resources for read-only reference data instead of tools that fetch it.
Mark destructive tools and gate them behind confirmation or scopes.
The full checklist is 28 checks, each with the reasoning behind it. Still ahead: Errors & observability, Testing, Deployment & operations. Enter your email and it unlocks right here.
Includes a Markdown download for your repo.
Unsubscribe anytime.