← Kembali ke Blog

Bikin MCP Server Lokal Pakai Python dan Sambungin ke Claude Code

Kebanyakan agent mentok begitu harus pegang data beneran. Mereka bisa nulis kode, tapi baca file yang lagi kamu buka itu masalah lain. Sampai baru-baru ini setiap IDE dan setiap coding agent punya format plugin sendiri-sendiri. Pindah tool artinya tulis ulang integrasi yang sama.

Model Context Protocol (MCP) itu upaya buat nutup celah itu. Anthropic open-source di akhir 2024, spec-nya sekarang di-maintain di modelcontextprotocol.io, dan SDK Python, TypeScript, Go, Rust, sama Java semua ngomong wire format yang sama. Model mentalnya: server JSON-RPC (MCP server) yang di-spawn sama aplikasi host (Claude Code, Cursor, Zed, Continue), terus ngobrol lewat stdio atau HTTP. Kamu nulis tool, agent bisa manggil.

Panduan ini bikin server beneran yang jalan. Di akhir kamu punya proses Python yang expose filesystem reader sama git status tool, dan udah disambungin ke Claude Code biar agent bisa panggil. Totalnya sekitar 60 baris Python.

Prasyarat

  • Python 3.10+ (SDK butuh minimal segini; cek pakai python3 --version)
  • uv buat dependency management (pip install uv kalau belum ada)
  • Claude Code (npm install -g @anthropic-ai/claude-code) atau Cursor 0.42+ yang udah support MCP
  • Opsional: Node 20+ kalau mau pake MCP Inspector buat debug

Step 1: Siapin project

uv init mcp-fs-server
cd mcp-fs-server
uv add "mcp[cli]"

Extra [cli] itu narik command mcp, yang dipanggil sama Inspector dan Claude Code di belakang layar. Install-nya landing di virtualenv lokal project; nggak nyentuh system Python.

Step 2: Tulis server minimal

Buat server.py:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("fs-tools")

@mcp.tool()
def read_file(path: str, max_lines: int = 200) -> str:
    """Read a text file and return its contents.

    Args:
        path: Absolute path to the file.
        max_lines: Cap on lines returned (default 200).
    """
    with open(path) as f:
        lines = f.readlines()
    return "".join(lines[:max_lines])

@mcp.tool()
def list_dir(path: str) -> list[str]:
    """List immediate children of a directory.

    Args:
        path: Absolute path to the directory.
    """
    import os
    return sorted(os.listdir(path))

if __name__ == "__main__":
    mcp.run()  # stdio by default

Itu server-nya. FastMCP baca type hint sama docstring, terus diubah jadi JSON Schema yang dilihat agent. Nama fungsi jadi nama tool. path: str jadi {type: "string"}. Blok Args di docstring jadi deskripsi parameter. Kamu nggak nulis satu baris schema pun secara manual.

Transport default-nya stdio, yang diharapkan Claude Code dan Cursor waktu mereka spawn server sebagai subprocess. SDK juga nyediain streamable-http dan sse kalau kamu mau expose server lewat network.

Step 3: Test pakai MCP Inspector

Sebelum dipasang ke host beneran, pastiin server-nya ngomong MCP dengan bener. Inspector itu tool debug resmi.

Di satu terminal:

uv run server.py

Di terminal lain:

npx -y @modelcontextprotocol/inspector

UI Inspector bakal nanya cara konek. Pilih "stdio" dan arahin ke command uv run server.py yang sama. Harus muncul dua tool: read_file dan list_dir. Klik list_dir, isi /tmp, pastiin dia balikin entry direktori yang asli. Kalau response JSON-nya balik dengan benar, server kamu sehat.

Kalau Inspector nggak bisa konek, biang kerok paling umum adalah PATH uv. Inspector spawn command di shell baru. Jalanin which uv dulu dan copy path lengkapnya ke field command kalau uv belum ada di PATH default.

Step 4: Tambahin tool git

Filesystem reader doang kurang greget. Alasan kamu mau agent beneran ngomong sama project beneran ya biar bisa jawab pertanyaan kayak "apa yang lagi ada di branch ini." Tambahin tool git status:

import subprocess

@mcp.tool()
def git_status(repo: str) -> str:
    """Get a short git status for a local repository.

    Args:
        repo: Absolute path to the repo working directory.
    """
    result = subprocess.run(
        ["git", "-C", repo, "status", "--short", "--branch"],
        capture_output=True,
        text=True,
        check=True,
    )
    return result.stdout

Dua hal yang perlu diperhatiin. Tool-nya jalanin git sebagai subprocess dan pipe output-nya balik. Nggak ada LLM di dalam loop tool itu sendiri. MCP tool itu fungsi biasa, dan itu fitur, bukan keterbatasan. Kerja berat harus tetap di sisi deterministic.

Yang kedua: path handling. MCP tool nerima JSON, artinya semuanya string. os.path.expanduser, pathlib.Path.resolve, sama validasi eksplisit jadi makin penting dibanding fungsi Python biasa. Inspector bakal dengan senang hati kirim "~/" dan OS bakal dengan senang hati expand itu buat kamu. Kalau kamu nggak mau itu, kamu harus nolak input-nya.

Step 5: Sambungin ke Claude Code

Sekarang bagian serunya. Tambahin server-nya ke Claude Code:

claude mcp add fs-tools -- uv --directory $(pwd) run server.py

Itu nulis blok ke ~/.claude.json (atau .mcp.json scope project) yang ngasih tau cara launch server-nya. Restart sesi Claude Code. Tool-nya harus muncul di tool picker, namanya read_file, list_dir, sama git_status.

Coba:

> Cek status project aku di /home/me/code/foo pakai fs-tools.

Claude harusnya manggil git_status, dapet porcelain status, terus ringkesin buat kamu. Kalau nggak, ketik /mcp di dalam sesi buat liat server-nya konek apa nggak. Titik merah artinya spawn gagal. Hal pertama yang dicek: command launch-nya. claude mcp get fs-tools nunjukin apa yang sebenernya dijalanin Claude Code.

Step 6: Batasi path yang boleh diakses server

Server yang bisa baca file mana aja sih oke buat development lokal, tapi ngeri buat apa pun yang dishare. Solusinya allowlist path yang dipasang di dalam tool, bukan di host:

from pathlib import Path

ALLOWED_ROOTS = [Path.home() / "code"]

def _safe(path: str) -> Path:
    resolved = Path(path).expanduser().resolve()
    if not any(resolved.is_relative_to(root) for root in ALLOWED_ROOTS):
        raise ValueError(f"path {resolved} is outside allowed roots")
    return resolved

@mcp.tool()
def read_file(path: str, max_lines: int = 200) -> str:
    """Read a text file under an allowed root.

    Args:
        path: Absolute path; must live under $HOME/code.
        max_lines: Cap on lines returned.
    """
    p = _safe(path)
    with p.open() as f:
        lines = f.readlines()
    return "".join(lines[:max_lines])

Path.is_relative_to masuk di Python 3.9, dan resolve() ngelipat symlink, persis yang kamu butuhin buat security check. Begitu kamu punya pola allowlist, copy buat tiap tool baru. Pelajarannya general: tool yang "cuma fungsi" tetap butuh validasi input yang sama kayak web endpoint, karena caller-nya LLM yang bakal pasti nyoba /etc/passwd.

Kapan pake MCP vs alternatif

Pake MCP kalau:

  • Capability yang sama (database access, file system, internal API) harus bisa dicapai dari banyak host (Claude Code, Cursor, Zed, agent custom) tanpa nulis ulang.
  • Kamu mau agent loop sama implementasi tool-nya tinggal di proses yang terpisah. Transport stdio MCP ngasih itu secara cuma-cuma.
  • Kamu build buat tim. Satu MCP server, tiap developer hook sekali.

Skip MCP kalau:

  • Tool-nya cuma perlu ada di dalam satu agent framework. Kalau kamu udah dalem-dalem di LangGraph dan tool-nya nggak bakal keluar dari graph itu, fungsi Python biasa lebih simpel.
  • Data-nya udah di-expose lewat HTTP dengan OpenAPI spec yang rapi. Agent framework biasanya bisa konsumsi itu langsung. Tambahin MCP di depan REST API kebanyakan cuma nambahin satu moving part.
  • Latency kritis. Tiap tool call minimal satu JSON-RPC round trip plus satu model turn. Buat inner loop yang ketat, tool-nya harus satu proses.

Gotcha yang sering kejadian

  1. Transport default-nya stdio. Kalau kamu start server manual dan lihat output nyetak ke stdout, output itu ngebrek JSON-RPC stream. print debugging dari dalam tool masuk ke log host, bukan punya kamu. Pake logging dengan file handler, atau pake Inspector.
  2. Nama tool yang ada titiknya bakal di-rename. SDK pake nama fungsi sebagai nama tool dan ganti . jadi __. Pilih nama fungsi yang polos.
  3. Docstring itu penting. Kalimat pertama jadi deskripsi tool yang dilihat agent. Deskripsi vague => pemakaian vague. Yang spesifik ("Read a text file and return its contents") bakal dipanggil buat hal yang tepat.
  4. Proses server dimiliki host. Waktu Claude Code exit, server kamu mati. Itu oke buat dev, tapi kalau kamu mau server hidup lebih lama dari satu sesi host, pindah ke transport streamable-http dan jalanin sebagai long-lived service.
  5. @mcp.resource dan @mcp.prompt ada di samping @mcp.tool. Resources itu data read-only yang bisa di-pull host ke context (kayak config file). Prompts itu template message yang bisa di-trigger ulang. Kamu nggak butuh itu buat ship server yang jalan, tapi mereka primitive yang tepat waktu kamu udah kelewat dari tool biasa.

Lanjut ke mana

  • Tambahin structured logging. SDK route panggilan logging ke MCP logger host, jadi satu panggilan logging.info di dalam tool jadi span di trace view Claude Code.
  • Tukar stdio sama Streamable HTTP begitu kamu punya banyak host di mesin berbeda. Perubahannya satu argumen di mcp.run(transport="streamable-http", host="0.0.0.0", port=8000).
  • Tulis test pytest. SDK nyediain in-memory transport, jadi kamu bisa test tool tanpa spawn proses.
  • Liat example server resmi di github.com/modelcontextprotocol/servers. Filesystem, GitHub, Postgres, sama Puppeteer itu semua reference implementation dengan bentuk yang sama kayak yang baru kamu build.

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis