How Do I Build My First MCP Server?
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 Idea | Tools to Expose | Difficulty |
|---|---|---|
| Personal notes | save_note, search_notes, list_notes | Easy |
| Time tracker | start_timer, stop_timer, get_summary | Easy |
| Local file search | search_files, read_file, get_stats | Easy |
| RSS reader | add_feed, get_latest, search_articles | Medium |
| Project manager | create_task, update_status, list_tasks | Medium |
| Expense tracker | add_expense, get_totals, export_csv | Medium |
| API wrapper | Expose any REST API as MCP tools | Medium |
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