Building MCP Tools: A Hands-On Guide
L4-07 explained what MCP is. This piece walks you through writing one that Claude / Cursor can use directly.
L4-07 introduced MCP (Model Context Protocol). This piece is the hands-on companion: how to actually write one.
By the end, you’ll have an MCP server that Claude Desktop / Cursor can use.
Pick a Language
Official SDKs:
- Python (most mature, easiest)
- TypeScript
- Go
- Rust
- Kotlin
We’ll use Python for examples — easiest to start with.
Minimal MCP Server
A working MCP server in ~30 lines:
from mcp.server import Server
from mcp.types import Tool, TextContent
server = Server("my-first-mcp-server")
@server.list_tools()
async def list_tools():
return [
Tool(
name="get_weather",
description="Get current weather for a city",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'Tokyo'"
}
},
"required": ["city"]
}
)
]
@server.call_tool()
async def call_tool(name, arguments):
if name == "get_weather":
city = arguments["city"]
# ... call actual weather API ...
weather = {"city": city, "temp": "22°C", "condition": "sunny"}
return [TextContent(type="text", text=f"{weather}")]
raise ValueError(f"Unknown tool: {name}")
if __name__ == "__main__":
import asyncio
asyncio.run(server.run_stdio())
Save as weather_server.py.
Install in Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["/absolute/path/to/weather_server.py"]
}
}
}
Restart Claude Desktop. Now ask Claude: “What’s the weather in Tokyo?” — it calls your tool.
Three MCP Primitives
MCP supports three things a server can expose:
Tools (functions LLM can call)
@server.list_tools()
async def list_tools():
return [
Tool(name="search", description="...", inputSchema={...}),
Tool(name="create_file", description="...", inputSchema={...}),
]
Resources (content LLM can read)
@server.list_resources()
async def list_resources():
return [
Resource(
uri="myapp://docs/api",
name="API Documentation",
mimeType="text/markdown"
)
]
@server.read_resource()
async def read_resource(uri):
if uri == "myapp://docs/api":
return open("api-docs.md").read()
Prompts (pre-defined workflows)
@server.list_prompts()
async def list_prompts():
return [
Prompt(
name="code_review",
description="Review a code file",
arguments=[{"name": "file_path", "required": True}]
)
]
@server.get_prompt()
async def get_prompt(name, arguments):
if name == "code_review":
return [Message(role="user", content=f"Review this code: ...")]
A Real Example: Note-Taking MCP
Let’s build something useful — a personal notes MCP:
import os
from pathlib import Path
from mcp.server import Server
from mcp.types import Tool, TextContent
NOTES_DIR = Path.home() / "notes"
NOTES_DIR.mkdir(exist_ok=True)
server = Server("notes")
@server.list_tools()
async def list_tools():
return [
Tool(
name="list_notes",
description="List all notes",
inputSchema={"type": "object", "properties": {}}
),
Tool(
name="read_note",
description="Read a note by name",
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "Note filename (without .md)"}
},
"required": ["name"]
}
),
Tool(
name="write_note",
description="Create or update a note",
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string"},
"content": {"type": "string"}
},
"required": ["name", "content"]
}
),
]
@server.call_tool()
async def call_tool(name, arguments):
if name == "list_notes":
notes = [f.stem for f in NOTES_DIR.glob("*.md")]
return [TextContent(type="text", text="\n".join(notes) or "(no notes)")]
elif name == "read_note":
path = NOTES_DIR / f"{arguments['name']}.md"
if not path.exists():
return [TextContent(type="text", text=f"Note '{arguments['name']}' not found")]
return [TextContent(type="text", text=path.read_text())]
elif name == "write_note":
path = NOTES_DIR / f"{arguments['name']}.md"
path.write_text(arguments["content"])
return [TextContent(type="text", text=f"Saved: {path}")]
raise ValueError(f"Unknown tool: {name}")
if __name__ == "__main__":
import asyncio
asyncio.run(server.run_stdio())
Configure in Claude Desktop, restart, then ask:
- “List my notes”
- “Read my note ‘meeting-2024-03-15’”
- “Save a note titled ‘idea’ with content ‘Build a MCP server for Slack’”
Claude operates your notes directly.
Security Best Practices
1. Whitelist paths
ALLOWED_DIR = Path.home() / "notes"
def safe_path(name):
path = (ALLOWED_DIR / f"{name}.md").resolve()
if not str(path).startswith(str(ALLOWED_DIR.resolve())):
raise ValueError("Path escape attempt detected")
return path
Otherwise the LLM might write to /etc/passwd via path traversal.
2. Validate inputs
if not name.isalnum() and not name.replace("-", "").replace("_", "").isalnum():
raise ValueError("Invalid characters in note name")
3. Rate limit dangerous tools
@rate_limit(per_minute=10)
def write_note(...):
...
4. Audit logging
import logging
logging.info(f"Tool called: {name}, args: {arguments}")
Know what your MCP server did.
Publishing Your MCP
If your server is generic enough, share it:
- GitHub repo with README + install instructions
- Submit to modelcontextprotocol/servers — the official directory
- PyPI / npm for easy installation
Community-popular MCP servers as of 2025:
- filesystem
- github / gitlab
- postgres / mongodb
- slack / discord
- google drive
- notion
- 1password
- …and 1000+ more
Debugging Tips
When your MCP doesn’t work:
1. Check the logs
Claude Desktop logs MCP errors:
tail -f ~/Library/Logs/Claude/mcp-server-*.log
2. Test stdio manually
# Run server alone, send JSON-RPC manually:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python my_server.py
3. Use the inspector
npx @modelcontextprotocol/inspector python my_server.py
UI for testing MCP servers — very useful.
4. Check restart
After config change, fully quit and restart Claude Desktop. Just closing the window isn’t enough.
What MCP Doesn’t Solve
- Authentication: each server handles its own (no MCP-level auth yet)
- Streaming: still being designed (good for image-gen, audio)
- Multi-tenancy: one server = one user
- Cross-org permissions: each org runs their own
These are roadmap items for MCP 2.0+.
MCP is one of those things that looks simple until you build one — then you realize it’s actually 200 lines of careful engineering for each non-trivial integration.
But the leverage is huge: write once, and Claude / Cursor / Cline / all future MCP clients can use it.
Compare with old-school plugins: write a Chrome extension, a VS Code extension, a Slack app, a Notion plugin — all separately, all duplicate work. MCP is one server, infinite clients.
Next recommended: L4-12 Cost Optimization or L4-07 MCP Protocol Overview.