เป้าหมาย
เขียน MCP server ภาษา Python ที่ห่อระบบภายใน (เช่น ระบบสัญญาเช่าซื้อ) ต่อเข้า Claude Code ทดสอบด้วย Inspector และรู้ทางไปสู่ server ระยะไกล
หลักออกแบบ tool ที่ดี
| หลัก | ทำไม |
|---|---|
| ชื่อ tool + docstring ชัด | docstring และ type hint กลายเป็น description/schema ที่ Claude อ่าน |
| เริ่มจาก tool อ่าน | tool เขียน/ลบ เพิ่มทีหลังพร้อมคนยืนยัน |
| คืนข้อความสั้น มีโครงสร้าง | ประหยัด context, Claude สรุปต่อง่าย |
| error ให้บอกเหตุและวิธีแก้ | Claude จะลองใหม่หรือถามผู้ใช้ได้ถูก |
ห้าม print() ลง stdout |
stdio ใช้ stdout คุยโปรโตคอล → log ไป stderr |
| secret อ่านจาก env | ไม่ฝังใน code ไม่ส่งกลับในผลลัพธ์ |
ลองเลย 🧪
ทดลอง A — สร้างโปรเจกต์
uv init lease-mcp
cd lease-mcp
uv add "mcp[cli]"ทดลอง B — เขียน server.py (ไม่มี API จริงก็รันได้ ใช้ข้อมูลสมมติแทน)
import json
import logging
import os
import sys
import urllib.parse
import urllib.request
from mcp.server import MCPServer
logging.basicConfig(stream=sys.stderr, level=logging.INFO) # log ไป stderr เท่านั้น
mcp = MCPServer("lease-contracts")
API_URL = os.environ.get("LEASE_API_URL") # เช่น https://lease-api.internal
API_TOKEN = os.environ.get("LEASE_API_TOKEN") # token อ่านอย่างเดียว
DEMO = {
"HP-2026-0012": {"customer": "สมชาย ใจดี (สมมติ)", "status": "ปกติ", "overdue_installments": 0, "balance": 412000},
"HP-2026-0045": {"customer": "วิภา สายบุญ (สมมติ)", "status": "ค้างชำระ", "overdue_installments": 2, "balance": 238500},
}
def fetch_contract(contract_id: str) -> dict:
if not API_URL:
if contract_id not in DEMO:
raise ValueError(f"ไม่พบสัญญา {contract_id} (โหมดสาธิต มีแค่ {', '.join(DEMO)})")
return DEMO[contract_id]
req = urllib.request.Request(
f"{API_URL}/contracts/{urllib.parse.quote(contract_id)}",
headers={"Authorization": f"Bearer {API_TOKEN}"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.load(resp)
@mcp.tool()
def get_contract_status(contract_id: str) -> str:
"""ดูสถานะสัญญาเช่าซื้อ ยอดคงเหลือ และจำนวนงวดที่ค้าง (อ่านอย่างเดียว)
Args:
contract_id: เลขสัญญา รูปแบบ HP-YYYY-NNNN เช่น HP-2026-0012
"""
cid = contract_id.strip().upper()
try:
c = fetch_contract(cid)
except ValueError:
raise
except Exception as e:
logging.exception("เรียก API ไม่สำเร็จ")
raise RuntimeError(f"ระบบสัญญาไม่ตอบ ({type(e).__name__}) ลองใหม่ภายหลัง") from e
return (f"{cid}: {c['customer']} | สถานะ {c['status']} | "
f"ค้าง {c['overdue_installments']} งวด | คงเหลือ {c['balance']:,} บาท")
if __name__ == "__main__":
mcp.run(transport="stdio")→ error ที่ raise ออกไปจะถูกส่งกลับให้ Claude เป็นผลลัพธ์แบบ error ไม่ทำให้ server ล่ม
ทดลอง C — ทดสอบด้วย MCP Inspector ก่อนต่อจริง
npx @modelcontextprotocol/inspector uv --directory "$(pwd)" run server.py→ เปิดหน้าเว็บที่ขึ้นมา → Tools → ดูว่า schema มี contract_id และ description ภาษาไทยครบ → ลองเลข HP-2026-0045 และเลขที่ไม่มี
ทดลอง D — ต่อเข้า Claude Code แล้วใช้งาน (รันในโฟลเดอร์ lease-mcp)
claude mcp add lease-contracts -- uv --directory "$(pwd)" run server.pyสัญญา HP-2026-0045 กับ HP-2026-0012 ค้างชำระกี่งวด ร่างข้อความ LINE ทวงถามสุภาพสำหรับสัญญาที่ค้าง ยังไม่ต้องส่ง→ ต่อกับ API จริง: ใส่ -e LEASE_API_URL=... -e LEASE_API_TOKEN=... (token อ่านอย่างเดียว) ไม่ใส่ใน .mcp.json
ต่อยอด
- Resources / Prompts: SDK มี decorator แบบ
@mcp.resource("policy://late-fee")และ@mcp.prompt()สำหรับเอกสารนโยบายและเทมเพลตคำสั่ง - Server ระยะไกล (HTTP): เปลี่ยนเป็น
mcp.run(transport="streamable-http")แล้ว deploy หลัง HTTPS + การยืนยันตัวตน (OAuth/token) → ทีมต่อด้วยclaude mcp add --transport http ...หรือเป็น custom connector บน claude.ai - ก่อนเปิดให้คนนอกใช้: จำกัดสิทธิ์ตามผู้ใช้, rate limit, audit log ทุก tool call, ไม่คืนข้อมูลส่วนบุคคลเกินจำเป็น
⚠️ ต้องตรวจสอบ: SDK รุ่นใหม่ใช้
from mcp.server import MCPServerส่วนบทความเก่าใช้from mcp.server.fastmcp import FastMCPชื่อ transport, decorator resource/prompt และคำสั่ง Inspector ให้ดูที่ modelcontextprotocol.io
สรุปจำง่าย
tool อ่านก่อน docstring ชัด log ลง stderr ทดสอบใน Inspector แล้วค่อยต่อ Claude