mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat(toolsets): add support for multiple toolsets in configuration (#323)
Users can now enable or disable different toolsets either by providing a command-line flag or by setting the toolsets array field in the TOML configuration. Downstream Kubernetes API developers can declare toolsets for their APIs by creating a new nested package in pkg/toolsets and registering it in pkg/mcp/modules.go Signed-off-by: Marc Nuri <marc@marcnuri.com>
This commit is contained in:
4
Makefile
4
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
|
||||
|
||||
294
README.md
294
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 <a id="tools"></a>
|
||||
## 🛠️ Tools and Functionalities <a id="tools-and-functionalities"></a>
|
||||
|
||||
### `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`
|
||||
<!-- AVAILABLE-TOOLSETS-START -->
|
||||
|
||||
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
|
||||
<!-- AVAILABLE-TOOLSETS-END -->
|
||||
|
||||
### `helm_install`
|
||||
### Tools
|
||||
|
||||
Install a Helm chart in the current or provided namespace with the provided name and chart
|
||||
<!-- AVAILABLE-TOOLSETS-TOOLS-START -->
|
||||
|
||||
**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
|
||||
<details>
|
||||
|
||||
### `helm_list`
|
||||
<summary>config</summary>
|
||||
|
||||
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
|
||||
</details>
|
||||
|
||||
### `helm_uninstall`
|
||||
<details>
|
||||
|
||||
Uninstall a Helm release in the current or provided namespace with the provided name
|
||||
<summary>core</summary>
|
||||
|
||||
**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
|
||||
</details>
|
||||
|
||||
### `pods_list_in_namespace`
|
||||
<details>
|
||||
|
||||
List all the Kubernetes pods in the specified namespace in the current cluster
|
||||
<summary>helm</summary>
|
||||
|
||||
**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
|
||||
- **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)
|
||||
|
||||
### `pods_log`
|
||||
- **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)
|
||||
|
||||
Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name
|
||||
- **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)
|
||||
|
||||
**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
|
||||
</details>
|
||||
|
||||
### `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.
|
||||
<!-- AVAILABLE-TOOLSETS-TOOLS-END -->
|
||||
|
||||
## 🧑💻 Development <a id="development"></a>
|
||||
|
||||
|
||||
8
internal/test/test.go
Normal file
8
internal/test/test.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package test
|
||||
|
||||
func Must[T any](v T, err error) T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
99
internal/tools/update-readme/main.go
Normal file
99
internal/tools/update-readme/main.go
Normal file
@@ -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),
|
||||
"<!-- AVAILABLE-TOOLSETS-START -->",
|
||||
"<!-- AVAILABLE-TOOLSETS-END -->",
|
||||
availableToolsets.String(),
|
||||
)
|
||||
|
||||
// Available Toolset Tools
|
||||
toolsetTools := strings.Builder{}
|
||||
for _, toolset := range toolsetsList {
|
||||
toolsetTools.WriteString("<details>\n\n<summary>" + toolset.GetName() + "</summary>\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("</details>\n\n")
|
||||
}
|
||||
updated = replaceBetweenMarkers(
|
||||
updated,
|
||||
"<!-- AVAILABLE-TOOLSETS-TOOLS-START -->",
|
||||
"<!-- AVAILABLE-TOOLSETS-TOOLS-END -->",
|
||||
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:]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (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
|
||||
|
||||
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"]
|
||||
|
||||
denied_resources = [
|
||||
{group = "apps", version = "v1", kind = "Deployment"},
|
||||
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
|
||||
]
|
||||
|
||||
`)
|
||||
|
||||
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")
|
||||
})
|
||||
s.Run("log_level parsed correctly", func() {
|
||||
s.Equalf(1, config.LogLevel, "Expected LogLevel to be 1, got %d", config.LogLevel)
|
||||
})
|
||||
s.Run("port parsed correctly", func() {
|
||||
s.Equalf("9999", config.Port, "Expected Port to be 9999, got %s", 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)
|
||||
})
|
||||
s.Run("kubeconfig parsed correctly", func() {
|
||||
s.Equalf("./path/to/config", config.KubeConfig, "Expected KubeConfig to be ./path/to/config, got %s", 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)
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
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")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
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 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
|
||||
|
||||
denied_resources = [
|
||||
{group = "apps", version = "v1", kind = "Deployment"},
|
||||
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
|
||||
]
|
||||
|
||||
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"]
|
||||
`)
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
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])
|
||||
}
|
||||
})
|
||||
t.Run("log_level parsed correctly", func(t *testing.T) {
|
||||
if config.LogLevel != 1 {
|
||||
t.Fatalf("Unexpected log level: %v", config.LogLevel)
|
||||
}
|
||||
})
|
||||
t.Run("port parsed correctly", func(t *testing.T) {
|
||||
if config.Port != "9999" {
|
||||
t.Fatalf("Unexpected port value: %v", config.Port)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
t.Run("kubeconfig parsed correctly", func(t *testing.T) {
|
||||
if config.KubeConfig != "./path/to/config" {
|
||||
t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig)
|
||||
}
|
||||
})
|
||||
t.Run("list_output parsed correctly", func(t *testing.T) {
|
||||
if config.ListOutput != "yaml" {
|
||||
t.Fatalf("Unexpected list_output value: %v", config.ListOutput)
|
||||
}
|
||||
})
|
||||
t.Run("read_only parsed correctly", func(t *testing.T) {
|
||||
if !config.ReadOnly {
|
||||
t.Fatalf("Unexpected read-only mode: %v", config.ReadOnly)
|
||||
}
|
||||
})
|
||||
t.Run("disable_destructive parsed correctly", func(t *testing.T) {
|
||||
if !config.DisableDestructive {
|
||||
t.Fatalf("Unexpected disable destructive: %v", config.DisableDestructive)
|
||||
}
|
||||
})
|
||||
t.Run("enabled_tools parsed correctly", func(t *testing.T) {
|
||||
if len(config.EnabledTools) != 8 {
|
||||
t.Fatalf("Unexpected enabled tools: %v", config.EnabledTools)
|
||||
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
})
|
||||
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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.listOutput == nil {
|
||||
c.listOutput = output.Yaml
|
||||
}
|
||||
if c.staticConfig == nil {
|
||||
c.staticConfig = &config.StaticConfig{
|
||||
ReadOnly: false,
|
||||
DisableDestructive: false,
|
||||
}
|
||||
c.staticConfig = config.Default()
|
||||
// Default to use YAML output for lists (previously the default)
|
||||
c.staticConfig.ListOutput = "yaml"
|
||||
}
|
||||
if c.toolsets != nil {
|
||||
c.staticConfig.Toolsets = c.toolsets
|
||||
|
||||
}
|
||||
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)) {
|
||||
|
||||
@@ -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{}{})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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{}{})
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package full
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
31
pkg/toolsets/config/toolset.go
Normal file
31
pkg/toolsets/config/toolset.go
Normal file
@@ -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{})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package full
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -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",
|
||||
@@ -1,4 +1,4 @@
|
||||
package full
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -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)
|
||||
34
pkg/toolsets/core/toolset.go
Normal file
34
pkg/toolsets/core/toolset.go
Normal file
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package full
|
||||
package helm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
31
pkg/toolsets/helm/toolset.go
Normal file
31
pkg/toolsets/helm/toolset.go
Normal file
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user