---
title: "Build an MCP Server from Scratch"
description: "MCP is how your TypeScript code becomes a tool Claude can call. Build an MCP server from scratch: the protocol, the three primitives, and a working server."
canonical: https://omiid.me/notebook/51/build-an-mcp-server-from-scratch
author: Omid Sayfun
published: 2026-05-13
updated: 2026-08-09
---

# Build an MCP Server from Scratch

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.

## Pick the right primitive: tools, resources, or prompts

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.)

## Set up the project as ESM with Node16 resolution

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

The SDK is ESM-only, so `package.json` needs:

```json
{
	"type": "module"
}
```

The TypeScript config needs these compiler options:

```json
{
	"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.

## Register one tool and connect it over stdio

A TypeScript MCP server is one `McpServer` instance, one or more registered tools, and a transport. Here it is with a single echo tool:

```ts
// 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:

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

Nothing visible happens, because it's waiting for JSON-RPC on stdin. That's correct. Test it with the Inspector before touching Claude Desktop.

## Wire it into Claude Desktop

The config file lives at:

- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`

Add your server:

```json
{
	"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.

## Add a real tool that queries Postgres

This is where Zod input schemas matter. Without them, the model gets no type information about your arguments and makes bad calls.

```bash
npm install postgres
```

With the driver installed, register a second tool that takes a query string and a row limit:

```ts
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:

```json
{
	"mcpServers": {
		"my-mcp-server": {
			"command": "npx",
			"args": ["tsx", "/absolute/path/to/src/index.ts"],
			"env": {
				"DATABASE_URL": "postgres://user:pass@localhost:5432/mydb"
			}
		}
	}
}
```

## Use stdio locally, HTTP for a remote MCP server

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.

## Choose stateless HTTP unless tools share state

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`:

```ts
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.

```ts
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.

## Debug with the MCP Inspector, not by restarting the host

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.

```bash
npx @modelcontextprotocol/inspector npx tsx src/index.ts
```

Open `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.
