iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

Day 8|手刻一個 MCP server

https://ithelp.ithome.com.tw/upload/images/20260824/20183634ApEL6EEFUm.png

今天寫一個能跑的 MCP server,然後真的掛到 Claude Code 上讓它呼叫。

底下每一段程式碼我都跑過,最後那段對話輸出是實際跑出來的,不是我編的。


完整的 server

Python 標準函式庫,零依賴。九十行,其中一半是資料和說明文字。

#!/usr/bin/env python3
"""最小 MCP server:stdin/stdout 上的 JSON-RPC 2.0。"""
import json, sys

TASKS = [
    {"id": 1, "title": "修好排程的鎖檔漂移", "status": "doing"},
    {"id": 2, "title": "把工具 schema 砍一半", "status": "todo"},
    {"id": 3, "title": "寫 Day 8 的文章", "status": "done"},
]

TOOLS = [{
    "name": "task_list",
    "description": (
        "列出待辦任務。當使用者問「有什麼要做的」、「進度如何」,"
        "或你需要知道目前有哪些工作在進行時使用。"
    ),
    "inputSchema": {
        "type": "object",
        "properties": {
            "status": {
                "type": "string",
                "enum": ["todo", "doing", "done"],
                "description": "只列出這個狀態的任務,省略則列出全部",
            }
        },
        "required": [],
    },
}]

def log(msg: str) -> None:
    print(msg, file=sys.stderr, flush=True)      # 日誌一律走 stderr

def call_tool(name: str, args: dict) -> dict:
    if name != "task_list":
        return {"content": [{"type": "text", "text": f"未知工具:{name}"}],
                "isError": True}
    status = args.get("status")
    rows = [t for t in TASKS if status is None or t["status"] == status]
    text = ("沒有符合條件的任務。" if not rows else
            "\n".join(f"[{t['status']}] #{t['id']} {t['title']}" for t in rows))
    return {"content": [{"type": "text", "text": text}], "isError": False}

def handle(req: dict) -> dict | None:
    method, rid = req.get("method"), req.get("id")

    if method == "initialize":
        result = {
            "protocolVersion": "2024-11-05",
            "capabilities": {"tools": {}},
            "serverInfo": {"name": "demo-tasks", "version": "0.1.0"},
        }
    elif method == "tools/list":
        result = {"tools": TOOLS}
    elif method == "tools/call":
        p = req.get("params", {})
        result = call_tool(p.get("name", ""), p.get("arguments") or {})
    elif method and method.startswith("notifications/"):
        return None                     # 通知沒有 id,不能回覆
    else:
        return {"jsonrpc": "2.0", "id": rid,
                "error": {"code": -32601, "message": f"Method not found: {method}"}}

    return {"jsonrpc": "2.0", "id": rid, "result": result}

def main() -> None:
    log("demo-tasks MCP server 啟動")
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            req = json.loads(line)
        except json.JSONDecodeError as e:
            log(f"解析失敗:{e}")
            continue
        resp = handle(req)
        if resp is not None:
            print(json.dumps(resp, ensure_ascii=False), flush=True)

if __name__ == "__main__":
    main()

先用管線測,不要一開始就掛上去

在把它接到 Claude Code 之前,先確認協定層是對的。這一步能省下大量除錯時間,因為透過客戶端測的時候,錯誤訊息通常只有一句「server 沒有回應」。

printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"task_list","arguments":{"status":"todo"}}}' \
| python3 server.py 2>/dev/null

我實際跑出來的三行回應(節錄):

id 1 -> {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, ...}
id 2 -> {"tools": [{"name": "task_list", "description": "列出待辦任務。當使用者問…
id 3 -> {"content": [{"type": "text", "text": "[todo] #2 把工具 schema 砍一半"}], "isError": false}

三個都對,可以掛上去了。

https://ithelp.ithome.com.tw/upload/images/20260824/20183634HQMcCrTmS0.png

整個協定要能動起來只有這三次往返。右邊那條指向旁邊的箭頭是日誌,一定要走 stderr,寫進 stdout 會把協定弄壞。

掛到 Claude Code

寫一份設定檔:

{
  "mcpServers": {
    "demo-tasks": {
      "command": "python3",
      "args": ["/絕對路徑/server.py"]
    }
  }
}

路徑要用絕對路徑。相對路徑會相對於客戶端的工作目錄,而那個目錄未必是你以為的那個。

然後呼叫:

claude -p "現在有哪些還沒開始做的任務?" \
  --mcp-config ./mcp.json \
  --strict-mcp-config \
  --tools "" \
  --allowedTools "mcp__demo-tasks__task_list" \
  --setting-sources project,local \
  < /dev/null

實際輸出:

還沒開始的任務只有一項:

- **#2** 把工具 schema 砍一半

需要我看一下 doing / done 的狀態,或著手處理 #2 嗎?

它自己判斷「還沒開始做」對應到 status: "todo",呼叫了工具,然後把結果講成人話。整個過程我沒有告訴它有這個工具,也沒有教它參數怎麼填,那份 schema 就是它全部的資訊來源。

注意 --tools "" 那行:內建工具全部關掉,這個 agent 只有我給它的那一個 MCP 工具。固定開銷因此極低,而它照樣完成了任務。這就是昨天講的「只帶那顆燈泡進門」。

工具名稱的格式是 mcp__<server名稱>__<工具名稱>,兩個底線。寫錯的話 --allowedTools 會靜默失效,工具還是能被呼叫(因為沒有匹配到任何限制規則),你會以為權限有生效,其實沒有。

四個我踩過的坑

一、stdout 是協定專用的。

我第一版在 call_tool 裡加了一行 print(f"呼叫 {name}") 除錯。整個 server 當場壞掉,客戶端說解析失敗。因為那行字被寫進了 stdout,混在 JSON-RPC 訊息流裡。

所有日誌走 stderr,沒有例外。如果你的 server 有用到任何會印東西的第三方套件,要特別檢查它印去哪裡。

二、通知類訊息不能回覆。

客戶端會送 notifications/initialized 這類訊息,它們沒有 id 欄位。如果你照常回一個 {"id": null, "result": ...},有些客戶端會當成協定違規。

處理方式就是上面那行:method 以 notifications/ 開頭就直接 return None。

三、headless 模式的 stdin 陷阱。

我第一次跑的時候看到這行警告:

Warning: no stdin data received in 3s, proceeding without it.

claude -p 會等 stdin 三秒,看你是不是要從管線餵資料進去。在腳本裡這三秒是純浪費,而且如果你的腳本剛好在某個會 hang 住 stdin 的環境跑,它會一直等。

< /dev/null 明確告訴它沒有輸入。自動化腳本一律加。

四、錯誤要走 isError,不要走 JSON-RPC error。

# ✗ 協定層錯誤:客戶端可能直接中斷,模型看不到發生什麼事
return {"jsonrpc": "2.0", "id": rid,
        "error": {"code": -32000, "message": "檔案不存在"}}

# ✓ 工具層錯誤:模型看得到,可以自己修正參數重試
return {"content": [{"type": "text",
        "text": "找不到檔案 config.toml,請確認路徑"}], "isError": True}

第二種寫法的價值在於,模型收到之後常常會自己修正。我看過它拿到「找不到 config.toml」之後,改去呼叫另一個工具查目錄,找到正確路徑再試一次。這種自我修正只有在錯誤訊息進得到模型的視野裡才會發生。

加一個會寫入的工具

讀取工具很安全,寫入工具要多想一層。加上 task_update

TOOLS.append({
    "name": "task_update",
    "description": "更新任務狀態。只能改 status,不能改標題。",
    "inputSchema": {
        "type": "object",
        "properties": {
            "id": {"type": "integer", "description": "任務編號"},
            "status": {"type": "string", "enum": ["todo", "doing", "done"]},
        },
        "required": ["id", "status"],
    },
})

def task_update(args: dict) -> dict:
    tid, status = args.get("id"), args.get("status")

    # 邊界驗證:不要相信參數,即使 schema 說了型別
    if not isinstance(tid, int):
        return {"content": [{"type": "text", "text": "id 必須是整數"}],
                "isError": True}
    if status not in {"todo", "doing", "done"}:
        return {"content": [{"type": "text", "text": f"不合法的狀態:{status}"}],
                "isError": True}

    for t in TASKS:
        if t["id"] == tid:
            old = t["status"]
            t["status"] = status
            log(f"task {tid}: {old} -> {status}")     # 寫入操作一定要留紀錄
            return {"content": [{"type": "text",
                    "text": f"#{tid} 已從 {old} 改為 {status}"}], "isError": False}

    return {"content": [{"type": "text", "text": f"找不到任務 #{tid}"}],
            "isError": True}

兩個重點。

schema 不是驗證。 inputSchema 是給模型看的說明,不是執行期的保證。模型可能傳字串 "2" 而不是整數 2,也可能傳一個不在 enum 裡的值。所有參數在你的程式裡都要重新驗證一次。這是邊界驗證的基本功,MCP 工具就是你系統的邊界。

寫入操作要留紀錄。 那行 log 看起來可有可無,等到你要回答「這個狀態是誰改的、什麼時候改的」時,它就是唯一的證據。這個主題在 Day 18 會展開,因為它後來變成我整個系統裡最重要的一份檔案。

代價

這個 server 有幾個地方是為了篇幅簡化的,實際用要補上。

沒有並發保護。 兩個 agent 同時呼叫 task_update,資料會亂。實務上要嘛用資料庫,要嘛在寫檔時加檔案鎖。

狀態在記憶體。 server 重啟就回到初始值。真的要用得寫進 SQLite 或檔案。

沒有身分概念。 誰呼叫的?他有權限改這筆任務嗎?這個 server 完全不知道。單人用沒差,多 agent 環境下這是個大洞,Day 26 會講怎麼補。

還有一件事值得先想:這個 server 現在有兩個工具,schema 大概 300 個 token。加到二十個工具就是三千,加到兩百個就是三萬,而那是每次呼叫都要付的。

明天講這件事:宣告一個工具的成本,以及為什麼「隱藏工具」跟「禁用工具」是兩回事。


上一篇
MCP 到底解決什麼問題
下一篇
tools/list 是宣告面,不是權限面
系列文
Claude Code 下班之後:30 天把 CLI 工具養成會自己交差的 AI 員工12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言