package mcp import ( "cmp" "context" "fmt" "maps" "slices" "strings" "time" "go.yaml.in/yaml/v3" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/sophotechlabs/spinoza/internal/api" "github.com/sophotechlabs/spinoza/internal/checks" "github.com/sophotechlabs/spinoza/internal/logs" "github.com/sophotechlabs/spinoza/internal/prom" "github.com/sophotechlabs/spinoza/internal/topology" ) const ( defaultRows = 100 defaultTop = 20 defaultTailFor = 200 maxRows = 1000 ) func (s *Server) registerReads() { s.register(tool{ name: "get_dashboard", title: "Cluster dashboard", description: "Cluster health in one call: versions, node and pod counts, what is failing now, and how many best-practice findings stand.", run: s.dashboard, }) s.register(tool{ name: "list_namespaces", title: "Namespaces", description: "Every namespace this cluster reports, with its phase.", run: s.namespaces, }) s.register(tool{ name: "list_resource_kinds", title: "Resource kinds", description: "Every resource type the cluster serves, grouped as spinoza groups them. Ask this before guessing a resource name.", run: s.kinds, }) s.register(tool{ name: "list_resources", title: "List resources", description: "Objects of one kind, summarized to name, namespace, age and readiness.", properties: map[string]propOf{ argResource: text("Resource name or kind, for example deployments or Deployment."), argGroup: text("API group, when two groups serve the same name."), argNamespace: text("Namespace to limit to. Omit for every namespace."), argLimit: number("How many to return. Defaults to 100."), }, required: []string{argResource}, run: s.listResources, }) s.register(tool{ name: "get_resource", title: "Get one resource", description: "One object with its conditions, containers, owners and optionally its events. Secret values are never returned.", properties: map[string]propOf{ argResource: text("Resource name or kind."), argName: text("Object name."), argNamespace: text("Namespace, for a namespaced kind."), argGroup: text("API group, when ambiguous."), "events": toggle("Include the object's events."), }, required: []string{argResource, argName}, run: s.getResource, }) s.register(tool{ name: "get_events", title: "Events", description: "One object's events, deduplicated by reason and message, newest first.", properties: map[string]propOf{ argNamespace: text("Object namespace."), "uid": text("Object uid."), argLimit: number("How many to return. Defaults to 100."), }, required: []string{"uid"}, run: s.events, }) s.register(tool{ name: "get_topology", title: "Ownership topology", description: "The ownership and wiring graph as nodes and edges, folded so one Deployment is one node.", properties: map[string]propOf{ argNamespace: text("Namespace to scope to. Omit for the whole cluster."), "expand": text("Comma-separated node ids to open one level."), }, run: s.topology, }) s.register(tool{ name: "get_pod_logs", title: "Pod logs", description: "Logs from one pod. Raw lines are withheld by default because redaction cannot guarantee arbitrary text is safe.", properties: map[string]propOf{ argNamespace: text("Pod namespace."), argName: text("Pod name."), "container": text("Container name. Omit for the first."), "tail": number("Lines to read. Defaults to 200."), "errorsOnly": toggle("Keep only lines that look like errors or warnings."), }, required: []string{argNamespace, argName}, run: s.podLogs, }) s.register(tool{ name: "get_workload_logs", title: "Workload logs", description: "Logs from every pod of a workload. Raw lines are withheld by default because redaction cannot guarantee arbitrary text is safe.", properties: map[string]propOf{ argResource: text("Workload resource or kind, for example deployments."), argName: text("Workload name."), argNamespace: text("Namespace."), argGroup: text("API group, when ambiguous."), "tail": number("Lines per pod. Defaults to 200."), "errorsOnly": toggle("Keep only lines that look like errors or warnings."), }, required: []string{argResource, argName, argNamespace}, run: s.workloadLogs, }) s.register(tool{ name: "top_resources", title: "Top consumers", description: "Pods ranked by live CPU or memory.", properties: map[string]propOf{ "by": choice("What to rank by. Defaults to cpu.", "cpu", "memory"), argNamespace: text("Namespace to limit to."), argLimit: number("How many to return. Defaults to 20."), }, run: s.top, }) s.register(tool{ name: "search", title: "Search the cluster", description: "Find objects by name across every kind spinoza has cached.", properties: map[string]propOf{ argQuery: text("What to look for."), }, required: []string{argQuery}, run: s.search, }) s.register(tool{ name: "list_helm_releases", title: "Helm releases", description: "Every Helm release, with chart, version and status.", run: s.helmReleases, }) s.register(tool{ name: "get_helm_release", title: "Get a Helm release", description: "One release with its history and the values it was installed with.", properties: map[string]propOf{ argNamespace: text("Release namespace."), argName: text("Release name."), }, required: []string{argNamespace, argName}, run: s.helmRelease, }) s.register(tool{ name: "get_cluster_audit", title: "Best-practice audit", description: "Findings from spinoza's checks, with the object each one names and what to change.", properties: map[string]propOf{ "severity": choice("Keep only this severity.", "high", "medium", "low"), "check": text("Keep only this check id."), "scope": choice( "How much of the cluster to read. Defaults to everything.", "cluster", "workloads", ), argLimit: number("How many findings to return. Defaults to 100."), }, run: s.audit, }) s.register(tool{ name: "get_issues", title: "What is broken now", description: "The live queue of what is failing, root cause first, with pods folded under the workload that owns them.", properties: map[string]propOf{ argLimit: number("How many rows. Defaults to 100."), }, run: s.issues, }) if s.prom != nil { s.register(tool{ name: "query_prometheus", title: "Query Prometheus", description: "Run an instant PromQL query against the Prometheus spinoza discovered in this cluster.", properties: map[string]propOf{ argQuery: text("PromQL expression."), }, required: []string{argQuery}, run: s.queryProm, }) } } func (s *Server) dashboard(ctx context.Context, _ arguments) (any, error) { overview := s.cluster.Overview(ctx) counts := s.cluster.Counts(ctx) queue := s.cluster.Issues(ctx) report := s.cluster.Checks(ctx, checks.Filter{WholeCluster: true}) return map[string]any{ "context": s.context, "kubernetes": overview.Version, "nodes": overview.Nodes, "pods": overview.Pods, "warnings": overview.Warnings, "warningCount": overview.WarningCount, "failing": failingSummary(counts), "issues": issueLines(queue, 10), "issueCount": len(queue.Rows) + queue.Dropped, "auditGroups": len(report.Groups), "auditFound": findingCount(report), "incompleteChecks": incompleteChecks(report.Groups, ""), "errors": trimErrors(overview.Error, queue.Error, report.Error), }, nil } func (s *Server) namespaces(ctx context.Context, _ arguments) (any, error) { found := s.cluster.Namespaces(ctx) out := make([]map[string]string, 0, len(found.Names)) for _, name := range found.Names { out = append(out, map[string]string{argName: name}) } return map[string]any{"namespaces": out, keyError: found.Error}, nil } func (s *Server) kinds(_ context.Context, _ arguments) (any, error) { catalog := s.cluster.Resources() out := map[string][]string{} for _, category := range catalog.Categories { for _, desc := range category.Resources { out[category.Name] = append(out[category.Name], label(desc)) } } return map[string]any{"kinds": out, keyError: catalog.Error}, nil } func label(desc api.ResourceDescriptor) string { if desc.Group == "" { return desc.Resource } return desc.Resource + "." + desc.Group } func (s *Server) listResources(ctx context.Context, args arguments) (any, error) { ref, err := args.kind(s.cluster.Resources()) if err != nil { return nil, err } items, err := s.cluster.ListKind(ctx, ref) if err != nil { return nil, err } limit := args.limit(argLimit, defaultRows) rows := make([]map[string]any, 0, len(items)) for _, item := range items { if ref.Namespace != "" && item.GetNamespace() != ref.Namespace { continue } if len(rows) == limit { break } rows = append(rows, summaryOf(item)) } return map[string]any{argResource: ref.Resource, "total": len(items), "returned": len(rows), "items": rows}, nil } func summaryOf(item *unstructured.Unstructured) map[string]any { out := map[string]any{argName: item.GetName()} if item.GetNamespace() != "" { out["namespace"] = item.GetNamespace() } if created := item.GetCreationTimestamp(); !created.IsZero() { out["age"] = time.Since(created.Time).Truncate(time.Second).String() } return out } func (s *Server) getResource(ctx context.Context, args arguments) (any, error) { ref, err := args.ref(s.cluster.Resources()) if err != nil { return nil, err } detail, err := s.cluster.Object(ctx, ref) if err != nil { return nil, err } out := map[string]any{ "kind": detail.Kind, "resourceVersion": resourceVersionOf(detail.YAML), argName: detail.Name, argNamespace: detail.Namespace, "conditions": detail.Conditions, "containers": detail.Containers, "owners": detail.Owners, "labels": scrubMap(detail.Labels), "replicas": detail.Replicas, "managedBy": detail.ManagedBy, argYAML: safeYAML(detail.Kind, detail.YAML), } if len(detail.Data) > 0 { out["dataKeys"] = keysOnly(detail.Data) } if carriesSecrets(detail.Kind) { out["note"] = "spinoza withholds Secret values and the raw document; key names and sizes are above" } if args.flag("events") { events, eventsErr := s.cluster.Events(ctx, detail.Namespace, detail.UID) if eventsErr != nil { return nil, eventsErr } sample, total := dedupeEvents(events, defaultRows) out["events"] = sample out["eventTotal"] = total out["eventsReturned"] = len(sample) } return out, nil } func resourceVersionOf(document string) string { var parsed struct { Metadata struct { ResourceVersion string `yaml:"resourceVersion"` } `yaml:"metadata"` } err := yaml.Unmarshal([]byte(document), &parsed) if err != nil { return "" } return parsed.Metadata.ResourceVersion } func (s *Server) events(ctx context.Context, args arguments) (any, error) { uid, err := args.required("uid") if err != nil { return nil, err } found, err := s.cluster.Events(ctx, args.text(argNamespace), uid) if err != nil { return nil, err } sample, total := dedupeEvents(found, args.limit(argLimit, defaultRows)) return map[string]any{"events": sample, "total": total, "returned": len(sample)}, nil } func dedupeEvents(found []api.Event, limit int) ([]map[string]any, int) { seen := map[string]int{} out := []map[string]any{} for _, event := range found { key := event.Reason + "|" + event.Message at, held := seen[key] if held { running, ok := out[at]["count"].(int) if ok { out[at]["count"] = running + int(event.Count) } continue } seen[key] = len(out) out = append(out, map[string]any{ "type": event.Type, "reason": event.Reason, "message": scrub(event.Message), "source": event.Source, "lastSeen": event.LastSeen, "count": int(event.Count), }) } total := len(out) if total > limit { out = out[:limit] } return out, total } func (s *Server) topology(ctx context.Context, args arguments) (any, error) { req := topology.Request{Namespace: args.text(argNamespace)} if open := args.text("expand"); open != "" { req.Expanded = strings.Split(open, ",") } graph := s.cluster.Topology(ctx, req) nodes := make([]map[string]any, 0, len(graph.Nodes)) for _, node := range graph.Nodes { row := map[string]any{ "id": node.ID, "kind": node.Kind, argName: node.Name, argNamespace: node.Namespace, "ready": node.Ready, "status": node.Status, } if node.Contains > 0 { row["folded"] = node.Contains row["foldedUnhealthy"] = node.Unhealthy } nodes = append(nodes, row) } edges := make([]map[string]string, 0, len(graph.Edges)) for _, edge := range graph.Edges { edges = append(edges, map[string]string{"from": edge.From, "to": edge.To, "kind": edge.Kind}) } return map[string]any{"nodes": nodes, "edges": edges, keyError: graph.Error}, nil } func (s *Server) podLogs(ctx context.Context, args arguments) (any, error) { namespace, err := args.required(argNamespace) if err != nil { return nil, err } name, err := args.required(argName) if err != nil { return nil, err } if !s.unsafeRawOutput { return map[string]any{"pod": name, "lines": []string{}, "rawOutput": rawOutputWithheld}, nil } lines, err := s.readLogs(ctx, logs.Request{ Namespace: namespace, Name: name, Container: args.text("container"), TailLines: int64(args.number("tail", defaultTailFor)), }) if err != nil { return nil, err } return map[string]any{"pod": name, "lines": pick(lines, args.flag("errorsOnly"))}, nil } func (s *Server) workloadLogs(ctx context.Context, args arguments) (any, error) { ref, err := args.refIn(s.cluster.Resources()) if err != nil { return nil, err } if !s.unsafeRawOutput { return map[string]any{"workload": ref.Name, "lines": []string{}, "rawOutput": rawOutputWithheld}, nil } selector, err := s.cluster.PodSelector(ctx, ref) if err != nil { return nil, err } if selector == "" { return nil, fmt.Errorf("%s/%s selects no pods", ref.Resource, ref.Name) } lines, err := s.readLogs(ctx, logs.Request{ Namespace: ref.Namespace, Selector: selector, TailLines: int64(args.number("tail", defaultTailFor)), }) if err != nil { return nil, err } return map[string]any{"workload": ref.Name, "selector": selector, "lines": pick(lines, args.flag("errorsOnly"))}, nil } func (s *Server) readLogs(ctx context.Context, req logs.Request) ([]string, error) { if req.TailLines <= 0 { req.TailLines = int64(s.logLines) } req.TailLines = min(req.TailLines, int64(s.logLines)) collected, err := s.cluster.LogLines(ctx, req) if err != nil { return nil, err } if len(collected) > s.logLines { collected = collected[:s.logLines] } return scrubLines(collected), nil } var noisy = []string{keyError, "err ", "fatal", "panic", "warn", "exception", "failed", "failure"} func pick(lines []string, errorsOnly bool) []string { if !errorsOnly { return lines } kept := []string{} for _, line := range lines { lowered := strings.ToLower(line) for _, mark := range noisy { if strings.Contains(lowered, mark) { kept = append(kept, line) break } } } if len(kept) == 0 { return lines } return kept } func (s *Server) top(ctx context.Context, args arguments) (any, error) { usage := s.cluster.Metrics(ctx) by := args.text("by") namespace := args.text(argNamespace) type row struct { Pod string `json:"pod"` CPU int64 `json:"cpuMilli"` Memory int64 `json:"memoryMi"` } rows := []row{} for pod, used := range usage.Pods { if namespace != "" && !strings.HasPrefix(pod, namespace+"/") { continue } rows = append(rows, row{Pod: pod, CPU: used.CPUMilli, Memory: used.MemoryMi}) } slices.SortFunc(rows, func(left, right row) int { if by == "memory" { return cmp.Compare(right.Memory, left.Memory) } return cmp.Compare(right.CPU, left.CPU) }) limit := args.limit(argLimit, defaultTop) total := len(rows) if total > limit { rows = rows[:limit] } return map[string]any{ "by": rankedBy(by), "pods": rows, "total": total, "returned": len(rows), keyError: usage.Error, }, nil } func rankedBy(by string) string { if by == "memory" { return "memory" } return "cpu" } func (s *Server) search(ctx context.Context, args arguments) (any, error) { query, err := args.required(argQuery) if err != nil { return nil, err } found := s.cluster.Search(ctx, query) return map[string]any{"hits": found.Hits, "truncated": found.Truncated, "errors": found.Errors}, nil } func (s *Server) helmReleases(ctx context.Context, _ arguments) (any, error) { found, err := s.cluster.HelmReleases(ctx) if err != nil { return nil, err } return map[string]any{"releases": found.Releases, keyError: found.Error}, nil } func (s *Server) helmRelease(ctx context.Context, args arguments) (any, error) { namespace, err := args.required(argNamespace) if err != nil { return nil, err } name, err := args.required(argName) if err != nil { return nil, err } detail, err := s.cluster.HelmRelease(ctx, namespace, name, 0) if err != nil { return nil, err } history, err := s.cluster.HelmHistory(ctx, namespace, name, detail.Release.Revision) if err != nil { return nil, err } values := "" rawOutput := rawOutputWithheld if s.unsafeRawOutput { values = scrubStructuredYAML(detail.Values) rawOutput = "best-effort redaction enabled by unsafe raw-output option" } return map[string]any{ "release": detail.Release, "history": history.Revisions, "historyNext": history.Next, "values": values, "rawOutput": rawOutput, keyError: detail.Error, }, nil } func (s *Server) audit(ctx context.Context, args arguments) (any, error) { report := s.cluster.Checks(ctx, checks.Filter{WholeCluster: args.text("scope") != "workloads"}) severity := args.text("severity") only := args.text("check") limit := args.limit(argLimit, defaultRows) rows := []map[string]any{} truncated := false for _, group := range report.Groups { if only != "" && group.ID != only { continue } truncated = truncated || group.Truncated for _, finding := range group.Findings { foundSeverity := finding.Severity if foundSeverity == "" { foundSeverity = group.Severity } if severity != "" && foundSeverity != severity { continue } if len(rows) == limit { truncated = true continue } rows = append(rows, map[string]any{ "check": group.ID, "title": group.Title, "severity": foundSeverity, "wrong": group.Wrong, "remedy": group.Remedy, "object": objectAt(report, finding.Ref), "detail": finding.Detail, }) } } return map[string]any{ "scanned": report.Scanned, "findings": rows, "returned": len(rows), "truncated": truncated, "incompleteChecks": incompleteChecks(report.Groups, only), keyError: report.Error, }, nil } func incompleteChecks(groups []api.CheckGroup, only string) []map[string]any { incomplete := []map[string]any{} for _, group := range groups { if only != "" && group.ID != only { continue } if group.Skipped == "" && len(group.PartialOn) == 0 { continue } incomplete = append(incomplete, map[string]any{ "check": group.ID, "title": group.Title, "skipped": group.Skipped, "partialOn": group.PartialOn, }) } return incomplete } func objectAt(report api.CheckReport, ref int) any { if ref < 0 || ref >= len(report.Objects) { return nil } return report.Objects[ref] } func (s *Server) issues(ctx context.Context, args arguments) (any, error) { queue := s.cluster.Issues(ctx) rows := issueRows(queue, args.limit(argLimit, defaultRows)) dropped := queue.Dropped + len(queue.Rows) - len(rows) return map[string]any{"rows": rows, "dropped": dropped, keyError: queue.Error}, nil } func issueRows(queue api.IssueQueue, limit int) []map[string]any { out := []map[string]any{} for _, row := range queue.Rows { if len(out) == limit { break } out = append(out, map[string]any{ "title": row.Title, "severity": row.Severity, "kind": row.Kind, "object": row.Object, "detail": scrub(row.Detail), argAction: row.Action, "folded": row.Folded, }) } return out } func issueLines(queue api.IssueQueue, limit int) []string { out := []string{} for _, row := range queue.Rows { if len(out) == limit { break } out = append(out, row.Severity+": "+row.Title+" — "+row.Object.Name) } return out } func failingSummary(counts api.ResourceCounts) map[string]int { out := make(map[string]int, len(counts.Failing)) maps.Copy(out, counts.Failing) return out } func findingCount(report api.CheckReport) int { total := 0 for _, group := range report.Groups { found := max(group.Total, len(group.Findings)) total += found } return total } func trimErrors(reported ...string) []string { out := []string{} for _, one := range reported { if one == "" { continue } if slices.Contains(out, one) { continue } out = append(out, one) } return out } type series struct { Labels map[string]string `json:"labels"` Value float64 `json:"value"` } func seriesOf(samples []prom.Sample) []series { out := make([]series, 0, len(samples)) for _, one := range samples { out = append(out, series{Labels: one.Labels, Value: one.Value}) } return out } func (s *Server) queryProm(ctx context.Context, args arguments) (any, error) { query, err := args.required(argQuery) if err != nil { return nil, err } samples, err := s.prom.Instant(ctx, query, time.Now()) if err != nil { return nil, err } return map[string]any{argQuery: query, "samples": seriesOf(samples)}, nil }