diff --git a/Makefile b/Makefile
index 6925717b..02ba9323 100644
--- a/Makefile
+++ b/Makefile
@@ -111,3 +111,7 @@ golangci-lint: ## Download and install golangci-lint if not already installed
.PHONY: lint
lint: golangci-lint ## Lint the code
$(GOLANGCI_LINT) run --verbose --print-resources-usage
+
+.PHONY: update-readme-tools
+update-readme-tools: ## Update the README.md file with the latest toolsets
+ go run ./internal/tools/update-readme/main.go README.md
diff --git a/README.md b/README.md
index 4c27ae42..fa0c0e47 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[](https://github.com/containers/kubernetes-mcp-server/releases/latest)
[](https://github.com/containers/kubernetes-mcp-server/actions/workflows/build.yaml)
-[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🛠️ Tools](#tools) | [🧑💻 Development](#development)
+[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🛠️ Tools](#tools-and-functionalities) | [🧑💻 Development](#development)
https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e
@@ -183,242 +183,140 @@ uvx kubernetes-mcp-server@latest --help
| `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") |
| `--read-only` | If set, the MCP server will run in read-only mode, meaning it will not allow any write operations (create, update, delete) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without making changes. |
| `--disable-destructive` | If set, the MCP server will disable all destructive operations (delete, update, etc.) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without accidentally making changes. This option has no effect when `--read-only` is used. |
+| `--toolsets` | Comma-separated list of toolsets to enable. Check the [🛠️ Tools and Functionalities](#tools-and-functionalities) section for more information. |
-## 🛠️ Tools
+## 🛠️ Tools and Functionalities
-### `configuration_view`
+The Kubernetes MCP server supports enabling or disabling specific groups of tools and functionalities (tools, resources, prompts, and so on) via the `--toolsets` command-line flag or `toolsets` configuration option.
+This allows you to control which Kubernetes functionalities are available to your AI tools.
+Enabling only the toolsets you need can help reduce the context size and improve the LLM's tool selection accuracy.
-Get the current Kubernetes configuration content as a kubeconfig YAML
+### Available Toolsets
-**Parameters:**
-- `minified` (`boolean`, optional, default: `true`)
- - Return a minified version of the configuration
- - If `true`, keeps only the current-context and relevant configuration pieces
- - If `false`, returns all contexts, clusters, auth-infos, and users
+The following sets of tools are available (all on by default):
-### `events_list`
+
-List all the Kubernetes events in the current cluster from all namespaces
+| Toolset | Description |
+|---------|-------------------------------------------------------------------------------------|
+| 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 |
-**Parameters:**
-- `namespace` (`string`, optional)
- - Namespace to retrieve the events from. If not provided, will list events from all namespaces
+
-### `helm_install`
+### Tools
-Install a Helm chart in the current or provided namespace with the provided name and chart
+
-**Parameters:**
-- `chart` (`string`, required)
- - Name of the Helm chart to install
- - Can be a local path or a remote URL
- - Example: `./my-chart.tgz` or `https://example.com/my-chart.tgz`
-- `values` (`object`, optional)
- - Values to pass to the Helm chart
- - Example: `{"key": "value"}`
-- `name` (`string`, optional)
- - Name of the Helm release
- - Random name if not provided
-- `namespace` (`string`, optional)
- - Namespace to install the Helm chart in
- - If not provided, will use the configured namespace
+
-### `helm_list`
+config
-List all the Helm releases in the current or provided namespace (or in all namespaces if specified)
+- **configuration_view** - Get the current Kubernetes configuration content as a kubeconfig YAML
+ - `minified` (`boolean`) - Return a minified version of the configuration. If set to true, keeps only the current-context and the relevant pieces of the configuration for that context. If set to false, all contexts, clusters, auth-infos, and users are returned in the configuration. (Optional, default true)
-**Parameters:**
-- `namespace` (`string`, optional)
- - Namespace to list the Helm releases from
- - If not provided, will use the configured namespace
-- `all_namespaces` (`boolean`, optional)
- - If `true`, will list Helm releases from all namespaces
- - If `false`, will list Helm releases from the specified namespace
+
-### `helm_uninstall`
+
-Uninstall a Helm release in the current or provided namespace with the provided name
+core
-**Parameters:**
-- `name` (`string`, required)
- - Name of the Helm release to uninstall
-- `namespace` (`string`, optional)
- - Namespace to uninstall the Helm release from
- - If not provided, will use the configured namespace
+- **events_list** - List all the Kubernetes events in the current cluster from all namespaces
+ - `namespace` (`string`) - Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces
-### `namespaces_list`
+- **namespaces_list** - List all the Kubernetes namespaces in the current cluster
-List all the Kubernetes namespaces in the current cluster
+- **projects_list** - List all the OpenShift projects in the current cluster
-**Parameters:** None
+- **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
+ - `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
-### `pods_delete`
+- **pods_list_in_namespace** - List all the Kubernetes pods in the specified namespace in the current cluster
+ - `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
+ - `namespace` (`string`) **(required)** - Namespace to list pods from
-Delete a Kubernetes Pod in the current or provided namespace with the provided name
+- **pods_get** - Get a Kubernetes Pod in the current or provided namespace with the provided name
+ - `name` (`string`) **(required)** - Name of the Pod
+ - `namespace` (`string`) - Namespace to get the Pod from
-**Parameters:**
-- `name` (`string`, required)
- - Name of the Pod to delete
-- `namespace` (`string`, required)
- - Namespace to delete the Pod from
+- **pods_delete** - Delete a Kubernetes Pod in the current or provided namespace with the provided name
+ - `name` (`string`) **(required)** - Name of the Pod to delete
+ - `namespace` (`string`) - Namespace to delete the Pod from
-### `pods_exec`
+- **pods_top** - List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace
+ - `all_namespaces` (`boolean`) - If true, list the resource consumption for all Pods in all namespaces. If false, list the resource consumption for Pods in the provided namespace or the current namespace
+ - `label_selector` (`string`) - Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label (Optional, only applicable when name is not provided)
+ - `name` (`string`) - Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)
+ - `namespace` (`string`) - Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)
-Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command
+- **pods_exec** - Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command
+ - `command` (`array`) **(required)** - Command to execute in the Pod container. The first item is the command to be run, and the rest are the arguments to that command. Example: ["ls", "-l", "/tmp"]
+ - `container` (`string`) - Name of the Pod container where the command will be executed (Optional)
+ - `name` (`string`) **(required)** - Name of the Pod where the command will be executed
+ - `namespace` (`string`) - Namespace of the Pod where the command will be executed
-**Parameters:**
-- `command` (`string[]`, required)
- - Command to execute in the Pod container
- - First item is the command, rest are arguments
- - Example: `["ls", "-l", "/tmp"]`
-- `name` (string, required)
- - Name of the Pod
-- `namespace` (string, required)
- - Namespace of the Pod
-- `container` (`string`, optional)
- - Name of the Pod container to get logs from
+- **pods_log** - Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name
+ - `container` (`string`) - Name of the Pod container to get the logs from (Optional)
+ - `name` (`string`) **(required)** - Name of the Pod to get the logs from
+ - `namespace` (`string`) - Namespace to get the Pod logs from
+ - `previous` (`boolean`) - Return previous terminated container logs (Optional)
-### `pods_get`
+- **pods_run** - Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name
+ - `image` (`string`) **(required)** - Container Image to run in the Pod
+ - `name` (`string`) - Name of the Pod (Optional, random name if not provided)
+ - `namespace` (`string`) - Namespace to run the Pod in
+ - `port` (`number`) - TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)
-Get a Kubernetes Pod in the current or provided namespace with the provided name
+- **resources_list** - List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector
+(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
+ - `apiVersion` (`string`) **(required)** - apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
+ - `kind` (`string`) **(required)** - kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)
+ - `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
+ - `namespace` (`string`) - Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces
-**Parameters:**
-- `name` (`string`, required)
- - Name of the Pod
-- `namespace` (`string`, required)
- - Namespace to get the Pod from
+- **resources_get** - Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name
+(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
+ - `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
+ - `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)
+ - `name` (`string`) **(required)** - Name of the resource
+ - `namespace` (`string`) - Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace
-### `pods_list`
+- **resources_create_or_update** - Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource
+(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
+ - `resource` (`string`) **(required)** - A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec
-List all the Kubernetes pods in the current cluster from all namespaces
+- **resources_delete** - Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name
+(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
+ - `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
+ - `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)
+ - `name` (`string`) **(required)** - Name of the resource
+ - `namespace` (`string`) - Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace
-**Parameters:**
-- `labelSelector` (`string`, optional)
- - Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label
+
-### `pods_list_in_namespace`
+
-List all the Kubernetes pods in the specified namespace in the current cluster
-
-**Parameters:**
-- `namespace` (`string`, required)
- - Namespace to list pods from
-- `labelSelector` (`string`, optional)
- - Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label
-
-### `pods_log`
-
-Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name
-
-**Parameters:**
-- `name` (`string`, required)
- - Name of the Pod to get logs from
-- `namespace` (`string`, required)
- - Namespace to get the Pod logs from
-- `container` (`string`, optional)
- - Name of the Pod container to get logs from
-- `previous` (`boolean`, optional)
- - Return previous terminated container logs
-
-### `pods_run`
-
-Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name
-
-**Parameters:**
-- `image` (`string`, required)
- - Container Image to run in the Pod
-- `namespace` (`string`, required)
- - Namespace to run the Pod in
-- `name` (`string`, optional)
- - Name of the Pod (random name if not provided)
-- `port` (`number`, optional)
- - TCP/IP port to expose from the Pod container
- - No port exposed if not provided
-
-### `pods_top`
-
-Lists the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace
-
-**Parameters:**
-- `all_namespaces` (`boolean`, optional, default: `true`)
- - If `true`, lists resource consumption for Pods in all namespaces
- - If `false`, lists resource consumption for Pods in the configured or provided namespace
-- `namespace` (`string`, optional)
- - Namespace to list the Pod resources from
- - If not provided, will list Pods from the configured namespace (in case all_namespaces is false)
-- `name` (`string`, optional)
- - Name of the Pod to get resource consumption from
- - If not provided, will list resource consumption for all Pods in the applicable namespace(s)
-- `label_selector` (`string`, optional)
- - Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label (Optional, only applicable when name is not provided)
-
-### `projects_list`
-
-List all the OpenShift projects in the current cluster
-
-### `resources_create_or_update`
-
-Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource
-
-**Parameters:**
-- `resource` (`string`, required)
- - A JSON or YAML containing a representation of the Kubernetes resource
- - Should include top-level fields such as apiVersion, kind, metadata, and spec
-
-**Common apiVersion and kind include:**
-- v1 Pod
-- v1 Service
-- v1 Node
-- apps/v1 Deployment
-- networking.k8s.io/v1 Ingress
-
-### `resources_delete`
-
-Delete a Kubernetes resource in the current cluster
-
-**Parameters:**
-- `apiVersion` (`string`, required)
- - apiVersion of the resource (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`)
-- `kind` (`string`, required)
- - kind of the resource (e.g., `Pod`, `Service`, `Deployment`, `Ingress`)
-- `name` (`string`, required)
- - Name of the resource
-- `namespace` (`string`, optional)
- - Namespace to delete the namespaced resource from
- - Ignored for cluster-scoped resources
- - Uses configured namespace if not provided
-
-### `resources_get`
-
-Get a Kubernetes resource in the current cluster
-
-**Parameters:**
-- `apiVersion` (`string`, required)
- - apiVersion of the resource (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`)
-- `kind` (`string`, required)
- - kind of the resource (e.g., `Pod`, `Service`, `Deployment`, `Ingress`)
-- `name` (`string`, required)
- - Name of the resource
-- `namespace` (`string`, optional)
- - Namespace to retrieve the namespaced resource from
- - Ignored for cluster-scoped resources
- - Uses configured namespace if not provided
-
-### `resources_list`
-
-List Kubernetes resources and objects in the current cluster
-
-**Parameters:**
-- `apiVersion` (`string`, required)
- - apiVersion of the resources (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`)
-- `kind` (`string`, required)
- - kind of the resources (e.g., `Pod`, `Service`, `Deployment`, `Ingress`)
-- `namespace` (`string`, optional)
- - Namespace to retrieve the namespaced resources from
- - Ignored for cluster-scoped resources
- - Lists resources from all namespaces if not provided
-- `labelSelector` (`string`, optional)
- - Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label.
+helm
+
+- **helm_install** - Install a Helm chart in the current or provided namespace
+ - `chart` (`string`) **(required)** - Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)
+ - `name` (`string`) - Name of the Helm release (Optional, random name if not provided)
+ - `namespace` (`string`) - Namespace to install the Helm chart in (Optional, current namespace if not provided)
+ - `values` (`object`) - Values to pass to the Helm chart (Optional)
+
+- **helm_list** - List all the Helm releases in the current or provided namespace (or in all namespaces if specified)
+ - `all_namespaces` (`boolean`) - If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)
+ - `namespace` (`string`) - Namespace to list Helm releases from (Optional, all namespaces if not provided)
+
+- **helm_uninstall** - Uninstall a Helm release in the current or provided namespace
+ - `name` (`string`) **(required)** - Name of the Helm release to uninstall
+ - `namespace` (`string`) - Namespace to uninstall the Helm release from (Optional, current namespace if not provided)
+
+
+
+
+
## 🧑💻 Development
diff --git a/internal/test/test.go b/internal/test/test.go
new file mode 100644
index 00000000..b11b9570
--- /dev/null
+++ b/internal/test/test.go
@@ -0,0 +1,8 @@
+package test
+
+func Must[T any](v T, err error) T {
+ if err != nil {
+ panic(err)
+ }
+ return v
+}
diff --git a/internal/tools/update-readme/main.go b/internal/tools/update-readme/main.go
new file mode 100644
index 00000000..837b27d1
--- /dev/null
+++ b/internal/tools/update-readme/main.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "maps"
+ "os"
+ "slices"
+ "strings"
+
+ internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
+ "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
+
+ _ "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"
+)
+
+type OpenShift struct{}
+
+func (o *OpenShift) IsOpenShift(ctx context.Context) bool {
+ return true
+}
+
+var _ internalk8s.Openshift = (*OpenShift)(nil)
+
+func main() {
+ readme, err := os.ReadFile(os.Args[1])
+ if err != nil {
+ panic(err)
+ }
+ // Available Toolsets
+ toolsetsList := toolsets.Toolsets()
+ maxNameLen, maxDescLen := len("Toolset"), len("Description")
+ for _, toolset := range toolsetsList {
+ nameLen := len(toolset.GetName())
+ descLen := len(toolset.GetDescription())
+ if nameLen > maxNameLen {
+ maxNameLen = nameLen
+ }
+ if descLen > maxDescLen {
+ maxDescLen = descLen
+ }
+ }
+ availableToolsets := strings.Builder{}
+ availableToolsets.WriteString(fmt.Sprintf("| %-*s | %-*s |\n", maxNameLen, "Toolset", maxDescLen, "Description"))
+ availableToolsets.WriteString(fmt.Sprintf("|-%s-|-%s-|\n", strings.Repeat("-", maxNameLen), strings.Repeat("-", maxDescLen)))
+ for _, toolset := range toolsetsList {
+ availableToolsets.WriteString(fmt.Sprintf("| %-*s | %-*s |\n", maxNameLen, toolset.GetName(), maxDescLen, toolset.GetDescription()))
+ }
+ updated := replaceBetweenMarkers(
+ string(readme),
+ "",
+ "",
+ availableToolsets.String(),
+ )
+
+ // Available Toolset Tools
+ toolsetTools := strings.Builder{}
+ for _, toolset := range toolsetsList {
+ toolsetTools.WriteString("\n\n" + toolset.GetName() + "
\n\n")
+ tools := toolset.GetTools(&OpenShift{})
+ for _, tool := range tools {
+ toolsetTools.WriteString(fmt.Sprintf("- **%s** - %s\n", tool.Tool.Name, tool.Tool.Description))
+ for _, propName := range slices.Sorted(maps.Keys(tool.Tool.InputSchema.Properties)) {
+ property := tool.Tool.InputSchema.Properties[propName]
+ toolsetTools.WriteString(fmt.Sprintf(" - `%s` (`%s`)", propName, property.Type))
+ if slices.Contains(tool.Tool.InputSchema.Required, propName) {
+ toolsetTools.WriteString(" **(required)**")
+ }
+ toolsetTools.WriteString(fmt.Sprintf(" - %s\n", property.Description))
+ }
+ toolsetTools.WriteString("\n")
+ }
+ toolsetTools.WriteString(" \n\n")
+ }
+ updated = replaceBetweenMarkers(
+ updated,
+ "",
+ "",
+ toolsetTools.String(),
+ )
+
+ if err := os.WriteFile(os.Args[1], []byte(updated), 0o644); err != nil {
+ panic(err)
+ }
+}
+
+func replaceBetweenMarkers(content, startMarker, endMarker, replacement string) string {
+ startIdx := strings.Index(content, startMarker)
+ if startIdx == -1 {
+ return content
+ }
+ endIdx := strings.Index(content, endMarker)
+ if endIdx == -1 || endIdx <= startIdx {
+ return content
+ }
+ return content[:startIdx+len(startMarker)] + "\n\n" + replacement + "\n" + content[endIdx:]
+}
diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go
index 2317de34..3ccb888f 100644
--- a/pkg/api/toolsets.go
+++ b/pkg/api/toolsets.go
@@ -20,7 +20,7 @@ type Toolset interface {
// Examples: "core", "metrics", "helm"
GetName() string
GetDescription() string
- GetTools(k *internalk8s.Manager) []ServerTool
+ GetTools(o internalk8s.Openshift) []ServerTool
}
type ToolCallRequest interface {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 26e007d1..177055f6 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -20,6 +20,7 @@ type StaticConfig struct {
ReadOnly bool `toml:"read_only,omitempty"`
// When true, disable tools annotated with destructiveHint=true
DisableDestructive bool `toml:"disable_destructive,omitempty"`
+ Toolsets []string `toml:"toolsets,omitempty"`
EnabledTools []string `toml:"enabled_tools,omitempty"`
DisabledTools []string `toml:"disabled_tools,omitempty"`
@@ -50,22 +51,32 @@ type StaticConfig struct {
ServerURL string `toml:"server_url,omitempty"`
}
+func Default() *StaticConfig {
+ return &StaticConfig{
+ ListOutput: "table",
+ Toolsets: []string{"core", "config", "helm"},
+ }
+}
+
type GroupVersionKind struct {
Group string `toml:"group"`
Version string `toml:"version"`
Kind string `toml:"kind,omitempty"`
}
-// ReadConfig reads the toml file and returns the StaticConfig.
-func ReadConfig(configPath string) (*StaticConfig, error) {
+// Read reads the toml file and returns the StaticConfig.
+func Read(configPath string) (*StaticConfig, error) {
configData, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
+ return ReadToml(configData)
+}
- var config *StaticConfig
- err = toml.Unmarshal(configData, &config)
- if err != nil {
+// ReadToml reads the toml data and returns the StaticConfig.
+func ReadToml(configData []byte) (*StaticConfig, error) {
+ config := Default()
+ if err := toml.Unmarshal(configData, config); err != nil {
return nil, err
}
return config, nil
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 1f52361d..b498548d 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -1,156 +1,175 @@
package config
import (
+ "errors"
+ "io/fs"
"os"
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/suite"
)
-func TestReadConfigMissingFile(t *testing.T) {
- config, err := ReadConfig("non-existent-config.toml")
- t.Run("returns error for missing file", func(t *testing.T) {
- if err == nil {
- t.Fatal("Expected error for missing file, got nil")
- }
- if config != nil {
- t.Fatalf("Expected nil config for missing file, got %v", config)
- }
+type ConfigSuite struct {
+ suite.Suite
+}
+
+func (s *ConfigSuite) TestReadConfigMissingFile() {
+ config, err := Read("non-existent-config.toml")
+ s.Run("returns error for missing file", func() {
+ s.Require().NotNil(err, "Expected error for missing file, got nil")
+ s.True(errors.Is(err, fs.ErrNotExist), "Expected ErrNotExist, got %v", err)
+ })
+ s.Run("returns nil config for missing file", func() {
+ s.Nil(config, "Expected nil config for missing file")
})
}
-func TestReadConfigInvalid(t *testing.T) {
- invalidConfigPath := writeConfig(t, `
-[[denied_resources]]
-group = "apps"
-version = "v1"
-kind = "Deployment"
-[[denied_resources]]
-group = "rbac.authorization.k8s.io"
-version = "v1"
-kind = "Role
-`)
+func (s *ConfigSuite) TestReadConfigInvalid() {
+ invalidConfigPath := s.writeConfig(`
+ [[denied_resources]]
+ group = "apps"
+ version = "v1"
+ kind = "Deployment"
+ [[denied_resources]]
+ group = "rbac.authorization.k8s.io"
+ version = "v1"
+ kind = "Role
+ `)
- config, err := ReadConfig(invalidConfigPath)
- t.Run("returns error for invalid file", func(t *testing.T) {
- if err == nil {
- t.Fatal("Expected error for invalid file, got nil")
- }
- if config != nil {
- t.Fatalf("Expected nil config for invalid file, got %v", config)
- }
+ config, err := Read(invalidConfigPath)
+ s.Run("returns error for invalid file", func() {
+ s.Require().NotNil(err, "Expected error for invalid file, got nil")
})
- t.Run("error message contains toml error with line number", func(t *testing.T) {
+ s.Run("error message contains toml error with line number", func() {
expectedError := "toml: line 9"
- if err != nil && !strings.HasPrefix(err.Error(), expectedError) {
- t.Fatalf("Expected error message '%s' to contain line number, got %v", expectedError, err)
- }
+ s.Truef(strings.HasPrefix(err.Error(), expectedError), "Expected error message to contain line number, got %v", err)
+ })
+ s.Run("returns nil config for invalid file", func() {
+ s.Nil(config, "Expected nil config for missing file")
})
}
-func TestReadConfigValid(t *testing.T) {
- validConfigPath := writeConfig(t, `
-log_level = 1
-port = "9999"
-sse_base_url = "https://example.com"
-kubeconfig = "./path/to/config"
-list_output = "yaml"
-read_only = true
-disable_destructive = true
+func (s *ConfigSuite) TestReadConfigValid() {
+ validConfigPath := s.writeConfig(`
+ log_level = 1
+ port = "9999"
+ sse_base_url = "https://example.com"
+ kubeconfig = "./path/to/config"
+ list_output = "yaml"
+ read_only = true
+ disable_destructive = true
-denied_resources = [
- {group = "apps", version = "v1", kind = "Deployment"},
- {group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
-]
+ toolsets = ["core", "config", "helm", "metrics"]
+
+ enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
+ disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
-enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
-disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
-`)
+ denied_resources = [
+ {group = "apps", version = "v1", kind = "Deployment"},
+ {group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
+ ]
+
+ `)
- config, err := ReadConfig(validConfigPath)
- t.Run("reads and unmarshalls file", func(t *testing.T) {
- if err != nil {
- t.Fatalf("ReadConfig returned an error for a valid file: %v", err)
- }
- if config == nil {
- t.Fatal("ReadConfig returned a nil config for a valid file")
- }
+ config, err := Read(validConfigPath)
+ s.Require().NotNil(config)
+ s.Run("reads and unmarshalls file", func() {
+ s.Nil(err, "Expected nil error for valid file")
+ s.Require().NotNil(config, "Expected non-nil config for valid file")
})
- t.Run("denied resources are parsed correctly", func(t *testing.T) {
- if len(config.DeniedResources) != 2 {
- t.Fatalf("Expected 2 denied resources, got %d", len(config.DeniedResources))
- }
- if config.DeniedResources[0].Group != "apps" ||
- config.DeniedResources[0].Version != "v1" ||
- config.DeniedResources[0].Kind != "Deployment" {
- t.Errorf("Unexpected denied resources: %v", config.DeniedResources[0])
- }
+ s.Run("log_level parsed correctly", func() {
+ s.Equalf(1, config.LogLevel, "Expected LogLevel to be 1, got %d", config.LogLevel)
})
- t.Run("log_level parsed correctly", func(t *testing.T) {
- if config.LogLevel != 1 {
- t.Fatalf("Unexpected log level: %v", config.LogLevel)
- }
+ s.Run("port parsed correctly", func() {
+ s.Equalf("9999", config.Port, "Expected Port to be 9999, got %s", config.Port)
})
- t.Run("port parsed correctly", func(t *testing.T) {
- if config.Port != "9999" {
- t.Fatalf("Unexpected port value: %v", config.Port)
- }
+ s.Run("sse_base_url parsed correctly", func() {
+ s.Equalf("https://example.com", config.SSEBaseURL, "Expected SSEBaseURL to be https://example.com, got %s", config.SSEBaseURL)
})
- t.Run("sse_base_url parsed correctly", func(t *testing.T) {
- if config.SSEBaseURL != "https://example.com" {
- t.Fatalf("Unexpected sse_base_url value: %v", config.SSEBaseURL)
- }
+ s.Run("kubeconfig parsed correctly", func() {
+ s.Equalf("./path/to/config", config.KubeConfig, "Expected KubeConfig to be ./path/to/config, got %s", config.KubeConfig)
})
- t.Run("kubeconfig parsed correctly", func(t *testing.T) {
- if config.KubeConfig != "./path/to/config" {
- t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig)
- }
+ s.Run("list_output parsed correctly", func() {
+ s.Equalf("yaml", config.ListOutput, "Expected ListOutput to be yaml, got %s", config.ListOutput)
+ })
+ s.Run("read_only parsed correctly", func() {
+ s.Truef(config.ReadOnly, "Expected ReadOnly to be true, got %v", config.ReadOnly)
+ })
+ s.Run("disable_destructive parsed correctly", func() {
+ s.Truef(config.DisableDestructive, "Expected DisableDestructive to be true, got %v", config.DisableDestructive)
})
- t.Run("list_output parsed correctly", func(t *testing.T) {
- if config.ListOutput != "yaml" {
- t.Fatalf("Unexpected list_output value: %v", config.ListOutput)
+ s.Run("toolsets", func() {
+ s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
+ for _, toolset := range []string{"core", "config", "helm", "metrics"} {
+ s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
}
})
- t.Run("read_only parsed correctly", func(t *testing.T) {
- if !config.ReadOnly {
- t.Fatalf("Unexpected read-only mode: %v", config.ReadOnly)
+ s.Run("enabled_tools", func() {
+ s.Require().Lenf(config.EnabledTools, 8, "Expected 8 enabled tools, got %d", len(config.EnabledTools))
+ for _, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
+ s.Containsf(config.EnabledTools, tool, "Expected enabled tools to contain %s", tool)
}
})
- t.Run("disable_destructive parsed correctly", func(t *testing.T) {
- if !config.DisableDestructive {
- t.Fatalf("Unexpected disable destructive: %v", config.DisableDestructive)
+ s.Run("disabled_tools", func() {
+ s.Require().Lenf(config.DisabledTools, 5, "Expected 5 disabled tools, got %d", len(config.DisabledTools))
+ for _, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
+ s.Containsf(config.DisabledTools, tool, "Expected disabled tools to contain %s", tool)
}
})
- t.Run("enabled_tools parsed correctly", func(t *testing.T) {
- if len(config.EnabledTools) != 8 {
- t.Fatalf("Unexpected enabled tools: %v", config.EnabledTools)
+ s.Run("denied_resources", func() {
+ s.Require().Lenf(config.DeniedResources, 2, "Expected 2 denied resources, got %d", len(config.DeniedResources))
+ s.Run("contains apps/v1/Deployment", func() {
+ s.Contains(config.DeniedResources, GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ "Expected denied resources to contain apps/v1/Deployment")
+ })
+ s.Run("contains rbac.authorization.k8s.io/v1/Role", func() {
+ s.Contains(config.DeniedResources, GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "Role"},
+ "Expected denied resources to contain rbac.authorization.k8s.io/v1/Role")
+ })
+ })
+}
- }
- for i, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
- if config.EnabledTools[i] != tool {
- t.Errorf("Expected enabled tool %d to be %s, got %s", i, tool, config.EnabledTools[i])
- }
- }
+func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
+ validConfigPath := s.writeConfig(`
+ port = "1337"
+ `)
+
+ config, err := Read(validConfigPath)
+ s.Require().NotNil(config)
+ s.Run("reads and unmarshalls file", func() {
+ s.Nil(err, "Expected nil error for valid file")
+ s.Require().NotNil(config, "Expected non-nil config for valid file")
})
- t.Run("disabled_tools parsed correctly", func(t *testing.T) {
- if len(config.DisabledTools) != 5 {
- t.Fatalf("Unexpected disabled tools: %v", config.DisabledTools)
- }
- for i, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
- if config.DisabledTools[i] != tool {
- t.Errorf("Expected disabled tool %d to be %s, got %s", i, tool, config.DisabledTools[i])
- }
+ s.Run("log_level defaulted correctly", func() {
+ s.Equalf(0, config.LogLevel, "Expected LogLevel to be 0, got %d", config.LogLevel)
+ })
+ s.Run("port parsed correctly", func() {
+ s.Equalf("1337", config.Port, "Expected Port to be 9999, got %s", config.Port)
+ })
+ s.Run("list_output defaulted correctly", func() {
+ s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
+ })
+ s.Run("toolsets defaulted correctly", func() {
+ s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
+ for _, toolset := range []string{"core", "config", "helm"} {
+ s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
}
})
}
-func writeConfig(t *testing.T, content string) string {
- t.Helper()
- tempDir := t.TempDir()
+func (s *ConfigSuite) writeConfig(content string) string {
+ s.T().Helper()
+ tempDir := s.T().TempDir()
path := filepath.Join(tempDir, "config.toml")
err := os.WriteFile(path, []byte(content), 0644)
if err != nil {
- t.Fatalf("Failed to write config file %s: %v", path, err)
+ s.T().Fatalf("Failed to write config file %s: %v", path, err)
}
return path
}
+
+func TestConfig(t *testing.T) {
+ suite.Run(t, new(ConfigSuite))
+}
diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go
index 3ab5fc39..97dc2c9f 100644
--- a/pkg/http/http_test.go
+++ b/pkg/http/http_test.go
@@ -21,7 +21,6 @@ import (
"time"
"github.com/containers/kubernetes-mcp-server/internal/test"
- "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"golang.org/x/sync/errgroup"
@@ -63,7 +62,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
t.Helper()
http.DefaultClient.Timeout = 10 * time.Second
if c.StaticConfig == nil {
- c.StaticConfig = &config.StaticConfig{}
+ c.StaticConfig = config.Default()
}
c.mockServer = test.NewMockServer()
// Fake Kubernetes configuration
@@ -87,10 +86,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
t.Fatalf("Failed to close random port listener: %v", randomPortErr)
}
c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
- mcpServer, err := mcp.NewServer(mcp.Configuration{
- Toolset: toolsets.Toolsets()[0],
- StaticConfig: c.StaticConfig,
- })
+ mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: c.StaticConfig})
if err != nil {
t.Fatalf("Failed to create MCP server: %v", err)
}
diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go
index b2fa2e7a..4709d9f6 100644
--- a/pkg/kubernetes-mcp-server/cmd/root.go
+++ b/pkg/kubernetes-mcp-server/cmd/root.go
@@ -13,7 +13,6 @@ import (
"strconv"
"strings"
- "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/cobra"
@@ -27,6 +26,7 @@ import (
internalhttp "github.com/containers/kubernetes-mcp-server/pkg/http"
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
"github.com/containers/kubernetes-mcp-server/pkg/output"
+ "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/containers/kubernetes-mcp-server/pkg/version"
)
@@ -58,7 +58,7 @@ type MCPServerOptions struct {
HttpPort int
SSEBaseUrl string
Kubeconfig string
- Toolset string
+ Toolsets []string
ListOutput string
ReadOnly bool
DisableDestructive bool
@@ -78,9 +78,7 @@ type MCPServerOptions struct {
func NewMCPServerOptions(streams genericiooptions.IOStreams) *MCPServerOptions {
return &MCPServerOptions{
IOStreams: streams,
- Toolset: "full",
- ListOutput: "table",
- StaticConfig: &config.StaticConfig{},
+ StaticConfig: config.Default(),
}
}
@@ -116,8 +114,8 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
cmd.Flags().StringVar(&o.Port, "port", o.Port, "Start a streamable HTTP and SSE HTTP server on the specified port (e.g. 8080)")
cmd.Flags().StringVar(&o.SSEBaseUrl, "sse-base-url", o.SSEBaseUrl, "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
cmd.Flags().StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, "Path to the kubeconfig file to use for authentication")
- cmd.Flags().StringVar(&o.Toolset, "toolset", o.Toolset, "MCP toolset to use (one of: "+strings.Join(toolsets.ToolsetNames(), ", ")+")")
- cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to table.")
+ cmd.Flags().StringSliceVar(&o.Toolsets, "toolsets", o.Toolsets, "Comma-separated list of MCP toolsets to use (available toolsets: "+strings.Join(toolsets.ToolsetNames(), ", ")+"). Defaults to "+strings.Join(o.StaticConfig.Toolsets, ", ")+".")
+ cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to "+o.StaticConfig.ListOutput+".")
cmd.Flags().BoolVar(&o.ReadOnly, "read-only", o.ReadOnly, "If true, only tools annotated with readOnlyHint=true are exposed")
cmd.Flags().BoolVar(&o.DisableDestructive, "disable-destructive", o.DisableDestructive, "If true, tools annotated with destructiveHint=true are disabled")
cmd.Flags().BoolVar(&o.RequireOAuth, "require-oauth", o.RequireOAuth, "If true, requires OAuth authorization as defined in the Model Context Protocol (MCP) specification. This flag is ignored if transport type is stdio")
@@ -138,7 +136,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
func (m *MCPServerOptions) Complete(cmd *cobra.Command) error {
if m.ConfigPath != "" {
- cnf, err := config.ReadConfig(m.ConfigPath)
+ cnf, err := config.Read(m.ConfigPath)
if err != nil {
return err
}
@@ -174,7 +172,7 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
if cmd.Flag("kubeconfig").Changed {
m.StaticConfig.KubeConfig = m.Kubeconfig
}
- if cmd.Flag("list-output").Changed || m.StaticConfig.ListOutput == "" {
+ if cmd.Flag("list-output").Changed {
m.StaticConfig.ListOutput = m.ListOutput
}
if cmd.Flag("read-only").Changed {
@@ -183,6 +181,9 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
if cmd.Flag("disable-destructive").Changed {
m.StaticConfig.DisableDestructive = m.DisableDestructive
}
+ if cmd.Flag("toolsets").Changed {
+ m.StaticConfig.Toolsets = m.Toolsets
+ }
if cmd.Flag("require-oauth").Changed {
m.StaticConfig.RequireOAuth = m.RequireOAuth
}
@@ -219,6 +220,12 @@ func (m *MCPServerOptions) Validate() error {
if m.Port != "" && (m.SSEPort > 0 || m.HttpPort > 0) {
return fmt.Errorf("--port is mutually exclusive with deprecated --http-port and --sse-port flags")
}
+ if output.FromString(m.StaticConfig.ListOutput) == nil {
+ return fmt.Errorf("invalid output name: %s, valid names are: %s", m.StaticConfig.ListOutput, strings.Join(output.Names, ", "))
+ }
+ if err := toolsets.Validate(m.StaticConfig.Toolsets); err != nil {
+ return err
+ }
if !m.StaticConfig.RequireOAuth && (m.StaticConfig.ValidateToken || m.StaticConfig.OAuthAudience != "" || m.StaticConfig.AuthorizationURL != "" || m.StaticConfig.ServerURL != "" || m.StaticConfig.CertificateAuthority != "") {
return fmt.Errorf("validate-token, oauth-audience, authorization-url, server-url and certificate-authority are only valid if require-oauth is enabled. Missing --port may implicitly set require-oauth to false")
}
@@ -238,18 +245,10 @@ func (m *MCPServerOptions) Validate() error {
}
func (m *MCPServerOptions) Run() error {
- toolset := toolsets.ToolsetFromString(m.Toolset)
- if toolset == nil {
- return fmt.Errorf("invalid toolset name: %s, valid names are: %s", m.Toolset, strings.Join(toolsets.ToolsetNames(), ", "))
- }
- listOutput := output.FromString(m.StaticConfig.ListOutput)
- if listOutput == nil {
- return fmt.Errorf("invalid output name: %s, valid names are: %s", m.StaticConfig.ListOutput, strings.Join(output.Names, ", "))
- }
klog.V(1).Info("Starting kubernetes-mcp-server")
klog.V(1).Infof(" - Config: %s", m.ConfigPath)
- klog.V(1).Infof(" - Toolset: %s", toolset.GetName())
- klog.V(1).Infof(" - ListOutput: %s", listOutput.GetName())
+ klog.V(1).Infof(" - Toolsets: %s", strings.Join(m.StaticConfig.Toolsets, ", "))
+ klog.V(1).Infof(" - ListOutput: %s", m.StaticConfig.ListOutput)
klog.V(1).Infof(" - Read-only mode: %t", m.StaticConfig.ReadOnly)
klog.V(1).Infof(" - Disable destructive tools: %t", m.StaticConfig.DisableDestructive)
@@ -291,11 +290,7 @@ func (m *MCPServerOptions) Run() error {
oidcProvider = provider
}
- mcpServer, err := mcp.NewServer(mcp.Configuration{
- Toolset: toolset,
- ListOutput: listOutput,
- StaticConfig: m.StaticConfig,
- })
+ mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: m.StaticConfig})
if err != nil {
return fmt.Errorf("failed to initialize MCP server: %w", err)
}
diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go
index 455e0f29..c9ac8df5 100644
--- a/pkg/kubernetes-mcp-server/cmd/root_test.go
+++ b/pkg/kubernetes-mcp-server/cmd/root_test.go
@@ -129,13 +129,13 @@ func TestConfig(t *testing.T) {
})
}
-func TestToolset(t *testing.T) {
+func TestToolsets(t *testing.T) {
t.Run("available", func(t *testing.T) {
ioStreams, _ := testStream()
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, "MCP toolset to use (one of: full) ") {
+ if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
t.Fatalf("Expected all available toolsets, got %s %v", o, err)
}
})
@@ -143,16 +143,16 @@ func TestToolset(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
- if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolset: full") {
- t.Fatalf("Expected toolset 'full', got %s %v", out, err)
+ if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
+ t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
}
})
- t.Run("set with --toolset", func(t *testing.T) {
+ t.Run("set with --toolsets", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
- rootCmd.SetArgs([]string{"--version", "--log-level=1", "--toolset", "full"}) // TODO: change by some non-default toolset
+ rootCmd.SetArgs([]string{"--version", "--log-level=1", "--toolsets", "helm,config"})
_ = rootCmd.Execute()
- expected := `(?m)\" - Toolset\: full\"`
+ expected := `(?m)\" - Toolsets\: helm, config\"`
if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
t.Fatalf("Expected toolset to be %s, got %s %v", expected, out.String(), err)
}
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index db0ac542..a24d80d9 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -53,11 +53,12 @@ type Manager struct {
CloseWatchKubeConfig CloseWatchKubeConfig
}
+var _ helm.Kubernetes = (*Manager)(nil)
+var _ Openshift = (*Manager)(nil)
+
var Scheme = scheme.Scheme
var ParameterCodec = runtime.NewParameterCodec(Scheme)
-var _ helm.Kubernetes = &Manager{}
-
func NewManager(config *config.StaticConfig) (*Manager, error) {
k8s := &Manager{
staticConfig: config,
diff --git a/pkg/kubernetes/openshift.go b/pkg/kubernetes/openshift.go
index e94f9875..7cb3e273 100644
--- a/pkg/kubernetes/openshift.go
+++ b/pkg/kubernetes/openshift.go
@@ -6,6 +6,10 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
)
+type Openshift interface {
+ IsOpenShift(context.Context) bool
+}
+
func (m *Manager) IsOpenShift(_ context.Context) bool {
// This method should be fast and not block (it's called at startup)
_, err := m.discoveryClient.ServerResourcesForGroupVersion(schema.GroupVersion{
diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go
index 439cb7de..702045a7 100644
--- a/pkg/mcp/common_test.go
+++ b/pkg/mcp/common_test.go
@@ -42,10 +42,8 @@ import (
"sigs.k8s.io/controller-runtime/tools/setup-envtest/versions"
"sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows"
- "github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
- "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
)
// envTest has an expensive setup, so we only want to do it once per entire test run.
@@ -106,7 +104,7 @@ func TestMain(m *testing.M) {
}
type mcpContext struct {
- toolset api.Toolset
+ toolsets []string
listOutput output.Output
logLevel int
@@ -129,17 +127,17 @@ func (c *mcpContext) beforeEach(t *testing.T) {
c.ctx, c.cancel = context.WithCancel(t.Context())
c.tempDir = t.TempDir()
c.withKubeConfig(nil)
- if c.toolset == nil {
- c.toolset = &full.Full{}
+ if c.staticConfig == nil {
+ c.staticConfig = config.Default()
+ // Default to use YAML output for lists (previously the default)
+ c.staticConfig.ListOutput = "yaml"
}
- if c.listOutput == nil {
- c.listOutput = output.Yaml
+ if c.toolsets != nil {
+ c.staticConfig.Toolsets = c.toolsets
+
}
- if c.staticConfig == nil {
- c.staticConfig = &config.StaticConfig{
- ReadOnly: false,
- DisableDestructive: false,
- }
+ if c.listOutput != nil {
+ c.staticConfig.ListOutput = c.listOutput.GetName()
}
if c.before != nil {
c.before(c)
@@ -151,11 +149,7 @@ func (c *mcpContext) beforeEach(t *testing.T) {
_ = flags.Set("v", strconv.Itoa(c.logLevel))
klog.SetLogger(textlogger.NewLogger(textlogger.NewConfig(textlogger.Verbosity(c.logLevel), textlogger.Output(&c.logBuffer))))
// MCP Server
- if c.mcpServer, err = NewServer(Configuration{
- Toolset: c.toolset,
- ListOutput: c.listOutput,
- StaticConfig: c.staticConfig,
- }); err != nil {
+ if c.mcpServer, err = NewServer(Configuration{StaticConfig: c.staticConfig}); err != nil {
t.Fatal(err)
return
}
@@ -191,7 +185,7 @@ func (c *mcpContext) afterEach() {
}
func testCase(t *testing.T, test func(c *mcpContext)) {
- testCaseWithContext(t, &mcpContext{toolset: &full.Full{}}, test)
+ testCaseWithContext(t, &mcpContext{}, test)
}
func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpContext)) {
diff --git a/pkg/mcp/events_test.go b/pkg/mcp/events_test.go
index f6609ed4..30123af7 100644
--- a/pkg/mcp/events_test.go
+++ b/pkg/mcp/events_test.go
@@ -1,11 +1,14 @@
package mcp
import (
- "github.com/containers/kubernetes-mcp-server/pkg/config"
+ "testing"
+
"github.com/mark3labs/mcp-go/mcp"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "testing"
+
+ "github.com/containers/kubernetes-mcp-server/internal/test"
+ "github.com/containers/kubernetes-mcp-server/pkg/config"
)
func TestEventsList(t *testing.T) {
@@ -96,7 +99,9 @@ func TestEventsList(t *testing.T) {
}
func TestEventsListDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Event"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Event" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
eventList, _ := c.callTool("events_list", map[string]interface{}{})
diff --git a/pkg/mcp/helm_test.go b/pkg/mcp/helm_test.go
index ac837640..b5c777a6 100644
--- a/pkg/mcp/helm_test.go
+++ b/pkg/mcp/helm_test.go
@@ -8,13 +8,15 @@ import (
"strings"
"testing"
- "github.com/containers/kubernetes-mcp-server/pkg/config"
+ "github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/mark3labs/mcp-go/mcp"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/yaml"
+
+ "github.com/containers/kubernetes-mcp-server/pkg/config"
)
func TestHelmInstall(t *testing.T) {
@@ -60,7 +62,9 @@ func TestHelmInstall(t *testing.T) {
}
func TestHelmInstallDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Secret" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
_, file, _, _ := runtime.Caller(0)
@@ -226,7 +230,9 @@ func TestHelmUninstall(t *testing.T) {
}
func TestHelmUninstallDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Secret" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
diff --git a/pkg/mcp/m3labs.go b/pkg/mcp/m3labs.go
index 5dfe13a0..999d8e2e 100644
--- a/pkg/mcp/m3labs.go
+++ b/pkg/mcp/m3labs.go
@@ -41,7 +41,7 @@ func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.S
Context: ctx,
Kubernetes: k,
ToolCallRequest: request,
- ListOutput: s.configuration.ListOutput,
+ ListOutput: s.configuration.ListOutput(),
})
if err != nil {
return nil, err
diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go
index cb19ad3c..c8eca794 100644
--- a/pkg/mcp/mcp.go
+++ b/pkg/mcp/mcp.go
@@ -7,16 +7,17 @@ import (
"net/http"
"slices"
- "github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
authenticationapiv1 "k8s.io/api/authentication/v1"
"k8s.io/klog/v2"
"k8s.io/utils/ptr"
+ "github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/config"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
+ "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/containers/kubernetes-mcp-server/pkg/version"
)
@@ -25,10 +26,25 @@ type ContextKey string
const TokenScopesContextKey = ContextKey("TokenScopesContextKey")
type Configuration struct {
- Toolset api.Toolset
- ListOutput output.Output
+ *config.StaticConfig
+ listOutput output.Output
+ toolsets []api.Toolset
+}
- StaticConfig *config.StaticConfig
+func (c *Configuration) Toolsets() []api.Toolset {
+ if c.toolsets == nil {
+ for _, toolset := range c.StaticConfig.Toolsets {
+ c.toolsets = append(c.toolsets, toolsets.ToolsetFromString(toolset))
+ }
+ }
+ return c.toolsets
+}
+
+func (c *Configuration) ListOutput() output.Output {
+ if c.listOutput == nil {
+ c.listOutput = output.FromString(c.StaticConfig.ListOutput)
+ }
+ return c.listOutput
}
func (c *Configuration) isToolApplicable(tool api.ServerTool) bool {
@@ -90,12 +106,14 @@ func (s *Server) reloadKubernetesClient() error {
}
s.k = k
applicableTools := make([]api.ServerTool, 0)
- for _, tool := range s.configuration.Toolset.GetTools(s.k) {
- if !s.configuration.isToolApplicable(tool) {
- continue
+ for _, toolset := range s.configuration.Toolsets() {
+ for _, tool := range toolset.GetTools(s.k) {
+ if !s.configuration.isToolApplicable(tool) {
+ continue
+ }
+ applicableTools = append(applicableTools, tool)
+ s.enabledTools = append(s.enabledTools, tool.Tool.Name)
}
- applicableTools = append(applicableTools, tool)
- s.enabledTools = append(s.enabledTools, tool.Tool.Name)
}
m3labsServerTools, err := ServerToolToM3LabsServerTool(s, applicableTools)
if err != nil {
diff --git a/pkg/mcp/mcp_tools_test.go b/pkg/mcp/mcp_tools_test.go
index 2a56ffee..196b93e2 100644
--- a/pkg/mcp/mcp_tools_test.go
+++ b/pkg/mcp/mcp_tools_test.go
@@ -9,6 +9,7 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/utils/ptr"
+ "github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
)
@@ -74,11 +75,10 @@ func TestDisableDestructive(t *testing.T) {
}
func TestEnabledTools(t *testing.T) {
- testCaseWithContext(t, &mcpContext{
- staticConfig: &config.StaticConfig{
- EnabledTools: []string{"namespaces_list", "events_list"},
- },
- }, func(c *mcpContext) {
+ enabledToolsServer := test.Must(config.ReadToml([]byte(`
+ enabled_tools = [ "namespaces_list", "events_list" ]
+ `)))
+ testCaseWithContext(t, &mcpContext{staticConfig: enabledToolsServer}, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
if err != nil {
diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go
index 5a473b19..3295d72b 100644
--- a/pkg/mcp/modules.go
+++ b/pkg/mcp/modules.go
@@ -1,3 +1,5 @@
package mcp
-import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
+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"
diff --git a/pkg/mcp/namespaces_test.go b/pkg/mcp/namespaces_test.go
index fb45568c..e7c71709 100644
--- a/pkg/mcp/namespaces_test.go
+++ b/pkg/mcp/namespaces_test.go
@@ -5,14 +5,16 @@ import (
"slices"
"testing"
- "github.com/containers/kubernetes-mcp-server/pkg/config"
- "github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/mcp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"sigs.k8s.io/yaml"
+
+ "github.com/containers/kubernetes-mcp-server/internal/test"
+ "github.com/containers/kubernetes-mcp-server/pkg/config"
+ "github.com/containers/kubernetes-mcp-server/pkg/output"
)
func TestNamespacesList(t *testing.T) {
@@ -51,7 +53,9 @@ func TestNamespacesList(t *testing.T) {
}
func TestNamespacesListDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Namespace"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Namespace" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
namespacesList, _ := c.callTool("namespaces_list", map[string]interface{}{})
@@ -156,7 +160,9 @@ func TestProjectsListInOpenShift(t *testing.T) {
}
func TestProjectsListInOpenShiftDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Group: "project.openshift.io", Version: "v1"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { group = "project.openshift.io", version = "v1" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer, before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
c.withEnvTest()
projectsList, _ := c.callTool("projects_list", map[string]interface{}{})
diff --git a/pkg/mcp/pods_exec_test.go b/pkg/mcp/pods_exec_test.go
index 58c8702b..dac6883c 100644
--- a/pkg/mcp/pods_exec_test.go
+++ b/pkg/mcp/pods_exec_test.go
@@ -7,11 +7,12 @@ import (
"strings"
"testing"
- "github.com/containers/kubernetes-mcp-server/internal/test"
- "github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/mark3labs/mcp-go/mcp"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/containers/kubernetes-mcp-server/internal/test"
+ "github.com/containers/kubernetes-mcp-server/pkg/config"
)
func TestPodsExec(t *testing.T) {
@@ -104,7 +105,9 @@ func TestPodsExec(t *testing.T) {
}
func TestPodsExecDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Pod" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsRun, _ := c.callTool("pods_exec", map[string]interface{}{
diff --git a/pkg/mcp/pods_test.go b/pkg/mcp/pods_test.go
index 0e748fd4..a83e44dd 100644
--- a/pkg/mcp/pods_test.go
+++ b/pkg/mcp/pods_test.go
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
+ "github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
@@ -179,7 +180,9 @@ func TestPodsListInNamespace(t *testing.T) {
}
func TestPodsListDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Pod" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsList, _ := c.callTool("pods_list", map[string]interface{}{})
@@ -414,7 +417,9 @@ func TestPodsGet(t *testing.T) {
}
func TestPodsGetDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Pod" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsGet, _ := c.callTool("pods_get", map[string]interface{}{"name": "a-pod-in-default"})
@@ -564,7 +569,9 @@ func TestPodsDelete(t *testing.T) {
}
func TestPodsDeleteDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Pod" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsDelete, _ := c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-in-default"})
@@ -753,7 +760,9 @@ func TestPodsLog(t *testing.T) {
}
func TestPodsLogDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Pod" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsLog, _ := c.callTool("pods_log", map[string]interface{}{"name": "a-pod-in-default"})
@@ -922,7 +931,9 @@ func TestPodsRun(t *testing.T) {
}
func TestPodsRunDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { version = "v1", kind = "Pod" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsRun, _ := c.callTool("pods_run", map[string]interface{}{"image": "nginx"})
diff --git a/pkg/mcp/pods_top_test.go b/pkg/mcp/pods_top_test.go
index 1023d96f..bd8c8dc6 100644
--- a/pkg/mcp/pods_top_test.go
+++ b/pkg/mcp/pods_top_test.go
@@ -210,7 +210,9 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
}
func TestPodsTopDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Group: "metrics.k8s.io", Version: "v1beta1"}}}
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [ { group = "metrics.k8s.io", version = "v1beta1" } ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
mockServer := test.NewMockServer()
defer mockServer.Close()
diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go
index ebd44195..3aa7b875 100644
--- a/pkg/mcp/resources_test.go
+++ b/pkg/mcp/resources_test.go
@@ -14,6 +14,7 @@ import (
"k8s.io/client-go/dynamic"
"sigs.k8s.io/yaml"
+ "github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
@@ -152,12 +153,12 @@ func TestResourcesList(t *testing.T) {
}
func TestResourcesListDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{
- DeniedResources: []config.GroupVersionKind{
- {Version: "v1", Kind: "Secret"},
- {Group: "rbac.authorization.k8s.io", Version: "v1"},
- },
- }
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [
+ { version = "v1", kind = "Secret" },
+ { group = "rbac.authorization.k8s.io", version = "v1" }
+ ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
deniedByKind, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Secret"})
@@ -357,12 +358,12 @@ func TestResourcesGet(t *testing.T) {
}
func TestResourcesGetDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{
- DeniedResources: []config.GroupVersionKind{
- {Version: "v1", Kind: "Secret"},
- {Group: "rbac.authorization.k8s.io", Version: "v1"},
- },
- }
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [
+ { version = "v1", kind = "Secret" },
+ { group = "rbac.authorization.k8s.io", version = "v1" }
+ ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
@@ -583,12 +584,12 @@ func TestResourcesCreateOrUpdate(t *testing.T) {
}
func TestResourcesCreateOrUpdateDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{
- DeniedResources: []config.GroupVersionKind{
- {Version: "v1", Kind: "Secret"},
- {Group: "rbac.authorization.k8s.io", Version: "v1"},
- },
- }
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [
+ { version = "v1", kind = "Secret" },
+ { group = "rbac.authorization.k8s.io", version = "v1" }
+ ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
secretYaml := "apiVersion: v1\nkind: Secret\nmetadata:\n name: a-denied-secret\n namespace: default\n"
@@ -745,12 +746,12 @@ func TestResourcesDelete(t *testing.T) {
}
func TestResourcesDeleteDenied(t *testing.T) {
- deniedResourcesServer := &config.StaticConfig{
- DeniedResources: []config.GroupVersionKind{
- {Version: "v1", Kind: "Secret"},
- {Group: "rbac.authorization.k8s.io", Version: "v1"},
- },
- }
+ deniedResourcesServer := test.Must(config.ReadToml([]byte(`
+ denied_resources = [
+ { version = "v1", kind = "Secret" },
+ { group = "rbac.authorization.k8s.io", version = "v1" }
+ ]
+ `)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
diff --git a/pkg/mcp/toolsets_test.go b/pkg/mcp/toolsets_test.go
index 2a817978..ac0cdeeb 100644
--- a/pkg/mcp/toolsets_test.go
+++ b/pkg/mcp/toolsets_test.go
@@ -9,12 +9,11 @@ import (
"strings"
"testing"
- "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
)
-func TestFullToolsetTools(t *testing.T) {
+func TestDefaultToolsetTools(t *testing.T) {
expectedNames := []string{
"configuration_view",
"events_list",
@@ -35,7 +34,7 @@ func TestFullToolsetTools(t *testing.T) {
"resources_create_or_update",
"resources_delete",
}
- mcpCtx := &mcpContext{toolset: &full.Full{}}
+ mcpCtx := &mcpContext{}
testCaseWithContext(t, mcpCtx, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
@@ -72,11 +71,10 @@ func TestFullToolsetTools(t *testing.T) {
})
}
-func TestFullToolsetToolsInOpenShift(t *testing.T) {
+func TestDefaultToolsetToolsInOpenShift(t *testing.T) {
mcpCtx := &mcpContext{
- toolset: &full.Full{},
- before: inOpenShift,
- after: inOpenShiftClear,
+ before: inOpenShift,
+ after: inOpenShiftClear,
}
testCaseWithContext(t, mcpCtx, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
diff --git a/pkg/toolsets/full/configuration.go b/pkg/toolsets/config/configuration.go
similarity index 99%
rename from pkg/toolsets/full/configuration.go
rename to pkg/toolsets/config/configuration.go
index 483991ef..4bc3eade 100644
--- a/pkg/toolsets/full/configuration.go
+++ b/pkg/toolsets/config/configuration.go
@@ -1,4 +1,4 @@
-package full
+package config
import (
"fmt"
diff --git a/pkg/toolsets/config/toolset.go b/pkg/toolsets/config/toolset.go
new file mode 100644
index 00000000..5d641fe5
--- /dev/null
+++ b/pkg/toolsets/config/toolset.go
@@ -0,0 +1,31 @@
+package config
+
+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 "config"
+}
+
+func (t *Toolset) GetDescription() string {
+ return "View and manage the current local Kubernetes configuration (kubeconfig)"
+}
+
+func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool {
+ return slices.Concat(
+ initConfiguration(),
+ )
+}
+
+func init() {
+ toolsets.Register(&Toolset{})
+}
diff --git a/pkg/toolsets/full/events.go b/pkg/toolsets/core/events.go
similarity index 99%
rename from pkg/toolsets/full/events.go
rename to pkg/toolsets/core/events.go
index 48fae711..5277d3d7 100644
--- a/pkg/toolsets/full/events.go
+++ b/pkg/toolsets/core/events.go
@@ -1,4 +1,4 @@
-package full
+package core
import (
"fmt"
diff --git a/pkg/toolsets/full/namespaces.go b/pkg/toolsets/core/namespaces.go
similarity index 94%
rename from pkg/toolsets/full/namespaces.go
rename to pkg/toolsets/core/namespaces.go
index fcbae2e4..84650073 100644
--- a/pkg/toolsets/full/namespaces.go
+++ b/pkg/toolsets/core/namespaces.go
@@ -1,4 +1,4 @@
-package full
+package core
import (
"context"
@@ -12,7 +12,7 @@ import (
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)
-func initNamespaces(k *internalk8s.Manager) []api.ServerTool {
+func initNamespaces(o internalk8s.Openshift) []api.ServerTool {
ret := make([]api.ServerTool, 0)
ret = append(ret, api.ServerTool{
Tool: api.Tool{
@@ -30,7 +30,7 @@ func initNamespaces(k *internalk8s.Manager) []api.ServerTool {
},
}, Handler: namespacesList,
})
- if k.IsOpenShift(context.Background()) {
+ if o.IsOpenShift(context.Background()) {
ret = append(ret, api.ServerTool{
Tool: api.Tool{
Name: "projects_list",
diff --git a/pkg/toolsets/full/pods.go b/pkg/toolsets/core/pods.go
similarity index 99%
rename from pkg/toolsets/full/pods.go
rename to pkg/toolsets/core/pods.go
index f417888c..ce879089 100644
--- a/pkg/toolsets/full/pods.go
+++ b/pkg/toolsets/core/pods.go
@@ -1,4 +1,4 @@
-package full
+package core
import (
"bytes"
diff --git a/pkg/toolsets/full/resources.go b/pkg/toolsets/core/resources.go
similarity index 98%
rename from pkg/toolsets/full/resources.go
rename to pkg/toolsets/core/resources.go
index 20ff867a..2c14b77c 100644
--- a/pkg/toolsets/full/resources.go
+++ b/pkg/toolsets/core/resources.go
@@ -1,4 +1,4 @@
-package full
+package core
import (
"context"
@@ -15,9 +15,9 @@ import (
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
-func initResources(k *internalk8s.Manager) []api.ServerTool {
+func initResources(o internalk8s.Openshift) []api.ServerTool {
commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress"
- if k.IsOpenShift(context.Background()) {
+ if o.IsOpenShift(context.Background()) {
commonApiVersion += ", route.openshift.io/v1 Route"
}
commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion)
diff --git a/pkg/toolsets/core/toolset.go b/pkg/toolsets/core/toolset.go
new file mode 100644
index 00000000..9f88c7aa
--- /dev/null
+++ b/pkg/toolsets/core/toolset.go
@@ -0,0 +1,34 @@
+package core
+
+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 "core"
+}
+
+func (t *Toolset) GetDescription() string {
+ return "Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.)"
+}
+
+func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool {
+ return slices.Concat(
+ initEvents(),
+ initNamespaces(o),
+ initPods(),
+ initResources(o),
+ )
+}
+
+func init() {
+ toolsets.Register(&Toolset{})
+}
diff --git a/pkg/toolsets/full/toolset.go b/pkg/toolsets/full/toolset.go
deleted file mode 100644
index 301e3fd5..00000000
--- a/pkg/toolsets/full/toolset.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package full
-
-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 Full struct{}
-
-var _ api.Toolset = (*Full)(nil)
-
-func (p *Full) GetName() string {
- return "full"
-}
-
-func (p *Full) GetDescription() string {
- return "Complete toolset with all tools and extended outputs"
-}
-
-func (p *Full) GetTools(k *internalk8s.Manager) []api.ServerTool {
- return slices.Concat(
- initConfiguration(),
- initEvents(),
- initNamespaces(k),
- initPods(),
- initResources(k),
- initHelm(),
- )
-}
-
-func init() {
- toolsets.Register(&Full{})
-}
diff --git a/pkg/toolsets/full/helm.go b/pkg/toolsets/helm/helm.go
similarity index 99%
rename from pkg/toolsets/full/helm.go
rename to pkg/toolsets/helm/helm.go
index 1389f20c..0352cf60 100644
--- a/pkg/toolsets/full/helm.go
+++ b/pkg/toolsets/helm/helm.go
@@ -1,4 +1,4 @@
-package full
+package helm
import (
"fmt"
diff --git a/pkg/toolsets/helm/toolset.go b/pkg/toolsets/helm/toolset.go
new file mode 100644
index 00000000..dbe75c1e
--- /dev/null
+++ b/pkg/toolsets/helm/toolset.go
@@ -0,0 +1,31 @@
+package helm
+
+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 "helm"
+}
+
+func (t *Toolset) GetDescription() string {
+ return "Tools for managing Helm charts and releases"
+}
+
+func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool {
+ return slices.Concat(
+ initHelm(),
+ )
+}
+
+func init() {
+ toolsets.Register(&Toolset{})
+}
diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go
index 621be220..031bed43 100644
--- a/pkg/toolsets/toolsets.go
+++ b/pkg/toolsets/toolsets.go
@@ -1,7 +1,9 @@
package toolsets
import (
+ "fmt"
"slices"
+ "strings"
"github.com/containers/kubernetes-mcp-server/pkg/api"
)
@@ -32,9 +34,18 @@ func ToolsetNames() []string {
func ToolsetFromString(name string) api.Toolset {
for _, toolset := range Toolsets() {
- if toolset.GetName() == name {
+ if toolset.GetName() == strings.TrimSpace(name) {
return toolset
}
}
return nil
}
+
+func Validate(toolsets []string) error {
+ for _, toolset := range toolsets {
+ if ToolsetFromString(toolset) == nil {
+ return fmt.Errorf("invalid toolset name: %s, valid names are: %s", toolset, strings.Join(ToolsetNames(), ", "))
+ }
+ }
+ return nil
+}
diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go
index f717e4ed..2857b014 100644
--- a/pkg/toolsets/toolsets_test.go
+++ b/pkg/toolsets/toolsets_test.go
@@ -25,7 +25,7 @@ func (t *TestToolset) GetName() string { return t.name }
func (t *TestToolset) GetDescription() string { return t.description }
-func (t *TestToolset) GetTools(k *kubernetes.Manager) []api.ServerTool { return nil }
+func (t *TestToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool { return nil }
var _ api.Toolset = (*TestToolset)(nil)
@@ -53,6 +53,35 @@ func (s *ToolsetsSuite) TestToolsetFromString() {
s.NotNil(res, "Expected to find the registered toolset")
s.Equal("existent", res.GetName(), "Expected to find the registered toolset by name")
})
+ s.Run("Returns the correct toolset if found after trimming spaces", func() {
+ Register(&TestToolset{name: "no-spaces"})
+ res := ToolsetFromString(" no-spaces ")
+ s.NotNil(res, "Expected to find the registered toolset")
+ s.Equal("no-spaces", res.GetName(), "Expected to find the registered toolset by name")
+ })
+}
+
+func (s *ToolsetsSuite) TestValidate() {
+ s.Run("Returns nil for empty toolset list", func() {
+ s.Nil(Validate([]string{}), "Expected nil for empty toolset list")
+ })
+ s.Run("Returns error for invalid toolset name", func() {
+ err := Validate([]string{"invalid"})
+ s.NotNil(err, "Expected error for invalid toolset name")
+ s.Contains(err.Error(), "invalid toolset name: invalid", "Expected error message to contain invalid toolset name")
+ })
+ s.Run("Returns nil for valid toolset names", func() {
+ Register(&TestToolset{name: "valid-1"})
+ Register(&TestToolset{name: "valid-2"})
+ err := Validate([]string{"valid-1", "valid-2"})
+ s.Nil(err, "Expected nil for valid toolset names")
+ })
+ s.Run("Returns error if any toolset name is invalid", func() {
+ Register(&TestToolset{name: "valid"})
+ err := Validate([]string{"valid", "invalid"})
+ s.NotNil(err, "Expected error if any toolset name is invalid")
+ s.Contains(err.Error(), "invalid toolset name: invalid", "Expected error message to contain invalid toolset name")
+ })
}
func TestToolsets(t *testing.T) {