Build Your Own MCP Server from Scratch — A Complete Tutorial (No SDKs)

Build a working MCP server from scratch with zero dependencies. Learn the JSON-RPC protocol, capability handshake, tool registration, and connect it to Claude Desktop and Cursor — no boilerplate, no SDKs.


Build Your Own MCP Server from Scratch (Without Any Framework)

Introduction

I closed the MCP spec tab within 30 seconds the first time I opened it.

Looked like yet another framework. Transport layers. Session management. Capability negotiation. All the stuff that makes a protocol feel like a framework.

Then I actually built one. Here's what nobody tells you: MCP is just JSON-RPC over stdio with a capability handshake. That's literally it.

Once you strip away the SDKs and boilerplate generators, you're left with something any Node.js developer could knock out in an afternoon. Zero dependencies (well, Node.js built-ins). Three message types. One handshake. Done.

By the end of this you'll have a working MCP server that Claude Desktop or Cursor can talk to. No frameworks. No SDKs. Just you and the raw protocol.

Why go through the trouble? SDKs hide the protocol from you. If you never see the raw messages, you don't actually know what's happening. When something breaks — and it will — you're debugging a black box. Build it once without training wheels, and every MCP framework afterward will make perfect sense.


What MCP Actually Does

MCP (Model Context Protocol) is just a standard way for LLM apps to talk to your tools. Think USB-C for AI — one protocol, any client can plug into any server.

LLM Client (Claude/Cursor)  ←→  MCP Protocol  ←→  Your Tool Server

Every MCP connection does exactly four things:

  1. Handshake — "Hey, I'm Claude. Here's what I support."
  2. Tool discovery — "Cool. What tools do you have?"
  3. Tool invocation — "Run this one with these arguments."
  4. Result — "Here's what happened."

That's the whole loop. Everything else is just details.

Here's what that looks like in real time — hit auto-play or click through step by step:

MCP Protocol Flow1 / 7
▶ Client/initialize
1{2:  "jsonrpc" "2.0",3:  "id" 1,4:  "method" "initialize",5{:  "params" 6   : "protocolVersion" "2024-11-05",7   {}: "capabilities" ,8   {: "clientInfo" 9      :"name" "claude-desktop",10      :"version" "1.0.0"11   } 12}  13}

Client sends its capabilities and asks the server to identify itself.


Project Architecture

Here's the file structure we're building:

mcp-server/
├── index.js          # Entry point
├── server.js         # MCP protocol handler (core logic)
├── transport.js      # stdin/stdout reader/writer
└── tools/            # Your actual tool implementations
    └── weather.js    # Example

Three files for the protocol, one directory for your business logic. The transport layer reads JSON from stdin and writes to stdout. The server parses messages, dispatches them, sends replies. Your tools are just async functions.


JSON-RPC Basics

MCP uses JSON-RPC 2.0 under the hood. If you've ever called an API, you already get this.

Three message shapes. That's it.

Request — Client asks the server to do something:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

Response — Server replies:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": { "tools": [...] }
}

Notification — No id means "don't reply, I'm just telling you":

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

The id field is how requests and responses pair up. Client sends id: 1, waits for a matching response. Notifications have no id — fire and forget.

That's literally everything you need to know about JSON-RPC for MCP.


Building the Transport Layer

Start at the bottom. Read JSON from stdin, write JSON to stdout.

// transport.js
import { createInterface } from "readline";
 
export function createTransport() {
  const rl = createInterface({ input: process.stdin });
 
  function send(message) {
    const line = JSON.stringify(message);
    process.stdout.write(line + "\n");
  }
 
  function onMessage(handler) {
    rl.on("line", (line) => {
      try {
        const message = JSON.parse(line);
        handler(message);
      } catch (err) {
        console.error("Failed to parse message:", line);
      }
    });
  }
 
  return { send, onMessage };
}

That's it. Node's readline gives us line-by-line input. We parse each line as JSON, pass it to the handler. Sending is JSON.stringify + stdout. MCP uses newline-delimited JSON — each message is one line.

Why stdio? MCP can run over SSE or WebSocket too, but stdio is the simplest. The LLM client spawns your server as a child process, pipes stdin/stdout, and they talk through that. No ports, no networking, no config. Just a pipe.


The MCP Protocol Handshake

Before any tool gets called, the client and server need to agree on what each side can do. This is the capability handshake:

  1. Client sends initialize with its capabilities
  2. Server responds with its capabilities
  3. Client sends notifications/initialized — "got it, let's go"
  4. Normal operation starts

Here's the server handler:

// server.js
import { createTransport } from "./transport.js";
 
const CAPABILITIES = {
  tools: {}
};
 
export function createServer(toolRegistry) {
  const transport = createTransport();
 
  transport.onMessage(async (message) => {
    // Notifications don't need responses
    if (!message.id) return;
 
    try {
      const result = await dispatchMethod(
        message.method,
        message.params || {},
        toolRegistry
      );
 
      transport.send({
        jsonrpc: "2.0",
        id: message.id,
        result
      });
    } catch (err) {
      transport.send({
        jsonrpc: "2.0",
        id: message.id,
        error: {
          code: -32000,
          message: err.message
        }
      });
    }
  });
}

This tripped me up at first — MCP servers never send requests to the client. They only respond and send fire-and-forget notifications. No pending request map needed.

The dispatch function handles exactly three methods:

async function dispatchMethod(method, params, toolRegistry) {
  switch (method) {
    case "initialize":
      return {
        protocolVersion: "2024-11-05",
        capabilities: CAPABILITIES,
        serverInfo: {
          name: "my-mcp-server",
          version: "1.0.0"
        }
      };
 
    case "tools/list":
      return {
        tools: toolRegistry.list()
      };
 
    case "tools/call":
      return await toolRegistry.call(params.name, params.arguments);
 
    default:
      throw new Error(`Unknown method: ${method}`);
  }
}

Three cases. initialize returns your capabilities. tools/list returns registered tools. tools/call runs a tool and returns the result. Everything else in MCP is optional.

Watch the version string. As of writing, "2024-11-05" is the current protocol version. Send the wrong one and clients will bounce your handshake.


Exposing Tools: The Tool Registry

Each tool needs a schema (what params it accepts) and a handler (what it does).

// server.js (continued)
 
export function createToolRegistry() {
  const tools = new Map();
 
  return {
    register(name, schema, handler) {
      tools.set(name, { name, inputSchema: schema, handler });
    },
 
    list() {
      return Array.from(tools.values()).map(({ handler, ...rest }) => rest);
    },
 
    async call(name, args) {
      const tool = tools.get(name);
      if (!tool) {
        throw new Error(`Unknown tool: ${name}`);
      }
      return tool.handler(args);
    }
  };
}

Now wire it together:

// index.js
import { createServer, createToolRegistry } from "./server.js";
 
const registry = createToolRegistry();
 
registry.register(
  "get_weather",
  {
    type: "object",
    properties: {
      city: {
        type: "string",
        description: "City name (e.g., 'Tokyo')"
      }
    },
    required: ["city"]
  },
  async (args) => {
    const conditions = ["Sunny", "Cloudy", "Rainy", "Windy"];
    const condition = conditions[Math.floor(Math.random() * conditions.length)];
    const temp = Math.round(15 + Math.random() * 20);
 
    return {
      content: [
        {
          type: "text",
          text: `Weather in ${args.city}: ${condition}, ${temp}°C`
        }
      ]
    };
  }
);
 
registry.register(
  "calculator",
  {
    type: "object",
    properties: {
      expression: {
        type: "string",
        description: "A math expression to evaluate (e.g., '2 + 2')"
      }
    },
    required: ["expression"]
  },
  async (args) => {
    // Use math.js in production. This is just for demo.
    const result = Function(`"use strict"; return (${args.expression})`)();
    return {
      content: [
        {
          type: "text",
          text: `${args.expression} = ${result}`
        }
      ]
    };
  }
);
 
createServer(registry);

Two things to notice:

Result format: MCP tools always return a content array. Most common is { type: "text" }, but you can return images, resources, or embedded content too.

Input schema: This is JSON Schema. The LLM reads the description fields to figure out what args to pass. Be descriptive. A good description is the difference between a model that uses your tool correctly and one that keeps guessing wrong.


Connecting with Claude / Cursor

Time to make your server real.

Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "my-tools": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-server/index.js"]
    }
  }
}

Restart Claude Desktop. Look for the hammer icon in the input area — your tools should be there.

Cursor

Go to Settings → Features → MCP Servers and add:

Name: my-tools
Type: command
Command: node /absolute/path/to/mcp-server/index.js

Once connected, try asking Claude in Cursor: "What's the weather in Tokyo?" — watch it call your tool live.

How Discovery Works

When Claude/Cursor starts your server, it:

  1. Spawns node index.js as a child process
  2. Sends initialize → gets capabilities
  3. Sends tools/list → gets schemas
  4. Shows your tools in the UI

Every time you change your code, restart the client (or reload MCP in Cursor).


Testing Locally

You don't need Claude or Cursor to test. MCP over stdio is trivial to test from the command line:

// test.js
import { spawn } from "child_process";
 
const server = spawn("node", ["index.js"]);
 
let id = 0;
function request(method, params = {}) {
  return new Promise((resolve, reject) => {
    const msgId = ++id;
    const handler = (data) => {
      const response = JSON.parse(data.toString());
      if (response.id === msgId) {
        server.stdout.off("data", handler);
        if (response.error) reject(response.error);
        else resolve(response.result);
      }
    };
    server.stdout.on("data", handler);
    server.stdin.write(
      JSON.stringify({ jsonrpc: "2.0", id: msgId, method, params }) + "\n"
    );
  });
}
 
// Test initialize
const init = await request("initialize", {
  protocolVersion: "2024-11-05",
  capabilities: {},
  clientInfo: { name: "test-client", version: "1.0.0" }
});
console.log("Initialize:", JSON.stringify(init, null, 2));
 
// Test tools/list
const tools = await request("tools/list");
console.log("Tools:", JSON.stringify(tools, null, 2));
 
// Test tools/call
const result = await request("tools/call", {
  name: "get_weather",
  arguments: { city: "Tokyo" }
});
console.log("Weather result:", JSON.stringify(result, null, 2));
 
server.kill();

Run it:

node test.js

You should see:

Initialize: {
  "protocolVersion": "2024-11-05",
  "capabilities": { "tools": {} },
  "serverInfo": { "name": "my-mcp-server", "version": "1.0.0" }
}
Tools: {
  "tools": [
    { "name": "get_weather", "inputSchema": { "type": "object", ... } },
    { "name": "calculator", "inputSchema": { "type": "object", ... } }
  ]
}
Weather result: {
  "content": [{ "type": "text", "text": "Weather in Tokyo: Sunny, 28°C" }]
}

If you see that, your server works.

Common gotcha: stderr gets interleaved with stdout. Use console.error for your debug logs. console.log goes to stdout and will corrupt your JSON stream — the client won't understand a thing.


Putting It All Together

Here's everything in one file (minus imports):

import { createInterface } from "readline";
 
const rl = createInterface({ input: process.stdin });
 
function send(msg) {
  process.stdout.write(JSON.stringify(msg) + "\n");
}
 
rl.on("line", async (line) => {
  const msg = JSON.parse(line);
  if (!msg.id) return;
 
  try {
    let result;
    switch (msg.method) {
      case "initialize":
        result = {
          protocolVersion: "2024-11-05",
          capabilities: { tools: {} },
          serverInfo: { name: "mini-mcp", version: "1.0.0" }
        };
        break;
 
      case "tools/list": {
        const tools = Array.from(registry.values()).map(t => ({
          name: t.name,
          inputSchema: t.schema
        }));
        result = { tools };
        break;
      }
 
      case "tools/call": {
        const tool = registry.get(msg.params.name);
        if (!tool) throw new Error(`Unknown tool: ${msg.params.name}`);
        result = await tool.handler(msg.params.arguments);
        break;
      }
 
      default:
        throw new Error(`Unknown method: ${msg.method}`);
    }
 
    send({ jsonrpc: "2.0", id: msg.id, result });
  } catch (err) {
    send({
      jsonrpc: "2.0", id: msg.id,
      error: { code: -32000, message: err.message }
    });
  }
});
 
const registry = new Map();
 
export function registerTool(name, schema, handler) {
  registry.set(name, { name, schema, handler });
}

~40 lines of logic. The rest is your tool implementations.


What I'd Do Differently If I Started Today

  1. Ship the raw server first. Before touching any SDK, write the stdio loop by hand. Takes 30 minutes and gives you x-ray vision into every MCP framework afterward.

  2. Test with the CLI script before any client. Debugging through Claude's UI is slow. Write a test script that sends raw JSON-RPC and prints responses. You'll iterate 10x faster.

  3. Make your tool descriptions detailed. The LLM reads description fields to decide when to call your tool. "Get the current weather for a given city" works way better than "Weather tool."


Common Mistakes & Gotchas

  • Using console.log() instead of console.error() for debugging — corrupts the JSON-RPC stream
  • Forgetting the initialize handshake — client sends it first, and your tools won't be reachable until you respond
  • Omitting the 'content' array in tool results — the MCP spec requires content to be an array of content blocks
  • Sending a protocol version that doesn't match what the client expects — check the current MCP spec version
  • Not handling JSON parse errors in your input stream — one bad line kills the entire connection
  • Assuming your tools will be called in order — the client may send multiple parallel requests

Mini FAQ

Q1. What is an MCP server?

An MCP server exposes tools and data sources to LLM applications. It's a JSON-RPC endpoint (stdio, SSE, or WebSocket) that clients like Claude Desktop and Cursor connect to.

Q2. Do I need an SDK to build an MCP server?

No. MCP is JSON-RPC 2.0 with a handshake. You can implement it with built-in language features. SDKs save time but hide the protocol.

Q3. What can an MCP tool return?

A content array of content blocks. Most common is { type: "text", text: "..." }. You can also return images, embedded resources, or annotations.

Q4. How is MCP different from an API?

APIs need custom client code for each endpoint. MCP standardizes discovery and invocation — any MCP client can discover and call your tools without bespoke integration.

Q5. Can I run MCP over HTTP?

Yes. MCP supports SSE and WebSocket. stdio is simplest for local dev; SSE is better for remote servers.


My Honest Take

MCP looks complex until you strip away the tooling.

NOT for:

  • Teams shipping production MCP servers at scale (use the SDK — it handles edge cases)
  • People who want one-line setup (SDKs exist for a reason)
  • Anyone who doesn't care how the protocol works (use the abstractions)

IS for:

  • Developers who want to understand what's actually happening
  • Anyone who's hit a bug in an MCP SDK and couldn't debug it
  • People who learn by building from the ground up

Build one raw server. Every MCP framework afterward will make sense. You'll know what's happening under the hood because you've written that code.


Outro

The SDKs are fine. But they're also a veil.

The first time I saw the raw JSON messages flowing between Claude and my server, everything clicked. It wasn't magic. It wasn't a framework. It was JSON-RPC over a pipe with a handshake. Something I could have built myself all along.

Now you have.

If this clicked, you'll probably enjoy Vector Databases in 10 Days — another protocol-level deep dive — or From Notebook to Production for the broader engineering mindset.


Credible Sources

  1. MCP Specification — The official protocol spec. Surprisingly readable once you understand the JSON-RPC layer.
  2. JSON-RPC 2.0 Specification — The entire spec in a single page. Two message types, three rules.
  3. Model Context Protocol GitHub — Reference implementations and SDKs in Python, TypeScript, and Java.
  4. Anthropic MCP Documentation — Official docs including client configuration guides.

Subscribe to Our Newsletter