diff --git a/README.md b/README.md
index ee592bd5..d9d4c01e 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,136 @@ 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
new file mode 100644
index 00000000..00952744
--- /dev/null
+++ b/docs/KIALI_INTEGRATION.md
@@ -0,0 +1,170 @@
+## Kiali integration
+
+This server can expose Kiali tools so assistants can query mesh information (e.g., mesh status/graph).
+
+### Enable the Kiali toolset
+
+Enable the Kiali tools via the server TOML configuration file.
+
+Config (TOML):
+
+```toml
+toolsets = ["core", "kiali"]
+
+[toolset_configs.kiali]
+url = "https://kiali.example"
+# 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.
+```
+
+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
+
+- 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)
+
+
+
+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 → set `[toolset_configs.kiali].url` in the config TOML.
+- Invalid URL → ensure `[toolset_configs.kiali].url` is a valid `http(s)://host` URL.
+- 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/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{}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 81bec2b7..5601e7f0 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -68,8 +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"`
+ // 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
@@ -127,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
}
@@ -163,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..82e8d7f3
--- /dev/null
+++ b/pkg/kiali/config.go
@@ -0,0 +1,49 @@
+package kiali
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+ "github.com/containers/kubernetes-mcp-server/pkg/config"
+)
+
+// Config holds Kiali toolset configuration
+type Config struct {
+ Url string `toml:"url"`
+ Insecure bool `toml:"insecure,omitempty"`
+ CertificateAuthority string `toml:"certificate_authority,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("url is required")
+ }
+ 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
+}
+
+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/endpoints.go b/pkg/kiali/endpoints.go
new file mode 100644
index 00000000..1c4c3938
--- /dev/null
+++ b/pkg/kiali/endpoints.go
@@ -0,0 +1,27 @@
+package kiali
+
+// Kiali API endpoint paths shared across this package.
+const (
+ // MeshGraph is the Kiali API path that returns the mesh graph/status.
+ 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
new file mode 100644
index 00000000..cf5c9284
--- /dev/null
+++ b/pkg/kiali/kiali.go
@@ -0,0 +1,144 @@
+package kiali
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "io"
+ "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 {
+ bearerToken string
+ kialiURL string
+ kialiInsecure bool
+ certificateAuthority string
+}
+
+// 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
+ kiali.certificateAuthority = kc.CertificateAuthority
+ }
+ }
+ 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.kialiURL == "" {
+ return "", fmt.Errorf("kiali client not initialized")
+ }
+ baseStr := strings.TrimSpace(k.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 {
+ // Base TLS configuration, optionally extended with a custom CA
+ tlsConfig := &tls.Config{
+ InsecureSkipVerify: k.kialiInsecure,
+ }
+
+ // 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 {
+ certPool = systemPool
+ } else {
+ certPool = x509.NewCertPool()
+ }
+ if ok := certPool.AppendCertsFromPEM([]byte(caPEM)); ok {
+ tlsConfig.RootCAs = certPool
+ } else {
+ klog.V(0).Infof("failed to append provided certificate authority PEM; proceeding without custom CA")
+ }
+ }
+
+ return &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: tlsConfig,
+ },
+ }
+}
+
+// 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) authorizationHeader() string {
+ if k == nil {
+ return ""
+ }
+ token := strings.TrimSpace(k.bearerToken)
+ if token == "" {
+ return ""
+ }
+ if strings.HasPrefix(token, "Bearer ") {
+ return token
+ }
+ return "Bearer " + token
+}
+
+// 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 API call: %s %s", method, ApiCallURL)
+ req, err := http.NewRequestWithContext(ctx, method, ApiCallURL, body)
+ if err != nil {
+ return "", err
+ }
+ authHeader := k.authorizationHeader()
+ 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() }()
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ 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(respBody), nil
+}
diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go
new file mode 100644
index 00000000..fc08cb9f
--- /dev/null
+++ b/pkg/kiali/kiali_test.go
@@ -0,0 +1,137 @@
+package kiali
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/containers/kubernetes-mcp-server/internal/test"
+ "github.com/containers/kubernetes-mcp-server/pkg/config"
+ "github.com/stretchr/testify/suite"
+)
+
+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, "url must be a valid URL", "Unexpected error message")
+ 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())
+
+ 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 (s *KialiSuite) TestExecuteRequest() {
+ // setup test server to assert path and auth header
+ var seenAuth string
+ var seenPath string
+ 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"))
+ }))
+
+ 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(), 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")
+ })
+ 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/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
new file mode 100644
index 00000000..c9043791
--- /dev/null
+++ b/pkg/kiali/mesh.go
@@ -0,0 +1,22 @@
+package kiali
+
+import (
+ "context"
+ "net/http"
+ "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(MeshGraphEndpoint)
+ if err != nil {
+ return "", err
+ }
+ q := u.Query()
+ q.Set("includeGateways", "false")
+ q.Set("includeWaypoints", "false")
+ u.RawQuery = q.Encode()
+ return k.executeRequest(ctx, http.MethodGet, u.String(), "", nil)
+}
diff --git a/pkg/kiali/mesh_test.go b/pkg/kiali/mesh_test.go
new file mode 100644
index 00000000..a729015d
--- /dev/null
+++ b/pkg/kiali/mesh_test.go
@@ -0,0 +1,40 @@
+package kiali
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/containers/kubernetes-mcp-server/internal/test"
+ "github.com/containers/kubernetes-mcp-server/pkg/config"
+)
+
+func (s *KialiSuite) TestMeshStatus() {
+ var capturedURL *url.URL
+ 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"))
+ }))
+
+ 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/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_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go
index 22521667..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)
}
})
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..b389d264
--- /dev/null
+++ b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml
@@ -0,0 +1,5 @@
+toolsets = ["core", "kiali"]
+
+[toolset_configs.kiali]
+url = "http://kiali"
+
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 3b5733e1..7de8d6ff 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -4,6 +4,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"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"
@@ -37,3 +38,9 @@ func (k *Kubernetes) NewHelm() *helm.Helm {
// This is a derived Kubernetes, so it already has the Helm initialized
return helm.NewHelm(k.manager)
}
+
+// NewKiali returns a Kiali client initialized with the same StaticConfig and bearer token
+// as the underlying derived Kubernetes manager.
+func (k *Kubernetes) NewKiali() *kiali.Kiali {
+ return kiali.NewKiali(k.manager.staticConfig, k.manager.cfg)
+}
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/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/mesh.go b/pkg/toolsets/kiali/mesh.go
new file mode 100644
index 00000000..d13fa48b
--- /dev/null
+++ b/pkg/toolsets/kiali/mesh.go
@@ -0,0 +1,42 @@
+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) {
+ 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
+ }
+ return api.NewToolCallResult(content, 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
new file mode 100644
index 00000000..0cd5508b
--- /dev/null
+++ b/pkg/toolsets/kiali/toolset.go
@@ -0,0 +1,44 @@
+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(
+ initGraph(),
+ initMeshStatus(),
+ initIstioConfig(),
+ initIstioObjectDetails(),
+ initIstioObjectPatch(),
+ initIstioObjectCreate(),
+ initIstioObjectDelete(),
+ initValidations(),
+ initNamespaces(),
+ initServices(),
+ initWorkloads(),
+ initHealth(),
+ initLogs(),
+ initTraces(),
+ )
+}
+
+func init() {
+ toolsets.Register(&Toolset{})
+}
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
+}