1. Omid Sayfun
  2. /
  3. Notebook
  • Home
  • About
  • Notebook
  • Token Usage
  • Whisper Usage
Tools
  • Agent Knowledge Base

Build an MCP Server from Scratch

May 13, 2026 ยท Updated on August 09, 2026

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.

There's a free production checklist at the end of this post. It's the checklist I wish I had on day one.

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

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:

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

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:

// 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.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:

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

npm install postgres

With 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"
			}
		}
	}
}

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:

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.

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.

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.

Free checklist

MCP Server Production 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.

Transport

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

Tool design

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

Auth & security

  • Bearer-token auth on every Streamable HTTP endpoint, validated per request.

  • Scope the server's credentials to the minimum the tools need.

  • Treat tool arguments as untrusted user input everywhere downstream.

  • Rate-limit per session/token, not just globally.

  • Log every tool invocation with arguments (redacted) and caller identity.

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.

Continue Reading

  • Embeddings rot too. Running pgvector in production.Aug 28, 2026
  • Stop shipping retrieval changes on vibesAug 25, 2026
  • Your RAG is confidently wrong without hybrid searchAug 21, 2026
  • Stop tuning everything. pgvector has three knobs that matter.Aug 18, 2026
  • Your agent's knowledge base is lying to you. Run these 14 checks.Aug 18, 2026