Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<!-- AVAILABLE-TOOLSETS-START -->

Expand All @@ -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 |

<!-- AVAILABLE-TOOLSETS-END -->

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.
Expand Down Expand Up @@ -343,6 +346,14 @@ In case multi-cluster support is enabled (default) and you have access to multip

</details>

<details>

<summary>kiali</summary>

- **mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status

</details>


<!-- AVAILABLE-TOOLSETS-TOOLS-END -->

Expand Down
45 changes: 45 additions & 0 deletions docs/KIALI_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## Kiali integration

This server can expose Kiali tools so assistants can query mesh information (e.g., mesh status/graph).

### Enable the Kiali toolset

You can enable the Kiali tools via config or flags.

Config (TOML):

```toml
toolsets = ["core", "kiali"]

[toolset_configs.kiali]
url = "https://kiali.example"
# insecure = true # optional: allow insecure TLS
```

Flags:

```bash
kubernetes-mcp-server \
--toolsets core,kiali \
--kiali-url https://kiali.example \
[--kiali-insecure]
```

When the `kiali` toolset is enabled, a Kiali toolset configuration is required. Provide it via `[toolset_configs.kiali]` in the config file or by passing flags (which populate the toolset config). If missing or invalid, the server will refuse to start.

### How authentication works

- The server uses your existing Kubernetes credentials (from kubeconfig or in-cluster) to set a bearer token for Kiali calls.
- If you pass an HTTP Authorization header to the MCP HTTP endpoint, that is not required for Kiali; Kiali calls use the server's configured token.

### Available tools (initial)

- `mesh_status`: retrieves mesh components status from Kiali’s mesh graph endpoint.

### Troubleshooting

- Missing Kiali configuration when `kiali` toolset is enabled → provide `--kiali-url` or set `[toolset_configs.kiali].url` in the config TOML.
- Invalid URL → ensure `[toolset_configs.kiali].url` is a valid `http(s)://host` URL.
- TLS issues against Kiali → try `--kiali-insecure` or `[toolset_configs.kiali].insecure = true` for non-production environments.


1 change: 1 addition & 0 deletions internal/tools/update-readme/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
50 changes: 50 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
34 changes: 34 additions & 0 deletions pkg/config/toolset_config.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 43 additions & 0 deletions pkg/kiali/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package kiali

import (
"context"
"errors"
"net/url"

"github.com/BurntSushi/toml"
"github.com/containers/kubernetes-mcp-server/pkg/config"
)

// Config holds Kiali toolset configuration
type Config struct {
Url string `toml:"url,omitempty"`
Insecure bool `toml:"insecure,omitempty"`
}

var _ config.ToolsetConfig = (*Config)(nil)

func (c *Config) Validate() error {
if c == nil {
return errors.New("kiali config is nil")
}
if c.Url == "" {
return errors.New("kiali-url is required")
}
if u, err := url.Parse(c.Url); err != nil || u.Scheme == "" || u.Host == "" {
return errors.New("kiali-url must be a valid URL")
}
return nil
}

func kialiToolsetParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (config.ToolsetConfig, error) {
var cfg Config
if err := md.PrimitiveDecode(primitive, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}

func init() {
config.RegisterToolsetConfig("kiali", kialiToolsetParser)
}
8 changes: 8 additions & 0 deletions pkg/kiali/endpoints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kiali

// Kiali API endpoint paths shared across this package.
const (
// MeshGraph is the Kiali API path that returns the mesh graph/status.
MeshGraph = "/api/mesh/graph"
AuthInfo = "/api/auth/info"
)
117 changes: 117 additions & 0 deletions pkg/kiali/kiali.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package kiali

import (
"context"
"crypto/tls"
"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
}

// NewKiali creates a new Kiali instance
func NewKiali(config *config.StaticConfig, kubernetes *rest.Config) *Kiali {
kiali := &Kiali{bearerToken: kubernetes.BearerToken}
if cfg, ok := config.GetToolsetConfig("kiali"); ok {
if kc, ok := cfg.(*Config); ok && kc != nil {
kiali.kialiURL = kc.Url
kiali.kialiInsecure = kc.Insecure
}
}
return kiali
}

// validateAndGetURL validates the Kiali client configuration and returns the full URL
// by safely concatenating the base URL with the provided endpoint, avoiding duplicate
// or missing slashes regardless of trailing/leading slashes.
func (k *Kiali) validateAndGetURL(endpoint string) (string, error) {
if k == nil || k.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 {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: k.kialiInsecure,
},
},
}
}

// CurrentAuthorizationHeader returns the Authorization header value that the
// Kiali client is currently configured to use (Bearer <token>), 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 and handles common error scenarios.
func (k *Kiali) executeRequest(ctx context.Context, endpoint string) (string, error) {
ApiCallURL, err := k.validateAndGetURL(endpoint)
if err != nil {
return "", err
}

klog.V(0).Infof("Kiali Call URL: %s", ApiCallURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ApiCallURL, nil)
if err != nil {
return "", err
}
authHeader := k.authorizationHeader()
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
client := k.createHTTPClient()
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if len(body) > 0 {
return "", fmt.Errorf("kiali API error: %s", strings.TrimSpace(string(body)))
}
return "", fmt.Errorf("kiali API error: status %d", resp.StatusCode)
}
return string(body), nil
}
Loading
Loading