Build an MCP Server in Go (Part 2): Building the MCP tool layer
Part 1designs theKubeClientinterface 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,. . .

Part 1designs theKubeClientinterface 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 isStrip away the hype, and MCP is a small, well-defined protocol: a server exposes a list oftools, 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 serverEnter fullscreen modeExit fullscreen modeThat's the whole mental model. The interesting engineering problem wasn't the protocol, it was theKubeClientinterface from part 1. What's left is wrapping it correctly. Two ways tools and calls actually travel: stdio vs. HTTPThe diagram above showswhatgets exchanged. It says nothing abouthowthose messages physically move between client and server, that's a separate choice, called the transport, and MCP defines two of them. Stdio transportis 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. Everytools/listandtools/callmessage 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 whymain. gocallsserver. 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 transportis 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 structurego-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. goEnter fullscreen modeExit fullscreen modeinternal/kubernetes/is exactly what part 1 built, nothing changes there. Everything new in this post lives incmd/k8s-mcp-server/andinternal/tools/, following the samecmd/+internal/convention fromGo packages and modules explained. The tool layerKubeClientanswers "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. AKubeClientmethod might take aLogOptionsstruct; 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 untypedmap[string]anyargument parsing. go get github. com/modelcontextprotocol/go-sdkEnter fullscreen modeExit fullscreen modeEach 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 fileEvery 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: aNamethe agent calls it by ("list_pods"), and aDescriptionthe agent reads to decidewhento 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 withjsonandjsonschematags. 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 signaturefunc(ctx context. Context, req *mcp. CallToolRequest, in In) (*mcp. CallToolResult, Out, error). This is where the input gets validated,KubeClientgets called, and the result gets shaped intoOut. 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 aPhasefield, summing aRestartscount), rather than a paragraph of text it has to parse itself. TheNew*Toolconstructor: a small function that builds and returns the(*mcp. Tool, handler)pair together, closing over aKubeClientso the handler has something to call. This is what makes each tool independently testable: pass in a fakeKubeClient, call the returned handler directly, assert onOut, no MCP machinery involved at all. (*mcp. Tool, handler)is the pair everyNew*Toolfunction returns, and the pairmcp. AddToolexpects at registration time inserver. 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_podsThe pieces above, in a real file:toolbelow is the*mcp. Toolidentity,ListPodsInputisIn, the closure returned second is the handler, andListPodsOutputisOut. // internal/tools/list_pods. gopackagetoolsimport("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. typeListPodsInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace to list pods from. Leave empty to list pods across all namespaces. "`}typePodSummarystruct{Namespacestring`json:"namespace"`Namestring`json:"name"`Phasestring`json:"phase"`Readystring`json:"ready"`Restartsint32`json:"restarts"`}typeListPodsOutputstruct{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. funcNewListPodsTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inListPodsInput)(*mcp. CallToolResult,ListPodsOutput,error){pods,err:=kube. ListPods(ctx,in. Namespace)iferr!=nil{returnnil,ListPodsOutput{},fmt. Errorf("list_pods: %w",err)}out:=ListPodsOutput{Pods:make([]PodSummary,0,len(pods. Items))}for_,pod:=rangepods. Items{ready,total:=0,len(pod. Status. ContainerStatuses)varrestartsint32for_,cs:=rangepod. Status. ContainerStatuses{ifcs. 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,})}returnnil,out,nil}returntool,handler}Enter fullscreen modeExit fullscreen modeTwo things worth calling out. First, the tool returns a typedListPodsOutput, 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 decidewhento 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. gopackagetoolsimport("context""fmt""github. com/modelcontextprotocol/go-sdk/mcp""github. com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes")typeGetPodLogsInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace of the pod. "`Namestring`json:"name" jsonschema:"Name of the pod. "`Containerstring`json:"container" jsonschema:"Container name. Leave empty for the pod's first container. "`Previousbool`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. "`TailLinesint64`json:"tailLines" jsonschema:"Number of lines to return from the end of the log, capped at 500. Defaults to 200. "`}typeGetPodLogsOutputstruct{Logsstring`json:"logs"`}constmaxLogTailLines=500funcNewGetPodLogsTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inGetPodLogsInput)(*mcp. CallToolResult,GetPodLogsOutput,error){tail:=in. TailLinesiftailmaxLogTailLines{tail=maxLogTailLines}logs,err:=kube. GetPodLogs(ctx,in. Namespace,in. Name,kubernetes. LogOptions{Container:in. Container,Previous:in. Previous,TailLines:tail,})iferr!=nil{returnnil,GetPodLogsOutput{},fmt. Errorf("get_pod_logs: %w",err)}returnnil,GetPodLogsOutput{Logs:logs},nil}returntool,handler}Enter fullscreen modeExit fullscreen modeNotice the cap is enforcedagainhere, on top of the default already living inLogOptions. That's not redundant, the schema description tells the agent "capped at 500," but nothing stops a misbehaving or adversarial caller from sendingtailLines: 50000anyway. 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_podThis one composes threeKubeClientcalls into a single tool; deliberately, it mirrors whatkubectl describe poddoes under the hood, giving the agent one call instead of three for a question it will ask constantly. // internal/tools/describe_pod. gopackagetoolsimport("context""fmt""github. com/modelcontextprotocol/go-sdk/mcp"corev1"k8s. io/api/core/v1""github. com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes")typeDescribePodInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace of the pod. "`Namestring`json:"name" jsonschema:"Name of the pod. "`}typeContainerStatestruct{Namestring`json:"name"`Statestring`json:"state"`// Waiting, Running, TerminatedReasonstring`json:"reason"`// CrashLoopBackOff, OOMKilled, etc. Restartsint32`json:"restarts"`}typeDescribePodOutputstruct{Phasestring`json:"phase"`Containers[]ContainerState`json:"containers"`Events[]string`json:"recentEvents"`}funcNewDescribePodTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inDescribePodInput)(*mcp. CallToolResult,DescribePodOutput,error){pod,err:=kube. GetPod(ctx,in. Namespace,in. Name)iferr!=nil{returnnil,DescribePodOutput{},fmt. Errorf("describe_pod: %w",err)}events,err:=kube. GetEvents(ctx,in. Namespace,in. Name)iferr!=nil{returnnil,DescribePodOutput{},fmt. Errorf("describe_pod: %w",err)}out:=DescribePodOutput{Phase:string(pod. Status. Phase)}for_,cs:=rangepod. Status. ContainerStatuses{state,reason:=containerState(cs)out. Containers=append(out. Containers,ContainerState{Name:cs. Name,State:state,Reason:reason,Restarts:cs. RestartCount,})}for_,e:=rangeevents{out. Events=append(out. Events,fmt. Sprintf("[%s] %s",e. Reason,e. Message))}returnnil,out,nil}returntool,handler}funccontainerState(cscorev1. ContainerStatus)(state,reasonstring){switch{casecs. State. Waiting!=nil:return"Waiting",cs. State. Waiting. Reasoncasecs. State. Terminated!=nil:return"Terminated",cs. State. Terminated. Reasondefault:return"Running",""}}Enter fullscreen modeExit fullscreen modeIf you've read the client-go post,containerStatewill look familiar, it's the same waiting/terminated distinctioninspectPodmade in the cluster health reporter, because it's the same underlying problem: a pod'sPhasealone doesn't tell you the truth; you have to readContainerStatuses. get_eventsStandalone fromdescribe_podbecause an agent sometimes wants events for a whole namespace, not one pod, a burst ofFailedSchedulingevents across several pods is a cluster-capacity story, not a single-pod story, anddescribe_podcan't surface that. // internal/tools/get_events. gopackagetoolsimport("context""fmt""github. com/modelcontextprotocol/go-sdk/mcp""github. com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes")typeGetEventsInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace to fetch events from. "`InvolvedObjectNamestring`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. "`}typeEventSummarystruct{Typestring`json:"type"`// Normal or WarningReasonstring`json:"reason"`// FailedScheduling, BackOff, Pulled, etc. Objectstring`json:"object"`Messagestring`json:"message"`Countint32`json:"count"`}typeGetEventsOutputstruct{Events[]EventSummary`json:"events"`}constmaxEventsReturned=50funcNewGetEventsTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inGetEventsInput)(*mcp. CallToolResult,GetEventsOutput,error){events,err:=kube. GetEvents(ctx,in. Namespace,in. InvolvedObjectName)iferr!=nil{returnnil,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. iflen(events)>maxEventsReturned{events=events[len(events)-maxEventsReturned:]}out:=GetEventsOutput{Events:make([]EventSummary,0,len(events))}for_,e:=rangeevents{out. Events=append(out. Events,EventSummary{Type:e. Type,Reason:e. Reason,Object:e. InvolvedObject. Name,Message:e. Message,Count:e. Count,})}returnnil,out,nil}returntool,handler}Enter fullscreen modeExit fullscreen modecheck_endpointsThis is the tool that answers "the Service exists, so why can't anything reach it", a questionlist_podsanddescribe_podgenuinely can't answer, since a healthy-looking pod can still be missing from a Service's endpoints entirely. // internal/tools/check_endpoints. gopackagetoolsimport("context""fmt""github. com/modelcontextprotocol/go-sdk/mcp""github. com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes")typeCheckEndpointsInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace of the service. "`ServiceNamestring`json:"serviceName" jsonschema:"Name of the service to check. "`}typeCheckEndpointsOutputstruct{ServiceSelectormap[string]string`json:"serviceSelector"`ReadyAddressesint`json:"readyAddresses"`NotReadyint`json:"notReadyAddresses"`Healthybool`json:"healthy"`}funcNewCheckEndpointsTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inCheckEndpointsInput)(*mcp. CallToolResult,CheckEndpointsOutput,error){svc,err:=kube. GetService(ctx,in. Namespace,in. ServiceName)iferr!=nil{returnnil,CheckEndpointsOutput{},fmt. Errorf("check_endpoints: %w",err)}eps,err:=kube. GetEndpoints(ctx,in. Namespace,in. ServiceName)iferr!=nil{returnnil,CheckEndpointsOutput{},fmt. Errorf("check_endpoints: %w",err)}ready,notReady:=0,0for_,subset:=rangeeps. Subsets{ready+=len(subset. Addresses)notReady+=len(subset. NotReadyAddresses)}returnnil,CheckEndpointsOutput{ServiceSelector:svc. Spec. Selector,ReadyAddresses:ready,NotReady:notReady,Healthy:ready>0,},nil}returntool,handler}Enter fullscreen modeExit fullscreen modeReturningServiceSelectoralongside the counts is deliberate: whenReadyAddressesis zero, the very next thing an agent needs is the selector to cross-reference against pod labels vialist_pods, handing it back here saves a tool call the agent would otherwise have to make anyway. describe_ingressComposesGetIngress,ListIngressClasses, andGetEvents, the same three-source patterndescribe_podused, applied to "is the ALB even provisioned" instead of "is the pod healthy. "// internal/tools/describe_ingress. gopackagetoolsimport("context""fmt""github. com/modelcontextprotocol/go-sdk/mcp""github. com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes")typeDescribeIngressInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace of the Ingress. "`Namestring`json:"name" jsonschema:"Name of the Ingress. "`}typeDescribeIngressOutputstruct{IngressClassNamestring`json:"ingressClassName"`IngressClassFoundbool`json:"ingressClassFound"`LoadBalancerHoststring`json:"loadBalancerHost"`// empty if not yet provisionedRecentEvents[]string`json:"recentEvents"`}funcNewDescribeIngressTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inDescribeIngressInput)(*mcp. CallToolResult,DescribeIngressOutput,error){ing,err:=kube. GetIngress(ctx,in. Namespace,in. Name)iferr!=nil{returnnil,DescribeIngressOutput{},fmt. Errorf("describe_ingress: %w",err)}out:=DescribeIngressOutput{}ifing. Spec. IngressClassName!=nil{out. IngressClassName=*ing. Spec. IngressClassName}iflen(ing. Status. LoadBalancer. Ingress)>0{out. LoadBalancerHost=ing. Status. LoadBalancer. Ingress[0]. Hostname}classes,err:=kube. ListIngressClasses(ctx)iferr!=nil{returnnil,DescribeIngressOutput{},fmt. Errorf("describe_ingress: %w",err)}for_,ic:=rangeclasses. Items{ific. Name==out. IngressClassName{out. IngressClassFound=truebreak}}events,err:=kube. GetEvents(ctx,in. Namespace,in. Name)iferr!=nil{returnnil,DescribeIngressOutput{},fmt. Errorf("describe_ingress: %w",err)}for_,e:=rangeevents{out. RecentEvents=append(out. RecentEvents,fmt. Sprintf("[%s] %s",e. Reason,e. Message))}returnnil,out,nil}returntool,handler}Enter fullscreen modeExit fullscreen modeIngressClassFounddeliberately gets computed here rather than left for the agent to figure out from a raw class list, an empty or mismatchedingressClassNamewith 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_httprouteGateway API's structuredstatus. conditionsmake this tool simpler thandescribe_ingressin 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. gopackagetoolsimport("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")typeDescribeHTTPRouteInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace of the HTTPRoute. "`Namestring`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. GatewayNamestring`json:"gatewayName" jsonschema:"Optional. Name of the parent Gateway to check alongside the route. Leave empty to skip the Gateway-level check. "`}typeConditionSummarystruct{Typestring`json:"type"`// Accepted, ResolvedRefs, Programmed, etc. Statusstring`json:"status"`// True, False, UnknownReasonstring`json:"reason"`Messagestring`json:"message"`}typeDescribeHTTPRouteOutputstruct{RouteConditions[]ConditionSummary`json:"routeConditions"`GatewayConditions[]ConditionSummary`json:"gatewayConditions,omitempty"`}funcNewDescribeHTTPRouteTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inDescribeHTTPRouteInput)(*mcp. CallToolResult,DescribeHTTPRouteOutput,error){route,err:=kube. GetHTTPRoute(ctx,in. Namespace,in. Name)iferr!=nil{returnnil,DescribeHTTPRouteOutput{},fmt. Errorf("describe_httproute: %w",err)}out:=DescribeHTTPRouteOutput{}for_,parent:=rangeroute. Status. Parents{out. RouteConditions=append(out. RouteConditions,summarizeConditions(parent. Conditions). . . )}ifin. GatewayName!=""{gw,err:=kube. GetGateway(ctx,in. Namespace,in. GatewayName)iferr!=nil{returnnil,DescribeHTTPRouteOutput{},fmt. Errorf("describe_httproute: %w",err)}out. GatewayConditions=summarizeConditions(gw. Status. Conditions)}returnnil,out,nil}returntool,handler}funcsummarizeConditions(conditions[]metav1. Condition)[]ConditionSummary{summaries:=make([]ConditionSummary,0,len(conditions))for_,c:=rangeconditions{summaries=append(summaries,ConditionSummary{Type:c. Type,Status:string(c. Status),Reason:c. Reason,Message:c. Message,})}returnsummaries}Enter fullscreen modeExit fullscreen modesummarizeConditionsexists as its own small function because bothroute. Status. Parents[]. Conditionsandgw. Status. Conditionsare the same[]metav1. Conditionshape; Gateway API standardizes onmetav1. Conditionthroughout, which is exactly what lets one helper cover both without a type switch. list_configmap_keysThe read-only, keys-only boundary fromConfigClientcarries straight through to the tool layer, there's novaluefield anywhere in this output, by design. // internal/tools/list_configmap_keys. gopackagetoolsimport("context""fmt""github. com/modelcontextprotocol/go-sdk/mcp""github. com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes")typeListConfigMapKeysInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace of the ConfigMap. "`Namestring`json:"name" jsonschema:"Name of the ConfigMap. "`}typeListConfigMapKeysOutputstruct{Keys[]string`json:"keys"`}funcNewListConfigMapKeysTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inListConfigMapKeysInput)(*mcp. CallToolResult,ListConfigMapKeysOutput,error){keys,err:=kube. GetConfigMapKeys(ctx,in. Namespace,in. Name)iferr!=nil{returnnil,ListConfigMapKeysOutput{},fmt. Errorf("list_configmap_keys: %w",err)}returnnil,ListConfigMapKeysOutput{Keys:keys},nil}returntool,handler}Enter fullscreen modeExit fullscreen modeThere's nolist_secret_keystool shown separately here, it's the identical shape callingkube. GetSecretKeysinstead, so I'm not pasting a near-duplicate file. If you're building this yourself, add it exactly like this one. pod_metricsThe 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. gopackagetoolsimport("context""fmt""github. com/modelcontextprotocol/go-sdk/mcp""github. com/FerRiosCosta/go-k8s-mcp-server/internal/kubernetes")typeGetPodMetricsInputstruct{Namespacestring`json:"namespace" jsonschema:"Kubernetes namespace of the pod. "`Namestring`json:"name" jsonschema:"Name of the pod. "`}typeContainerUsagestruct{Namestring`json:"name"`CPUstring`json:"cpu"`// e. g. "12m"Memorystring`json:"memory"`// e. g. "84Mi"}typeGetPodMetricsOutputstruct{Containers[]ContainerUsage`json:"containers"`}funcNewGetPodMetricsTool(kubekubernetes. 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(ctxcontext. Context,_*mcp. CallToolRequest,inGetPodMetricsInput)(*mcp. CallToolResult,GetPodMetricsOutput,error){metrics,err:=kube. GetPodMetrics(ctx,in. Namespace,in. Name)iferr!=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. "returnnil,GetPodMetricsOutput{},fmt. Errorf("pod_metrics: %w (is metrics-server installed?)",err)}out:=GetPodMetricsOutput{Containers:make([]ContainerUsage,0,len(metrics. Containers))}for_,c:=rangemetrics. Containers{out. Containers=append(out. Containers,ContainerUsage{Name:c. Name,CPU:c. Usage. Cpu(). String(),Memory:c. Usage. Memory(). String(),})}returnnil,out,nil}returntool,handler}Enter fullscreen modeExit fullscreen modeThat's twelveKubeClientmethods reachable through nine tools (describe_pod,describe_ingress, anddescribe_httprouteeach composing more than one). Every tool file follows the identical shape: typed input, typed output, a handler that calls one or moreKubeClientmethods 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. gopackagemcpserverimport("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. funcNew(kubekubernetes. 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)returnserver}Enter fullscreen modeExit fullscreen mode// cmd/k8s-mcp-server/main. gopackagemainimport("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")funcmain(){kube,err:=k8s. NewClient()iferr!=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. iferr:=server. Run(context. Background(),&mcp. StdioTransport{});err!=nil{fmt. Fprintf(os. Stderr,"server error: %v\n",err)os. Exit(1)}}Enter fullscreen modeExit fullscreen modek8s. NewClientno longer takes a path, it resolves config the same waykubectldoes: in-cluster if running inside a pod, otherwise$KUBECONFIGif set, otherwise~/. kube/config, using whatever context is currently active. That means switching clusters withkubectl config use-contextchanges what this server talks to as well, without touching any config file for the MCP client itself. Running it end-to-endBuild the binary first:go build-ok8s-mcp-server . /cmd/k8s-mcp-serverEnter fullscreen modeExit fullscreen modeThen point an MCP-compatible client at it. SinceNewClientresolves config the same waykubectldoes, noKUBECONFIGneeds to be passed through the client config at all, whatever context is currently active is the one the server talks to. Claude Desktop, editclaude_desktop_config. json(~/Library/Application Support/Claude/on macOS,%APPDATA%\Claude\on Windows):{"mcpServers":{"k8s-diagnostics":{"command":"/absolute/path/to/k8s-mcp-server"}}}Enter fullscreen modeExit fullscreen modeRestart Claude Desktop, it only reads this file on startup, not live. VS Code (GitHub Copilot), add to. vscode/mcp. jsonin the project, or via Command Palette →MCP: Open User Configuration. Note the root key isservers, notmcpServers:{"servers":{"k8s-diagnostics":{"type":"stdio","command":"/absolute/path/to/k8s-mcp-server"}}}Enter fullscreen modeExit fullscreen modeMCP tools only show up once Copilot Chat's mode dropdown is switched toAgent, the default chat mode won't call tools at all. If Copilot still reports the tools as unavailableeven afterMCP: List Serversshowsk8s-diagnosticsasRunningand 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 thego-apideployment from the earlier posts still there, recreate thememory-hogpod from the client-go post's OOMKill simulation:kubectl apply-fmemory-hog. yaml# wait for CrashLoopBackOffEnter fullscreen modeExit fullscreen modeThen, 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 callslist_pods, seesmemory-hogwith a non-zero restart count, callsdescribe_pod, sees the container's waiting reason isCrashLoopBackOff, and callsget_pod_logswithprevious: truesince 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-hogEnter fullscreen modeExit fullscreen modeEngineering practices that matter hereRead-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 toListPodswith no namespace on a thousand-pod cluster, orGetEventson 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 inmain. go, inject it into every tool throughKubeClient, 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 singleslogcall wrapping each handler is enough to start. RBAC scope the service account, not just the interface. TheKubeClientinterface being read-only is a code-level guarantee. Pair it with a Kubernetes-level one: run the server with a ServiceAccount bound to aClusterRolethat only grantsget,list, andwatchverbs. Two independent layers that both have to agree before anything unexpected can happen. Common gotchasVague 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 overlist_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 calllist_podswith 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'sjsonschematag doesn't match what you documented in the tool'sDescription, 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 guessdefault. Consider a tool that resolves a name to its namespace first (afind_resourcestyle tool), rather than assuming every downstream tool call carries the right namespace. mcp. AddTool(server, tools. NewXTool(kube))doesn't compile. EveryNew*Toolconstructor returns two values,(*mcp. Tool, handler). Go only spreads a multi-value call into another call's parameters when that call is thesoleargument —mcp. AddToolalso takesserveras a first argument, so the pair has to be captured into named variables first:tool, handler := tools. NewListPodsTool(kube), thenmcp. AddTool(server, tool, handler). Easy to miss since the single-argument version of this pattern (a baref(g())) does compile, which makes the two-argument failure feel inconsistent the first time you hit it. describe_httprouteerroring on every call means Gateway API isn't installed. gatewayClientset. NewForConfigsucceeds 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 onceGetHTTPRouteorGetGatewayactually runs, as aNotFound-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 waypod_metricswraps a missing metrics-server. SummaryThe 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: aKubeClientinterface 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. Toolfor identity, a typedIn, a handler, a typedOut, which is what makes nine tools feel like one pattern instead of nine separate designs. Return structuredOuttypes, 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!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. LinkedInGitHubTwitter / XBuilding from Asunción, Paraguay 🇵🇾