)
如果你手頭有一臺帶串口指令的 IoT Power 功耗計又天天盯著 AI 寫代碼的能力流口水這個項目應該正合胃口。我用一個下午給功耗計寫了一個 MCPModel Context Protocol服務端把它接進了 AI 對話流——現(xiàn)在我在對話框里問“當前輸出功率多少”AI 會自己去讀串口、解析報文然后把電壓、電流、功率整理好回給我。這種“讓 AI 自己看功耗計”的體驗一旦跑通就回不去了而且它不挑客戶端Claude Desktop、Cursor、Codex 這些支持 MCP 的軟件都能直接用。這篇就按我實際踩坑的路線來寫從整體設計、MCP 協(xié)議怎么和串口設備對接到完整代碼、客戶端配置和排錯經(jīng)驗都一并攤開。適合手頭有 IoT Power 或類似串口功耗設備、想深入理解 MCP 服務端寫法、或者想給 AI Agent 接真實硬件的開發(fā)者。1. 項目全貌與關(guān)鍵設計決策1.1 為什么是 MCP而不是讓 AI 自己寫串口代碼最開始我面臨三個方案簡單對比一下就能明白為什么最終選 MCP方案優(yōu)點缺點結(jié)論讓 AI 現(xiàn)場寫一段 Python 串口讀取代碼零開發(fā)量AI 每次生成的代碼不一樣依賴也難裝硬件通信還容易踩權(quán)限坑不穩(wěn)定僅適合演示自己寫一個 REST 服務讓 AI 通過 HTTP 調(diào)用接口清晰可控要額外部署服務、處理鑒權(quán)、做進程管理本地場景過于重量可行但沒必要寫一個 MCP 服務端聲明工具給 AI 直接調(diào)客戶端原生支持一次寫好到處復用需要理解 MCP 協(xié)議和 SDK選它MCP 解決的核心問題是“模型上下文協(xié)議”它定義了一套標準化的消息格式讓 AI 應用能發(fā)現(xiàn)外部工具、調(diào)用外部工具、讀取外部資源。協(xié)議本身不關(guān)心底層設備是串口、藍牙還是 USB只負責傳輸“這個工具叫什么、參數(shù)是什么、返回什么”。所以我把功耗計封裝成幾個小工具AI 只要看到工具描述和參數(shù) schema就知道怎么調(diào)用、怎么解讀返回結(jié)果。這個取舍背后還有一個實際原因我經(jīng)常需要在不同客戶端之間切換。給 Claude Desktop 寫的服務端如果協(xié)議是私有 JSON-RPC那換到 Cursor 又得重寫一套。而 MCP 現(xiàn)在已經(jīng)成為 AI 客戶端的公共協(xié)議寫一次服務端配置 JSON 里改一行地址就能到處接。這個“一次封裝、到處復用”的價值在真正維護過三套客戶端接入后會覺得特別香。1.2 服務端架構(gòu)與工具粒度設計項目整體結(jié)構(gòu)一句話說就是AI 客戶端通過標準輸入輸出stdio啟動我的 Python 進程進程內(nèi)部維護一個串口連接收到 AI 發(fā)來的工具調(diào)用請求后翻譯成功耗計的 ASCII 指令讀完回復再打包成 MCP 格式返回。我選的工具棧是 Python FastMCP pySerial。FastMCP 是目前封裝 MCP 協(xié)議最舒服的 Python 庫幾行裝飾器就能把一個普通函數(shù)暴露成工具底層 JSON-RPC 握手全部遮掉。pySerial 則是 Python 操作串口的標準庫跨平臺Windows 下用 COM 口、Linux 下用 /dev/ttyUSB0行為一致。工具粒度是設計里最容易被忽略的地方。一開始我想按電壓、電流、功率分成三個工具讓 AI 分別調(diào)用。后來實測發(fā)現(xiàn)AI 問“當前功率”時會先調(diào) voltage 又調(diào) current來回折騰而且多次調(diào)用之間設備狀態(tài)可能有變化讀出來的數(shù)據(jù)三個時間點對不上。最終我收斂成三個核心工具read_snapshot一次讀取電壓、電流、功率三組實時數(shù)據(jù)適合大多數(shù)問答場景。read_series按照設定次數(shù)和間隔連續(xù)采樣返回一組帶時間戳的記錄適合讓 AI 做均值、波動分析。raw_query透傳任意指令給設備適合調(diào)試和覆蓋我沒預設到的功能。工具不是越細越好而是越貼合 AI 的“思考習慣”越好。如果 AI 只需要知道一個整機功耗值你卻只讓它讀某一路電流它還得自己乘電壓容易出錯。把常用動作封裝成完整語義的原子操作AI 調(diào)用一次就拿到完整答案是服務端設計里很關(guān)鍵的一點。2. MCP 協(xié)議與功耗計協(xié)議的對接原理2.1 MCP 服務端在協(xié)議棧里的位置MCP 是一個應用層軟件協(xié)議跟設備側(cè)指令集完全是兩碼事。功耗計說話用的是串口 ASCII 指令MCP 服務端是夾在 AI 和硬件之間的翻譯官。它要做兩件事向上用 MCP 協(xié)議和 AI 客戶端對話向下用設備協(xié)議和功耗計對話。MCP 有三個開發(fā)者最常用的原語tools、resources、prompts。本項目核心是 tools也就是把功耗計的能力暴露成可執(zhí)行的函數(shù)。resources 適合暴露不需要參數(shù)的數(shù)據(jù)內(nèi)容比如設備信息prompts 適合預置常用操作模板比如“幫我測一下充電器紋波”。理解 MCP 服務端時不用把這三個原語想得太玄就當成三種和 AI 交互的方式能執(zhí)行的動作是 tool能讀取的狀態(tài)是 resource能填好的對話模板是 prompt。協(xié)議傳輸層上本地這類工具服務優(yōu)先用 stdio。MCP 客戶端啟動時把我的 Python 進程作為子進程運行通過 stdin/stdout 傳遞 JSON-RPC 2.0 消息。stdio 傳輸最大的好處是免鑒權(quán)、免端口、環(huán)境隔離好——服務端不會在網(wǎng)絡里裸奔也不會被別的機器掃到端口。這也是 MCP 設計里“本地優(yōu)先”的體現(xiàn)。2.2 一次完整調(diào)用的生命周期剛開始調(diào) MCP 服務端時最困惑的是“AI 怎么知道我的工具存在”。實際上整個流程是這樣的客戶端啟動我的 Python 進程先發(fā)一個initialize請求雙方確認 MCP 版本和協(xié)議能力。客戶端發(fā)notifications/initialized通知服務端已就緒??蛻舳税l(fā)送tools/list我的服務端返回所有用 FastMCP 裝飾器注冊的工具列表包括每個工具的描述、參數(shù)類型和必需項。用戶提問后客戶端判斷需要調(diào)用哪個工具發(fā)送tools/call請求里面帶工具名和參數(shù)。我的服務端執(zhí)行函數(shù)訪問串口讀數(shù)據(jù)把結(jié)果組裝成 MCP 的 content 數(shù)組返回??蛻舳税逊祷匚谋窘唤o大模型大模型整理成自然語言回答用戶。這個生命周期里有一個容易被忽略的細節(jié)AI 判斷“該用哪個工具”依賴的是工具描述和參數(shù)名而不是你代碼里的函數(shù)名注釋。也就是說docstring 里寫什么直接影響 AI 能不能正確調(diào)用。比如我在read_snapshot的 docstring 里明確寫了“返回電壓(V)、電流(A)、功率(W)單位分別是伏特、安培、瓦特”AI 就不會把數(shù)值誤讀成其他單位。這在后面實戰(zhàn)里還會體會到重要性。2.3 串口側(cè)協(xié)議設計功耗計這邊的協(xié)議并不復雜但每個設備都不太一樣。我目前用的這臺 IoT Power 默認波特率 115200指令以 ASCII 文本行為單位典型命令長這樣*IDN?查詢設備身份信息。MEAS:VOLT?讀電壓。MEAS:CURR?讀電流。MEAS:POW?讀功率。OUTPut:STATe ON打開輸出。如果你的設備是 SCPI 風格基本能無縫對接如果是 Modbus 風格需要把讀寫 PDU 封裝一下。我這邊先按 SCPI 風格設計因為這類指令人眼可讀、調(diào)試方便也符合 MCP 工具“語義清晰”的要求。串口通信的幾個要點要提前想清楚否則后面全是坑第一指令必須以\r\n結(jié)尾很多設備對換行符敏感只發(fā)\n可能導致它一直不回包。第二每次查詢前最好清一次輸入緩沖。設備偶爾會殘留上一次的響應碎片不清緩沖會出現(xiàn)“把上次的尾巴當成這次的結(jié)果”這種詭異問題。第三串口是獨占資源MCP 工具被 AI 并發(fā)調(diào)用時必須用線程鎖保護否則兩個查詢同時寫指令響應就交叉錯亂了。第四超時處理要比想象中更嚴格。AI 客戶端等待工具返回有時間窗口如果串口沒接對或設備沒上電函數(shù)一直阻塞AI 就會認為工具無響應。所以每次 query 都要設置超時超時后拋異常讓 AI 看到明確錯誤而不是干等。3. 實操從零寫一個可運行的 MCP 服務端3.1 環(huán)境準備先把 Python 環(huán)境準備好。建議用虛擬環(huán)境避免污染系統(tǒng)環(huán)境python -m venv .venv source .venv/bin/activate pip install fastmcp pyserial如果你是 Windows激活命令是.venv\Scripts\activate如果你要使用 MCP Inspector 調(diào)試工具再裝一個官方 CLIpip install mcp硬件方面IoT Power 一般通過 USB 轉(zhuǎn) TTL 串口接電腦。連接時注意幾個引腳TXD 接設備的 RXD、RXD 接設備的 TXD、GND 接 GND。接錯 TX/RX 不會燒設備但你會發(fā)現(xiàn)“指令發(fā)出去沒反應”因為兩者在互相等待對方說話。Linux 下插入 USB 轉(zhuǎn)串口后大概率會出現(xiàn)/dev/ttyUSB0或/dev/ttyCH340Windows 下通常是 COM3 這類端口名??梢杂么谥窒仁謩影l(fā)一條*IDN?確認通信鏈路正常再進行下一步——這一步能省掉后面一半的排查時間。3.2 服務端完整代碼代碼量不多核心就一個設備類加三個工具函數(shù)。先把串口設備封裝成獨立類這樣 MCP 工具層只是薄薄一層轉(zhuǎn)發(fā)import threading import time import serial from fastmcp import FastMCP from pydantic import Field mcp FastMCP(iot-power) class PowerMeter: def __init__(self, port: str /dev/ttyUSB0, baudrate: int 115200): self.ser serial.Serial( portport, baudratebaudrate, bytesize8, parityN, stopbits1, timeout1.0, ) self._lock threading.Lock() def query(self, command: str) - str: with self._lock: self.ser.reset_input_buffer() self.ser.write((command \r\n).encode(ascii)) line self.ser.readline().decode(ascii, errorsreplace).strip() if not line: raise RuntimeError(fCommand timeout: {command}) return line def snapshot(self) - dict: voltage float(self.query(MEAS:VOLT?)) current float(self.query(MEAS:CURR?)) power float(self.query(MEAS:POW?)) return { voltage_v: voltage, current_a: current, power_w: power, timestamp: time.time(), } dev PowerMeter(port/dev/ttyUSB0, baudrate115200) mcp.tool() def read_snapshot() - dict: 讀取功耗計當前快照返回電壓(V)、電流(A)、功率(W)。當用戶詢問當前電壓、電流或功耗時調(diào)用。 return dev.snapshot() mcp.tool() def read_series( samples: int Field(ge1, le30, description采樣次數(shù)最大 30), interval_ms: int Field(ge100, le5000, description采樣間隔毫秒最小 100), ) - list: 按固定間隔連續(xù)采樣返回一組電壓電流功率數(shù)據(jù)適合分析平均值和波動。 result [] for _ in range(samples): result.append(dev.snapshot()) if _ samples - 1: time.sleep(interval_ms / 1000.0) return result mcp.tool() def raw_query(command: str) - str: 透傳一條原始指令給功耗計返回設備原始響應文本。僅調(diào)試時使用。 return dev.query(command) if __name__ __main__: mcp.run(transportstdio)代碼講幾個關(guān)鍵位置。PowerMeter.query里那把threading.Lock是必須的——FastMCP 默認按請求分發(fā)如果 AI 在一次對話里同時調(diào)用了多個工具沒有鎖的話兩條指令會同時往串口里寫讀回來的數(shù)據(jù)就亂了。snapshot里直接float()解析設備返回值功耗計返回的都是純數(shù)字字符串比如5.0123解析失敗時異常會沿著 MCP 通道傳回給 AIAI 會告訴你“設備響應解析失敗”這比靜默吞掉錯誤好得多。FastMCP實例化時傳入的字符串iot-power是服務端名稱會顯示在客戶端 MCP 服務器列表里。工具函數(shù)用mcp.tool()注冊函數(shù)名就是工具名docstring 就是工具描述參數(shù)類型和 Field 約束會自動生成 JSON Schema。Field(ge1, le30)把采樣次數(shù)限制在 1 到 30 之間否則用戶讓 AI 采樣一萬次工具會長時間阻塞很可能超過客戶端等待時限。3.3 本地調(diào)試先用 MCP Inspector 驗證工具寫完代碼別急著直接接客戶端先跑一遍 MCP Inspector。這個工具會以圖形界面加載你的服務端列出所有注冊的工具你可以手動點擊調(diào)用不用經(jīng)過大模型推理。運行方式python -m mcp dev server.py瀏覽器里打開它給的地址左側(cè)能看到read_snapshot、read_series、raw_query三個工具。點read_snapshot的 Call 按鈕如果返回{voltage_v: 5.12, current_a: 1.35, power_w: 6.91}這類數(shù)據(jù)說明你的服務端協(xié)議沒問題設備鏈路也正常。這一步特別值得養(yǎng)成習慣。因為 MCP Inspector 幫你剝離了“AI 會不會用”這個變量只驗證“服務端能不能返回”。如果工具在 Inspector 里能跑通后面接入 AI 客戶端就只剩配置問題如果不行你也不需要去讀大模型日志直接看串口和函數(shù)邏輯就行。實測下來這個工作流至少幫我省掉了兩小時無意義的“和 AI 對話式排查”。4. 接入 AI 客戶端與實測效果4.1 客戶端配置以 Claude Desktop 為例配置文件路徑在claude_desktop_config.json里加一個mcpServers節(jié)點{ mcpServers: { iot-power: { command: /home/user/projects/iot-power-mcp/.venv/bin/python, args: [ /home/user/projects/iot-power-mcp/server.py ] } } }這里有個我實測踩過的大坑command字段一定要寫虛擬環(huán)境里 Python 的絕對路徑不要寫python。原因是桌面客戶端啟動進程時不會加載你的 shell 配置PATH 環(huán)境變量很可能不指向虛擬環(huán)境如果寫成裸python服務端可能用系統(tǒng) Python 啟動然后報ModuleNotFoundError: fastmcp。Linux 和 macOS 都有這個問題Windows 上則要注意寫清python.exe的完整路徑。Cursor 的配置位置稍有不同在項目根目錄.cursor/mcp.jsonCodex 可以通過命令行添加。但本質(zhì)相同都是給客戶端提供“命令 參數(shù)”客戶端負責拉起服務端子進程。配置完成后重啟客戶端如果一切正常MCP 服務器列表里會出現(xiàn)iot-power和它下面的幾個工具。4.2 實測對話效果配置好后我通常先問一句“你現(xiàn)在能讀到什么設備信息嗎”AI 會調(diào)用raw_query(*IDN?)拿到設備廠商和型號然后回我一句“連接到了 IoT Power波特率 115200”。這種“AI 自己探索設備身份”的過程很能確認鏈路已經(jīng)跑通。再試真正的功率讀取。我問“幫我讀一下當前負載的輸出電壓和功率?!盇I 會調(diào)用read_snapshot返回類似{ voltage_v: 5.121, current_a: 1.352, power_w: 6.917 }它接著會把數(shù)值翻譯成自然語言“當前輸出電壓 5.121V電流 1.352A功率約 6.92W?!比绻麊査斑B續(xù)采樣 10 次間隔 200 毫秒算一下平均功率”它會用read_series拿到 10 條記錄然后用代碼解釋器算平均值和標準差最后給你一份波動情況總結(jié)。這種“讀儀表 數(shù)據(jù)分析 語言總結(jié)”的組合能力正是單靠指令集交互很難實現(xiàn)的體驗。4.3 可選的擴展工具如果你的設備支持控制類指令再封裝一兩個寫操作也很順手。比如我這臺支持OUTPut:STATe就可以加一個工具mcp.tool() def set_output_enabled(enabled: bool Field(description是否打開輸出)) - dict: 開關(guān)功耗計輸出通道返回操作后的輸出狀態(tài)。 state ON if enabled else OFF response dev.query(fOUTPut:STATe {state}) return {output_enabled: enabled, device_response: response}加上這個工具后AI 就不只是“看功耗計”還能“操作功耗計”。比如你可以讓它做一輪完整的電源測試開輸出采樣功率關(guān)輸出生成一條時間線。這個場景對測試電源適配器、驗證充電協(xié)議非常有用。不過要強調(diào)寫操作工具必須有清晰的 docstring并且最好加一層參數(shù)校驗AI 有時會誤解自然語言比如你說“幫我關(guān)一下”它可能把 enabled 傳成False所以返回里帶上device_response能讓你追蹤設備側(cè)真實狀態(tài)。5. 常見問題與排錯實錄5.1 串口層問題現(xiàn)象可能原因解決辦法啟動服務端時報serial.serialutil.SerialException串口被占用或權(quán)限不足Linux 下把用戶加入dialout組或加 udev 規(guī)則Windows 下確認串口助手已關(guān)閉能發(fā)指令但讀不到響應TX/RX 接反或設備未上電先用串口助手手動發(fā)*IDN?測試檢查 GND 是否連接返回內(nèi)容亂碼波特率不匹配或換行符不對翻設備手冊確認波特率嘗試\n與\r\n兩種結(jié)尾串口問題的排查思路很簡單先用排除法確認設備本身是好的。我會用串口助手把波特率調(diào)到 115200發(fā)*IDN?看有沒有可讀響應。如果串口助手里都沒響應那就不是 MCP 的事是接線、供電或端口配置問題如果串口助手里正常但 MCP 服務端讀不到問題在代碼的換行符或超時設置上。5.2 MCP 協(xié)議與客戶端配置問題我自己遇到最 spooky 的問題是服務端在命令行里跑得好好的但客戶端就是連不上工具列表加載不出來。查了半天發(fā)現(xiàn)是有個調(diào)試日志用print寫到了 stdout。MCP 使用 stdio 傳輸時stdout 是協(xié)議通道任何非協(xié)議內(nèi)容的輸出都會讓客戶端解析 JSON 失敗。解決方法是把日志全部改道到 stderr比如import sys print([debug] query: MEAS:VOLT?, filesys.stderr)或者干脆用logging模塊配置一個 StreamHandler 指向sys.stderr。凡是走 stdio transport 的 MCP 服務端一律不要向 stdout 寫日志這條能記一輩子。另一個常見問題是啟動后客戶端顯示“工具執(zhí)行失敗”但 Inspector 里正常。這種情況多半是工具函數(shù)里拋了異常而異常信息沒有被結(jié)構(gòu)化返回。FastMCP 默認會捕獲異常并把錯誤信息作為文本返回但如果異常發(fā)生在serial.Serial初始化階段服務端進程直接退出客戶端就只顯示“連接失敗”。所以設備連接動作不要在 import 時執(zhí)行最好放在工具首次調(diào)用時惰性初始化或者用 try/except 包起來把錯誤文本拋給 MCP 層。5.3 從踩坑中總結(jié)的經(jīng)驗最后分享兩條我實際使用下來的體會。第一條經(jīng)驗是 docstring 要寫成“給 AI 看的說明文檔”而不是“給人看的技術(shù)注釋”。我說的不是代碼風格而是像“返回電壓(V)、電流(A)、功率(W)”這種明確帶單位、帶調(diào)用時機的描述。AI 選擇工具的準確度高度依賴這段文本我曾經(jīng)把 docstring 寫成read current voltage and current and power結(jié)果 AI 分不清該調(diào)read_snapshot還是read_series經(jīng)常隨機選一個。后來把描述改成“當用戶詢問當前電壓、電流或功耗時調(diào)用”準確率立刻上來了。第二條經(jīng)驗是保留一個raw_query入口但只存在于調(diào)試階段。它一方面讓我能手動探索設備固件支持哪些指令另一方面也給 AI 留了一條“自己嘗試新指令”的路。不過這個工具權(quán)限很大比如如果設備支持SYSTem:REBootAI 可能在你毫無防備情況下重啟設備。所以正式使用時我會把它從注冊表里刪掉只保留語義清晰的業(yè)務工具。這算是我給所有 MCP 服務端定下的規(guī)矩寧可少一個工具也不要給 AI 過大的底層自由。