Skip to content

How to Build a Custom MCP Server for Claude: A Complete Guide

Problem

I wanted to extend Claude’s capabilities with my own tools. Everyone talked about MCP servers - they sounded powerful. I started building one for git operations before I realized something important.

Most MCP servers aren’t worth building.

Here’s what happened: I spent hours creating an MCP server for git commands. It had tools for commit, push, branch, merge - the works. Then I watched Claude use it. Claude would call my git_commit tool, which then called git commit in the shell.

Wait. Claude could just call git commit directly.

I had built an abstraction layer that added complexity without value. My git MCP server was a wrapper around a perfectly good CLI.

This sent me down a rabbit hole. When should I build an MCP server? When should I just let Claude use the CLI?

What I Learned

I found a Reddit thread that crystallized the answer. One comment stuck with me:

“The honest answer is that most MCP servers aren’t worth it. The useful heuristic: if the tool already has a good CLI, skip MCP and just let Claude use the CLI directly. MCP only earns its complexity when the tool has hundreds of operations with parameter relationships that require domain knowledge to compose correctly.”

This was the key insight. MCP servers earn their complexity when:

  1. The tool has hundreds of operations (not 5-10)
  2. Operations have complex parameter relationships
  3. You need domain knowledge to compose operations correctly

For git, docker, npm - these have excellent CLIs. Claude can use them directly. Skip MCP.

But what about tools where MCP DOES make sense?

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Skip MCP │ │ Consider MCP │ │ Build MCP │
│ ─────────── │ │ ───────────── │ │ ────────── │
│ git │ │ REST API │ │ Browser auth │
│ docker │ │ with 50+ │ │ sessions │
│ npm/yarn │ │ endpoints │ │ ───────────── │
│ kubectl │ │ ───────────── │ │ SQLite │
│ ───────────── │ │ Custom API │ │ databases │
│ Good CLI │ │ with complex │ │ ───────────── │
│ Claude can │ │ auth flows │ │ Excel │
│ use directly │ │ ───────────── │ │ integration │
│ │ │ Proprietary │ │ ───────────── │
│ │ │ tools │ │ Email IMAP │
│ │ │ │ │ ───────────── │
│ │ │ │ │ VPS deployment │
│ │ │ │ │ automation │
└─────────────────┘ └─────────────────┘ └─────────────────┘

Real Examples That Justify MCP

From the Reddit thread, I found developers building MCP servers for:

1. Browser session routing

“I built a single MCP server that routes tool calls through a Chrome extension using whatever login sessions are already active in the browser.”

This needs MCP because:

  • Browser automation requires session state
  • Login flows are complex
  • You can’t just CLI into a logged-in browser session

2. SQLite databases

“I’ve been running some local sqlite databases for my personal work notes and being able to just query them directly through Claude is wild.”

This needs MCP because:

  • Direct database access
  • Schema knowledge embedded in tool descriptions
  • Natural language to SQL translation

3. Excel integration

“I build a MCP to talk to my Excel files. And by ‘talk’ I mean build wild stuff in it.”

This needs MCP because:

  • Excel has complex APIs
  • Cell/formula relationships require domain knowledge
  • Operations compose in non-obvious ways

4. Email access

“An email MCP server that gives Claude full IMAP/SMTP access.”

This needs MCP because:

  • IMAP/SMTP protocols are complex
  • Folder structures vary
  • Search queries need translation

5. VPS deployment

“I provided vps access to claude using ssh and it deployed my apps files, setup domains, nginx and automated ssl.”

This could use CLI, but MCP helps orchestrate:

  • Multiple related operations (deploy, config, SSL)
  • State management across steps
  • Error recovery

Building My First MCP Server

I decided to build an MCP server for SQLite - a clear use case. Here’s my process.

Step 1: Choose Your Language

MCP supports both Python and TypeScript. I chose Python because:

  • I work with data frequently
  • SQLite is native to Python
  • Simpler for database operations

Step 2: Install the MCP SDK

Terminal window
# Python
pip install mcp
# TypeScript
npm install @modelcontextprotocol/sdk

Step 3: Define the Tool

I started with a simple query tool:

sqlite_server.py
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
import sqlite3
import json
app = Server("sqlite-tools")
@app.list_tools()
async def list_tools():
return [
Tool(
name="query_sqlite",
description="Execute a SQL query on a SQLite database",
inputSchema={
"type": "object",
"properties": {
"database_path": {
"type": "string",
"description": "Path to the SQLite database file"
},
"query": {
"type": "string",
"description": "SQL query to execute (SELECT only)"
}
},
"required": ["database_path", "query"]
}
)
]

Step 4: Implement the Handler

This is where domain knowledge matters:

sqlite_server.py (continued)
@app.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "query_sqlite":
db_path = arguments["database_path"]
query = arguments["query"]
# Security: Only allow SELECT queries
if not query.strip().upper().startswith("SELECT"):
return [TextContent(
type="text",
text="Error: Only SELECT queries are allowed for safety"
)]
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(query)
results = cursor.fetchall()
columns = [desc[0] for desc in cursor.description]
conn.close()
return [TextContent(
type="text",
text=json.dumps({
"columns": columns,
"rows": results
}, indent=2)
)]
except Exception as e:
return [TextContent(type="text", text=f"Error: {str(e)}")]
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())

Notice the security check - I only allow SELECT queries. This is domain knowledge baked into the tool.

Step 5: Configure Claude

Add to Claude Desktop config:

claude_desktop_config.json
{
"mcpServers": {
"sqlite": {
"command": "python",
"args": ["/path/to/sqlite_server.py"]
}
}
}

Testing the Server

I tested with my work notes database:

Me: Query my work database for all notes from this week
Claude: I'll use the query_sqlite tool to search your database.
[Calls query_sqlite with database_path="/Users/cowrie/work.db" and query="SELECT * FROM notes WHERE date >= date('now', '-7 days')"]
Here are your notes from this week:
- Meeting notes from Monday's standup
- API design decisions from Tuesday
- Bug fix documentation from Wednesday

This worked. But I made several mistakes along the way.

Common Mistakes

Mistake 1: Building MCP for Tools with Good CLIs

My first attempt was a git MCP server. Complete waste of time. Git has an excellent CLI. Claude can use git commands directly:

Terminal window
# Claude can do this directly
git commit -m "message"
git push origin main
# My MCP server just wrapped these - pointless

Lesson: Before building, ask: “Does this tool have a good CLI that Claude can use directly?”

Mistake 2: No Security Validation

My first SQLite server allowed ANY query:

# WRONG - allows DROP, DELETE, etc.
cursor.execute(query)

Claude could accidentally delete data. I added validation:

# CORRECT - only allow safe operations
if not query.strip().upper().startswith("SELECT"):
return error("Only SELECT queries allowed")

Mistake 3: Poor Error Messages

My initial error handling was:

# BAD
except Exception:
return "Query failed"

Claude couldn’t understand what went wrong. I improved it:

# GOOD
except sqlite3.OperationalError as e:
return f"Database error: {e}. Check if the table exists and the path is correct."
except Exception as e:
return f"Unexpected error: {type(e).__name__}: {str(e)}"

Mistake 4: Not Letting Claude Help Build It

I spent hours writing MCP server code from scratch. Then I realized: Claude can write MCP server code.

I tried this:

Me: I need an MCP server that connects to my email via IMAP. Here's the IMAP
library docs: [pasted docs]. Create the server.
Claude: I'll create an IMAP MCP server for you. Let me start with the basic
structure...
[Generated working code in minutes]

Now I let Claude generate the initial server, then I refine it.

Mistake 5: Overcomplicating the First Version

My first SQLite server had:

  • Query tool
  • Schema inspector
  • Table creator
  • Index analyzer
  • Performance monitor

Too much. I should have started with just the query tool, tested it, then added features.

When to Skip MCP

Based on my experience, skip MCP when:

  1. The tool has a CLI - git, docker, npm, kubectl, aws-cli
  2. Operations are simple - single commands, no orchestration
  3. No session state needed - each operation is independent
  4. No domain knowledge required - parameters are straightforward

When to Build MCP

Build MCP when:

  1. Browser automation - login sessions, cookies, JavaScript execution
  2. Database access - schema knowledge, query translation
  3. Complex APIs - hundreds of endpoints, auth flows
  4. Proprietary tools - Excel, internal services
  5. Multi-step orchestration - deployment pipelines, workflows

TypeScript Example: Email Server

For completeness, here’s a TypeScript MCP server for email:

email_server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { ImapFlow } from "imapflow";
const server = new Server(
{ name: "email-tools", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_emails",
description: "Search emails in a folder",
inputSchema: {
type: "object",
properties: {
folder: { type: "string", description: "Folder to search (e.g., INBOX)" },
criteria: { type: "string", description: "Search criteria" }
},
required: ["folder", "criteria"]
}
},
{
name: "send_email",
description: "Send an email via SMTP",
inputSchema: {
type: "object",
properties: {
to: { type: "string", description: "Recipient email address" },
subject: { type: "string", description: "Email subject" },
body: { type: "string", description: "Email body text" }
},
required: ["to", "subject", "body"]
}
}
]
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "search_emails") {
const client = new ImapFlow({
host: process.env.IMAP_HOST,
port: 993,
secure: true,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
await client.connect();
const messages = [];
for await (const message of client.fetch(args.criteria, { envelope: true })) {
messages.push(message.envelope);
}
await client.logout();
return { content: [{ type: "text", text: JSON.stringify(messages, null, 2) }] };
}
throw new Error(`Unknown tool: ${name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);

Configuration with environment variables:

claude_desktop_config.json
{
"mcpServers": {
"email": {
"command": "node",
"args": ["/path/to/email_server/dist/index.js"],
"env": {
"IMAP_HOST": "imap.gmail.com",
"EMAIL_USER": "[email protected]",
"EMAIL_PASS": "your-app-password"
}
}
}
}

How MCP Earns Its Complexity

The key is understanding what MCP provides that CLI doesn’t:

1. Tool Discovery MCP servers describe their tools with schemas. Claude knows what parameters each tool accepts.

2. Input Validation JSON Schema validates inputs before execution. Claude can’t pass bad parameters.

3. Error Handling Structured errors help Claude understand what went wrong and retry.

4. Session Management MCP servers maintain state across calls. CLIs are stateless.

5. Domain Knowledge Embedding Tool descriptions encode when and how to use each operation.

For simple tools, this is overkill. For complex tools, it’s essential.

Summary

In this post, I showed when and how to build custom MCP servers for Claude. The key insight: most MCP servers aren’t worth it.

Before building, ask:

  1. Does this tool have a good CLI? If yes, skip MCP.
  2. Does it have hundreds of operations with complex relationships?
  3. Do you need domain knowledge to compose operations?

Build MCP for:

  • Browser session automation
  • Database access with schema knowledge
  • Complex API orchestration
  • Proprietary tool integration

Skip MCP for:

  • git, docker, npm, kubectl
  • Simple CLI tools
  • Stateless operations

Best practice: Ask Claude to write your MCP server code. Provide API docs and requirements. Iterate on the generated code.

The best MCP server is one you build yourself, tailored exactly to your needs. Most pre-built connectors add complexity without value. Focus on YAGNI - build what you need, when you need it.

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