Skip to main content

Command Palette

Search for a command to run...

Build an MCP Server in Go (Part 2): Building the MCP tool layer

Updated
29 min readView as Markdown
Build an MCP Server in Go (Part 2): Building the MCP tool layer
F
Hey, I'm Fer, a Senior SRE/DevOps engineer based in Asunción, Paraguay. My day-to-day life is at the intersection of cloud infrastructure, automation, and reliability, working with AWS, Kubernetes, and the tools that keep systems running at scale. A while back, I decided to go deeper into Go. Not just as a scripting language to replace Bash, but as a proper engineering discipline, learning how to design clean systems, write idiomatic Golang code, and build tools that last.

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.


What MCP actually is

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.

Two ways tools and calls actually travel: stdio vs. HTTP

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.


Project structure

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.


The tool layer

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.

Anatomy of a tool file

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.


Wiring it together

// 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.


Running it end-to-end

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 unavailable even after MCP: List Servers shows k8s-diagnostics as Running 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
# wait for CrashLoopBackOff

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

Engineering practices that matter here

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.


Common gotchas

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.


Summary

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.


Let's connect!

If something in this post resonated, if you spotted a bug, or if you just want to talk Go, Kubernetes, AI agents, or Platform Engineering, I'm always happy to hear from you.

  • LinkedIn

  • GitHub

  • Twitter / X

Building from Asunción, Paraguay 🇵🇾

#go #kubernetes #mcp #ai-agents #platform-engineering

Platform Engineering with Go

Part 1 of 7

Platform engineering is how you scale engineering teams without scaling headcount. Go is how you build the tools that make it possible. This series covers both: from Kubernetes internals to production Go CLIs, operators, admission webhooks, and automation tooling. Each post either teaches a platform engineering concept, builds a real Go tool, or both. The series never ends. As the platform engineering landscape evolves, Kubernetes, ArgoCD, Backstage, Prometheus, Helm, Terraform, new posts get added. Follow along if you want to build the layer that makes everything else work. Who this is for: Senior DevOps and SRE engineers who want to go beyond YAML and build real internal tooling with Go. Go developers who want to apply their skills to infrastructure and platform problems.

Up next

Build an MCP Server in Go (Part 1): Designing a diagnostic-grade Kubernetes client

This post designs the Kubernetes client. The next post wraps it as an MCP server and wires it to an agent. Asking instead of running There's a familiar rhythm to debugging a bad deploy: kubectl get po