返回部落格

用 Flask 自建 MCP 適配層:工具宣告、路由與除錯

現成的 MCP Server 多半只提供原語,業務動作仍要由 Agent 每次重新組合並自行判斷失敗處理。用 Flask 做一層輕量適配層,可以集中管理工具宣告、請求路由、執行結果、驗證與錯誤語意。

把瀏覽器環境接進 AI Agent,有一件事繞不開:標準工具提供的是原語,業務動作還是得自己組合。

舉個具體例子。要跑通一次採集,實際流程是「依地區開一個環境、綁好出口、先造訪一次做預熱、確認出口可用」,然後才交給 Agent 工作。現成的 MCP Server 通常只提供開環境、導覽、點擊、截圖等單步工具,上述那串組合邏輯沒有人替你完成。Agent 每次都得從零安排一遍,不但慢,而且中間任何一步失敗都得自己判斷後續處理。

適配層就是用來處理這件事:把組合邏輯、驗證和狀態留在自己這邊,對外只暴露一個業務動作。

最小可用結構

一個能跑起來的適配層只需要三塊:工具宣告、請求路由、執行與回傳。用 Flask 的話,一個行程就能裝下。

MCP 适配层把工具声明、JSON-RPC 路由、执行器和结构化结果串成可重试的业务流程

先看工具宣告,它決定 Agent 能看見什麼。

# adapter/tools.py
TOOLS = [
    {
        "name": "prepare_environment",
        "description": "依地區準備一個可用環境,完成後回傳環境 ID",
        "inputSchema": {
            "type": "object",
            "properties": {
                "region": {"type": "string", "description": "出口地區,例如 US-CA"},
                "purpose": {"type": "string", "description": "用途標籤,用於重複使用與配額統計"},
                "timeout": {"type": "integer", "minimum": 10, "maximum": 120, "default": 60},
            },
            "required": ["region"],
            "additionalProperties": False,
        },
    },
    {
        "name": "open_page",
        "description": "在指定環境裡開啟頁面並等待到可互動",
        "inputSchema": {
            "type": "object",
            "properties": {
                "env_id": {"type": "string"},
                "url": {"type": "string"},
            },
            "required": ["env_id", "url"],
            "additionalProperties": False,
        },
    },
]

路由負責分派 JSON-RPC 的方法。用戶端會先詢問 tools/list,再依名稱呼叫 tools/call。

# adapter/app.py
from flask import Flask, request, jsonify
from adapter.tools import TOOLS
from adapter.runner import run_tool

app = Flask(__name__)

@app.post("/mcp")
def mcp():
    req = request.get_json(force=True)
    method, rid = req.get("method"), req.get("id")

    if method == "tools/list":
        return jsonify({"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}})

    if method == "tools/call":
        name = req["params"]["name"]
        args = req["params"].get("arguments", {})
        return jsonify({"jsonrpc": "2.0", "id": rid, "result": run_tool(name, args)})

    return jsonify({"jsonrpc": "2.0", "id": rid,
                    "error": {"code": -32601, "message": "method not found"}})

執行層是唯一碰環境 API 和頁面協定的地方,多步驟串接在這裡完成,失敗也在這裡轉成統一格式。

# adapter/runner.py
def run_tool(name, args):
    try:
        payload = HANDLERS[name](**args)
        return ok(payload)
    except ToolError as e:
        return fail(e.code, e.retryable, e.message)

回傳格式值得提早定下來。不要把底層例外原文丟給 Agent,它會嘗試解析那些字串,然後可能做出莫名其妙的決定。可以約定一個帶結果碼的結構,例如 {"ok": false, "code": "env_unavailable", "retryable": true, "attempts": 3}。這樣 Agent 只需要判斷兩件事:能不能重試,以及要不要交給人處理。

參數怎麼設計

工具粒度要依業務動作切,不要依 API 切。把底層每個 endpoint 都包成一個工具,等於沒有真正封裝,Agent 還是得自己排序。

參數有一條清楚的分界線:哪些該由模型決定,哪些該由適配層自己決定。地區、用途、目標 URL 屬於前者,交給模型填;除錯連接埠、內部佇列名稱、從哪個環境池取資源屬於後者,不要放進 schema,否則早晚會被模型填錯。

Selector 很容易踩坑。頁面結構一變,把 selector 寫死的呼叫就可能成片失效。可以讓模型傳語意化目標,例如「登入按鈕」這類相對穩定的識別,把 selector 對應關係留在適配層維護,改一處就夠。

數值參數一定要有上限。如果 timeout、重試次數、翻頁數量在 schema 裡沒有 maximum,模型可能傳一個很大的值,讓一次呼叫拖成十幾分鐘。凡是帶迴圈語意的工具都要有明確終點,用 max_pages 這類欄位收口,而不是「翻到沒有資料為止」。

冪等也要考慮。讓呼叫端帶一個 task_id,重複請求可以直接回傳上一次結果,避免 Agent 重試時把環境開兩遍。

回傳值盡量小。截圖不要回 base64,回一個檔案參照;列表結果帶 count 和截斷標記,不要把整張表塞進 context。

除錯時先盯這三處

如果走 stdio,JSON-RPC 會占用 stdout,所以日誌絕對不能寫到 stdout。隨手一行 print 就可能讓協定解析當場崩掉,接著排查半天。日誌統一送到 stderr 或檔案。

MCP 适配层按标准输出分流、工具列表、逐步追踪和固定夹具的顺序排查问题

tools/list 是第一個檢查點。宣告沒有被讀到,後面的呼叫都不會發生。先確認工具名稱拼字、schema 結構,以及 additionalProperties 是否把參數擋掉。

第二個檢查點是逐步 trace。每次呼叫都記下 task_id、參數摘要、耗時和結果碼,出問題時就能看出是卡在開環境、導覽還是驗證。失敗案例要保留原始參數,才能原樣重播——能重現的 bug 才修得快。

第三個檢查點是一組固定 fixtures:一個內容穩定的測試頁,配上幾個不會變的元素定位。每次改完適配層跑一次 smoke test,比任何口頭驗證都省事。

權限邊界

適配層是整個系統裡權限最集中的地方,憑證、環境、頁面操作都在它手上,所以邊界只能在這裡劃。

憑證放在伺服器端設定裡,不進工具參數,也不進模型 context。工具依風險程度分開關:截圖、取文字這類唯讀工具預設開啟;點擊、提交、刪除這類寫入工具預設關閉,按任務暫時開啟。這樣就算模型判斷失誤,損失也有上限。

環境依用途隔離。不同帳號、不同任務使用不同環境,不要混用同一套環境。在多帳號管理情境中,環境建立、網路出口綁定和狀態維護可以交給專門的環境層能力。PurpleMark 這類工具就是把這一層隔開,適配層只負責業務組合與驗證。

稽核日誌要保留下來。哪個任務、什麼時間、用了哪個環境、呼叫了哪些寫入工具,都應該能依時間匯出。真的發生問題時,這份紀錄是唯一能還原過程的東西。

最後是底線:適配層不該提供任何繞過平台規則的入口。大量註冊、繞過驗證、偽造身分這類動作,不要包裝成工具,也不要掛在內部工具的名義下。工具一旦暴露給模型,呼叫就可能自動發生,事前沒有可靠的攔截點,事後也無法把影響補回來。

協定版本與欄位定義請以 MCP 官方文件為準。