The Problem
Claude can write code, explain concepts, and answer questions. But it can't read your filesystem, query your database, or call your APIs. It's a brain in a jar - powerful but disconnected.
You've tried copy-pasting file contents. You've manually run commands and fed results back. It works, but it's slow, error-prone, and kills the flow state. You need Claude to do things, not just suggest things.
The Model Context Protocol (MCP) turns Claude from a chatbot into an agent with capabilities. But most tutorials are either too abstract ("here's the protocol spec") or too complex ("build a full production server"). You need the middle path: build something that works in 30 minutes, understand the fundamentals, then scale up.
The Core Insight
MCP is just three things: resources, tools, and prompts. Everything else is plumbing.
Think of MCP servers like browser extensions:
- Resources: Static data the AI can read (like bookmarks)
- Tools: Functions the AI can execute (like "take screenshot")
- Prompts: Templates the AI can invoke (like keyboard shortcuts)
The protocol handles discovery, parameter validation, and result formatting. You just define what's available and implement the handlers. It's an RPC framework with AI affordances baked in.
| Primitive | Purpose | Example |
|---|---|---|
| Resource | Expose readable data | File contents, API docs, logs |
| Tool | Execute actions | Search files, run query, send email |
| Prompt | Templated workflows | "Review this PR", "Debug error" |
The Walkthrough
Step 1: Install the SDK
We'll use Python (TypeScript works too). Install the official SDK:
pip install mcp
Step 2: Create a Minimal Server
Create file_search_server.py. This server will expose one tool: searching files by pattern.
#!/usr/bin/env python3
from mcp.server import Server
from mcp.types import Tool, TextContent
import mcp.server.stdio
import os
import fnmatch
# Initialize server
server = Server("file-search-server")
# Define the tool
@server.list_tools()
async def list_tools():
return [
Tool(
name="search_files",
description="Search for files matching a pattern in a directory",
inputSchema={
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "File pattern to match (e.g., '*.py', 'test_*.js')"
},
"directory": {
"type": "string",
"description": "Directory to search in (default: current directory)"
}
},
"required": ["pattern"]
}
)
]
# Implement the tool
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name != "search_files":
raise ValueError(f"Unknown tool: {name}")
pattern = arguments["pattern"]
directory = arguments.get("directory", ".")
# Safety: prevent directory traversal
directory = os.path.abspath(directory)
matches = []
for root, dirs, files in os.walk(directory):
for filename in files:
if fnmatch.fnmatch(filename, pattern):
full_path = os.path.join(root, filename)
relative_path = os.path.relpath(full_path, directory)
matches.append(relative_path)
result = f"Found {len(matches)} files:\n" + "\n".join(matches[:50])
if len(matches) > 50:
result += f"\n... and {len(matches) - 50} more"
return [TextContent(type="text", text=result)]
# Run the server
async def main():
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
Step 3: Configure Claude Desktop
Tell Claude Desktop about your server. Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or equivalent on other platforms:
{
"mcpServers": {
"file-search": {
"command": "python3",
"args": ["/absolute/path/to/file_search_server.py"]
}
}
}
Path Must Be Absolute
Use the full path to your script. Relative paths and ~ won't work. Run pwd in your script directory to get the absolute path.
Step 4: Test It
Restart Claude Desktop. The server runs automatically when Claude starts. Try this prompt:
"Search for all Python files in my current directory"
Claude will:
- Discover the
search_filestool - Generate arguments:
{"pattern": "*.py"} - Call your server
- Present the results
You've just given Claude filesystem awareness. With 60 lines of Python.
Understanding the Request-Response Cycle
Here's what happens under the hood:
- Initialization: Claude Desktop launches your server via stdio
- Discovery: Claude calls
list_tools()to see what's available - Invocation: User prompt triggers Claude to call
call_tool() - Execution: Your handler runs, returns result
- Presentation: Claude incorporates results into its response
The server stays alive for the session. No startup cost per-tool-use.
Failure Patterns
1. Server Crashes Silently
Symptom: Claude says "tool unavailable" but gives no error message.
Fix: Add logging. MCP servers run headless - print statements disappear. Use a log file:
import logging
logging.basicConfig(
filename='/tmp/mcp_server.log',
level=logging.DEBUG,
format='%(asctime)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Then use logger.debug() throughout your code
2. Tool Never Gets Called
Symptom: Claude doesn't attempt to use your tool, even when relevant.
Fix: Improve the tool description. Claude decides what to call based on descriptions. Be specific:
# Bad
description="Search for files"
# Good
description="Search for files matching a glob pattern (e.g., '*.py', 'test_*') in a directory tree. Returns up to 50 matching file paths."
3. Invalid Parameters
Symptom: Your handler crashes on unexpected input types.
Fix: Validate early, fail with helpful messages:
if not isinstance(pattern, str) or not pattern.strip():
raise ValueError("pattern must be a non-empty string")
if ".." in directory or directory.startswith("/"):
raise ValueError("directory must be relative and cannot contain ..")
4. Results Too Large
Symptom: Server times out or Claude says "response truncated".
Fix: Limit output size. Return summaries, not full contents:
# Don't return 10MB of file contents
# Do return: "Found 150 files totaling 10MB. First 10: ..."
The Discovery Problem
Claude won't use a tool unless it knows it exists. If you add a new tool, restart Claude Desktop to force re-discovery. In production, implement the tools/list notification to push updates.
Extending Your Server
Once the basic pattern works, add more tools:
# Add a "read file" tool
@server.list_tools()
async def list_tools():
return [
# ... search_files tool ...
Tool(
name="read_file",
description="Read contents of a file",
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file"
}
},
"required": ["path"]
}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "search_files":
# ... existing implementation ...
elif name == "read_file":
path = arguments["path"]
# Safety checks
path = os.path.abspath(path)
if not os.path.isfile(path):
raise ValueError(f"Not a file: {path}")
with open(path, 'r') as f:
content = f.read(100000) # Limit to 100KB
return [TextContent(type="text", text=content)]
else:
raise ValueError(f"Unknown tool: {name}")
Now Claude can search for files AND read them. Combine tools in one prompt:
"Find all configuration files and show me the database connection settings"
Quick Reference
MCP Server Checklist:
- Install SDK:
pip install mcp - Define tools: Implement
@server.list_tools() - Handle calls: Implement
@server.call_tool() - Add logging: Use file-based logging for debugging
- Validate inputs: Check types and ranges early
- Limit output: Cap result size to prevent timeouts
Common Issues:
- Tool not discovered: Restart Claude Desktop
- Server crashes: Check
/tmp/mcp_server.log - Tool not called: Improve description clarity
- Slow responses: Reduce output size, add caching
Next Steps:
- Add resources for static data (docs, config files)
- Implement prompts for common workflows
- Add authentication for sensitive operations
- Deploy as a network server (not just stdio)