Skip to main content
0

In the Weeds: Building Your First MCP Server from Scratch

joey-io's avatarjoey-io7 min read

A hands-on technical tutorial for building a custom MCP server — from zero to functional, with real examples from the ecosystem.

What We Are Building and Why

The Model Context Protocol is one of those things that sounds complicated until you build one. Then it clicks. An MCP server is just a program that exposes tools and resources to AI models through a standardized protocol. Your AI asks "what can you do?" and your server answers with a list of capabilities. The AI calls one, your server executes it, sends back the result.

No REST endpoints to design. No authentication dance. No OpenAPI spec. Just a clean, typed interface between your code and an AI model.

In this tutorial, we are going to build an MCP server from scratch. By the end, you will have something functional — and more importantly, you will understand the pattern well enough to build anything.

The Landscape: MCP Servers That Already Exist

Before we build, let us look at what is out there. Understanding existing servers will inform our design.

SSupabase MCP gives AI models direct access to Supabase projects — querying databases, managing tables, handling auth. It is one of the most polished MCP servers available, and a great reference for how a production-quality server should work.

NNeon MCP does something similar for Neon serverless PostgreSQL. It exposes database operations — schema inspection, query execution, branch management — through MCP tools. If you are a Neon user, it is indispensable.

PPuppeteer MCP takes a completely different approach. Instead of databases, it gives AI models a web browser. Navigate pages, click elements, take screenshots, extract content. It shows how MCP can wrap practically any capability.

The pattern across all of them is the same: take something useful, wrap it in the MCP protocol, and suddenly any AI model can use it.

Prerequisites

You will need:
- Node.js 18+ (for the TypeScript SDK)
- A text editor
- Basic TypeScript/JavaScript knowledge
- An MCP-compatible client (Claude Code works perfectly)

Step 1: Project Setup

Create a new directory and initialize:

bashmkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Create a tsconfig.json:

json{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true
  },
  "include": ["src/**/"]
}

Step 2: The Skeleton

Create src/index.ts. Every MCP server starts the same way:

typescriptimport { 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-first-mcp",
version: "1.0.0",
});

// We will add tools here

const transport = new StdioServerTransport();
await server.connect(transport);

That is a complete (if useless) MCP server. It starts up, announces itself, and listens for requests over stdio. The stdio transport is the most common — it means the AI client launches your server as a subprocess and communicates through stdin/stdout.

Step 3: Adding Your First Tool

Tools are the core of most MCP servers. They are functions the AI can call. Here is how to add one:

typescriptserver.tool(
  "get-weather",
  "Get the current weather for a city",
  {
    city: z.string().describe("The city to get weather for"),
    units: z.enum(["celsius", "fahrenheit"]).default("celsius")
      .describe("Temperature units"),
  },
  async ({ city, units }) => {
    const temp = units === "celsius" ? "22C" : "72F";
    return {
      content: [
        {
          type: "text" as const,
          text: "Weather in " + city + ": " + temp + ", partly cloudy",
        },
      ],
    };
  }
);

Breaking this down:
- First argument: the tool name. Keep it lowercase, hyphenated.
- Second argument: a human-readable description. The AI reads this to decide when to use the tool.
- Third argument: a Zod schema defining the parameters. This gives the AI type information and descriptions.
- Fourth argument: the handler function. It receives the validated parameters and returns a result.

The return format is always an object with a content array. Each item has a type (usually "text") and the actual content.

Step 4: Adding Resources

Resources are the other major MCP primitive. While tools are things the AI can do, resources are things the AI can read. Think of them as files or data the AI can access on demand.

typescriptserver.resource(
  "config",
  "app://config",
  async (uri) => {
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify({
            version: "1.0.0",
            environment: "development",
            features: ["weather", "search"],
          }, null, 2),
        },
      ],
    };
  }
);

Resources have a URI scheme, which helps the AI understand what kind of data it is looking at. You can use any scheme you want — app://, db://, file://, whatever makes sense for your domain.

Step 5: Error Handling

Real MCP servers need to handle errors gracefully. The AI needs useful error messages to recover:

typescriptserver.tool(
  "divide",
  "Divide two numbers",
  {
    numerator: z.number().describe("The number to divide"),
    denominator: z.number().describe("The number to divide by"),
  },
  async ({ numerator, denominator }) => {
    if (denominator === 0) {
      return {
        content: [
          {
            type: "text" as const,
            text: "Error: Division by zero is not allowed",
          },
        ],
        isError: true,
      };
    }
    return {
      content: [
        {
          type: "text" as const,
          text: numerator + " / " + denominator + " = " + (numerator / denominator),
        },
      ],
    };
  }
);

The isError flag tells the AI that something went wrong. It can then decide how to handle it — retry with different parameters, ask the user for help, or try a different approach entirely.

Step 6: Building and Running

Add scripts to your package.json:

json{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Build and test:

bashnpm run build

To use it with Claude Code, add it to your MCP configuration:

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

Step 7: A Real-World Example

Here is something actually useful — a server that reads and searches a local notes directory:

typescriptimport  as fs from "fs/promises";
import * as path from "path";

const NOTES_DIR = process.env.NOTES_DIR || "./notes";

server.tool(
"list-notes",
"List all notes in the notes directory",
{},
async () => {
const files = await fs.readdir(NOTES_DIR);
const notes = files.filter(f => f.endsWith(".md"));
return {
content: [{
type: "text" as const,
text: notes.length
? "Found " + notes.length + " notes:\n" + notes.join("\n")
: "No notes found.",
}],
};
}
);

server.tool(
"read-note",
"Read the contents of a specific note",
{
filename: z.string().describe("The note filename"),
},
async ({ filename }) => {
const safeName = path.basename(filename);
const filePath = path.join(NOTES_DIR, safeName);
try {
const content = await fs.readFile(filePath, "utf-8");
return {
content: [{ type: "text" as const, text: content }],
};
} catch {
return {
content: [{ type: "text" as const, text: "Note not found: " + safeName }],
isError: true,
};
}
}
);

server.tool(
"search-notes",
"Search for a term across all notes",
{
query: z.string().describe("The search term"),
},
async ({ query }) => {
const files = await fs.readdir(NOTES_DIR);
const results = [];
for (const file of files.filter(f => f.endsWith(".md"))) {
const content = await fs.readFile(
path.join(NOTES_DIR, file), "utf-8"
);
if (content.toLowerCase().includes(query.toLowerCase())) {
const lines = content.split("\n")
.filter(l => l.toLowerCase().includes(query.toLowerCase()));
results.push("" + file + ": " + lines[0]);
}
}
return {
content: [{
type: "text" as const,
text: results.length
? results.join("\n")
: "No notes contain the term: " + query,
}],
};
}
);

This is a fully functional MCP server. An AI connected to it can browse your notes, read specific ones, and search across them. You have just given an AI access to your personal knowledge base.

Patterns From Production Servers

Looking at how SSupabase MCP and NNeon MCP are built, a few patterns emerge:

Group related tools. Do not create one giant tool that does everything. Create focused tools that each do one thing well. list-tables, describe-table, run-query — not database-operation.

Validate thoroughly. Use Zod schemas to their full potential. Add .min(), .max(), .regex() constraints. Better to reject bad input upfront than to send a confusing error from your backend.

Return structured data when possible. Instead of returning a paragraph of text, return JSON or formatted markdown. The AI can parse structured data much more reliably.

Handle connection failures. If your server talks to external services, handle timeouts and connection errors. Return isError with a clear message so the AI can tell the user what happened.

Try This Now

  1. Clone the MCP SDK examples repository and study the reference implementations
  2. Pick something you interact with daily — a database, an API, a file system — and wrap it in an MCP server
  3. Connect it to Claude Code and see how the AI uses your tools
  4. Share it with the community. The MCP ecosystem grows when people contribute servers

What Comes Next

MCP is still early. The protocol is evolving, new transports are being added (HTTP/SSE alongside stdio), and the ecosystem of servers is growing fast. Building one now means you will understand the foundation as everything built on top of it matures.

The best part? Every MCP server you build works with every MCP-compatible client. Build once, use everywhere. That is the promise of an open protocol, and it is one that MCP is delivering on.

Share this post:

Ratings & Reviews

0.0

out of 5

0 ratings

No reviews yet. Be the first to share your experience.