DEV Community

Cover image for I Built an MCP Server in 50 Lines of Python. Here Is How.
Kevin
Kevin

Posted on

I Built an MCP Server in 50 Lines of Python. Here Is How.

TL;DR: The Model Context Protocol (MCP) is the standard for giving AI agents access to tools. But most tutorials overcomplicate it. Here is how to build a fully functional MCP server in 50 lines of Python: weather lookup, SQLite database, file system access. Copy, paste, run.


What MCP Actually Is

MCP is a protocol that lets AI agents discover and call tools. Instead of every agent needing a bespoke integration for every service, MCP defines a standard way for servers to say:

"Here are the tools I offer. Here is what they accept. Here is what they return."

The agent reads this manifest and calls tools as needed. No OpenAPI specs. No REST wrappers. Just a self-describing server that any MCP-compatible client (Claude Desktop, Cursor, Facio, OpenAI Codex) can use.

The 50-Line Server

Here is a complete MCP server with three tools: weather lookup, SQLite query, and file reading. Copy this into server.py and you have a working tool server.

import json
import sys
import sqlite3
from pathlib import Path

# Tool 1: Weather lookup (simulated)
def get_weather(city: str) -> dict:
    data = {"Berlin": 14, "London": 11, "Tokyo": 22, "New York": 19}
    temp = data.get(city)
    if temp is None:
        return {"error": f"Unknown city: {city}"}
    return {"city": city, "temperature_c": temp, "condition": "clear"}

# Tool 2: SQLite query
def query_db(sql: str) -> dict:
    try:
        conn = sqlite3.connect("data.db")
        cur = conn.cursor()
        cur.execute(sql)
        if sql.strip().upper().startswith("SELECT"):
            rows = cur.fetchall()
            cols = [d[0] for d in cur.description]
            return {"columns": cols, "rows": rows}
        conn.commit()
        return {"affected": cur.rowcount}
    except Exception as e:
        return {"error": str(e)}
    finally:
        conn.close()

# Tool 3: Read file
def read_file(path: str) -> dict:
    p = Path(path)
    if not p.exists():
        return {"error": f"File not found: {path}"}
    if p.stat().st_size > 100_000:
        return {"error": "File too large (>100KB)"}
    return {"path": path, "content": p.read_text()}

# Tool manifest: the agent reads this to discover tools
MANIFEST = {
    "tools": [
        {
            "name": "get_weather",
            "description": "Get current temperature for a city.",
            "inputSchema": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
        {
            "name": "query_db",
            "description": "Run a read-only SQL query on data.db.",
            "inputSchema": {
                "type": "object",
                "properties": {"sql": {"type": "string"}},
                "required": ["sql"],
            },
        },
        {
            "name": "read_file",
            "description": "Read a file from the local filesystem (max 100KB).",
            "inputSchema": {
                "type": "object",
                "properties": {"path": {"type": "string"}},
                "required": ["path"],
            },
        },
    ]
}

TOOLS = {"get_weather": get_weather, "query_db": query_db, "read_file": read_file}

def handle_request(req: dict) -> dict:
    if req.get("method") == "tools/list":
        return MANIFEST
    if req.get("method") == "tools/call":
        name = req["params"]["name"]
        args = req["params"].get("arguments", {})
        fn = TOOLS.get(name)
        if not fn:
            return {"error": f"Unknown tool: {name}"}
        return {"content": [{"type": "text", "text": json.dumps(fn(**args))}]}
    return {"error": f"Unknown method: {req.get('method')}"}

if __name__ == "__main__":
    for line in sys.stdin:
        req = json.loads(line)
        print(json.dumps(handle_request(req)), flush=True)
Enter fullscreen mode Exit fullscreen mode

That is the entire server. Fifty lines including comments.

How It Works

Every MCP server speaks JSON-RPC over stdin/stdout. The agent sends a request, the server responds. No HTTP framework. No gRPC. No dependency beyond Python's standard library.

The server implements two methods:

tools/list returns the manifest. The agent calls this once at startup. It learns what tools are available, what parameters they accept, and what they do.

tools/call invokes a specific tool with arguments. The agent calls this whenever it needs to use a tool. The server runs the function and returns structured JSON.

That is the entire protocol surface. Two methods. Self-describing tools. Structured responses.

Connecting It to an Agent

Save the server as server.py. Then configure your MCP client. Here is the configuration for Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "my-tools": {
      "command": "python",
      "args": ["server.py"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

For Cursor, add it via Settings → MCP → Add Server. For Facio, use the manage_mcp tool. For any compatible client, point it at python server.py and the tools appear automatically.

Once connected, the agent can call get_weather with {"city": "Berlin"}, query your SQLite database, or read local files. No additional integration code needed.

Adding Real Capabilities

The weather tool is simulated. Here is how to replace it with a real API call:

import urllib.request

def get_weather(city: str) -> dict:
    api_key = "YOUR_OPENWEATHERMAP_KEY"
    url = (
        f"https://api.openweathermap.org/data/2.5/weather"
        f"?q={city}&appid={api_key}&units=metric"
    )
    with urllib.request.urlopen(url) as r:
        data = json.loads(r.read())
    return {
        "city": city,
        "temperature_c": data["main"]["temp"],
        "condition": data["weather"][0]["description"],
        "humidity": data["main"]["humidity"],
    }
Enter fullscreen mode Exit fullscreen mode

Same interface. Same manifest. The agent does not know or care that the implementation switched from simulated data to a real API.

This is the core value proposition of MCP: the agent sees tools, not APIs. You can change the implementation without changing the interface. The manifest stays the same. Every client stays in sync.

Adding a Tool with Multiple Return Types

Not every tool returns a single result. Here is a tool that returns different output depending on input:

def search_files(query: str, directory: str = ".") -> dict:
    """Search files by name in a directory."""
    p = Path(directory)
    if not p.is_dir():
        return {"error": f"Not a directory: {directory}"}

    matches = []
    for f in p.rglob(f"*{query}*"):
        if f.is_file():
            matches.append({
                "name": f.name,
                "path": str(f),
                "size": f.stat().st_size,
            })

    if not matches:
        return {"matches": [], "message": f"No files matching '{query}'"}
    return {"matches": matches[:20], "total": len(matches)}
Enter fullscreen mode Exit fullscreen mode

Add it to TOOLS and MANIFEST and the agent gains file search. Three new lines in the manifest. No new endpoints.

Security Considerations

The query_db and read_file tools in this example are open by design for a local development server. In production:

  1. Restrict paths. Never expose the full filesystem. Use a whitelist of allowed directories.
  2. Sandbox SQL. The query_db tool allows arbitrary SQL. In production, restrict to SELECT only or use a read-only connection.
  3. Add authentication. The JSON-RPC over stdin model assumes the agent is trusted. For network-exposed MCP servers, add API key validation.
  4. Rate limit. A single agent can call tools in rapid loops. Add per-tool rate limiting.

Here is the same server with a path sandbox:

ALLOWED_DIRS = [Path("/project/data"), Path("/project/templates")]

def read_file(path: str) -> dict:
    p = Path(path).resolve()
    if not any(p.is_relative_to(d) for d in ALLOWED_DIRS):
        return {"error": f"Access denied: {path} is outside allowed directories"}
    # ... rest of implementation
Enter fullscreen mode Exit fullscreen mode

Why This Matters

Before MCP, every AI agent integration required custom code. You wrote a REST wrapper, handled authentication, parsed responses, formatted output for the LLM, and prayed the API did not change.

With MCP:

  1. One server, many agents. Build once. Claude, Cursor, Facio, Codex all connect to the same server.
  2. Self-documenting. The manifest is the documentation. No separate docs to maintain.
  3. Schema-safe. Type definitions in the manifest catch mismatches before the agent calls a tool.
  4. Replaceable implementations. Swap the weather mock for a real API without touching the agent config.

Fifty lines of Python. Three tools. Unlimited agents.


I build MCP-native AI agent infrastructure at centerbit. If you are building tool servers for AI agents, we should talk.

Top comments (0)