Roy-Try-Catch
← Back to list

【AI】讓 AI 安全查 Grafana:自架官方 mcp-grafana 的五層防護

Roy • Updated 2026-10-16 03:09:02

前言

監控做完之後,我的 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 injectionlog 內容(request path、User-Agent、錯誤訊息)夾帶指令,AI 讀到後照做①② 只能降低損害,無法根除
一個查詢掃爆 LokiAI 下了沒有篩選條件、範圍好幾天的 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 遲早會讀到惡意內容,那就要確保它讀到之後,手上沒有可以造成傷害的工具。

架構

整條路徑分成五段:

Claude Code 經 headersHelper 取 token、走 SSH tunnel 到 VM 上的 mcp-grafana 容器,再以 Viewer 帳號查 Grafana、Prometheus、Loki,沿路標示五層防護。

請求經 SSH tunnel 進入 mcp-grafana,沿路通過五層防護,只能唯讀查詢。

段落從哪到哪這一段的驗證/限制
1Claude Code → Mac 的 127.0.0.1:8765headersHelper 從 macOS 鑰匙圈讀 token,加上 Authorization: Bearer
2Mac → VM(SSH tunnel)要先能 SSH 登入 VM,流量走 SSH 加密
3VM 127.0.0.1:8765 → 容器 :8000只綁 loopback;mcp-grafana 檢查 Bearer token、Host、Origin
4mcp-grafana → Grafana(monitoring 網路)Viewer 角色的 service account token
5Grafana → Prometheus/LokiGrafana 既有的 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不帶 token401④
2錯誤 token401④
3Host: evil.example.com:8765403④
4帶 Origin: https://evil.example.com403④
5正確 token 初始化200—
6tools/list61 個工具,用名稱掃過沒有 create/update/delete 類;grafana_api_request 不存在②
7user_infomcp-readonly,isGrafanaAdmin=false①
8list_datasources/search_dashboards正常(Loki、Prometheus;儀表板清單)—
9query_prometheus count(up==1)19—
10query_loki_logs {job=~".+"},範圍 8 天被 guardrail 擋下,並回傳改寫建議③
11query_loki_logs {job="nginx"} |= " 500 "(預設 1 小時)正常(0 筆,附 hints)③
12alerting_manage_routing get_contact_points正常(唯讀)②
13alerting_manage_routing delete_contact_point不存在,只允許 get_* 操作②
14繞過 MCP,用 SA token 直接 POST /api/dashboards/db403①
15繞過 MCP,用 SA token 直接建 silence403①

① 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:Host header 不在白名單就回 403。預設值是從 --address 推出的 loopback 組合(例如 localhost:8000,127.0.0.1:8000,[::1]:8000)。
  • --allowed-origins:預設是空的,任何帶 Origin header 的請求都會被拒絕,理由是不應該有瀏覽器直接呼叫這個服務。

實測:測試 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 連 GrafanaGrafana UIVM 上 /etc/mcp-grafana/grafana-sa-token,只有容器 uid 能讀
呼叫端 tokenClaude Code 連 mcp-grafanaVM 上用 openssl rand -hex 32VM 上 /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 天到期。輪替步驟:

  1. 在 Grafana UI 替 mcp-readonly 產生一個新 token;
  2. 用上面的 read -rsp 指令覆寫 /etc/mcp-grafana/grafana-sa-token,確認檔案權限仍是 uid 1000、400(可重跑下面照做順序第 4 步的 chown/chmod);
  3. 因為用的是 GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE,下一個請求就會讀到新 token,不用重啟容器;用 Claude Code 跑一次 user_info 確認正常;
  4. 回 Grafana UI 刪掉舊 token。

呼叫端 token 是透過 env_file 在容器建立時讀入的。部署指令只在 server.conf 不存在時才產生新 token,所以輪替方式是:刪掉 server.conf、重跑產生它的那行、docker compose up -d --force-recreate 重建容器,再用上面的指令更新鑰匙圈。這段流程我還沒實測。

完整部署

照做順序

  1. 在 Grafana 建立 service account mcp-readonly(角色 Viewer),產生 token(建議設到期日);
  2. 在 VM 上用 bash 執行上面的 read -rsp 片段,存 SA token;
  3. 確認外部網路 monitoring 已存在(docker network ls);
  4. 在 VM 上設好權限、產生呼叫端 token、放好 Compose 檔並啟動(指令見清單下方);
  5. Mac 端確認 ssh my-vm 能免互動登入(金鑰+ssh-agent、host key 已接受過):headersHelper 在背景執行,沒辦法回答密碼或 host key 提示;
  6. 用「呼叫端 token:在主機上產生,不經過對話」一節的指令把呼叫端 token 存進鑰匙圈,放好 helper 腳本(內容見下方「Claude Code 端」)並 chmod 700;
  7. 把下面的 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_infomcp-readonly,不是 admin
列出資料來源list_datasources2 個:Loki、Prometheus
有幾個抓取目標是活的query_prometheus:count(up==1)19
查一段 Loki logquery_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 就能完成的事;但要讓我放心地每天用,我做了五層:

  1. Grafana 權限:Viewer service account,繞過 MCP 直接打 API 寫入一樣 403;
  2. MCP 工具層:--disable-write+--disable-api,61 個工具用名稱掃過沒有建立/修改/刪除類,多操作工具也抽測過只剩讀取,萬用 API 工具不存在;
  3. 查詢成本:--loki-guardrail-mode=enforce,8 天的全量查詢直接被擋,並回改寫建議;
  4. 呼叫端驗證:Bearer token(401)、Host 白名單與 Origin 檢查(403);
  5. 網路與容器:只綁 127.0.0.1、走 SSH tunnel、唯讀檔案系統、拿掉所有 capability。

回頭看,我覺得最重要的不是哪一個參數,而是兩個原則:最底層一定要是資料來源本身的權限,應用層的保護都可能被繞過;以及假設 AI 一定會讀到惡意內容,那就讓它手上沒有能造成傷害的工具。token 管理則是另一條獨立的線:不貼進對話、不寫進設定檔、各自放在該放的地方。

這篇也是「自架 VM 維運實戰」系列的最後一篇。從 Cloudflare Access 保護後台、Jenkins 部署、Prometheus 與 Loki 監控、k6 壓測、備份上雲,到最後讓 AI 安全地使用這些監控資料,每一步都是在同一台 2 核 VM 上實際做過、實際踩過坑的紀錄。希望對同樣在養一台小 VM 的你有幫助。

系列文章:自架 VM 維運實戰

保護 → 部署 → 觀測 → 調校 → 備份 → AI 協作,建議依序閱讀:

Comments

No comments yet.

請先登入