HelloAI
L4 Chapter 11 🐣 🕒 10 min

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.

A
Alai
8/22/2026

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:

  1. GitHub repo with README + install instructions
  2. Submit to modelcontextprotocol/servers — the official directory
  3. 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+.

💡 A reflection

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.