Skip to content

How to Build an MCP Server for Claude AI

Problem

I was copying and pasting context into Claude constantly. Every session, I’d paste my project structure, coding conventions, and previous decisions. Claude had no memory of what we discussed yesterday. My workflow looked like this:

My old workflow
Day 1: Explain project structure to Claude
Day 2: Explain project structure again (Claude forgot)
Day 3: Explain project structure again (Claude forgot)
Day 4: Give up, start pasting everything manually

I heard about MCP (Model Context Protocol) servers. The pitch: build a server that Claude can query for context, tools, and resources. But when I looked at the documentation, I got overwhelmed. Which SDK do I use? How do I connect it to Claude Desktop? Do I need a vector database?

What is MCP?

MCP (Model Context Protocol) is a standard way to extend Claude’s capabilities. An MCP server provides:

MCP Server capabilities
┌─────────────────┐
│ MCP Server │
│ │
│ ┌───────────┐ │
│ │ Tools │──┼── Functions Claude can call (search, write, etc.)
│ └───────────┘ │
│ ┌───────────┐ │
│ │ Resources │──┼── Files/data Claude can read
│ └───────────┘ │
│ ┌───────────┐ │
│ │ Prompts │──┼── Pre-written templates
│ └───────────┘ │
└─────────────────┘
┌─────────────────┐
│ Claude Desktop │
│ or Claude Code │
└─────────────────┘

I found a Reddit post where someone built a knowledge base MCP server. Key insight: they used SQLite FTS5 for search, not a vector database. That meant I could run everything locally with zero cloud costs.

My First MCP Server

I started with the TypeScript SDK. First, I created a project:

Project setup
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D @types/node typescript

Then I created my server file:

src/index.ts
import { 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-knowledge-base",
version: "1.0.0",
});
server.tool(
"kb_search",
"Search the knowledge base",
{
query: z.string().describe("Search query"),
},
async ({ query }) => {
return {
content: [
{
type: "text",
text: `Results for: ${query}`,
},
],
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Server running on stdio");
}
main().catch(console.error);

I ran it and got nothing. No error, no output. What happened?

The Stdio Trap

I spent an hour debugging. Then I found the problem: MCP servers communicate over stdin/stdout using JSON-RPC. But I was using console.log() for debugging.

The problem
// WRONG - corrupts JSON-RPC messages
console.log("Server started");
// CORRECT - writes to stderr, not stdout
console.error("Server started");

MCP servers use stdout for protocol messages. Any console.log() corrupts the JSON-RPC stream. All logs must go to stderr.

Now I needed actual search functionality. I added SQLite with FTS5 (full-text search):

Install SQLite
npm install better-sqlite3
npm install -D @types/better-sqlite3
src/db.ts
import Database from "better-sqlite3";
import { homedir } from "os";
import { mkdirSync } from "fs";
import { join } from "path";
const dbPath = join(homedir(), ".knowledge-base");
mkdirSync(dbPath, { recursive: true });
export const db = new Database(join(dbPath, "kb.db"));
// Create FTS5 virtual table for full-text search
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS documents USING fts5(
id,
title,
content,
tags,
tokenize = 'porter unicode61'
)
`);
export function searchDocuments(query: string, limit: number = 5) {
const stmt = db.prepare(`
SELECT
id,
title,
snippet(documents, 2, '>>>', '<<<', '...', 20) as snippet
FROM documents
WHERE documents MATCH ?
ORDER BY bm25(documents)
LIMIT ?
`);
return stmt.all(query, limit);
}
export function ingestDocument(id: string, title: string, content: string, tags: string) {
const stmt = db.prepare(`
INSERT INTO documents (id, title, content, tags)
VALUES (?, ?, ?, ?)
`);
return stmt.run(id, title, content, tags);
}

Now I updated my tool to use real search:

Updated tool implementation
server.tool(
"kb_search",
"Search the knowledge base for documents",
{
query: z.string().describe("Search query"),
limit: z.number().optional().default(5).describe("Max results"),
},
async ({ query, limit }) => {
const results = searchDocuments(query, limit);
return {
content: [
{
type: "text",
text: JSON.stringify(results, null, 2),
},
],
};
}
);
server.tool(
"kb_ingest",
"Add a document to the knowledge base",
{
id: z.string().describe("Unique document ID"),
title: z.string().describe("Document title"),
content: z.string().describe("Document content"),
tags: z.string().optional().default("").describe("Comma-separated tags"),
},
async ({ id, title, content, tags }) => {
ingestDocument(id, title, content, tags);
return {
content: [
{
type: "text",
text: `Ingested: ${title}`,
},
],
};
}
);

Connecting to Claude Desktop

I built the TypeScript:

Build TypeScript
npx tsc

Then I configured Claude Desktop to use my server:

~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"knowledge-base": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/build/index.js"]
}
}
}

Important: Use absolute paths. Relative paths don’t work.

I restarted Claude Desktop. Nothing. My server wasn’t showing up.

Debugging with MCP Inspector

I used the MCP Inspector to debug:

Run MCP Inspector
npx @modelcontextprotocol/inspector node build/index.js

The Inspector opens a browser where I could see:

  • My server’s tools
  • Tool schemas
  • Test tool calls
  • Error messages

I found that my server.tool() syntax was wrong. The SDK I was using had a different API than the examples I copied.

Corrected tool registration
// Old (wrong) syntax
server.tool("kb_search", "Search", { ... }, handler);
// New (correct) syntax
server.tool(
"kb_search",
"Search the knowledge base for documents",
{
query: z.string().describe("Search query"),
limit: z.number().optional().default(5),
},
async ({ query, limit }) => { ... }
);

Adding More Tools

With search working, I added more tools based on my actual needs:

Tool inventory
┌─────────────────────┬────────────────────────────┬───────────────┐
│ Tool │ Purpose │ Complexity │
├─────────────────────┼────────────────────────────┼───────────────┤
│ kb_search │ Full-text search with BM25 │ Low │
│ kb_read │ Read full document by ID │ Low │
│ kb_list │ List documents by tag │ Low │
│ kb_write │ Write to Obsidian vault │ Medium │
│ kb_ingest │ Add text to database │ Low │
│ kb_context │ Get context briefing │ Medium │
└─────────────────────┴────────────────────────────┴───────────────┘

The kb_context tool is particularly useful. It generates a brief summary instead of returning full documents, saving tokens:

kb_context tool
server.tool(
"kb_context",
"Get a token-efficient briefing about a topic",
{
topic: z.string().describe("Topic to get context for"),
maxTokens: z.number().optional().default(500),
},
async ({ topic, maxTokens }) => {
const results = searchDocuments(topic, 10);
const briefing = generateBriefing(results, maxTokens);
return {
content: [{ type: "text", text: briefing }],
};
}
);

Python Alternative

I later discovered the Python SDK is even simpler. Here’s the equivalent server:

server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-knowledge-base")
@mcp.tool()
async def kb_search(query: str, limit: int = 5) -> str:
"""Search the knowledge base for documents.
Args:
query: Search query string
limit: Maximum number of results to return
"""
results = await search_database(query, limit)
return format_results(results)
@mcp.tool()
async def kb_ingest(id: str, title: str, content: str, tags: str = "") -> str:
"""Add a document to the knowledge base."""
# Ingest logic here
return f"Ingested: {title}"
if __name__ == "__main__":
mcp.run(transport="stdio")

Python benefits: type hints and docstrings auto-generate tool schemas. Less boilerplate than TypeScript.

Common Gotchas

I hit several issues building this. Here are the main ones:

Gotcha 1: Console.log breaks everything

Never use console.log in stdio mode
// WRONG - corrupts JSON-RPC
console.log("Debug message");
// CORRECT - use stderr
console.error("Debug message");
// BETTER - use a logging library
import pino from "pino";
const logger = pino({ transport: { target: "pino-pretty" } });
logger.info("Debug message");

Gotcha 2: Relative paths in config

Always use absolute paths
// WRONG
{
"mcpServers": {
"kb": { "command": "node", "args": ["./build/index.js"] }
}
}
// CORRECT
{
"mcpServers": {
"kb": { "command": "node", "args": ["/home/user/my-server/build/index.js"] }
}
}

Gotcha 3: Forgetting to build TypeScript

Common mistake
# Edit TypeScript file
vim src/index.ts
# Restart Claude Desktop (nothing changes because you didn't rebuild!)
# CORRECT workflow
vim src/index.ts
npm run build
# Then restart Claude Desktop

Gotcha 4: Not restarting Claude Desktop

The config file is only read at startup. After editing claude_desktop_config.json, you must restart Claude Desktop.

Deployment Options

For local development, stdio transport works great. But I wanted my server accessible from multiple machines.

Option 1: Local only (stdio)

Local architecture
┌─────────────────┐
│ Claude Desktop │
│ (local) │
└────────┬────────┘
│ stdio
┌─────────────────┐
│ MCP Server │
│ (local) │
└─────────────────┘

Option 2: VPS with HTTP transport

HTTP transport setup
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/http.js";
const app = express();
const transport = new StreamableHTTPServerTransport();
// Connect server to HTTP transport
await server.connect(transport);
app.use("/mcp", transport.handleRequest);
app.listen(3838);

For security, I added API key authentication:

API key middleware
app.use("/mcp", (req, res, next) => {
const apiKey = req.headers["x-api-key"];
if (apiKey !== process.env.MCP_API_KEY) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
}, transport.handleRequest);

Cost Comparison

I compared costs between my setup and cloud alternatives:

Monthly cost comparison
┌─────────────────────────┬───────────────┬─────────────────────────┐
│ Approach │ Monthly Cost │ Notes │
├─────────────────────────┼───────────────┼─────────────────────────┤
│ MCP server on VPS │ ~$60 │ Supports 3 AI agents │
│ Cloud vector DB │ $50-200+ │ Depends on usage │
│ Cloud embeddings API │ $20-100 │ Per million tokens │
│ Local (my setup) │ $0 │ SQLite + local embeddings│
└─────────────────────────┴───────────────┴─────────────────────────┘

SQLite FTS5 handles full-text search. For semantic search, I use local embeddings:

Local embeddings with Xenova
import { pipeline } from "@xenova/transformers";
const embedder = await pipeline(
"feature-extraction",
"Xenova/all-MiniLM-L6-v2"
);
async function generateEmbedding(text: string): Promise<number[]> {
const output = await embedder(text, { pooling: "mean", normalize: true });
return Array.from(output.data);
}

No cloud API calls needed. Everything runs locally.

Summary

In this post, I showed how I built an MCP server for Claude AI with persistent memory. The key points:

  1. MCP servers communicate via stdin/stdout using JSON-RPC
  2. Never use console.log() - it corrupts the protocol
  3. SQLite FTS5 is sufficient for most knowledge base needs
  4. Local embeddings eliminate cloud API costs
  5. Always use absolute paths in Claude Desktop config
  6. Debug with MCP Inspector before testing in Claude Desktop

The complete server took about 100 lines of TypeScript. Now Claude remembers my project context across sessions, and I no longer copy-paste context manually.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments