Part 1**designs the KubeClient
interface this post builds on. If you haven't read it, the short version: a read-only, domain-composed Kubernetes client, pods, events, workloads, nodes, network, config, storage, metrics, with every unbounded query capped before an agent ever touches it.
With that client in hand, this post does the rest: wrap it as MCP tools, wire it to an agent, and run the whole thing end-to-end against a real cluster.
Strip away the hype, and MCP is a small, well-defined protocol: a server exposes a list of tools, each with a name, a description, and a JSON Schema describing its input. A client, usually an LLM-powered agent, asks the server "what tools do you have," decides which one is relevant to the user's question, and calls it with structured arguments. The server runs the tool and returns a structured result. Repeat, as many times as the agent needs, until it has enough information to answer.
Agent (Claude, etc.)
β
β "list your tools" β tools/list
β β [list_pods, get_pod_logs, describe_pod, ...]
β
β "call get_pod_logs" β tools/call
β β { "logs": "panic: ..." }
β
βΌ
Your MCP server
β
β client-go
βΌ
Kubernetes API server
That's the whole mental model. The interesting engineering problem wasn't the protocol, it was the KubeClient
interface from part 1. What's left is wrapping it correctly.
The diagram above shows what gets exchanged. It says nothing about how those messages physically move between client and server, that's a separate choice, called the transport, and MCP defines two of them.
Stdio transport is what this post builds. The client, Claude Desktop, VS Code, spawns your compiled binary as a local child process and talks to it over that process's stdin and stdout. Every tools/list
and tools/call
message is a line of JSON written to the child's stdin; every response is a line of JSON written back to stdout. There's no network involved, no port to open, no auth to configure: the trust boundary is just "whoever can run this binary already has access to whatever it can reach," which for this server means your kubeconfig. This is exactly why main.go
calls server.Run(ctx, &mcp.StdioTransport{})
, the server's entire life cycle is tied to one client process spawning it, talking to it, and eventually killing it.
Streamable HTTP transport is the other option, and it's a genuinely different deployment shape, not just a different function call. The server runs as a long-lived process, think a pod in a cluster, listening on a port, and any number of clients connect to it over the network instead of spawning it. That unlocks things stdio structurally can't do: multiple agents sharing one server, a server that outlives any single client's session, a server nowhere near the machine that's using it. It also introduces problems stdio never has to solve: authenticating requests from strangers on the network, and keeping session state consistent when a client's calls might land on different backend replicas instead of the one process that remembers the conversation so far.
Neither transport is strictly better, they fit different situations. Stdio is the right choice for exactly what this post is: a tool one engineer runs locally, pointed at whatever cluster their kubeconfig currently targets. HTTP is what you'd reach for to run this as shared infrastructure other people's agents connect to, and that jump, with the session handling and auth it requires, is real enough to be its own post rather than a paragraph here.
go-k8s-mcp-server/
βββ go.mod
βββ cmd/
β βββ k8s-mcp-server/
β βββ main.go β entry point β wires client, tools, transport
βββ internal/
βββ kubernetes/ β the KubeClient from part 1, unchanged
β βββ client.go
β βββ pods.go
β βββ workloads.go
β βββ nodes.go
β βββ events.go
β βββ network.go
β βββ ingress.go
β βββ gateway.go
β βββ config.go
β βββ storage.go
β βββ metrics.go
βββ tools/ β new in this post
βββ list_pods.go
βββ pod_logs.go
βββ describe_pod.go
βββ get_events.go
βββ check_endpoints.go
βββ describe_ingress.go
βββ describe_httproute.go
βββ list_configmap_keys.go
βββ pod_metrics.go
internal/kubernetes/
is exactly what part 1 built, nothing changes there. Everything new in this post lives in cmd/k8s-mcp-server/
and internal/tools/
, following the same cmd/
internal/
convention from Go packages and modules explained.
KubeClient
answers "what can this program do to a cluster." The tool layer answers a different question: "what is the agent allowed to ask for, and in what shape." These are not the same interface. A KubeClient
method might take a LogOptions
struct; an MCP tool needs a JSON Schema an LLM can read and a handler that translates one into the other.
We'll use the official Go SDK, github.com/modelcontextprotocol/go-sdk/mcp
. It infers JSON Schema directly from Go struct tags and gives you typed handlers. func(ctx, req, In) (*mcp.CallToolResult, Out, error)
, so there's no manual schema-writing and no untyped map[string]any
argument parsing.
go get github.com/modelcontextprotocol/go-sdk
Each tool gets its own file, same "one reason to change" rule as the reporter package in the client-go post: a new tool is a new file, not a new branch in a shared switch statement.
Every tool in this project follows the same four-part shape. Before looking at a real one, it's worth naming each piece, since the same pattern repeats seven times below and it's easier to recognize once you know what you're looking at:
*mcp.Tool
: the tool's identity as far as the agent is concerned: a Name
the agent calls it by ("list_pods"
), and a Description
the agent reads to decide when to call it. This is metadata only, it holds no logic and touches no Kubernetes code.
In
(the input struct), what the agent is allowed to send as arguments, expressed as a normal Go struct with json
and jsonschema
tags. The SDK turns this struct's shape into the JSON Schema the agent actually sees; you never write schema by hand.
The handler function: the actual logic, with the signature func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error)
. This is where the input gets validated, KubeClient
gets called, and the result gets shaped into Out
. It's an ordinary Go function; nothing about it is MCP-specific beyond its signature.
Out
(the output struct): what the agent receives back, again a plain Go struct. Returning a typed struct instead of a preformatted string matters: the agent gets structured JSON it can reason over programmatically (checking a Phase
field, summing a Restarts
count), rather than a paragraph of text it has to parse itself.
The New*Tool
constructor: a small function that builds and returns the (*mcp.Tool, handler)
pair together, closing over a KubeClient
so the handler has something to call. This is what makes each tool independently testable: pass in a fake KubeClient
, call the returned handler directly, assert on Out
, no MCP machinery involved at all.
(*mcp.Tool, handler)
is the pair every New*Tool
function returns, and the pair mcp.AddTool
expects at registration time in server.go
, worth keeping that shape in mind, since it's also exactly where the multi-value spread gotcha from the "Common gotchas" section comes from.
list_pods
The pieces above, in a real file: tool
below is the *mcp.Tool
identity, ListPodsInput
is In
, the closure returned second is the handler, and ListPodsOutput
is Out
.
// internal/tools/list_pods.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
// ListPodsInput is inferred into JSON Schema from these struct tags,
// the agent sees "namespace" as an optional string with this description,
// nothing more to write by hand.
type ListPodsInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace to list pods from. Leave empty to list pods across all namespaces."`
}
type PodSummary struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Phase string `json:"phase"`
Ready string `json:"ready"`
Restarts int32 `json:"restarts"`
}
type ListPodsOutput struct {
Pods []PodSummary `json:"pods"`
}
// NewListPodsTool returns the tool definition and its handler, bound to
// a concrete KubeClient. Keeping the constructor take the client as a
// parameter (rather than a package-level global) is what keeps this
// testable with a fake client.
func NewListPodsTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, ListPodsInput) (*mcp.CallToolResult, ListPodsOutput, error)) {
tool := &mcp.Tool{
Name: "list_pods",
Description: "List pods in a namespace, or across all namespaces, with phase, readiness, and restart count.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in ListPodsInput) (*mcp.CallToolResult, ListPodsOutput, error) {
pods, err := kube.ListPods(ctx, in.Namespace)
if err != nil {
return nil, ListPodsOutput{}, fmt.Errorf("list_pods: %w", err)
}
out := ListPodsOutput{Pods: make([]PodSummary, 0, len(pods.Items))}
for _, pod := range pods.Items {
ready, total := 0, len(pod.Status.ContainerStatuses)
var restarts int32
for _, cs := range pod.Status.ContainerStatuses {
if cs.Ready {
ready++
}
restarts += cs.RestartCount
}
out.Pods = append(out.Pods, PodSummary{
Namespace: pod.Namespace,
Name: pod.Name,
Phase: string(pod.Status.Phase),
Ready: fmt.Sprintf("%d/%d", ready, total),
Restarts: restarts,
})
}
return nil, out, nil
}
return tool, handler
}
Two things worth calling out. First, the tool returns a typed ListPodsOutput
, not a formatted string, the SDK turns that into structured JSON the agent can reason over, the same way it would parse an API response. Second, the description on the tool itself matters as much as the code: it's the only thing the agent has to decide when to call this versus another tool. Vague descriptions produce agents that call the wrong tool, or the right tool with the wrong arguments, worth testing with real prompts once you've built a few tools, not just checking that the code compiles.
get_pod_logs
// internal/tools/pod_logs.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
type GetPodLogsInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace of the pod."`
Name string `json:"name" jsonschema:"Name of the pod."`
Container string `json:"container" jsonschema:"Container name. Leave empty for the pod's first container."`
Previous bool `json:"previous" jsonschema:"If true, fetch logs from the previous terminated instance of the container. Use this for CrashLoopBackOff pods β the current instance often has no logs yet."`
TailLines int64 `json:"tailLines" jsonschema:"Number of lines to return from the end of the log, capped at 500. Defaults to 200."`
}
type GetPodLogsOutput struct {
Logs string `json:"logs"`
}
const maxLogTailLines = 500
func NewGetPodLogsTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, GetPodLogsInput) (*mcp.CallToolResult, GetPodLogsOutput, error)) {
tool := &mcp.Tool{
Name: "get_pod_logs",
Description: "Fetch recent logs for a pod's container. Set previous=true to diagnose CrashLoopBackOff.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in GetPodLogsInput) (*mcp.CallToolResult, GetPodLogsOutput, error) {
tail := in.TailLines
if tail <= 0 || tail > maxLogTailLines {
tail = maxLogTailLines
}
logs, err := kube.GetPodLogs(ctx, in.Namespace, in.Name, kubernetes.LogOptions{
Container: in.Container,
Previous: in.Previous,
TailLines: tail,
})
if err != nil {
return nil, GetPodLogsOutput{}, fmt.Errorf("get_pod_logs: %w", err)
}
return nil, GetPodLogsOutput{Logs: logs}, nil
}
return tool, handler
}
Notice the cap is enforced again here, on top of the default already living in LogOptions
. That's not redundant, the schema description tells the agent "capped at 500," but nothing stops a misbehaving or adversarial caller from sending tailLines: 50000
anyway. The tool handler is the last line of defense before the request reaches the cluster, so it re-validates rather than trusting that the caller respected the description.
describe_pod
This one composes three KubeClient
calls into a single tool; deliberately, it mirrors what kubectl describe pod
does under the hood, giving the agent one call instead of three for a question it will ask constantly.
// internal/tools/describe_pod.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
corev1 "k8s.io/api/core/v1"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
type DescribePodInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace of the pod."`
Name string `json:"name" jsonschema:"Name of the pod."`
}
type ContainerState struct {
Name string `json:"name"`
State string `json:"state"` // Waiting, Running, Terminated
Reason string `json:"reason"` // CrashLoopBackOff, OOMKilled, etc.
Restarts int32 `json:"restarts"`
}
type DescribePodOutput struct {
Phase string `json:"phase"`
Containers []ContainerState `json:"containers"`
Events []string `json:"recentEvents"`
}
func NewDescribePodTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, DescribePodInput) (*mcp.CallToolResult, DescribePodOutput, error)) {
tool := &mcp.Tool{
Name: "describe_pod",
Description: "Get a pod's phase, per-container state and restart reasons, and its most recent events β the fastest way to understand why a pod is unhealthy.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in DescribePodInput) (*mcp.CallToolResult, DescribePodOutput, error) {
pod, err := kube.GetPod(ctx, in.Namespace, in.Name)
if err != nil {
return nil, DescribePodOutput{}, fmt.Errorf("describe_pod: %w", err)
}
events, err := kube.GetEvents(ctx, in.Namespace, in.Name)
if err != nil {
return nil, DescribePodOutput{}, fmt.Errorf("describe_pod: %w", err)
}
out := DescribePodOutput{Phase: string(pod.Status.Phase)}
for _, cs := range pod.Status.ContainerStatuses {
state, reason := containerState(cs)
out.Containers = append(out.Containers, ContainerState{
Name: cs.Name, State: state, Reason: reason, Restarts: cs.RestartCount,
})
}
for _, e := range events {
out.Events = append(out.Events, fmt.Sprintf("[%s] %s", e.Reason, e.Message))
}
return nil, out, nil
}
return tool, handler
}
func containerState(cs corev1.ContainerStatus) (state, reason string) {
switch {
case cs.State.Waiting != nil:
return "Waiting", cs.State.Waiting.Reason
case cs.State.Terminated != nil:
return "Terminated", cs.State.Terminated.Reason
default:
return "Running", ""
}
}
If you've read the client-go post, containerState
will look familiar, it's the same waiting/terminated distinction inspectPod
made in the cluster health reporter, because it's the same underlying problem: a pod's Phase
alone doesn't tell you the truth; you have to read ContainerStatuses
.
get_events
Standalone from describe_pod
because an agent sometimes wants events for a whole namespace, not one pod, a burst of FailedScheduling
events across several pods is a cluster-capacity story, not a single-pod story, and describe_pod
can't surface that.
// internal/tools/get_events.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
type GetEventsInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace to fetch events from."`
InvolvedObjectName string `json:"involvedObjectName" jsonschema:"Optional. Filter events to those involving this object name, e.g. a Pod or Deployment. Leave empty for all events in the namespace."`
}
type EventSummary struct {
Type string `json:"type"` // Normal or Warning
Reason string `json:"reason"` // FailedScheduling, BackOff, Pulled, etc.
Object string `json:"object"`
Message string `json:"message"`
Count int32 `json:"count"`
}
type GetEventsOutput struct {
Events []EventSummary `json:"events"`
}
const maxEventsReturned = 50
func NewGetEventsTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, GetEventsInput) (*mcp.CallToolResult, GetEventsOutput, error)) {
tool := &mcp.Tool{
Name: "get_events",
Description: "List recent Kubernetes events in a namespace, optionally filtered to a specific object. A burst of Warning events across multiple objects usually points to a cluster-level problem, not a single-resource one.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in GetEventsInput) (*mcp.CallToolResult, GetEventsOutput, error) {
events, err := kube.GetEvents(ctx, in.Namespace, in.InvolvedObjectName)
if err != nil {
return nil, GetEventsOutput{}, fmt.Errorf("get_events: %w", err)
}
// Cap the response the same way get_pod_logs caps its tail,
// a noisy namespace can return thousands of events, and the
// agent needs the most relevant slice, not all of it.
if len(events) > maxEventsReturned {
events = events[len(events)-maxEventsReturned:]
}
out := GetEventsOutput{Events: make([]EventSummary, 0, len(events))}
for _, e := range events {
out.Events = append(out.Events, EventSummary{
Type: e.Type,
Reason: e.Reason,
Object: e.InvolvedObject.Name,
Message: e.Message,
Count: e.Count,
})
}
return nil, out, nil
}
return tool, handler
}
check_endpoints
This is the tool that answers "the Service exists, so why can't anything reach it", a question list_pods
and describe_pod
genuinely can't answer, since a healthy-looking pod can still be missing from a Service's endpoints entirely.
// internal/tools/check_endpoints.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
type CheckEndpointsInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace of the service."`
ServiceName string `json:"serviceName" jsonschema:"Name of the service to check."`
}
type CheckEndpointsOutput struct {
ServiceSelector map[string]string `json:"serviceSelector"`
ReadyAddresses int `json:"readyAddresses"`
NotReady int `json:"notReadyAddresses"`
Healthy bool `json:"healthy"`
}
func NewCheckEndpointsTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, CheckEndpointsInput) (*mcp.CallToolResult, CheckEndpointsOutput, error)) {
tool := &mcp.Tool{
Name: "check_endpoints",
Description: "Check whether a Service has healthy endpoints backing it. Zero ready addresses despite matching pods usually means a label selector mismatch or a failing readiness probe.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in CheckEndpointsInput) (*mcp.CallToolResult, CheckEndpointsOutput, error) {
svc, err := kube.GetService(ctx, in.Namespace, in.ServiceName)
if err != nil {
return nil, CheckEndpointsOutput{}, fmt.Errorf("check_endpoints: %w", err)
}
eps, err := kube.GetEndpoints(ctx, in.Namespace, in.ServiceName)
if err != nil {
return nil, CheckEndpointsOutput{}, fmt.Errorf("check_endpoints: %w", err)
}
ready, notReady := 0, 0
for _, subset := range eps.Subsets {
ready += len(subset.Addresses)
notReady += len(subset.NotReadyAddresses)
}
return nil, CheckEndpointsOutput{
ServiceSelector: svc.Spec.Selector,
ReadyAddresses: ready,
NotReady: notReady,
Healthy: ready > 0,
}, nil
}
return tool, handler
}
Returning ServiceSelector
alongside the counts is deliberate: when ReadyAddresses
is zero, the very next thing an agent needs is the selector to cross-reference against pod labels via list_pods
, handing it back here saves a tool call the agent would otherwise have to make anyway.
describe_ingress
Composes GetIngress
, ListIngressClasses
, and GetEvents
, the same three-source pattern describe_pod
used, applied to "is the ALB even provisioned" instead of "is the pod healthy."
// internal/tools/describe_ingress.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
type DescribeIngressInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace of the Ingress."`
Name string `json:"name" jsonschema:"Name of the Ingress."`
}
type DescribeIngressOutput struct {
IngressClassName string `json:"ingressClassName"`
IngressClassFound bool `json:"ingressClassFound"`
LoadBalancerHost string `json:"loadBalancerHost"` // empty if not yet provisioned
RecentEvents []string `json:"recentEvents"`
}
func NewDescribeIngressTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, DescribeIngressInput) (*mcp.CallToolResult, DescribeIngressOutput, error)) {
tool := &mcp.Tool{
Name: "describe_ingress",
Description: "Diagnose an Ingress on EKS: whether its ingressClassName resolves to a real IngressClass, whether the ALB has finished provisioning, and recent reconciliation events from the AWS Load Balancer Controller.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in DescribeIngressInput) (*mcp.CallToolResult, DescribeIngressOutput, error) {
ing, err := kube.GetIngress(ctx, in.Namespace, in.Name)
if err != nil {
return nil, DescribeIngressOutput{}, fmt.Errorf("describe_ingress: %w", err)
}
out := DescribeIngressOutput{}
if ing.Spec.IngressClassName != nil {
out.IngressClassName = *ing.Spec.IngressClassName
}
if len(ing.Status.LoadBalancer.Ingress) > 0 {
out.LoadBalancerHost = ing.Status.LoadBalancer.Ingress[0].Hostname
}
classes, err := kube.ListIngressClasses(ctx)
if err != nil {
return nil, DescribeIngressOutput{}, fmt.Errorf("describe_ingress: %w", err)
}
for _, ic := range classes.Items {
if ic.Name == out.IngressClassName {
out.IngressClassFound = true
break
}
}
events, err := kube.GetEvents(ctx, in.Namespace, in.Name)
if err != nil {
return nil, DescribeIngressOutput{}, fmt.Errorf("describe_ingress: %w", err)
}
for _, e := range events {
out.RecentEvents = append(out.RecentEvents, fmt.Sprintf("[%s] %s", e.Reason, e.Message))
}
return nil, out, nil
}
return tool, handler
}
IngressClassFound
deliberately gets computed here rather than left for the agent to figure out from a raw class list, an empty or mismatched ingressClassName
with no error anywhere else in the object is exactly the kind of silent failure an agent won't reliably catch by eyeballing JSON, so the tool does the comparison itself and hands back a plain boolean.
describe_httproute
Gateway API's structured status.conditions
make this tool simpler than describe_ingress
in one respect, no cross-referencing an IngressClass list. but it has to check two objects instead of one, since a route's health depends on the Gateway underneath it.
// internal/tools/describe_httproute.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
type DescribeHTTPRouteInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace of the HTTPRoute."`
Name string `json:"name" jsonschema:"Name of the HTTPRoute."`
// GatewayName lets the tool also check the parent Gateway's status,
// since a route can never work if its Gateway isn't Programmed,
// optional because a route can have multiple parents.
GatewayName string `json:"gatewayName" jsonschema:"Optional. Name of the parent Gateway to check alongside the route. Leave empty to skip the Gateway-level check."`
}
type ConditionSummary struct {
Type string `json:"type"` // Accepted, ResolvedRefs, Programmed, etc.
Status string `json:"status"` // True, False, Unknown
Reason string `json:"reason"`
Message string `json:"message"`
}
type DescribeHTTPRouteOutput struct {
RouteConditions []ConditionSummary `json:"routeConditions"`
GatewayConditions []ConditionSummary `json:"gatewayConditions,omitempty"`
}
func NewDescribeHTTPRouteTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, DescribeHTTPRouteInput) (*mcp.CallToolResult, DescribeHTTPRouteOutput, error)) {
tool := &mcp.Tool{
Name: "describe_httproute",
Description: "Diagnose an HTTPRoute's Accepted/ResolvedRefs conditions, and optionally its parent Gateway's Accepted/Programmed conditions. Check the Gateway conditions first β a route cannot work if its Gateway isn't Programmed.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in DescribeHTTPRouteInput) (*mcp.CallToolResult, DescribeHTTPRouteOutput, error) {
route, err := kube.GetHTTPRoute(ctx, in.Namespace, in.Name)
if err != nil {
return nil, DescribeHTTPRouteOutput{}, fmt.Errorf("describe_httproute: %w", err)
}
out := DescribeHTTPRouteOutput{}
for _, parent := range route.Status.Parents {
out.RouteConditions = append(out.RouteConditions, summarizeConditions(parent.Conditions)...)
}
if in.GatewayName != "" {
gw, err := kube.GetGateway(ctx, in.Namespace, in.GatewayName)
if err != nil {
return nil, DescribeHTTPRouteOutput{}, fmt.Errorf("describe_httproute: %w", err)
}
out.GatewayConditions = summarizeConditions(gw.Status.Conditions)
}
return nil, out, nil
}
return tool, handler
}
func summarizeConditions(conditions []metav1.Condition) []ConditionSummary {
summaries := make([]ConditionSummary, 0, len(conditions))
for _, c := range conditions {
summaries = append(summaries, ConditionSummary{
Type: c.Type,
Status: string(c.Status),
Reason: c.Reason,
Message: c.Message,
})
}
return summaries
}
summarizeConditions
exists as its own small function because both route.Status.Parents[].Conditions
and gw.Status.Conditions
are the same []metav1.Condition
shape; Gateway API standardizes on metav1.Condition
throughout, which is exactly what lets one helper cover both without a type switch.
list_configmap_keys
The read-only, keys-only boundary from ConfigClient
carries straight through to the tool layer, there's no value
field anywhere in this output, by design.
// internal/tools/list_configmap_keys.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
type ListConfigMapKeysInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace of the ConfigMap."`
Name string `json:"name" jsonschema:"Name of the ConfigMap."`
}
type ListConfigMapKeysOutput struct {
Keys []string `json:"keys"`
}
func NewListConfigMapKeysTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, ListConfigMapKeysInput) (*mcp.CallToolResult, ListConfigMapKeysOutput, error)) {
tool := &mcp.Tool{
Name: "list_configmap_keys",
Description: "List the key names present in a ConfigMap. Returns keys only, never values β use this to check whether an expected config key exists.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in ListConfigMapKeysInput) (*mcp.CallToolResult, ListConfigMapKeysOutput, error) {
keys, err := kube.GetConfigMapKeys(ctx, in.Namespace, in.Name)
if err != nil {
return nil, ListConfigMapKeysOutput{}, fmt.Errorf("list_configmap_keys: %w", err)
}
return nil, ListConfigMapKeysOutput{Keys: keys}, nil
}
return tool, handler
}
There's no list_secret_keys
tool shown separately here, it's the identical shape calling kube.GetSecretKeys
instead, so I'm not pasting a near-duplicate file. If you're building this yourself, add it exactly like this one.
pod_metrics
The one tool that depends on metrics-server actually being installed in the cluster, worth surfacing that as a real error, not a generic 404, since it's a common reason for this specific tool to fail in a fresh cluster.
// internal/tools/pod_metrics.go
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
)
type GetPodMetricsInput struct {
Namespace string `json:"namespace" jsonschema:"Kubernetes namespace of the pod."`
Name string `json:"name" jsonschema:"Name of the pod."`
}
type ContainerUsage struct {
Name string `json:"name"`
CPU string `json:"cpu"` // e.g. "12m"
Memory string `json:"memory"` // e.g. "84Mi"
}
type GetPodMetricsOutput struct {
Containers []ContainerUsage `json:"containers"`
}
func NewGetPodMetricsTool(kube kubernetes.KubeClient) (*mcp.Tool, func(context.Context, *mcp.CallToolRequest, GetPodMetricsInput) (*mcp.CallToolResult, GetPodMetricsOutput, error)) {
tool := &mcp.Tool{
Name: "pod_metrics",
Description: "Get current CPU and memory usage per container in a pod, from metrics-server. Requires metrics-server to be installed in the cluster.",
}
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in GetPodMetricsInput) (*mcp.CallToolResult, GetPodMetricsOutput, error) {
metrics, err := kube.GetPodMetrics(ctx, in.Namespace, in.Name)
if err != nil {
// metrics-server missing or not yet scraped this pod shows up
// as a normal API error here, wrap it with the likely cause
// rather than letting the agent see a bare "not found."
return nil, GetPodMetricsOutput{}, fmt.Errorf("pod_metrics: %w (is metrics-server installed?)", err)
}
out := GetPodMetricsOutput{Containers: make([]ContainerUsage, 0, len(metrics.Containers))}
for _, c := range metrics.Containers {
out.Containers = append(out.Containers, ContainerUsage{
Name: c.Name,
CPU: c.Usage.Cpu().String(),
Memory: c.Usage.Memory().String(),
})
}
return nil, out, nil
}
return tool, handler
}
That's twelve KubeClient
methods reachable through nine tools (describe_pod
, describe_ingress
, and describe_httproute
each composing more than one). Every tool file follows the identical shape: typed input, typed output, a handler that calls one or more KubeClient
methods and translates the result, which is exactly the point of designing the client interface first, the way this post did.
// internal/mcpserver/server.go
package mcpserver
import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/tools"
)
// New builds an MCP server with every diagnostic tool registered against
// the given KubeClient. Adding a tool means adding one pair of lines
// here, the server itself has no Kubernetes-specific logic, it only
// wires things up.
func New(kube kubernetes.KubeClient) *mcp.Server {
server := mcp.NewServer(&mcp.Implementation{
Name: "k8s-diagnostics",
Version: "0.1.0",
}, nil)
// Each New*Tool constructor returns two values, (*mcp.Tool, handler).
// Go's multi-value spread only applies when that call is the sole
// argument to the outer call, mcp.AddTool also takes server, so the
// pair has to be captured into named variables first, tool by tool,
// rather than nested directly inside AddTool.
listPodsTool, listPodsHandler := tools.NewListPodsTool(kube)
mcp.AddTool(server, listPodsTool, listPodsHandler)
getPodLogsTool, getPodLogsHandler := tools.NewGetPodLogsTool(kube)
mcp.AddTool(server, getPodLogsTool, getPodLogsHandler)
describePodTool, describePodHandler := tools.NewDescribePodTool(kube)
mcp.AddTool(server, describePodTool, describePodHandler)
getEventsTool, getEventsHandler := tools.NewGetEventsTool(kube)
mcp.AddTool(server, getEventsTool, getEventsHandler)
checkEndpointsTool, checkEndpointsHandler := tools.NewCheckEndpointsTool(kube)
mcp.AddTool(server, checkEndpointsTool, checkEndpointsHandler)
describeIngressTool, describeIngressHandler := tools.NewDescribeIngressTool(kube)
mcp.AddTool(server, describeIngressTool, describeIngressHandler)
describeHTTPRouteTool, describeHTTPRouteHandler := tools.NewDescribeHTTPRouteTool(kube)
mcp.AddTool(server, describeHTTPRouteTool, describeHTTPRouteHandler)
listConfigMapKeysTool, listConfigMapKeysHandler := tools.NewListConfigMapKeysTool(kube)
mcp.AddTool(server, listConfigMapKeysTool, listConfigMapKeysHandler)
getPodMetricsTool, getPodMetricsHandler := tools.NewGetPodMetricsTool(kube)
mcp.AddTool(server, getPodMetricsTool, getPodMetricsHandler)
return server
}
// cmd/k8s-mcp-server/main.go
package main
import (
"context"
"fmt"
"os"
"github.com/modelcontextprotocol/go-sdk/mcp"
k8s "github.com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes"
"github.com/FerRiosCosta/go-k8s-mcp-server/internal/mcpserver"
)
func main() {
kube, err := k8s.NewClient()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to connect to cluster: %v\n", err)
os.Exit(1)
}
server := mcpserver.New(kube)
// Stdio is the right transport for a local agent (Claude Desktop, an
// editor extension, or an agent CLI) running on the same machine as
// the server. A remote, multi-tenant deployment needs a different
// transport and a different set of tradeoffs entirely, that's its
// own post.
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}
k8s.NewClient
no longer takes a path, it resolves config the same way kubectl
does: in-cluster if running inside a pod, otherwise $KUBECONFIG
if set, otherwise ~/.kube/config
, using whatever context is currently active. That means switching clusters with kubectl config use-context
changes what this server talks to as well, without touching any config file for the MCP client itself.
Build the binary first:
go build -o k8s-mcp-server ./cmd/k8s-mcp-server
Then point an MCP-compatible client at it. Since NewClient
resolves config the same way kubectl
does, no KUBECONFIG
needs to be passed through the client config at all, whatever context is currently active is the one the server talks to.
Claude Desktop, edit claude_desktop_config.json
(~/Library/Application Support/Claude/
on macOS, %APPDATA%\Claude\
on Windows):
{
"mcpServers": {
"k8s-diagnostics": {
"command": "/absolute/path/to/k8s-mcp-server"
}
}
}
Restart Claude Desktop, it only reads this file on startup, not live.
VS Code (GitHub Copilot), add to .vscode/mcp.json
in the project, or via Command Palette β MCP: Open User Configuration
. Note the root key is servers
, not mcpServers
:
{
"servers": {
"k8s-diagnostics": {
"type": "stdio",
"command": "/absolute/path/to/k8s-mcp-server"
}
}
}
MCP tools only show up once Copilot Chat's mode dropdown is switched to Agent, the default chat mode won't call tools at all.
If Copilot still reports the tools as unavailableeven afterMCP: List Servers
showsk8s-diagnostics
asRunning
and every tool is checked in the tools picker, start a brand new chat rather than continuing the existing one. Copilot fetches a session's tool list once, at the start of that conversation, enabling a server or checking its tools after the chat is already open doesn't retroactively update it. A fresh chat re-fetches the list and picks them up immediately. This is a quick thing to rule out before assuming the server itself is broken.
With minikube running and the go-api
deployment from the earlier posts still there, recreate the memory-hog
pod from the client-go post's OOMKill simulation:
kubectl apply -f memory-hog.yaml
Then, in the agent, ask the question you'd normally type into a terminal:
"Why is memory-hog failing in the default namespace?"
Behind the scenes, the agent calls list_pods
, sees memory-hog
with a non-zero restart count, calls describe_pod
, sees the container's waiting reason is CrashLoopBackOff
, and calls get_pod_logs
with previous: true
since it knows the current instance may not have logged anything yet. It comes back with something like: "memory-hog is crash-looping with 13 restarts. The container is being OOMKilled, it's allocating 100MB but its memory limit is 10Mi." That's the same conclusion the reporter in the client-go post prints as a table row, arrived at through conversation instead of a report you have to read and interpret yourself.
kubectl delete pod memory-hog
Read-only by default. Covered above at the interface level, worth restating: nothing in this server can mutate the cluster. If you extend it later with write operations, put them behind an explicit flag and treat them as a different trust boundary, not an addition to this interface.
Bound every unbounded query. Logs are the obvious one, but the same instinct applies to ListPods
with no namespace on a thousand-pod cluster, or GetEvents
on a noisy namespace. An agent doesn't know your cluster's scale before it asks; the server has to protect itself.
Never construct a new Clientset per tool call. Same gotcha as the client-go post, just resurfacing in a new context: build the Clientset once in main.go
, inject it into every tool through KubeClient
, and let client-go's connection pooling and rate limiting actually do their job across the lifetime of the process.
Log every tool invocation, structured. Not for debugging your Go code, for auditability: which tool was called, with what arguments, and what it returned, is the trail you'll want if an agent's actions ever need to be reconstructed after the fact. A single slog
call wrapping each handler is enough to start.
RBAC scope the service account, not just the interface. The KubeClient
interface being read-only is a code-level guarantee. Pair it with a Kubernetes-level one: run the server with a ServiceAccount bound to a ClusterRole
that only grants get
, list
, and watch
verbs. Two independent layers that both have to agree before anything unexpected can happen.
Vague tool descriptions produce wrong tool calls. "Get pod info" and "Get a pod's phase, per-container state and restart reasons, and its most recent events" are the difference between an agent guessing and an agent knowing exactly when to reach for this tool over list_pods
. Write descriptions for the agent the way you'd write a docstring for a teammate who's never seen your codebase.
Agents default to broad queries. Ask "what's wrong with my cluster" and an agent will happily call list_pods
with an empty namespace before narrowing down. That's fine, as long as the tool is built to handle it cheaply, it's a reason the tool layer, not just the agent's judgment, needs to enforce limits.
Silent schema mismatches are worse than loud errors. If a struct field's jsonschema
tag doesn't match what you documented in the tool's Description
, the agent gets a schema that contradicts your prose and picks unpredictably. Keep the two in sync, and when in doubt, let the inferred schema be the source of truth.
Namespace scoping mistakes are common on the agent side. An agent asked to check "the checkout service" may not know which namespace it lives in and will guess default
. Consider a tool that resolves a name to its namespace first (a find_resource
style tool), rather than assuming every downstream tool call carries the right namespace.
mcp.AddTool(server, tools.NewXTool(kube))
doesn't compile. Every New*Tool
constructor returns two values, (*mcp.Tool, handler)
. Go only spreads a multi-value call into another call's parameters when that call is the sole argument β mcp.AddTool
also takes server
as a first argument, so the pair has to be captured into named variables first: tool, handler := tools.NewListPodsTool(kube)
, then mcp.AddTool(server, tool, handler)
. Easy to miss since the single-argument version of this pattern (a bare f(g())
) does compile, which makes the two-argument failure feel inconsistent the first time you hit it.
describe_httproute
erroring on every call means Gateway API isn't installed. gatewayClientset.NewForConfig
succeeds even against a cluster with zero Gateway API CRDs β it's just building a REST client for a set of API paths, not checking they exist. The failure only shows up once GetHTTPRoute
or GetGateway
actually runs, as a NotFound
-shaped error on the CRD's group/version rather than a clear "Gateway API isn't installed" message. Worth wrapping that specific error with a clearer message in the tool handler, the same way pod_metrics
wraps a missing metrics-server.
The protocol part of MCP is almost incidental, tools, JSON Schema, call and response. What actually made this server useful was already built in part 1: a KubeClient
interface scoped to real diagnostic needs, read-only by construction. This post's job was just to wrap it correctly.
Three things worth taking away:
Every tool follows the same four-part shape, *mcp.Tool
for identity, a typed In
, a handler, a typed Out
, which is what makes nine tools feel like one pattern instead of nine separate designs.
Return structured Out
types, not preformatted strings. The agent reasons over JSON fields, not text it has to parse back apart.
Every boundary that matters, bounded log tails, capped event counts, secret values never leaving the cluster, gets enforced again at the tool layer, not just assumed from the client underneath it. An agent (or a misbehaving caller) can send arguments that ignore a schema's stated limits; the handler is the last line of defense.
What's next for this server is running it as shared infrastructure instead of a local stdio process someone points their laptop at, a different transport, session handling under concurrent agents, and the AWS-specific pieces of running it on EKS. That's a real jump in complexity, and it deserves its own post rather than a rushed section at the end of this one.
One 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.
If 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.
Building from AsunciΓ³n, Paraguay π΅πΎ