{"slug": "build-an-mcp-server-in-go-part-1-designing-a-diagnostic-grade-kubernetes-client", "title": "Build an MCP Server in Go (Part 1): Designing a diagnostic-grade Kubernetes client", "summary": "A developer detailed the design of a diagnostic-grade Kubernetes client in Go, the foundation for a planned Model Context Protocol (MCP) server. The client mirrors the troubleshooting steps a senior engineer takes, such as checking pod state, events, and endpoints, to enable AI agents to diagnose failures. The post contrasts this with the developer's earlier CLI tool, ferctl, which runs predetermined commands but cannot adapt its next step based on previous output.", "body_md": "*This post designs the Kubernetes client.* *The next post* *wraps it as an MCP server and wires it to an agent.*\n\nThere's a familiar rhythm to debugging a bad deploy: `kubectl get pods`\n\n, spot the `CrashLoopBackOff`\n\n, `kubectl logs --previous`\n\n, still not obvious, `kubectl describe pod`\n\n, scroll to Events, cross-reference with `kubectl get events`\n\n, maybe check if the Service even has endpoints. Five or six commands, in your head, in a specific order, because you've done this enough times to know the order.\n\nThat order is exactly what an AI agent can execute for you, if you give it the right tools, ask \"why is `checkout-service`\n\nfailing?\" and have it chain through the same commands you would have, arriving at an actual answer instead of a wall of YAML. The protocol that makes an agent capable of calling tools like that is the Model Context Protocol (MCP), and building that server is part 2 of this series.\n\nThis post is about what comes first and matters more: the Kubernetes client those tools will eventually sit on top of. An agent is only as good as what it's allowed to ask for. Hand it a client with one `ListPods`\n\nmethod and it can list pods, nothing else. Hand it a client that mirrors what a senior engineer actually checks when something's broken, pod state, events, endpoints, node capacity, rollout history, and it can genuinely diagnose. That client is a real piece of engineering on its own, independent of whether an LLM ever touches it, which is why it gets its own post before MCP enters the picture at all.\n\n`ferctl`\n\nvs. an MCP server: two answers to the same problem\n`ferctl`\n\nwas my first attempt at this: a Cobra CLI, backed by client-go, that wraps common troubleshooting checks into subcommands: `ferctl top`\n\n, `ferctl logs`\n\n, that shape. It's fast, deterministic, and scriptable. Run `ferctl describe-pod checkout-service`\n\n, get the same structured output every time, pipe it into`jq`\n\n, drop it into a CI step, no ambiguity about what ran or why. That predictability is exactly what a CLI is good at.\n\nWhat it doesn't do is investigate. `ferctl`\n\nruns the one command you gave it, you're still the one who has to know that a `CrashLoopBackOff`\n\nmeans \"check previous logs, then check events\", and you're still typing each step by hand. It's a faster way to run the commands you already know, not a way to skip learning them.\n\nAn MCP server flips that. You ask \"why is `checkout-service`\n\nfailing\" once, and the agent decides the sequence, list pods, notices the restart count, pulls previous logs, cross-references events, the way a `ferctl`\n\ninvocation never will, because no single subcommand can adapt its next step to what the last one returned. That's the shift this series is really about: from \"a faster way to run known commands\" to \"something that can figure out which commands to run.\" It's also, frankly, the more current way to build this kind of tooling, a fixed CLI surface is a fine interface for a human who already knows the shape of the problem, but an LLM-driven agent chaining calls dynamically is a better fit for the actual shape of debugging, which rarely follows a script.\n\nNone of that makes `ferctl`\n\nobsolete. A CLI is still the right tool when you want guaranteed, repeatable output, a CI health check, a pre-deploy gate, anything where non-determinism is a bug, not a feature. An agent is the right tool when the problem is exploratory, and you don't yet know which three commands you'll need. They're not really competitors; they're two different interfaces that answer \"is my cluster healthy\" in two different situations. And notably, both could sit on top of the exact same `KubeClient`\n\nthis post builds, the interface doesn't care whether its caller is a Cobra command or an MCP tool handler, which is itself a small argument for designing the client first, independent of either.\n\n```\ngo-k8s-mcp-server/\n├── go.mod\n└── internal/\n    └── kubernetes/\n        ├── client.go           ← KubeClient interface, constructor\n        ├── pods.go             ← PodClient implementation\n        ├── workloads.go        ← WorkloadClient implementation\n        ├── nodes.go            ← NodeClient implementation\n        ├── events.go           ← EventClient implementation\n        ├── network.go          ← NetworkClient implementation\n        ├── ingress.go          ← IngressClient implementation\n        ├── gateway.go          ← GatewayClient implementation\n        ├── config.go           ← ConfigClient implementation\n        ├── storage.go          ← StorageClient implementation\n        └── metrics.go          ← MetricsClient implementation\n```\n\nThis is only the client half of the project, next post adds `cmd/k8s-mcp-server/`\n\nand `internal/tools/`\n\non top of this same `internal/kubernetes/`\n\npackage. The `internal/`\n\nboundary and one-package-per-domain layout follow the same convention laid out in [Go packages and modules explained](https://ferztyle.me/go-packages-and-modules-explained): `internal/`\n\nfor everything the Go toolchain should keep private to this module, one focused file per concern rather than one large one.\n\nIf you haven't set up a client-go connection before, this post assumes that groundwork. I covered kubeconfig loading, the Clientset, and how API groups work in [Talking to Kubernetes from Go: a practical client-go guide](https://ferztyle.me/talking-to-kubernetes-from-go-a-practical-client-go-guide). Everything below builds directly on that same connection pattern, same `rest.Config`\n\nsetup, same in-cluster/out-of-cluster fallback, same \"wrap the Clientset behind an interface\" philosophy. What's new here is the shape of the interface itself.\n\n`KubeClient`\n\ninterface\nA tempting first move is one fat interface with every method the agent might ever need. Resist it. A twenty-method interface is hard to test, hard to fake, and it hides the fact that these methods address genuinely different diagnostic concerns: some are about pods, some are about scheduling, some are about networking. Go rewards small interfaces, so we compose one from several.\n\nIf you're starting this project fresh rather than following along from the client-go post, pin the packages to your cluster's version, same as before, mixing minor versions across them breaks at compile time. `k8s.io/metrics`\n\nand `sigs.k8s.io/gateway-api`\n\nare included here too, even though neither is used until their respective sub-clients further down, because the `client`\n\nstruct and `NewClient`\n\nconstructor right below already need both:\n\n```\nmkdir go-k8s-mcp-server && cd go-k8s-mcp-server\ngo mod init github.com/FerRiosCosta/go-k8s-mcp-server\n\ngo get k8s.io/client-go@v0.36.2\ngo get k8s.io/api@v0.36.2\ngo get k8s.io/apimachinery@v0.36.2\ngo get k8s.io/metrics@v0.36.2\ngo get sigs.k8s.io/gateway-api@v1.3.0\ngo mod tidy\n// internal/kubernetes/client.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    \"k8s.io/client-go/kubernetes\"\n    \"k8s.io/client-go/rest\"\n    \"k8s.io/client-go/tools/clientcmd\"\n    metricsclientset \"k8s.io/metrics/pkg/client/clientset/versioned\"\n    gatewayclientset \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned\"\n)\n\n// KubeClient is the full set of read-only diagnostic operations the\n// MCP server exposes as tools. It's composed from smaller, domain-specific\n// interfaces so each one stays independently testable and each\n// implementation file has exactly one reason to change.\n//\n// Every method here is read-only by design. There is no Delete, Scale,\n// or Patch anywhere in this interface, that's not an accident, it's\n// the security boundary. See \"Read-only by construction\" below.\ntype KubeClient interface {\n    PodClient\n    WorkloadClient\n    NodeClient\n    EventClient\n    NetworkClient\n    IngressClient\n    GatewayClient\n    ConfigClient\n    StorageClient\n    MetricsClient\n}\n\n// client is the concrete implementation every sub-interface method\n// below is defined on. It holds three Clientsets: the core Clientset\n// for everything except metrics and Gateway API, a metrics-server\n// Clientset (the split the client-go post flagged as needed once\n// k8s.io/metrics entered the picture), and a Gateway API Clientset,\n// Gateway API is CRD-based, so it isn't reachable through the core\n// Clientset the way Ingress is.\ntype client struct {\n    clientset        kubernetes.Interface\n    metricsClientset metricsclientset.Interface\n    gatewayClientset gatewayclientset.Interface\n}\n\n// NewClient builds a KubeClient the same way kubectl resolves its\n// config: in-cluster config if running inside a pod, otherwise\n// $KUBECONFIG if set, otherwise ~/.kube/config, and whichever\n// context is marked current-context in that file, automatically.\n// There's no path parameter here on purpose: hardcoding a path means\n// this server silently ignores context switches made with\n// `kubectl config use-context`, which is exactly the surprise you\n// don't want from a diagnostic tool.\nfunc NewClient() (KubeClient, error) {\n    config, err := buildConfig()\n    if err != nil {\n        return nil, fmt.Errorf(\"build config: %w\", err)\n    }\n\n    clientset, err := kubernetes.NewForConfig(config)\n    if err != nil {\n        return nil, fmt.Errorf(\"create clientset: %w\", err)\n    }\n\n    metricsClientset, err := metricsclientset.NewForConfig(config)\n    if err != nil {\n        return nil, fmt.Errorf(\"create metrics clientset: %w\", err)\n    }\n\n    gatewayClientset, err := gatewayclientset.NewForConfig(config)\n    if err != nil {\n        return nil, fmt.Errorf(\"create gateway clientset: %w\", err)\n    }\n\n    return &client{\n        clientset:        clientset,\n        metricsClientset: metricsClientset,\n        gatewayClientset: gatewayClientset,\n    }, nil\n}\n\nfunc buildConfig() (*rest.Config, error) {\n    if config, err := rest.InClusterConfig(); err == nil {\n        return config, nil\n    }\n\n    // NewDefaultClientConfigLoadingRules checks $KUBECONFIG first\n    // (colon-separated on Linux/macOS, semicolon on Windows, merging\n    // multiple files if listed), then falls back to ~/.kube/config.\n    // ConfigOverrides{} is empty on purpose, an empty overrides\n    // struct means \"use whatever current-context is set,\" the same\n    // default clientcmd.BuildConfigFromFlags gave us before, just\n    // without hardcoding the path ourselves.\n    loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()\n    overrides := &clientcmd.ConfigOverrides{}\n    return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides).ClientConfig()\n}\n```\n\nSame packages as the client-go post's `client.go`\n\n, plus two additions: `metricsclientset`\n\nfrom `k8s.io/metrics`\n\n, and `gatewayclientset`\n\nfrom `sigs.k8s.io/gateway-api`\n\n, both alongside the core Clientset. `gatewayClientset`\n\nwill show `NewForConfig`\n\nsucceed even against a cluster with no Gateway API CRDs installed; it's just a REST client pointed at a set of API paths, and only fails once you actually call it against a cluster that doesn't serve them. That failure mode gets called out concretely in the Gateway API section below.\n\nEvery method in the sections below is defined on this same `*client`\n\ntype, one file per domain.\n\n```\n// internal/kubernetes/pods.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n    \"io\"\n\n    corev1 \"k8s.io/api/core/v1\"\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// PodClient covers the operations that answer \"what's wrong with this pod.\"\ntype PodClient interface {\n    // ListPods returns pods in namespace. An empty namespace returns\n    // pods from every namespace, same convention as ListPods in the\n    // client-go guide.\n    ListPods(ctx context.Context, namespace string) (*corev1.PodList, error)\n\n    // GetPod returns a single pod by name, used once the agent has\n    // narrowed down which pod it cares about.\n    GetPod(ctx context.Context, namespace, name string) (*corev1.Pod, error)\n\n    // GetPodLogs returns log output for one container in a pod.\n    GetPodLogs(ctx context.Context, namespace, name string, opts LogOptions) (string, error)\n}\n\n// LogOptions bounds a log request. This type exists specifically so an\n// agent can never accidentally pull megabytes of logs in a single tool\n// call, TailLines and SinceSeconds are the caps, not suggestions.\ntype LogOptions struct {\n    Container    string // empty selects the pod's first container\n    Previous     bool   // true fetches the last terminated instance's logs,\n                         // essential for CrashLoopBackOff, since the *current*\n                         // instance often hasn't logged the failure yet\n    TailLines    int64  // 0 falls back to a safe default, never \"unbounded\"\n    SinceSeconds int64  // 0 means no time bound\n}\n\nconst defaultLogTailLines = 200\n\nfunc (c *client) ListPods(ctx context.Context, namespace string) (*corev1.PodList, error) {\n    pods, err := c.clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"list pods namespace=%q: %w\", namespace, err)\n    }\n    return pods, nil\n}\n\nfunc (c *client) GetPod(ctx context.Context, namespace, name string) (*corev1.Pod, error) {\n    pod, err := c.clientset.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get pod namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    return pod, nil\n}\n\nfunc (c *client) GetPodLogs(ctx context.Context, namespace, name string, opts LogOptions) (string, error) {\n    tail := opts.TailLines\n    if tail <= 0 {\n        tail = defaultLogTailLines\n    }\n\n    podLogOpts := &corev1.PodLogOptions{\n        Container: opts.Container,\n        Previous:  opts.Previous,\n        TailLines: &tail,\n    }\n    if opts.SinceSeconds > 0 {\n        podLogOpts.SinceSeconds = &opts.SinceSeconds\n    }\n\n    req := c.clientset.CoreV1().Pods(namespace).GetLogs(name, podLogOpts)\n    stream, err := req.Stream(ctx)\n    if err != nil {\n        return \"\", fmt.Errorf(\"stream logs namespace=%q pod=%q: %w\", namespace, name, err)\n    }\n    defer stream.Close()\n\n    data, err := io.ReadAll(stream)\n    if err != nil {\n        return \"\", fmt.Errorf(\"read logs namespace=%q pod=%q: %w\", namespace, name, err)\n    }\n    return string(data), nil\n}\n```\n\nNote that `TailLines`\n\ndefaults to `200`\n\n, not `0`\n\nmeaning unbounded. That single line is the difference between a tool an agent can call freely and a tool that occasionally hands it 40,000 lines of stack traces and blows past its context window on one call.\n\n```\n// internal/kubernetes/events.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    corev1 \"k8s.io/api/core/v1\"\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// EventClient covers the Events API, which frequently surfaces a root\n// cause, a failed scheduling attempt, an image pull error, before\n// anything else does. It's queried by involved object, not by name,\n// which is different enough from PodClient to earn its own file.\ntype EventClient interface {\n    // GetEvents returns events in namespace, optionally filtered to\n    // those involving a specific object name (e.g. a Pod).\n    // An empty involvedObjectName returns all events in the namespace.\n    GetEvents(ctx context.Context, namespace, involvedObjectName string) ([]corev1.Event, error)\n}\n\nfunc (c *client) GetEvents(ctx context.Context, namespace, involvedObjectName string) ([]corev1.Event, error) {\n    opts := metav1.ListOptions{}\n    if involvedObjectName != \"\" {\n        opts.FieldSelector = \"involvedObject.name=\" + involvedObjectName\n    }\n\n    events, err := c.clientset.CoreV1().Events(namespace).List(ctx, opts)\n    if err != nil {\n        return nil, fmt.Errorf(\"list events namespace=%q object=%q: %w\", namespace, involvedObjectName, err)\n    }\n    return events.Items, nil\n}\n```\n\nNot every failure lives at the pod level. A deployment that's stuck rolling out, or one whose pods belong to two different generations at once, needs a different query shape entirely: you're not asking about one pod anymore, you're asking about the relationship between a Deployment and the ReplicaSets it owns.\n\n```\n// internal/kubernetes/workloads.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    appsv1 \"k8s.io/api/apps/v1\"\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// WorkloadClient covers Deployments and the ReplicaSets they own,\n// the layer above individual pods, where rollout and scheduling\n// problems that span multiple pods actually live.\ntype WorkloadClient interface {\n    // GetDeployment returns a Deployment's spec and status, including\n    // desired vs. available replica counts and rollout conditions.\n    GetDeployment(ctx context.Context, namespace, name string) (*appsv1.Deployment, error)\n\n    // ListReplicaSets finds every ReplicaSet matching labelSelector in\n    // a namespace. This is what surfaces orphaned or stuck ReplicaSets,\n    // the classic \"Deployment says 3/3 but two of those pods belong to\n    // the old ReplicaSet\" failed-rollout scenario, which GetDeployment\n    // alone won't show you.\n    ListReplicaSets(ctx context.Context, namespace, labelSelector string) (*appsv1.ReplicaSetList, error)\n}\n\nfunc (c *client) GetDeployment(ctx context.Context, namespace, name string) (*appsv1.Deployment, error) {\n    deploy, err := c.clientset.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get deployment namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    return deploy, nil\n}\n\nfunc (c *client) ListReplicaSets(ctx context.Context, namespace, labelSelector string) (*appsv1.ReplicaSetList, error) {\n    rs, err := c.clientset.AppsV1().ReplicaSets(namespace).List(ctx, metav1.ListOptions{\n        LabelSelector: labelSelector,\n    })\n    if err != nil {\n        return nil, fmt.Errorf(\"list replicasets namespace=%q selector=%q: %w\", namespace, labelSelector, err)\n    }\n    return rs, nil\n}\n```\n\n`ListReplicaSets`\n\ntakes a label selector rather than a Deployment name because that's what the ReplicaSets API actually indexes on, a Deployment doesn't \"contain\" its ReplicaSets, it selects them by label, the same way a Service selects its pods. To go from \"diagnose this Deployment\" to \"here are its ReplicaSets,\" a tool built on this interface pulls the selector off the Deployment's spec first (`deploy.Spec.Selector`\n\n), then passes that into `ListReplicaSets`\n\n. That two-step shape is exactly why this stays two methods instead of one convenience method that hides the relationship, the tool layer is where composing them into a single `diagnose_rollout`\n\n-style tool belongs, not the client.\n\n```\n// internal/kubernetes/nodes.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    corev1 \"k8s.io/api/core/v1\"\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// NodeClient covers cluster capacity, what you need to explain a\n// \"0/3 nodes are available\" scheduling failure, which is a node-level\n// question, not a pod-level one.\ntype NodeClient interface {\n    ListNodes(ctx context.Context) (*corev1.NodeList, error)\n\n    // GetNode returns conditions (MemoryPressure, DiskPressure,\n    // PIDPressure), taints, and allocatable resources for one node.\n    GetNode(ctx context.Context, name string) (*corev1.Node, error)\n}\n\nfunc (c *client) ListNodes(ctx context.Context) (*corev1.NodeList, error) {\n    nodes, err := c.clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"list nodes: %w\", err)\n    }\n    return nodes, nil\n}\n\nfunc (c *client) GetNode(ctx context.Context, name string) (*corev1.Node, error) {\n    node, err := c.clientset.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get node name=%q: %w\", name, err)\n    }\n    return node, nil\n}\n// internal/kubernetes/network.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    corev1 \"k8s.io/api/core/v1\"\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// NetworkClient covers Services and Endpoints. A Service existing\n// doesn't mean anything is listening behind it, that's exactly the\n// gap GetEndpoints is here to close.\ntype NetworkClient interface {\n    GetService(ctx context.Context, namespace, name string) (*corev1.Service, error)\n\n    // GetEndpoints checks whether a Service actually has healthy pods\n    // backing it. Zero endpoints despite matching pods usually means a\n    // label selector mismatch or a failing readiness probe, one of the\n    // most common \"works locally, broken in cluster\" bugs.\n    GetEndpoints(ctx context.Context, namespace, serviceName string) (*corev1.Endpoints, error)\n}\n\nfunc (c *client) GetService(ctx context.Context, namespace, name string) (*corev1.Service, error) {\n    svc, err := c.clientset.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get service namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    return svc, nil\n}\n\nfunc (c *client) GetEndpoints(ctx context.Context, namespace, serviceName string) (*corev1.Endpoints, error) {\n    // The Endpoints object shares its name with the Service it backs,\n    // Kubernetes creates and keeps it in sync automatically, so no\n    // separate lookup by label is needed here.\n    eps, err := c.clientset.CoreV1().Endpoints(namespace).Get(ctx, serviceName, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get endpoints namespace=%q service=%q: %w\", namespace, serviceName, err)\n    }\n    return eps, nil\n}\n```\n\nA healthy Service with healthy endpoints still means nothing to an external caller if the layer in front of it, the Ingress, and on EKS almost always the AWS Load Balancer Controller (LBC) behind it, never finished provisioning. This is a different failure class from anything `NetworkClient`\n\ncovers: the Service can be perfectly correct and traffic still never arrives, because the ALB was never created, was created against the wrong subnets, or is pointing at the wrong target group.\n\n```\n// internal/kubernetes/ingress.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    networkingv1 \"k8s.io/api/networking/v1\"\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// IngressClient covers classic Ingress resources, on EKS, almost\n// always reconciled by the AWS Load Balancer Controller into an ALB.\n// No AWS API calls happen here; everything comes from the Kubernetes\n// object the controller writes status and events back onto, which is\n// usually enough to tell you where reconciliation stalled.\ntype IngressClient interface {\n    GetIngress(ctx context.Context, namespace, name string) (*networkingv1.Ingress, error)\n\n    // ListIngressClasses lets a diagnosis confirm the Ingress's\n    // ingressClassName actually exists and matches a real controller.\n    // A typo'd or missing class is a common reason an Ingress just\n    // sits there, the LBC never picks it up, and nothing in the\n    // Ingress object itself says why.\n    ListIngressClasses(ctx context.Context) (*networkingv1.IngressClassList, error)\n}\n\nfunc (c *client) GetIngress(ctx context.Context, namespace, name string) (*networkingv1.Ingress, error) {\n    ing, err := c.clientset.NetworkingV1().Ingresses(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get ingress namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    return ing, nil\n}\n\nfunc (c *client) ListIngressClasses(ctx context.Context) (*networkingv1.IngressClassList, error) {\n    classes, err := c.clientset.NetworkingV1().IngressClasses().List(ctx, metav1.ListOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"list ingress classes: %w\", err)\n    }\n    return classes, nil\n}\n```\n\nThree things matter when reading an `Ingress`\n\nobject for diagnosis, worth knowing even before the tool layer wraps this in part 2:\n\n`ingress.Status.LoadBalancer.Ingress`\n\n: empty means the LBC either hasn't reconciled yet or is stuck. A populated hostname (the ALB's DNS name) means AWS-side provisioning succeeded; an empty one after more than a minute or two almost always means the LBC is failing, not just slow.\n\n`ingress.Spec.IngressClassName`\n\n: cross-reference this against `ListIngressClasses`\n\noutput. A `nil`\n\nor misspelled class is the single most common reason an Ingress is silently ignored, no error on the object itself, the LBC just never claims it.\n\n**Events on the Ingress object**: this is where `EventClient.GetEvents`\n\n, already built above, becomes directly useful here without any new code: the LBC writes events like `SuccessfullyReconciled`\n\non success, and specific failure reasons (invalid target group, subnet tagging problems, certificate ARN not found) as `Warning`\n\nevents when reconciliation fails. Composing `GetIngress`\n\nwith `GetEvents`\n\nis exactly the same pattern `describe_pod`\n\nused for pods, applied one layer up the stack.\n\nGateway API is the follow-on to Ingress: instead of one annotation-heavy resource, traffic routing splits across a `Gateway`\n\n(the listener; where traffic enters, which addresses/ports it accepts) and route resources like `HTTPRoute`\n\n(how it's matched and where it goes). EKS supports it the same way it supports Ingress, the AWS Load Balancer Controller reconciles `Gateway`\n\n/`HTTPRoute`\n\ninto an ALB, the same way it reconciles `Ingress`\n\n.\n\nThe client-side difference is real, not cosmetic: Gateway API types are CRDs, not part of the core Kubernetes API, so they need their own Clientset, the `gatewayClientset`\n\nalready added to the `client`\n\nstruct above.\n\n```\n// internal/kubernetes/gateway.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    gatewayv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// GatewayClient covers Gateway API's two core resources. Both come\n// back with rich status.conditions, Gateway API standardizes on\n// Accepted/Programmed for Gateways and Accepted/ResolvedRefs for\n// routes, which is a more structured diagnostic signal than Ingress\n// ever gave you: no need to infer state from events alone.\ntype GatewayClient interface {\n    GetGateway(ctx context.Context, namespace, name string) (*gatewayv1.Gateway, error)\n\n    // GetHTTPRoute returns the route's status, including\n    // per-parent-Gateway conditions. ResolvedRefs=False on a route\n    // almost always means a backendRef points at a Service or port\n    // that doesn't exist, check it before assuming the problem is\n    // upstream at the Gateway.\n    GetHTTPRoute(ctx context.Context, namespace, name string) (*gatewayv1.HTTPRoute, error)\n}\n\nfunc (c *client) GetGateway(ctx context.Context, namespace, name string) (*gatewayv1.Gateway, error) {\n    gw, err := c.gatewayClientset.GatewayV1().Gateways(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get gateway namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    return gw, nil\n}\n\nfunc (c *client) GetHTTPRoute(ctx context.Context, namespace, name string) (*gatewayv1.HTTPRoute, error) {\n    route, err := c.gatewayClientset.GatewayV1().HTTPRoutes(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get httproute namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    return route, nil\n}\n```\n\nDiagnosis here is a two-level check, and skipping the first level is the most common mistake:\n\n`gateway.Status.Conditions`\n\n: check `Accepted`\n\nand `Programmed`\n\nfirst. If the `Gateway`\n\nitself isn't `Programmed`\n\n, no `HTTPRoute`\n\nattached to it can possibly work, no matter how correct that route is. This is the equivalent of checking `describe_pod`\n\n's container state before chasing application-level logs, start at the layer closest to the infrastructure.\n\n`httproute.Status.Parents[].Conditions`\n\n: a route can reference multiple Gateways, so status is reported per-parent, not once. `ResolvedRefs: False`\n\nspecifically means a `backendRef`\n\n, the Service and port the route sends traffic to, doesn't resolve. That's diagnosable the same way `check_endpoints`\n\ndiagnoses a plain Service: the route's `backendRefs[].name`\n\nand `.port`\n\nare exactly what `GetService`\n\n/`GetEndpoints`\n\nfrom earlier need to cross-check.\n\n**A cluster without Gateway API CRDs installed**: surfaces as a real error from `GetGateway`\n\n/`GetHTTPRoute`\n\n, a `NotFound`\n\n-shaped error on the CRD's group/version, not on the specific object. Worth catching and wrapping with a clearer message at the tool layer in part 2, the same way `pod_metrics`\n\nwraps a missing metrics-server as a clearer error than the bare API response gives you.\n\n```\n// internal/kubernetes/config.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// ConfigClient deliberately returns key names only, for both\n// ConfigMaps and Secrets. An agent diagnosing a missing env var needs\n// to know a key exists, never what it contains, see \"Read-only by\n// construction\" below for why that boundary lives here and not in a\n// tool description.\ntype ConfigClient interface {\n    GetConfigMapKeys(ctx context.Context, namespace, name string) ([]string, error)\n    GetSecretKeys(ctx context.Context, namespace, name string) ([]string, error)\n}\n\nfunc (c *client) GetConfigMapKeys(ctx context.Context, namespace, name string) ([]string, error) {\n    cm, err := c.clientset.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get configmap namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    keys := make([]string, 0, len(cm.Data))\n    for k := range cm.Data {\n        keys = append(keys, k)\n    }\n    return keys, nil\n}\n\nfunc (c *client) GetSecretKeys(ctx context.Context, namespace, name string) ([]string, error) {\n    secret, err := c.clientset.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get secret namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    // secret.Data is map[string][]byte, the values are deliberately\n    // never read here, only the key names.\n    keys := make([]string, 0, len(secret.Data))\n    for k := range secret.Data {\n        keys = append(keys, k)\n    }\n    return keys, nil\n}\n// internal/kubernetes/storage.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    corev1 \"k8s.io/api/core/v1\"\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// StorageClient surfaces the \"pod stuck Pending because its volume\n// can't bind\" class of failure, a Pending PVC explains a Pending pod\n// that otherwise looks like a scheduling mystery.\ntype StorageClient interface {\n    ListPVCs(ctx context.Context, namespace string) (*corev1.PersistentVolumeClaimList, error)\n}\n\nfunc (c *client) ListPVCs(ctx context.Context, namespace string) (*corev1.PersistentVolumeClaimList, error) {\n    pvcs, err := c.clientset.CoreV1().PersistentVolumeClaims(namespace).List(ctx, metav1.ListOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"list pvcs namespace=%q: %w\", namespace, err)\n    }\n    return pvcs, nil\n}\n```\n\n`k8s.io/metrics`\n\nwas already installed above alongside the three core packages, since `NewClient`\n\nneeded `metricsclientset`\n\nbefore this section even started. This is where it actually gets used:\n\n```\n// internal/kubernetes/metrics.go\npackage kubernetes\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    metav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n    metricsv1beta1 \"k8s.io/metrics/pkg/apis/metrics/v1beta1\"\n)\n\n// MetricsClient reads from the metrics-server extension API, a\n// separate Clientset from the core one, same as it was flagged in the\n// client-go post's \"what's next\" for the ferctl top CLI.\ntype MetricsClient interface {\n    GetPodMetrics(ctx context.Context, namespace, name string) (*metricsv1beta1.PodMetrics, error)\n    GetNodeMetrics(ctx context.Context, name string) (*metricsv1beta1.NodeMetrics, error)\n}\n\nfunc (c *client) GetPodMetrics(ctx context.Context, namespace, name string) (*metricsv1beta1.PodMetrics, error) {\n    m, err := c.metricsClientset.MetricsV1beta1().PodMetricses(namespace).Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get pod metrics namespace=%q name=%q: %w\", namespace, name, err)\n    }\n    return m, nil\n}\n\nfunc (c *client) GetNodeMetrics(ctx context.Context, name string) (*metricsv1beta1.NodeMetrics, error) {\n    m, err := c.metricsClientset.MetricsV1beta1().NodeMetricses().Get(ctx, name, metav1.GetOptions{})\n    if err != nil {\n        return nil, fmt.Errorf(\"get node metrics name=%q: %w\", name, err)\n    }\n    return m, nil\n}\n```\n\n`GetPodMetrics`\n\nand `GetNodeMetrics`\n\nreach through a second Clientset, `c.metricsClientset`\n\n, built from `k8s.io/metrics`\n\nalongside the core one in `NewClient`\n\n, the same two-Clientset shape the client-go post flagged as coming in a future post, now realized here instead.\n\n`GetConfigMapKeys`\n\nand `GetSecretKeys`\n\nare worth pausing on. It would be less code to return the whole object, `map[string]string`\n\nand all. Returning only the keys is a security decision made at the interface boundary, before a single tool or prompt exists, an agent that can only ever see \"this Secret has a key called `DATABASE_URL`\n\n\" cannot leak what that key contains, no matter how it's prompted. That's a much stronger guarantee than \"the tool description tells the agent not to print secret values.\"\n\nEvery method on `KubeClient`\n\nis `List`\n\n, `Get`\n\n, or `Stream`\n\n, nothing that mutates the cluster. That boundary, along with the read-only `Secret`\n\n/`ConfigMap`\n\nkey-only design and the bounded `LogOptions`\n\n, all live in the Go types themselves rather than in documentation someone has to remember to follow. Three things worth taking away:\n\nCompose small, domain-specific interfaces (`PodClient`\n\n, `EventClient`\n\n, `NetworkClient`\n\n, ...) instead of one large `KubeClient`\n\nwith twenty methods on it, easier to test, easier to fake, and each file has one reason to change.\n\nDesign the client before you design anything that calls it. Whether the caller ends up being a CLI, a report generator, or an AI agent, the interface should already answer \"what's actually useful to ask a cluster\" independent of who's asking.\n\nEvery safety boundary that matters, read-only access, bounded queries, secret values never leaving the cluster, belongs in the interface's types and method signatures, not in a comment asking future callers to be careful.\n\nPart 2 picks up exactly here: wrapping this `KubeClient`\n\nas MCP tools, wiring it to an agent, and running the whole thing end-to-end against a real cluster. [Building a Kubernetes-aware AI agent with a Go MCP server →](https://dev.to/ferztyle/build-an-mcp-server-in-go-part-2-building-the-mcp-tool-layer-4l3m)\n\nOne of the best parts of writing in public is the people you meet along the way, engineers at different stages of their journey, working on similar problems from completely different angles.\n\nIf something in this post resonated, if you spotted a bug, or if you just want to talk Go, Kubernetes, Platform Engineering, DevOps, or whatever, I'm always happy to hear from you.\n\n*Building from Asunción, Paraguay 🇵🇾*", "url": "https://wpnews.pro/news/build-an-mcp-server-in-go-part-1-designing-a-diagnostic-grade-kubernetes-client", "canonical_source": "https://dev.to/ferztyle/build-an-mcp-server-in-go-part-1-designing-a-diagnostic-grade-kubernetes-client-49a2", "published_at": "2026-08-14 14:12:02+00:00", "updated_at": "2026-08-14 14:36:17.888890+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Kubernetes", "Go", "MCP", "ferctl", "client-go", "Cobra"], "alternates": {"html": "https://wpnews.pro/news/build-an-mcp-server-in-go-part-1-designing-a-diagnostic-grade-kubernetes-client", "markdown": "https://wpnews.pro/news/build-an-mcp-server-in-go-part-1-designing-a-diagnostic-grade-kubernetes-client.md", "text": "https://wpnews.pro/news/build-an-mcp-server-in-go-part-1-designing-a-diagnostic-grade-kubernetes-client.txt", "jsonld": "https://wpnews.pro/news/build-an-mcp-server-in-go-part-1-designing-a-diagnostic-grade-kubernetes-client.jsonld"}}