Build an MCP Server in Go (Part 2): Building the MCP tool layer A developer detailed the second part of building a Model Context Protocol (MCP) server in Go, focusing on the tool layer that wraps a Kubernetes client. The post explains how to expose Kubernetes operations as MCP tools, wire them to an agent, and run end-to-end against a real cluster, covering both stdio and streamable HTTP transports. The implementation uses the KubeClient interface from part one, with tools like list_pods and get_pod_logs, and demonstrates the protocol's simplicity. 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 https://ferztyle.me/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 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 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 🇵🇾