Kamu punya AI assistant yang bisa ngobrol, tapi nggak bisa ngapa-ngapain. Nggak bisa ngecek database, bikin tiket, atau baca file. Padahal aplikasi AI di dunia nyata butuh koneksi ke sistem yang beneran ada.
Model Context Protocol (MCP) jawabannya. Ini standar open dari Anthropic yang ngasih LLM cara buat manggil tools, baca resources, dan ngikutin prompts - dari klien MCP mana aja.
Anggep aja kayak port USB-C buat aplikasi AI. Satu protokol. Banyak server. Bisa dipake klien mana aja.
Tutorial ini ngajarin bikin MCP server dari nol pake Python. Di akhir, kamu bakal punya server yang ngekspos tools yang bisa dipanggil AI agent - plus tahu cara nyambungin ke Claude Desktop, VS Code, atau MCP host lainnya.
Prerequisites
- Python 3.10+
uvterinstall (pip boleh, tapi uv lebih cepet)- Text editor
- Basic Python dan async
Langkah 1: Setup Project
Buat direktori project dan virtual environment:
mkdir mcp-tasks-server
cd mcp-tasks-server
uv init
uv venv
source .venv/bin/activate
Install MCP SDK:
uv add "mcp[cli]"
Package mcp berisi framework server dan CLI inspector. Extra [cli] ngasih kamu command mcp buat testing.
Buat file server.py:
touch server.py
Langkah 2: Skeleton Server
Buka server.py dan tambahin kode minimal:
from mcp.server import MCPServer
mcp = MCPServer("tasks")
if __name__ == "__main__":
mcp.run(transport="stdio")
Jalanin buat verifikasi:
python server.py
Nggak ada output. Itu normal - server stdio diem aja sampe client ngirim pesan JSON-RPC. Tekan Ctrl+C buat berhentiin.
Langkah 3: Tambah Tool Pertama
Kita tambah tool sederhana yang balikin salam. Decorator @mcp.tool() ngubah fungsi async jadi MCP tool. Type hints dan docstring fungsi jadi schema tool-nya:
from mcp.server import MCPServer
mcp = MCPServer("tasks")
@mcp.tool()
async def hello(name: str) -> str:
"""Say hello to someone.
Args:
name: The person's name
"""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run(transport="stdio")
Test pake MCP Inspector:
mcp dev server.py
Ini bakal ngebuka web UI di http://localhost:5173. Klik "Connect", terus coba panggil tool hello dengan {"name": "Alice"}. Harusnya dapet "Hello, Alice!".
MCP SDK pake type hints Python buat generate JSON Schema tiap tool. Docstring jadi description. Bagian Args: ngisi parameter descriptions. Nggak perlu boilerplate schema.
Langkah 4: Tool Beneran - Task Manager pake SQLite
Tool salam doang nggak berguna. Yuk bikin task manager pake SQLite. Ini nunjukin tools dengan pola parameter yang beda-beda dan akses database.
Setup Database
Tambahin fungsi helper SQLite di bagian atas server.py:
import sqlite3
from pathlib import Path
from typing import Any
from mcp.server import MCPServer
DB_PATH = Path.home() / ".mcp-tasks.db"
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'done')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
""")
init_db()
mcp = MCPServer("tasks")
Tool: Create Task
@mcp.tool()
async def create_task(title: str, description: str = "") -> str:
"""Create a new task.
Args:
title: Task title
description: Optional task description
"""
with get_db() as conn:
cursor = conn.execute(
"INSERT INTO tasks (title, description) VALUES (?, ?)",
(title, description),
)
return f"Task created with ID {cursor.lastrowid}"
Tool: List Semua Task
@mcp.tool()
async def list_tasks(status: str = "") -> str:
"""List all tasks, optionally filtered by status.
Args:
status: Filter by status - 'pending', 'done', or empty for all
"""
with get_db() as conn:
if status:
rows = conn.execute(
"SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC",
(status,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM tasks ORDER BY created_at DESC"
).fetchall()
if not rows:
return "No tasks found."
result = []
for row in rows:
result.append(
f"[{row['id']}] {row['title']} ({row['status']})\n"
f" {row['description'] or 'No description'}"
)
return "\n\n".join(result)
Tool: Tandai Selesai
@mcp.tool()
async def complete_task(task_id: int) -> str:
"""Mark a task as completed.
Args:
task_id: ID of the task to complete
"""
with get_db() as conn:
cursor = conn.execute(
"UPDATE tasks SET status = 'done', updated_at = datetime('now') WHERE id = ?",
(task_id,),
)
if cursor.rowcount == 0:
return f"No task found with ID {task_id}"
return f"Task {task_id} marked as done."
Tool: Cari Task
@mcp.tool()
async def search_tasks(query: str) -> str:
"""Search tasks by title or description.
Args:
query: Search keyword
"""
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM tasks WHERE title LIKE ? OR description LIKE ? ORDER BY created_at DESC",
(f"%{query}%", f"%{query}%"),
).fetchall()
if not rows:
return f"No tasks matching '{query}'."
result = []
for row in rows:
result.append(f"[{row['id']}] {row['title']} ({row['status']})")
return "\n".join(result)
Server Lengkap
server.py lengkap:
import sqlite3
from pathlib import Path
from typing import Any
from mcp.server import MCPServer
DB_PATH = Path.home() / ".mcp-tasks.db"
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'done')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
""")
init_db()
mcp = MCPServer("tasks")
@mcp.tool()
async def create_task(title: str, description: str = "") -> str:
"""Create a new task.
Args:
title: Task title
description: Optional task description
"""
with get_db() as conn:
cursor = conn.execute(
"INSERT INTO tasks (title, description) VALUES (?, ?)",
(title, description),
)
return f"Task created with ID {cursor.lastrowid}"
@mcp.tool()
async def list_tasks(status: str = "") -> str:
"""List all tasks, optionally filtered by status.
Args:
status: Filter by status - 'pending', 'done', or empty for all
"""
with get_db() as conn:
if status:
rows = conn.execute(
"SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC",
(status,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM tasks ORDER BY created_at DESC"
).fetchall()
if not rows:
return "No tasks found."
result = []
for row in rows:
result.append(
f"[{row['id']}] {row['title']} ({row['status']})\n"
f" {row['description'] or 'No description'}"
)
return "\n\n".join(result)
@mcp.tool()
async def complete_task(task_id: int) -> str:
"""Mark a task as completed.
Args:
task_id: ID of the task to complete
"""
with get_db() as conn:
cursor = conn.execute(
"UPDATE tasks SET status = 'done', updated_at = datetime('now') WHERE id = ?",
(task_id,),
)
if cursor.rowcount == 0:
return f"No task found with ID {task_id}"
return f"Task {task_id} marked as done."
@mcp.tool()
async def search_tasks(query: str) -> str:
"""Search tasks by title or description.
Args:
query: Search keyword
"""
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM tasks WHERE title LIKE ? OR description LIKE ? ORDER BY created_at DESC",
(f"%{query}%", f"%{query}%"),
).fetchall()
if not rows:
return f"No tasks matching '{query}'."
result = []
for row in rows:
result.append(f"[{row['id']}] {row['title']} ({row['status']})")
return "\n".join(result)
if __name__ == "__main__":
mcp.run(transport="stdio")
Langkah 5: Test pake MCP Inspector
Jalanin inspector:
mcp dev server.py
Buka http://localhost:5173 di browser. Inspector connect otomatis ke server kamu. Coba panggil:
create_taskdengan{"title": "Beli sembako", "description": "Beras, telur, minyak"}create_taskdengan{"title": "Nulis blog post", "description": "Tutorial MCP"}list_tasksdengan{}search_tasksdengan{"query": "blog"}complete_taskdengan{"task_id": 2}
Inspector nunjukin setiap request dan response, termasuk raw JSON-RPC messages. Ini cara terbaik buat debug server pas development.
Langkah 6: Sambungin ke Claude Desktop
Claude Desktop bisa jalanin MCP server lokal. Tambahin server kamu ke file config-nya.
Di macOS, config-nya di ~/Library/Application Support/Claude/claude_desktop_config.json. Buat kalo belum ada:
{
"mcpServers": {
"tasks": {
"command": "uv",
"args": [
"--directory",
"/ABSOLUTE/PATH/TO/mcp-tasks-server",
"run",
"server.py"
]
}
}
}
Ganti /ABSOLUTE/PATH/TO/mcp-tasks-server dengan path yang sesuai.
Di Windows, ada di %APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"tasks": {
"command": "uv",
"args": [
"--directory",
"C:\\ABSOLUTE\\PATH\\TO\\mcp-tasks-server",
"run",
"server.py"
]
}
}
}
Simpan file dan restart Claude Desktop (Cmd+Q di Mac, atau klik kanan system tray > Quit di Windows).
Kamu bakal liat icon hammer dengan tools "Tasks" yang tersedia. Coba bilang ke Claude: "Buat task buat riset MCP server" - dia bakal manggil tool kamu.
Langkah 7: Tambah Resources
Tools buat aksi. Resources buat data. Yuk tambah resource yang ngekspos statistik database:
@mcp.resource("tasks://stats")
async def get_stats() -> str:
"""Database statistics"""
with get_db() as conn:
total = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
pending = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE status='pending'"
).fetchone()[0]
done = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE status='done'"
).fetchone()[0]
return f"Total: {total}, Pending: {pending}, Done: {done}"
Resources diakses lewat URI kayak tasks://stats. Client narik datanya pake resources/read.
Cara Kerja Transport
MCP server pake salah satu dari dua mekanisme transport:
STDIO (lokal) - Client menjalankan server sebagai subprocess dan komunikasi lewat stdin/stdout. Ini yang kita pake di atas. Cepet, simpel, nggak perlu config network.
Streamable HTTP (remote) - Server jalan sebagai HTTP server. Client connect lewat HTTP POST dengan opsi SSE buat streaming. Ini cara kerja MCP server remote kayak punya Sentry.
Buat development lokal, STDIO pilihan tepat. Buat production, kamu mau HTTP biar banyak client bisa connect dan server jalan independen.
Kapan Pake MCP vs Alternatif
| Tool | Cocok Buat | Kurang Cocok Buat |
|---|---|---|
| MCP | Tool akses terstandarisasi buat AI agent; multi-client (Claude Desktop, VS Code, Cursor, dll) | Workflow orchestration kompleks; agent logic stateful multi-step |
| OpenAI Function Calling | Skenario single-provider; definisi tool simpel lewat API | Provider lock-in; nggak ada ekosistem server standar |
| LangChain Tools | Agent yang terintegrasi framework (pengguna LangChain/LangGraph) | Ketergantungan ke ekosistem LangChain; overhead buat use case simpel |
| Custom HTTP API | Kontrol penuh; konsumen non-AI juga | Tool discovery harus bikin sendiri; nggak ada ekosistem client |
MCP unggul kalo kamu mau bikin tool sekali dan pake di banyak AI client. Tulis satu server, pake dari Claude Desktop, VS Code, Cursor, atau klien kustom mana aja.
Kesalahan Umum
Print ke stdout. Server STDIO pake stdout buat JSON-RPC. Jangan pake print(). Pake logging (nulis ke stderr):
import logging
logger = logging.getLogger(__name__)
logger.info("Server started") # aman, ke stderr
Lupa await di async tools. MCP SDK expect fungsi async. Lupa await bakal balikin coroutine object, bukan hasil.
Nggak handle error. Bungkus kode database pake try/except. Return string error yang jelas. Server yang crash mutusin koneksi.
Hardcoding path. Pake pathlib.Path.home() atau environment variable buat path yang bisa dikonfigurasi. Server kamu harus jalan di mesin mana aja.
Terlalu rumit di tool pertama. Mulai dengan satu tool. Bikin jalan. Baru tambah yang lain. Jangan bikin server 10 tool sebelum ngetes tool pertama end-to-end.
Langkah Selanjutnya
Server kamu sudah jalan. Apa berikutnya?
- Tambah prompts - template reusable yang ngasih tahu LLM cara pake tools kamu
- Coba Streamable HTTP transport biar server kamu bisa diakses lewat network
- Cek MCP Inspector buat debugging lebih dalem
- Baca MCP specification buat detail protokol lengkap
- Lihat example servers di situs MCP resmi buat pola filesystem, database, dan API