用 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 依赖已固定在下载包中。这是复现记录,不是生产环境版本选型建议。

组件本次使用的版本
操作系统与 GomacOS arm64,Go 1.26.5
Docker / kindDocker Engine 29.4.0,kind v0.29.0
Kubernetes 服务端 / kubectlv1.33.1 / v1.34.1
MCP Go SDK / client-gov1.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 patchAPI 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 未绑定。每增加一个工具,都同时补上它应当成功和应当被拒绝的请求,再讨论模型能否正确使用这些证据。

相关阅读