Fifteen minutes from npm init to Claude Desktop calling a function you wrote. That's what MCP gives you if you skip the spec and go straight to the SDK.
MCP (Model Context Protocol) is a JSON-RPC protocol that lets an LLM host - Claude Desktop, Cursor, Zed - discover and call functions you define. Think of it 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.
The official docs read like a JSON-RPC reference because that's what they are. This post skips to the working code.
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:
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.
{
"type": "module"
}And tsconfig.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.
// 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 - 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 - that's 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 actually matter. Without them, the model gets no type information about your arguments and makes bad calls.
npm install postgresimport 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 for when multiple hosts or users connect to the same server, when you're deploying to a remote machine, or when you need 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.
This only applies to HTTP. stdio is inherently stateful - 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 actually 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.
The scaling tradeoff is real: 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 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 - 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.