昨天比的是三條取得途徑,封裝對象在內網時只剩自建與自架兩條。今天走自建這條:把一個 IoT 專案既有的四支 REST 端點包成 MCP server,掛進 agent,然後量三件事——工具的 schema 佔多少、確認閘門擋不擋得住、這個 server 實際講的是哪一版協定。
那四支端點本身就是 HTTP,理論上給模型一個發請求的工具就能用。多包一層是為了把四件事固定在協定層:
四件事的共通點是它們都需要一個確定會被執行的位置。寫在 prompt 裡的規則由模型決定要不要遵守,寫在工具實作裡的規則由程式決定。
| 模組 | 負責 | 不碰 |
|---|---|---|
config.py |
API 位址、監聽埠、溫度政策、方向定義 | 邏輯與 I/O |
api_client.py |
對四支端點發 HTTP,封裝 payload 格式 | 業務與安全判定 |
safety.py |
純判定函式,回傳 (allowed, message) |
任何 I/O |
tools.py |
參數檢查、呼叫 safety、呼叫 client、整理回傳 | 判定規則本身 |
server.py |
建 client、註冊工具、掛 health、啟動 | 上面四項的內容 |
safety.py 沒有 I/O 是刻意的,判定規則因此可以用單元測試蓋滿,不必起 server 也不必連 API。tools.py 以 register_tools(mcp, client) 注入依賴,client 在模組層沒有綁死的實例,測試時換成替身即可。
被封裝的四支端點是三個 POST 加一個 GET,一支讀感測值、三支下控制指令。
同一組硬體有兩個工具可以操作。低階那個 control_fans 收設備編號與兩顆風扇各自的方向,語意層那個 control_door_fans 收一個位置名稱與 on/off,內部自己查對照表、自己決定方向、自己對多台設備各發一次請求。
兩者的 schema 大小反過來:
| 工具 | schema 合計 | 其中 description | 其中 inputSchema |
|---|---|---|---|
get_environment_status |
402 | 180 | 62 |
control_fans |
940 | 550 | 230 |
control_door_fans |
912 | 601 | 142 |
control_ac |
499 | 190 | 155 |
set_ac_temp |
594 | 291 | 152 |
| 合計 | 3,347 | 1,812 | 741 |
低階工具的參數多,inputSchema 230 byte 是全組最大的一個。語意層工具的參數只有兩個,inputSchema 142 byte,它的 description 反而是全組最長的 601 byte,因為要寫清楚哪些說法對應到哪一個位置、on 實際會做什麼。
三個數字合起來是同一件事的兩面:參數從 schema 移到描述裡,總量沒有省下來,省的是模型要做的推理。 低階工具要模型自己查對照、自己決定方向、自己拆成兩次呼叫,語意層工具把這三步收進實作。
description 全組 1,812 byte,佔 3,347 的 54%,加上工具名與 title 之後 schema 的其餘部分才 794 byte。寫工具描述的成本與寫 schema 的成本不在同一個量級。
判定規則分三類,實測各跑一次。需確認與不合法這兩類在判定階段就回傳,實際的 HTTP 請求沒有送出,因此以本機的替身 API 執行不影響結論:
| 呼叫 | 回傳 |
|---|---|
set_ac_temp(25) |
ok: true,指令送出 |
set_ac_temp(20) |
ok: false、needs_confirmation: true,訊息說明超出自主範圍並要求複述後帶 confirm=true |
set_ac_temp(35) |
ok: false、error: true,訊息說明超出 API 合法範圍,已拒絕 |
control_ac("OFF") |
ok: false、needs_confirmation: true,訊息要求複述「將關閉冷氣」 |
control_ac("OFF", confirm=True) |
ok: true,指令送出 |
control_fans(device_id=9, ...) |
ok: false、error,訊息說明編號只能是那兩個值 |
三種形狀對應三種語意:
ok: true — 判定通過,API 收到請求needs_confirmation: true — 值合法但超出 agent 的自主範圍,實作沒有送出請求error — 值本身不合法,實作沒有送出請求第二類與第三類的差別在有沒有回頭路。合法但需確認的,模型複述給使用者、得到同意、帶 confirm=true 再呼叫一次就會執行。不合法的帶什麼旗標都不會執行。
溫度那個工具同時有兩條界線,API 的合法範圍是一組數字,agent 可以自己決定的範圍是更窄的一組。自主範圍與合法範圍分開寫,是為了讓「拒絕」與「要問一下」變成兩種不同的回傳。
判定結果裡寫明下一步,模型才接得下去
hermes mcp add 只給 --url,它先連線、列出探索到的工具、問要不要全部啟用:
Connecting to http://127.0.0.1:9100/mcp
Does this server require authentication? [Y/n]:
Connecting to 'env-control'...
✓ Connected! Found 5 tool(s) from 'env-control':
...
Enable all 5 tools? [Y/n/select]:
✓ Saved 'env-control' to .../config.yaml (5/5 tools enabled)
Start a new session to use these tools.
hermes mcp test 另外報連線耗時:
Testing 'env-control'...
Transport: HTTP → http://127.0.0.1:9100/mcp
Auth: none
✓ Connected (1531ms)
✓ Tools discovered: 5
兩個指令合起來決定了三件事的先後:
[Y/n/select] 那一問可以只挑其中幾個prompt-size 量不到 MCP 的工具前幾天量 context 用的是 hermes prompt-size,把這個 server 掛上去之後再量一次,工具數與 byte 數沒有變:
| 狀態 | 工具數 | 工具 schema byte |
|---|---|---|
| 掛上 5 個 MCP 工具 | 19 | 33,227 |
| 移除之後 | 19 | 33,227 |
原因在 hermes_cli/prompt_size.py 的 _build_inspection_agent()。它建的是一個離線的 agent,api_key 與 base_url 都填假值以強制走直接建構的路徑,工具集只從組態裡的 toolset 清單來。註解寫明這樣做是為了不呼叫網路,MCP 的工具要連上 server 才拿得到,離線的量測自然看不到它們。
所以 MCP 那一側的 schema 成本要自己量,方法是直接對 server 發一次 tools/list,把回傳的每個工具序列化成 JSON 再數 byte。上面那張 3,347 的表就是這樣來的。
把兩個數字放在一起:原有的 33,227 加上這 3,347,MCP 這五個工具讓工具 schema 的總量多了 10%。
這個 server 建在 FastMCP 上,組態以環境變數 FASTMCP_STATELESS_HTTP=true 開啟無狀態模式,容器回報的 serverInfo 版本是 3.4.4。
四個探測的結果:
| 探測 | 結果 |
|---|---|
不先握手直接發 tools/list |
正常回傳五個工具 |
initialize 時宣告 protocolVersion: 2026-07-28 |
回傳 protocolVersion: 2025-11-25 |
呼叫 server/discover |
-32602 Invalid request parameters |
tools/list 回傳的鍵 |
只有 tools |
server/discover 是新版要求 server MUST 實作的探索方法,這裡回的錯誤碼是參數無效。tools/list 那一列對應的是新版要求列表結果帶上 ttlMs 與 cacheScope,回傳裡兩個都沒有。
capabilities 裡倒是出現了 extensions 欄位,內容是 io.modelcontextprotocol/ui。新版加的那個宣告位置已經在了,協定版本本身還停在上一版。
因此這個 server 的「無狀態」要講清楚是哪一種:
server/discover 與列表結果的快取欄位都還沒有前者是 FastMCP 自己的選項帶來的,後者要等上游跟上新版才有。組態旗標的名字與規範條文的名字剛好撞在一起,兩者指的不是同一件事。
那個 10% 是這次唯一讓我意外的數字。掛上去之前預期它會很明顯,五個工具、每個描述都寫得很長,結果是原本就在的 33,227 把它稀釋掉了。原生的工具集有十九個,MCP 這五個是加在一個已經不小的基數上。
順著這個數字往回看,之前為了省 byte 去刪工具描述的那些字,省的比例比想像中小。真正決定總量的是工具的個數,不是每個工具寫得多詳細。
另一件是量到一半才發現的。原本打算用 prompt-size 直接看掛上去前後的差,掛了、量了、數字一模一樣,第一反應是組態沒存進去,去翻 config.yaml 才確認存了,再去翻那個指令的實作才知道它根本不連 MCP。
一個量測工具涵蓋的範圍,寫在它的實作裡
控制指令送出之後要等一段時間才會反映在感測值上,同步的回應表達不了「已受理但還沒完成」。