The official @modelcontextprotocol/sdk package is good. We use it on every production MCP server we ship. The pillar post in this series uses it. Most teams should use it. So why spend a post implementing one without it?
Because the SDK is doing something specific and small, and as long as it stays opaque it will feel like magic. Things that feel like magic are exactly the things that bite you in production — they are the parts you cannot debug when they break, cannot reason about when they behave oddly, and cannot extend when you need a corner the SDK does not expose. The SDK saves a day. It does not enable a single thing that was not already possible. The protocol is small enough to write a useful subset of it in one sitting, in plain Node.js, with node: modules and nothing else, and once you have, the SDK stops being magic.
This post is that exercise. The shape of mcp-blog-publisher from the pillar — listDrafts, readDraft, publishDraft — but with the SDK peeled off. Then we put the SDK back on and compare.
What is on the wire
MCP rides on JSON-RPC 2.0. JSON-RPC has been around since 2010 and is exactly what it sounds like — remote procedure calls, encoded as JSON, with a small spec you can hold in your head.
A request looks like this:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}A successful response:
{
"jsonrpc": "2.0",
"id": 1,
"result": { "tools": [ ... ] }
}An error response:
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32601, "message": "Method not found" }
}A notification — fire-and-forget, no response expected — is a request without an id:
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}Four shapes. That is the entire framing. Match ids on requests and responses, handle errors with the right code, and you have implemented JSON-RPC. There is no "channel" abstraction, no streaming primitive, no built-in auth — just messages.
MCP's contribution on top of that is a specific menu of method names — initialize, tools/list, tools/call, resources/list, prompts/list, and a handful more — plus the shapes of the params and results for each. The whole spec can be read in an afternoon. The reason it feels intimidating from the outside is mostly the SDK and the surrounding tooling; the wire format is, deliberately, boring.
Step 1: read messages off stdin, write replies to stdout
Stdio transport for MCP is line-delimited JSON-RPC. Each message is a JSON object on its own line, terminated by a newline. The host writes to your stdin; you write to your stdout.
// transport.ts
import * as readline from "node:readline";
const rl = readline.createInterface({
input: process.stdin,
terminal: false,
});
function send(message: unknown): void {
process.stdout.write(JSON.stringify(message) + "\n");
}
function log(...args: unknown[]): void {
// stderr, never stdout — stdout is the wire
process.stderr.write(args.map(String).join(" ") + "\n");
}
rl.on("line", (line) => {
let msg: any;
try {
msg = JSON.parse(line);
} catch {
log("dropping unparseable line:", line);
return;
}
handle(msg).catch((err) => {
log("handler error:", err);
if (msg.id !== undefined) {
send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32603, message: String(err.message ?? err) },
});
}
});
});Twenty-something lines and the entire transport is in place. readline gives per-line callbacks. send writes JSON to stdout. log writes diagnostics to stderr — and notice how careful we have to be about that. If log accidentally wrote to stdout, the very first diagnostic would corrupt the protocol stream and the host would silently disconnect. Stdout is reserved for protocol bytes, no exceptions. This is the kind of detail the SDK papers over for you, and the kind of thing that is genuinely worth feeling once.
handle(msg) is what we will fill in next.
Step 2: the lifecycle handshake
Before any tools can be listed or called, MCP requires a three-step handshake:
- The host sends
initializewith its supported protocol version and capabilities. - The server responds with its own protocol version and capabilities (which primitives it supports, basic metadata about itself).
- The host sends an
initializednotification — no response expected — to confirm the handshake is done.
After that, the server can answer tools/list, tools/call, and the rest.
// handle.ts
async function handle(msg: any): Promise<void> {
const { method, id, params } = msg;
if (method === "initialize") {
send({
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: {
tools: {},
},
serverInfo: {
name: "blog-publisher-no-sdk",
version: "0.1.0",
},
},
});
return;
}
if (method === "notifications/initialized") {
log("handshake complete");
return;
}
if (method === "tools/list") {
send({
jsonrpc: "2.0",
id,
result: { tools: TOOL_DEFINITIONS },
});
return;
}
if (method === "tools/call") {
const result = await callTool(params.name, params.arguments ?? {});
send({ jsonrpc: "2.0", id, result });
return;
}
// unknown method
if (id !== undefined) {
send({
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method not found: ${method}` },
});
}
}The interesting move here is that "method routing" is just an if/else chain. No router class. No decorator. No plugin system. JSON-RPC is not a framework — it is a wire format. You can ladder if-statements over the methods you support and ignore the ones you do not, exactly the way HTTP servers do before they reach for Express.
-32601 is JSON-RPC's "method not found" error code. The full table is in the spec; the four codes worth memorizing are -32600 (invalid request), -32601 (method not found), -32602 (invalid params), -32603 (internal error). Most of the errors you will ever return are one of those four, and using them correctly is the difference between a host that prints a useful diagnostic and one that just hangs up.
Step 3: define the tools
Tool definitions — the things returned by tools/list — are plain JSON. Schemas are JSON Schema. No Zod required (the SDK adds Zod for ergonomics, but it is not on the wire).
const TOOL_DEFINITIONS = [
{
name: "listDrafts",
description:
"Returns the filenames of every markdown draft in blog-drafts/. " +
"Use this before reading a specific draft or before publishing one.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
},
{
name: "readDraft",
description:
"Returns the full markdown contents of a single draft from blog-drafts/. " +
"Filename must come from listDrafts; path traversal is rejected.",
inputSchema: {
type: "object",
properties: {
filename: {
type: "string",
description: "Filename returned by listDrafts. Must end in .md.",
},
},
required: ["filename"],
additionalProperties: false,
},
},
];The same descriptions you would write with the SDK, the same JSON Schema you would derive from a Zod definition. The SDK gives Zod-to-JSON-Schema conversion as a convenience; it is not load-bearing. You can hand-write the schema and skip the dependency entirely if your project is sensitive to dependency surface.
Step 4: dispatch the tool calls
import * as fs from "node:fs/promises";
import * as path from "node:path";
const DRAFTS_ROOT = path.resolve("/Users/me/code/blog-drafts");
async function callTool(name: string, args: Record<string, unknown>) {
if (name === "listDrafts") {
const entries = await fs.readdir(DRAFTS_ROOT);
const mds = entries.filter((f) => f.endsWith(".md"));
return {
content: [{ type: "text", text: mds.join("\n") }],
};
}
if (name === "readDraft") {
const filename = String(args.filename ?? "");
const target = path.resolve(DRAFTS_ROOT, filename);
if (!target.startsWith(DRAFTS_ROOT + path.sep)) {
throw new Error("Path escapes drafts root");
}
if (!target.endsWith(".md")) {
throw new Error("Only .md files allowed");
}
const text = await fs.readFile(target, "utf8");
return {
content: [{ type: "text", text }],
};
}
throw new Error(`Unknown tool: ${name}`);
}The content: [{ type: "text", text: ... }] shape is part of the MCP spec — tool results are arrays of typed content blocks (text, image, embedded resource). The shape exists so that tools can return mixed media. For most server work, "one text block" is the right answer.
The path-traversal check is identical to the one we would write with the SDK. Schema validation is the LLM's interface; the runtime check is the security boundary. The SDK does not do this for you; no SDK does. If you want the boundary, you write it.
What we just built, end to end
Combine the four pieces and you have a complete, working MCP server in plain Node.js, with one runtime dependency: Node itself. About a hundred lines. Drop it into claude_desktop_config.json the same way you would register an SDK-built server:
{
"mcpServers": {
"blog-publisher-no-sdk": {
"command": "node",
"args": ["/Users/me/code/from-scratch/server.js"]
}
}
}Restart Claude Desktop. The handshake fires. Your tools appear in the tool list. The model picks listDrafts, the host JSON-RPCs into your stdin, your handler runs, you write a response to stdout, the model continues. Indistinguishable from an SDK build, from the host's perspective.
That sentence deserves to land. There is no special permission, no registration with Anthropic, no SDK doing anything you could not have done yourself. The protocol is on the wire, and the wire is process.stdin and process.stdout. Many of the framings in the broader AI-tooling space lean on opacity to feel impressive; the MCP protocol does not, and walking it once at byte level is the fastest way to internalize that.
What the SDK adds, and why we still use it
Given how small the protocol is, why does @modelcontextprotocol/sdk exist, and why do we still reach for it on production work?
Five things the SDK is buying you that you would otherwise build yourself:
Schema ergonomics. The Zod-to-JSON-Schema conversion, plus runtime validation of incoming arguments. You can do this by hand with ajv or another validator, but the SDK ties it to your tool definition so there is one source of truth. Skip it and you have two — which drift apart, eventually.
Lifecycle bookkeeping. Capability negotiation, version checking, the initialized notification, the shutdown sequence. None of it is rocket science, but every one of those steps is a place where a buggy server fails silently and the host hangs up without explaining why. The SDK gets the boring corners right.
Transport abstraction. The above is written against stdio. To switch the same server to Streamable HTTP, the SDK lets you swap one transport object. By hand, you would be writing the HTTP framing, the SSE bits, the session management — significantly more than a hundred lines. There is a whole post on the transport choice and why it is a deployment decision; the SDK letting you defer that decision until ship time is genuinely valuable.
TypeScript types for the protocol. The Tool, Resource, Prompt, CallToolResult, ContentBlock types match the spec. By hand, you write your own types, you have to update them when the spec evolves, you eat the maintenance.
Spec evolution. MCP is still moving. The 2024-11-05 protocol version above will not be the last one. The SDK tracks the spec; if you have written the framing yourself, you track the spec. Manageable, not free.
The trade is roughly: lose a runtime dependency (and a transitive set of others) in exchange for about a day of upfront engineering, plus the ongoing tax of spec-tracking. For a one-off internal tool, by-hand is fine. For anything you intend to ship to a client and maintain for a year, the SDK pays for itself by lunchtime.
What this exercise is actually for
The reason to write a no-SDK server once, even if you will never ship one without the SDK, is the operational intuition you walk away with. Three concrete shifts that compound for the rest of your MCP work:
You know what is on the wire when something breaks. When a tool call mysteriously fails, the first instinct of someone who has done this exercise is to log every JSON-RPC message in and out at the transport layer. That instinct only forms once you have seen the messages with your own eyes. With the SDK only, "the wire" stays abstract. Without the SDK once, it stays concrete forever. This is the single most useful debugging stance in MCP work.
You understand what the lifecycle buys you. Implementing the handshake by hand forces a careful read of the spec section on capability negotiation. After that, it stops being surprising when, say, a host that does not support prompts simply never calls prompts/list. The protocol is opt-in for both sides, and that opt-in is a real design choice — not an oversight.
You can extend off-spec when you need to. The spec does not yet cover everything one might want — server-pushed notifications about external state changes, custom progress reporting, application-specific request types. Knowing the framing means you can add JSON-RPC methods of your own, with appropriate care, instead of feeling like the SDK is the boundary of what is possible. Most production MCP servers will never need this; the ones that do save themselves a fork by understanding it is allowed.
That is the value. Not "ship the no-SDK version." Just write the no-SDK version, once, on a Saturday, for an internal tool you do not care about, and then return to the SDK with x-ray vision.
A small operational note
The version of the no-SDK server above runs reliably for everyday use, but a production-shaped variant of the same code needs a few more things — graceful shutdown on SIGINT, structured logging to stderr that an ops team can ingest, request timeouts, and a metric for handler latency. None of those are MCP-specific; they are the same hygiene you would apply to any backend service. The SDK does not provide them either. The point is that "running" and "running well" are two different bars, and both of them sit on top of a protocol that is genuinely small.
This is also where the "MCP server is just a backend service" framing pays back. Every operational habit you already have for HTTP services — graceful shutdown, structured logging, health metrics, dependency injection for testability — applies almost unchanged. The only thing that is different is the wire. Treat the rest the way you treat anything else, and an MCP server is an unremarkable engineering artifact, which is exactly what a production MCP server should be.
Where this fits in the series
If you are following the series in order, you now have: a conceptual model of MCP, a reframing of what a Node program is, a picture of where intelligence lives in the agent stack, the pillar's worked example with the SDK, and now a from-scratch implementation that demystifies the SDK itself.
The next worthwhile post for most readers is the one on tool descriptions. Once the protocol stops being mysterious, the actual hard problem — how do we write tools an LLM can use correctly? — comes into focus. That is where we would point you next: Writing tool descriptions LLMs can reason about. Code is the small part.
The protocol is small. What you do with it is where the engineering lives. The SDK is a useful convenience over a small idea, not a barrier to a complex one.