前言
監控做完之後,我的 VM 上有 Prometheus 抓指標、Loki 收 log、Grafana 看儀表板(見系列第 3、4 篇)。但真的出事時,我的工作流程還是很原始:開 Grafana、切到 Explore、想 LogQL 怎麼寫、把結果複製出來再丟給 AI 分析。既然我平常寫程式就在用 Claude Code,很自然會想:能不能讓 AI 直接去查 log 和指標?
【系列】自架 VM 維運實戰 第 7 篇/共 7 篇
上一篇:【備份】MySQL 每日備份上雲:只能上傳不能刪,還要真的還原得回來
下一篇:(本篇是最後一篇)
Grafana Labs 官方有一個 MCP server:mcp-grafana,接上之後 AI 就能用工具的方式查 Prometheus、Loki、搜尋儀表板。技術上很容易,難的是另一件事:給 AI 一把鑰匙之前,要先想清楚這把鑰匙能開哪些門。AI 會照著它讀到的內容行動,而 log 裡的內容有一部分是外面的人可以控制的。
這篇記錄我 2026-10-16 在自己 VM 上自架 mcp-grafana 2.0.2 的完整過程:先列威脅模型,再用五層防護一層一層擋,每一層都附上實測結果(共 15 項測試)。最後也會老實寫出還沒防到的地方,這套設定能降低風險,但不是「絕對安全」。
先想清楚:讓 AI 連 Grafana 會出什麼事
我把擔心的事情列成一張表,右邊是這篇會用哪一層去擋。層的編號對應後面「五層防護」的小節。
| 威脅 | 具體會發生什麼 | 主要靠哪一層 |
|---|---|---|
| AI 誤改設定 | 好心「幫你修」儀表板、改告警規則、建一個 silence 把告警靜音 | ① Grafana 權限、② MCP 工具層 |
| 萬用 API 工具被拿來繞路 | 透過可以打任意 Grafana API 的工具,做到專用工具沒開放的事 | ② --disable-api、① 權限兜底 |
| Prompt injection | log 內容(request path、User-Agent、錯誤訊息)夾帶指令,AI 讀到後照做 | ①② 只能降低損害,無法根除 |
| 一個查詢掃爆 Loki | AI 下了沒有篩選條件、範圍好幾天的 LogQL,整台機器被拖慢 | ③ 查詢成本 guardrail |
| Grafana SA token 外洩 | 對方繞過 MCP 直接打 Grafana API | ① Viewer 限縮影響範圍、檔案權限、90 天到期(MCP 的 guardrail 擋不到) |
| 呼叫端 token 外洩 | 對方拿它連 mcp-grafana,用我的身分查 log | ⑤ 服務只綁 loopback、外部要先過 SSH(鑰匙圈擋不住同一使用者的程式) |
| MCP 服務被別人連上 | 有人直接連到 mcp-grafana,用我的身分查 log | ④ Bearer token、⑤ 只綁 loopback+SSH tunnel |
| DNS rebinding | 我瀏覽的惡意網頁,透過瀏覽器去打我本機的 MCP 服務 | ④ Host 白名單、Origin 檢查(Bearer token 也會擋) |
表格裡最需要先講清楚的是 prompt injection。對 AI 來說,工具回傳的 log 就是一段文字,它不一定分得出「這是資料」還是「這是指令」。我的 nginx access log 裡,URL 和 User-Agent 都是任何人都能塞字串的欄位。所以我的設計前提是:假設 AI 遲早會讀到惡意內容,那就要確保它讀到之後,手上沒有可以造成傷害的工具。
架構
整條路徑分成五段:

請求經 SSH tunnel 進入 mcp-grafana,沿路通過五層防護,只能唯讀查詢。
| 段落 | 從哪到哪 | 這一段的驗證/限制 |
|---|---|---|
| 1 | Claude Code → Mac 的 127.0.0.1:8765 | headersHelper 從 macOS 鑰匙圈讀 token,加上 Authorization: Bearer |
| 2 | Mac → VM(SSH tunnel) | 要先能 SSH 登入 VM,流量走 SSH 加密 |
| 3 | VM 127.0.0.1:8765 → 容器 :8000 | 只綁 loopback;mcp-grafana 檢查 Bearer token、Host、Origin |
| 4 | mcp-grafana → Grafana(monitoring 網路) | Viewer 角色的 service account token |
| 5 | Grafana → Prometheus/Loki | Grafana 既有的 datasource 設定 |
這裡有一個值得注意的地方:我的 Grafana 對外是 grafana.example.com,前面有 Cloudflare Access 擋著(系列第 1 篇)。但 mcp-grafana 是在 Docker 內部網路直接連 Grafana 的,完全不經過 Cloudflare Access。第 1 篇那層保護對這條路徑無效,所以後面每一層都要自己補起來。
傳輸方式我用 streamable HTTP,而不是官方 README 裡最常見的 stdio。原因是我不想讓 Grafana 的 service account token 出現在我的 Mac 上:token 只放在 VM 的一個檔案裡,Mac 這端只拿得到「連 MCP 用的 token」,拿不到「連 Grafana 用的 token」。
五層防護
先看實測總表。測試腳本是兩支 Python,在 VM 上以 root 執行:測試 1–13 用 MCP 的 streamable HTTP 協定依序送 initialize、tools/list、tools/call;測試 14、15 則繞過 MCP,直接拿 SA token 打 Grafana API。腳本只印狀態碼與結果摘要,不印任何 token。
| # | 測試 | 結果 | 對應層 |
|---|---|---|---|
| 1 | 不帶 token | 401 | ④ |
| 2 | 錯誤 token | 401 | ④ |
| 3 | Host: evil.example.com:8765 | 403 | ④ |
| 4 | 帶 Origin: https://evil.example.com | 403 | ④ |
| 5 | 正確 token 初始化 | 200 | — |
| 6 | tools/list | 61 個工具,用名稱掃過沒有 create/update/delete 類;grafana_api_request 不存在 | ② |
| 7 | user_info | mcp-readonly,isGrafanaAdmin=false | ① |
| 8 | list_datasources/search_dashboards | 正常(Loki、Prometheus;儀表板清單) | — |
| 9 | query_prometheus count(up==1) | 19 | — |
| 10 | query_loki_logs {job=~".+"},範圍 8 天 | 被 guardrail 擋下,並回傳改寫建議 | ③ |
| 11 | query_loki_logs {job="nginx"} |= " 500 "(預設 1 小時) | 正常(0 筆,附 hints) | ③ |
| 12 | alerting_manage_routing get_contact_points | 正常(唯讀) | ② |
| 13 | alerting_manage_routing delete_contact_point | 不存在,只允許 get_* 操作 | ② |
| 14 | 繞過 MCP,用 SA token 直接 POST /api/dashboards/db | 403 | ① |
| 15 | 繞過 MCP,用 SA token 直接建 silence | 403 | ① |
① Grafana 權限:Viewer 角色的 service account
設定方式:在 Grafana 建一個 service account mcp-readonly,角色選 Viewer,再替它產生一個 token。mcp-grafana 用這個 token 代表 AI 去查 Grafana。官方 README 為了方便,也提到可以直接給 service account 內建的 Editor 角色(它自己也註明限制比較少),這條捷徑我刻意不走。
為什麼這層最重要:後面的 --disable-write 是 MCP 程式自己決定「不註冊寫入工具」,那是應用層的保護;如果哪天 MCP 有 bug、設定被改掉,或 token 直接被偷走,最後能擋住寫入的只剩 Grafana 本身的權限檢查。所以我把最底層設計成「就算整個 MCP 被繞過,這把 token 也只能讀」。
實測:測試 7 透過 MCP 呼叫 user_info,回傳身分是 mcp-readonly、isGrafanaAdmin=false;我另外用 API 確認過角色是 Viewer。測試 14、15 則是故意繞過 MCP,直接拿這把 SA token 打 Grafana API:建儀表板(POST /api/dashboards/db)和建 silence 都回 403。也就是說,即使 token 外洩,對方能做的事情跟一個 Viewer 帳號一樣,沒辦法改儀表板、也沒辦法把告警靜音。
② MCP 工具層:不註冊寫入工具與萬用 API 工具
設定方式:啟動參數加兩個:
--disable-write:官方 README 的 Read-Only Mode。啟用後不註冊建立/修改類工具,例如update_dashboard、create_folder、alerting_silences_write、create_annotation、create_snapshot等;README 也特別說明,可以執行任意 SQL 的query_sql、query_influxdb在這個模式下同樣會被移除,因為它們可能執行DROP TABLE或DELETE。--disable-api:不註冊grafana_api_request。這個工具可以「呼叫任意 Grafana HTTP API endpoint」,是一把萬用鑰匙。官方 README 在 Loki query enforcement 一節也點名:grafana_api_request可以直接打 Loki 的 datasource proxy,對強制 label 限制來說是「full bypass」,所以要用那個功能就必須搭配--disable-api。
為什麼兩個都要開:只關寫入工具、留著萬用 API 工具,等於把門鎖了、窗戶開著。專用工具的權限邊界是一個一個設計過的,萬用 API 工具的邊界只能靠它自己內部的判斷。我的原則是 AI 用不到的就不要註冊,工具清單越短,能被 prompt injection 利用的東西就越少。
實測:測試 6 的 tools/list 回傳 61 個工具,用名稱規則掃過,沒有任何 create/update/delete 類工具,grafana_api_request 也不在清單裡。名稱規則掃不到的是 alerting_manage_routing 這種「一個工具、多種操作」的設計,所以另外抽測:測試 12 呼叫 get_contact_points 正常回傳;測試 13 改成 delete_contact_point,回應是這個操作不存在,只允許 get_*。至少在我這個設定下,這支工具只剩讀取操作;不過我沒有拿未開 --disable-write 的版本做對照,所以不確定這是唯讀模式收掉的,還是它原本就沒有刪除操作。
③ 查詢成本:Loki guardrail
設定方式:--loki-guardrail-mode=enforce。這個參數預設是 off。
為什麼:官方 README 的說明很直接:Loki 對「沒有 line filter 的 log 查詢」不會套用 max_query_bytes_read 限制,所以一個很寬的 selector 加上很長的時間範圍,可能會掃非常大量的資料。AI 為了「先看看全貌」,很容易下出 {job=~".+"} 這種查詢。我的 Loki 跟正式服務在同一台 2 核 VM 上,一個大查詢拖慢的是整台機器。
guardrail 開啟後會對 query_loki_logs 做三件事(官方 README):
- 要求 stream selector 要有選擇性,不能是「全部都要」;
- 限制單次查詢的有效時間範圍,預設
24h(--loki-guardrail-max-range),連[30d]這種 range vector 的長度也算進去; - 查詢前先用 Loki 的 index/stats API 估算會掃多少 bytes,預設上限 100 GiB(
--loki-guardrail-max-bytes)。
模式有 shadow(只記錄、照樣執行)和 enforce(直接拒絕)兩種。enforce 拒絕時會附上改寫建議,讓 AI 可以自己縮小查詢再試一次。
實測:測試 10 我故意下 {job=~".+"}、範圍 8 天,結果被擋下,理由有兩個:selector 沒有選擇性的 label,以及範圍 192h 超過 24h 上限;回應裡同時附上改寫建議。測試 11 換成正常的查法 {job="nginx"} |= " 500 "、用預設的 1 小時範圍,就順利執行(0 筆,另外附上 hints)。
④ 呼叫端驗證與瀏覽器防護
前兩層管的是「連上之後能做什麼」,這一層管的是「誰能連上」。
Bearer token:設定環境變數 MCP_GRAFANA_SERVER_TOKEN 後,呼叫端必須帶 Authorization: Bearer <token>,否則在任何工具執行之前就回 401。官方 README 建議用環境變數而不是 --server-auth-token 參數,避免 token 出現在 process 的參數裡被 ps 看到。官方另外提到一個細節:啟用呼叫端驗證後,這個 Authorization header 驗完會被拿掉,不會轉送給 Grafana。
這一層一定要知道的預設行為:沒有設 server token、又綁在非 loopback 位址時,mcp-grafana 照樣會啟動,只在 log 記一筆 error 等級的安全錯誤(官方說未來的 major 版本會改成拒絕啟動),而官方 Docker image 在 SSE/streamable HTTP 模式下綁的就是 0.0.0.0:8000。所以照著最簡單的 docker run -p 8000:8000 跑、又忘了設 token,就是一個誰都能連的 Grafana 查詢服務。後面的 Host 白名單救不了這種情況:它不是身分驗證,curl 之類的程式可以自己填 Host。
DNS rebinding 是什麼:假設我在 Mac 上開著 tunnel,localhost:8765 就是我的 MCP 服務。此時我打開一個惡意網頁,網頁裡的 JavaScript 對自己的網域(例如 evil.example.com)發請求;攻擊者把這個網域的 DNS 改成指向 127.0.0.1,瀏覽器以為是同源請求,實際上卻打到我本機的服務。這種攻擊有兩個特徵:請求的 Host header 是攻擊者的網域(例如 evil.example.com:8765);MCP 用的是 POST,瀏覽器送 POST 時即使判定為同源也會帶上 Origin header。另外,惡意網頁也拿不到我鑰匙圈裡的 Bearer token,所以這一層其實有三道檢查。
mcp-grafana 對前兩個特徵都有檢查(官方 README 的 HTTP Transport Security):
--allowed-hosts:Hostheader 不在白名單就回 403。預設值是從--address推出的 loopback 組合(例如localhost:8000,127.0.0.1:8000,[::1]:8000)。--allowed-origins:預設是空的,任何帶Originheader 的請求都會被拒絕,理由是不應該有瀏覽器直接呼叫這個服務。
實測:測試 1、2 不帶 token、帶錯誤 token 都是 401;測試 3 帶 Host: evil.example.com:8765(token 正確)回 403;測試 4 帶 Origin: https://evil.example.com(token 正確)也回 403:就算 token 正確,長得像從瀏覽器或奇怪網域來的請求一樣進不去。
⑤ 網路與容器
網路:Compose 的 port 寫 127.0.0.1:8765:8000,只綁 VM 的 loopback,外部網路完全連不到這個 port。我從 Mac 連的方式是 SSH tunnel:把 Mac 的 127.0.0.1:8765 轉到 VM 的 127.0.0.1:8765。這樣不需要對外開任何新的 port;也不需要另外處理 TLS,因為主機端的 port mapping 只綁 loopback(容器內雖然綁 0.0.0.0,這段流量只在本機的 Docker bridge 上),跨網路那段走 SSH。如果你把主機端改成非 loopback 位址,官方 README 要求啟用呼叫端驗證時要加 TLS。從外部網路要連上這個服務,前提是先有 SSH 登入權限。但 VM 上的其他程序、monitoring 網路上的容器,以及 tunnel 開著時 Mac 上的其他程序,在網路層都連得到它,這些情況靠的是 Bearer token。
容器:mcp-grafana 本身不需要寫檔、不需要任何 Linux capability,所以全部收掉:
read_only: true:root filesystem 唯讀;cap_drop: [ALL]:拿掉所有 capability;security_opt: ["no-new-privileges:true"]:程式不能再透過 setuid 等方式提權;mem_limit: 128m:跟系列第 3 篇的原則一樣,萬一它吃爆記憶體,被 OOM kill 的應該是它自己,不是同機的 MySQL。
這一層是為了「萬一 mcp-grafana 本身有漏洞」準備的:它會處理外部輸入,就算被打下來,在容器裡也做不了什麼。
憑證管理:兩把 token,各自放好
先講原則:token 從頭到尾不經過 AI 的對話。部署時最容易順手把 token 貼給 AI(「幫我把這個 token 存進設定檔」),但對話紀錄會保存在本機、也會送到服務端,一旦貼進去,就要當作這把 token 已經不只存在你手上。這套架構裡有兩把 token,用途完全不同,很容易搞混:
| token | 用途 | 在哪裡產生 | 存在哪裡 |
|---|---|---|---|
Grafana SA token(glsa_ 開頭) | mcp-grafana 連 Grafana | Grafana UI | VM 上 /etc/mcp-grafana/grafana-sa-token,只有容器 uid 能讀 |
| 呼叫端 token | Claude Code 連 mcp-grafana | VM 上用 openssl rand -hex 32 | VM 上 /etc/mcp-grafana/server.conf(600);Mac 的鑰匙圈 |
Grafana SA token:環境變數只給檔案路徑,不直接放值
官方支援兩種給法:GRAFANA_SERVICE_ACCOUNT_TOKEN(直接給值)和 GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE(給檔案路徑)。我用檔案,原因是官方 README 這段:檔案每次請求都會重新讀取,所以 token 輪替之後會自動生效,不用重啟服務。檔案內容前後的空白和換行會被去掉;如果兩種都設,直接給值的那個優先,所以不要兩個都設。
檔案權限:容器以 uid 1000、gid 999 執行(可用 docker run --rm --entrypoint id grafana/mcp-grafana:2.0.2 確認),所以 token 檔 chown 1000:999、chmod 400,再以唯讀方式掛進容器的 /run/secrets/grafana-sa-token。
Token 本身是我在 Grafana UI 產生、手動貼進 VM 存檔的。存檔時我用 read -rsp,輸入不會顯示在畫面上、也不會留在 shell history,並且加上格式檢查(這是踩坑之後才加的,見後面踩坑一節)。這段要用 bash 執行,zsh 的 read -p 意思不同。大致是這樣:
sudo install -d -m 700 /etc/mcp-grafana
read -rsp 'Grafana SA token: ' T; echo
# 格式檢查:必須是 glsa_ 開頭、不能含空白,不符合就不存
if [[ "$T" == glsa_* && "$T" != *[[:space:]]* ]]; then
printf '%s' "$T" | sudo sh -c 'umask 077; cat > /etc/mcp-grafana/grafana-sa-token'
echo "已存檔(${#T} 字元)"
else
echo "格式不對:要 glsa_ 開頭且不能含空白,沒有存檔"
fi
unset T
呼叫端 token:在主機上產生,不經過對話
呼叫端 token 在部署時用 openssl rand -hex 32 產生,直接寫進 server.conf,再用 Compose 的 env_file 讀進容器。整個過程 token 不上螢幕、不進 process 參數、不經過對話。
Mac 這端,我在終端機從 VM 讀出 token 後直接存進 macOS 鑰匙圈,Claude Code 的設定檔裡只放一個 helper 腳本的路徑(下一節)。對照組是 claude mcp add --header "Authorization: Bearer …":這個寫法很方便,但會把 token 明文寫進 ~/.claude.json,跟著設定檔被備份、同步、commit 出去。
但要說清楚鑰匙圈在這個用法下的實際效果:token 不寫進設定檔、不進對話、不進版控,如此而已。它擋不了以我身分執行的本機程式:helper 腳本用一行 security find-generic-password 就讀得到,其他程式(包括 Claude Code 自己的 shell 工具)也能用同一行讀到。add-generic-password 有 -T 可以限制哪些程式能存取這個項目,但我沒有試過,在這個架構下能擋到什麼要自己驗證。
從 VM 讀出並存進鑰匙圈的寫法如下。這段我在 macOS 上用 bash 與 zsh 各實跑過:存入的值與 VM 上的 token 相同、長度驗證為 65,讀取失敗時確實不會存入。重點:token 不印到畫面、不進外部程式的參數(-w "$T" 會讓 token 出現在 argv、ps 看得到,所以改經 stdin 交給 security -i);sudo 失敗或格式不對時一律不存,避免存進空值或錯值。
# 在 Mac 上用 zsh 或 bash 執行(作者已實跑驗證)
# 前提:VM 上的帳號可以免密碼 sudo(sudo -n),否則直接失敗、不會存
T=$(ssh my-vm "sudo -n sed -n 's/^MCP_GRAFANA_SERVER_TOKEN=//p' /etc/mcp-grafana/server.conf") || T=
if [[ "$T" =~ ^[0-9a-f]{64}$ ]]; then
# printf 是 shell 內建指令;token 經 stdin 交給 security -i,不出現在 argv
printf 'add-generic-password -U -a %s -s mcp-grafana-vm -w %s\n' "$USER" "$T" | security -i >/dev/null
echo "已存入鑰匙圈"
# 驗證長度(不印出值):應為 65(64 字元+換行)
security find-generic-password -a "$USER" -s mcp-grafana-vm -w | wc -c
else
echo "讀取失敗或格式不對(應為 64 字元小寫 hex),沒有存"
fi
unset T
到期與輪替
Grafana SA token 我設 90 天到期。輪替步驟:
- 在 Grafana UI 替
mcp-readonly產生一個新 token; - 用上面的
read -rsp指令覆寫/etc/mcp-grafana/grafana-sa-token,確認檔案權限仍是 uid 1000、400(可重跑下面照做順序第 4 步的chown/chmod); - 因為用的是
GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE,下一個請求就會讀到新 token,不用重啟容器;用 Claude Code 跑一次user_info確認正常; - 回 Grafana UI 刪掉舊 token。
呼叫端 token 是透過 env_file 在容器建立時讀入的。部署指令只在 server.conf 不存在時才產生新 token,所以輪替方式是:刪掉 server.conf、重跑產生它的那行、docker compose up -d --force-recreate 重建容器,再用上面的指令更新鑰匙圈。這段流程我還沒實測。
完整部署
照做順序
- 在 Grafana 建立 service account
mcp-readonly(角色 Viewer),產生 token(建議設到期日); - 在 VM 上用 bash 執行上面的
read -rsp片段,存 SA token; - 確認外部網路
monitoring已存在(docker network ls); - 在 VM 上設好權限、產生呼叫端 token、放好 Compose 檔並啟動(指令見清單下方);
- Mac 端確認
ssh my-vm能免互動登入(金鑰+ssh-agent、host key 已接受過):headersHelper 在背景執行,沒辦法回答密碼或 host key 提示; - 用「呼叫端 token:在主機上產生,不經過對話」一節的指令把呼叫端 token 存進鑰匙圈,放好 helper 腳本(內容見下方「Claude Code 端」)並
chmod 700; - 把下面的 JSON 加入 Claude Code 的 MCP 設定,再用
claude mcp get grafana-vm確認 Connected。
第 4 步在 VM 上執行,對應我的部署腳本做的事:
sudo chown 1000:999 /etc/mcp-grafana/grafana-sa-token && sudo chmod 400 /etc/mcp-grafana/grafana-sa-token
sudo sh -c 'umask 077; [ -s /etc/mcp-grafana/server.conf ] || printf "MCP_GRAFANA_SERVER_TOKEN=%s\n" "$(openssl rand -hex 32)" > /etc/mcp-grafana/server.conf'
sudo install -d -m 755 /opt/mcp-grafana
# 把下面的 Compose 檔存成 /opt/mcp-grafana/docker-compose.yml(目錄屬 root,需 sudo,例如 sudo tee)之後:
cd /opt/mcp-grafana && sudo docker compose up -d && sudo docker logs --tail 20 mcp-grafana
docker-compose.yml
以下是我的部署腳本實際寫出的 /opt/mcp-grafana/docker-compose.yml。mon-grafana 是 Grafana 容器在 monitoring 網路上的名稱,請換成你自己的。
services:
mcp-grafana:
image: grafana/mcp-grafana:2.0.2
container_name: mcp-grafana
restart: unless-stopped
entrypoint: ["/app/mcp-grafana"]
command:
- --transport=streamable-http
- --address=0.0.0.0:8000
# 經 SSH tunnel 進來的 Host 是 localhost:8765,其他 Host 一律 403(防 DNS rebinding)
- --allowed-hosts=localhost:8765,127.0.0.1:8765
- --disable-write # 不註冊任何建立/修改類工具
- --disable-api # 不註冊可打任意 Grafana API 的 grafana_api_request
- --loki-guardrail-mode=enforce
- --usage-stats=disabled
environment:
GRAFANA_URL: http://mon-grafana:3000
GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE: /run/secrets/grafana-sa-token
env_file: /etc/mcp-grafana/server.conf
volumes:
- /etc/mcp-grafana/grafana-sa-token:/run/secrets/grafana-sa-token:ro
ports:
- "127.0.0.1:8765:8000" # 只綁 loopback,不對外
networks: [monitoring]
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
mem_limit: 128m
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
networks:
monitoring:
external: true
幾個補充:
entrypoint我選擇覆寫,原因見踩坑一節。--usage-stats=disabled:mcp-grafana 可以回報匿名使用統計給 Grafana Labs(呼叫了哪些工具、失敗幾次、伺服器怎麼設定),我選擇關掉。server.conf只有一行MCP_GRAFANA_SERVER_TOKEN=<token>,權限 600。monitoring是第 3 篇建立的外部網路,所以這裡宣告external: true。
Claude Code 端
Claude Code 的 MCP 設定(user scope,名稱 grafana-vm)只有這幾行,裡面沒有任何 token。helper 路徑建議寫絕對路徑(/Users/you 換成你的家目錄):
{
"type": "http",
"url": "http://localhost:8765/mcp",
"headersHelper": "/Users/you/.claude/mcp-helpers/grafana-vm-headers.sh"
}
headersHelper 指向一個腳本,Claude Code 會執行它,把它輸出的 JSON 當成 HTTP header。我的腳本做兩件事:tunnel 沒開就先開,再從鑰匙圈讀 token:
#!/bin/zsh
# Claude Code headersHelper:連 VM 上的 mcp-grafana
# 1. 確保 SSH tunnel(Mac 127.0.0.1:8765 → VM 127.0.0.1:8765)存在
# 2. 從 macOS 鑰匙圈讀呼叫端 token,輸出 header JSON(token 不寫進任何設定檔)
set -eu
if ! nc -z 127.0.0.1 8765 2>/dev/null; then
ssh -fN -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-L 127.0.0.1:8765:127.0.0.1:8765 my-vm >/dev/null 2>&1
for _ in 1 2 3 4 5 6 7 8 9 10; do nc -z 127.0.0.1 8765 2>/dev/null && break; sleep 0.5; done
fi
token=$(security find-generic-password -a "$USER" -s mcp-grafana-vm -w)
printf '{"Authorization":"Bearer %s"}\n' "$token"
幾個細節:-L 的本機端明確寫 127.0.0.1,tunnel 不會開在 Mac 的其他網卡上;ExitOnForwardFailure 讓轉發失敗時 ssh 直接結束,不會留下一個看起來有連、其實沒轉發的程序;開完 tunnel 最多等 5 秒確認 port 可連。my-vm 是 ~/.ssh/config 裡的主機別名。ssh 的錯誤訊息在這裡被丟掉了,tunnel 開不起來時 Claude Code 只會顯示連線失敗;除錯時可以先把 2>&1 改成寫進一個 log 檔。
設定好之後,claude mcp get grafana-vm 顯示 Connected。
實際用起來
部署完我開了一個全新的 headless Claude Code session,請它依序做四件事,驗證整條路徑是通的:
| 請它做的事 | 用的工具 | 結果 |
|---|---|---|
| 確認自己的身分 | user_info | mcp-readonly,不是 admin |
| 列出資料來源 | list_datasources | 2 個:Loki、Prometheus |
| 有幾個抓取目標是活的 | query_prometheus:count(up==1) | 19 |
| 查一段 Loki log | query_loki_logs | 成功回傳 |
第一項是刻意放的:讓 AI 動手前先確認自己是唯讀身分,哪天 service account 的角色被改掉,這裡就看得出來。
跟以前「開 Grafana → 寫查詢 → 複製結果 → 貼給 AI」比起來,現在我只要說「看一下過去一小時 nginx 有沒有 5xx」,AI 會自己決定要查哪個 label、寫什麼 LogQL;查得太寬時,guardrail 會擋下並附上改寫建議。我不用再把 log 搬來搬去,也不用把 log 貼進對話,AI 只拿它需要的那幾行。
踩坑整理
官方 image 的預設 entrypoint 是 SSE
官方 Docker image 的預設 entrypoint 帶著 --transport sse --address 0.0.0.0:8000(README:「entrypoint 預設是 SSE 模式」)。README 的做法是在 image 後面加 -t streamable-http,讓後面的參數蓋掉預設值。我選擇直接覆寫 entrypoint: ["/app/mcp-grafana"],把 transport、address 等參數全部寫在 command 裡,這樣不用去記 entrypoint 原本帶了什麼,實際生效的參數在 Compose 檔裡一眼就看得到。啟動後再看一次 docker logs,確認跑的是 streamable HTTP。
經過 tunnel 時,Host 白名單要明確設定
--allowed-hosts 的預設值是從 --address 推出來的,也就是 localhost:8000 這一類。但經過 port mapping 和 SSH tunnel,Claude Code 送出的 Host 是 localhost:8765,埠號對不上,結果全部 403。這個 403 是 Host 檢查正常運作的結果,不是 bug;解法是明確列出 localhost:8765,127.0.0.1:8765。千萬不要為了省事填 *,官方說那只適合前面有可信任的 reverse proxy 幫你驗 Host 的情境。
Token 貼錯,而 Grafana token 只顯示一次
第一次存 SA token 時我貼錯了:存進去的是 195 個字元、含空白、而且不是 glsa_ 開頭的字串。容器照樣正常啟動,直到呼叫工具時才全部回 401 Invalid API key。更麻煩的是,Grafana 的 token 只在產生當下顯示一次,貼錯就找不回原本的值,只能刪掉重建。這也是我後來在存檔指令裡加 glsa_ 前綴與空白檢查的原因:能在存檔時擋下的錯,就不要等到執行時才發現。
auto mode 擋下 token 自動化
我原本想讓 Claude Code 自動化「在瀏覽器產生 token、經剪貼簿存檔」這一段,結果被 Claude Code 的 auto mode 擋下來(歸類為 Secret-Store Writes)。被擋的當下有點不方便,但回頭看這個判斷是對的,改成我自己手動存檔之後,token 確實從頭到尾沒有進入對話。
還沒防到的地方
這五層能把「AI 改壞東西」和「別人連進來」的風險壓得很低,但下面這幾件事,目前的設定沒有完全解決:
- Prompt injection 只能降低損害,不能根除。log 裡被塞進去的指令,AI 還是讀得到;我做的是確保 mcp-grafana 這邊沒有寫入工具,讀到了也無處下手。但風險是以「整個用戶端」為單位計算的:如果同一個 Claude Code session 裡還接了其他可以寫入的工具(能改檔、能跑 shell、能發訊息的 MCP),被注入的指令就可能透過那些工具執行。外洩管道也不一定是「可寫」的工具:能抓網頁的工具可以把讀到的 log 塞進 URL 送出去,而這類工具常被當成「唯讀」放進自動核准。比較穩的做法是:資料查詢類工具和有寫入或對外連線能力的工具,不要放進同一個自動核准範圍,這類動作保留人工確認。
- Viewer 還是能讀全部。Viewer 可以讀所有儀表板、查所有 Loki stream。如果 log 裡有個資、token、密碼,AI 一樣讀得到,而且會進到對話紀錄。正確的解法是在收 log 時就遮罩,我在系列第 4 篇用 Alloy 做了遮罩,但 regex 遮罩一定有漏網之魚。
- SA token 外洩時,guardrail 也一起失效。guardrail 和
--loki-enforced-matchers都是 mcp-grafana 的功能。有人直接拿 SA token 打 Grafana API,就能讀到 Viewer 看得到的所有資料,也能下任意大的查詢。這把 token 的保護只能靠檔案權限和到期日。 - guardrail 只管
query_loki_logs。其他 Loki 工具和query_prometheus不在範圍內;一個很長範圍、高基數的 PromQL 一樣可能拖慢機器,而mem_limit: 128m只限制 mcp-grafana 自己。這部分只能靠 Prometheus/Loki 本身的查詢限制。 - helper 腳本是自動執行點。Claude Code 每次連線都會執行它,如果它被改掉(包括被注入的 session 用編輯工具改掉),每次連線都會跑任意指令。
chmod 700只能擋其他使用者,擋不了以我身分執行的程式;在 Claude Code 權限設定裡禁止編輯這個路徑,也只擋得住編輯類工具,擋不了 shell 指令。比較實際的做法是不讓 shell 工具自動核准,並定期比對這支腳本的雜湊值;這兩點是建議,我目前還沒做。 - 可以再限制能讀哪些 stream,但我沒有用。官方有
--loki-enforced-matchers,會把固定的 label 條件 AND 進每一個 Loki 查詢,例如只允許讀environment=~"prod|staging",或排除特定 namespace,而且查詢無法解析時是直接拒絕(fail closed)。它要求同時開--disable-api(我已經開了),官方也提醒get_panel_image等工具會繞過這個限制,要一起關掉(--disable-rendering)。我的 VM 上 log 種類不多,所以這次沒有啟用,也沒有實測,有需要的人可以參考官方 README 的 Loki query enforcement 一節。 - 同一個 Docker 網路裡的容器。mcp-grafana 在容器內綁
0.0.0.0:8000,monitoring 網路上的其他容器可以直接連到它(這是 Docker 的預設行為)。Host 白名單擋不了它們,因為非瀏覽器的程式可以自己填Host: localhost:8765,所以實際上只靠 Bearer token。要再收緊,可以把 mcp-grafana 和 Grafana 放進一個只有它們兩個的獨立網路。這點我沒有另外實測。同樣的道理也適用於 VM 上的其他程序,以及 tunnel 開著時 Mac 上的其他程序:它們在網路層都連得到這個服務,只靠 Bearer token 擋。 - 鑰匙圈不防同一個使用者的程式。呼叫端 token 放在鑰匙圈,只是不寫進設定檔、不進版控;任何以我身分執行的程式(包括被注入的 Claude Code shell 工具)都能用
security find-generic-password讀出來。這把 token 外洩後,能擋住的只剩網路限制。
小結
讓 AI 直接查 Grafana,技術上是一個 docker run 就能完成的事;但要讓我放心地每天用,我做了五層:
- Grafana 權限:Viewer service account,繞過 MCP 直接打 API 寫入一樣 403;
- MCP 工具層:
--disable-write+--disable-api,61 個工具用名稱掃過沒有建立/修改/刪除類,多操作工具也抽測過只剩讀取,萬用 API 工具不存在; - 查詢成本:
--loki-guardrail-mode=enforce,8 天的全量查詢直接被擋,並回改寫建議; - 呼叫端驗證:Bearer token(401)、Host 白名單與 Origin 檢查(403);
- 網路與容器:只綁 127.0.0.1、走 SSH tunnel、唯讀檔案系統、拿掉所有 capability。
回頭看,我覺得最重要的不是哪一個參數,而是兩個原則:最底層一定要是資料來源本身的權限,應用層的保護都可能被繞過;以及假設 AI 一定會讀到惡意內容,那就讓它手上沒有能造成傷害的工具。token 管理則是另一條獨立的線:不貼進對話、不寫進設定檔、各自放在該放的地方。
這篇也是「自架 VM 維運實戰」系列的最後一篇。從 Cloudflare Access 保護後台、Jenkins 部署、Prometheus 與 Loki 監控、k6 壓測、備份上雲,到最後讓 AI 安全地使用這些監控資料,每一步都是在同一台 2 核 VM 上實際做過、實際踩過坑的紀錄。希望對同樣在養一台小 VM 的你有幫助。
系列文章:自架 VM 維運實戰
保護 → 部署 → 觀測 → 調校 → 備份 → AI 協作,建議依序閱讀:
- 第 1 篇:【資安】Cloudflare Access 實戰:不用 VPN 保護後台與 Grafana
- 第 2 篇:【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH
- 第 3 篇:【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警
- 第 4 篇:【監控】Loki + Alloy 集中 Laravel 與 Nginx Log:遮罩與踩坑
- 第 5 篇:【效能】k6 壓測 Laravel Octane:找出 2 核 VM 的真實上限
- 第 6 篇:【備份】MySQL 每日備份上雲:只能上傳不能刪,還要真的還原得回來
- 第 7 篇:【AI】讓 AI 安全查 Grafana:自架官方 mcp-grafana 的五層防護(本篇)
No comments yet.