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)
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"]
}
}
}
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"],
}
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)}
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:
- Restrict paths. Never expose the full filesystem. Use a whitelist of allowed directories.
-
Sandbox SQL. The
query_dbtool allows arbitrary SQL. In production, restrict toSELECTonly or use a read-only connection. - Add authentication. The JSON-RPC over stdin model assumes the agent is trusted. For network-exposed MCP servers, add API key validation.
- 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
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:
- One server, many agents. Build once. Claude, Cursor, Facio, Codex all connect to the same server.
- Self-documenting. The manifest is the documentation. No separate docs to maintain.
- Schema-safe. Type definitions in the manifest catch mismatches before the agent calls a tool.
- 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)