diff --git a/Makefile b/Makefile index 77cc29e7..dd0a7de9 100644 --- a/Makefile +++ b/Makefile @@ -27,9 +27,8 @@ build: $(VENDOR_STAMP) GOFLAGS=$(GOFLAGS) go build ./... lint: $(VENDOR_STAMP) - @command -v golangci-lint >/dev/null || (echo "golangci-lint not found; use nix develop" && exit 1) - GOFLAGS=$(GOFLAGS) golangci-lint run ./... - GOFLAGS=$(GOFLAGS) golangci-lint fmt --diff + GOFLAGS=$(GOFLAGS) go tool golangci-lint run ./... + GOFLAGS=$(GOFLAGS) go tool golangci-lint fmt --diff vuln: $(VENDOR_STAMP) @command -v govulncheck >/dev/null || (echo "govulncheck not found; use nix develop" && exit 1) diff --git a/internal/app/mcpapp/server_test.go b/internal/app/mcpapp/server_test.go new file mode 100644 index 00000000..4189bbcb --- /dev/null +++ b/internal/app/mcpapp/server_test.go @@ -0,0 +1,29 @@ +package mcpapp + +import ( + "testing" + + k8sfake "k8s.io/client-go/kubernetes/fake" +) + +func TestNewServerDoesNotPanic(t *testing.T) { + t.Helper() + + k8sClient := mustNewFakeClient(t) + clientset := k8sfake.NewClientset() + if clientset == nil { + t.Fatal("expected non-nil clientset") + } + + defer func() { + recovered := recover() + if recovered != nil { + t.Fatalf("expected NewServer not to panic, got %v", recovered) + } + }() + + server := NewServer(k8sClient, clientset) + if server == nil { + t.Fatal("expected non-nil MCP server") + } +} diff --git a/internal/app/mcpapp/tools.go b/internal/app/mcpapp/tools.go index e6c853ae..6c65778f 100644 --- a/internal/app/mcpapp/tools.go +++ b/internal/app/mcpapp/tools.go @@ -50,12 +50,24 @@ type getControlPlaneStatusInput struct { Name string `json:"name"` } +// NOTE: We cannot return metav1.Condition directly because metav1.Time embeds +// time.Time, which causes jsonschema-go schema inference to fail for MCP tool +// schemas. +type controlPlaneConditionSummary struct { + Type string `json:"type"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + LastTransitionTime string `json:"lastTransitionTime,omitempty"` +} + type getControlPlaneStatusOutput struct { - ObservedGeneration int64 `json:"observedGeneration"` - ReadyReplicas int32 `json:"readyReplicas"` - URL string `json:"url"` - Phase string `json:"phase"` - Conditions []metav1.Condition `json:"conditions"` + ObservedGeneration int64 `json:"observedGeneration"` + ReadyReplicas int32 `json:"readyReplicas"` + URL string `json:"url"` + Phase string `json:"phase"` + Conditions []controlPlaneConditionSummary `json:"conditions"` } type listControlPlanePodsInput struct { @@ -293,7 +305,7 @@ func registerTools(server *mcp.Server, k8sClient client.Client, clientset kubern ReadyReplicas: controlPlane.Status.ReadyReplicas, URL: controlPlane.Status.URL, Phase: controlPlane.Status.Phase, - Conditions: controlPlane.Status.Conditions, + Conditions: summarizeMetav1Conditions(controlPlane.Status.Conditions), }, nil }) @@ -873,6 +885,29 @@ func desiredReplicas(replicas *int32) int32 { return *replicas } +func summarizeMetav1Conditions(in []metav1.Condition) []controlPlaneConditionSummary { + if len(in) == 0 { + return nil + } + + out := make([]controlPlaneConditionSummary, 0, len(in)) + for _, cond := range in { + summary := controlPlaneConditionSummary{ + Type: cond.Type, + Status: string(cond.Status), + Reason: cond.Reason, + Message: cond.Message, + ObservedGeneration: cond.ObservedGeneration, + } + if !cond.LastTransitionTime.IsZero() { + summary.LastTransitionTime = cond.LastTransitionTime.Time.UTC().Format(time.RFC3339Nano) + } + out = append(out, summary) + } + + return out +} + func sanitizeControlPlanePodListLimit(limit int64) int64 { if limit <= 0 { return defaultControlPlanePodListLimit