用 Go 写一个只读 MCP Server:实测 Kubernetes Pending 排障链路
Pod 一直停在 Pending。如果让 AI 帮忙排查,它首先得拿到集群里的证据:Pod 有没有分配节点、调度条件是什么、调度器留下了哪些事件。
这篇文章实现一个 Go MCP Server,只提供 inspect_pod 工具。客户端传入 Pod 名称,服务端读取 Kubernetes API,再把调度条件与相关 Events 返回给客户端。我们在本地 kind 集群故意制造一次节点标签不匹配,并实际验证调用结果和权限拒绝行为。
验证范围:2026 年 9 月 27 日,本地单节点实验;验证了 MCP 协议调用与 Kubernetes 权限链路,没有调用大模型,也没有测试模型诊断准确率。 文中的原始结果来自这次实验。它是一套可复现的教学实现,尚未验证远程多用户部署。
下载完整源码与实验结果(ZIP)。压缩包包含 go.mod、go.sum、服务端、验证客户端、RBAC 清单、测试和 evidence/ 原始输出,不含集群凭据。
1. MCP 在这条链路里负责什么
MCP(Model Context Protocol,模型上下文协议)让客户端用统一方式发现和调用工具。这里用到的两个操作是 tools/list 和 tools/call,由官方 Go SDK 处理协议消息与数据类型。MCP 工具规范、官方 Go SDK。
本次调用路径如下:
验证客户端 probe
→ stdio:启动本地 Go MCP Server
→ tools/list:发现 inspect_pod
→ tools/call:传入 {"pod":"bad-selector"}
→ client-go:使用专用 ServiceAccount 凭据
→ Kubernetes API:读取 Pod 与 Events
→ MCP structuredContent:返回结构化证据
stdio 用进程的标准输入和标准输出传输消息,因此无需为这个例子开放 HTTP 端口。服务端日志写入标准错误 stderr;普通日志一旦混进 stdout,就可能破坏协议消息流。MCP 传输规范。
probe 是一个确定性验证客户端:它直接调用工具。以后接入支持本地 stdio MCP 的 AI 客户端时,工具发现与调用可以由客户端和模型完成;模型的实际表现需要另行评测。
2. 把实验范围压到一个 namespace、一个 Pod
实验使用以下版本,Go 依赖已固定在下载包中。这是复现记录,不是生产环境版本选型建议。
| 组件 | 本次使用的版本 |
|---|---|
| 操作系统与 Go | macOS arm64,Go 1.26.5 |
| Docker / kind | Docker Engine 29.4.0,kind v0.29.0 |
| Kubernetes 服务端 / kubectl | v1.33.1 / v1.34.1 |
| MCP Go SDK / client-go | v1.8.0 / v0.33.1 |
解压源码,在 mcp-k8s-pending 目录执行下面的命令。前置条件是 Docker 正在运行,已安装 Go、kind、kubectl 和 Python 3。
# 单独保存实验凭据,不改动日常使用的 kubeconfig。
LAB_DIR=$(mktemp -d)
kind create cluster --name hihuo-mcp-20260927 \
--image kindest/node:v1.33.1 \
--kubeconfig "$LAB_DIR/admin.kubeconfig" \
--wait 120s
# 创建实验 Pod、只读 Role,并生成独立的 reader kubeconfig。
python3 setup-reader.py \
"$LAB_DIR/admin.kubeconfig" "$LAB_DIR/reader.kubeconfig"
setup-reader.py 会先确认 context 名称是这个临时 kind 集群,再应用实验清单。它请求一个时长为一小时的 ServiceAccount token,将其写入权限为 0600 的新文件;token 不会打印到终端,实际有效期由 API Server 决定。服务端只使用 reader 文件,管理员文件只用于搭建和清理实验。
故障 Pod 的关键配置是一个故意不存在的节点标签:
apiVersion: v1
kind: Pod
metadata:
name: bad-selector
namespace: mcp-lab
spec:
automountServiceAccountToken: false
nodeSelector:
hihuo.com/lab-pool: deliberately-absent
containers:
- name: pause
image: registry.k8s.io/pause:3.10
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 32Mi
nodeSelector 要求节点具有指定标签。本次实验节点没有这个标签,所以调度器无法给 Pod 选择节点。完整清单见下载包的 manifests/lab.yaml。Kubernetes 节点选择文档。
Role 中的授权刻意收得很窄:
rules:
- apiGroups: [""]
resources: ["pods"]
resourceNames: ["bad-selector"]
verbs: ["get"]
- apiGroups: [""]
resources: ["events"]
verbs: ["list"]
这个 Role 通过 RoleBinding 绑定给 mcp-lab 下的 mcp-reader。Pod 读取被限制到 bad-selector;没有授予读取 Secret、获取日志、进入容器或修改资源的权限。pods/log、pods/exec 是需要分别授权的子资源。Kubernetes RBAC 文档。
有一个边界要讲清楚:Events 的 list 权限覆盖整个 mcp-lab namespace。 后面代码里的 UID 筛选只是查询条件,不能替代授权。如果凭据被其他程序拿到,它仍能列出该 namespace 的其他事件。因此这个例子使用独立实验 namespace;不能声称它已经实现了“只授权某个 Pod 的事件”。
3. 服务端只做证据收集
工具输入只有一个字段,namespace 在启动时固定:
type Input struct {
Pod string `json:"pod" jsonschema:"Pod name in the server's fixed namespace"`
}
服务端没有接收任意 shell 命令的参数,也不调用 kubectl 子进程。它通过 client-go 的类型化客户端访问 API。下面是 main.go 中处理函数的关键部分;完整上下文在源码包中。
if len(validation.IsDNS1123Subdomain(in.Pod)) != 0 {
return nil, out, fmt.Errorf("invalid pod name")
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
pod, err := client.Pods(namespace).Get(ctx, in.Pod, metav1.GetOptions{})
if err != nil {
return nil, out, fmt.Errorf("get pod: %w", err)
}
events, err := client.Events(namespace).List(ctx, metav1.ListOptions{
FieldSelector: fields.OneTermEqualSelector(
"involvedObject.uid", string(pod.UID),
).String(),
Limit: 50,
})
if err != nil {
return nil, out, fmt.Errorf("list events: %w", err)
}
这里有几个影响结果可信度的选择。
第一,Events 按 Pod 的 UID 匹配。删除后重新创建的同名 Pod 是另一个对象,单凭名称关联可能混入前一个对象的事件。
第二,读取 Pod 和 Events 共用一个五秒 deadline。客户端取消请求或截止时间到达后,API 请求会通过 context 接收到取消信号。
第三,事件读取失败就返回工具错误。把“没有权限读取事件”显示成“没有事件”,会给后续诊断提供错误前提。
第四,只取最多 50 条的一页数据,并通过 eventsPartial 表示是否还有后续分页。这个实现没有把它们按最近发生时间排序,也不能保证拿到全部事件。返回内容还包含 observedAt,便于知道采集时间。
最后,Pod 和 Events 是两次独立读取,不构成原子快照。集群状态可能在两次请求之间改变,消费方应把结果当作带时间边界的观察记录。
注册工具时设置了 ReadOnlyHint: true,供客户端理解工具用途。这个标记只是描述,真正拦截 Kubernetes 越权请求的是 API Server 的 RBAC。 把管理员 kubeconfig 交给服务端,不能靠这个标记变成只读权限。
返回值也经过字段选择:只保留 Pod 名称、UID、阶段、节点选择条件、调度条件和事件。没有返回完整 Pod YAML,因而不会顺手把容器环境变量一起送出去。不过事件消息本身仍可能包含敏感信息;“只读”也不等于“数据可以公开”。
4. 跑通协议,再看真实输出
编译服务端和验证客户端:
# 从下载包目录执行。
go build -o bin/mcp-k8s .
go build -o bin/probe ./cmd/probe
go test -race ./...
go vet ./...
# 自动等待故障事件出现,并验证正常调用和拒绝场景。
python3 verify.py "$LAB_DIR/reader.kubeconfig"
probe 会启动服务端,先通过 tools/list 确认只有 inspect_pod,再执行 tools/call。服务端返回的 structuredContent 可以被程序读取,文本内容则便于客户端展示。
下面是本次 evidence/pending.json 中 structuredContent 的字段节选。省略了 UID、采集时间等字段;消息保留实验中的原文。
{
"namespace": "mcp-lab",
"pod": "bad-selector",
"phase": "Pending",
"nodeName": "",
"nodeSelector": {
"hihuo.com/lab-pool": "deliberately-absent"
},
"conditions": [
{
"type": "PodScheduled",
"status": "False",
"reason": "Unschedulable",
"message": "0/1 nodes are available: 1 node(s) didn't match Pod's node affinity/selector. preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling."
}
],
"events": [
{
"reason": "FailedScheduling",
"message": "0/1 nodes are available: 1 node(s) didn't match Pod's node affinity/selector. preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.",
"count": 1
}
],
"eventsPartial": false
}
这组证据能支持的结论是:本次采集时 Pod 尚未分配节点,调度条件为不可调度,调度器报告节点与 affinity/selector 不匹配。
结合我们主动设置的 nodeSelector,再以管理员身份检查实验节点标签,可以把故障收敛到这个人为设置的约束:
# 这是实验人员核对环境的命令;MCP reader 没有读取节点的授权。
kubectl --kubeconfig "$LAB_DIR/admin.kubeconfig" \
get nodes -L hihuo.com/lab-pool
本次输出中该标签列为空。解决实际问题时,还需要确认业务希望 Pod 落到哪类节点,才能决定修改 Pod 选择条件,还是给合适的节点配置标签。工具收集到的这组数据本身不足以替业务作这个决定。
也不能把 Pending 一概解释成标签问题:Pod 等待容器创建时同样可能处于这个阶段。排查应继续看调度条件、节点分配和事件,而不是只看阶段名称。Kubernetes Pod 排错文档。
5. 拒绝场景也要真正跑一次
本次 verify.py 输出如下,完整记录见 evidence/verification.txt:
PASS MCP tools/list + tools/call: Pending / Unschedulable / FailedScheduling
PASS MCP: another Pod is denied by Kubernetes RBAC
PASS MCP: invalid Pod name is rejected
PASS RBAC rejects: list Secrets
PASS RBAC rejects: read Pod logs
PASS RBAC rejects: read another namespace
PASS RBAC rejects: patch Pod
这些检查分成三层,避免把不同的验证混在一起:
| 层次 | 做了什么 | 验证了什么 |
|---|---|---|
| MCP 集成 | 启动真实服务端,发现工具,调用正常 Pod、其他 Pod 和非法名称 | 协议调用、结构化结果与工具错误返回 |
| Kubernetes 授权 | 用同一个 reader kubeconfig 实际请求 Secrets、日志、另一 namespace 和 Pod patch | API Server 拒绝这些操作 |
| Go 单元测试 | 使用模拟 HTTP API,检查 UID 筛选、分页标记、非法输入、事件接口拒绝 | 指定代码分支的行为;这部分使用的是模拟接口 |
例如读取 other-pod 的 MCP 响应返回 isError: true,错误里有 forbidden。即使这个名字对应的 Pod 不存在,API Server 也会先因权限不足拒绝请求,这个测试验证的是授权拒绝,不是在判断该 Pod 是否存在。
单元测试还覆盖了事件接口返回 403 的情况,确保它不会被吞成空事件数组。go test -race ./... 和 go vet ./... 在上述环境通过;这些结果不代表已经完成高并发压测或生产安全审计。
每次自行运行验证脚本,evidence/ 会更新成你自己的结果。Pod UID、时间戳、事件次数以及调度消息可能不同,判断应以条件和原因字段为主。
6. 接给 AI 客户端之前,还差哪些边界
对于支持 stdio MCP 的客户端,服务端启动信息是:
command: /绝对路径/mcp-k8s-pending/bin/mcp-k8s
args:
-kubeconfig /绝对路径/reader.kubeconfig
-namespace mcp-lab
不同客户端的配置文件格式不同,上面给的是进程启动参数,不是可以通用于所有产品的配置 JSON。配置凭据路径即可,不要把 token 粘贴进对话。reader token 过期后,需重新生成凭据并更新启动参数。
消费工具结果时,可以要求客户端区分“从字段直接观察到的事实”和“仍待确认的原因”。例如优先报告 PodScheduled=False 与 FailedScheduling,再提出核对节点标签的下一步。
事件文本应作为外部数据处理,其中出现的自然语言不能升级成对模型的指令。工具描述中已经标明这个约束,但提示文字本身不能证明提示注入防护有效;本次也没有开展模型层面的对抗测试。
如果继续扩展到远程 HTTP 服务,还需要单独设计调用者认证、每个用户可访问的集群与 namespace、凭据轮换、数据脱敏和审计留存。当前程序只覆盖本地进程与一个实验 namespace,这些能力没有在本文实现。
实验完成后清理自己的临时集群和凭据:
kind delete cluster --name hihuo-mcp-20260927
rm "$LAB_DIR/admin.kubeconfig" "$LAB_DIR/reader.kubeconfig"
rmdir "$LAB_DIR"
下一步可以沿用这套协议与授权验证方法,增加一个具体故障场景,例如 PVC 未绑定。每增加一个工具,都同时补上它应当成功和应当被拒绝的请求,再讨论模型能否正确使用这些证据。