From 77568e4e116324b1f750846cbe76499815ac1e25 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Fri, 31 Oct 2025 13:06:32 +0100 Subject: [PATCH 01/11] Add kiali toolset Signed-off-by: Alberto Gutierrez --- pkg/api/toolsets.go | 2 + pkg/config/config.go | 5 + pkg/kiali/endpoints.go | 8 ++ pkg/kiali/kiali.go | 118 ++++++++++++++++++ pkg/kiali/kiali_test.go | 97 ++++++++++++++ pkg/kiali/manager.go | 39 ++++++ pkg/kiali/manager_test.go | 56 +++++++++ pkg/kiali/mesh.go | 21 ++++ pkg/kiali/mesh_test.go | 45 +++++++ pkg/kubernetes-mcp-server/cmd/root.go | 17 +++ pkg/kubernetes-mcp-server/cmd/root_test.go | 43 ++++++- .../testdata/kiali-toolset-missing-url.toml | 2 + .../cmd/testdata/kiali-toolset-with-url.toml | 3 + pkg/kubernetes/kubernetes.go | 10 ++ pkg/mcp/m3labs.go | 9 +- pkg/mcp/modules.go | 1 + pkg/toolsets/kiali/mesh.go | 41 ++++++ pkg/toolsets/kiali/toolset.go | 31 +++++ 18 files changed, 546 insertions(+), 2 deletions(-) create mode 100644 pkg/kiali/endpoints.go create mode 100644 pkg/kiali/kiali.go create mode 100644 pkg/kiali/kiali_test.go create mode 100644 pkg/kiali/manager.go create mode 100644 pkg/kiali/manager_test.go create mode 100644 pkg/kiali/mesh.go create mode 100644 pkg/kiali/mesh_test.go create mode 100644 pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-missing-url.toml create mode 100644 pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml create mode 100644 pkg/toolsets/kiali/mesh.go create mode 100644 pkg/toolsets/kiali/toolset.go diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go index 9a990484..5058f01c 100644 --- a/pkg/api/toolsets.go +++ b/pkg/api/toolsets.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" + "github.com/containers/kubernetes-mcp-server/pkg/kiali" internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/google/jsonschema-go/jsonschema" @@ -65,6 +66,7 @@ func NewToolCallResult(content string, err error) *ToolCallResult { type ToolHandlerParams struct { context.Context *internalk8s.Kubernetes + *kiali.Kiali ToolCallRequest ListOutput output.Output } diff --git a/pkg/config/config.go b/pkg/config/config.go index 81bec2b7..f4885063 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -68,6 +68,11 @@ type StaticConfig struct { // This map holds raw TOML primitives that will be parsed by registered provider parsers ClusterProviderConfigs map[string]toml.Primitive `toml:"cluster_provider_configs,omitempty"` + // KialiServerURL is the URL of the Kiali server. + KialiURL string `toml:"kiali_url,omitempty"` + // KialiInsecure indicates whether the server should use insecure TLS for the Kiali server. + KialiInsecure bool `toml:"kiali_insecure,omitempty"` + // Internal: parsed provider configs (not exposed to TOML package) parsedClusterProviderConfigs map[string]ProviderConfig diff --git a/pkg/kiali/endpoints.go b/pkg/kiali/endpoints.go new file mode 100644 index 00000000..bf4d3407 --- /dev/null +++ b/pkg/kiali/endpoints.go @@ -0,0 +1,8 @@ +package kiali + +// Kiali API endpoint paths shared across this package. +const ( + // MeshGraph is the Kiali API path that returns the mesh graph/status. + MeshGraph = "/api/mesh/graph" + AuthInfo = "/api/auth/info" +) diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go new file mode 100644 index 00000000..dd7b89f9 --- /dev/null +++ b/pkg/kiali/kiali.go @@ -0,0 +1,118 @@ +package kiali + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "k8s.io/klog/v2" +) + +type Kiali struct { + manager *Manager +} + +func (m *Manager) GetKiali() *Kiali { + return &Kiali{manager: m} +} + +func (k *Kiali) GetKiali() *Kiali { + return k +} + +// validateAndGetURL validates the Kiali client configuration and returns the full URL +// by safely concatenating the base URL with the provided endpoint, avoiding duplicate +// or missing slashes regardless of trailing/leading slashes. +func (k *Kiali) validateAndGetURL(endpoint string) (string, error) { + if k == nil || k.manager == nil || k.manager.KialiURL == "" { + return "", fmt.Errorf("kiali client not initialized") + } + baseStr := strings.TrimSpace(k.manager.KialiURL) + if baseStr == "" { + return "", fmt.Errorf("kiali server URL not configured") + } + baseURL, err := url.Parse(baseStr) + if err != nil { + return "", fmt.Errorf("invalid kiali base URL: %w", err) + } + if endpoint == "" { + return baseURL.String(), nil + } + ref, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("invalid endpoint path: %w", err) + } + return baseURL.ResolveReference(ref).String(), nil +} + +func (k *Kiali) createHTTPClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: k.manager.KialiInsecure, + }, + }, + } +} + +// CurrentAuthorizationHeader returns the Authorization header value that the +// Kiali client is currently configured to use (Bearer ), or empty +// if no bearer token is configured. +func (k *Kiali) CurrentAuthorizationHeader(ctx context.Context) string { + token, _ := ctx.Value(internalk8s.OAuthAuthorizationHeader).(string) + token = strings.TrimSpace(token) + + if token == "" { + // Fall back to using the same token that the Kubernetes client is using + if k == nil || k.manager == nil || k.manager.BearerToken == "" { + return "" + } + token = strings.TrimSpace(k.manager.BearerToken) + if token == "" { + return "" + } + } + // Normalize to exactly "Bearer " without double prefix + lower := strings.ToLower(token) + if strings.HasPrefix(lower, "bearer ") { + return "Bearer " + strings.TrimSpace(token[7:]) + } + return "Bearer " + token +} + +// executeRequest executes an HTTP request and handles common error scenarios. +func (k *Kiali) executeRequest(ctx context.Context, endpoint string) (string, error) { + ApiCallURL, err := k.validateAndGetURL(endpoint) + if err != nil { + return "", err + } + + klog.V(0).Infof("Kiali Call URL: %s", ApiCallURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ApiCallURL, nil) + if err != nil { + return "", err + } + authHeader := k.CurrentAuthorizationHeader(ctx) + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + client := k.createHTTPClient() + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if len(body) > 0 { + return "", fmt.Errorf("kiali API error: %s", strings.TrimSpace(string(body))) + } + return "", fmt.Errorf("kiali API error: status %d", resp.StatusCode) + } + return string(body), nil +} diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go new file mode 100644 index 00000000..dbbab1db --- /dev/null +++ b/pkg/kiali/kiali_test.go @@ -0,0 +1,97 @@ +package kiali + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +func TestValidateAndGetURL_JoinsProperly(t *testing.T) { + m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example/"}) + k := m.GetKiali() + + full, err := k.validateAndGetURL("/api/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if full != "https://kiali.example/api/path" { + t.Fatalf("unexpected url: %s", full) + } + + m.KialiURL = "https://kiali.example" + full, err = k.validateAndGetURL("api/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if full != "https://kiali.example/api/path" { + t.Fatalf("unexpected url: %s", full) + } + + // preserve query + m.KialiURL = "https://kiali.example" + full, err = k.validateAndGetURL("/api/path?x=1&y=2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + u, _ := url.Parse(full) + if u.Path != "/api/path" || u.Query().Get("x") != "1" || u.Query().Get("y") != "2" { + t.Fatalf("unexpected parsed url: %s", full) + } +} + +func TestCurrentAuthorizationHeader_FromContext(t *testing.T) { + m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example"}) + k := m.GetKiali() + ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "bearer abc") + got := k.CurrentAuthorizationHeader(ctx) + if got != "Bearer abc" { + t.Fatalf("expected normalized bearer header, got '%s'", got) + } +} + +func TestCurrentAuthorizationHeader_FromManagerToken(t *testing.T) { + m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example"}) + m.BearerToken = "abc" + k := m.GetKiali() + got := k.CurrentAuthorizationHeader(context.Background()) + if got != "Bearer abc" { + t.Fatalf("expected 'Bearer abc', got '%s'", got) + } +} + +func TestExecuteRequest_SetsAuthAndCallsServer(t *testing.T) { + // setup test server to assert path and auth header + var seenAuth string + var seenPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenAuth = r.Header.Get("Authorization") + seenPath = r.URL.String() + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + m := NewManager(&config.StaticConfig{KialiURL: srv.URL}) + k := m.GetKiali() + ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer token-xyz") + + out, err := k.executeRequest(ctx, "/api/ping?q=1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "ok" { + t.Fatalf("unexpected body: %s", out) + } + if seenAuth != "Bearer token-xyz" { + t.Fatalf("expected auth header to be set, got '%s'", seenAuth) + } + if seenPath != "/api/ping?q=1" { + t.Fatalf("unexpected path: %s", seenPath) + } +} + + diff --git a/pkg/kiali/manager.go b/pkg/kiali/manager.go new file mode 100644 index 00000000..3b2ee5d1 --- /dev/null +++ b/pkg/kiali/manager.go @@ -0,0 +1,39 @@ +package kiali + +import ( + "context" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "k8s.io/klog/v2" +) + +type Manager struct { + BearerToken string + KialiURL string + KialiInsecure bool +} + +func NewManager(config *config.StaticConfig) *Manager { + return &Manager{ + BearerToken: "", + KialiURL: config.KialiURL, + KialiInsecure: config.KialiInsecure, + } +} + +func (m *Manager) Derived(ctx context.Context) (*Kiali, error) { + authorization, ok := ctx.Value(internalk8s.OAuthAuthorizationHeader).(string) + if !ok || !strings.HasPrefix(authorization, "Bearer ") { + return &Kiali{manager: m}, nil + } + // Authorization header is present; nothing special is needed for the Kiali HTTP client + klog.V(5).Infof("%s header found (Bearer), using provided bearer token", internalk8s.OAuthAuthorizationHeader) + + return &Kiali{manager: &Manager{ + BearerToken: strings.TrimPrefix(authorization, "Bearer "), + KialiURL: m.KialiURL, + KialiInsecure: m.KialiInsecure, + }}, nil +} diff --git a/pkg/kiali/manager_test.go b/pkg/kiali/manager_test.go new file mode 100644 index 00000000..6ffe7cc0 --- /dev/null +++ b/pkg/kiali/manager_test.go @@ -0,0 +1,56 @@ +package kiali + +import ( + "context" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +func TestNewManagerUsesConfigFields(t *testing.T) { + cfg := &config.StaticConfig{KialiURL: "https://kiali.example", KialiInsecure: true} + m := NewManager(cfg) + if m == nil { + t.Fatalf("expected manager, got nil") + } + if m.KialiURL != cfg.KialiURL { + t.Fatalf("expected KialiURL %s, got %s", cfg.KialiURL, m.KialiURL) + } + if m.KialiInsecure != cfg.KialiInsecure { + t.Fatalf("expected KialiInsecure %v, got %v", cfg.KialiInsecure, m.KialiInsecure) + } +} + +func TestDerivedWithoutAuthorizationReturnsOriginalManager(t *testing.T) { + cfg := &config.StaticConfig{KialiURL: "https://kiali.example"} + m := NewManager(cfg) + k, err := m.Derived(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if k == nil || k.manager != m { + t.Fatalf("expected derived Kiali to keep original manager") + } +} + +func TestDerivedWithAuthorizationPreservesURLAndToken(t *testing.T) { + cfg := &config.StaticConfig{KialiURL: "https://kiali.example", KialiInsecure: true} + m := NewManager(cfg) + ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer token-abc") + k, err := m.Derived(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if k == nil || k.manager == nil { + t.Fatalf("expected derived Kiali with manager") + } + if k.manager.BearerToken != "token-abc" { + t.Fatalf("expected bearer token 'token-abc', got '%s'", k.manager.BearerToken) + } + if k.manager.KialiURL != m.KialiURL || k.manager.KialiInsecure != m.KialiInsecure { + t.Fatalf("expected Kiali URL/insecure preserved") + } +} + + diff --git a/pkg/kiali/mesh.go b/pkg/kiali/mesh.go new file mode 100644 index 00000000..b443dcb9 --- /dev/null +++ b/pkg/kiali/mesh.go @@ -0,0 +1,21 @@ +package kiali + +import ( + "context" + "net/url" +) + +// MeshStatus calls the Kiali mesh graph API to get the status of mesh components. +// This returns information about mesh components like Istio, Kiali, Grafana, Prometheus +// and their interactions, versions, and health status. +func (k *Kiali) MeshStatus(ctx context.Context) (string, error) { + u, err := url.Parse(MeshGraph) + if err != nil { + return "", err + } + q := u.Query() + q.Set("includeGateways", "false") + q.Set("includeWaypoints", "false") + u.RawQuery = q.Encode() + return k.executeRequest(ctx, u.String()) +} diff --git a/pkg/kiali/mesh_test.go b/pkg/kiali/mesh_test.go new file mode 100644 index 00000000..5712ae92 --- /dev/null +++ b/pkg/kiali/mesh_test.go @@ -0,0 +1,45 @@ +package kiali + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +func TestMeshStatus_CallsGraphWithExpectedQuery(t *testing.T) { + var capturedURL *url.URL + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := *r.URL + capturedURL = &u + _, _ = w.Write([]byte("graph")) + })) + defer srv.Close() + + m := NewManager(&config.StaticConfig{KialiURL: srv.URL}) + k := m.GetKiali() + ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer tkn") + + out, err := k.MeshStatus(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "graph" { + t.Fatalf("unexpected response: %s", out) + } + if capturedURL == nil { + t.Fatalf("expected request to be captured") + } + if capturedURL.Path != "/api/mesh/graph" { + t.Fatalf("unexpected path: %s", capturedURL.Path) + } + if capturedURL.Query().Get("includeGateways") != "false" || capturedURL.Query().Get("includeWaypoints") != "false" { + t.Fatalf("unexpected query: %s", capturedURL.RawQuery) + } +} + + diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index db1782ab..f154d007 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "slices" "strconv" "strings" @@ -73,6 +74,8 @@ const ( flagServerUrl = "server-url" flagCertificateAuthority = "certificate-authority" flagDisableMultiCluster = "disable-multi-cluster" + flagKialiUrl = "kiali-url" + flagKialiInsecure = "kiali-insecure" ) type MCPServerOptions struct { @@ -94,6 +97,8 @@ type MCPServerOptions struct { CertificateAuthority string ServerURL string DisableMultiCluster bool + KialiUrl string + KialiInsecure bool ConfigPath string StaticConfig *config.StaticConfig @@ -157,6 +162,8 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.CertificateAuthority, flagCertificateAuthority, o.CertificateAuthority, "Certificate authority path to verify certificates. Optional. Only valid if require-oauth is enabled.") _ = cmd.Flags().MarkHidden(flagCertificateAuthority) cmd.Flags().BoolVar(&o.DisableMultiCluster, flagDisableMultiCluster, o.DisableMultiCluster, "Disable multi cluster tools. Optional. If true, all tools will be run against the default cluster/context.") + cmd.Flags().StringVar(&o.KialiUrl, flagKialiUrl, o.KialiUrl, "Kiali endpoint to use for kiali tools. Optional. If not set, the kiali tools will not be available.") + cmd.Flags().BoolVar(&o.KialiInsecure, flagKialiInsecure, o.KialiInsecure, "If true, allows insecure TLS connections to Kiali. Optional. If true, the kiali tools will not be available.") return cmd } @@ -232,6 +239,12 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { if cmd.Flag(flagDisableMultiCluster).Changed && m.DisableMultiCluster { m.StaticConfig.ClusterProviderStrategy = config.ClusterProviderDisabled } + if cmd.Flag(flagKialiUrl).Changed { + m.StaticConfig.KialiURL = m.KialiUrl + } + if cmd.Flag(flagKialiInsecure).Changed { + m.StaticConfig.KialiInsecure = m.KialiInsecure + } } func (m *MCPServerOptions) initializeLogging() { @@ -277,6 +290,10 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } + /* If Kiali tools are enabled, validate the Kiali URL */ + if slices.Contains(m.StaticConfig.Toolsets, "kiali") && strings.TrimSpace(m.StaticConfig.KialiURL) == "" { + return fmt.Errorf("kiali-url is required when kiali tools are enabled") + } return nil } diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 22521667..59aca96d 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -137,7 +137,7 @@ func TestToolsets(t *testing.T) { rootCmd := NewMCPServer(ioStreams) rootCmd.SetArgs([]string{"--help"}) o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout - if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") { + if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kiali).") { t.Fatalf("Expected all available toolsets, got %s %v", o, err) } }) @@ -161,6 +161,47 @@ func TestToolsets(t *testing.T) { }) } +func TestKialiURLRequired(t *testing.T) { + t.Run("flag toolsets includes kiali and missing kiali-url returns error", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali"}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { + t.Fatalf("expected error about missing kiali-url, got %v", err) + } + }) + t.Run("flag toolsets includes kiali and kiali-url provided passes", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali", "--kiali-url", "http://kiali"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("config toolsets includes kiali and missing kiali_url returns error", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + _, file, _, _ := runtime.Caller(0) + cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-missing-url.toml") + rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { + t.Fatalf("expected error about missing kiali-url, got %v", err) + } + }) + t.Run("config toolsets includes kiali and kiali_url present passes", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + _, file, _, _ := runtime.Caller(0) + cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-with-url.toml") + rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + func TestListOutput(t *testing.T) { t.Run("available", func(t *testing.T) { ioStreams, _ := testStream() diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-missing-url.toml b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-missing-url.toml new file mode 100644 index 00000000..9b65e3ad --- /dev/null +++ b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-missing-url.toml @@ -0,0 +1,2 @@ +toolsets = ["core", "kiali"] + diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml new file mode 100644 index 00000000..79eb4a97 --- /dev/null +++ b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml @@ -0,0 +1,3 @@ +toolsets = ["core", "kiali"] +kiali_url = "http://kiali" + diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 3b5733e1..2bbc7d4e 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -2,6 +2,7 @@ package kubernetes import ( "k8s.io/apimachinery/pkg/runtime" + "strings" "github.com/containers/kubernetes-mcp-server/pkg/helm" "k8s.io/client-go/kubernetes/scheme" @@ -37,3 +38,12 @@ func (k *Kubernetes) NewHelm() *helm.Helm { // This is a derived Kubernetes, so it already has the Helm initialized return helm.NewHelm(k.manager) } + +// CurrentBearerToken returns the bearer token that the Kubernetes client is currently +// configured to use, or empty if none is set in the underlying rest.Config. +func (k *Kubernetes) CurrentBearerToken() string { + if k == nil || k.manager == nil || k.manager.cfg == nil { + return "" + } + return strings.TrimSpace(k.manager.cfg.BearerToken) +} diff --git a/pkg/mcp/m3labs.go b/pkg/mcp/m3labs.go index ade0f56b..cd56798d 100644 --- a/pkg/mcp/m3labs.go +++ b/pkg/mcp/m3labs.go @@ -9,6 +9,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/kiali" ) func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.ServerTool, error) { @@ -45,10 +46,16 @@ func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.S if err != nil { return nil, err } - + kialiManager := kiali.NewManager(s.configuration.StaticConfig) + kialiManager.BearerToken = k.CurrentBearerToken() + derivedKiali, err := kialiManager.Derived(ctx) + if err != nil { + return nil, err + } result, err := tool.Handler(api.ToolHandlerParams{ Context: ctx, Kubernetes: k, + Kiali: derivedKiali, ToolCallRequest: request, ListOutput: s.configuration.ListOutput(), }) diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go index 3295d72b..464eefc8 100644 --- a/pkg/mcp/modules.go +++ b/pkg/mcp/modules.go @@ -3,3 +3,4 @@ package mcp import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" +import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali" diff --git a/pkg/toolsets/kiali/mesh.go b/pkg/toolsets/kiali/mesh.go new file mode 100644 index 00000000..7a96f7f8 --- /dev/null +++ b/pkg/toolsets/kiali/mesh.go @@ -0,0 +1,41 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initMeshStatus() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "mesh_status", + Description: "Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + Required: []string{}, + }, + Annotations: api.ToolAnnotations{ + Title: "Mesh Status: Components Overview", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: meshStatusHandler, + }) + return ret +} + +func meshStatusHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + content, err := params.MeshStatus(params.Context) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve mesh status: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/toolset.go b/pkg/toolsets/kiali/toolset.go new file mode 100644 index 00000000..a175888a --- /dev/null +++ b/pkg/toolsets/kiali/toolset.go @@ -0,0 +1,31 @@ +package kiali + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" +) + +type Toolset struct{} + +var _ api.Toolset = (*Toolset)(nil) + +func (t *Toolset) GetName() string { + return "kiali" +} + +func (t *Toolset) GetDescription() string { + return "Most common tools for managing Kiali" +} + +func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool { + return slices.Concat( + initMeshStatus(), + ) +} + +func init() { + toolsets.Register(&Toolset{}) +} From 414ac515238b0613e56c2c3a0d6900c570ca5a9a Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Tue, 4 Nov 2025 09:09:19 +0100 Subject: [PATCH 02/11] Kiali Options in a type and make kiali instance Signed-off-by: Alberto Gutierrez --- pkg/api/toolsets.go | 2 - pkg/config/config.go | 12 +- pkg/kiali/kiali.go | 25 +--- pkg/kiali/kiali_test.go | 140 ++++++++---------- pkg/kiali/manager.go | 22 +-- pkg/kiali/manager_test.go | 83 +++++------ pkg/kiali/mesh_test.go | 68 ++++----- pkg/kubernetes-mcp-server/cmd/root.go | 24 +-- pkg/kubernetes-mcp-server/cmd/root_test.go | 78 +++++----- .../cmd/testdata/kiali-toolset-with-url.toml | 4 +- pkg/kubernetes/kubernetes.go | 18 ++- pkg/mcp/m3labs.go | 8 - pkg/toolsets/kiali/mesh.go | 3 +- 13 files changed, 220 insertions(+), 267 deletions(-) diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go index 5058f01c..9a990484 100644 --- a/pkg/api/toolsets.go +++ b/pkg/api/toolsets.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" - "github.com/containers/kubernetes-mcp-server/pkg/kiali" internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/google/jsonschema-go/jsonschema" @@ -66,7 +65,6 @@ func NewToolCallResult(content string, err error) *ToolCallResult { type ToolHandlerParams struct { context.Context *internalk8s.Kubernetes - *kiali.Kiali ToolCallRequest ListOutput output.Output } diff --git a/pkg/config/config.go b/pkg/config/config.go index f4885063..4a18a82b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,6 +16,12 @@ const ( ClusterProviderDisabled = "disabled" ) +// KialiOptions is the configuration for the kiali toolset. +type KialiOptions struct { + Url string `toml:"url,omitempty"` + Insecure bool `toml:"insecure,omitempty"` +} + // StaticConfig is the configuration for the server. // It allows to configure server specific settings and tools to be enabled or disabled. type StaticConfig struct { @@ -68,10 +74,8 @@ type StaticConfig struct { // This map holds raw TOML primitives that will be parsed by registered provider parsers ClusterProviderConfigs map[string]toml.Primitive `toml:"cluster_provider_configs,omitempty"` - // KialiServerURL is the URL of the Kiali server. - KialiURL string `toml:"kiali_url,omitempty"` - // KialiInsecure indicates whether the server should use insecure TLS for the Kiali server. - KialiInsecure bool `toml:"kiali_insecure,omitempty"` + // KialiOptions is the configuration for the kiali toolset. + KialiOptions KialiOptions `toml:"kiali,omitempty"` // Internal: parsed provider configs (not exposed to TOML package) parsedClusterProviderConfigs map[string]ProviderConfig diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go index dd7b89f9..aa86c91e 100644 --- a/pkg/kiali/kiali.go +++ b/pkg/kiali/kiali.go @@ -5,12 +5,10 @@ import ( "crypto/tls" "fmt" "io" + "k8s.io/klog/v2" "net/http" "net/url" "strings" - - internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" - "k8s.io/klog/v2" ) type Kiali struct { @@ -63,21 +61,14 @@ func (k *Kiali) createHTTPClient() *http.Client { // CurrentAuthorizationHeader returns the Authorization header value that the // Kiali client is currently configured to use (Bearer ), or empty // if no bearer token is configured. -func (k *Kiali) CurrentAuthorizationHeader(ctx context.Context) string { - token, _ := ctx.Value(internalk8s.OAuthAuthorizationHeader).(string) - token = strings.TrimSpace(token) - +func (k *Kiali) authorizationHeader() string { + if k == nil || k.manager == nil { + return "" + } + token := strings.TrimSpace(k.manager.BearerToken) if token == "" { - // Fall back to using the same token that the Kubernetes client is using - if k == nil || k.manager == nil || k.manager.BearerToken == "" { - return "" - } - token = strings.TrimSpace(k.manager.BearerToken) - if token == "" { - return "" - } + return "" } - // Normalize to exactly "Bearer " without double prefix lower := strings.ToLower(token) if strings.HasPrefix(lower, "bearer ") { return "Bearer " + strings.TrimSpace(token[7:]) @@ -97,7 +88,7 @@ func (k *Kiali) executeRequest(ctx context.Context, endpoint string) (string, er if err != nil { return "", err } - authHeader := k.CurrentAuthorizationHeader(ctx) + authHeader := k.authorizationHeader() if authHeader != "" { req.Header.Set("Authorization", authHeader) } diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go index dbbab1db..9c70fc1d 100644 --- a/pkg/kiali/kiali_test.go +++ b/pkg/kiali/kiali_test.go @@ -1,97 +1,75 @@ package kiali import ( - "context" - "net/http" - "net/http/httptest" - "net/url" - "testing" + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" - "github.com/containers/kubernetes-mcp-server/pkg/config" - internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/config" ) func TestValidateAndGetURL_JoinsProperly(t *testing.T) { - m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example/"}) - k := m.GetKiali() + m := NewManager(&config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example/"}}) + k := m.GetKiali() - full, err := k.validateAndGetURL("/api/path") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if full != "https://kiali.example/api/path" { - t.Fatalf("unexpected url: %s", full) - } + full, err := k.validateAndGetURL("/api/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if full != "https://kiali.example/api/path" { + t.Fatalf("unexpected url: %s", full) + } - m.KialiURL = "https://kiali.example" - full, err = k.validateAndGetURL("api/path") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if full != "https://kiali.example/api/path" { - t.Fatalf("unexpected url: %s", full) - } + m.KialiURL = "https://kiali.example" + full, err = k.validateAndGetURL("api/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if full != "https://kiali.example/api/path" { + t.Fatalf("unexpected url: %s", full) + } - // preserve query - m.KialiURL = "https://kiali.example" - full, err = k.validateAndGetURL("/api/path?x=1&y=2") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - u, _ := url.Parse(full) - if u.Path != "/api/path" || u.Query().Get("x") != "1" || u.Query().Get("y") != "2" { - t.Fatalf("unexpected parsed url: %s", full) - } + // preserve query + m.KialiURL = "https://kiali.example" + full, err = k.validateAndGetURL("/api/path?x=1&y=2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + u, _ := url.Parse(full) + if u.Path != "/api/path" || u.Query().Get("x") != "1" || u.Query().Get("y") != "2" { + t.Fatalf("unexpected parsed url: %s", full) + } } -func TestCurrentAuthorizationHeader_FromContext(t *testing.T) { - m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example"}) - k := m.GetKiali() - ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "bearer abc") - got := k.CurrentAuthorizationHeader(ctx) - if got != "Bearer abc" { - t.Fatalf("expected normalized bearer header, got '%s'", got) - } -} - -func TestCurrentAuthorizationHeader_FromManagerToken(t *testing.T) { - m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example"}) - m.BearerToken = "abc" - k := m.GetKiali() - got := k.CurrentAuthorizationHeader(context.Background()) - if got != "Bearer abc" { - t.Fatalf("expected 'Bearer abc', got '%s'", got) - } -} +// CurrentAuthorizationHeader behavior is now implicit via executeRequest using Manager.BearerToken func TestExecuteRequest_SetsAuthAndCallsServer(t *testing.T) { - // setup test server to assert path and auth header - var seenAuth string - var seenPath string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seenAuth = r.Header.Get("Authorization") - seenPath = r.URL.String() - _, _ = w.Write([]byte("ok")) - })) - defer srv.Close() + // setup test server to assert path and auth header + var seenAuth string + var seenPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenAuth = r.Header.Get("Authorization") + seenPath = r.URL.String() + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() - m := NewManager(&config.StaticConfig{KialiURL: srv.URL}) - k := m.GetKiali() - ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer token-xyz") - - out, err := k.executeRequest(ctx, "/api/ping?q=1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if out != "ok" { - t.Fatalf("unexpected body: %s", out) - } - if seenAuth != "Bearer token-xyz" { - t.Fatalf("expected auth header to be set, got '%s'", seenAuth) - } - if seenPath != "/api/ping?q=1" { - t.Fatalf("unexpected path: %s", seenPath) - } + m := NewManager(&config.StaticConfig{KialiOptions: config.KialiOptions{Url: srv.URL}}) + m.BearerToken = "token-xyz" + k := m.GetKiali() + out, err := k.executeRequest(context.Background(), "/api/ping?q=1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "ok" { + t.Fatalf("unexpected body: %s", out) + } + if seenAuth != "Bearer token-xyz" { + t.Fatalf("expected auth header to be set, got '%s'", seenAuth) + } + if seenPath != "/api/ping?q=1" { + t.Fatalf("unexpected path: %s", seenPath) + } } - - diff --git a/pkg/kiali/manager.go b/pkg/kiali/manager.go index 3b2ee5d1..ee1442f1 100644 --- a/pkg/kiali/manager.go +++ b/pkg/kiali/manager.go @@ -2,11 +2,8 @@ package kiali import ( "context" - "strings" "github.com/containers/kubernetes-mcp-server/pkg/config" - internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" - "k8s.io/klog/v2" ) type Manager struct { @@ -18,22 +15,11 @@ type Manager struct { func NewManager(config *config.StaticConfig) *Manager { return &Manager{ BearerToken: "", - KialiURL: config.KialiURL, - KialiInsecure: config.KialiInsecure, + KialiURL: config.KialiOptions.Url, + KialiInsecure: config.KialiOptions.Insecure, } } -func (m *Manager) Derived(ctx context.Context) (*Kiali, error) { - authorization, ok := ctx.Value(internalk8s.OAuthAuthorizationHeader).(string) - if !ok || !strings.HasPrefix(authorization, "Bearer ") { - return &Kiali{manager: m}, nil - } - // Authorization header is present; nothing special is needed for the Kiali HTTP client - klog.V(5).Infof("%s header found (Bearer), using provided bearer token", internalk8s.OAuthAuthorizationHeader) - - return &Kiali{manager: &Manager{ - BearerToken: strings.TrimPrefix(authorization, "Bearer "), - KialiURL: m.KialiURL, - KialiInsecure: m.KialiInsecure, - }}, nil +func (m *Manager) Derived(_ context.Context) (*Kiali, error) { + return &Kiali{manager: m}, nil } diff --git a/pkg/kiali/manager_test.go b/pkg/kiali/manager_test.go index 6ffe7cc0..69a93531 100644 --- a/pkg/kiali/manager_test.go +++ b/pkg/kiali/manager_test.go @@ -1,56 +1,53 @@ package kiali import ( - "context" - "testing" + "context" + "testing" - "github.com/containers/kubernetes-mcp-server/pkg/config" - internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/config" ) func TestNewManagerUsesConfigFields(t *testing.T) { - cfg := &config.StaticConfig{KialiURL: "https://kiali.example", KialiInsecure: true} - m := NewManager(cfg) - if m == nil { - t.Fatalf("expected manager, got nil") - } - if m.KialiURL != cfg.KialiURL { - t.Fatalf("expected KialiURL %s, got %s", cfg.KialiURL, m.KialiURL) - } - if m.KialiInsecure != cfg.KialiInsecure { - t.Fatalf("expected KialiInsecure %v, got %v", cfg.KialiInsecure, m.KialiInsecure) - } + cfg := &config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example", Insecure: true}} + m := NewManager(cfg) + if m == nil { + t.Fatalf("expected manager, got nil") + } + if m.KialiURL != cfg.KialiOptions.Url { + t.Fatalf("expected KialiURL %s, got %s", cfg.KialiOptions.Url, m.KialiURL) + } + if m.KialiInsecure != cfg.KialiOptions.Insecure { + t.Fatalf("expected KialiInsecure %v, got %v", cfg.KialiOptions.Insecure, m.KialiInsecure) + } } func TestDerivedWithoutAuthorizationReturnsOriginalManager(t *testing.T) { - cfg := &config.StaticConfig{KialiURL: "https://kiali.example"} - m := NewManager(cfg) - k, err := m.Derived(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if k == nil || k.manager != m { - t.Fatalf("expected derived Kiali to keep original manager") - } + cfg := &config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example"}} + m := NewManager(cfg) + k, err := m.Derived(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if k == nil || k.manager != m { + t.Fatalf("expected derived Kiali to keep original manager") + } } -func TestDerivedWithAuthorizationPreservesURLAndToken(t *testing.T) { - cfg := &config.StaticConfig{KialiURL: "https://kiali.example", KialiInsecure: true} - m := NewManager(cfg) - ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer token-abc") - k, err := m.Derived(ctx) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if k == nil || k.manager == nil { - t.Fatalf("expected derived Kiali with manager") - } - if k.manager.BearerToken != "token-abc" { - t.Fatalf("expected bearer token 'token-abc', got '%s'", k.manager.BearerToken) - } - if k.manager.KialiURL != m.KialiURL || k.manager.KialiInsecure != m.KialiInsecure { - t.Fatalf("expected Kiali URL/insecure preserved") - } +func TestDerivedPreservesURLAndToken(t *testing.T) { + cfg := &config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example", Insecure: true}} + m := NewManager(cfg) + m.BearerToken = "token-abc" + k, err := m.Derived(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if k == nil || k.manager == nil { + t.Fatalf("expected derived Kiali with manager") + } + if k.manager.BearerToken != "token-abc" { + t.Fatalf("expected bearer token 'token-abc', got '%s'", k.manager.BearerToken) + } + if k.manager.KialiURL != m.KialiURL || k.manager.KialiInsecure != m.KialiInsecure { + t.Fatalf("expected Kiali URL/insecure preserved") + } } - - diff --git a/pkg/kiali/mesh_test.go b/pkg/kiali/mesh_test.go index 5712ae92..03a4fab7 100644 --- a/pkg/kiali/mesh_test.go +++ b/pkg/kiali/mesh_test.go @@ -1,45 +1,41 @@ package kiali import ( - "context" - "net/http" - "net/http/httptest" - "net/url" - "testing" + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" - "github.com/containers/kubernetes-mcp-server/pkg/config" - internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/config" ) func TestMeshStatus_CallsGraphWithExpectedQuery(t *testing.T) { - var capturedURL *url.URL - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - u := *r.URL - capturedURL = &u - _, _ = w.Write([]byte("graph")) - })) - defer srv.Close() + var capturedURL *url.URL + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := *r.URL + capturedURL = &u + _, _ = w.Write([]byte("graph")) + })) + defer srv.Close() - m := NewManager(&config.StaticConfig{KialiURL: srv.URL}) - k := m.GetKiali() - ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer tkn") - - out, err := k.MeshStatus(ctx) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if out != "graph" { - t.Fatalf("unexpected response: %s", out) - } - if capturedURL == nil { - t.Fatalf("expected request to be captured") - } - if capturedURL.Path != "/api/mesh/graph" { - t.Fatalf("unexpected path: %s", capturedURL.Path) - } - if capturedURL.Query().Get("includeGateways") != "false" || capturedURL.Query().Get("includeWaypoints") != "false" { - t.Fatalf("unexpected query: %s", capturedURL.RawQuery) - } + m := NewManager(&config.StaticConfig{KialiOptions: config.KialiOptions{Url: srv.URL}}) + m.BearerToken = "tkn" + k := m.GetKiali() + out, err := k.MeshStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "graph" { + t.Fatalf("unexpected response: %s", out) + } + if capturedURL == nil { + t.Fatalf("expected request to be captured") + } + if capturedURL.Path != "/api/mesh/graph" { + t.Fatalf("unexpected path: %s", capturedURL.Path) + } + if capturedURL.Query().Get("includeGateways") != "false" || capturedURL.Query().Get("includeWaypoints") != "false" { + t.Fatalf("unexpected query: %s", capturedURL.RawQuery) + } } - - diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index f154d007..9157a685 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -78,6 +78,11 @@ const ( flagKialiInsecure = "kiali-insecure" ) +type KialiOptions struct { + Url string + Insecure bool +} + type MCPServerOptions struct { Version bool LogLevel int @@ -97,8 +102,7 @@ type MCPServerOptions struct { CertificateAuthority string ServerURL string DisableMultiCluster bool - KialiUrl string - KialiInsecure bool + KialiOptions KialiOptions ConfigPath string StaticConfig *config.StaticConfig @@ -162,8 +166,8 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.CertificateAuthority, flagCertificateAuthority, o.CertificateAuthority, "Certificate authority path to verify certificates. Optional. Only valid if require-oauth is enabled.") _ = cmd.Flags().MarkHidden(flagCertificateAuthority) cmd.Flags().BoolVar(&o.DisableMultiCluster, flagDisableMultiCluster, o.DisableMultiCluster, "Disable multi cluster tools. Optional. If true, all tools will be run against the default cluster/context.") - cmd.Flags().StringVar(&o.KialiUrl, flagKialiUrl, o.KialiUrl, "Kiali endpoint to use for kiali tools. Optional. If not set, the kiali tools will not be available.") - cmd.Flags().BoolVar(&o.KialiInsecure, flagKialiInsecure, o.KialiInsecure, "If true, allows insecure TLS connections to Kiali. Optional. If true, the kiali tools will not be available.") + cmd.Flags().StringVar(&o.KialiOptions.Url, flagKialiUrl, o.KialiOptions.Url, "Kiali endpoint to use for kiali tools. Optional. If not set, the kiali tools will not be available.") + cmd.Flags().BoolVar(&o.KialiOptions.Insecure, flagKialiInsecure, o.KialiOptions.Insecure, "If true, allows insecure TLS connections to Kiali. Optional. If true, the kiali tools will not be available.") return cmd } @@ -240,10 +244,10 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { m.StaticConfig.ClusterProviderStrategy = config.ClusterProviderDisabled } if cmd.Flag(flagKialiUrl).Changed { - m.StaticConfig.KialiURL = m.KialiUrl + m.StaticConfig.KialiOptions.Url = m.KialiOptions.Url } if cmd.Flag(flagKialiInsecure).Changed { - m.StaticConfig.KialiInsecure = m.KialiInsecure + m.StaticConfig.KialiOptions.Insecure = m.KialiOptions.Insecure } } @@ -290,10 +294,10 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } - /* If Kiali tools are enabled, validate the Kiali URL */ - if slices.Contains(m.StaticConfig.Toolsets, "kiali") && strings.TrimSpace(m.StaticConfig.KialiURL) == "" { - return fmt.Errorf("kiali-url is required when kiali tools are enabled") - } + /* If Kiali tools are enabled, validate the Kiali URL */ + if slices.Contains(m.StaticConfig.Toolsets, "kiali") && strings.TrimSpace(m.StaticConfig.KialiOptions.Url) == "" { + return fmt.Errorf("kiali-url is required when kiali tools are enabled") + } return nil } diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 59aca96d..f5fb0803 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -137,7 +137,7 @@ func TestToolsets(t *testing.T) { rootCmd := NewMCPServer(ioStreams) rootCmd.SetArgs([]string{"--help"}) o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout - if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kiali).") { + if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kiali).") { t.Fatalf("Expected all available toolsets, got %s %v", o, err) } }) @@ -162,44 +162,44 @@ func TestToolsets(t *testing.T) { } func TestKialiURLRequired(t *testing.T) { - t.Run("flag toolsets includes kiali and missing kiali-url returns error", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali"}) - err := rootCmd.Execute() - if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { - t.Fatalf("expected error about missing kiali-url, got %v", err) - } - }) - t.Run("flag toolsets includes kiali and kiali-url provided passes", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali", "--kiali-url", "http://kiali"}) - if err := rootCmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - t.Run("config toolsets includes kiali and missing kiali_url returns error", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - _, file, _, _ := runtime.Caller(0) - cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-missing-url.toml") - rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) - err := rootCmd.Execute() - if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { - t.Fatalf("expected error about missing kiali-url, got %v", err) - } - }) - t.Run("config toolsets includes kiali and kiali_url present passes", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - _, file, _, _ := runtime.Caller(0) - cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-with-url.toml") - rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) - if err := rootCmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) + t.Run("flag toolsets includes kiali and missing kiali-url returns error", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali"}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { + t.Fatalf("expected error about missing kiali-url, got %v", err) + } + }) + t.Run("flag toolsets includes kiali and kiali-url provided passes", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali", "--kiali-url", "http://kiali"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("config toolsets includes kiali and missing kiali_url returns error", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + _, file, _, _ := runtime.Caller(0) + cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-missing-url.toml") + rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { + t.Fatalf("expected error about missing kiali-url, got %v", err) + } + }) + t.Run("config toolsets includes kiali and kiali_url present passes", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + _, file, _, _ := runtime.Caller(0) + cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-with-url.toml") + rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) } func TestListOutput(t *testing.T) { diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml index 79eb4a97..2762762f 100644 --- a/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml +++ b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml @@ -1,3 +1,5 @@ toolsets = ["core", "kiali"] -kiali_url = "http://kiali" + +[kiali] +url = "http://kiali" diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 2bbc7d4e..d5da8385 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -2,9 +2,9 @@ package kubernetes import ( "k8s.io/apimachinery/pkg/runtime" - "strings" "github.com/containers/kubernetes-mcp-server/pkg/helm" + "github.com/containers/kubernetes-mcp-server/pkg/kiali" "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" @@ -39,11 +39,15 @@ func (k *Kubernetes) NewHelm() *helm.Helm { return helm.NewHelm(k.manager) } -// CurrentBearerToken returns the bearer token that the Kubernetes client is currently -// configured to use, or empty if none is set in the underlying rest.Config. -func (k *Kubernetes) CurrentBearerToken() string { - if k == nil || k.manager == nil || k.manager.cfg == nil { - return "" +// NewKiali returns a Kiali client initialized with the same StaticConfig and bearer token +// as the underlying Kubernetes manager. The token is taken from the manager rest.Config. +func (k *Kubernetes) NewKiali() *kiali.Kiali { + if k == nil || k.manager == nil || k.manager.staticConfig == nil { + return nil } - return strings.TrimSpace(k.manager.cfg.BearerToken) + km := kiali.NewManager(k.manager.staticConfig) + if k.manager.cfg != nil { + km.BearerToken = k.manager.cfg.BearerToken + } + return km.GetKiali() } diff --git a/pkg/mcp/m3labs.go b/pkg/mcp/m3labs.go index cd56798d..db3b7752 100644 --- a/pkg/mcp/m3labs.go +++ b/pkg/mcp/m3labs.go @@ -9,7 +9,6 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/containers/kubernetes-mcp-server/pkg/api" - "github.com/containers/kubernetes-mcp-server/pkg/kiali" ) func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.ServerTool, error) { @@ -46,16 +45,9 @@ func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.S if err != nil { return nil, err } - kialiManager := kiali.NewManager(s.configuration.StaticConfig) - kialiManager.BearerToken = k.CurrentBearerToken() - derivedKiali, err := kialiManager.Derived(ctx) - if err != nil { - return nil, err - } result, err := tool.Handler(api.ToolHandlerParams{ Context: ctx, Kubernetes: k, - Kiali: derivedKiali, ToolCallRequest: request, ListOutput: s.configuration.ListOutput(), }) diff --git a/pkg/toolsets/kiali/mesh.go b/pkg/toolsets/kiali/mesh.go index 7a96f7f8..d13fa48b 100644 --- a/pkg/toolsets/kiali/mesh.go +++ b/pkg/toolsets/kiali/mesh.go @@ -33,7 +33,8 @@ func initMeshStatus() []api.ServerTool { } func meshStatusHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - content, err := params.MeshStatus(params.Context) + k := params.NewKiali() + content, err := k.MeshStatus(params.Context) if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to retrieve mesh status: %v", err)), nil } From 0f3dd4219150c87b117022ad9b4ee7755e80a5e5 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Wed, 5 Nov 2025 09:29:56 +0100 Subject: [PATCH 03/11] Update docs about Kiali Signed-off-by: Alberto Gutierrez --- README.md | 13 +++++++- docs/KIALI_INTEGRATION.md | 44 ++++++++++++++++++++++++++++ internal/tools/update-readme/main.go | 1 + 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 docs/KIALI_INTEGRATION.md diff --git a/README.md b/README.md index ee592bd5..2acc2600 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ Enabling only the toolsets you need can help reduce the context size and improve ### Available Toolsets -The following sets of tools are available (all on by default): +The following sets of tools are available (all on by default). @@ -213,9 +213,12 @@ The following sets of tools are available (all on by default): | config | View and manage the current local Kubernetes configuration (kubeconfig) | | core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | | helm | Tools for managing Helm charts and releases | +| kiali | Most common tools for managing Kiali | +See more info about Kiali integration in [docs/KIALI_INTEGRATION.md](docs/KIALI_INTEGRATION.md). + ### Tools In case multi-cluster support is enabled (default) and you have access to multiple clusters, all applicable tools will include an additional `context` argument to specify the Kubernetes context (cluster) to use for that operation. @@ -343,6 +346,14 @@ In case multi-cluster support is enabled (default) and you have access to multip +
+ +kiali + +- **mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status + +
+ diff --git a/docs/KIALI_INTEGRATION.md b/docs/KIALI_INTEGRATION.md new file mode 100644 index 00000000..09ed43e4 --- /dev/null +++ b/docs/KIALI_INTEGRATION.md @@ -0,0 +1,44 @@ +## Kiali integration + +This server can expose Kiali tools so assistants can query mesh information (e.g., mesh status/graph). + +### Enable the Kiali toolset + +You can enable the Kiali tools via config or flags. + +Config (TOML): + +```toml +toolsets = ["core", "kiali"] + +[kiali] +url = "https://kiali.example" +# insecure = true # optional: allow insecure TLS +``` + +Flags: + +```bash +kubernetes-mcp-server \ + --toolsets core,kiali \ + --kiali-url https://kiali.example \ + [--kiali-insecure] +``` + +When the `kiali` toolset is enabled, a Kiali URL is required. Without it, the server will refuse to start. + +### How authentication works + +- The server uses your existing Kubernetes credentials (from kubeconfig or in-cluster) to set a bearer token for Kiali calls. +- If you pass an HTTP Authorization header to the MCP HTTP endpoint, that is not required for Kiali; Kiali calls use the server's configured token. + +### Available tools (initial) + +- `mesh_status`: retrieves mesh components status from Kiali’s mesh graph endpoint. + +### Troubleshooting + +- Error: "kiali-url is required when kiali tools are enabled" → provide `--kiali-url` or set `[kiali].url` in the config TOML. +- TLS issues against Kiali → try `--kiali-insecure` or `[kiali].insecure = true` for non-production environments. + + diff --git a/internal/tools/update-readme/main.go b/internal/tools/update-readme/main.go index cdf695fc..1a9ba276 100644 --- a/internal/tools/update-readme/main.go +++ b/internal/tools/update-readme/main.go @@ -15,6 +15,7 @@ import ( _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" + _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali" ) type OpenShift struct{} From 37fabea2df0a780f919f22230d989004d0eb0c5c Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Wed, 5 Nov 2025 16:20:56 +0100 Subject: [PATCH 04/11] Make configuration toolsets and update docs Signed-off-by: Alberto Gutierrez --- docs/KIALI_INTEGRATION.md | 9 +++-- pkg/config/config.go | 57 +++++++++++++++++++++++---- pkg/config/toolset_config.go | 34 ++++++++++++++++ pkg/kiali/config.go | 43 ++++++++++++++++++++ pkg/kiali/kiali_test.go | 8 +++- pkg/kiali/manager.go | 13 ++++-- pkg/kiali/manager_test.go | 17 ++++---- pkg/kiali/mesh_test.go | 4 +- pkg/kubernetes-mcp-server/cmd/root.go | 20 ++++++---- 9 files changed, 172 insertions(+), 33 deletions(-) create mode 100644 pkg/config/toolset_config.go create mode 100644 pkg/kiali/config.go diff --git a/docs/KIALI_INTEGRATION.md b/docs/KIALI_INTEGRATION.md index 09ed43e4..c48f51be 100644 --- a/docs/KIALI_INTEGRATION.md +++ b/docs/KIALI_INTEGRATION.md @@ -11,7 +11,7 @@ Config (TOML): ```toml toolsets = ["core", "kiali"] -[kiali] +[toolset_configs.kiali] url = "https://kiali.example" # insecure = true # optional: allow insecure TLS ``` @@ -25,7 +25,7 @@ kubernetes-mcp-server \ [--kiali-insecure] ``` -When the `kiali` toolset is enabled, a Kiali URL is required. Without it, the server will refuse to start. +When the `kiali` toolset is enabled, a Kiali toolset configuration is required. Provide it via `[toolset_configs.kiali]` in the config file or by passing flags (which populate the toolset config). If missing or invalid, the server will refuse to start. ### How authentication works @@ -38,7 +38,8 @@ When the `kiali` toolset is enabled, a Kiali URL is required. Without it, the se ### Troubleshooting -- Error: "kiali-url is required when kiali tools are enabled" → provide `--kiali-url` or set `[kiali].url` in the config TOML. -- TLS issues against Kiali → try `--kiali-insecure` or `[kiali].insecure = true` for non-production environments. +- Missing Kiali configuration when `kiali` toolset is enabled → provide `--kiali-url` or set `[toolset_configs.kiali].url` in the config TOML. +- Invalid URL → ensure `[toolset_configs.kiali].url` is a valid `http(s)://host` URL. +- TLS issues against Kiali → try `--kiali-insecure` or `[toolset_configs.kiali].insecure = true` for non-production environments. diff --git a/pkg/config/config.go b/pkg/config/config.go index 4a18a82b..5601e7f0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,12 +16,6 @@ const ( ClusterProviderDisabled = "disabled" ) -// KialiOptions is the configuration for the kiali toolset. -type KialiOptions struct { - Url string `toml:"url,omitempty"` - Insecure bool `toml:"insecure,omitempty"` -} - // StaticConfig is the configuration for the server. // It allows to configure server specific settings and tools to be enabled or disabled. type StaticConfig struct { @@ -74,11 +68,14 @@ type StaticConfig struct { // This map holds raw TOML primitives that will be parsed by registered provider parsers ClusterProviderConfigs map[string]toml.Primitive `toml:"cluster_provider_configs,omitempty"` - // KialiOptions is the configuration for the kiali toolset. - KialiOptions KialiOptions `toml:"kiali,omitempty"` + // Toolset-specific configurations + // This map holds raw TOML primitives that will be parsed by registered toolset parsers + ToolsetConfigs map[string]toml.Primitive `toml:"toolset_configs,omitempty"` // Internal: parsed provider configs (not exposed to TOML package) parsedClusterProviderConfigs map[string]ProviderConfig + // Internal: parsed toolset configs (not exposed to TOML package) + parsedToolsetConfigs map[string]ToolsetConfig // Internal: the config.toml directory, to help resolve relative file paths configDirPath string @@ -136,6 +133,10 @@ func ReadToml(configData []byte, opts ...ReadConfigOpt) (*StaticConfig, error) { return nil, err } + if err := config.parseToolsetConfigs(md); err != nil { + return nil, err + } + return config, nil } @@ -172,3 +173,43 @@ func (c *StaticConfig) parseClusterProviderConfigs(md toml.MetaData) error { return nil } + +func (c *StaticConfig) parseToolsetConfigs(md toml.MetaData) error { + if c.parsedToolsetConfigs == nil { + c.parsedToolsetConfigs = make(map[string]ToolsetConfig, len(c.ToolsetConfigs)) + } + + ctx := withConfigDirPath(context.Background(), c.configDirPath) + + for name, primitive := range c.ToolsetConfigs { + parser, ok := getToolsetConfigParser(name) + if !ok { + continue + } + + toolsetConfig, err := parser(ctx, primitive, md) + if err != nil { + return fmt.Errorf("failed to parse config for Toolset '%s': %w", name, err) + } + + if err := toolsetConfig.Validate(); err != nil { + return fmt.Errorf("invalid config file for Toolset '%s': %w", name, err) + } + + c.parsedToolsetConfigs[name] = toolsetConfig + } + + return nil +} + +func (c *StaticConfig) GetToolsetConfig(name string) (ToolsetConfig, bool) { + cfg, ok := c.parsedToolsetConfigs[name] + return cfg, ok +} + +func (c *StaticConfig) SetToolsetConfig(name string, cfg ToolsetConfig) { + if c.parsedToolsetConfigs == nil { + c.parsedToolsetConfigs = make(map[string]ToolsetConfig) + } + c.parsedToolsetConfigs[name] = cfg +} diff --git a/pkg/config/toolset_config.go b/pkg/config/toolset_config.go new file mode 100644 index 00000000..fb230e71 --- /dev/null +++ b/pkg/config/toolset_config.go @@ -0,0 +1,34 @@ +package config + +import ( + "context" + "fmt" + + "github.com/BurntSushi/toml" +) + +// ToolsetConfig is the interface that all toolset-specific configurations must implement. +// Each toolset registers a factory function to parse its config from TOML primitives +type ToolsetConfig interface { + Validate() error +} + +type ToolsetConfigParser func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (ToolsetConfig, error) + +var ( + toolsetConfigParsers = make(map[string]ToolsetConfigParser) +) + +func RegisterToolsetConfig(name string, parser ToolsetConfigParser) { + if _, exists := toolsetConfigParsers[name]; exists { + panic(fmt.Sprintf("toolset config parser already registered for toolset '%s'", name)) + } + + toolsetConfigParsers[name] = parser +} + +func getToolsetConfigParser(name string) (ToolsetConfigParser, bool) { + parser, ok := toolsetConfigParsers[name] + + return parser, ok +} diff --git a/pkg/kiali/config.go b/pkg/kiali/config.go new file mode 100644 index 00000000..92bb6614 --- /dev/null +++ b/pkg/kiali/config.go @@ -0,0 +1,43 @@ +package kiali + +import ( + "context" + "errors" + "net/url" + + "github.com/BurntSushi/toml" + "github.com/containers/kubernetes-mcp-server/pkg/config" +) + +// Config holds Kiali toolset configuration +type Config struct { + Url string `toml:"url,omitempty"` + Insecure bool `toml:"insecure,omitempty"` +} + +var _ config.ToolsetConfig = (*Config)(nil) + +func (c *Config) Validate() error { + if c == nil { + return errors.New("kiali config is nil") + } + if c.Url == "" { + return errors.New("kiali-url is required") + } + if u, err := url.Parse(c.Url); err != nil || u.Scheme == "" || u.Host == "" { + return errors.New("kiali-url must be a valid URL") + } + return nil +} + +func kialiToolsetParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (config.ToolsetConfig, error) { + var cfg Config + if err := md.PrimitiveDecode(primitive, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func init() { + config.RegisterToolsetConfig("kiali", kialiToolsetParser) +} diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go index 9c70fc1d..2e520d0b 100644 --- a/pkg/kiali/kiali_test.go +++ b/pkg/kiali/kiali_test.go @@ -11,7 +11,9 @@ import ( ) func TestValidateAndGetURL_JoinsProperly(t *testing.T) { - m := NewManager(&config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example/"}}) + cfg := config.Default() + cfg.SetToolsetConfig("kiali", &Config{Url: "https://kiali.example/"}) + m := NewManager(cfg) k := m.GetKiali() full, err := k.validateAndGetURL("/api/path") @@ -56,7 +58,9 @@ func TestExecuteRequest_SetsAuthAndCallsServer(t *testing.T) { })) defer srv.Close() - m := NewManager(&config.StaticConfig{KialiOptions: config.KialiOptions{Url: srv.URL}}) + cfg := config.Default() + cfg.SetToolsetConfig("kiali", &Config{Url: srv.URL}) + m := NewManager(cfg) m.BearerToken = "token-xyz" k := m.GetKiali() out, err := k.executeRequest(context.Background(), "/api/ping?q=1") diff --git a/pkg/kiali/manager.go b/pkg/kiali/manager.go index ee1442f1..276f2221 100644 --- a/pkg/kiali/manager.go +++ b/pkg/kiali/manager.go @@ -13,11 +13,18 @@ type Manager struct { } func NewManager(config *config.StaticConfig) *Manager { - return &Manager{ + m := &Manager{ BearerToken: "", - KialiURL: config.KialiOptions.Url, - KialiInsecure: config.KialiOptions.Insecure, + KialiURL: "", + KialiInsecure: false, } + if cfg, ok := config.GetToolsetConfig("kiali"); ok { + if kc, ok := cfg.(*Config); ok && kc != nil { + m.KialiURL = kc.Url + m.KialiInsecure = kc.Insecure + } + } + return m } func (m *Manager) Derived(_ context.Context) (*Kiali, error) { diff --git a/pkg/kiali/manager_test.go b/pkg/kiali/manager_test.go index 69a93531..d8aa275a 100644 --- a/pkg/kiali/manager_test.go +++ b/pkg/kiali/manager_test.go @@ -8,21 +8,23 @@ import ( ) func TestNewManagerUsesConfigFields(t *testing.T) { - cfg := &config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example", Insecure: true}} + cfg := config.Default() + cfg.SetToolsetConfig("kiali", &Config{Url: "https://kiali.example", Insecure: true}) m := NewManager(cfg) if m == nil { t.Fatalf("expected manager, got nil") } - if m.KialiURL != cfg.KialiOptions.Url { - t.Fatalf("expected KialiURL %s, got %s", cfg.KialiOptions.Url, m.KialiURL) + if m.KialiURL != "https://kiali.example" { + t.Fatalf("expected KialiURL %s, got %s", "https://kiali.example", m.KialiURL) } - if m.KialiInsecure != cfg.KialiOptions.Insecure { - t.Fatalf("expected KialiInsecure %v, got %v", cfg.KialiOptions.Insecure, m.KialiInsecure) + if m.KialiInsecure != true { + t.Fatalf("expected KialiInsecure %v, got %v", true, m.KialiInsecure) } } func TestDerivedWithoutAuthorizationReturnsOriginalManager(t *testing.T) { - cfg := &config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example"}} + cfg := config.Default() + cfg.SetToolsetConfig("kiali", &Config{Url: "https://kiali.example"}) m := NewManager(cfg) k, err := m.Derived(context.Background()) if err != nil { @@ -34,7 +36,8 @@ func TestDerivedWithoutAuthorizationReturnsOriginalManager(t *testing.T) { } func TestDerivedPreservesURLAndToken(t *testing.T) { - cfg := &config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example", Insecure: true}} + cfg := config.Default() + cfg.SetToolsetConfig("kiali", &Config{Url: "https://kiali.example", Insecure: true}) m := NewManager(cfg) m.BearerToken = "token-abc" k, err := m.Derived(context.Background()) diff --git a/pkg/kiali/mesh_test.go b/pkg/kiali/mesh_test.go index 03a4fab7..8af6f11b 100644 --- a/pkg/kiali/mesh_test.go +++ b/pkg/kiali/mesh_test.go @@ -19,7 +19,9 @@ func TestMeshStatus_CallsGraphWithExpectedQuery(t *testing.T) { })) defer srv.Close() - m := NewManager(&config.StaticConfig{KialiOptions: config.KialiOptions{Url: srv.URL}}) + cfg := config.Default() + cfg.SetToolsetConfig("kiali", &Config{Url: srv.URL}) + m := NewManager(cfg) m.BearerToken = "tkn" k := m.GetKiali() out, err := k.MeshStatus(context.Background()) diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 9157a685..a1e80d0f 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -25,6 +25,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/config" internalhttp "github.com/containers/kubernetes-mcp-server/pkg/http" + "github.com/containers/kubernetes-mcp-server/pkg/kiali" "github.com/containers/kubernetes-mcp-server/pkg/mcp" "github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/containers/kubernetes-mcp-server/pkg/toolsets" @@ -243,11 +244,8 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { if cmd.Flag(flagDisableMultiCluster).Changed && m.DisableMultiCluster { m.StaticConfig.ClusterProviderStrategy = config.ClusterProviderDisabled } - if cmd.Flag(flagKialiUrl).Changed { - m.StaticConfig.KialiOptions.Url = m.KialiOptions.Url - } - if cmd.Flag(flagKialiInsecure).Changed { - m.StaticConfig.KialiOptions.Insecure = m.KialiOptions.Insecure + if cmd.Flag(flagKialiUrl).Changed || cmd.Flag(flagKialiInsecure).Changed { + m.StaticConfig.SetToolsetConfig("kiali", &kiali.Config{Url: m.KialiOptions.Url, Insecure: m.KialiOptions.Insecure}) } } @@ -294,9 +292,15 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } - /* If Kiali tools are enabled, validate the Kiali URL */ - if slices.Contains(m.StaticConfig.Toolsets, "kiali") && strings.TrimSpace(m.StaticConfig.KialiOptions.Url) == "" { - return fmt.Errorf("kiali-url is required when kiali tools are enabled") + /* If Kiali tools are enabled, validate Kiali toolset configuration */ + if slices.Contains(m.StaticConfig.Toolsets, "kiali") { + cfg, ok := m.StaticConfig.GetToolsetConfig("kiali") + if !ok { + return fmt.Errorf("kiali configuration is required when kiali tools are enabled") + } + if err := cfg.Validate(); err != nil { + return fmt.Errorf("invalid kiali configuration: %w", err) + } } return nil } From 8842217bcd89cdbb04aa5d54432172e20ec58ad5 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Wed, 5 Nov 2025 17:32:33 +0100 Subject: [PATCH 05/11] Change token get Signed-off-by: Alberto Gutierrez --- pkg/kiali/kiali.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go index aa86c91e..eea7a18d 100644 --- a/pkg/kiali/kiali.go +++ b/pkg/kiali/kiali.go @@ -69,9 +69,8 @@ func (k *Kiali) authorizationHeader() string { if token == "" { return "" } - lower := strings.ToLower(token) - if strings.HasPrefix(lower, "bearer ") { - return "Bearer " + strings.TrimSpace(token[7:]) + if strings.HasPrefix(token, "Bearer ") { + return token } return "Bearer " + token } From e1c2140147ab7e46692524b1df922f066b6c5601 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Wed, 5 Nov 2025 17:44:36 +0100 Subject: [PATCH 06/11] Adapt tests to the new toolsetconfig Signed-off-by: Alberto Gutierrez --- pkg/kubernetes-mcp-server/cmd/root.go | 24 +++++++++++-------- .../cmd/testdata/kiali-toolset-with-url.toml | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index a1e80d0f..3bb0f821 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -292,16 +292,20 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } - /* If Kiali tools are enabled, validate Kiali toolset configuration */ - if slices.Contains(m.StaticConfig.Toolsets, "kiali") { - cfg, ok := m.StaticConfig.GetToolsetConfig("kiali") - if !ok { - return fmt.Errorf("kiali configuration is required when kiali tools are enabled") - } - if err := cfg.Validate(); err != nil { - return fmt.Errorf("invalid kiali configuration: %w", err) - } - } + /* If Kiali tools are enabled, validate Kiali toolset configuration */ + if slices.Contains(m.StaticConfig.Toolsets, "kiali") { + cfg, ok := m.StaticConfig.GetToolsetConfig("kiali") + if !ok { + return fmt.Errorf("kiali-url is required when kiali tools are enabled") + } + if err := cfg.Validate(); err != nil { + // Normalize error message for missing URL to match expected UX/tests + if strings.Contains(err.Error(), "kiali-url is required") { + return fmt.Errorf("kiali-url is required when kiali tools are enabled") + } + return fmt.Errorf("invalid kiali configuration: %w", err) + } + } return nil } diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml index 2762762f..b389d264 100644 --- a/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml +++ b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml @@ -1,5 +1,5 @@ toolsets = ["core", "kiali"] -[kiali] +[toolset_configs.kiali] url = "http://kiali" From 4cc8a89c3619861cbf16ddc35df5f200a742f982 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Fri, 7 Nov 2025 12:53:46 +0100 Subject: [PATCH 07/11] review(toolsets): align kiali implementation Signed-off-by: Marc Nuri --- pkg/kiali/kiali.go | 35 +++--- pkg/kiali/kiali_test.go | 161 +++++++++++++++++--------- pkg/kiali/manager.go | 32 ----- pkg/kiali/manager_test.go | 56 --------- pkg/kiali/mesh_test.go | 51 ++++---- pkg/kubernetes-mcp-server/cmd/root.go | 28 ++--- pkg/kubernetes/kubernetes.go | 11 +- pkg/mcp/m3labs.go | 1 + 8 files changed, 167 insertions(+), 208 deletions(-) delete mode 100644 pkg/kiali/manager.go delete mode 100644 pkg/kiali/manager_test.go diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go index eea7a18d..9c82030c 100644 --- a/pkg/kiali/kiali.go +++ b/pkg/kiali/kiali.go @@ -5,32 +5,41 @@ import ( "crypto/tls" "fmt" "io" - "k8s.io/klog/v2" "net/http" "net/url" "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" ) type Kiali struct { - manager *Manager + bearerToken string + kialiURL string + kialiInsecure bool } -func (m *Manager) GetKiali() *Kiali { - return &Kiali{manager: m} -} - -func (k *Kiali) GetKiali() *Kiali { - return k +// NewKiali creates a new Kiali instance +func NewKiali(config *config.StaticConfig, kubernetes *rest.Config) *Kiali { + kiali := &Kiali{bearerToken: kubernetes.BearerToken} + if cfg, ok := config.GetToolsetConfig("kiali"); ok { + if kc, ok := cfg.(*Config); ok && kc != nil { + kiali.kialiURL = kc.Url + kiali.kialiInsecure = kc.Insecure + } + } + return kiali } // validateAndGetURL validates the Kiali client configuration and returns the full URL // by safely concatenating the base URL with the provided endpoint, avoiding duplicate // or missing slashes regardless of trailing/leading slashes. func (k *Kiali) validateAndGetURL(endpoint string) (string, error) { - if k == nil || k.manager == nil || k.manager.KialiURL == "" { + if k == nil || k.kialiURL == "" { return "", fmt.Errorf("kiali client not initialized") } - baseStr := strings.TrimSpace(k.manager.KialiURL) + baseStr := strings.TrimSpace(k.kialiURL) if baseStr == "" { return "", fmt.Errorf("kiali server URL not configured") } @@ -52,7 +61,7 @@ func (k *Kiali) createHTTPClient() *http.Client { return &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: k.manager.KialiInsecure, + InsecureSkipVerify: k.kialiInsecure, }, }, } @@ -62,10 +71,10 @@ func (k *Kiali) createHTTPClient() *http.Client { // Kiali client is currently configured to use (Bearer ), or empty // if no bearer token is configured. func (k *Kiali) authorizationHeader() string { - if k == nil || k.manager == nil { + if k == nil { return "" } - token := strings.TrimSpace(k.manager.BearerToken) + token := strings.TrimSpace(k.bearerToken) if token == "" { return "" } diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go index 2e520d0b..2db55aa0 100644 --- a/pkg/kiali/kiali_test.go +++ b/pkg/kiali/kiali_test.go @@ -1,79 +1,126 @@ package kiali import ( - "context" + "fmt" "net/http" - "net/http/httptest" "net/url" "testing" + "github.com/containers/kubernetes-mcp-server/internal/test" "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/stretchr/testify/suite" ) -func TestValidateAndGetURL_JoinsProperly(t *testing.T) { - cfg := config.Default() - cfg.SetToolsetConfig("kiali", &Config{Url: "https://kiali.example/"}) - m := NewManager(cfg) - k := m.GetKiali() - - full, err := k.validateAndGetURL("/api/path") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if full != "https://kiali.example/api/path" { - t.Fatalf("unexpected url: %s", full) - } - - m.KialiURL = "https://kiali.example" - full, err = k.validateAndGetURL("api/path") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if full != "https://kiali.example/api/path" { - t.Fatalf("unexpected url: %s", full) - } - - // preserve query - m.KialiURL = "https://kiali.example" - full, err = k.validateAndGetURL("/api/path?x=1&y=2") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - u, _ := url.Parse(full) - if u.Path != "/api/path" || u.Query().Get("x") != "1" || u.Query().Get("y") != "2" { - t.Fatalf("unexpected parsed url: %s", full) - } +type KialiSuite struct { + suite.Suite + MockServer *test.MockServer + Config *config.StaticConfig +} + +func (s *KialiSuite) SetupTest() { + s.MockServer = test.NewMockServer() + s.MockServer.Config().BearerToken = "" + s.Config = config.Default() +} + +func (s *KialiSuite) TearDownTest() { + s.MockServer.Close() +} + +func (s *KialiSuite) TestNewKiali_SetsFields() { + s.Config = test.Must(config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "https://kiali.example/" + insecure = true + `))) + s.MockServer.Config().BearerToken = "bearer-token" + k := NewKiali(s.Config, s.MockServer.Config()) + + s.Run("URL is set", func() { + s.Equal("https://kiali.example/", k.kialiURL, "Unexpected Kiali URL") + }) + s.Run("Insecure is set", func() { + s.True(k.kialiInsecure, "Expected Kiali Insecure to be true") + }) + s.Run("BearerToken is set", func() { + s.Equal("bearer-token", k.bearerToken, "Unexpected Kiali BearerToken") + }) +} + +func (s *KialiSuite) TestNewKiali_InvalidConfig() { + cfg, err := config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "://invalid-url" + `)) + s.Error(err, "Expected error reading invalid config") + s.ErrorContains(err, "kiali-url must be a valid URL", "Unexpected error message") + s.Nil(cfg, "Unexpected Kiali config") +} + +func (s *KialiSuite) TestValidateAndGetURL() { + s.Config = test.Must(config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "https://kiali.example/" + `))) + k := NewKiali(s.Config, s.MockServer.Config()) + + s.Run("Computes full URL", func() { + s.Run("with leading slash", func() { + full, err := k.validateAndGetURL("/api/path") + s.Require().NoError(err, "Expected no error validating URL") + s.Equal("https://kiali.example/api/path", full, "Unexpected full URL") + }) + + s.Run("without leading slash", func() { + full, err := k.validateAndGetURL("api/path") + s.Require().NoError(err, "Expected no error validating URL") + s.Equal("https://kiali.example/api/path", full, "Unexpected full URL") + }) + + s.Run("with query parameters, preserves query", func() { + full, err := k.validateAndGetURL("/api/path?x=1&y=2") + s.Require().NoError(err, "Expected no error validating URL") + u, err := url.Parse(full) + s.Require().NoError(err, "Expected to parse full URL") + s.Equal("/api/path", u.Path, "Unexpected path in parsed URL") + s.Equal("1", u.Query().Get("x"), "Unexpected query parameter x") + s.Equal("2", u.Query().Get("y"), "Unexpected query parameter y") + }) + }) } // CurrentAuthorizationHeader behavior is now implicit via executeRequest using Manager.BearerToken -func TestExecuteRequest_SetsAuthAndCallsServer(t *testing.T) { +func (s *KialiSuite) TestExecuteRequest() { // setup test server to assert path and auth header var seenAuth string var seenPath string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.MockServer.Config().BearerToken = "token-xyz" + s.MockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenAuth = r.Header.Get("Authorization") seenPath = r.URL.String() _, _ = w.Write([]byte("ok")) })) - defer srv.Close() - - cfg := config.Default() - cfg.SetToolsetConfig("kiali", &Config{Url: srv.URL}) - m := NewManager(cfg) - m.BearerToken = "token-xyz" - k := m.GetKiali() - out, err := k.executeRequest(context.Background(), "/api/ping?q=1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if out != "ok" { - t.Fatalf("unexpected body: %s", out) - } - if seenAuth != "Bearer token-xyz" { - t.Fatalf("expected auth header to be set, got '%s'", seenAuth) - } - if seenPath != "/api/ping?q=1" { - t.Fatalf("unexpected path: %s", seenPath) - } + + s.Config = test.Must(config.ReadToml([]byte(fmt.Sprintf(` + [toolset_configs.kiali] + url = "%s" + `, s.MockServer.Config().Host)))) + k := NewKiali(s.Config, s.MockServer.Config()) + + out, err := k.executeRequest(s.T().Context(), "/api/ping?q=1") + s.Require().NoError(err, "Expected no error executing request") + s.Run("auth header set", func() { + s.Equal("Bearer token-xyz", seenAuth, "Unexpected Authorization header") + }) + s.Run("path is correct", func() { + s.Equal("/api/ping?q=1", seenPath, "Unexpected path") + }) + s.Run("response body is correct", func() { + s.Equal("ok", out, "Unexpected response body") + }) +} + +func TestKiali(t *testing.T) { + suite.Run(t, new(KialiSuite)) } diff --git a/pkg/kiali/manager.go b/pkg/kiali/manager.go deleted file mode 100644 index 276f2221..00000000 --- a/pkg/kiali/manager.go +++ /dev/null @@ -1,32 +0,0 @@ -package kiali - -import ( - "context" - - "github.com/containers/kubernetes-mcp-server/pkg/config" -) - -type Manager struct { - BearerToken string - KialiURL string - KialiInsecure bool -} - -func NewManager(config *config.StaticConfig) *Manager { - m := &Manager{ - BearerToken: "", - KialiURL: "", - KialiInsecure: false, - } - if cfg, ok := config.GetToolsetConfig("kiali"); ok { - if kc, ok := cfg.(*Config); ok && kc != nil { - m.KialiURL = kc.Url - m.KialiInsecure = kc.Insecure - } - } - return m -} - -func (m *Manager) Derived(_ context.Context) (*Kiali, error) { - return &Kiali{manager: m}, nil -} diff --git a/pkg/kiali/manager_test.go b/pkg/kiali/manager_test.go deleted file mode 100644 index d8aa275a..00000000 --- a/pkg/kiali/manager_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package kiali - -import ( - "context" - "testing" - - "github.com/containers/kubernetes-mcp-server/pkg/config" -) - -func TestNewManagerUsesConfigFields(t *testing.T) { - cfg := config.Default() - cfg.SetToolsetConfig("kiali", &Config{Url: "https://kiali.example", Insecure: true}) - m := NewManager(cfg) - if m == nil { - t.Fatalf("expected manager, got nil") - } - if m.KialiURL != "https://kiali.example" { - t.Fatalf("expected KialiURL %s, got %s", "https://kiali.example", m.KialiURL) - } - if m.KialiInsecure != true { - t.Fatalf("expected KialiInsecure %v, got %v", true, m.KialiInsecure) - } -} - -func TestDerivedWithoutAuthorizationReturnsOriginalManager(t *testing.T) { - cfg := config.Default() - cfg.SetToolsetConfig("kiali", &Config{Url: "https://kiali.example"}) - m := NewManager(cfg) - k, err := m.Derived(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if k == nil || k.manager != m { - t.Fatalf("expected derived Kiali to keep original manager") - } -} - -func TestDerivedPreservesURLAndToken(t *testing.T) { - cfg := config.Default() - cfg.SetToolsetConfig("kiali", &Config{Url: "https://kiali.example", Insecure: true}) - m := NewManager(cfg) - m.BearerToken = "token-abc" - k, err := m.Derived(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if k == nil || k.manager == nil { - t.Fatalf("expected derived Kiali with manager") - } - if k.manager.BearerToken != "token-abc" { - t.Fatalf("expected bearer token 'token-abc', got '%s'", k.manager.BearerToken) - } - if k.manager.KialiURL != m.KialiURL || k.manager.KialiInsecure != m.KialiInsecure { - t.Fatalf("expected Kiali URL/insecure preserved") - } -} diff --git a/pkg/kiali/mesh_test.go b/pkg/kiali/mesh_test.go index 8af6f11b..a729015d 100644 --- a/pkg/kiali/mesh_test.go +++ b/pkg/kiali/mesh_test.go @@ -1,43 +1,40 @@ package kiali import ( - "context" + "fmt" "net/http" - "net/http/httptest" "net/url" - "testing" + "github.com/containers/kubernetes-mcp-server/internal/test" "github.com/containers/kubernetes-mcp-server/pkg/config" ) -func TestMeshStatus_CallsGraphWithExpectedQuery(t *testing.T) { +func (s *KialiSuite) TestMeshStatus() { var capturedURL *url.URL - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.MockServer.Config().BearerToken = "token-xyz" + s.MockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u := *r.URL capturedURL = &u _, _ = w.Write([]byte("graph")) })) - defer srv.Close() - cfg := config.Default() - cfg.SetToolsetConfig("kiali", &Config{Url: srv.URL}) - m := NewManager(cfg) - m.BearerToken = "tkn" - k := m.GetKiali() - out, err := k.MeshStatus(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if out != "graph" { - t.Fatalf("unexpected response: %s", out) - } - if capturedURL == nil { - t.Fatalf("expected request to be captured") - } - if capturedURL.Path != "/api/mesh/graph" { - t.Fatalf("unexpected path: %s", capturedURL.Path) - } - if capturedURL.Query().Get("includeGateways") != "false" || capturedURL.Query().Get("includeWaypoints") != "false" { - t.Fatalf("unexpected query: %s", capturedURL.RawQuery) - } + s.Config = test.Must(config.ReadToml([]byte(fmt.Sprintf(` + [toolset_configs.kiali] + url = "%s" + `, s.MockServer.Config().Host)))) + k := NewKiali(s.Config, s.MockServer.Config()) + + out, err := k.MeshStatus(s.T().Context()) + s.Require().NoError(err, "Expected no error executing request") + s.Run("response body is correct", func() { + s.Equal("graph", out, "Unexpected response body") + }) + s.Run("path is correct", func() { + s.Equal("/api/mesh/graph", capturedURL.Path, "Unexpected path") + }) + s.Run("query parameters are correct", func() { + s.Equal("false", capturedURL.Query().Get("includeGateways"), "Unexpected includeGateways query parameter") + s.Equal("false", capturedURL.Query().Get("includeWaypoints"), "Unexpected includeWaypoints query parameter") + }) + } diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 3bb0f821..1a81ce1f 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -292,20 +292,20 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } - /* If Kiali tools are enabled, validate Kiali toolset configuration */ - if slices.Contains(m.StaticConfig.Toolsets, "kiali") { - cfg, ok := m.StaticConfig.GetToolsetConfig("kiali") - if !ok { - return fmt.Errorf("kiali-url is required when kiali tools are enabled") - } - if err := cfg.Validate(); err != nil { - // Normalize error message for missing URL to match expected UX/tests - if strings.Contains(err.Error(), "kiali-url is required") { - return fmt.Errorf("kiali-url is required when kiali tools are enabled") - } - return fmt.Errorf("invalid kiali configuration: %w", err) - } - } + /* If Kiali tools are enabled, validate Kiali toolset configuration */ + if slices.Contains(m.StaticConfig.Toolsets, "kiali") { + cfg, ok := m.StaticConfig.GetToolsetConfig("kiali") + if !ok { + return fmt.Errorf("kiali-url is required when kiali tools are enabled") + } + if err := cfg.Validate(); err != nil { + // Normalize error message for missing URL to match expected UX/tests + if strings.Contains(err.Error(), "kiali-url is required") { + return fmt.Errorf("kiali-url is required when kiali tools are enabled") + } + return fmt.Errorf("invalid kiali configuration: %w", err) + } + } return nil } diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index d5da8385..7de8d6ff 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -40,14 +40,7 @@ func (k *Kubernetes) NewHelm() *helm.Helm { } // NewKiali returns a Kiali client initialized with the same StaticConfig and bearer token -// as the underlying Kubernetes manager. The token is taken from the manager rest.Config. +// as the underlying derived Kubernetes manager. func (k *Kubernetes) NewKiali() *kiali.Kiali { - if k == nil || k.manager == nil || k.manager.staticConfig == nil { - return nil - } - km := kiali.NewManager(k.manager.staticConfig) - if k.manager.cfg != nil { - km.BearerToken = k.manager.cfg.BearerToken - } - return km.GetKiali() + return kiali.NewKiali(k.manager.staticConfig, k.manager.cfg) } diff --git a/pkg/mcp/m3labs.go b/pkg/mcp/m3labs.go index db3b7752..ade0f56b 100644 --- a/pkg/mcp/m3labs.go +++ b/pkg/mcp/m3labs.go @@ -45,6 +45,7 @@ func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.S if err != nil { return nil, err } + result, err := tool.Handler(api.ToolHandlerParams{ Context: ctx, Kubernetes: k, From 1e03b7508c1a8aeef7830402d3c0cc2dc8f384e0 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Mon, 10 Nov 2025 16:26:44 +0100 Subject: [PATCH 08/11] Remove kiali flags Signed-off-by: Alberto Gutierrez --- pkg/kiali/config.go | 4 +- pkg/kubernetes-mcp-server/cmd/root.go | 31 +-------------- pkg/kubernetes-mcp-server/cmd/root_test.go | 45 +--------------------- 3 files changed, 5 insertions(+), 75 deletions(-) diff --git a/pkg/kiali/config.go b/pkg/kiali/config.go index 92bb6614..3c5f8ed7 100644 --- a/pkg/kiali/config.go +++ b/pkg/kiali/config.go @@ -22,10 +22,10 @@ func (c *Config) Validate() error { return errors.New("kiali config is nil") } if c.Url == "" { - return errors.New("kiali-url is required") + return errors.New("url is required") } if u, err := url.Parse(c.Url); err != nil || u.Scheme == "" || u.Host == "" { - return errors.New("kiali-url must be a valid URL") + return errors.New("url must be a valid URL") } return nil } diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 1a81ce1f..39e78f32 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -10,7 +10,6 @@ import ( "net/http" "net/url" "os" - "slices" "strconv" "strings" @@ -25,7 +24,6 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/config" internalhttp "github.com/containers/kubernetes-mcp-server/pkg/http" - "github.com/containers/kubernetes-mcp-server/pkg/kiali" "github.com/containers/kubernetes-mcp-server/pkg/mcp" "github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/containers/kubernetes-mcp-server/pkg/toolsets" @@ -75,15 +73,8 @@ const ( flagServerUrl = "server-url" flagCertificateAuthority = "certificate-authority" flagDisableMultiCluster = "disable-multi-cluster" - flagKialiUrl = "kiali-url" - flagKialiInsecure = "kiali-insecure" ) -type KialiOptions struct { - Url string - Insecure bool -} - type MCPServerOptions struct { Version bool LogLevel int @@ -103,7 +94,6 @@ type MCPServerOptions struct { CertificateAuthority string ServerURL string DisableMultiCluster bool - KialiOptions KialiOptions ConfigPath string StaticConfig *config.StaticConfig @@ -167,8 +157,6 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.CertificateAuthority, flagCertificateAuthority, o.CertificateAuthority, "Certificate authority path to verify certificates. Optional. Only valid if require-oauth is enabled.") _ = cmd.Flags().MarkHidden(flagCertificateAuthority) cmd.Flags().BoolVar(&o.DisableMultiCluster, flagDisableMultiCluster, o.DisableMultiCluster, "Disable multi cluster tools. Optional. If true, all tools will be run against the default cluster/context.") - cmd.Flags().StringVar(&o.KialiOptions.Url, flagKialiUrl, o.KialiOptions.Url, "Kiali endpoint to use for kiali tools. Optional. If not set, the kiali tools will not be available.") - cmd.Flags().BoolVar(&o.KialiOptions.Insecure, flagKialiInsecure, o.KialiOptions.Insecure, "If true, allows insecure TLS connections to Kiali. Optional. If true, the kiali tools will not be available.") return cmd } @@ -244,9 +232,6 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { if cmd.Flag(flagDisableMultiCluster).Changed && m.DisableMultiCluster { m.StaticConfig.ClusterProviderStrategy = config.ClusterProviderDisabled } - if cmd.Flag(flagKialiUrl).Changed || cmd.Flag(flagKialiInsecure).Changed { - m.StaticConfig.SetToolsetConfig("kiali", &kiali.Config{Url: m.KialiOptions.Url, Insecure: m.KialiOptions.Insecure}) - } } func (m *MCPServerOptions) initializeLogging() { @@ -292,20 +277,6 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } - /* If Kiali tools are enabled, validate Kiali toolset configuration */ - if slices.Contains(m.StaticConfig.Toolsets, "kiali") { - cfg, ok := m.StaticConfig.GetToolsetConfig("kiali") - if !ok { - return fmt.Errorf("kiali-url is required when kiali tools are enabled") - } - if err := cfg.Validate(); err != nil { - // Normalize error message for missing URL to match expected UX/tests - if strings.Contains(err.Error(), "kiali-url is required") { - return fmt.Errorf("kiali-url is required when kiali tools are enabled") - } - return fmt.Errorf("invalid kiali configuration: %w", err) - } - } return nil } @@ -379,4 +350,4 @@ func (m *MCPServerOptions) Run() error { } return nil -} +} \ No newline at end of file diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index f5fb0803..20929b0c 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -137,7 +137,7 @@ func TestToolsets(t *testing.T) { rootCmd := NewMCPServer(ioStreams) rootCmd.SetArgs([]string{"--help"}) o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout - if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kiali).") { + if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") { t.Fatalf("Expected all available toolsets, got %s %v", o, err) } }) @@ -161,47 +161,6 @@ func TestToolsets(t *testing.T) { }) } -func TestKialiURLRequired(t *testing.T) { - t.Run("flag toolsets includes kiali and missing kiali-url returns error", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali"}) - err := rootCmd.Execute() - if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { - t.Fatalf("expected error about missing kiali-url, got %v", err) - } - }) - t.Run("flag toolsets includes kiali and kiali-url provided passes", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali", "--kiali-url", "http://kiali"}) - if err := rootCmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - t.Run("config toolsets includes kiali and missing kiali_url returns error", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - _, file, _, _ := runtime.Caller(0) - cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-missing-url.toml") - rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) - err := rootCmd.Execute() - if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { - t.Fatalf("expected error about missing kiali-url, got %v", err) - } - }) - t.Run("config toolsets includes kiali and kiali_url present passes", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - _, file, _, _ := runtime.Caller(0) - cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-with-url.toml") - rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) - if err := rootCmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) -} - func TestListOutput(t *testing.T) { t.Run("available", func(t *testing.T) { ioStreams, _ := testStream() @@ -337,4 +296,4 @@ func TestDisableMultiCluster(t *testing.T) { t.Fatalf("Expected ClusterProviderStrategy %s, got %s %v", expected, out.String(), err) } }) -} +} \ No newline at end of file From f03f1c8e47c9185483e47e04b8af823852a52f48 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Mon, 10 Nov 2025 18:00:38 +0100 Subject: [PATCH 09/11] Add kiali tools Signed-off-by: Alberto Gutierrez --- pkg/kiali/endpoints.go | 23 +- pkg/kiali/graph.go | 45 ++++ pkg/kiali/health.go | 40 +++ pkg/kiali/istio.go | 152 +++++++++++ pkg/kiali/kiali.go | 23 +- pkg/kiali/kiali_test.go | 6 +- pkg/kiali/logs.go | 190 ++++++++++++++ pkg/kiali/mesh.go | 5 +- pkg/kiali/namespaces.go | 12 + pkg/kiali/services.go | 64 +++++ pkg/kiali/traces.go | 107 ++++++++ pkg/kiali/validations.go | 34 +++ pkg/kiali/workloads.go | 64 +++++ pkg/kubernetes-mcp-server/cmd/root.go | 2 +- pkg/kubernetes-mcp-server/cmd/root_test.go | 4 +- pkg/toolsets/kiali/graph.go | 86 ++++++ pkg/toolsets/kiali/health.go | 80 ++++++ pkg/toolsets/kiali/istio_config.go | 288 +++++++++++++++++++++ pkg/toolsets/kiali/logs.go | 154 +++++++++++ pkg/toolsets/kiali/namespaces.go | 40 +++ pkg/toolsets/kiali/services.go | 209 +++++++++++++++ pkg/toolsets/kiali/toolset.go | 13 + pkg/toolsets/kiali/traces.go | 285 ++++++++++++++++++++ pkg/toolsets/kiali/validations.go | 86 ++++++ pkg/toolsets/kiali/workloads.go | 209 +++++++++++++++ 25 files changed, 2202 insertions(+), 19 deletions(-) create mode 100644 pkg/kiali/graph.go create mode 100644 pkg/kiali/health.go create mode 100644 pkg/kiali/istio.go create mode 100644 pkg/kiali/logs.go create mode 100644 pkg/kiali/namespaces.go create mode 100644 pkg/kiali/services.go create mode 100644 pkg/kiali/traces.go create mode 100644 pkg/kiali/validations.go create mode 100644 pkg/kiali/workloads.go create mode 100644 pkg/toolsets/kiali/graph.go create mode 100644 pkg/toolsets/kiali/health.go create mode 100644 pkg/toolsets/kiali/istio_config.go create mode 100644 pkg/toolsets/kiali/logs.go create mode 100644 pkg/toolsets/kiali/namespaces.go create mode 100644 pkg/toolsets/kiali/services.go create mode 100644 pkg/toolsets/kiali/traces.go create mode 100644 pkg/toolsets/kiali/validations.go create mode 100644 pkg/toolsets/kiali/workloads.go diff --git a/pkg/kiali/endpoints.go b/pkg/kiali/endpoints.go index bf4d3407..1c4c3938 100644 --- a/pkg/kiali/endpoints.go +++ b/pkg/kiali/endpoints.go @@ -3,6 +3,25 @@ package kiali // Kiali API endpoint paths shared across this package. const ( // MeshGraph is the Kiali API path that returns the mesh graph/status. - MeshGraph = "/api/mesh/graph" - AuthInfo = "/api/auth/info" + AuthInfoEndpoint = "/api/auth/info" + MeshGraphEndpoint = "/api/mesh/graph" + GraphEndpoint = "/api/namespaces/graph" + HealthEndpoint = "/api/clusters/health" + IstioConfigEndpoint = "/api/istio/config" + IstioObjectEndpoint = "/api/namespaces/%s/istio/%s/%s/%s/%s" + IstioObjectCreateEndpoint = "/api/namespaces/%s/istio/%s/%s/%s" + NamespacesEndpoint = "/api/namespaces" + PodDetailsEndpoint = "/api/namespaces/%s/pods/%s" + PodsLogsEndpoint = "/api/namespaces/%s/pods/%s/logs" + ServicesEndpoint = "/api/clusters/services" + ServiceDetailsEndpoint = "/api/namespaces/%s/services/%s" + ServiceMetricsEndpoint = "/api/namespaces/%s/services/%s/metrics" + AppTracesEndpoint = "/api/namespaces/%s/apps/%s/traces" + ServiceTracesEndpoint = "/api/namespaces/%s/services/%s/traces" + WorkloadTracesEndpoint = "/api/namespaces/%s/workloads/%s/traces" + WorkloadsEndpoint = "/api/clusters/workloads" + WorkloadDetailsEndpoint = "/api/namespaces/%s/workloads/%s" + WorkloadMetricsEndpoint = "/api/namespaces/%s/workloads/%s/metrics" + ValidationsEndpoint = "/api/istio/validations" + ValidationsListEndpoint = "/api/istio/validations" ) diff --git a/pkg/kiali/graph.go b/pkg/kiali/graph.go new file mode 100644 index 00000000..be3ac3c4 --- /dev/null +++ b/pkg/kiali/graph.go @@ -0,0 +1,45 @@ +package kiali + +import ( + "context" + "net/http" + "net/url" + "strings" +) + +// Graph calls the Kiali graph API using the provided Authorization header value. +// `namespaces` may contain zero, one or many namespaces. If empty, the API may return an empty graph +// or the server default, depending on Kiali configuration. +func (k *Kiali) Graph(ctx context.Context, namespaces []string) (string, error) { + u, err := url.Parse(GraphEndpoint) + if err != nil { + return "", err + } + q := u.Query() + // Static graph parameters per requirements + q.Set("duration", "60s") + q.Set("graphType", "versionedApp") + q.Set("includeIdleEdges", "false") + q.Set("injectServiceNodes", "true") + q.Set("boxBy", "cluster,namespace,app") + q.Set("ambientTraffic", "none") + q.Set("appenders", "deadNode,istio,serviceEntry,meshCheck,workloadEntry,health") + q.Set("rateGrpc", "requests") + q.Set("rateHttp", "requests") + q.Set("rateTcp", "sent") + // Optional namespaces param + cleaned := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + ns = strings.TrimSpace(ns) + if ns != "" { + cleaned = append(cleaned, ns) + } + } + if len(cleaned) > 0 { + q.Set("namespaces", strings.Join(cleaned, ",")) + } + u.RawQuery = q.Encode() + endpoint := u.String() + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/health.go b/pkg/kiali/health.go new file mode 100644 index 00000000..ff9d6226 --- /dev/null +++ b/pkg/kiali/health.go @@ -0,0 +1,40 @@ +package kiali + +import ( + "context" + "net/http" + "net/url" +) + +// Health returns health status for apps, workloads, and services across namespaces. +// Parameters: +// - namespaces: comma-separated list of namespaces (optional, if empty returns health for all accessible namespaces) +// - queryParams: optional query parameters map for filtering health data (e.g., "type", "rateInterval", "queryTime") +// - type: health type - "app", "service", or "workload" (default: "app") +// - rateInterval: rate interval for fetching error rate (default: "10m") +// - queryTime: Unix timestamp for the prometheus query (optional) +func (k *Kiali) Health(ctx context.Context, namespaces string, queryParams map[string]string) (string, error) { + // Build query parameters + u, err := url.Parse(HealthEndpoint) + if err != nil { + return "", err + } + q := u.Query() + + // Add namespaces if provided + if namespaces != "" { + q.Set("namespaces", namespaces) + } + + // Add optional query parameters + if len(queryParams) > 0 { + for key, value := range queryParams { + q.Set(key, value) + } + } + + u.RawQuery = q.Encode() + endpoint := u.String() + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/istio.go b/pkg/kiali/istio.go new file mode 100644 index 00000000..bd831d6c --- /dev/null +++ b/pkg/kiali/istio.go @@ -0,0 +1,152 @@ +package kiali + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" +) + +// IstioConfig calls the Kiali Istio config API to get all Istio objects in the mesh. +// Returns the full YAML resources and additional details about each object. +func (k *Kiali) IstioConfig(ctx context.Context) (string, error) { + endpoint := IstioConfigEndpoint + "?validate=true" + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// IstioObjectDetails returns detailed information about a specific Istio object. +// Parameters: +// - namespace: the namespace containing the Istio object +// - group: the API group (e.g., "networking.istio.io", "gateway.networking.k8s.io") +// - version: the API version (e.g., "v1", "v1beta1") +// - kind: the resource kind (e.g., "DestinationRule", "VirtualService", "HTTPRoute") +// - name: the name of the resource +func (k *Kiali) IstioObjectDetails(ctx context.Context, namespace, group, version, kind, name string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if group == "" { + return "", fmt.Errorf("group is required") + } + if version == "" { + return "", fmt.Errorf("version is required") + } + if kind == "" { + return "", fmt.Errorf("kind is required") + } + if name == "" { + return "", fmt.Errorf("name is required") + } + endpoint := fmt.Sprintf(IstioObjectEndpoint+"?validate=true&help=true", + url.PathEscape(namespace), + url.PathEscape(group), + url.PathEscape(version), + url.PathEscape(kind), + url.PathEscape(name)) + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// IstioObjectPatch patches an existing Istio object using PATCH method. +// Parameters: +// - namespace: the namespace containing the Istio object +// - group: the API group (e.g., "networking.istio.io", "gateway.networking.k8s.io") +// - version: the API version (e.g., "v1", "v1beta1") +// - kind: the resource kind (e.g., "DestinationRule", "VirtualService", "HTTPRoute") +// - name: the name of the resource +// - jsonPatch: the JSON patch data to apply +func (k *Kiali) IstioObjectPatch(ctx context.Context, namespace, group, version, kind, name, jsonPatch string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if group == "" { + return "", fmt.Errorf("group is required") + } + if version == "" { + return "", fmt.Errorf("version is required") + } + if kind == "" { + return "", fmt.Errorf("kind is required") + } + if name == "" { + return "", fmt.Errorf("name is required") + } + if jsonPatch == "" { + return "", fmt.Errorf("json patch data is required") + } + endpoint := fmt.Sprintf(IstioObjectEndpoint, + url.PathEscape(namespace), + url.PathEscape(group), + url.PathEscape(version), + url.PathEscape(kind), + url.PathEscape(name)) + + return k.executeRequest(ctx, http.MethodPatch, endpoint, "application/json", strings.NewReader(jsonPatch)) +} + +// IstioObjectCreate creates a new Istio object using POST method. +// Parameters: +// - namespace: the namespace where the Istio object will be created +// - group: the API group (e.g., "networking.istio.io", "gateway.networking.k8s.io") +// - version: the API version (e.g., "v1", "v1beta1") +// - kind: the resource kind (e.g., "DestinationRule", "VirtualService", "HTTPRoute") +// - jsonData: the JSON data for the new object +func (k *Kiali) IstioObjectCreate(ctx context.Context, namespace, group, version, kind, jsonData string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if group == "" { + return "", fmt.Errorf("group is required") + } + if version == "" { + return "", fmt.Errorf("version is required") + } + if kind == "" { + return "", fmt.Errorf("kind is required") + } + if jsonData == "" { + return "", fmt.Errorf("json data is required") + } + endpoint := fmt.Sprintf(IstioObjectCreateEndpoint, + url.PathEscape(namespace), + url.PathEscape(group), + url.PathEscape(version), + url.PathEscape(kind)) + + return k.executeRequest(ctx, http.MethodPost, endpoint, "application/json", strings.NewReader(jsonData)) +} + +// IstioObjectDelete deletes an existing Istio object using DELETE method. +// Parameters: +// - namespace: the namespace containing the Istio object +// - group: the API group (e.g., "networking.istio.io", "gateway.networking.k8s.io") +// - version: the API version (e.g., "v1", "v1beta1") +// - kind: the resource kind (e.g., "DestinationRule", "VirtualService", "HTTPRoute", "Gateway") +// - name: the name of the resource +func (k *Kiali) IstioObjectDelete(ctx context.Context, namespace, group, version, kind, name string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if group == "" { + return "", fmt.Errorf("group is required") + } + if version == "" { + return "", fmt.Errorf("version is required") + } + if kind == "" { + return "", fmt.Errorf("kind is required") + } + if name == "" { + return "", fmt.Errorf("name is required") + } + endpoint := fmt.Sprintf(IstioObjectEndpoint, + url.PathEscape(namespace), + url.PathEscape(group), + url.PathEscape(version), + url.PathEscape(kind), + url.PathEscape(name)) + + return k.executeRequest(ctx, http.MethodDelete, endpoint, "", nil) +} diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go index 9c82030c..c384fe97 100644 --- a/pkg/kiali/kiali.go +++ b/pkg/kiali/kiali.go @@ -84,15 +84,17 @@ func (k *Kiali) authorizationHeader() string { return "Bearer " + token } -// executeRequest executes an HTTP request and handles common error scenarios. -func (k *Kiali) executeRequest(ctx context.Context, endpoint string) (string, error) { +// executeRequest executes an HTTP request (optionally with a body) and handles common error scenarios. +func (k *Kiali) executeRequest(ctx context.Context, method, endpoint, contentType string, body io.Reader) (string, error) { + if method == "" { + method = http.MethodGet + } ApiCallURL, err := k.validateAndGetURL(endpoint) if err != nil { return "", err } - - klog.V(0).Infof("Kiali Call URL: %s", ApiCallURL) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ApiCallURL, nil) + klog.V(0).Infof("kiali API call: %s %s", method, ApiCallURL) + req, err := http.NewRequestWithContext(ctx, method, ApiCallURL, body) if err != nil { return "", err } @@ -100,18 +102,21 @@ func (k *Kiali) executeRequest(ctx context.Context, endpoint string) (string, er if authHeader != "" { req.Header.Set("Authorization", authHeader) } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } client := k.createHTTPClient() resp, err := client.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() - body, _ := io.ReadAll(resp.Body) + respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - if len(body) > 0 { - return "", fmt.Errorf("kiali API error: %s", strings.TrimSpace(string(body))) + if len(respBody) > 0 { + return "", fmt.Errorf("kiali API error: %s", strings.TrimSpace(string(respBody))) } return "", fmt.Errorf("kiali API error: status %d", resp.StatusCode) } - return string(body), nil + return string(respBody), nil } diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go index 2db55aa0..a997c14b 100644 --- a/pkg/kiali/kiali_test.go +++ b/pkg/kiali/kiali_test.go @@ -53,7 +53,7 @@ func (s *KialiSuite) TestNewKiali_InvalidConfig() { url = "://invalid-url" `)) s.Error(err, "Expected error reading invalid config") - s.ErrorContains(err, "kiali-url must be a valid URL", "Unexpected error message") + s.ErrorContains(err, "url must be a valid URL", "Unexpected error message") s.Nil(cfg, "Unexpected Kiali config") } @@ -108,7 +108,7 @@ func (s *KialiSuite) TestExecuteRequest() { `, s.MockServer.Config().Host)))) k := NewKiali(s.Config, s.MockServer.Config()) - out, err := k.executeRequest(s.T().Context(), "/api/ping?q=1") + out, err := k.executeRequest(s.T().Context(), http.MethodGet, "/api/ping?q=1", "", nil) s.Require().NoError(err, "Expected no error executing request") s.Run("auth header set", func() { s.Equal("Bearer token-xyz", seenAuth, "Unexpected Authorization header") @@ -123,4 +123,4 @@ func (s *KialiSuite) TestExecuteRequest() { func TestKiali(t *testing.T) { suite.Run(t, new(KialiSuite)) -} +} \ No newline at end of file diff --git a/pkg/kiali/logs.go b/pkg/kiali/logs.go new file mode 100644 index 00000000..aae6a8eb --- /dev/null +++ b/pkg/kiali/logs.go @@ -0,0 +1,190 @@ +package kiali + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// WorkloadLogs returns logs for a specific workload's pods in a namespace. +// This method first gets workload details to find associated pods, then retrieves logs for each pod. +// Parameters: +// - namespace: the namespace containing the workload +// - workload: the name of the workload +// - container: container name (optional, will be auto-detected if not provided) +// - service: service name (optional) +// - duration: time duration (e.g., "5m", "1h") - optional +// - logType: type of logs (app, proxy, ztunnel, waypoint) - optional +// - sinceTime: Unix timestamp for start time - optional +// - maxLines: maximum number of lines to return - optional +func (k *Kiali) WorkloadLogs(ctx context.Context, namespace string, workload string, container string, service string, duration string, logType string, sinceTime string, maxLines string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if workload == "" { + return "", fmt.Errorf("workload name is required") + } + // Container is optional - will be auto-detected if not provided + + // First, get workload details to find associated pods + workloadDetails, err := k.WorkloadDetails(ctx, namespace, workload) + if err != nil { + return "", fmt.Errorf("failed to get workload details: %v", err) + } + + // Parse the workload details JSON to extract pod names and containers + var workloadData struct { + Pods []struct { + Name string `json:"name"` + Containers []struct { + Name string `json:"name"` + } `json:"containers"` + } `json:"pods"` + } + + if err := json.Unmarshal([]byte(workloadDetails), &workloadData); err != nil { + return "", fmt.Errorf("failed to parse workload details: %v", err) + } + + if len(workloadData.Pods) == 0 { + return "", fmt.Errorf("no pods found for workload %s in namespace %s", workload, namespace) + } + + // Collect logs from all pods + var allLogs []string + for _, pod := range workloadData.Pods { + // Auto-detect container if not provided + podContainer := container + if podContainer == "" { + // Find the main application container (not istio-proxy or istio-init) + for _, c := range pod.Containers { + if c.Name != "istio-proxy" && c.Name != "istio-init" { + podContainer = c.Name + break + } + } + // If no app container found, use the first container + if podContainer == "" && len(pod.Containers) > 0 { + podContainer = pod.Containers[0].Name + } + } + + if podContainer == "" { + allLogs = append(allLogs, fmt.Sprintf("Error: No container found for pod %s", pod.Name)) + continue + } + + podLogs, err := k.PodLogs(ctx, namespace, pod.Name, podContainer, workload, service, duration, logType, sinceTime, maxLines) + if err != nil { + // Log the error but continue with other pods + allLogs = append(allLogs, fmt.Sprintf("Error getting logs for pod %s: %v", pod.Name, err)) + continue + } + if podLogs != "" { + allLogs = append(allLogs, fmt.Sprintf("=== Pod: %s (Container: %s) ===\n%s", pod.Name, podContainer, podLogs)) + } + } + + if len(allLogs) == 0 { + return "", fmt.Errorf("no logs found for workload %s in namespace %s", workload, namespace) + } + + return strings.Join(allLogs, "\n\n"), nil +} + +// PodLogs returns logs for a specific pod using the Kiali API endpoint. +// Parameters: +// - namespace: the namespace containing the pod +// - podName: the name of the pod +// - container: container name (optional, will be auto-detected if not provided) +// - workload: workload name (optional) +// - service: service name (optional) +// - duration: time duration (e.g., "5m", "1h") - optional +// - logType: type of logs (app, proxy, ztunnel, waypoint) - optional +// - sinceTime: Unix timestamp for start time - optional +// - maxLines: maximum number of lines to return - optional +func (k *Kiali) PodLogs(ctx context.Context, namespace string, podName string, container string, workload string, service string, duration string, logType string, sinceTime string, maxLines string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if podName == "" { + return "", fmt.Errorf("pod name is required") + } + // Container is optional - will be auto-detected if not provided + podContainer := container + if podContainer == "" { + // Get pod details to find containers + podDetails, err := k.executeRequest(ctx, http.MethodGet, fmt.Sprintf(PodDetailsEndpoint, url.PathEscape(namespace), url.PathEscape(podName)), "", nil) + if err != nil { + return "", fmt.Errorf("failed to get pod details: %v", err) + } + + // Parse pod details to extract container names + var podData struct { + Containers []struct { + Name string `json:"name"` + } `json:"containers"` + } + + if err := json.Unmarshal([]byte(podDetails), &podData); err != nil { + return "", fmt.Errorf("failed to parse pod details: %v", err) + } + + // Find the main application container (not istio-proxy or istio-init) + for _, c := range podData.Containers { + if c.Name != "istio-proxy" && c.Name != "istio-init" { + podContainer = c.Name + break + } + } + // If no app container found, use the first container + if podContainer == "" && len(podData.Containers) > 0 { + podContainer = podData.Containers[0].Name + } + + if podContainer == "" { + return "", fmt.Errorf("no container found for pod %s in namespace %s", podName, namespace) + } + } + + endpoint := fmt.Sprintf(PodsLogsEndpoint, + url.PathEscape(namespace), url.PathEscape(podName)) + + // Add query parameters + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + + // Required parameters + q.Set("container", podContainer) + + // Optional parameters + if workload != "" { + q.Set("workload", workload) + } + if service != "" { + q.Set("service", service) + } + if duration != "" { + q.Set("duration", duration) + } + if logType != "" { + q.Set("logType", logType) + } + if sinceTime != "" { + q.Set("sinceTime", sinceTime) + } + if maxLines != "" { + q.Set("maxLines", maxLines) + } + + u.RawQuery = q.Encode() + endpoint = u.String() + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/mesh.go b/pkg/kiali/mesh.go index b443dcb9..c9043791 100644 --- a/pkg/kiali/mesh.go +++ b/pkg/kiali/mesh.go @@ -2,6 +2,7 @@ package kiali import ( "context" + "net/http" "net/url" ) @@ -9,7 +10,7 @@ import ( // This returns information about mesh components like Istio, Kiali, Grafana, Prometheus // and their interactions, versions, and health status. func (k *Kiali) MeshStatus(ctx context.Context) (string, error) { - u, err := url.Parse(MeshGraph) + u, err := url.Parse(MeshGraphEndpoint) if err != nil { return "", err } @@ -17,5 +18,5 @@ func (k *Kiali) MeshStatus(ctx context.Context) (string, error) { q.Set("includeGateways", "false") q.Set("includeWaypoints", "false") u.RawQuery = q.Encode() - return k.executeRequest(ctx, u.String()) + return k.executeRequest(ctx, http.MethodGet, u.String(), "", nil) } diff --git a/pkg/kiali/namespaces.go b/pkg/kiali/namespaces.go new file mode 100644 index 00000000..00217ebb --- /dev/null +++ b/pkg/kiali/namespaces.go @@ -0,0 +1,12 @@ +package kiali + +import ( + "context" + "net/http" +) + +// ListNamespaces calls the Kiali namespaces API using the provided Authorization header value. +// Returns all namespaces in the mesh that the user has access to. +func (k *Kiali) ListNamespaces(ctx context.Context) (string, error) { + return k.executeRequest(ctx, http.MethodGet, NamespacesEndpoint, "", nil) +} diff --git a/pkg/kiali/services.go b/pkg/kiali/services.go new file mode 100644 index 00000000..1b8dc9be --- /dev/null +++ b/pkg/kiali/services.go @@ -0,0 +1,64 @@ +package kiali + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// ServicesList returns the list of services across specified namespaces. +func (k *Kiali) ServicesList(ctx context.Context, namespaces string) (string, error) { + endpoint := ServicesEndpoint + "?health=true&istioResources=true&rateInterval=60s&onlyDefinitions=false" + if namespaces != "" { + endpoint += "&namespaces=" + url.QueryEscape(namespaces) + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// ServiceDetails returns the details for a specific service in a namespace. +func (k *Kiali) ServiceDetails(ctx context.Context, namespace string, service string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if service == "" { + return "", fmt.Errorf("service name is required") + } + endpoint := fmt.Sprintf(ServiceDetailsEndpoint, url.PathEscape(namespace), url.PathEscape(service)) + "?validate=true&rateInterval=60s" + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// ServiceMetrics returns the metrics for a specific service in a namespace. +// Parameters: +// - namespace: the namespace containing the service +// - service: the name of the service +// - queryParams: optional query parameters map for filtering metrics (e.g., "duration", "step", "rateInterval", "direction", "reporter", "filters[]", "byLabels[]", etc.) +func (k *Kiali) ServiceMetrics(ctx context.Context, namespace string, service string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if service == "" { + return "", fmt.Errorf("service name is required") + } + + endpoint := fmt.Sprintf(ServiceMetricsEndpoint, + url.PathEscape(namespace), url.PathEscape(service)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/traces.go b/pkg/kiali/traces.go new file mode 100644 index 00000000..ba54b54a --- /dev/null +++ b/pkg/kiali/traces.go @@ -0,0 +1,107 @@ +package kiali + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// AppTraces returns distributed tracing data for a specific app in a namespace. +// Parameters: +// - namespace: the namespace containing the app +// - app: the name of the app +// - queryParams: optional query parameters map for filtering traces (e.g., "startMicros", "endMicros", "limit", "minDuration", "tags", "clusterName") +func (k *Kiali) AppTraces(ctx context.Context, namespace string, app string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if app == "" { + return "", fmt.Errorf("app name is required") + } + + endpoint := fmt.Sprintf(AppTracesEndpoint, + url.PathEscape(namespace), url.PathEscape(app)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// ServiceTraces returns distributed tracing data for a specific service in a namespace. +// Parameters: +// - namespace: the namespace containing the service +// - service: the name of the service +// - queryParams: optional query parameters map for filtering traces (e.g., "startMicros", "endMicros", "limit", "minDuration", "tags", "clusterName") +func (k *Kiali) ServiceTraces(ctx context.Context, namespace string, service string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if service == "" { + return "", fmt.Errorf("service name is required") + } + + endpoint := fmt.Sprintf(ServiceTracesEndpoint, + url.PathEscape(namespace), url.PathEscape(service)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// WorkloadTraces returns distributed tracing data for a specific workload in a namespace. +// Parameters: +// - namespace: the namespace containing the workload +// - workload: the name of the workload +// - queryParams: optional query parameters map for filtering traces (e.g., "startMicros", "endMicros", "limit", "minDuration", "tags", "clusterName") +func (k *Kiali) WorkloadTraces(ctx context.Context, namespace string, workload string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if workload == "" { + return "", fmt.Errorf("workload name is required") + } + + endpoint := fmt.Sprintf(WorkloadTracesEndpoint, + url.PathEscape(namespace), url.PathEscape(workload)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/validations.go b/pkg/kiali/validations.go new file mode 100644 index 00000000..400ed66f --- /dev/null +++ b/pkg/kiali/validations.go @@ -0,0 +1,34 @@ +package kiali + +import ( + "context" + "net/http" + "net/url" + "strings" +) + +// ValidationsList calls the Kiali validations API using the provided Authorization header value. +// `namespaces` may contain zero, one or many namespaces. If empty, returns validations from all namespaces. +func (k *Kiali) ValidationsList(ctx context.Context, namespaces []string) (string, error) { + // Add namespaces query parameter if any provided + cleaned := make([]string, 0, len(namespaces)) + endpoint := ValidationsEndpoint + for _, ns := range namespaces { + ns = strings.TrimSpace(ns) + if ns != "" { + cleaned = append(cleaned, ns) + } + } + if len(cleaned) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + q.Set("namespaces", strings.Join(cleaned, ",")) + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/workloads.go b/pkg/kiali/workloads.go new file mode 100644 index 00000000..ccd5538a --- /dev/null +++ b/pkg/kiali/workloads.go @@ -0,0 +1,64 @@ +package kiali + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// WorkloadsList returns the list of workloads across specified namespaces. +func (k *Kiali) WorkloadsList(ctx context.Context, namespaces string) (string, error) { + + endpoint := WorkloadsEndpoint + "?health=true&istioResources=true&rateInterval=60s" + if namespaces != "" { + endpoint += "&namespaces=" + url.QueryEscape(namespaces) + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// WorkloadDetails returns the details for a specific workload in a namespace. +func (k *Kiali) WorkloadDetails(ctx context.Context, namespace string, workload string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if workload == "" { + return "", fmt.Errorf("workload name is required") + } + endpoint := fmt.Sprintf(WorkloadDetailsEndpoint, url.PathEscape(namespace), url.PathEscape(workload)) + "?validate=true&rateInterval=60s&health=true" + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// WorkloadMetrics returns the metrics for a specific workload in a namespace. +// Parameters: +// - namespace: the namespace containing the workload +// - workload: the name of the workload +// - queryParams: optional query parameters map for filtering metrics (e.g., "duration", "step", "rateInterval", "direction", "reporter", "filters[]", "byLabels[]", etc.) +func (k *Kiali) WorkloadMetrics(ctx context.Context, namespace string, workload string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if workload == "" { + return "", fmt.Errorf("workload name is required") + } + + endpoint := fmt.Sprintf(WorkloadMetricsEndpoint, url.PathEscape(namespace), url.PathEscape(workload)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 39e78f32..db1782ab 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -350,4 +350,4 @@ func (m *MCPServerOptions) Run() error { } return nil -} \ No newline at end of file +} diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 20929b0c..a464daab 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -137,7 +137,7 @@ func TestToolsets(t *testing.T) { rootCmd := NewMCPServer(ioStreams) rootCmd.SetArgs([]string{"--help"}) o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout - if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") { + if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kiali).") { t.Fatalf("Expected all available toolsets, got %s %v", o, err) } }) @@ -296,4 +296,4 @@ func TestDisableMultiCluster(t *testing.T) { t.Fatalf("Expected ClusterProviderStrategy %s, got %s %v", expected, out.String(), err) } }) -} \ No newline at end of file +} diff --git a/pkg/toolsets/kiali/graph.go b/pkg/toolsets/kiali/graph.go new file mode 100644 index 00000000..6bf32d47 --- /dev/null +++ b/pkg/toolsets/kiali/graph.go @@ -0,0 +1,86 @@ +package kiali + +import ( + "fmt" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initGraph() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "graph", + Description: "Check the status of my mesh by querying Kiali graph", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional single namespace to include in the graph (alternative to namespaces)", + }, + "namespaces": { + Type: "string", + Description: "Optional comma-separated list of namespaces to include in the graph", + }, + }, + Required: []string{}, + }, + Annotations: api.ToolAnnotations{ + Title: "Graph: Mesh status", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: graphHandler, + }) + return ret +} + +func graphHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + + // Parse arguments: allow either `namespace` or `namespaces` (comma-separated string) + namespaces := make([]string, 0) + if v, ok := params.GetArguments()["namespace"].(string); ok { + v = strings.TrimSpace(v) + if v != "" { + namespaces = append(namespaces, v) + } + } + if v, ok := params.GetArguments()["namespaces"].(string); ok { + for _, ns := range strings.Split(v, ",") { + ns = strings.TrimSpace(ns) + if ns != "" { + namespaces = append(namespaces, ns) + } + } + } + // Deduplicate namespaces if both provided + if len(namespaces) > 1 { + seen := map[string]struct{}{} + unique := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + key := strings.TrimSpace(ns) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + unique = append(unique, key) + } + namespaces = unique + } + k := params.NewKiali() + content, err := k.Graph(params.Context, namespaces) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve mesh graph: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/health.go b/pkg/toolsets/kiali/health.go new file mode 100644 index 00000000..01b86e1e --- /dev/null +++ b/pkg/toolsets/kiali/health.go @@ -0,0 +1,80 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initHealth() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // Cluster health tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "health", + Description: "Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespaces": { + Type: "string", + Description: "Comma-separated list of namespaces to get health from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, returns health for all accessible namespaces", + }, + "type": { + Type: "string", + Description: "Type of health to retrieve: 'app', 'service', or 'workload'. Default: 'app'", + }, + "rateInterval": { + Type: "string", + Description: "Rate interval for fetching error rate (e.g., '10m', '5m', '1h'). Default: '10m'", + }, + "queryTime": { + Type: "string", + Description: "Unix timestamp (in seconds) for the prometheus query. If not provided, uses current time. Optional", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Health", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: clusterHealthHandler, + }) + + return ret +} + +func clusterHealthHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespaces, _ := params.GetArguments()["namespaces"].(string) + + // Extract optional query parameters + queryParams := make(map[string]string) + if healthType, ok := params.GetArguments()["type"].(string); ok && healthType != "" { + // Validate type parameter + if healthType != "app" && healthType != "service" && healthType != "workload" { + return api.NewToolCallResult("", fmt.Errorf("invalid type parameter: must be one of 'app', 'service', or 'workload'")), nil + } + queryParams["type"] = healthType + } + if rateInterval, ok := params.GetArguments()["rateInterval"].(string); ok && rateInterval != "" { + queryParams["rateInterval"] = rateInterval + } + if queryTime, ok := params.GetArguments()["queryTime"].(string); ok && queryTime != "" { + queryParams["queryTime"] = queryTime + } + + k := params.NewKiali() + content, err := k.Health(params.Context, namespaces, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get health: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/istio_config.go b/pkg/toolsets/kiali/istio_config.go new file mode 100644 index 00000000..df8a97c6 --- /dev/null +++ b/pkg/toolsets/kiali/istio_config.go @@ -0,0 +1,288 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initIstioConfig() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_config", + Description: "Get all Istio configuration objects in the mesh including their full YAML resources and details", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + Required: []string{}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Config: List All", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: istioConfigHandler, + }) + return ret +} + +func istioConfigHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + k := params.NewKiali() + content, err := k.IstioConfig(params.Context) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve Istio configuration: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func initIstioObjectDetails() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_object_details", + Description: "Get detailed information about a specific Istio object including validation and help information", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the Istio object", + }, + "group": { + Type: "string", + Description: "API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')", + }, + "version": { + Type: "string", + Description: "API version of the Istio object (e.g., 'v1', 'v1beta1')", + }, + "kind": { + Type: "string", + Description: "Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')", + }, + "name": { + Type: "string", + Description: "Name of the Istio object", + }, + }, + Required: []string{"namespace", "group", "version", "kind", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Object: Details", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: istioObjectDetailsHandler, + }) + return ret +} + +func istioObjectDetailsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + group, _ := params.GetArguments()["group"].(string) + version, _ := params.GetArguments()["version"].(string) + kind, _ := params.GetArguments()["kind"].(string) + name, _ := params.GetArguments()["name"].(string) + + k := params.NewKiali() + content, err := k.IstioObjectDetails(params.Context, namespace, group, version, kind, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve Istio object details: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func initIstioObjectPatch() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_object_patch", + Description: "Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the Istio object", + }, + "group": { + Type: "string", + Description: "API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')", + }, + "version": { + Type: "string", + Description: "API version of the Istio object (e.g., 'v1', 'v1beta1')", + }, + "kind": { + Type: "string", + Description: "Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')", + }, + "name": { + Type: "string", + Description: "Name of the Istio object", + }, + "json_patch": { + Type: "string", + Description: "JSON patch data to apply to the object", + }, + }, + Required: []string{"namespace", "group", "version", "kind", "name", "json_patch"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Object: Patch", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(false), + }, + }, Handler: istioObjectPatchHandler, + }) + return ret +} + +func istioObjectPatchHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + group, _ := params.GetArguments()["group"].(string) + version, _ := params.GetArguments()["version"].(string) + kind, _ := params.GetArguments()["kind"].(string) + name, _ := params.GetArguments()["name"].(string) + jsonPatch, _ := params.GetArguments()["json_patch"].(string) + + k := params.NewKiali() + content, err := k.IstioObjectPatch(params.Context, namespace, group, version, kind, name, jsonPatch) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to patch Istio object: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func initIstioObjectCreate() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_object_create", + Description: "Create a new Istio object using POST method. The JSON data will be used to create the new object.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace where the Istio object will be created", + }, + "group": { + Type: "string", + Description: "API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')", + }, + "version": { + Type: "string", + Description: "API version of the Istio object (e.g., 'v1', 'v1beta1')", + }, + "kind": { + Type: "string", + Description: "Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')", + }, + "json_data": { + Type: "string", + Description: "JSON data for the new object", + }, + }, + Required: []string{"namespace", "group", "version", "kind", "json_data"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Object: Create", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(false), + }, + }, Handler: istioObjectCreateHandler, + }) + return ret +} + +func istioObjectCreateHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + group, _ := params.GetArguments()["group"].(string) + version, _ := params.GetArguments()["version"].(string) + kind, _ := params.GetArguments()["kind"].(string) + jsonData, _ := params.GetArguments()["json_data"].(string) + + k := params.NewKiali() + content, err := k.IstioObjectCreate(params.Context, namespace, group, version, kind, jsonData) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to create Istio object: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func initIstioObjectDelete() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_object_delete", + Description: "Delete an existing Istio object using DELETE method.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the Istio object", + }, + "group": { + Type: "string", + Description: "API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')", + }, + "version": { + Type: "string", + Description: "API version of the Istio object (e.g., 'v1', 'v1beta1')", + }, + "kind": { + Type: "string", + Description: "Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')", + }, + "name": { + Type: "string", + Description: "Name of the Istio object", + }, + }, + Required: []string{"namespace", "group", "version", "kind", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Object: Delete", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(false), + }, + }, Handler: istioObjectDeleteHandler, + }) + return ret +} + +func istioObjectDeleteHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + group, _ := params.GetArguments()["group"].(string) + version, _ := params.GetArguments()["version"].(string) + kind, _ := params.GetArguments()["kind"].(string) + name, _ := params.GetArguments()["name"].(string) + + k := params.NewKiali() + content, err := k.IstioObjectDelete(params.Context, namespace, group, version, kind, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to delete Istio object: %v", err)), nil + } + + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/logs.go b/pkg/toolsets/kiali/logs.go new file mode 100644 index 00000000..5c74ce68 --- /dev/null +++ b/pkg/toolsets/kiali/logs.go @@ -0,0 +1,154 @@ +package kiali + +import ( + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initLogs() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // Workload logs tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workload_logs", + Description: "Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the workload", + }, + "workload": { + Type: "string", + Description: "Name of the workload to get logs for", + }, + "container": { + Type: "string", + Description: "Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init)", + }, + "since": { + Type: "string", + Description: "Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs", + }, + "tail": { + Type: "integer", + Description: "Number of lines to retrieve from the end of logs (default: 100)", + Minimum: ptr.To(float64(1)), + }, + }, + Required: []string{"namespace", "workload"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Workload: Logs", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: workloadLogsHandler, + }) + + return ret +} + +func workloadLogsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + workload, _ := params.GetArguments()["workload"].(string) + k := params.NewKiali() + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if workload == "" { + return api.NewToolCallResult("", fmt.Errorf("workload parameter is required")), nil + } + + // Extract optional parameters + container, _ := params.GetArguments()["container"].(string) + since, _ := params.GetArguments()["since"].(string) + tail := params.GetArguments()["tail"] + + // Convert parameters to Kiali API format + var duration, logType, sinceTime, maxLines string + var service string // We don't have service parameter in our schema, but Kiali API supports it + + // Convert since to duration (Kiali expects duration format like "5m", "1h") + if since != "" { + duration = since + } + + // Convert tail to maxLines + if tail != nil { + switch v := tail.(type) { + case float64: + maxLines = fmt.Sprintf("%.0f", v) + case int: + maxLines = fmt.Sprintf("%d", v) + case int64: + maxLines = fmt.Sprintf("%d", v) + } + } + + // If no container specified, we need to get workload details first to find the main app container + if container == "" { + workloadDetails, err := k.WorkloadDetails(params.Context, namespace, workload) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload details: %v", err)), nil + } + + // Parse the workload details JSON to extract container names + var workloadData struct { + Pods []struct { + Name string `json:"name"` + Containers []struct { + Name string `json:"name"` + } `json:"containers"` + } `json:"pods"` + } + + if err := json.Unmarshal([]byte(workloadDetails), &workloadData); err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to parse workload details: %v", err)), nil + } + + if len(workloadData.Pods) == 0 { + return api.NewToolCallResult("", fmt.Errorf("no pods found for workload %s in namespace %s", workload, namespace)), nil + } + + // Find the main application container (not istio-proxy or istio-init) + for _, pod := range workloadData.Pods { + for _, c := range pod.Containers { + if c.Name != "istio-proxy" && c.Name != "istio-init" { + container = c.Name + break + } + } + if container != "" { + break + } + } + + // If no app container found, use the first container + if container == "" && len(workloadData.Pods) > 0 && len(workloadData.Pods[0].Containers) > 0 { + container = workloadData.Pods[0].Containers[0].Name + } + } + + if container == "" { + return api.NewToolCallResult("", fmt.Errorf("no container found for workload %s in namespace %s", workload, namespace)), nil + } + + // Use the WorkloadLogs method with the correct parameters + logs, err := k.WorkloadLogs(params.Context, namespace, workload, container, service, duration, logType, sinceTime, maxLines) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload logs: %v", err)), nil + } + + return api.NewToolCallResult(logs, nil), nil +} diff --git a/pkg/toolsets/kiali/namespaces.go b/pkg/toolsets/kiali/namespaces.go new file mode 100644 index 00000000..a006f2b1 --- /dev/null +++ b/pkg/toolsets/kiali/namespaces.go @@ -0,0 +1,40 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initNamespaces() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "namespaces", + Description: "Get all namespaces in the mesh that the user has access to", + InputSchema: &jsonschema.Schema{ + Type: "object", + }, + Annotations: api.ToolAnnotations{ + Title: "Namespaces: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: namespacesHandler, + }) + return ret +} + +func namespacesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + k := params.NewKiali() + content, err := k.ListNamespaces(params.Context) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/services.go b/pkg/toolsets/kiali/services.go new file mode 100644 index 00000000..1fd2018c --- /dev/null +++ b/pkg/toolsets/kiali/services.go @@ -0,0 +1,209 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initServices() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // Services list tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "services_list", + Description: "Get all services in the mesh across specified namespaces with health and Istio resource information", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespaces": { + Type: "string", + Description: "Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Services: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: servicesListHandler, + }) + + // Service details tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "service_details", + Description: "Get detailed information for a specific service in a namespace, including validation, health status, and configuration", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the service", + }, + "service": { + Type: "string", + Description: "Name of the service to get details for", + }, + }, + Required: []string{"namespace", "service"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Service: Details", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: serviceDetailsHandler, + }) + + // Service metrics tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "service_metrics", + Description: "Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the service", + }, + "service": { + Type: "string", + Description: "Name of the service to get metrics for", + }, + "duration": { + Type: "string", + Description: "Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds", + }, + "step": { + Type: "string", + Description: "Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds", + }, + "rateInterval": { + Type: "string", + Description: "Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m'", + }, + "direction": { + Type: "string", + Description: "Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound'", + }, + "reporter": { + Type: "string", + Description: "Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source'", + }, + "requestProtocol": { + Type: "string", + Description: "Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional", + }, + "quantiles": { + Type: "string", + Description: "Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional", + }, + "byLabels": { + Type: "string", + Description: "Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional", + }, + }, + Required: []string{"namespace", "service"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Service: Metrics", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: serviceMetricsHandler, + }) + + return ret +} + +func servicesListHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespaces, _ := params.GetArguments()["namespaces"].(string) + + k := params.NewKiali() + content, err := k.ServicesList(params.Context, namespaces) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list services: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func serviceDetailsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace, _ := params.GetArguments()["namespace"].(string) + service, _ := params.GetArguments()["service"].(string) + + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if service == "" { + return api.NewToolCallResult("", fmt.Errorf("service parameter is required")), nil + } + + k := params.NewKiali() + content, err := k.ServiceDetails(params.Context, namespace, service) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get service details: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func serviceMetricsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + service, _ := params.GetArguments()["service"].(string) + + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if service == "" { + return api.NewToolCallResult("", fmt.Errorf("service parameter is required")), nil + } + + // Extract optional query parameters + queryParams := make(map[string]string) + if duration, ok := params.GetArguments()["duration"].(string); ok && duration != "" { + queryParams["duration"] = duration + } + if step, ok := params.GetArguments()["step"].(string); ok && step != "" { + queryParams["step"] = step + } + if rateInterval, ok := params.GetArguments()["rateInterval"].(string); ok && rateInterval != "" { + queryParams["rateInterval"] = rateInterval + } + if direction, ok := params.GetArguments()["direction"].(string); ok && direction != "" { + queryParams["direction"] = direction + } + if reporter, ok := params.GetArguments()["reporter"].(string); ok && reporter != "" { + queryParams["reporter"] = reporter + } + if requestProtocol, ok := params.GetArguments()["requestProtocol"].(string); ok && requestProtocol != "" { + queryParams["requestProtocol"] = requestProtocol + } + if quantiles, ok := params.GetArguments()["quantiles"].(string); ok && quantiles != "" { + queryParams["quantiles"] = quantiles + } + if byLabels, ok := params.GetArguments()["byLabels"].(string); ok && byLabels != "" { + queryParams["byLabels"] = byLabels + } + + k := params.NewKiali() + content, err := k.ServiceMetrics(params.Context, namespace, service, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get service metrics: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/toolset.go b/pkg/toolsets/kiali/toolset.go index a175888a..0cd5508b 100644 --- a/pkg/toolsets/kiali/toolset.go +++ b/pkg/toolsets/kiali/toolset.go @@ -22,7 +22,20 @@ func (t *Toolset) GetDescription() string { func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool { return slices.Concat( + initGraph(), initMeshStatus(), + initIstioConfig(), + initIstioObjectDetails(), + initIstioObjectPatch(), + initIstioObjectCreate(), + initIstioObjectDelete(), + initValidations(), + initNamespaces(), + initServices(), + initWorkloads(), + initHealth(), + initLogs(), + initTraces(), ) } diff --git a/pkg/toolsets/kiali/traces.go b/pkg/toolsets/kiali/traces.go new file mode 100644 index 00000000..fd5aacc9 --- /dev/null +++ b/pkg/toolsets/kiali/traces.go @@ -0,0 +1,285 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initTraces() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // App traces tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "app_traces", + Description: "Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the app", + }, + "app": { + Type: "string", + Description: "Name of the app to get traces for", + }, + "startMicros": { + Type: "string", + Description: "Start time for traces in microseconds since epoch (optional)", + }, + "endMicros": { + Type: "string", + Description: "End time for traces in microseconds since epoch (optional)", + }, + "limit": { + Type: "integer", + Description: "Maximum number of traces to return (default: 100)", + Minimum: ptr.To(float64(1)), + }, + "minDuration": { + Type: "integer", + Description: "Minimum trace duration in microseconds (optional)", + Minimum: ptr.To(float64(0)), + }, + "tags": { + Type: "string", + Description: "JSON string of tags to filter traces (optional)", + }, + "clusterName": { + Type: "string", + Description: "Cluster name for multi-cluster environments (optional)", + }, + }, + Required: []string{"namespace", "app"}, + }, + Annotations: api.ToolAnnotations{ + Title: "App: Traces", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: appTracesHandler, + }) + + // Service traces tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "service_traces", + Description: "Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the service", + }, + "service": { + Type: "string", + Description: "Name of the service to get traces for", + }, + "startMicros": { + Type: "string", + Description: "Start time for traces in microseconds since epoch (optional)", + }, + "endMicros": { + Type: "string", + Description: "End time for traces in microseconds since epoch (optional)", + }, + "limit": { + Type: "integer", + Description: "Maximum number of traces to return (default: 100)", + Minimum: ptr.To(float64(1)), + }, + "minDuration": { + Type: "integer", + Description: "Minimum trace duration in microseconds (optional)", + Minimum: ptr.To(float64(0)), + }, + "tags": { + Type: "string", + Description: "JSON string of tags to filter traces (optional)", + }, + "clusterName": { + Type: "string", + Description: "Cluster name for multi-cluster environments (optional)", + }, + }, + Required: []string{"namespace", "service"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Service: Traces", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: serviceTracesHandler, + }) + + // Workload traces tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workload_traces", + Description: "Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the workload", + }, + "workload": { + Type: "string", + Description: "Name of the workload to get traces for", + }, + "startMicros": { + Type: "string", + Description: "Start time for traces in microseconds since epoch (optional)", + }, + "endMicros": { + Type: "string", + Description: "End time for traces in microseconds since epoch (optional)", + }, + "limit": { + Type: "integer", + Description: "Maximum number of traces to return (default: 100)", + Minimum: ptr.To(float64(1)), + }, + "minDuration": { + Type: "integer", + Description: "Minimum trace duration in microseconds (optional)", + Minimum: ptr.To(float64(0)), + }, + "tags": { + Type: "string", + Description: "JSON string of tags to filter traces (optional)", + }, + "clusterName": { + Type: "string", + Description: "Cluster name for multi-cluster environments (optional)", + }, + }, + Required: []string{"namespace", "workload"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Workload: Traces", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: workloadTracesHandler, + }) + + return ret +} + +func appTracesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace := params.GetArguments()["namespace"].(string) + app := params.GetArguments()["app"].(string) + + // Build query parameters from optional arguments + queryParams := make(map[string]string) + if startMicros, ok := params.GetArguments()["startMicros"].(string); ok && startMicros != "" { + queryParams["startMicros"] = startMicros + } + if endMicros, ok := params.GetArguments()["endMicros"].(string); ok && endMicros != "" { + queryParams["endMicros"] = endMicros + } + if limit, ok := params.GetArguments()["limit"].(string); ok && limit != "" { + queryParams["limit"] = limit + } + if minDuration, ok := params.GetArguments()["minDuration"].(string); ok && minDuration != "" { + queryParams["minDuration"] = minDuration + } + if tags, ok := params.GetArguments()["tags"].(string); ok && tags != "" { + queryParams["tags"] = tags + } + if clusterName, ok := params.GetArguments()["clusterName"].(string); ok && clusterName != "" { + queryParams["clusterName"] = clusterName + } + k := params.NewKiali() + content, err := k.AppTraces(params.Context, namespace, app, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get app traces: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func serviceTracesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace := params.GetArguments()["namespace"].(string) + service := params.GetArguments()["service"].(string) + + // Build query parameters from optional arguments + queryParams := make(map[string]string) + if startMicros, ok := params.GetArguments()["startMicros"].(string); ok && startMicros != "" { + queryParams["startMicros"] = startMicros + } + if endMicros, ok := params.GetArguments()["endMicros"].(string); ok && endMicros != "" { + queryParams["endMicros"] = endMicros + } + if limit, ok := params.GetArguments()["limit"].(string); ok && limit != "" { + queryParams["limit"] = limit + } + if minDuration, ok := params.GetArguments()["minDuration"].(string); ok && minDuration != "" { + queryParams["minDuration"] = minDuration + } + if tags, ok := params.GetArguments()["tags"].(string); ok && tags != "" { + queryParams["tags"] = tags + } + if clusterName, ok := params.GetArguments()["clusterName"].(string); ok && clusterName != "" { + queryParams["clusterName"] = clusterName + } + + k := params.NewKiali() + content, err := k.ServiceTraces(params.Context, namespace, service, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get service traces: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func workloadTracesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace := params.GetArguments()["namespace"].(string) + workload := params.GetArguments()["workload"].(string) + + // Build query parameters from optional arguments + queryParams := make(map[string]string) + if startMicros, ok := params.GetArguments()["startMicros"].(string); ok && startMicros != "" { + queryParams["startMicros"] = startMicros + } + if endMicros, ok := params.GetArguments()["endMicros"].(string); ok && endMicros != "" { + queryParams["endMicros"] = endMicros + } + if limit, ok := params.GetArguments()["limit"].(string); ok && limit != "" { + queryParams["limit"] = limit + } + if minDuration, ok := params.GetArguments()["minDuration"].(string); ok && minDuration != "" { + queryParams["minDuration"] = minDuration + } + if tags, ok := params.GetArguments()["tags"].(string); ok && tags != "" { + queryParams["tags"] = tags + } + if clusterName, ok := params.GetArguments()["clusterName"].(string); ok && clusterName != "" { + queryParams["clusterName"] = clusterName + } + + k := params.NewKiali() + content, err := k.WorkloadTraces(params.Context, namespace, workload, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload traces: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/validations.go b/pkg/toolsets/kiali/validations.go new file mode 100644 index 00000000..898f7d03 --- /dev/null +++ b/pkg/toolsets/kiali/validations.go @@ -0,0 +1,86 @@ +package kiali + +import ( + "fmt" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initValidations() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "validations_list", + Description: "List all the validations in the current cluster from all namespaces", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional single namespace to retrieve validations from (alternative to namespaces)", + }, + "namespaces": { + Type: "string", + Description: "Optional comma-separated list of namespaces to retrieve validations from", + }, + }, + Required: []string{}, + }, + Annotations: api.ToolAnnotations{ + Title: "Validations: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: validationsList, + }) + return ret +} + +func validationsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Parse arguments: allow either `namespace` or `namespaces` (comma-separated string) + namespaces := make([]string, 0) + if v, ok := params.GetArguments()["namespace"].(string); ok { + v = strings.TrimSpace(v) + if v != "" { + namespaces = append(namespaces, v) + } + } + if v, ok := params.GetArguments()["namespaces"].(string); ok { + for _, ns := range strings.Split(v, ",") { + ns = strings.TrimSpace(ns) + if ns != "" { + namespaces = append(namespaces, ns) + } + } + } + // Deduplicate namespaces if both provided + if len(namespaces) > 1 { + seen := map[string]struct{}{} + unique := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + key := strings.TrimSpace(ns) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + unique = append(unique, key) + } + namespaces = unique + } + + k := params.NewKiali() + content, err := k.ValidationsList(params.Context, namespaces) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list validations: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/workloads.go b/pkg/toolsets/kiali/workloads.go new file mode 100644 index 00000000..f8d03a28 --- /dev/null +++ b/pkg/toolsets/kiali/workloads.go @@ -0,0 +1,209 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initWorkloads() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // Workloads list tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workloads_list", + Description: "Get all workloads in the mesh across specified namespaces with health and Istio resource information", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespaces": { + Type: "string", + Description: "Comma-separated list of namespaces to get workloads from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list workloads from all accessible namespaces", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Workloads: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: workloadsListHandler, + }) + + // Workload details tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workload_details", + Description: "Get detailed information for a specific workload in a namespace, including validation, health status, and configuration", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the workload", + }, + "workload": { + Type: "string", + Description: "Name of the workload to get details for", + }, + }, + Required: []string{"namespace", "workload"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Workload: Details", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: workloadDetailsHandler, + }) + + // Workload metrics tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workload_metrics", + Description: "Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the workload", + }, + "workload": { + Type: "string", + Description: "Name of the workload to get metrics for", + }, + "duration": { + Type: "string", + Description: "Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds", + }, + "step": { + Type: "string", + Description: "Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds", + }, + "rateInterval": { + Type: "string", + Description: "Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m'", + }, + "direction": { + Type: "string", + Description: "Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound'", + }, + "reporter": { + Type: "string", + Description: "Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source'", + }, + "requestProtocol": { + Type: "string", + Description: "Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional", + }, + "quantiles": { + Type: "string", + Description: "Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional", + }, + "byLabels": { + Type: "string", + Description: "Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional", + }, + }, + Required: []string{"namespace", "workload"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Workload: Metrics", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: workloadMetricsHandler, + }) + + return ret +} + +func workloadsListHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespaces, _ := params.GetArguments()["namespaces"].(string) + + k := params.NewKiali() + content, err := k.WorkloadsList(params.Context, namespaces) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list workloads: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func workloadDetailsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace, _ := params.GetArguments()["namespace"].(string) + workload, _ := params.GetArguments()["workload"].(string) + + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if workload == "" { + return api.NewToolCallResult("", fmt.Errorf("workload parameter is required")), nil + } + + k := params.NewKiali() + content, err := k.WorkloadDetails(params.Context, namespace, workload) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload details: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func workloadMetricsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + workload, _ := params.GetArguments()["workload"].(string) + + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if workload == "" { + return api.NewToolCallResult("", fmt.Errorf("workload parameter is required")), nil + } + + // Extract optional query parameters + queryParams := make(map[string]string) + if duration, ok := params.GetArguments()["duration"].(string); ok && duration != "" { + queryParams["duration"] = duration + } + if step, ok := params.GetArguments()["step"].(string); ok && step != "" { + queryParams["step"] = step + } + if rateInterval, ok := params.GetArguments()["rateInterval"].(string); ok && rateInterval != "" { + queryParams["rateInterval"] = rateInterval + } + if direction, ok := params.GetArguments()["direction"].(string); ok && direction != "" { + queryParams["direction"] = direction + } + if reporter, ok := params.GetArguments()["reporter"].(string); ok && reporter != "" { + queryParams["reporter"] = reporter + } + if requestProtocol, ok := params.GetArguments()["requestProtocol"].(string); ok && requestProtocol != "" { + queryParams["requestProtocol"] = requestProtocol + } + if quantiles, ok := params.GetArguments()["quantiles"].(string); ok && quantiles != "" { + queryParams["quantiles"] = quantiles + } + if byLabels, ok := params.GetArguments()["byLabels"].(string); ok && byLabels != "" { + queryParams["byLabels"] = byLabels + } + + k := params.NewKiali() + content, err := k.WorkloadMetrics(params.Context, namespace, workload, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload metrics: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} From 35f03fa2c31a0cebf4bffb36407ad00463f6a798 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Mon, 10 Nov 2025 19:47:41 +0100 Subject: [PATCH 10/11] Support for certificateAuthority Signed-off-by: Alberto Gutierrez --- pkg/kiali/config.go | 5 +++-- pkg/kiali/kiali.go | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/pkg/kiali/config.go b/pkg/kiali/config.go index 3c5f8ed7..8fbabd04 100644 --- a/pkg/kiali/config.go +++ b/pkg/kiali/config.go @@ -11,8 +11,9 @@ import ( // Config holds Kiali toolset configuration type Config struct { - Url string `toml:"url,omitempty"` - Insecure bool `toml:"insecure,omitempty"` + Url string `toml:"url"` + Insecure bool `toml:"insecure,omitempty"` + CertificateAuthority string `toml:"certificate_authority,omitempty"` } var _ config.ToolsetConfig = (*Config)(nil) diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go index c384fe97..6c6d4bed 100644 --- a/pkg/kiali/kiali.go +++ b/pkg/kiali/kiali.go @@ -3,10 +3,12 @@ package kiali import ( "context" "crypto/tls" + "crypto/x509" "fmt" "io" "net/http" "net/url" + "os" "strings" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -15,9 +17,10 @@ import ( ) type Kiali struct { - bearerToken string - kialiURL string - kialiInsecure bool + bearerToken string + kialiURL string + kialiInsecure bool + certificateAuthority string } // NewKiali creates a new Kiali instance @@ -27,6 +30,7 @@ func NewKiali(config *config.StaticConfig, kubernetes *rest.Config) *Kiali { if kc, ok := cfg.(*Config); ok && kc != nil { kiali.kialiURL = kc.Url kiali.kialiInsecure = kc.Insecure + kiali.certificateAuthority = kc.CertificateAuthority } } return kiali @@ -58,11 +62,34 @@ func (k *Kiali) validateAndGetURL(endpoint string) (string, error) { } func (k *Kiali) createHTTPClient() *http.Client { + // Base TLS configuration, optionally extended with a custom CA + tlsConfig := &tls.Config{ + InsecureSkipVerify: k.kialiInsecure, + } + + // If a custom Certificate Authority path is configured, load and add it + if caPath := strings.TrimSpace(k.certificateAuthority); caPath != "" { + // Start with the host system pool when possible so we don't drop system roots + var certPool *x509.CertPool + if systemPool, err := x509.SystemCertPool(); err == nil && systemPool != nil { + certPool = systemPool + } else { + certPool = x509.NewCertPool() + } + if pemData, err := os.ReadFile(caPath); err == nil { + if ok := certPool.AppendCertsFromPEM(pemData); ok { + tlsConfig.RootCAs = certPool + } else { + klog.V(0).Infof("failed to append certificates from %q; proceeding without custom CA", caPath) + } + } else { + klog.V(0).Infof("failed to read certificate authority file %q: %v; proceeding without custom CA", caPath, err) + } + } + return &http.Client{ Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: k.kialiInsecure, - }, + TLSClientConfig: tlsConfig, }, } } From 177cd984ca2b0fefd4bdcddede5a1aef258be1dc Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Mon, 10 Nov 2025 20:21:00 +0100 Subject: [PATCH 11/11] update docs y tests Signed-off-by: Alberto Gutierrez --- README.md | 122 ++++++++++++++++++++++++++++++ docs/KIALI_INTEGRATION.md | 155 ++++++++++++++++++++++++++++++++++---- pkg/kiali/config.go | 5 ++ pkg/kiali/kiali.go | 15 ++-- pkg/kiali/kiali_test.go | 13 +++- 5 files changed, 284 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2acc2600..d9d4c01e 100644 --- a/README.md +++ b/README.md @@ -350,8 +350,130 @@ In case multi-cluster support is enabled (default) and you have access to multip kiali +- **graph** - Check the status of my mesh by querying Kiali graph + - `namespace` (`string`) - Optional single namespace to include in the graph (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to include in the graph + - **mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status +- **istio_config** - Get all Istio configuration objects in the mesh including their full YAML resources and details + +- **istio_object_details** - Get detailed information about a specific Istio object including validation and help information + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_patch** - Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_patch` (`string`) **(required)** - JSON patch data to apply to the object + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_create** - Create a new Istio object using POST method. The JSON data will be used to create the new object. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_data` (`string`) **(required)** - JSON data for the new object + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `namespace` (`string`) **(required)** - Namespace where the Istio object will be created + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_delete** - Delete an existing Istio object using DELETE method. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **validations_list** - List all the validations in the current cluster from all namespaces + - `namespace` (`string`) - Optional single namespace to retrieve validations from (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to retrieve validations from + +- **namespaces** - Get all namespaces in the mesh that the user has access to + +- **services_list** - Get all services in the mesh across specified namespaces with health and Istio resource information + - `namespaces` (`string`) - Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces + +- **service_details** - Get detailed information for a specific service in a namespace, including validation, health status, and configuration + - `namespace` (`string`) **(required)** - Namespace containing the service + - `service` (`string`) **(required)** - Name of the service to get details for + +- **service_metrics** - Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds + - `namespace` (`string`) **(required)** - Namespace containing the service + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `service` (`string`) **(required)** - Name of the service to get metrics for + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + +- **workloads_list** - Get all workloads in the mesh across specified namespaces with health and Istio resource information + - `namespaces` (`string`) - Comma-separated list of namespaces to get workloads from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list workloads from all accessible namespaces + +- **workload_details** - Get detailed information for a specific workload in a namespace, including validation, health status, and configuration + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `workload` (`string`) **(required)** - Name of the workload to get details for + +- **workload_metrics** - Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + - `workload` (`string`) **(required)** - Name of the workload to get metrics for + +- **health** - Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type + - `namespaces` (`string`) - Comma-separated list of namespaces to get health from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, returns health for all accessible namespaces + - `queryTime` (`string`) - Unix timestamp (in seconds) for the prometheus query. If not provided, uses current time. Optional + - `rateInterval` (`string`) - Rate interval for fetching error rate (e.g., '10m', '5m', '1h'). Default: '10m' + - `type` (`string`) - Type of health to retrieve: 'app', 'service', or 'workload'. Default: 'app' + +- **workload_logs** - Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified. + - `container` (`string`) - Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `since` (`string`) - Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs + - `tail` (`integer`) - Number of lines to retrieve from the end of logs (default: 100) + - `workload` (`string`) **(required)** - Name of the workload to get logs for + +- **app_traces** - Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `app` (`string`) **(required)** - Name of the app to get traces for + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the app + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + +- **service_traces** - Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the service + - `service` (`string`) **(required)** - Name of the service to get traces for + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + +- **workload_traces** - Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + - `workload` (`string`) **(required)** - Name of the workload to get traces for + diff --git a/docs/KIALI_INTEGRATION.md b/docs/KIALI_INTEGRATION.md index c48f51be..00952744 100644 --- a/docs/KIALI_INTEGRATION.md +++ b/docs/KIALI_INTEGRATION.md @@ -4,7 +4,7 @@ This server can expose Kiali tools so assistants can query mesh information (e.g ### Enable the Kiali toolset -You can enable the Kiali tools via config or flags. +Enable the Kiali tools via the server TOML configuration file. Config (TOML): @@ -13,19 +13,14 @@ toolsets = ["core", "kiali"] [toolset_configs.kiali] url = "https://kiali.example" -# insecure = true # optional: allow insecure TLS +# insecure = true # optional: allow insecure TLS (not recommended in production) +# certificate_authority = """-----BEGIN CERTIFICATE----- +# MIID... +# -----END CERTIFICATE-----""" +# When url is https and insecure is false, certificate_authority is required. ``` -Flags: - -```bash -kubernetes-mcp-server \ - --toolsets core,kiali \ - --kiali-url https://kiali.example \ - [--kiali-insecure] -``` - -When the `kiali` toolset is enabled, a Kiali toolset configuration is required. Provide it via `[toolset_configs.kiali]` in the config file or by passing flags (which populate the toolset config). If missing or invalid, the server will refuse to start. +When the `kiali` toolset is enabled, a Kiali toolset configuration is required via `[toolset_configs.kiali]`. If missing or invalid, the server will refuse to start. ### How authentication works @@ -34,12 +29,142 @@ When the `kiali` toolset is enabled, a Kiali toolset configuration is required. ### Available tools (initial) -- `mesh_status`: retrieves mesh components status from Kiali’s mesh graph endpoint. +
+ +kiali + +- **graph** - Check the status of my mesh by querying Kiali graph + - `namespace` (`string`) - Optional single namespace to include in the graph (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to include in the graph + +- **mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status + +- **istio_config** - Get all Istio configuration objects in the mesh including their full YAML resources and details + +- **istio_object_details** - Get detailed information about a specific Istio object including validation and help information + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_patch** - Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_patch` (`string`) **(required)** - JSON patch data to apply to the object + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_create** - Create a new Istio object using POST method. The JSON data will be used to create the new object. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_data` (`string`) **(required)** - JSON data for the new object + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `namespace` (`string`) **(required)** - Namespace where the Istio object will be created + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_delete** - Delete an existing Istio object using DELETE method. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **validations_list** - List all the validations in the current cluster from all namespaces + - `namespace` (`string`) - Optional single namespace to retrieve validations from (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to retrieve validations from + +- **namespaces** - Get all namespaces in the mesh that the user has access to + +- **services_list** - Get all services in the mesh across specified namespaces with health and Istio resource information + - `namespaces` (`string`) - Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces + +- **service_details** - Get detailed information for a specific service in a namespace, including validation, health status, and configuration + - `namespace` (`string`) **(required)** - Namespace containing the service + - `service` (`string`) **(required)** - Name of the service to get details for + +- **service_metrics** - Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds + - `namespace` (`string`) **(required)** - Namespace containing the service + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `service` (`string`) **(required)** - Name of the service to get metrics for + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + +- **workloads_list** - Get all workloads in the mesh across specified namespaces with health and Istio resource information + - `namespaces` (`string`) - Comma-separated list of namespaces to get workloads from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list workloads from all accessible namespaces + +- **workload_details** - Get detailed information for a specific workload in a namespace, including validation, health status, and configuration + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `workload` (`string`) **(required)** - Name of the workload to get details for + +- **workload_metrics** - Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + - `workload` (`string`) **(required)** - Name of the workload to get metrics for + +- **health** - Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type + - `namespaces` (`string`) - Comma-separated list of namespaces to get health from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, returns health for all accessible namespaces + - `queryTime` (`string`) - Unix timestamp (in seconds) for the prometheus query. If not provided, uses current time. Optional + - `rateInterval` (`string`) - Rate interval for fetching error rate (e.g., '10m', '5m', '1h'). Default: '10m' + - `type` (`string`) - Type of health to retrieve: 'app', 'service', or 'workload'. Default: 'app' + +- **workload_logs** - Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified. + - `container` (`string`) - Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `since` (`string`) - Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs + - `tail` (`integer`) - Number of lines to retrieve from the end of logs (default: 100) + - `workload` (`string`) **(required)** - Name of the workload to get logs for + +- **app_traces** - Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `app` (`string`) **(required)** - Name of the app to get traces for + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the app + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + +- **service_traces** - Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the service + - `service` (`string`) **(required)** - Name of the service to get traces for + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + +- **workload_traces** - Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + - `workload` (`string`) **(required)** - Name of the workload to get traces for + +
### Troubleshooting -- Missing Kiali configuration when `kiali` toolset is enabled → provide `--kiali-url` or set `[toolset_configs.kiali].url` in the config TOML. +- Missing Kiali configuration when `kiali` toolset is enabled → set `[toolset_configs.kiali].url` in the config TOML. - Invalid URL → ensure `[toolset_configs.kiali].url` is a valid `http(s)://host` URL. -- TLS issues against Kiali → try `--kiali-insecure` or `[toolset_configs.kiali].insecure = true` for non-production environments. +- TLS certificate validation: + - If `[toolset_configs.kiali].url` uses HTTPS and `[toolset_configs.kiali].insecure` is false, you must set `[toolset_configs.kiali].certificate_authority` with the PEM-encoded certificate(s) used by the Kiali server. This field expects inline PEM content, not a file path. You may concatenate multiple PEM blocks to include an intermediate chain. + - For non-production environments you can set `[toolset_configs.kiali].insecure = true` to skip certificate verification. diff --git a/pkg/kiali/config.go b/pkg/kiali/config.go index 8fbabd04..82e8d7f3 100644 --- a/pkg/kiali/config.go +++ b/pkg/kiali/config.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/url" + "strings" "github.com/BurntSushi/toml" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -28,6 +29,10 @@ func (c *Config) Validate() error { if u, err := url.Parse(c.Url); err != nil || u.Scheme == "" || u.Host == "" { return errors.New("url must be a valid URL") } + u, _ := url.Parse(c.Url) + if strings.EqualFold(u.Scheme, "https") && !c.Insecure && strings.TrimSpace(c.CertificateAuthority) == "" { + return errors.New("certificate_authority is required for https when insecure is false") + } return nil } diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go index 6c6d4bed..cf5c9284 100644 --- a/pkg/kiali/kiali.go +++ b/pkg/kiali/kiali.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/url" - "os" "strings" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -67,8 +66,8 @@ func (k *Kiali) createHTTPClient() *http.Client { InsecureSkipVerify: k.kialiInsecure, } - // If a custom Certificate Authority path is configured, load and add it - if caPath := strings.TrimSpace(k.certificateAuthority); caPath != "" { + // If a custom Certificate Authority PEM is configured, load and add it + if caPEM := strings.TrimSpace(k.certificateAuthority); caPEM != "" { // Start with the host system pool when possible so we don't drop system roots var certPool *x509.CertPool if systemPool, err := x509.SystemCertPool(); err == nil && systemPool != nil { @@ -76,14 +75,10 @@ func (k *Kiali) createHTTPClient() *http.Client { } else { certPool = x509.NewCertPool() } - if pemData, err := os.ReadFile(caPath); err == nil { - if ok := certPool.AppendCertsFromPEM(pemData); ok { - tlsConfig.RootCAs = certPool - } else { - klog.V(0).Infof("failed to append certificates from %q; proceeding without custom CA", caPath) - } + if ok := certPool.AppendCertsFromPEM([]byte(caPEM)); ok { + tlsConfig.RootCAs = certPool } else { - klog.V(0).Infof("failed to read certificate authority file %q: %v; proceeding without custom CA", caPath, err) + klog.V(0).Infof("failed to append provided certificate authority PEM; proceeding without custom CA") } } diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go index a997c14b..fc08cb9f 100644 --- a/pkg/kiali/kiali_test.go +++ b/pkg/kiali/kiali_test.go @@ -57,10 +57,21 @@ func (s *KialiSuite) TestNewKiali_InvalidConfig() { s.Nil(cfg, "Unexpected Kiali config") } +func (s *KialiSuite) TestCertificateRequiredForHTTPSWhenNotInsecure() { + cfg, err := config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "https://kiali.example/" + `)) + s.Error(err, "Expected error when https and insecure=false without certificate_authority") + s.ErrorContains(err, "certificate_authority is required for https when insecure is false", "Unexpected error message") + s.Nil(cfg, "Unexpected Kiali config") +} + func (s *KialiSuite) TestValidateAndGetURL() { s.Config = test.Must(config.ReadToml([]byte(` [toolset_configs.kiali] url = "https://kiali.example/" + insecure = true `))) k := NewKiali(s.Config, s.MockServer.Config()) @@ -123,4 +134,4 @@ func (s *KialiSuite) TestExecuteRequest() { func TestKiali(t *testing.T) { suite.Run(t, new(KialiSuite)) -} \ No newline at end of file +}