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:
- The tool has hundreds of operations (not 5-10)
- Operations have complex parameter relationships
- 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
# Pythonpip install mcp
# TypeScriptnpm install @modelcontextprotocol/sdkStep 3: Define the Tool
I started with a simple query tool:
from mcp.server import Serverfrom mcp.server.stdio import stdio_serverfrom mcp.types import Tool, TextContentimport sqlite3import 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:
@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:
{ "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 WednesdayThis 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:
# Claude can do this directlygit commit -m "message"git push origin main
# My MCP server just wrapped these - pointlessLesson: 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 operationsif not query.strip().upper().startswith("SELECT"): return error("Only SELECT queries allowed")Mistake 3: Poor Error Messages
My initial error handling was:
# BADexcept Exception: return "Query failed"Claude couldn’t understand what went wrong. I improved it:
# GOODexcept 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 IMAPlibrary docs: [pasted docs]. Create the server.
Claude: I'll create an IMAP MCP server for you. Let me start with the basicstructure...[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:
- The tool has a CLI - git, docker, npm, kubectl, aws-cli
- Operations are simple - single commands, no orchestration
- No session state needed - each operation is independent
- No domain knowledge required - parameters are straightforward
When to Build MCP
Build MCP when:
- Browser automation - login sessions, cookies, JavaScript execution
- Database access - schema knowledge, query translation
- Complex APIs - hundreds of endpoints, auth flows
- Proprietary tools - Excel, internal services
- Multi-step orchestration - deployment pipelines, workflows
TypeScript Example: Email Server
For completeness, here’s a TypeScript MCP server for email:
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:
{ "mcpServers": { "email": { "command": "node", "args": ["/path/to/email_server/dist/index.js"], "env": { "IMAP_HOST": "imap.gmail.com", "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:
- Does this tool have a good CLI? If yes, skip MCP.
- Does it have hundreds of operations with complex relationships?
- 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