Build Your Own MCP Server from Scratch: Step-by-Step Tutorial

Learn how to build an MCP (Model Context Protocol) server from scratch with zero dependencies. This step-by-step tutorial covers JSON-RPC, stdio transport, capability handshake, tool registration, and connecting to Claude Desktop and Cursor. Perfect for developers wanting to understand MCP internals without frameworks.


Build Your Own MCP Server from Scratch: Step-by-Step Tutorial

Introduction

I'll be honest - 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 built one. And honestly? MCP is JSON-RPC over stdio with a capability handshake. That's it.

Once you strip away the SDKs and boilerplate generators, you get something any Node.js developer could knock out in an afternoon. Zero dependencies beyond Node built-ins. Three message types, one handshake.

By the end you'll have a working MCP server that Claude Desktop or Cursor can talk to. No frameworks, no SDKs. Just you, your code, and the satisfaction of knowing exactly what's happening under the hood.

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


What is MCP and Why Should You Care?

MCP (Model Context Protocol) is rapidly becoming the standard way for LLM applications to interact with external tools and data sources. Think of it as USB-C for AI - one universal protocol that lets any AI client (Claude, Cursor, etc.) connect to any tool server.

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.

Instead of every AI app needing custom integrations for each tool, MCP provides a standardized interface. This means:

  • Interoperability: Any MCP client can connect to any MCP server
  • Simplicity: Build once, connect everywhere
  • Future-proof: As MCP adoption grows, your server will work with new clients automatically

In this tutorial, you'll learn exactly how MCP works under the hood by building a server from scratch - no magic, no hidden complexity.


Project Structure: Keeping It Simple

The beauty of MCP is how little you actually need. Here's the complete file structure:

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 tool

That's literally four files for a fully functional MCP server. Three for the protocol itself, and one directory for your business logic.


Understanding JSON-RPC: The Foundation of MCP

Before diving into code, let's clarify what MCP actually is: it's JSON-RPC 2.0 with a specific handshake. If you've worked with APIs before, this will feel familiar.

MCP uses three message types that flow over stdio (standard input/output):

1. Request Messages

The client asks the server to do something:

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

2. Response Messages

The server replies to a request:

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

3. Notification Messages

Fire-and-forget messages (no response expected):

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

The id field is crucial - it's how the client matches requests to responses. Notifications lack an id because they don't expect a reply.

Why stdio? While MCP can technically run over SSE or WebSocket, stdio is the simplest implementation for local development. The AI client (Claude/Cursor) spawns your server as a child process and connects via pipes - no networking complexity, no ports to manage, just direct process communication.


Building the Transport Layer: Reading and Writing Messages

Let's start at the very bottom - how we actually send and receive messages:

// 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 };
}

This is remarkably simple:

  1. We use Node's readline module to get line-by-line input from stdin
  2. Each line is parsed as JSON and passed to a handler function
  3. To send a message, we stringify it and write to stdout with a newline
  4. Error handling ensures malformed JSON doesn't crash the server

The key insight? MCP uses newline-delimited JSON where each message is exactly one line, making it easy to parse.

Pro tip: Always use console.error for debugging, never console.log - anything written to stdout becomes part of your JSON-RPC stream and will break communication!


The Capability Handshake: How Clients and Servers Agree on Features

Before any tools can be used, the client and server must complete a capability handshake. This ensures both sides know what features are supported.

Here's how it works:

  1. Clientinitialize (says: "Here's what I support")
  2. Serverinitialize response (says: "Here's what I support")
  3. Clientnotifications/initialized (says: "Got it, let's proceed")
  4. Normal operation begins

Here's the server-side implementation:

// 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
        }
      });
    }
  });
}

The dispatch function handles MCP's three core 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}`);
  }
}

Critical version note: As of mid-2026, the protocol version is "2024-11-05". Using an incorrect version will cause the handshake to fail - clients will reject your server immediately.


Creating and Registering Tools: Where Your Business Logic Lives

Tools are the actual functionality your MCP server provides. Each tool needs:

  1. An input schema (JSON Schema defining accepted parameters)
  2. A handler function (the async function that executes when the tool is called)

Let's look at how to register tools:

// 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 let's register some practical tools:

// index.js
import { createServer, createToolRegistry } from "./server.js";
 
const registry = createToolRegistry();
 
// Weather tool example
registry.register(
  "get_weather",
  {
    type: "object",
    properties: {
      city: {
        type: "string",
        description: "City name (e.g., 'Tokyo', 'New York')"
      }
    },
    required: ["city"]
  },
  async (args) => {
    const conditions = ["Sunny", "Cloudy", "Rainy", "Windy", "Snowy"];
    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`
        }
      ]
    };
  }
);
 
// Calculator tool example
registry.register(
  "calculator",
  {
    type: "object",
    properties: {
      expression: {
        type: "string",
        description: "A math expression to evaluate (e.g., '2 + 2', '(10+5)*2')"
      }
    },
    required: ["expression"]
  },
  async (args) => {
    // In production, use a proper math library like math.js
    // This is simplified for demonstration - NEVER use Function() with user input in prod!
    try {
      const result = Function(`"use strict"; return (${args.expression})`)();
      return {
        content: [
          {
            type: "text",
            text: `${args.expression} = ${result}`
          }
        ]
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Error: Invalid expression "${args.expression}"`
          }
        ],
        isError: true
      };
    }
  }
);
 
// Register more tools as needed...
// registry.register("file_reader", fileSchema, fileHandler);
// registry.register("database_query", dbSchema, dbHandler);
 
createServer(registry);

Security note: The calculator example uses Function() for simplicity, but this is dangerous with user input! In production, always use a proper math expression evaluator or sandbox the execution. Never execute arbitrary code from untrusted sources.

Tool response format: MCP tools must return a content array. While { type: "text" } is most common, you can also return images, audio, or embedded resources. The LLM uses your tool's description field to understand when to invoke it - be specific and descriptive!


Connecting Your Server to AI Clients

Now that your server is built, let's connect it to Claude Desktop and Cursor.

Claude Desktop Setup

  1. Locate your Claude Desktop config file:

    • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
    • Windows: %APPDATA%\Claude\claude_desktop_config.json
    • Linux: ~/.config/Claude/claude_desktop_config.json
  2. Add your MCP server:

{
  "mcpServers": {
    "my-tools": {
      "command": "node",
      "args": ["/absolute/path/to/your/mcp-server/index.js"]
    }
  }
}
  1. Important: Use an absolute path to your index.js file
  2. Restart Claude Desktop completely
  3. Look for the hammer icon in the chat input - click it to see your available tools

Cursor Setup

  1. Go to Settings → Features → MCP Servers
  2. Click "Add New MCP Server"
  3. Fill in:
    • Name: my-tools (or whatever you prefer)
    • Type: Command
    • Command: node /absolute/path/to/your/mcp-server/index.js
  4. Save and restart Cursor
  5. Your tools will appear in the AI chat interface

Testing the Connection

Once connected, try asking:

  • "What's the weather in Tokyo?"
  • "Calculate (25 * 4) + 10"
  • "What tools do you have available?"

You should see your tools being invoked in real-time!


Local Testing Without AI Clients

You don't need Claude or Cursor to test your MCP server. Here's a simple test script:

// 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"
    );
  });
}
 
// Run tests
(async () => {
  try {
    // Test initialize
    const init = await request("initialize", {
      protocolVersion: "2024-11-05",
      capabilities: {},
      clientInfo: { name: "test-client", version: "1.0.0" }
    });
    console.log("✓ Initialize successful");
 
    // Test tools/list
    const tools = await request("tools/list");
    console.log("✓ Tools list:", JSON.stringify(tools.tools.map(t => t.name), null, 2));
 
    // Test weather tool
    const weather = await request("tools/call", {
      name: "get_weather",
      arguments: { city: "Tokyo" }
    });
    console.log("✓ Weather result:", weather.content[0].text);
 
    // Test calculator tool
    const calc = await request("tools/call", {
      name: "calculator",
      arguments: { expression: "2 + 2" }
    });
    console.log("✓ Calculator result:", calc.content[0].text);
 
    server.kill();
    console.log("\n🎉 All tests passed! Your MCP server is working correctly.");
  } catch (error) {
    console.error("❌ Test failed:", error);
    server.kill();
    process.exit(1);
  }
})();

Run it with: node test.js

You should see output confirming all tests pass, proving your server works at the protocol level.


Common Pitfalls and How to Avoid Them

Even with a simple protocol, there are several gotchas that can waste hours of debugging:

1. Console.log vs Console.error

Using console.log() sends output to stdout, which corrupts your JSON-RPC stream. Always use console.error() for debugging logs.

2. Missing Initialize Handshake

The client ALWAYS sends initialize first. If you don't respond properly, tools will never be accessible.

3. Incorrect Content Array Format

MCP requires tools to return a content array, even if it's just one item:

// ✅ Correct
return { content: [{ type: "text", text: "Hello world" }] };
 
// ❌ Incorrect
return { content: { type: "text", text: "Hello world" } };
// ❌ Also incorrect
return { type: "text", text: "Hello world" };

4. Version Mismatch

Using an incorrect protocolVersion in your initialize response will cause handshake failure. Stick to "2024-11-05" unless the spec changes.

5. JSON Parse Errors

Malformed input from the client should be caught and logged, not allowed to crash your server.

6. Assuming Sequential Tool Calls

Clients can send multiple parallel requests - your server must handle concurrent tool invocations safely.


What You'll Learn by Building This

By constructing an MCP server from scratch, you'll gain:

Deep Protocol Understanding

  • How JSON-RPC 2.0 actually works in practice
  • The purpose and mechanics of the capability handshake
  • Why stdio is a sensible default for local AI-tool communication

Practical Skills

  • Building robust Node.js servers that handle stdio communication
  • Creating JSON Schema definitions for tool parameters
  • Implementing secure tool execution patterns
  • Debugging protocol-level communication issues

Conceptual Clarity

  • The distinction between transport, protocol, and tool layers
  • How AI clients discover and invoke tools
  • Why MCP enables true interoperability in the AI ecosystem

This knowledge transfers directly to working with any MCP implementation - you'll understand what's happening beneath the abstraction layers of SDKs and frameworks.


When to Use (and When Not to Use) This Approach

Build from Scratch When:

  • You want to understand MCP internals deeply
  • You're learning or teaching the protocol
  • You need maximum transparency for debugging
  • You have simple tool requirements
  • You enjoy knowing exactly how things work

Consider an SDK When:

  • Building production systems at scale
  • You need advanced features (SSE/WebSocket transport, sophisticated tool management)
  • You want built-in testing utilities and type safety
  • Team familiarity with a specific SDK reduces onboarding time
  • You're dealing with complex security or authentication requirements

My recommendation: Always start with a from-scratch implementation to learn the protocol, then evaluate whether an SDK meets your production needs. The understanding you gain will make you a better developer regardless of which path you choose.


Frequently Asked Questions

Do I need to handle authentication in my MCP server?

MCP itself doesn't prescribe authentication - it assumes the communication channel is secure. For local development over stdio, authentication is typically unnecessary since it's process-to-process on the same machine. For remote implementations (SSE/WebSocket), you'd add authentication at the transport layer (e.g., bearer tokens, API keys).

Can I return binary data like images from my tools?

Yes! MCP supports various content types beyond text. You can return images using { type: "image", data: "<base64-encoded-data>", mimeType: "image/png" } or resources using { type: "resource", resource: { uri: "...", mimeType: "...", text: "..." } }. Check the MCP spec for full details.

How do I handle tool execution timeouts?

Implement timeout logic within your tool handlers. For example, use Promise.race() to reject slow operations:

const result = await Promise.race([
  toolHandler(args),
  new Promise((_, reject) => setTimeout(() => reject(new Error("Tool timeout")), 5000))
]);

What's the difference between MCP and traditional APIs?

Traditional APIs require custom client code for each endpoint. MCP standardizes:

  • Discovery (what tools are available)
  • Invocation format (how to call them)
  • Response structure (how results are returned) This means any MCP client can work with any MCP server without custom integration code.

How do I version my MCP server?

MCP handles versioning through the protocolVersion field in the initialize handshake. Update your server's reported version when you make breaking changes to your tool schemas or behavior. Clients can then choose whether to connect based on version compatibility.


Next Steps: Enhancing Your MCP Server

Once you have the basic server working, consider these enhancements:

  1. Add more tools: File system access, database queries, API integrations
  2. Implement tool validation: Use JSON Schema validation libraries for robust input checking
  3. Add logging: Track tool usage and performance metrics (log to stderr!)
  4. Create a tool marketplace: Share your tools with others in the MCP ecosystem
  5. Experiment with transports: Try implementing SSE or WebSocket for remote access
  6. Add caching: For expensive operations, cache results appropriately
  7. Implement streaming: For tools that generate large outputs incrementally

Credible Sources

  1. MCP Specification. The official protocol spec.
  2. JSON-RPC 2.0 Specification. The entire spec in one page.
  3. Model Context Protocol GitHub. Reference implementations in Python, TypeScript, and Java.
  4. Anthropic MCP Documentation. Official client configuration guides.

Happy building! 🚀

Subscribe to Our Newsletter