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:
Day 1: Explain project structure to ClaudeDay 2: Explain project structure again (Claude forgot)Day 3: Explain project structure again (Claude forgot)Day 4: Give up, start pasting everything manuallyI 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 ││ ││ ┌───────────┐ ││ │ 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:
mkdir my-mcp-servercd my-mcp-servernpm init -ynpm install @modelcontextprotocol/sdk zod@3npm install -D @types/node typescriptThen I created my server file:
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.
// WRONG - corrupts JSON-RPC messagesconsole.log("Server started");
// CORRECT - writes to stderr, not stdoutconsole.error("Server started");MCP servers use stdout for protocol messages. Any console.log() corrupts the JSON-RPC stream. All logs must go to stderr.
Adding Real Search
Now I needed actual search functionality. I added SQLite with FTS5 (full-text search):
npm install better-sqlite3npm install -D @types/better-sqlite3import 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 searchdb.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:
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:
npx tscThen I configured Claude Desktop to use my server:
{ "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:
npx @modelcontextprotocol/inspector node build/index.jsThe 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.
// Old (wrong) syntaxserver.tool("kb_search", "Search", { ... }, handler);
// New (correct) syntaxserver.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 │ 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:
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:
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
// WRONG - corrupts JSON-RPCconsole.log("Debug message");
// CORRECT - use stderrconsole.error("Debug message");
// BETTER - use a logging libraryimport pino from "pino";const logger = pino({ transport: { target: "pino-pretty" } });logger.info("Debug message");Gotcha 2: Relative paths in config
// 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
# Edit TypeScript filevim src/index.ts
# Restart Claude Desktop (nothing changes because you didn't rebuild!)
# CORRECT workflowvim src/index.tsnpm run build# Then restart Claude DesktopGotcha 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)
┌─────────────────┐│ Claude Desktop ││ (local) │└────────┬────────┘ │ stdio ▼┌─────────────────┐│ MCP Server ││ (local) │└─────────────────┘Option 2: VPS with HTTP transport
import express from "express";import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/http.js";
const app = express();const transport = new StreamableHTTPServerTransport();
// Connect server to HTTP transportawait server.connect(transport);
app.use("/mcp", transport.handleRequest);app.listen(3838);For security, I added API key authentication:
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:
┌─────────────────────────┬───────────────┬─────────────────────────┐│ 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:
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:
- MCP servers communicate via stdin/stdout using JSON-RPC
- Never use
console.log()- it corrupts the protocol - SQLite FTS5 is sufficient for most knowledge base needs
- Local embeddings eliminate cloud API costs
- Always use absolute paths in Claude Desktop config
- 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:
- 👨💻 Model Context Protocol Quickstart
- 👨💻 TypeScript SDK GitHub
- 👨💻 Python SDK GitHub
- 👨💻 Reddit: Obsidian + Claude Integration
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments