ASTGL Definitive Answers

How Do I Build My First MCP Server?

James Cruce

You’re going to build a working MCP server in about 30 minutes. By the end, you’ll have a server that Claude can connect to and use — and you’ll understand the pattern well enough to build anything.

We’ll build a simple bookmark manager — a server that lets Claude save, search, and list bookmarks. Small enough to understand completely, useful enough to actually keep.

What You Need

  • Node.js installed (v18 or newer) — download from nodejs.org
  • A terminal (Terminal on Mac, PowerShell on Windows)
  • A text editor (VS Code, Cursor, or anything)
  • Basic familiarity with JavaScript (if you can read a function, you’re fine)

Step 1: Set Up the Project

Open your terminal and run:

mkdir mcp-bookmarks
cd mcp-bookmarks
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Create a tsconfig.json:

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

Update package.json — add these fields:

{
  "type": "module",
  "bin": {
    "mcp-bookmarks": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Step 2: Write the Server

Create src/index.ts:

#!/usr/bin/env node

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Simple in-memory bookmark storage
interface Bookmark {
  url: string;
  title: string;
  tags: string[];
  savedAt: string;
}

const bookmarks: Bookmark[] = [];

// Create the server
const server = new McpServer({
  name: "mcp-bookmarks",
  version: "1.0.0",
});

// Tool 1: Save a bookmark
server.tool(
  "save_bookmark",
  "Save a URL as a bookmark with a title and optional tags",
  {
    url: z.string().url().describe("The URL to bookmark"),
    title: z.string().describe("A short title for the bookmark"),
    tags: z.array(z.string()).optional().describe("Optional tags for organization"),
  },
  async ({ url, title, tags }) => {
    const bookmark: Bookmark = {
      url,
      title,
      tags: tags || [],
      savedAt: new Date().toISOString(),
    };
    bookmarks.push(bookmark);

    return {
      content: [
        {
          type: "text" as const,
          text: `Saved bookmark: "${title}" (${url}) with tags: [${bookmark.tags.join(", ")}]`,
        },
      ],
    };
  }
);

// Tool 2: Search bookmarks
server.tool(
  "search_bookmarks",
  "Search saved bookmarks by title, URL, or tag",
  {
    query: z.string().describe("Search term to match against titles, URLs, and tags"),
  },
  async ({ query }) => {
    const q = query.toLowerCase();
    const results = bookmarks.filter(
      (b) =>
        b.title.toLowerCase().includes(q) ||
        b.url.toLowerCase().includes(q) ||
        b.tags.some((t) => t.toLowerCase().includes(q))
    );

    if (results.length === 0) {
      return {
        content: [{ type: "text" as const, text: `No bookmarks found matching "${query}".` }],
      };
    }

    const formatted = results
      .map((b) => `- ${b.title}: ${b.url} [${b.tags.join(", ")}]`)
      .join("\n");

    return {
      content: [
        {
          type: "text" as const,
          text: `Found ${results.length} bookmark(s):\n${formatted}`,
        },
      ],
    };
  }
);

// Tool 3: List all bookmarks
server.tool(
  "list_bookmarks",
  "List all saved bookmarks",
  {},
  async () => {
    if (bookmarks.length === 0) {
      return {
        content: [{ type: "text" as const, text: "No bookmarks saved yet." }],
      };
    }

    const formatted = bookmarks
      .map((b, i) => `${i + 1}. ${b.title}: ${b.url} [${b.tags.join(", ")}] (saved ${b.savedAt})`)
      .join("\n");

    return {
      content: [
        {
          type: "text" as const,
          text: `All bookmarks (${bookmarks.length}):\n${formatted}`,
        },
      ],
    };
  }
);

// Start the server
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch(console.error);

Step 3: Build and Test

npm run build

If it compiles without errors, your server is ready.

Step 4: Connect to Claude

Claude Desktop: Edit your claude_desktop_config.json:

{
  "mcpServers": {
    "bookmarks": {
      "command": "node",
      "args": ["/full/path/to/mcp-bookmarks/dist/index.js"]
    }
  }
}

Replace /full/path/to/ with the actual path to your project. Restart Claude Desktop.

Claude Code:

claude mcp add bookmarks node /full/path/to/mcp-bookmarks/dist/index.js

Step 5: Use It

Open Claude and try:

“Save a bookmark for https://astgl.ai with the title ‘ASTGL’ and tags ‘ai’ and ‘mcp’.”

Claude will call your save_bookmark tool. Then try:

“What bookmarks do I have about AI?”

Claude calls search_bookmarks with “ai” and returns your saved bookmark.

That’s it. You built a working MCP server.

Understanding the Pattern

Every MCP server follows the same structure:

1. Create a server instance (McpServer)
2. Define tools with:
   - A name (what to call it)
   - A description (when AI should use it)
   - A schema (what parameters it accepts)
   - A handler (what it actually does)
3. Connect via transport (stdio for local, SSE for remote)

The description is the most important part. It’s what the AI reads to decide whether to call your tool. Write it like you’re explaining to a smart coworker when they should use this function.

Making It Persistent

The bookmark server above stores bookmarks in memory — they disappear when the server restarts. To make them persistent, swap the array for a JSON file:

import { readFileSync, writeFileSync, existsSync } from "fs";

const BOOKMARKS_FILE = "./bookmarks.json";

function loadBookmarks(): Bookmark[] {
  if (existsSync(BOOKMARKS_FILE)) {
    return JSON.parse(readFileSync(BOOKMARKS_FILE, "utf-8"));
  }
  return [];
}

function saveBookmarks(bookmarks: Bookmark[]) {
  writeFileSync(BOOKMARKS_FILE, JSON.stringify(bookmarks, null, 2));
}

Replace the in-memory array with loadBookmarks() calls and saveBookmarks() after every write. Now your bookmarks survive restarts.

What to Build Next

Now that you know the pattern, here are servers you could build this week:

Server IdeaTools to ExposeDifficulty
Personal notessave_note, search_notes, list_notesEasy
Time trackerstart_timer, stop_timer, get_summaryEasy
Local file searchsearch_files, read_file, get_statsEasy
RSS readeradd_feed, get_latest, search_articlesMedium
Project managercreate_task, update_status, list_tasksMedium
Expense trackeradd_expense, get_totals, export_csvMedium
API wrapperExpose any REST API as MCP toolsMedium

The API wrapper is the most versatile — take any API you already use and wrap it in MCP tools. Now Claude can use that API for you.

Frequently Asked Questions

How do I debug my MCP server?

Write to stderr for debug logging (console.error("debug:", data)). Stdout is reserved for MCP protocol communication. Check Claude’s MCP logs for connection issues.

Can I use Python instead of TypeScript?

Yes. The official Python SDK (mcp) follows the same pattern. Install with pip install mcp, and the API is very similar — define tools with decorators instead of method calls.

How do I handle errors in tools?

Return an error message in the content array with isError: true. Claude will see the error and can retry or inform the user.

Can my MCP server call external APIs?

Absolutely. That’s one of the most common patterns — your server is a bridge between the AI and any external API. Use fetch or any HTTP client inside your tool handlers.


This is part of the ASTGL Definitive Answers series — structured, practical answers to the questions people actually ask about AI automation, MCP servers, and local AI infrastructure.

Get the full Definitive Answers series

Practical answers to the questions people actually ask about AI automation, MCP servers, and local AI infrastructure.

Subscribe on Substack