From 6be58fc57fc01603d06f9afa16699ae18af9df38 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Fri, 31 Oct 2025 13:06:32 +0100 Subject: [PATCH 1/7] 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 64f01f42ac4da01f790bc676d4f12f41271f8b0b Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Tue, 4 Nov 2025 09:09:19 +0100 Subject: [PATCH 2/7] 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 1d7541505359c4c4c9cd48d6bb7c572e274924fd Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Wed, 5 Nov 2025 09:29:56 +0100 Subject: [PATCH 3/7] 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 3a14d5b648b71dac8a8244edd5b097e09a4e4d9f Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Wed, 5 Nov 2025 16:20:56 +0100 Subject: [PATCH 4/7] 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 f099e92c8abcbc1b406011c1f3081e0bf8ac4f53 Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Wed, 5 Nov 2025 17:32:33 +0100 Subject: [PATCH 5/7] 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 e7f6ff931341d78cd569de0bdbc8591a8c7e426b Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Wed, 5 Nov 2025 17:44:36 +0100 Subject: [PATCH 6/7] 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 504303a35d9a80882825a1153ec4503085b192a8 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Fri, 7 Nov 2025 12:53:46 +0100 Subject: [PATCH 7/7] 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,