Merge pull request #34 from ardaguclu/sync-downstream

NO-JIRA: Sync downstream with the latest changes in upstream
This commit is contained in:
openshift-merge-bot[bot]
2025-09-25 14:04:01 +00:00
committed by GitHub
215 changed files with 21032 additions and 3846 deletions

View File

@@ -8,6 +8,7 @@ RUN make build
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest
WORKDIR /app
COPY --from=builder /app/kubernetes-mcp-server /app/kubernetes-mcp-server
USER 65532:65532
ENTRYPOINT ["/app/kubernetes-mcp-server", "--port", "8080"]
EXPOSE 8080

View File

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

162
README.md
View File

@@ -7,25 +7,25 @@ OpenShift MCP Server is currently under development.
A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.marcnuri.com/model-context-protocol-mcp-introduction) server implementation with support for **Kubernetes** and **OpenShift**.
- **✅ Configuration**:
- Automatically detect changes in the Kubernetes configuration and update the MCP server.
- **View** and manage the current [Kubernetes `.kube/config`](https://blog.marcnuri.com/where-is-my-default-kubeconfig-file) or in-cluster configuration.
- Automatically detect changes in the Kubernetes configuration and update the MCP server.
- **View** and manage the current [Kubernetes `.kube/config`](https://blog.marcnuri.com/where-is-my-default-kubeconfig-file) or in-cluster configuration.
- **✅ Generic Kubernetes Resources**: Perform operations on **any** Kubernetes or OpenShift resource.
- Any CRUD operation (Create or Update, Get, List, Delete).
- Any CRUD operation (Create or Update, Get, List, Delete).
- **✅ Pods**: Perform Pod-specific operations.
- **List** pods in all namespaces or in a specific namespace.
- **Get** a pod by name from the specified namespace.
- **Delete** a pod by name from the specified namespace.
- **Show logs** for a pod by name from the specified namespace.
- **Top** gets resource usage metrics for all pods or a specific pod in the specified namespace.
- **Exec** into a pod and run a command.
- **Run** a container image in a pod and optionally expose it.
- **List** pods in all namespaces or in a specific namespace.
- **Get** a pod by name from the specified namespace.
- **Delete** a pod by name from the specified namespace.
- **Show logs** for a pod by name from the specified namespace.
- **Top** gets resource usage metrics for all pods or a specific pod in the specified namespace.
- **Exec** into a pod and run a command.
- **Run** a container image in a pod and optionally expose it.
- **✅ Namespaces**: List Kubernetes Namespaces.
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
- **✅ Projects**: List OpenShift Projects.
- **☸️ Helm**:
- **Install** a Helm chart in the current or provided namespace.
- **List** Helm releases in all namespaces or in a specific namespace.
- **Uninstall** a Helm release in the current or provided namespace.
- **Install** a Helm chart in the current or provided namespace.
- **List** Helm releases in all namespaces or in a specific namespace.
- **Uninstall** a Helm release in the current or provided namespace.
Unlike other Kubernetes MCP server implementations, this **IS NOT** just a wrapper around `kubectl` or `helm` command-line tools.
It is a **Go-based native implementation** that interacts directly with the Kubernetes API server.
@@ -160,9 +160,9 @@ Get the current Kubernetes configuration content as a kubeconfig YAML
**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
- 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
### `events_list`
@@ -170,7 +170,7 @@ List all the Kubernetes events in the current cluster from all namespaces
**Parameters:**
- `namespace` (`string`, optional)
- Namespace to retrieve the events from. If not provided, will list events from all namespaces
- Namespace to retrieve the events from. If not provided, will list events from all namespaces
### `helm_install`
@@ -178,18 +178,18 @@ Install a Helm chart in the current or provided namespace with the provided name
**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`
- 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"}`
- Values to pass to the Helm chart
- Example: `{"key": "value"}`
- `name` (`string`, optional)
- Name of the Helm release
- Random name if not provided
- 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
- Namespace to install the Helm chart in
- If not provided, will use the configured namespace
### `helm_list`
@@ -197,11 +197,11 @@ List all the Helm releases in the current or provided namespace (or in all names
**Parameters:**
- `namespace` (`string`, optional)
- Namespace to list the Helm releases from
- If not provided, will use the configured namespace
- 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
- If `true`, will list Helm releases from all namespaces
- If `false`, will list Helm releases from the specified namespace
### `helm_uninstall`
@@ -209,10 +209,10 @@ Uninstall a Helm release in the current or provided namespace with the provided
**Parameters:**
- `name` (`string`, required)
- Name of the Helm release to uninstall
- 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
- Namespace to uninstall the Helm release from
- If not provided, will use the configured namespace
### `namespaces_list`
@@ -226,9 +226,9 @@ Delete a Kubernetes Pod in the current or provided namespace with the provided n
**Parameters:**
- `name` (`string`, required)
- Name of the Pod to delete
- Name of the Pod to delete
- `namespace` (`string`, required)
- Namespace to delete the Pod from
- Namespace to delete the Pod from
### `pods_exec`
@@ -236,15 +236,15 @@ Execute a command in a Kubernetes Pod in the current or provided namespace with
**Parameters:**
- `command` (`string[]`, required)
- Command to execute in the Pod container
- First item is the command, rest are arguments
- Example: `["ls", "-l", "/tmp"]`
- 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
- Name of the Pod
- `namespace` (string, required)
- Namespace of the Pod
- Namespace of the Pod
- `container` (`string`, optional)
- Name of the Pod container to get logs from
- Name of the Pod container to get logs from
### `pods_get`
@@ -252,9 +252,9 @@ Get a Kubernetes Pod in the current or provided namespace with the provided name
**Parameters:**
- `name` (`string`, required)
- Name of the Pod
- Name of the Pod
- `namespace` (`string`, required)
- Namespace to get the Pod from
- Namespace to get the Pod from
### `pods_list`
@@ -262,7 +262,7 @@ List all the Kubernetes pods in the current cluster from all namespaces
**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
- 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`
@@ -270,9 +270,9 @@ List all the Kubernetes pods in the specified namespace in the current cluster
**Parameters:**
- `namespace` (`string`, required)
- Namespace to list pods from
- 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
- 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`
@@ -280,11 +280,11 @@ Get the logs of a Kubernetes Pod in the current or provided namespace with the p
**Parameters:**
- `name` (`string`, required)
- Name of the Pod to get logs from
- Name of the Pod to get logs from
- `namespace` (`string`, required)
- Namespace to get the Pod logs from
- Namespace to get the Pod logs from
- `container` (`string`, optional)
- Name of the Pod container to get logs from
- Name of the Pod container to get logs from
### `pods_run`
@@ -292,14 +292,14 @@ Run a Kubernetes Pod in the current or provided namespace with the provided cont
**Parameters:**
- `image` (`string`, required)
- Container Image to run in the Pod
- Container Image to run in the Pod
- `namespace` (`string`, required)
- Namespace to run the Pod in
- Namespace to run the Pod in
- `name` (`string`, optional)
- Name of the Pod (random name if not provided)
- 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
- TCP/IP port to expose from the Pod container
- No port exposed if not provided
### `pods_top`
@@ -307,16 +307,16 @@ Lists the resource consumption (CPU and memory) as recorded by the Kubernetes Me
**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
- 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)
- 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)
- 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)
- 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`
@@ -328,8 +328,8 @@ Create or update a Kubernetes resource in the current cluster by providing a YAM
**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
- 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
@@ -344,15 +344,15 @@ 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`)
- 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`)
- kind of the resource (e.g., `Pod`, `Service`, `Deployment`, `Ingress`)
- `name` (`string`, required)
- Name of the resource
- 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
- Namespace to delete the namespaced resource from
- Ignored for cluster-scoped resources
- Uses configured namespace if not provided
### `resources_get`
@@ -360,15 +360,15 @@ 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`)
- 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`)
- kind of the resource (e.g., `Pod`, `Service`, `Deployment`, `Ingress`)
- `name` (`string`, required)
- Name of the resource
- 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
- Namespace to retrieve the namespaced resource from
- Ignored for cluster-scoped resources
- Uses configured namespace if not provided
### `resources_list`
@@ -376,15 +376,15 @@ 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`)
- 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`)
- 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
- 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.
- Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label.
## 🧑‍💻 Development <a id="development"></a>

48
go.mod
View File

@@ -7,24 +7,27 @@ require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/fsnotify/fsnotify v1.9.0
github.com/go-jose/go-jose/v4 v4.1.2
github.com/mark3labs/mcp-go v0.38.0
github.com/google/jsonschema-go v0.3.0
github.com/mark3labs/mcp-go v0.40.0
github.com/pkg/errors v0.9.1
github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.7
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.16.0
helm.sh/helm/v3 v3.18.6
k8s.io/api v0.34.0
k8s.io/apiextensions-apiserver v0.34.0
k8s.io/apimachinery v0.34.0
k8s.io/cli-runtime v0.34.0
k8s.io/client-go v0.34.0
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.44.0
golang.org/x/oauth2 v0.31.0
golang.org/x/sync v0.17.0
helm.sh/helm/v3 v3.19.0
k8s.io/api v0.34.1
k8s.io/apiextensions-apiserver v0.34.1
k8s.io/apimachinery v0.34.1
k8s.io/cli-runtime v0.34.1
k8s.io/client-go v0.34.1
k8s.io/klog/v2 v2.130.1
k8s.io/kubectl v0.33.4
k8s.io/metrics v0.34.0
k8s.io/kubectl v0.34.1
k8s.io/metrics v0.34.1
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
sigs.k8s.io/controller-runtime v0.22.0
sigs.k8s.io/controller-runtime v0.22.1
sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664
sigs.k8s.io/yaml v1.6.0
)
@@ -34,7 +37,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
@@ -119,11 +122,10 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/term v0.33.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
google.golang.org/grpc v1.72.1 // indirect
@@ -131,8 +133,8 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiserver v0.34.0 // indirect
k8s.io/component-base v0.34.0 // indirect
k8s.io/apiserver v0.34.1 // indirect
k8s.io/component-base v0.34.1 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
oras.land/oras-go/v2 v2.6.0 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect

108
go.sum
View File

@@ -14,8 +14,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
@@ -130,6 +130,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -185,8 +187,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I=
github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
github.com/mark3labs/mcp-go v0.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU=
github.com/mark3labs/mcp-go v0.40.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -268,15 +270,15 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
@@ -284,8 +286,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -355,47 +357,47 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -423,36 +425,36 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
helm.sh/helm/v3 v3.18.6 h1:S/2CqcYnNfLckkHLI0VgQbxgcDaU3N4A/46E3n9wSNY=
helm.sh/helm/v3 v3.18.6/go.mod h1:L/dXDR2r539oPlFP1PJqKAC1CUgqHJDLkxKpDGrWnyg=
k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE=
k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug=
k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc=
k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0=
k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0=
k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg=
k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ=
k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw=
k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8=
k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8=
k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg=
helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k=
helm.sh/helm/v3 v3.19.0/go.mod h1:Lk/SfzN0w3a3C3o+TdAKrLwJ0wcZ//t1/SDXAvfgDdc=
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI=
k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc=
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA=
k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0=
k8s.io/cli-runtime v0.34.1 h1:btlgAgTrYd4sk8vJTRG6zVtqBKt9ZMDeQZo2PIzbL7M=
k8s.io/cli-runtime v0.34.1/go.mod h1:aVA65c+f0MZiMUPbseU/M9l1Wo2byeaGwUuQEQVVveE=
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A=
k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
k8s.io/kubectl v0.33.4 h1:nXEI6Vi+oB9hXxoAHyHisXolm/l1qutK3oZQMak4N98=
k8s.io/kubectl v0.33.4/go.mod h1:Xe7P9X4DfILvKmlBsVqUtzktkI56lEj22SJW7cFy6nE=
k8s.io/metrics v0.34.0 h1:nYSfG2+tnL6/MRC2I+sGHjtNEGoEoM/KktgGOoQFwws=
k8s.io/metrics v0.34.0/go.mod h1:KCuXmotE0v4AvoARKUP8NC4lUnbK/Du1mluGdor5h4M=
k8s.io/kubectl v0.34.1 h1:1qP1oqT5Xc93K+H8J7ecpBjaz511gan89KO9Vbsh/OI=
k8s.io/kubectl v0.34.1/go.mod h1:JRYlhJpGPyk3dEmJ+BuBiOB9/dAvnrALJEiY/C5qa6A=
k8s.io/metrics v0.34.1 h1:374Rexmp1xxgRt64Bi0TsjAM8cA/Y8skwCoPdjtIslE=
k8s.io/metrics v0.34.1/go.mod h1:Drf5kPfk2NJrlpcNdSiAAHn/7Y9KqxpRNagByM7Ei80=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
sigs.k8s.io/controller-runtime v0.22.0 h1:mTOfibb8Hxwpx3xEkR56i7xSjB+nH4hZG37SrlCY5e0=
sigs.k8s.io/controller-runtime v0.22.0/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY=
sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg=
sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY=
sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664 h1:xC7x7FsPURJYhZnWHsWFd7nkdD/WRtQVWPC28FWt85Y=
sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664/go.mod h1:Cq9jUhwSYol5tNB0O/1vLYxNV9KqnhpvEa6HvJ1w0wY=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=

View File

@@ -0,0 +1,22 @@
package test
import (
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)
func KubeConfigFake() *clientcmdapi.Config {
fakeConfig := clientcmdapi.NewConfig()
fakeConfig.Clusters["fake"] = clientcmdapi.NewCluster()
fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443"
fakeConfig.Clusters["additional-cluster"] = clientcmdapi.NewCluster()
fakeConfig.AuthInfos["fake"] = clientcmdapi.NewAuthInfo()
fakeConfig.AuthInfos["additional-auth"] = clientcmdapi.NewAuthInfo()
fakeConfig.Contexts["fake-context"] = clientcmdapi.NewContext()
fakeConfig.Contexts["fake-context"].Cluster = "fake"
fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
fakeConfig.Contexts["additional-context"] = clientcmdapi.NewContext()
fakeConfig.Contexts["additional-context"].Cluster = "additional-cluster"
fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth"
fakeConfig.CurrentContext = "fake-context"
return fakeConfig
}

52
internal/test/mcp.go Normal file
View File

@@ -0,0 +1,52 @@
package test
import (
"net/http/httptest"
"testing"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/stretchr/testify/require"
"golang.org/x/net/context"
)
type McpClient struct {
ctx context.Context
testServer *httptest.Server
*client.Client
}
func NewMcpClient(t *testing.T, mcpHttpServer *server.StreamableHTTPServer) *McpClient {
require.NotNil(t, mcpHttpServer, "McpHttpServer must be provided")
var err error
ret := &McpClient{ctx: t.Context()}
ret.testServer = httptest.NewServer(mcpHttpServer)
ret.Client, err = client.NewStreamableHttpClient(ret.testServer.URL + "/mcp")
require.NoError(t, err, "Expected no error creating MCP client")
err = ret.Start(t.Context())
require.NoError(t, err, "Expected no error starting MCP client")
initRequest := mcp.InitializeRequest{}
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initRequest.Params.ClientInfo = mcp.Implementation{Name: "test", Version: "1.33.7"}
_, err = ret.Initialize(t.Context(), initRequest)
require.NoError(t, err, "Expected no error initializing MCP client")
return ret
}
func (m *McpClient) Close() {
if m.Client != nil {
_ = m.Client.Close()
}
if m.testServer != nil {
m.testServer.Close()
}
}
// CallTool helper function to call a tool by name with arguments
func (m *McpClient) CallTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) {
callToolRequest := mcp.CallToolRequest{}
callToolRequest.Params.Name = name
callToolRequest.Params.Arguments = args
return m.Client.CallTool(m.ctx, callToolRequest)
}

View File

@@ -6,7 +6,10 @@ import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
@@ -14,6 +17,7 @@ import (
"k8s.io/apimachinery/pkg/util/httpstream"
"k8s.io/apimachinery/pkg/util/httpstream/spdy"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
@@ -46,7 +50,9 @@ func NewMockServer() *MockServer {
}
func (m *MockServer) Close() {
m.server.Close()
if m.server != nil {
m.server.Close()
}
}
func (m *MockServer) Handle(handler http.Handler) {
@@ -57,21 +63,22 @@ func (m *MockServer) Config() *rest.Config {
return m.config
}
func (m *MockServer) KubeConfig() *api.Config {
fakeConfig := api.NewConfig()
fakeConfig.Clusters["fake"] = api.NewCluster()
func (m *MockServer) Kubeconfig() *api.Config {
fakeConfig := KubeConfigFake()
fakeConfig.Clusters["fake"].Server = m.config.Host
fakeConfig.Clusters["fake"].CertificateAuthorityData = m.config.CAData
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo()
fakeConfig.AuthInfos["fake"].ClientKeyData = m.config.KeyData
fakeConfig.AuthInfos["fake"].ClientCertificateData = m.config.CertData
fakeConfig.Contexts["fake-context"] = api.NewContext()
fakeConfig.Contexts["fake-context"].Cluster = "fake"
fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
fakeConfig.CurrentContext = "fake-context"
return fakeConfig
}
func (m *MockServer) KubeconfigFile(t *testing.T) string {
kubeconfig := filepath.Join(t.TempDir(), "config")
err := clientcmd.WriteToFile(*m.Kubeconfig(), kubeconfig)
require.NoError(t, err, "Expected no error writing kubeconfig file")
return kubeconfig
}
func WriteObject(w http.ResponseWriter, obj runtime.Object) {
w.Header().Set("Content-Type", runtime.ContentTypeJSON)
if err := json.NewEncoder(w).Encode(obj); err != nil {
@@ -170,3 +177,38 @@ WaitForStreams:
return ctx, nil
}
type InOpenShiftHandler struct {
}
var _ http.Handler = (*InOpenShiftHandler)(nil)
func (h *InOpenShiftHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
if req.URL.Path == "/api" {
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
return
}
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
if req.URL.Path == "/apis" {
_, _ = w.Write([]byte(`{
"kind":"APIGroupList",
"groups":[{
"name":"project.openshift.io",
"versions":[{"groupVersion":"project.openshift.io/v1","version":"v1"}],
"preferredVersion":{"groupVersion":"project.openshift.io/v1","version":"v1"}
}]}`))
return
}
if req.URL.Path == "/apis/project.openshift.io/v1" {
_, _ = w.Write([]byte(`{
"kind":"APIResourceList",
"apiVersion":"v1",
"groupVersion":"project.openshift.io/v1",
"resources":[
{"name":"projects","singularName":"","namespaced":false,"kind":"Project","verbs":["create","delete","get","list","patch","update","watch"],"shortNames":["pr"]}
]}`))
return
}
}

21
internal/test/test.go Normal file
View File

@@ -0,0 +1,21 @@
package test
import (
"os"
"path/filepath"
"runtime"
)
func Must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
func ReadFile(path ...string) string {
_, file, _, _ := runtime.Caller(1)
filePath := filepath.Join(append([]string{filepath.Dir(file)}, path...)...)
fileBytes := Must(os.ReadFile(filePath))
return string(fileBytes)
}

View 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:]
}

99
pkg/api/toolsets.go Normal file
View File

@@ -0,0 +1,99 @@
package api
import (
"context"
"encoding/json"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/google/jsonschema-go/jsonschema"
)
type ServerTool struct {
Tool Tool
Handler ToolHandlerFunc
}
type Toolset interface {
// GetName returns the name of the toolset.
// Used to identify the toolset in configuration, logs, and command-line arguments.
// Examples: "core", "metrics", "helm"
GetName() string
GetDescription() string
GetTools(o internalk8s.Openshift) []ServerTool
}
type ToolCallRequest interface {
GetArguments() map[string]any
}
type ToolCallResult struct {
// Raw content returned by the tool.
Content string
// Error (non-protocol) to send back to the LLM.
Error error
}
func NewToolCallResult(content string, err error) *ToolCallResult {
return &ToolCallResult{
Content: content,
Error: err,
}
}
type ToolHandlerParams struct {
context.Context
*internalk8s.Kubernetes
ToolCallRequest
ListOutput output.Output
}
type ToolHandlerFunc func(params ToolHandlerParams) (*ToolCallResult, error)
type Tool struct {
// The name of the tool.
// Intended for programmatic or logical use, but used as a display name in past
// specs or fallback (if title isn't present).
Name string `json:"name"`
// A human-readable description of the tool.
//
// This can be used by clients to improve the LLM's understanding of available
// tools. It can be thought of like a "hint" to the model.
Description string `json:"description,omitempty"`
// Additional tool information.
Annotations ToolAnnotations `json:"annotations"`
// A JSON Schema object defining the expected parameters for the tool.
InputSchema *jsonschema.Schema
}
type ToolAnnotations struct {
// Human-readable title for the tool
Title string `json:"title,omitempty"`
// If true, the tool does not modify its environment.
ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
// If true, the tool may perform destructive updates to its environment. If
// false, the tool performs only additive updates.
//
// (This property is meaningful only when ReadOnlyHint == false.)
DestructiveHint *bool `json:"destructiveHint,omitempty"`
// If true, calling the tool repeatedly with the same arguments will have no
// additional effect on its environment.
//
// (This property is meaningful only when ReadOnlyHint == false.)
IdempotentHint *bool `json:"idempotentHint,omitempty"`
// If true, this tool may interact with an "open world" of external entities. If
// false, the tool's domain of interaction is closed. For example, the world of
// a web search tool is open, whereas that of a memory tool is not.
OpenWorldHint *bool `json:"openWorldHint,omitempty"`
}
func ToRawMessage(v any) json.RawMessage {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}

View File

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

View File

@@ -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))
}

View File

@@ -13,7 +13,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -24,7 +23,6 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"golang.org/x/sync/errgroup"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/klog/v2"
"k8s.io/klog/v2/textlogger"
@@ -62,14 +60,11 @@ 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
mockKubeConfig := c.mockServer.KubeConfig()
kubeConfig := filepath.Join(t.TempDir(), "config")
_ = clientcmd.WriteToFile(*mockKubeConfig, kubeConfig)
c.StaticConfig.KubeConfig = kubeConfig
c.StaticConfig.KubeConfig = c.mockServer.KubeconfigFile(t)
// Capture logging
c.klogState = klog.CaptureState()
flags := flag.NewFlagSet("test", flag.ContinueOnError)
@@ -86,10 +81,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{
Profile: mcp.Profiles[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)
}

View File

@@ -26,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"
)
@@ -57,7 +58,7 @@ type MCPServerOptions struct {
HttpPort int
SSEBaseUrl string
Kubeconfig string
Profile string
Toolsets []string
ListOutput string
ReadOnly bool
DisableDestructive bool
@@ -77,9 +78,7 @@ type MCPServerOptions struct {
func NewMCPServerOptions(streams genericiooptions.IOStreams) *MCPServerOptions {
return &MCPServerOptions{
IOStreams: streams,
Profile: "full",
ListOutput: "table",
StaticConfig: &config.StaticConfig{},
StaticConfig: config.Default(),
}
}
@@ -107,7 +106,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
cmd.Flags().BoolVar(&o.Version, "version", o.Version, "Print version information and quit")
cmd.Flags().IntVar(&o.LogLevel, "log-level", o.LogLevel, "Set the log level (from 0 to 9)")
cmd.Flags().StringVar(&o.ConfigPath, "config", o.ConfigPath, "Path of the config file. Each profile has its set of defaults.")
cmd.Flags().StringVar(&o.ConfigPath, "config", o.ConfigPath, "Path of the config file.")
cmd.Flags().IntVar(&o.SSEPort, "sse-port", o.SSEPort, "Start a SSE server on the specified port")
cmd.Flag("sse-port").Deprecated = "Use --port instead"
cmd.Flags().IntVar(&o.HttpPort, "http-port", o.HttpPort, "Start a streamable HTTP server on the specified port")
@@ -115,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.Profile, "profile", o.Profile, "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")")
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")
@@ -137,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
}
@@ -173,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 {
@@ -182,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
}
@@ -205,6 +207,12 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
func (m *MCPServerOptions) initializeLogging() {
flagSet := flag.NewFlagSet("klog", flag.ContinueOnError)
klog.InitFlags(flagSet)
if m.StaticConfig.Port == "" {
// disable klog output for stdio mode
// this is needed to avoid klog writing to stderr and breaking the protocol
_ = flagSet.Parse([]string{"-logtostderr=false", "-alsologtostderr=false", "-stderrthreshold=FATAL"})
return
}
loggerOptions := []textlogger.ConfigOption{textlogger.Output(m.Out)}
if m.StaticConfig.LogLevel >= 0 {
loggerOptions = append(loggerOptions, textlogger.Verbosity(m.StaticConfig.LogLevel))
@@ -218,6 +226,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")
}
@@ -237,18 +251,10 @@ func (m *MCPServerOptions) Validate() error {
}
func (m *MCPServerOptions) Run() error {
profile := mcp.ProfileFromString(m.Profile)
if profile == nil {
return fmt.Errorf("invalid profile name: %s, valid names are: %s", m.Profile, strings.Join(mcp.ProfileNames, ", "))
}
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(" - Profile: %s", profile.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)
@@ -290,11 +296,7 @@ func (m *MCPServerOptions) Run() error {
oidcProvider = provider
}
mcpServer, err := mcp.NewServer(mcp.Configuration{
Profile: profile,
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)
}

View File

@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/cli-runtime/pkg/genericiooptions"
)
@@ -48,7 +50,7 @@ func TestConfig(t *testing.T) {
t.Run("defaults to none", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
expectedConfig := `" - Config: "`
if err := rootCmd.Execute(); !strings.Contains(out.String(), expectedConfig) {
t.Fatalf("Expected config to be %s, got %s %v", expectedConfig, out.String(), err)
@@ -59,7 +61,7 @@ func TestConfig(t *testing.T) {
rootCmd := NewMCPServer(ioStreams)
_, file, _, _ := runtime.Caller(0)
emptyConfigPath := filepath.Join(filepath.Dir(file), "testdata", "empty-config.toml")
rootCmd.SetArgs([]string{"--version", "--log-level=1", "--config", emptyConfigPath})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config", emptyConfigPath})
_ = rootCmd.Execute()
expected := `(?m)\" - Config\:[^\"]+empty-config\.toml\"`
if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
@@ -69,7 +71,7 @@ func TestConfig(t *testing.T) {
t.Run("invalid path throws error", func(t *testing.T) {
ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1", "--config", "invalid-path-to-config.toml"})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config", "invalid-path-to-config.toml"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("Expected error for invalid config path, got nil")
@@ -129,32 +131,32 @@ func TestConfig(t *testing.T) {
})
}
func TestProfile(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 profile to use (one of: full) ") {
t.Fatalf("Expected all available profiles, got %s %v", o, err)
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)
}
})
t.Run("default", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Profile: full") {
t.Fatalf("Expected profile 'full', got %s %v", out, err)
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
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 --profile", 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", "--profile", "full"}) // TODO: change by some non-default profile
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--toolsets", "helm,config"})
_ = rootCmd.Execute()
expected := `(?m)\" - Profile\: full\"`
expected := `(?m)\" - Toolsets\: helm, config\"`
if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
t.Fatalf("Expected profile to be %s, got %s %v", expected, out.String(), err)
t.Fatalf("Expected toolset to be %s, got %s %v", expected, out.String(), err)
}
})
}
@@ -172,7 +174,7 @@ func TestListOutput(t *testing.T) {
t.Run("defaults to table", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- ListOutput: table") {
t.Fatalf("Expected list-output 'table', got %s %v", out, err)
}
@@ -180,7 +182,7 @@ func TestListOutput(t *testing.T) {
t.Run("set with --list-output", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1", "--list-output", "yaml"})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--list-output", "yaml"})
_ = rootCmd.Execute()
expected := `(?m)\" - ListOutput\: yaml\"`
if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
@@ -193,7 +195,7 @@ func TestReadOnly(t *testing.T) {
t.Run("defaults to false", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
if err := rootCmd.Execute(); !strings.Contains(out.String(), " - Read-only mode: false") {
t.Fatalf("Expected read-only mode false, got %s %v", out, err)
}
@@ -201,7 +203,7 @@ func TestReadOnly(t *testing.T) {
t.Run("set with --read-only", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1", "--read-only"})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--read-only"})
_ = rootCmd.Execute()
expected := `(?m)\" - Read-only mode\: true\"`
if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
@@ -214,7 +216,7 @@ func TestDisableDestructive(t *testing.T) {
t.Run("defaults to false", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
if err := rootCmd.Execute(); !strings.Contains(out.String(), " - Disable destructive tools: false") {
t.Fatalf("Expected disable destructive false, got %s %v", out, err)
}
@@ -222,7 +224,7 @@ func TestDisableDestructive(t *testing.T) {
t.Run("set with --disable-destructive", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1", "--disable-destructive"})
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--disable-destructive"})
_ = rootCmd.Execute()
expected := `(?m)\" - Disable destructive tools\: true\"`
if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
@@ -255,3 +257,22 @@ func TestAuthorizationURL(t *testing.T) {
}
})
}
func TestStdioLogging(t *testing.T) {
t.Run("stdio disables klog", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
err := rootCmd.Execute()
require.NoErrorf(t, err, "Expected no error executing command, got %v", err)
assert.Equalf(t, "0.0.0\n", out.String(), "Expected only version output, got %s", out.String())
})
t.Run("http mode enables klog", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1", "--port=1337"})
err := rootCmd.Execute()
require.NoErrorf(t, err, "Expected no error executing command, got %v", err)
assert.Containsf(t, out.String(), "Starting kubernetes-mcp-server", "Expected klog output, got %s", out.String())
})
}

View File

@@ -81,24 +81,24 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return m.clientCmdConfig
}
func (m *Manager) ConfigurationView(minify bool) (runtime.Object, error) {
func (k *Kubernetes) ConfigurationView(minify bool) (runtime.Object, error) {
var cfg clientcmdapi.Config
var err error
if m.IsInCluster() {
if k.manager.IsInCluster() {
cfg = *clientcmdapi.NewConfig()
cfg.Clusters["cluster"] = &clientcmdapi.Cluster{
Server: m.cfg.Host,
InsecureSkipTLSVerify: m.cfg.Insecure,
Server: k.manager.cfg.Host,
InsecureSkipTLSVerify: k.manager.cfg.Insecure,
}
cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{
Token: m.cfg.BearerToken,
Token: k.manager.cfg.BearerToken,
}
cfg.Contexts["context"] = &clientcmdapi.Context{
Cluster: "cluster",
AuthInfo: "user",
}
cfg.CurrentContext = "context"
} else if cfg, err = m.clientCmdConfig.RawConfig(); err != nil {
} else if cfg, err = k.manager.clientCmdConfig.RawConfig(); err != nil {
return nil, err
}
if minify {

View File

@@ -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,

View File

@@ -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{

View File

@@ -17,10 +17,14 @@ import (
"k8s.io/client-go/tools/remotecommand"
"k8s.io/metrics/pkg/apis/metrics"
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/version"
)
// Default number of lines to retrieve from the end of the logs
const DefaultTailLines = int64(100)
type PodsTopOptions struct {
metav1.ListOptions
AllNamespaces bool
@@ -92,16 +96,26 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
k.ResourcesDelete(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
}
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string) (string, error) {
tailLines := int64(256)
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string, previous bool, tail int64) (string, error) {
pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace))
if err != nil {
return "", err
}
req := pods.GetLogs(name, &v1.PodLogOptions{
TailLines: &tailLines,
logOptions := &v1.PodLogOptions{
Container: container,
})
Previous: previous,
}
// Only set tailLines if a value is provided (non-zero)
if tail > 0 {
logOptions.TailLines = &tail
} else {
// Default to DefaultTailLines lines when not specified
logOptions.TailLines = ptr.To(DefaultTailLines)
}
req := pods.GetLogs(name, logOptions)
res := req.Do(ctx)
if res.Error() != nil {
return "", res.Error()

View File

@@ -14,14 +14,13 @@ import (
"testing"
"time"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/pkg/errors"
"github.com/spf13/afero"
"github.com/stretchr/testify/suite"
"golang.org/x/sync/errgroup"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
@@ -32,7 +31,7 @@ import (
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
toolswatch "k8s.io/client-go/tools/watch"
"k8s.io/klog/v2"
"k8s.io/klog/v2/textlogger"
@@ -43,6 +42,10 @@ import (
"sigs.k8s.io/controller-runtime/tools/setup-envtest/store"
"sigs.k8s.io/controller-runtime/tools/setup-envtest/versions"
"sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
// envTest has an expensive setup, so we only want to do it once per entire test run.
@@ -81,11 +84,9 @@ func TestMain(m *testing.M) {
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
}
adminSystemMasterBaseConfig, _ := envTest.Start()
au, err := envTest.AddUser(envTestUser, adminSystemMasterBaseConfig)
if err != nil {
panic(err)
}
au := test.Must(envTest.AddUser(envTestUser, adminSystemMasterBaseConfig))
envTestRestConfig = au.Config()
envTest.KubeConfig = test.Must(au.KubeConfig())
//Create test data as administrator
ctx := context.Background()
@@ -103,7 +104,7 @@ func TestMain(m *testing.M) {
}
type mcpContext struct {
profile Profile
toolsets []string
listOutput output.Output
logLevel int
@@ -126,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.profile == nil {
c.profile = &FullProfile{}
}
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)
@@ -148,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{
Profile: c.profile,
ListOutput: c.listOutput,
StaticConfig: c.staticConfig,
}); err != nil {
if c.mcpServer, err = NewServer(Configuration{StaticConfig: c.staticConfig}); err != nil {
t.Fatal(err)
return
}
@@ -188,7 +185,7 @@ func (c *mcpContext) afterEach() {
}
func testCase(t *testing.T, test func(c *mcpContext)) {
testCaseWithContext(t, &mcpContext{profile: &FullProfile{}}, test)
testCaseWithContext(t, &mcpContext{}, test)
}
func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpContext)) {
@@ -198,23 +195,23 @@ func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpConte
}
// withKubeConfig sets up a fake kubeconfig in the temp directory based on the provided rest.Config
func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
fakeConfig := api.NewConfig()
fakeConfig.Clusters["fake"] = api.NewCluster()
func (c *mcpContext) withKubeConfig(rc *rest.Config) *clientcmdapi.Config {
fakeConfig := clientcmdapi.NewConfig()
fakeConfig.Clusters["fake"] = clientcmdapi.NewCluster()
fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443"
fakeConfig.Clusters["additional-cluster"] = api.NewCluster()
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo()
fakeConfig.AuthInfos["additional-auth"] = api.NewAuthInfo()
fakeConfig.Clusters["additional-cluster"] = clientcmdapi.NewCluster()
fakeConfig.AuthInfos["fake"] = clientcmdapi.NewAuthInfo()
fakeConfig.AuthInfos["additional-auth"] = clientcmdapi.NewAuthInfo()
if rc != nil {
fakeConfig.Clusters["fake"].Server = rc.Host
fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.CAData
fakeConfig.AuthInfos["fake"].ClientKeyData = rc.KeyData
fakeConfig.AuthInfos["fake"].ClientCertificateData = rc.CertData
}
fakeConfig.Contexts["fake-context"] = api.NewContext()
fakeConfig.Contexts["fake-context"] = clientcmdapi.NewContext()
fakeConfig.Contexts["fake-context"].Cluster = "fake"
fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
fakeConfig.Contexts["additional-context"] = api.NewContext()
fakeConfig.Contexts["additional-context"] = clientcmdapi.NewContext()
fakeConfig.Contexts["additional-context"].Cluster = "additional-cluster"
fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth"
fakeConfig.CurrentContext = "fake-context"
@@ -422,3 +419,33 @@ func createTestData(ctx context.Context) {
_, _ = kubernetesAdmin.CoreV1().ConfigMaps("default").
Create(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a-configmap-to-delete"}}, metav1.CreateOptions{})
}
type BaseMcpSuite struct {
suite.Suite
*test.McpClient
mcpServer *Server
Cfg *config.StaticConfig
}
func (s *BaseMcpSuite) SetupTest() {
s.Cfg = config.Default()
s.Cfg.ListOutput = "yaml"
s.Cfg.KubeConfig = filepath.Join(s.T().TempDir(), "config")
s.Require().NoError(os.WriteFile(s.Cfg.KubeConfig, envTest.KubeConfig, 0600), "Expected to write kubeconfig")
}
func (s *BaseMcpSuite) TearDownTest() {
if s.McpClient != nil {
s.McpClient.Close()
}
if s.mcpServer != nil {
s.mcpServer.Close()
}
}
func (s *BaseMcpSuite) InitMcpClient() {
var err error
s.mcpServer, err = NewServer(Configuration{StaticConfig: s.Cfg})
s.Require().NoError(err, "Expected no error creating MCP server")
s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil))
}

View File

@@ -1,46 +0,0 @@
package mcp
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func (s *Server) initConfiguration() []server.ServerTool {
tools := []server.ServerTool{
{Tool: mcp.NewTool("configuration_view",
mcp.WithDescription("Get the current Kubernetes configuration content as a kubeconfig YAML"),
mcp.WithBoolean("minified", mcp.Description("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)")),
// Tool annotations
mcp.WithTitleAnnotation("Configuration: View"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.configurationView},
}
return tools
}
func (s *Server) configurationView(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
minify := true
minified := ctr.GetArguments()["minified"]
if _, ok := minified.(bool); ok {
minify = minified.(bool)
}
ret, err := s.k.ConfigurationView(minify)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get configuration: %v", err)), nil
}
configurationYaml, err := output.MarshalYaml(ret)
if err != nil {
err = fmt.Errorf("failed to get configuration: %v", err)
}
return NewTextResult(configurationYaml, err), nil
}

View File

@@ -3,177 +3,132 @@ package mcp
import (
"testing"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/suite"
"k8s.io/client-go/rest"
v1 "k8s.io/client-go/tools/clientcmd/api/v1"
"sigs.k8s.io/yaml"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)
func TestConfigurationView(t *testing.T) {
testCase(t, func(c *mcpContext) {
toolResult, err := c.callTool("configuration_view", map[string]interface{}{})
t.Run("configuration_view returns configuration", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
type ConfigurationSuite struct {
BaseMcpSuite
}
func (s *ConfigurationSuite) SetupTest() {
s.BaseMcpSuite.SetupTest()
// Use mock server for predictable kubeconfig content
mockServer := test.NewMockServer()
s.T().Cleanup(mockServer.Close)
s.Cfg.KubeConfig = mockServer.KubeconfigFile(s.T())
}
func (s *ConfigurationSuite) TestConfigurationView() {
s.InitMcpClient()
s.Run("configuration_view", func() {
toolResult, err := s.CallTool("configuration_view", map[string]interface{}{})
s.Run("returns configuration", func() {
s.Nilf(err, "call tool failed %v", err)
})
s.Require().NotNil(toolResult, "Expected tool result from call")
var decoded *v1.Config
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
s.Run("returns current-context", func() {
s.Equalf("fake-context", decoded.CurrentContext, "fake-context not found: %v", decoded.CurrentContext)
})
s.Run("returns context info", func() {
s.Lenf(decoded.Contexts, 1, "invalid context count, expected 1, got %v", len(decoded.Contexts))
s.Equalf("fake-context", decoded.Contexts[0].Name, "fake-context not found: %v", decoded.Contexts)
s.Equalf("fake", decoded.Contexts[0].Context.Cluster, "fake-cluster not found: %v", decoded.Contexts)
s.Equalf("fake", decoded.Contexts[0].Context.AuthInfo, "fake-auth not found: %v", decoded.Contexts)
})
s.Run("returns cluster info", func() {
s.Lenf(decoded.Clusters, 1, "invalid cluster count, expected 1, got %v", len(decoded.Clusters))
s.Equalf("fake", decoded.Clusters[0].Name, "fake-cluster not found: %v", decoded.Clusters)
s.Regexpf(`^https?://(127\.0\.0\.1|localhost):\d{1,5}$`, decoded.Clusters[0].Cluster.Server, "fake-server not found: %v", decoded.Clusters)
})
s.Run("returns auth info", func() {
s.Lenf(decoded.AuthInfos, 1, "invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
s.Equalf("fake", decoded.AuthInfos[0].Name, "fake-auth not found: %v", decoded.AuthInfos)
})
})
s.Run("configuration_view(minified=false)", func() {
toolResult, err := s.CallTool("configuration_view", map[string]interface{}{
"minified": false,
})
s.Run("returns configuration", func() {
s.Nilf(err, "call tool failed %v", err)
})
var decoded *v1.Config
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
t.Run("configuration_view has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
t.Run("configuration_view returns current-context", func(t *testing.T) {
if decoded.CurrentContext != "fake-context" {
t.Errorf("fake-context not found: %v", decoded.CurrentContext)
}
s.Run("returns additional context info", func() {
s.Lenf(decoded.Contexts, 2, "invalid context count, expected 2, got %v", len(decoded.Contexts))
s.Equalf("additional-context", decoded.Contexts[0].Name, "additional-context not found: %v", decoded.Contexts)
s.Equalf("additional-cluster", decoded.Contexts[0].Context.Cluster, "additional-cluster not found: %v", decoded.Contexts)
s.Equalf("additional-auth", decoded.Contexts[0].Context.AuthInfo, "additional-auth not found: %v", decoded.Contexts)
s.Equalf("fake-context", decoded.Contexts[1].Name, "fake-context not found: %v", decoded.Contexts)
})
t.Run("configuration_view returns context info", func(t *testing.T) {
if len(decoded.Contexts) != 1 {
t.Errorf("invalid context count, expected 1, got %v", len(decoded.Contexts))
}
if decoded.Contexts[0].Name != "fake-context" {
t.Errorf("fake-context not found: %v", decoded.Contexts)
}
if decoded.Contexts[0].Context.Cluster != "fake" {
t.Errorf("fake-cluster not found: %v", decoded.Contexts)
}
if decoded.Contexts[0].Context.AuthInfo != "fake" {
t.Errorf("fake-auth not found: %v", decoded.Contexts)
}
s.Run("returns cluster info", func() {
s.Lenf(decoded.Clusters, 2, "invalid cluster count, expected 2, got %v", len(decoded.Clusters))
s.Equalf("additional-cluster", decoded.Clusters[0].Name, "additional-cluster not found: %v", decoded.Clusters)
})
t.Run("configuration_view returns cluster info", func(t *testing.T) {
if len(decoded.Clusters) != 1 {
t.Errorf("invalid cluster count, expected 1, got %v", len(decoded.Clusters))
}
if decoded.Clusters[0].Name != "fake" {
t.Errorf("fake-cluster not found: %v", decoded.Clusters)
}
if decoded.Clusters[0].Cluster.Server != "https://127.0.0.1:6443" {
t.Errorf("fake-server not found: %v", decoded.Clusters)
}
})
t.Run("configuration_view returns auth info", func(t *testing.T) {
if len(decoded.AuthInfos) != 1 {
t.Errorf("invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
}
if decoded.AuthInfos[0].Name != "fake" {
t.Errorf("fake-auth not found: %v", decoded.AuthInfos)
}
})
toolResult, err = c.callTool("configuration_view", map[string]interface{}{
"minified": false,
})
t.Run("configuration_view with minified=false returns configuration", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
})
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
t.Run("configuration_view with minified=false has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
})
t.Run("configuration_view with minified=false returns additional context info", func(t *testing.T) {
if len(decoded.Contexts) != 2 {
t.Fatalf("invalid context count, expected2, got %v", len(decoded.Contexts))
}
if decoded.Contexts[0].Name != "additional-context" {
t.Errorf("additional-context not found: %v", decoded.Contexts)
}
if decoded.Contexts[0].Context.Cluster != "additional-cluster" {
t.Errorf("additional-cluster not found: %v", decoded.Contexts)
}
if decoded.Contexts[0].Context.AuthInfo != "additional-auth" {
t.Errorf("additional-auth not found: %v", decoded.Contexts)
}
if decoded.Contexts[1].Name != "fake-context" {
t.Errorf("fake-context not found: %v", decoded.Contexts)
}
})
t.Run("configuration_view with minified=false returns cluster info", func(t *testing.T) {
if len(decoded.Clusters) != 2 {
t.Errorf("invalid cluster count, expected 2, got %v", len(decoded.Clusters))
}
if decoded.Clusters[0].Name != "additional-cluster" {
t.Errorf("additional-cluster not found: %v", decoded.Clusters)
}
})
t.Run("configuration_view with minified=false returns auth info", func(t *testing.T) {
if len(decoded.AuthInfos) != 2 {
t.Errorf("invalid auth info count, expected 2, got %v", len(decoded.AuthInfos))
}
if decoded.AuthInfos[0].Name != "additional-auth" {
t.Errorf("additional-auth not found: %v", decoded.AuthInfos)
}
s.Run("configuration_view with minified=false returns auth info", func() {
s.Lenf(decoded.AuthInfos, 2, "invalid auth info count, expected 2, got %v", len(decoded.AuthInfos))
s.Equalf("additional-auth", decoded.AuthInfos[0].Name, "additional-auth not found: %v", decoded.AuthInfos)
})
})
}
func TestConfigurationViewInCluster(t *testing.T) {
func (s *ConfigurationSuite) TestConfigurationViewInCluster() {
s.Cfg.KubeConfig = "" // Force in-cluster
kubernetes.InClusterConfig = func() (*rest.Config, error) {
return &rest.Config{
Host: "https://kubernetes.default.svc",
BearerToken: "fake-token",
}, nil
}
defer func() {
kubernetes.InClusterConfig = rest.InClusterConfig
}()
testCase(t, func(c *mcpContext) {
toolResult, err := c.callTool("configuration_view", map[string]interface{}{})
t.Run("configuration_view returns configuration", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
s.T().Cleanup(func() { kubernetes.InClusterConfig = rest.InClusterConfig })
s.InitMcpClient()
s.Run("configuration_view", func() {
toolResult, err := s.CallTool("configuration_view", map[string]interface{}{})
s.Run("returns configuration", func() {
s.Nilf(err, "call tool failed %v", err)
})
s.Require().NotNil(toolResult, "Expected tool result from call")
var decoded *v1.Config
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
t.Run("configuration_view has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
t.Run("configuration_view returns current-context", func(t *testing.T) {
if decoded.CurrentContext != "context" {
t.Fatalf("context not found: %v", decoded.CurrentContext)
}
s.Run("returns current-context", func() {
s.Equalf("context", decoded.CurrentContext, "context not found: %v", decoded.CurrentContext)
})
t.Run("configuration_view returns context info", func(t *testing.T) {
if len(decoded.Contexts) != 1 {
t.Fatalf("invalid context count, expected 1, got %v", len(decoded.Contexts))
}
if decoded.Contexts[0].Name != "context" {
t.Fatalf("context not found: %v", decoded.Contexts)
}
if decoded.Contexts[0].Context.Cluster != "cluster" {
t.Fatalf("cluster not found: %v", decoded.Contexts)
}
if decoded.Contexts[0].Context.AuthInfo != "user" {
t.Fatalf("user not found: %v", decoded.Contexts)
}
s.Run("returns context info", func() {
s.Lenf(decoded.Contexts, 1, "invalid context count, expected 1, got %v", len(decoded.Contexts))
s.Equalf("context", decoded.Contexts[0].Name, "context not found: %v", decoded.Contexts)
s.Equalf("cluster", decoded.Contexts[0].Context.Cluster, "cluster not found: %v", decoded.Contexts)
s.Equalf("user", decoded.Contexts[0].Context.AuthInfo, "user not found: %v", decoded.Contexts)
})
t.Run("configuration_view returns cluster info", func(t *testing.T) {
if len(decoded.Clusters) != 1 {
t.Fatalf("invalid cluster count, expected 1, got %v", len(decoded.Clusters))
}
if decoded.Clusters[0].Name != "cluster" {
t.Fatalf("cluster not found: %v", decoded.Clusters)
}
if decoded.Clusters[0].Cluster.Server != "https://kubernetes.default.svc" {
t.Fatalf("server not found: %v", decoded.Clusters)
}
s.Run("returns cluster info", func() {
s.Lenf(decoded.Clusters, 1, "invalid cluster count, expected 1, got %v", len(decoded.Clusters))
s.Equalf("cluster", decoded.Clusters[0].Name, "cluster not found: %v", decoded.Clusters)
s.Equalf("https://kubernetes.default.svc", decoded.Clusters[0].Cluster.Server, "server not found: %v", decoded.Clusters)
})
t.Run("configuration_view returns auth info", func(t *testing.T) {
if len(decoded.AuthInfos) != 1 {
t.Fatalf("invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
}
if decoded.AuthInfos[0].Name != "user" {
t.Fatalf("user not found: %v", decoded.AuthInfos)
}
s.Run("returns auth info", func() {
s.Lenf(decoded.AuthInfos, 1, "invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
s.Equalf("user", decoded.AuthInfos[0].Name, "user not found: %v", decoded.AuthInfos)
})
})
}
func TestConfiguration(t *testing.T) {
suite.Run(t, new(ConfigurationSuite))
}

View File

@@ -1,49 +0,0 @@
package mcp
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func (s *Server) initEvents() []server.ServerTool {
return []server.ServerTool{
{Tool: mcp.NewTool("events_list",
mcp.WithDescription("List all the Kubernetes events in the current cluster from all namespaces"),
mcp.WithString("namespace",
mcp.Description("Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces")),
// Tool annotations
mcp.WithTitleAnnotation("Events: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.eventsList},
}
}
func (s *Server) eventsList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
namespace := ctr.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
eventMap, err := derived.EventsList(ctx, namespace.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
}
if len(eventMap) == 0 {
return NewTextResult("No events found", nil), nil
}
yamlEvents, err := output.MarshalYaml(eventMap)
if err != nil {
err = fmt.Errorf("failed to list events in all namespaces: %v", err)
}
return NewTextResult(fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), err), nil
}

View File

@@ -1,31 +1,36 @@
package mcp
import (
"github.com/containers/kubernetes-mcp-server/pkg/config"
"testing"
"github.com/BurntSushi/toml"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/suite"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"testing"
"k8s.io/client-go/kubernetes"
)
func TestEventsList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
toolResult, err := c.callTool("events_list", map[string]interface{}{})
t.Run("events_list with no events returns OK", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "No events found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
type EventsSuite struct {
BaseMcpSuite
}
func (s *EventsSuite) TestEventsList() {
s.InitMcpClient()
s.Run("events_list (no events)", func() {
toolResult, err := s.CallTool("events_list", map[string]interface{}{})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
client := c.newKubernetesClient()
s.Run("returns no events message", func() {
s.Equal("No events found", toolResult.Content[0].(mcp.TextContent).Text)
})
})
s.Run("events_list (with events)", func() {
client := kubernetes.NewForConfigOrDie(envTestRestConfig)
for _, ns := range []string{"default", "ns-1"} {
_, _ = client.CoreV1().Events(ns).Create(c.ctx, &v1.Event{
_, _ = client.CoreV1().Events(ns).Create(s.T().Context(), &v1.Event{
ObjectMeta: metav1.ObjectMeta{
Name: "an-event-in-" + ns,
},
@@ -39,77 +44,82 @@ func TestEventsList(t *testing.T) {
Message: "The event message",
}, metav1.CreateOptions{})
}
toolResult, err = c.callTool("events_list", map[string]interface{}{})
t.Run("events_list with events returns all OK", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "The following events (YAML format) were found:\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: default\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: ns-1\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
s.Run("events_list()", func() {
toolResult, err := s.CallTool("events_list", map[string]interface{}{})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns all events", func() {
s.Equalf("The following events (YAML format) were found:\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: default\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: ns-1\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n",
toolResult.Content[0].(mcp.TextContent).Text,
"unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
})
})
toolResult, err = c.callTool("events_list", map[string]interface{}{
"namespace": "ns-1",
})
t.Run("events_list in namespace with events returns from namespace OK", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "The following events (YAML format) were found:\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: ns-1\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
s.Run("events_list(namespace=ns-1)", func() {
toolResult, err := s.CallTool("events_list", map[string]interface{}{
"namespace": "ns-1",
})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns events from namespace", func() {
s.Equalf("The following events (YAML format) were found:\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: ns-1\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n",
toolResult.Content[0].(mcp.TextContent).Text,
"unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
})
})
})
}
func TestEventsListDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Event"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
eventList, _ := c.callTool("events_list", map[string]interface{}{})
t.Run("events_list has error", func(t *testing.T) {
if !eventList.IsError {
t.Fatalf("call tool should fail")
}
func (s *EventsSuite) TestEventsListDenied() {
s.Require().NoError(toml.Unmarshal([]byte(`
denied_resources = [ { version = "v1", kind = "Event" } ]
`), s.Cfg), "Expected to parse denied resources config")
s.InitMcpClient()
s.Run("events_list (denied)", func() {
toolResult, err := s.CallTool("events_list", map[string]interface{}{})
s.Run("has error", func() {
s.Truef(toolResult.IsError, "call tool should fail")
s.Nilf(err, "call tool should not return error object")
})
t.Run("events_list describes denial", func(t *testing.T) {
s.Run("describes denial", func() {
expectedMessage := "failed to list events in all namespaces: resource not allowed: /v1, Kind=Event"
if eventList.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, eventList.Content[0].(mcp.TextContent).Text)
}
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
}
func TestEvents(t *testing.T) {
suite.Run(t, new(EventsSuite))
}

View File

@@ -1,118 +0,0 @@
package mcp
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func (s *Server) initHelm() []server.ServerTool {
return []server.ServerTool{
{Tool: mcp.NewTool("helm_install",
mcp.WithDescription("Install a Helm chart in the current or provided namespace"),
mcp.WithString("chart", mcp.Description("Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)"), mcp.Required()),
mcp.WithObject("values", mcp.Description("Values to pass to the Helm chart (Optional)")),
mcp.WithString("name", mcp.Description("Name of the Helm release (Optional, random name if not provided)")),
mcp.WithString("namespace", mcp.Description("Namespace to install the Helm chart in (Optional, current namespace if not provided)")),
// Tool annotations
mcp.WithTitleAnnotation("Helm: Install"),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.helmInstall},
{Tool: mcp.NewTool("helm_list",
mcp.WithDescription("List all the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from (Optional, all namespaces if not provided)")),
mcp.WithBoolean("all_namespaces", mcp.Description("If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)")),
// Tool annotations
mcp.WithTitleAnnotation("Helm: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.helmList},
{Tool: mcp.NewTool("helm_uninstall",
mcp.WithDescription("Uninstall a Helm release in the current or provided namespace"),
mcp.WithString("name", mcp.Description("Name of the Helm release to uninstall"), mcp.Required()),
mcp.WithString("namespace", mcp.Description("Namespace to uninstall the Helm release from (Optional, current namespace if not provided)")),
// Tool annotations
mcp.WithTitleAnnotation("Helm: Uninstall"),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(true),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.helmUninstall},
}
}
func (s *Server) helmInstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var chart string
ok := false
if chart, ok = ctr.GetArguments()["chart"].(string); !ok {
return NewTextResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
}
values := map[string]interface{}{}
if v, ok := ctr.GetArguments()["values"].(map[string]interface{}); ok {
values = v
}
name := ""
if v, ok := ctr.GetArguments()["name"].(string); ok {
name = v
}
namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
namespace = v
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.NewHelm().Install(ctx, chart, values, name, namespace)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
}
return NewTextResult(ret, err), nil
}
func (s *Server) helmList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
allNamespaces := false
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok {
allNamespaces = v
}
namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
namespace = v
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.NewHelm().List(namespace, allNamespaces)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil
}
return NewTextResult(ret, err), nil
}
func (s *Server) helmUninstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var name string
ok := false
if name, ok = ctr.GetArguments()["name"].(string); !ok {
return NewTextResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil
}
namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
namespace = v
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.NewHelm().Uninstall(name, namespace)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil
}
return NewTextResult(ret, err), nil
}

View File

@@ -8,8 +8,9 @@ import (
"strings"
"testing"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/BurntSushi/toml"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/suite"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -17,240 +18,250 @@ import (
"sigs.k8s.io/yaml"
)
func TestHelmInstall(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
type HelmSuite struct {
BaseMcpSuite
}
func (s *HelmSuite) SetupTest() {
s.BaseMcpSuite.SetupTest()
clearHelmReleases(s.T().Context(), kubernetes.NewForConfigOrDie(envTestRestConfig))
}
func (s *HelmSuite) TestHelmInstall() {
s.InitMcpClient()
s.Run("helm_install(chart=helm-chart-no-op)", func() {
_, file, _, _ := runtime.Caller(0)
chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-no-op")
toolResult, err := c.callTool("helm_install", map[string]interface{}{
toolResult, err := s.CallTool("helm_install", map[string]interface{}{
"chart": chartPath,
})
t.Run("helm_install with local chart and no release name, returns installed chart", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns installed chart", func() {
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
if !strings.HasPrefix(decoded[0]["name"].(string), "helm-chart-no-op-") {
t.Fatalf("invalid helm install name, expected no-op-*, got %v", decoded[0]["name"])
}
if decoded[0]["namespace"] != "default" {
t.Fatalf("invalid helm install namespace, expected default, got %v", decoded[0]["namespace"])
}
if decoded[0]["chart"] != "no-op" {
t.Fatalf("invalid helm install name, expected release name, got empty")
}
if decoded[0]["chartVersion"] != "1.33.7" {
t.Fatalf("invalid helm install version, expected 1.33.7, got empty")
}
if decoded[0]["status"] != "deployed" {
t.Fatalf("invalid helm install status, expected deployed, got %v", decoded[0]["status"])
}
if decoded[0]["revision"] != float64(1) {
t.Fatalf("invalid helm install revision, expected 1, got %v", decoded[0]["revision"])
}
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
s.Run("has 1 item", func() {
s.Lenf(decoded, 1, "invalid helm install count, expected 1, got %v", len(decoded))
})
s.Run("has valid name", func() {
s.Truef(strings.HasPrefix(decoded[0]["name"].(string), "helm-chart-no-op-"), "invalid helm install name, expected no-op-*, got %v", decoded[0]["name"])
})
s.Run("has valid namespace", func() {
s.Equalf("default", decoded[0]["namespace"], "invalid helm install namespace, expected default, got %v", decoded[0]["namespace"])
})
s.Run("has valid chart", func() {
s.Equalf("no-op", decoded[0]["chart"], "invalid helm install name, expected release name, got empty")
})
s.Run("has valid chartVersion", func() {
s.Equalf("1.33.7", decoded[0]["chartVersion"], "invalid helm install version, expected 1.33.7, got empty")
})
s.Run("has valid status", func() {
s.Equalf("deployed", decoded[0]["status"], "invalid helm install status, expected deployed, got %v", decoded[0]["status"])
})
s.Run("has valid revision", func() {
s.Equalf(float64(1), decoded[0]["revision"], "invalid helm install revision, expected 1, got %v", decoded[0]["revision"])
})
})
})
}
func TestHelmInstallDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
func (s *HelmSuite) TestHelmInstallDenied() {
s.Require().NoError(toml.Unmarshal([]byte(`
denied_resources = [ { version = "v1", kind = "Secret" } ]
`), s.Cfg), "Expected to parse denied resources config")
s.InitMcpClient()
s.Run("helm_install(chart=helm-chart-secret, denied)", func() {
_, file, _, _ := runtime.Caller(0)
chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-secret")
helmInstall, _ := c.callTool("helm_install", map[string]interface{}{
toolResult, err := s.CallTool("helm_install", map[string]interface{}{
"chart": chartPath,
})
t.Run("helm_install has error", func(t *testing.T) {
if !helmInstall.IsError {
t.Fatalf("call tool should fail")
}
s.Run("has error", func() {
s.Truef(toolResult.IsError, "call tool should fail")
s.Nilf(err, "call tool should not return error object")
})
t.Run("helm_install describes denial", func(t *testing.T) {
toolOutput := helmInstall.Content[0].(mcp.TextContent).Text
s.Run("describes denial", func() {
s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "failed to install helm chart"), "expected descriptive error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
expectedMessage := ": resource not allowed: /v1, Kind=Secret"
if !strings.HasPrefix(toolOutput, "failed to install helm chart") || !strings.HasSuffix(toolOutput, expectedMessage) {
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, helmInstall.Content[0].(mcp.TextContent).Text)
}
s.Truef(strings.HasSuffix(toolResult.Content[0].(mcp.TextContent).Text, expectedMessage), "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
}
func TestHelmList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
clearHelmReleases(c.ctx, kc)
toolResult, err := c.callTool("helm_list", map[string]interface{}{})
t.Run("helm_list with no releases, returns not found", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "No Helm releases found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
func (s *HelmSuite) TestHelmListNoReleases() {
s.InitMcpClient()
s.Run("helm_list() with no releases", func() {
toolResult, err := s.CallTool("helm_list", map[string]interface{}{})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
_, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sh.helm.release.v1.release-to-list",
Labels: map[string]string{"owner": "helm", "name": "release-to-list"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"release-to-list\"," +
"\"info\":{\"status\":\"deployed\"}" +
"}"))),
},
}, metav1.CreateOptions{})
toolResult, err = c.callTool("helm_list", map[string]interface{}{})
t.Run("helm_list with deployed release, returns release", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
if len(decoded) != 1 {
t.Fatalf("invalid helm list count, expected 1, got %v", len(decoded))
}
if decoded[0]["name"] != "release-to-list" {
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
}
if decoded[0]["status"] != "deployed" {
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"])
}
})
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
t.Run("helm_list with deployed release in other namespaces, returns not found", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "No Helm releases found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
})
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1", "all_namespaces": true})
t.Run("helm_list with deployed release in all namespaces, returns release", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
if len(decoded) != 1 {
t.Fatalf("invalid helm list count, expected 1, got %v", len(decoded))
}
if decoded[0]["name"] != "release-to-list" {
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
}
if decoded[0]["status"] != "deployed" {
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"])
}
s.Run("returns not found", func() {
s.Equalf("No Helm releases found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
})
})
}
func TestHelmUninstall(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
clearHelmReleases(c.ctx, kc)
toolResult, err := c.callTool("helm_uninstall", map[string]interface{}{
func (s *HelmSuite) TestHelmList() {
kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
_, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sh.helm.release.v1.release-to-list",
Labels: map[string]string{"owner": "helm", "name": "release-to-list"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"release-to-list\"," +
"\"info\":{\"status\":\"deployed\"}" +
"}"))),
},
}, metav1.CreateOptions{})
s.Require().NoError(err)
s.InitMcpClient()
s.Run("helm_list() with deployed release", func() {
toolResult, err := s.CallTool("helm_list", map[string]interface{}{})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns release", func() {
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
s.Run("has 1 item", func() {
s.Lenf(decoded, 1, "invalid helm list count, expected 1, got %v", len(decoded))
})
s.Run("has valid name", func() {
s.Equalf("release-to-list", decoded[0]["name"], "invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
})
s.Run("has valid status", func() {
s.Equalf("deployed", decoded[0]["status"], "invalid helm list status, expected deployed, got %v", decoded[0]["status"])
})
})
})
s.Run("helm_list(namespace=ns-1) with deployed release in other namespaces", func() {
toolResult, err := s.CallTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns not found", func() {
s.Equalf("No Helm releases found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
})
})
s.Run("helm_list(namespace=ns-1, all_namespaces=true) with deployed release in all namespaces", func() {
toolResult, err := s.CallTool("helm_list", map[string]interface{}{"namespace": "ns-1", "all_namespaces": true})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns release", func() {
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
s.Run("has 1 item", func() {
s.Lenf(decoded, 1, "invalid helm list count, expected 1, got %v", len(decoded))
})
s.Run("has valid name", func() {
s.Equalf("release-to-list", decoded[0]["name"], "invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
})
s.Run("has valid status", func() {
s.Equalf("deployed", decoded[0]["status"], "invalid helm list status, expected deployed, got %v", decoded[0]["status"])
})
})
})
}
func (s *HelmSuite) TestHelmUninstallNoReleases() {
s.InitMcpClient()
s.Run("helm_uninstall(name=release-to-uninstall) with no releases", func() {
toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
"name": "release-to-uninstall",
})
t.Run("helm_uninstall with no releases, returns not found", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "Release release-to-uninstall not found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
_, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"existent-release-to-uninstall\"," +
"\"info\":{\"status\":\"deployed\"}" +
"}"))),
},
}, metav1.CreateOptions{})
toolResult, err = c.callTool("helm_uninstall", map[string]interface{}{
"name": "existent-release-to-uninstall",
})
t.Run("helm_uninstall with deployed release, returns uninstalled", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "Uninstalled release existent-release-to-uninstall") {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
_, err = kc.CoreV1().Secrets("default").Get(c.ctx, "sh.helm.release.v1.existent-release-to-uninstall.v0", metav1.GetOptions{})
if !errors.IsNotFound(err) {
t.Fatalf("expected release to be deleted, but it still exists")
}
s.Run("returns not found", func() {
s.Equalf("Release release-to-uninstall not found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
})
})
}
func TestHelmUninstallDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
clearHelmReleases(c.ctx, kc)
_, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"existent-release-to-uninstall\"," +
"\"info\":{\"status\":\"deployed\"}," +
"\"manifest\":\"apiVersion: v1\\nkind: Secret\\nmetadata:\\n name: secret-to-deny\\n namespace: default\\n\"" +
"}"))),
},
}, metav1.CreateOptions{})
helmUninstall, _ := c.callTool("helm_uninstall", map[string]interface{}{
func (s *HelmSuite) TestHelmUninstall() {
kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
_, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"existent-release-to-uninstall\"," +
"\"info\":{\"status\":\"deployed\"}" +
"}"))),
},
}, metav1.CreateOptions{})
s.Require().NoError(err)
s.InitMcpClient()
s.Run("helm_uninstall(name=existent-release-to-uninstall) with deployed release", func() {
toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
"name": "existent-release-to-uninstall",
})
t.Run("helm_uninstall has error", func(t *testing.T) {
if !helmUninstall.IsError {
t.Fatalf("call tool should fail")
}
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns uninstalled", func() {
s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "Uninstalled release existent-release-to-uninstall"), "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
_, err = kc.CoreV1().Secrets("default").Get(s.T().Context(), "sh.helm.release.v1.existent-release-to-uninstall.v0", metav1.GetOptions{})
s.Truef(errors.IsNotFound(err), "expected release to be deleted, but it still exists")
})
})
}
func (s *HelmSuite) TestHelmUninstallDenied() {
s.Require().NoError(toml.Unmarshal([]byte(`
denied_resources = [ { version = "v1", kind = "Secret" } ]
`), s.Cfg), "Expected to parse denied resources config")
kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
_, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"existent-release-to-uninstall\"," +
"\"info\":{\"status\":\"deployed\"}," +
"\"manifest\":\"apiVersion: v1\\nkind: Secret\\nmetadata:\\n name: secret-to-deny\\n namespace: default\\n\"" +
"}"))),
},
}, metav1.CreateOptions{})
s.Require().NoError(err)
s.InitMcpClient()
s.Run("helm_uninstall(name=existent-release-to-uninstall) with deployed release (denied)", func() {
toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
"name": "existent-release-to-uninstall",
})
s.Run("has error", func() {
s.Truef(toolResult.IsError, "call tool should fail")
s.Nilf(err, "call tool should not return error object")
})
s.Run("describes denial", func() {
s.T().Skipf("Helm won't report what underlying resource caused the failure, so we can't assert on it")
expectedMessage := "failed to uninstall release: resource not allowed: /v1, Kind=Secret"
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
}
@@ -263,3 +274,7 @@ func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) {
}
}
}
func TestHelm(t *testing.T) {
suite.Run(t, new(HelmSuite))
}

60
pkg/mcp/m3labs.go Normal file
View File

@@ -0,0 +1,60 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/containers/kubernetes-mcp-server/pkg/api"
)
func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.ServerTool, error) {
m3labTools := make([]server.ServerTool, 0)
for _, tool := range tools {
m3labTool := mcp.Tool{
Name: tool.Tool.Name,
Description: tool.Tool.Description,
Annotations: mcp.ToolAnnotation{
Title: tool.Tool.Annotations.Title,
ReadOnlyHint: tool.Tool.Annotations.ReadOnlyHint,
DestructiveHint: tool.Tool.Annotations.DestructiveHint,
IdempotentHint: tool.Tool.Annotations.IdempotentHint,
OpenWorldHint: tool.Tool.Annotations.OpenWorldHint,
},
}
if tool.Tool.InputSchema != nil {
schema, err := json.Marshal(tool.Tool.InputSchema)
if err != nil {
return nil, fmt.Errorf("failed to marshal tool input schema for tool %s: %v", tool.Tool.Name, err)
}
// TODO: temporary fix to append an empty properties object (some client have trouble parsing a schema without properties)
// As opposed, Gemini had trouble for a while when properties was present but empty.
// https://github.com/containers/kubernetes-mcp-server/issues/340
if string(schema) == `{"type":"object"}` {
schema = []byte(`{"type":"object","properties":{}}`)
}
m3labTool.RawInputSchema = schema
}
m3labHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
k, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
result, err := tool.Handler(api.ToolHandlerParams{
Context: ctx,
Kubernetes: k,
ToolCallRequest: request,
ListOutput: s.configuration.ListOutput(),
})
if err != nil {
return nil, err
}
return NewTextResult(result.Content, result.Error), nil
}
m3labTools = append(m3labTools, server.ServerTool{Tool: m3labTool, Handler: m3labHandler})
}
return m3labTools, nil
}

View File

@@ -13,9 +13,11 @@ import (
"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"
)
@@ -24,13 +26,28 @@ type ContextKey string
const TokenScopesContextKey = ContextKey("TokenScopesContextKey")
type Configuration struct {
Profile Profile
ListOutput output.Output
StaticConfig *config.StaticConfig
*config.StaticConfig
listOutput output.Output
toolsets []api.Toolset
}
func (c *Configuration) isToolApplicable(tool server.ServerTool) bool {
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 {
if c.StaticConfig.ReadOnly && !ptr.Deref(tool.Tool.Annotations.ReadOnlyHint, false) {
return false
}
@@ -88,15 +105,21 @@ func (s *Server) reloadKubernetesClient() error {
return err
}
s.k = k
applicableTools := make([]server.ServerTool, 0)
for _, tool := range s.configuration.Profile.GetTools(s) {
if !s.configuration.isToolApplicable(tool) {
continue
applicableTools := make([]api.ServerTool, 0)
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)
}
s.server.SetTools(applicableTools...)
m3labsServerTools, err := ServerToolToM3LabsServerTool(s, applicableTools)
if err != nil {
return fmt.Errorf("failed to convert tools: %v", err)
}
s.server.SetTools(m3labsServerTools...)
return nil
}

View File

@@ -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 {
@@ -161,7 +161,7 @@ func TestToolCallLogging(t *testing.T) {
}
})
sensitiveHeaders := []string{
"Authorization",
"Authorization:",
// TODO: Add more sensitive headers as needed
}
t.Run("Does not log sensitive headers", func(t *testing.T) {

5
pkg/mcp/modules.go Normal file
View File

@@ -0,0 +1,5 @@
package mcp
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"

View File

@@ -1,62 +0,0 @@
package mcp
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)
func (s *Server) initNamespaces() []server.ServerTool {
ret := make([]server.ServerTool, 0)
ret = append(ret, server.ServerTool{
Tool: mcp.NewTool("namespaces_list",
mcp.WithDescription("List all the Kubernetes namespaces in the current cluster"),
// Tool annotations
mcp.WithTitleAnnotation("Namespaces: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.namespacesList,
})
if s.k.IsOpenShift(context.Background()) {
ret = append(ret, server.ServerTool{
Tool: mcp.NewTool("projects_list",
mcp.WithDescription("List all the OpenShift projects in the current cluster"),
// Tool annotations
mcp.WithTitleAnnotation("Projects: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.projectsList,
})
}
return ret
}
func (s *Server) namespacesList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.NamespacesList(ctx, kubernetes.ResourceListOptions{AsTable: s.configuration.ListOutput.AsTable()})
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) projectsList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.ProjectsList(ctx, kubernetes.ResourceListOptions{AsTable: s.configuration.ListOutput.AsTable()})
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list projects: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}

View File

@@ -5,113 +5,111 @@ import (
"slices"
"testing"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/BurntSushi/toml"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/suite"
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"
)
func TestNamespacesList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
toolResult, err := c.callTool("namespaces_list", map[string]interface{}{})
t.Run("namespaces_list returns namespace list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
type NamespacesSuite struct {
BaseMcpSuite
}
func (s *NamespacesSuite) TestNamespacesList() {
s.InitMcpClient()
s.Run("namespaces_list", func() {
toolResult, err := s.CallTool("namespaces_list", map[string]interface{}{})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Require().NotNil(toolResult, "Expected tool result from call")
var decoded []unstructured.Unstructured
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
t.Run("namespaces_list has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
t.Run("namespaces_list returns at least 3 items", func(t *testing.T) {
if len(decoded) < 3 {
t.Errorf("invalid namespace count, expected at least 3, got %v", len(decoded))
}
s.Run("returns at least 3 items", func() {
s.Truef(len(decoded) >= 3, "expected at least 3 items, got %v", len(decoded))
for _, expectedNamespace := range []string{"default", "ns-1", "ns-2"} {
idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool {
s.Truef(slices.ContainsFunc(decoded, func(ns unstructured.Unstructured) bool {
return ns.GetName() == expectedNamespace
})
if idx == -1 {
t.Errorf("namespace %s not found in the list", expectedNamespace)
}
}), "namespace %s not found in the list", expectedNamespace)
}
})
})
}
func TestNamespacesListDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Namespace"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
namespacesList, _ := c.callTool("namespaces_list", map[string]interface{}{})
t.Run("namespaces_list has error", func(t *testing.T) {
if !namespacesList.IsError {
t.Fatalf("call tool should fail")
}
func (s *NamespacesSuite) TestNamespacesListDenied() {
s.Require().NoError(toml.Unmarshal([]byte(`
denied_resources = [ { version = "v1", kind = "Namespace" } ]
`), s.Cfg), "Expected to parse denied resources config")
s.InitMcpClient()
s.Run("namespaces_list (denied)", func() {
toolResult, err := s.CallTool("namespaces_list", map[string]interface{}{})
s.Run("has error", func() {
s.Truef(toolResult.IsError, "call tool should fail")
s.Nilf(err, "call tool should not return error object")
})
t.Run("namespaces_list describes denial", func(t *testing.T) {
s.Run("describes denial", func() {
expectedMessage := "failed to list namespaces: resource not allowed: /v1, Kind=Namespace"
if namespacesList.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, namespacesList.Content[0].(mcp.TextContent).Text)
}
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
}
func TestNamespacesListAsTable(t *testing.T) {
testCaseWithContext(t, &mcpContext{listOutput: output.Table}, func(c *mcpContext) {
c.withEnvTest()
toolResult, err := c.callTool("namespaces_list", map[string]interface{}{})
t.Run("namespaces_list returns namespace list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
func (s *NamespacesSuite) TestNamespacesListAsTable() {
s.Cfg.ListOutput = "table"
s.InitMcpClient()
s.Run("namespaces_list (list_output=table)", func() {
toolResult, err := s.CallTool("namespaces_list", map[string]interface{}{})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Require().NotNil(toolResult, "Expected tool result from call")
out := toolResult.Content[0].(mcp.TextContent).Text
t.Run("namespaces_list returns column headers", func(t *testing.T) {
s.Run("returns column headers", func() {
expectedHeaders := "APIVERSION\\s+KIND\\s+NAME\\s+STATUS\\s+AGE\\s+LABELS"
if m, e := regexp.MatchString(expectedHeaders, out); !m || e != nil {
t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, out)
}
m, e := regexp.MatchString(expectedHeaders, out)
s.Truef(m, "Expected headers '%s' not found in output:\n%s", expectedHeaders, out)
s.NoErrorf(e, "Error matching headers regex: %v", e)
})
t.Run("namespaces_list returns formatted row for ns-1", func(t *testing.T) {
s.Run("returns formatted row for ns-1", func() {
expectedRow := "(?<apiVersion>v1)\\s+" +
"(?<kind>Namespace)\\s+" +
"(?<name>ns-1)\\s+" +
"(?<status>Active)\\s+" +
"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
"(?<labels>kubernetes.io/metadata.name=ns-1)"
if m, e := regexp.MatchString(expectedRow, out); !m || e != nil {
t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, out)
}
m, e := regexp.MatchString(expectedRow, out)
s.Truef(m, "Expected row '%s' not found in output:\n%s", expectedRow, out)
s.NoErrorf(e, "Error matching ns-1 regex: %v", e)
})
t.Run("namespaces_list returns formatted row for ns-2", func(t *testing.T) {
s.Run("returns formatted row for ns-2", func() {
expectedRow := "(?<apiVersion>v1)\\s+" +
"(?<kind>Namespace)\\s+" +
"(?<name>ns-2)\\s+" +
"(?<status>Active)\\s+" +
"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
"(?<labels>kubernetes.io/metadata.name=ns-2)"
if m, e := regexp.MatchString(expectedRow, out); !m || e != nil {
t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, out)
}
m, e := regexp.MatchString(expectedRow, out)
s.Truef(m, "Expected row '%s' not found in output:\n%s", expectedRow, out)
s.NoErrorf(e, "Error matching ns-2 regex: %v", e)
})
})
}
func TestNamespaces(t *testing.T) {
suite.Run(t, new(NamespacesSuite))
}
func TestProjectsListInOpenShift(t *testing.T) {
@@ -156,7 +154,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{}{})

View File

@@ -1,330 +0,0 @@
package mcp
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"k8s.io/kubectl/pkg/metricsutil"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func (s *Server) initPods() []server.ServerTool {
return []server.ServerTool{
{Tool: mcp.NewTool("pods_list",
mcp.WithDescription("List all the Kubernetes pods in the current cluster from all namespaces"),
mcp.WithString("labelSelector", mcp.Description("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"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")),
// Tool annotations
mcp.WithTitleAnnotation("Pods: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsListInAllNamespaces},
{Tool: mcp.NewTool("pods_list_in_namespace",
mcp.WithDescription("List all the Kubernetes pods in the specified namespace in the current cluster"),
mcp.WithString("namespace", mcp.Description("Namespace to list pods from"), mcp.Required()),
mcp.WithString("labelSelector", mcp.Description("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"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")),
// Tool annotations
mcp.WithTitleAnnotation("Pods: List in Namespace"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsListInNamespace},
{Tool: mcp.NewTool("pods_get",
mcp.WithDescription("Get a Kubernetes Pod in the current or provided namespace with the provided name"),
mcp.WithString("namespace", mcp.Description("Namespace to get the Pod from")),
mcp.WithString("name", mcp.Description("Name of the Pod"), mcp.Required()),
// Tool annotations
mcp.WithTitleAnnotation("Pods: Get"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsGet},
{Tool: mcp.NewTool("pods_delete",
mcp.WithDescription("Delete a Kubernetes Pod in the current or provided namespace with the provided name"),
mcp.WithString("namespace", mcp.Description("Namespace to delete the Pod from")),
mcp.WithString("name", mcp.Description("Name of the Pod to delete"), mcp.Required()),
// Tool annotations
mcp.WithTitleAnnotation("Pods: Delete"),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(true),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsDelete},
{Tool: mcp.NewTool("pods_top",
mcp.WithDescription("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"),
mcp.WithBoolean("all_namespaces", mcp.Description("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"), mcp.DefaultBool(true)),
mcp.WithString("namespace", mcp.Description("Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)")),
mcp.WithString("name", mcp.Description("Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)")),
mcp.WithString("label_selector", mcp.Description("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)"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")),
// Tool annotations
mcp.WithTitleAnnotation("Pods: Top"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsTop},
{Tool: mcp.NewTool("pods_exec",
mcp.WithDescription("Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command"),
mcp.WithString("namespace", mcp.Description("Namespace of the Pod where the command will be executed")),
mcp.WithString("name", mcp.Description("Name of the Pod where the command will be executed"), mcp.Required()),
mcp.WithArray("command", mcp.Description("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"]`),
// TODO: manual fix to ensure that the items property gets initialized (Gemini)
// https://www.googlecloudcommunity.com/gc/AI-ML/Gemini-API-400-Bad-Request-Array-fields-breaks-function-calling/m-p/769835?nobounce
func(schema map[string]interface{}) {
schema["type"] = "array"
schema["items"] = map[string]interface{}{
"type": "string",
}
},
mcp.Required(),
),
mcp.WithString("container", mcp.Description("Name of the Pod container where the command will be executed (Optional)")),
// Tool annotations
mcp.WithTitleAnnotation("Pods: Exec"),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(true), // Depending on the Pod's entrypoint, executing certain commands may kill the Pod
mcp.WithIdempotentHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsExec},
{Tool: mcp.NewTool("pods_log",
mcp.WithDescription("Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name"),
mcp.WithString("namespace", mcp.Description("Namespace to get the Pod logs from")),
mcp.WithString("name", mcp.Description("Name of the Pod to get the logs from"), mcp.Required()),
mcp.WithString("container", mcp.Description("Name of the Pod container to get the logs from (Optional)")),
// Tool annotations
mcp.WithTitleAnnotation("Pods: Log"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsLog},
{Tool: mcp.NewTool("pods_run",
mcp.WithDescription("Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name"),
mcp.WithString("namespace", mcp.Description("Namespace to run the Pod in")),
mcp.WithString("name", mcp.Description("Name of the Pod (Optional, random name if not provided)")),
mcp.WithString("image", mcp.Description("Container Image to run in the Pod"), mcp.Required()),
mcp.WithNumber("port", mcp.Description("TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)")),
// Tool annotations
mcp.WithTitleAnnotation("Pods: Run"),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsRun},
}
}
func (s *Server) podsListInAllNamespaces(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
labelSelector := ctr.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
}
if labelSelector != nil {
resourceListOptions.LabelSelector = labelSelector.(string)
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.PodsListInAllNamespaces(ctx, resourceListOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
if ns == nil {
return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil
}
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
}
labelSelector := ctr.GetArguments()["labelSelector"]
if labelSelector != nil {
resourceListOptions.LabelSelector = labelSelector.(string)
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.PodsListInNamespace(ctx, ns.(string), resourceListOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
if name == nil {
return NewTextResult("", errors.New("failed to get pod, missing argument name")), nil
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.PodsGet(ctx, ns.(string), name.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil
}
return NewTextResult(output.MarshalYaml(ret)), nil
}
func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
if name == nil {
return NewTextResult("", errors.New("failed to delete pod, missing argument name")), nil
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.PodsDelete(ctx, ns.(string), name.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %v", name, ns, err)), nil
}
return NewTextResult(ret, err), nil
}
func (s *Server) podsTop(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true}
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
podsTopOptions.Namespace = v
}
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok {
podsTopOptions.AllNamespaces = v
}
if v, ok := ctr.GetArguments()["name"].(string); ok {
podsTopOptions.Name = v
}
if v, ok := ctr.GetArguments()["label_selector"].(string); ok {
podsTopOptions.LabelSelector = v
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.PodsTop(ctx, podsTopOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
}
buf := new(bytes.Buffer)
printer := metricsutil.NewTopCmdPrinter(buf)
err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
}
return NewTextResult(buf.String(), nil), nil
}
func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
if name == nil {
return NewTextResult("", errors.New("failed to exec in pod, missing argument name")), nil
}
container := ctr.GetArguments()["container"]
if container == nil {
container = ""
}
commandArg := ctr.GetArguments()["command"]
command := make([]string, 0)
if _, ok := commandArg.([]interface{}); ok {
for _, cmd := range commandArg.([]interface{}) {
if _, ok := cmd.(string); ok {
command = append(command, cmd.(string))
}
}
} else {
return NewTextResult("", errors.New("failed to exec in pod, invalid command argument")), nil
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.PodsExec(ctx, ns.(string), name.(string), container.(string), command)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil
} else if ret == "" {
ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns)
}
return NewTextResult(ret, err), nil
}
func (s *Server) podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
if name == nil {
return NewTextResult("", errors.New("failed to get pod log, missing argument name")), nil
}
container := ctr.GetArguments()["container"]
if container == nil {
container = ""
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.PodsLog(ctx, ns.(string), name.(string), container.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil
} else if ret == "" {
ret = fmt.Sprintf("The pod %s in namespace %s has not logged any message yet", name, ns)
}
return NewTextResult(ret, err), nil
}
func (s *Server) podsRun(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
if name == nil {
name = ""
}
image := ctr.GetArguments()["image"]
if image == nil {
return NewTextResult("", errors.New("failed to run pod, missing argument image")), nil
}
port := ctr.GetArguments()["port"]
if port == nil {
port = float64(0)
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
resources, err := derived.PodsRun(ctx, ns.(string), name.(string), image.(string), int32(port.(float64)))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to run pod %s in namespace %s: %v", name, ns, err)), nil
}
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
err = fmt.Errorf("failed to run pod: %v", err)
}
return NewTextResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
}

View File

@@ -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{}{

View File

@@ -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"})
@@ -719,11 +726,78 @@ func TestPodsLog(t *testing.T) {
return
}
})
podsPreviousLogInNamespace, err := c.callTool("pods_log", map[string]interface{}{
"namespace": "ns-1",
"name": "a-pod-in-ns-1",
"previous": true,
})
t.Run("pods_log with previous=true returns previous pod log", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if podsPreviousLogInNamespace.IsError {
t.Fatalf("call tool failed")
return
}
})
podsPreviousLogFalse, err := c.callTool("pods_log", map[string]interface{}{
"namespace": "ns-1",
"name": "a-pod-in-ns-1",
"previous": false,
})
t.Run("pods_log with previous=false returns current pod log", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if podsPreviousLogFalse.IsError {
t.Fatalf("call tool failed")
return
}
})
// Test with tail parameter
podsTailLines, err := c.callTool("pods_log", map[string]interface{}{
"namespace": "ns-1",
"name": "a-pod-in-ns-1",
"tail": 50,
})
t.Run("pods_log with tail=50 returns pod log", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if podsTailLines.IsError {
t.Fatalf("call tool failed")
return
}
})
// Test with invalid tail parameter
podsInvalidTailLines, _ := c.callTool("pods_log", map[string]interface{}{
"namespace": "ns-1",
"name": "a-pod-in-ns-1",
"tail": "invalid",
})
t.Run("pods_log with invalid tail returns error", func(t *testing.T) {
if !podsInvalidTailLines.IsError {
t.Fatalf("call tool should fail")
return
}
expectedErrorMsg := "failed to parse tail parameter: expected integer"
if errMsg := podsInvalidTailLines.Content[0].(mcp.TextContent).Text; !strings.Contains(errMsg, expectedErrorMsg) {
t.Fatalf("unexpected error message, expected to contain '%s', got '%s'", expectedErrorMsg, errMsg)
return
}
})
})
}
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"})
@@ -892,7 +966,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"})

View File

@@ -71,12 +71,12 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/pods" {
if req.URL.Query().Get("labelSelector") == "app=pod-ns-5-42" {
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
`{"metadata":{"name":"pod-ns-5-42","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"42m","memory":"42Mi"}}]}` +
`{"metadata":{"name":"pod-ns-5-42","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"42m","memory":"42Mi","swap":"42Mi"}}]}` +
`]}`))
} else {
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"100m","memory":"200Mi"}},{"name":"container-2","usage":{"cpu":"200m","memory":"300Mi"}}]},` +
`{"metadata":{"name":"pod-2","namespace":"ns-1"},"containers":[{"name":"container-1-ns-1","usage":{"cpu":"300m","memory":"400Mi"}}]}` +
`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"100m","memory":"200Mi","swap":"13Mi"}},{"name":"container-2","usage":{"cpu":"200m","memory":"300Mi","swap":"37Mi"}}]},` +
`{"metadata":{"name":"pod-2","namespace":"ns-1"},"containers":[{"name":"container-1-ns-1","usage":{"cpu":"300m","memory":"400Mi","swap":"42Mi"}}]}` +
`]}`))
}
@@ -85,14 +85,14 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
// Pod Metrics from configured namespace
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods" {
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi"}},{"name":"container-2","usage":{"cpu":"30m","memory":"40Mi"}}]}` +
`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi","swap":"13Mi"}},{"name":"container-2","usage":{"cpu":"30m","memory":"40Mi","swap":"37Mi"}}]}` +
`]}`))
return
}
// Pod Metrics from ns-5 namespace
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods" {
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
`{"metadata":{"name":"pod-ns-5-1","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi"}}]}` +
`{"metadata":{"name":"pod-ns-5-1","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi","swap":"42Mi"}}]}` +
`]}`))
return
}
@@ -100,7 +100,7 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods/pod-ns-5-5" {
_, _ = w.Write([]byte(`{"kind":"PodMetrics","apiVersion":"metrics.k8s.io/v1beta1",` +
`"metadata":{"name":"pod-ns-5-5","namespace":"ns-5"},` +
`"containers":[{"name":"container-1","usage":{"cpu":"13m","memory":"37Mi"}}]` +
`"containers":[{"name":"container-1","usage":{"cpu":"13m","memory":"37Mi","swap":"42Mi"}}]` +
`}`))
}
}))
@@ -113,21 +113,21 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
if podsTopDefaults.IsError {
t.Fatalf("call tool failed %s", textContent)
}
expectedHeaders := regexp.MustCompile(`(?m)^\s*NAMESPACE\s+POD\s+NAME\s+CPU\(cores\)\s+MEMORY\(bytes\)\s*$`)
expectedHeaders := regexp.MustCompile(`(?m)^\s*NAMESPACE\s+POD\s+NAME\s+CPU\(cores\)\s+MEMORY\(bytes\)\s+SWAP\(bytes\)\s*$`)
if !expectedHeaders.MatchString(textContent) {
t.Errorf("Expected headers '%s' not found in output:\n%s", expectedHeaders.String(), textContent)
}
expectedRows := []string{
"default\\s+pod-1\\s+container-1\\s+100m\\s+200Mi",
"default\\s+pod-1\\s+container-2\\s+200m\\s+300Mi",
"ns-1\\s+pod-2\\s+container-1-ns-1\\s+300m\\s+400Mi",
"default\\s+pod-1\\s+container-1\\s+100m\\s+200Mi\\s+13Mi",
"default\\s+pod-1\\s+container-2\\s+200m\\s+300Mi\\s+37Mi",
"ns-1\\s+pod-2\\s+container-1-ns-1\\s+300m\\s+400Mi\\s+42Mi",
}
for _, row := range expectedRows {
if !regexp.MustCompile(row).MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", row, textContent)
}
}
expectedTotal := regexp.MustCompile(`(?m)^\s+600m\s+900Mi\s*$`)
expectedTotal := regexp.MustCompile(`(?m)^\s+600m\s+900Mi\s+92Mi\s*$`)
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
@@ -141,15 +141,15 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
}
textContent := podsTopConfiguredNamespace.Content[0].(mcp.TextContent).Text
expectedRows := []string{
"default\\s+pod-1\\s+container-1\\s+10m\\s+20Mi",
"default\\s+pod-1\\s+container-2\\s+30m\\s+40Mi",
"default\\s+pod-1\\s+container-1\\s+10m\\s+20Mi\\s+13Mi",
"default\\s+pod-1\\s+container-2\\s+30m\\s+40Mi\\s+37Mi",
}
for _, row := range expectedRows {
if !regexp.MustCompile(row).MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", row, textContent)
}
}
expectedTotal := regexp.MustCompile(`(?m)^\s+40m\s+60Mi\s*$`)
expectedTotal := regexp.MustCompile(`(?m)^\s+40m\s+60Mi\s+50Mi\s*$`)
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
@@ -162,11 +162,11 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
t.Fatalf("call tool failed %v", err)
}
textContent := podsTopNamespace.Content[0].(mcp.TextContent).Text
expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-1\s+container-1\s+10m\s+20Mi`)
expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-1\s+container-1\s+10m\s+20Mi\s+42Mi`)
if !expectedRow.MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
}
expectedTotal := regexp.MustCompile(`(?m)^\s+10m\s+20Mi\s*$`)
expectedTotal := regexp.MustCompile(`(?m)^\s+10m\s+20Mi\s+42Mi\s*$`)
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
@@ -180,11 +180,11 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
t.Fatalf("call tool failed %v", err)
}
textContent := podsTopNamespaceName.Content[0].(mcp.TextContent).Text
expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-5\s+container-1\s+13m\s+37Mi`)
expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-5\s+container-1\s+13m\s+37Mi\s+42Mi`)
if !expectedRow.MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
}
expectedTotal := regexp.MustCompile(`(?m)^\s+13m\s+37Mi\s*$`)
expectedTotal := regexp.MustCompile(`(?m)^\s+13m\s+37Mi\s+42Mi\s*$`)
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
@@ -201,7 +201,7 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
if !expectedRow.MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
}
expectedTotal := regexp.MustCompile(`(?m)^\s+42m\s+42Mi\s*$`)
expectedTotal := regexp.MustCompile(`(?m)^\s+42m\s+42Mi\s+42Mi\s*$`)
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
@@ -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()

View File

@@ -1,54 +0,0 @@
package mcp
import (
"slices"
"github.com/mark3labs/mcp-go/server"
)
type Profile interface {
GetName() string
GetDescription() string
GetTools(s *Server) []server.ServerTool
}
var Profiles = []Profile{
&FullProfile{},
}
var ProfileNames []string
func ProfileFromString(name string) Profile {
for _, profile := range Profiles {
if profile.GetName() == name {
return profile
}
}
return nil
}
type FullProfile struct{}
func (p *FullProfile) GetName() string {
return "full"
}
func (p *FullProfile) GetDescription() string {
return "Complete profile with all tools and extended outputs"
}
func (p *FullProfile) GetTools(s *Server) []server.ServerTool {
return slices.Concat(
s.initConfiguration(),
s.initEvents(),
s.initNamespaces(),
s.initPods(),
s.initResources(),
s.initHelm(),
)
}
func init() {
ProfileNames = make([]string, 0)
for _, profile := range Profiles {
ProfileNames = append(ProfileNames, profile.GetName())
}
}

View File

@@ -1,89 +0,0 @@
package mcp
import (
"slices"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestFullProfileTools(t *testing.T) {
expectedNames := []string{
"configuration_view",
"events_list",
"helm_install",
"helm_list",
"helm_uninstall",
"namespaces_list",
"pods_list",
"pods_list_in_namespace",
"pods_get",
"pods_delete",
"pods_top",
"pods_log",
"pods_run",
"pods_exec",
"resources_list",
"resources_get",
"resources_create_or_update",
"resources_delete",
}
mcpCtx := &mcpContext{profile: &FullProfile{}}
testCaseWithContext(t, mcpCtx, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
if err != nil {
t.Fatalf("call ListTools failed %v", err)
return
}
})
nameSet := make(map[string]bool)
for _, tool := range tools.Tools {
nameSet[tool.Name] = true
}
for _, name := range expectedNames {
t.Run("ListTools has "+name+" tool", func(t *testing.T) {
if nameSet[name] != true {
t.Fatalf("tool %s not found", name)
return
}
})
}
})
}
func TestFullProfileToolsInOpenShift(t *testing.T) {
mcpCtx := &mcpContext{
profile: &FullProfile{},
before: inOpenShift,
after: inOpenShiftClear,
}
testCaseWithContext(t, mcpCtx, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
if err != nil {
t.Fatalf("call ListTools failed %v", err)
}
})
t.Run("ListTools contains projects_list tool", func(t *testing.T) {
idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool {
return tool.Name == "projects_list"
})
if idx == -1 {
t.Fatalf("tool projects_list not found")
}
})
t.Run("ListTools has resources_list tool with OpenShift hint", func(t *testing.T) {
idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool {
return tool.Name == "resources_list"
})
if idx == -1 {
t.Fatalf("tool resources_list not found")
}
if !strings.Contains(tools.Tools[idx].Description, ", route.openshift.io/v1 Route") {
t.Fatalf("tool resources_list does not have OpenShift hint, got %s", tools.Tools[9].Description)
}
})
})
}

View File

@@ -1,258 +0,0 @@
package mcp
import (
"context"
"errors"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func (s *Server) initResources() []server.ServerTool {
commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress"
if s.k.IsOpenShift(context.Background()) {
commonApiVersion += ", route.openshift.io/v1 Route"
}
commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion)
return []server.ServerTool{
{Tool: mcp.NewTool("resources_list",
mcp.WithDescription("List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n"+
commonApiVersion),
mcp.WithString("apiVersion",
mcp.Description("apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)"),
mcp.Required(),
),
mcp.WithString("kind",
mcp.Description("kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)"),
mcp.Required(),
),
mcp.WithString("namespace",
mcp.Description("Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces")),
mcp.WithString("labelSelector",
mcp.Description("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"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")),
// Tool annotations
mcp.WithTitleAnnotation("Resources: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.resourcesList},
{Tool: mcp.NewTool("resources_get",
mcp.WithDescription("Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n"+
commonApiVersion),
mcp.WithString("apiVersion",
mcp.Description("apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)"),
mcp.Required(),
),
mcp.WithString("kind",
mcp.Description("kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)"),
mcp.Required(),
),
mcp.WithString("namespace",
mcp.Description("Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace"),
),
mcp.WithString("name", mcp.Description("Name of the resource"), mcp.Required()),
// Tool annotations
mcp.WithTitleAnnotation("Resources: Get"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.resourcesGet},
{Tool: mcp.NewTool("resources_create_or_update",
mcp.WithDescription("Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n"+
commonApiVersion),
mcp.WithString("resource",
mcp.Description("A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec"),
mcp.Required(),
),
// Tool annotations
mcp.WithTitleAnnotation("Resources: Create or Update"),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(true),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.resourcesCreateOrUpdate},
{Tool: mcp.NewTool("resources_delete",
mcp.WithDescription("Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n"+
commonApiVersion),
mcp.WithString("apiVersion",
mcp.Description("apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)"),
mcp.Required(),
),
mcp.WithString("kind",
mcp.Description("kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)"),
mcp.Required(),
),
mcp.WithString("namespace",
mcp.Description("Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace"),
),
mcp.WithString("name", mcp.Description("Name of the resource"), mcp.Required()),
// Tool annotations
mcp.WithTitleAnnotation("Resources: Delete"),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(true),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.resourcesDelete},
}
}
func (s *Server) resourcesList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
namespace := ctr.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
labelSelector := ctr.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
}
if labelSelector != nil {
l, ok := labelSelector.(string)
if !ok {
return NewTextResult("", fmt.Errorf("labelSelector is not a string")), nil
}
resourceListOptions.LabelSelector = l
}
gvk, err := parseGroupVersionKind(ctr.GetArguments())
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources, %s", err)), nil
}
ns, ok := namespace.(string)
if !ok {
return NewTextResult("", fmt.Errorf("namespace is not a string")), nil
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.ResourcesList(ctx, gvk, ns, resourceListOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) resourcesGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
namespace := ctr.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
gvk, err := parseGroupVersionKind(ctr.GetArguments())
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get resource, %s", err)), nil
}
name := ctr.GetArguments()["name"]
if name == nil {
return NewTextResult("", errors.New("failed to get resource, missing argument name")), nil
}
ns, ok := namespace.(string)
if !ok {
return NewTextResult("", fmt.Errorf("namespace is not a string")), nil
}
n, ok := name.(string)
if !ok {
return NewTextResult("", fmt.Errorf("name is not a string")), nil
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.ResourcesGet(ctx, gvk, ns, n)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get resource: %v", err)), nil
}
return NewTextResult(output.MarshalYaml(ret)), nil
}
func (s *Server) resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
resource := ctr.GetArguments()["resource"]
if resource == nil || resource == "" {
return NewTextResult("", errors.New("failed to create or update resources, missing argument resource")), nil
}
r, ok := resource.(string)
if !ok {
return NewTextResult("", fmt.Errorf("resource is not a string")), nil
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
resources, err := derived.ResourcesCreateOrUpdate(ctx, r)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
}
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
err = fmt.Errorf("failed to create or update resources:: %v", err)
}
return NewTextResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
}
func (s *Server) resourcesDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
namespace := ctr.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
gvk, err := parseGroupVersionKind(ctr.GetArguments())
if err != nil {
return NewTextResult("", fmt.Errorf("failed to delete resource, %s", err)), nil
}
name := ctr.GetArguments()["name"]
if name == nil {
return NewTextResult("", errors.New("failed to delete resource, missing argument name")), nil
}
ns, ok := namespace.(string)
if !ok {
return NewTextResult("", fmt.Errorf("namespace is not a string")), nil
}
n, ok := name.(string)
if !ok {
return NewTextResult("", fmt.Errorf("name is not a string")), nil
}
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
err = derived.ResourcesDelete(ctx, gvk, ns, n)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
}
return NewTextResult("Resource deleted successfully", err), nil
}
func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) {
apiVersion := arguments["apiVersion"]
if apiVersion == nil {
return nil, errors.New("missing argument apiVersion")
}
kind := arguments["kind"]
if kind == nil {
return nil, errors.New("missing argument kind")
}
a, ok := apiVersion.(string)
if !ok {
return nil, fmt.Errorf("name is not a string")
}
gv, err := schema.ParseGroupVersion(a)
if err != nil {
return nil, errors.New("invalid argument apiVersion")
}
return &schema.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: kind.(string)}, nil
}

View File

@@ -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()

View File

@@ -0,0 +1,22 @@
[
{
"annotations": {
"title": "Configuration: View",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get the current Kubernetes configuration content as a kubeconfig YAML",
"inputSchema": {
"type": "object",
"properties": {
"minified": {
"description": "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)",
"type": "boolean"
}
}
},
"name": "configuration_view"
}
]

View File

@@ -0,0 +1,422 @@
[
{
"annotations": {
"title": "Events: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes events in the current cluster from all namespaces",
"inputSchema": {
"type": "object",
"properties": {
"namespace": {
"description": "Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces",
"type": "string"
}
}
},
"name": "events_list"
},
{
"annotations": {
"title": "Namespaces: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes namespaces in the current cluster",
"inputSchema": {
"type": "object"
},
"name": "namespaces_list"
},
{
"annotations": {
"title": "Pods: Delete",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Delete a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Pod to delete",
"type": "string"
},
"namespace": {
"description": "Namespace to delete the Pod from",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "pods_delete"
},
{
"annotations": {
"title": "Pods: Exec",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command",
"inputSchema": {
"type": "object",
"properties": {
"command": {
"description": "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\"]",
"items": {
"type": "string"
},
"type": "array"
},
"container": {
"description": "Name of the Pod container where the command will be executed (Optional)",
"type": "string"
},
"name": {
"description": "Name of the Pod where the command will be executed",
"type": "string"
},
"namespace": {
"description": "Namespace of the Pod where the command will be executed",
"type": "string"
}
},
"required": [
"name",
"command"
]
},
"name": "pods_exec"
},
{
"annotations": {
"title": "Pods: Get",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Pod",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pod from",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "pods_get"
},
{
"annotations": {
"title": "Pods: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes pods in the current cluster from all namespaces",
"inputSchema": {
"type": "object",
"properties": {
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
}
}
},
"name": "pods_list"
},
{
"annotations": {
"title": "Pods: List in Namespace",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes pods in the specified namespace in the current cluster",
"inputSchema": {
"type": "object",
"properties": {
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"namespace": {
"description": "Namespace to list pods from",
"type": "string"
}
},
"required": [
"namespace"
]
},
"name": "pods_list_in_namespace"
},
{
"annotations": {
"title": "Pods: Log",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"container": {
"description": "Name of the Pod container to get the logs from (Optional)",
"type": "string"
},
"name": {
"description": "Name of the Pod to get the logs from",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pod logs from",
"type": "string"
},
"previous": {
"description": "Return previous terminated container logs (Optional)",
"type": "boolean"
},
"tail": {
"default": 100,
"description": "Number of lines to retrieve from the end of the logs (Optional, default: 100)",
"minimum": 0,
"type": "integer"
}
},
"required": [
"name"
]
},
"name": "pods_log"
},
{
"annotations": {
"title": "Pods: Run",
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name",
"inputSchema": {
"type": "object",
"properties": {
"image": {
"description": "Container Image to run in the Pod",
"type": "string"
},
"name": {
"description": "Name of the Pod (Optional, random name if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to run the Pod in",
"type": "string"
},
"port": {
"description": "TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)",
"type": "number"
}
},
"required": [
"image"
]
},
"name": "pods_run"
},
{
"annotations": {
"title": "Pods: Top",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
},
"description": "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",
"inputSchema": {
"type": "object",
"properties": {
"all_namespaces": {
"default": true,
"description": "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",
"type": "boolean"
},
"label_selector": {
"description": "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)",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"name": {
"description": "Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)",
"type": "string"
}
}
},
"name": "pods_top"
},
{
"annotations": {
"title": "Resources: Create or Update",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)",
"inputSchema": {
"type": "object",
"properties": {
"resource": {
"description": "A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec",
"type": "string"
}
},
"required": [
"resource"
]
},
"name": "resources_create_or_update"
},
{
"annotations": {
"title": "Resources: Delete",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace",
"type": "string"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_delete"
},
{
"annotations": {
"title": "Resources: Get",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace",
"type": "string"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_get"
},
{
"annotations": {
"title": "Resources: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces",
"type": "string"
}
},
"required": [
"apiVersion",
"kind"
]
},
"name": "resources_list"
}
]

View File

@@ -0,0 +1,542 @@
[
{
"annotations": {
"title": "Configuration: View",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get the current Kubernetes configuration content as a kubeconfig YAML",
"inputSchema": {
"type": "object",
"properties": {
"minified": {
"description": "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)",
"type": "boolean"
}
}
},
"name": "configuration_view"
},
{
"annotations": {
"title": "Events: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes events in the current cluster from all namespaces",
"inputSchema": {
"type": "object",
"properties": {
"namespace": {
"description": "Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces",
"type": "string"
}
}
},
"name": "events_list"
},
{
"annotations": {
"title": "Helm: Install",
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Install a Helm chart in the current or provided namespace",
"inputSchema": {
"type": "object",
"properties": {
"chart": {
"description": "Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)",
"type": "string"
},
"name": {
"description": "Name of the Helm release (Optional, random name if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to install the Helm chart in (Optional, current namespace if not provided)",
"type": "string"
},
"values": {
"description": "Values to pass to the Helm chart (Optional)",
"type": "object"
}
},
"required": [
"chart"
]
},
"name": "helm_install"
},
{
"annotations": {
"title": "Helm: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)",
"inputSchema": {
"type": "object",
"properties": {
"all_namespaces": {
"description": "If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)",
"type": "boolean"
},
"namespace": {
"description": "Namespace to list Helm releases from (Optional, all namespaces if not provided)",
"type": "string"
}
}
},
"name": "helm_list"
},
{
"annotations": {
"title": "Helm: Uninstall",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Uninstall a Helm release in the current or provided namespace",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Helm release to uninstall",
"type": "string"
},
"namespace": {
"description": "Namespace to uninstall the Helm release from (Optional, current namespace if not provided)",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "helm_uninstall"
},
{
"annotations": {
"title": "Namespaces: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes namespaces in the current cluster",
"inputSchema": {
"type": "object"
},
"name": "namespaces_list"
},
{
"annotations": {
"title": "Pods: Delete",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Delete a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Pod to delete",
"type": "string"
},
"namespace": {
"description": "Namespace to delete the Pod from",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "pods_delete"
},
{
"annotations": {
"title": "Pods: Exec",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command",
"inputSchema": {
"type": "object",
"properties": {
"command": {
"description": "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\"]",
"items": {
"type": "string"
},
"type": "array"
},
"container": {
"description": "Name of the Pod container where the command will be executed (Optional)",
"type": "string"
},
"name": {
"description": "Name of the Pod where the command will be executed",
"type": "string"
},
"namespace": {
"description": "Namespace of the Pod where the command will be executed",
"type": "string"
}
},
"required": [
"name",
"command"
]
},
"name": "pods_exec"
},
{
"annotations": {
"title": "Pods: Get",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Pod",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pod from",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "pods_get"
},
{
"annotations": {
"title": "Pods: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes pods in the current cluster from all namespaces",
"inputSchema": {
"type": "object",
"properties": {
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
}
}
},
"name": "pods_list"
},
{
"annotations": {
"title": "Pods: List in Namespace",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes pods in the specified namespace in the current cluster",
"inputSchema": {
"type": "object",
"properties": {
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"namespace": {
"description": "Namespace to list pods from",
"type": "string"
}
},
"required": [
"namespace"
]
},
"name": "pods_list_in_namespace"
},
{
"annotations": {
"title": "Pods: Log",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"container": {
"description": "Name of the Pod container to get the logs from (Optional)",
"type": "string"
},
"name": {
"description": "Name of the Pod to get the logs from",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pod logs from",
"type": "string"
},
"previous": {
"description": "Return previous terminated container logs (Optional)",
"type": "boolean"
},
"tail": {
"default": 100,
"description": "Number of lines to retrieve from the end of the logs (Optional, default: 100)",
"minimum": 0,
"type": "integer"
}
},
"required": [
"name"
]
},
"name": "pods_log"
},
{
"annotations": {
"title": "Pods: Run",
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name",
"inputSchema": {
"type": "object",
"properties": {
"image": {
"description": "Container Image to run in the Pod",
"type": "string"
},
"name": {
"description": "Name of the Pod (Optional, random name if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to run the Pod in",
"type": "string"
},
"port": {
"description": "TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)",
"type": "number"
}
},
"required": [
"image"
]
},
"name": "pods_run"
},
{
"annotations": {
"title": "Pods: Top",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
},
"description": "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",
"inputSchema": {
"type": "object",
"properties": {
"all_namespaces": {
"default": true,
"description": "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",
"type": "boolean"
},
"label_selector": {
"description": "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)",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"name": {
"description": "Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)",
"type": "string"
}
}
},
"name": "pods_top"
},
{
"annotations": {
"title": "Projects: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the OpenShift projects in the current cluster",
"inputSchema": {
"type": "object"
},
"name": "projects_list"
},
{
"annotations": {
"title": "Resources: Create or Update",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)",
"inputSchema": {
"type": "object",
"properties": {
"resource": {
"description": "A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec",
"type": "string"
}
},
"required": [
"resource"
]
},
"name": "resources_create_or_update"
},
{
"annotations": {
"title": "Resources: Delete",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace",
"type": "string"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_delete"
},
{
"annotations": {
"title": "Resources: Get",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace",
"type": "string"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_get"
},
{
"annotations": {
"title": "Resources: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces",
"type": "string"
}
},
"required": [
"apiVersion",
"kind"
]
},
"name": "resources_list"
}
]

View File

@@ -0,0 +1,528 @@
[
{
"annotations": {
"title": "Configuration: View",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get the current Kubernetes configuration content as a kubeconfig YAML",
"inputSchema": {
"type": "object",
"properties": {
"minified": {
"description": "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)",
"type": "boolean"
}
}
},
"name": "configuration_view"
},
{
"annotations": {
"title": "Events: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes events in the current cluster from all namespaces",
"inputSchema": {
"type": "object",
"properties": {
"namespace": {
"description": "Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces",
"type": "string"
}
}
},
"name": "events_list"
},
{
"annotations": {
"title": "Helm: Install",
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Install a Helm chart in the current or provided namespace",
"inputSchema": {
"type": "object",
"properties": {
"chart": {
"description": "Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)",
"type": "string"
},
"name": {
"description": "Name of the Helm release (Optional, random name if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to install the Helm chart in (Optional, current namespace if not provided)",
"type": "string"
},
"values": {
"description": "Values to pass to the Helm chart (Optional)",
"type": "object"
}
},
"required": [
"chart"
]
},
"name": "helm_install"
},
{
"annotations": {
"title": "Helm: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)",
"inputSchema": {
"type": "object",
"properties": {
"all_namespaces": {
"description": "If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)",
"type": "boolean"
},
"namespace": {
"description": "Namespace to list Helm releases from (Optional, all namespaces if not provided)",
"type": "string"
}
}
},
"name": "helm_list"
},
{
"annotations": {
"title": "Helm: Uninstall",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Uninstall a Helm release in the current or provided namespace",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Helm release to uninstall",
"type": "string"
},
"namespace": {
"description": "Namespace to uninstall the Helm release from (Optional, current namespace if not provided)",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "helm_uninstall"
},
{
"annotations": {
"title": "Namespaces: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes namespaces in the current cluster",
"inputSchema": {
"type": "object"
},
"name": "namespaces_list"
},
{
"annotations": {
"title": "Pods: Delete",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Delete a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Pod to delete",
"type": "string"
},
"namespace": {
"description": "Namespace to delete the Pod from",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "pods_delete"
},
{
"annotations": {
"title": "Pods: Exec",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command",
"inputSchema": {
"type": "object",
"properties": {
"command": {
"description": "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\"]",
"items": {
"type": "string"
},
"type": "array"
},
"container": {
"description": "Name of the Pod container where the command will be executed (Optional)",
"type": "string"
},
"name": {
"description": "Name of the Pod where the command will be executed",
"type": "string"
},
"namespace": {
"description": "Namespace of the Pod where the command will be executed",
"type": "string"
}
},
"required": [
"name",
"command"
]
},
"name": "pods_exec"
},
{
"annotations": {
"title": "Pods: Get",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Pod",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pod from",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "pods_get"
},
{
"annotations": {
"title": "Pods: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes pods in the current cluster from all namespaces",
"inputSchema": {
"type": "object",
"properties": {
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
}
}
},
"name": "pods_list"
},
{
"annotations": {
"title": "Pods: List in Namespace",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Kubernetes pods in the specified namespace in the current cluster",
"inputSchema": {
"type": "object",
"properties": {
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"namespace": {
"description": "Namespace to list pods from",
"type": "string"
}
},
"required": [
"namespace"
]
},
"name": "pods_list_in_namespace"
},
{
"annotations": {
"title": "Pods: Log",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name",
"inputSchema": {
"type": "object",
"properties": {
"container": {
"description": "Name of the Pod container to get the logs from (Optional)",
"type": "string"
},
"name": {
"description": "Name of the Pod to get the logs from",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pod logs from",
"type": "string"
},
"previous": {
"description": "Return previous terminated container logs (Optional)",
"type": "boolean"
},
"tail": {
"default": 100,
"description": "Number of lines to retrieve from the end of the logs (Optional, default: 100)",
"minimum": 0,
"type": "integer"
}
},
"required": [
"name"
]
},
"name": "pods_log"
},
{
"annotations": {
"title": "Pods: Run",
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name",
"inputSchema": {
"type": "object",
"properties": {
"image": {
"description": "Container Image to run in the Pod",
"type": "string"
},
"name": {
"description": "Name of the Pod (Optional, random name if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to run the Pod in",
"type": "string"
},
"port": {
"description": "TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)",
"type": "number"
}
},
"required": [
"image"
]
},
"name": "pods_run"
},
{
"annotations": {
"title": "Pods: Top",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
},
"description": "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",
"inputSchema": {
"type": "object",
"properties": {
"all_namespaces": {
"default": true,
"description": "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",
"type": "boolean"
},
"label_selector": {
"description": "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)",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"name": {
"description": "Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)",
"type": "string"
}
}
},
"name": "pods_top"
},
{
"annotations": {
"title": "Resources: Create or Update",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)",
"inputSchema": {
"type": "object",
"properties": {
"resource": {
"description": "A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec",
"type": "string"
}
},
"required": [
"resource"
]
},
"name": "resources_create_or_update"
},
{
"annotations": {
"title": "Resources: Delete",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace",
"type": "string"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_delete"
},
{
"annotations": {
"title": "Resources: Get",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace",
"type": "string"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_get"
},
{
"annotations": {
"title": "Resources: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)",
"type": "string"
},
"labelSelector": {
"description": "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",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces",
"type": "string"
}
},
"required": [
"apiVersion",
"kind"
]
},
"name": "resources_list"
}
]

View File

@@ -0,0 +1,88 @@
[
{
"annotations": {
"title": "Helm: Install",
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "Install a Helm chart in the current or provided namespace",
"inputSchema": {
"type": "object",
"properties": {
"chart": {
"description": "Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)",
"type": "string"
},
"name": {
"description": "Name of the Helm release (Optional, random name if not provided)",
"type": "string"
},
"namespace": {
"description": "Namespace to install the Helm chart in (Optional, current namespace if not provided)",
"type": "string"
},
"values": {
"description": "Values to pass to the Helm chart (Optional)",
"type": "object"
}
},
"required": [
"chart"
]
},
"name": "helm_install"
},
{
"annotations": {
"title": "Helm: List",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
},
"description": "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)",
"inputSchema": {
"type": "object",
"properties": {
"all_namespaces": {
"description": "If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)",
"type": "boolean"
},
"namespace": {
"description": "Namespace to list Helm releases from (Optional, all namespaces if not provided)",
"type": "string"
}
}
},
"name": "helm_list"
},
{
"annotations": {
"title": "Helm: Uninstall",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Uninstall a Helm release in the current or provided namespace",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Helm release to uninstall",
"type": "string"
},
"namespace": {
"description": "Namespace to uninstall the Helm release from (Optional, current namespace if not provided)",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "helm_uninstall"
}
]

159
pkg/mcp/toolsets_test.go Normal file
View File

@@ -0,0 +1,159 @@
package mcp
import (
"encoding/json"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/suite"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/api"
configuration "github.com/containers/kubernetes-mcp-server/pkg/config"
"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 ToolsetsSuite struct {
suite.Suite
originalToolsets []api.Toolset
*test.MockServer
*test.McpClient
Cfg *configuration.StaticConfig
mcpServer *Server
}
func (s *ToolsetsSuite) SetupTest() {
s.originalToolsets = toolsets.Toolsets()
s.MockServer = test.NewMockServer()
s.Cfg = configuration.Default()
s.Cfg.KubeConfig = s.MockServer.KubeconfigFile(s.T())
}
func (s *ToolsetsSuite) TearDownTest() {
toolsets.Clear()
for _, toolset := range s.originalToolsets {
toolsets.Register(toolset)
}
s.MockServer.Close()
}
func (s *ToolsetsSuite) TearDownSubTest() {
if s.McpClient != nil {
s.McpClient.Close()
}
if s.mcpServer != nil {
s.mcpServer.Close()
}
}
func (s *ToolsetsSuite) TestNoToolsets() {
s.Run("No toolsets registered", func() {
toolsets.Clear()
s.Cfg.Toolsets = []string{}
s.InitMcpClient()
tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
s.Run("ListTools returns no tools", func() {
s.NotNil(tools, "Expected tools from ListTools")
s.NoError(err, "Expected no error from ListTools")
s.Empty(tools.Tools, "Expected no tools from ListTools")
})
})
}
func (s *ToolsetsSuite) TestDefaultToolsetsTools() {
s.Run("Default configuration toolsets", func() {
s.InitMcpClient()
tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
s.Run("ListTools returns tools", func() {
s.NotNil(tools, "Expected tools from ListTools")
s.NoError(err, "Expected no error from ListTools")
})
s.Run("ListTools returns correct Tool metadata", func() {
expectedMetadata := test.ReadFile("testdata", "toolsets-full-tools.json")
metadata, err := json.MarshalIndent(tools.Tools, "", " ")
s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
s.JSONEq(expectedMetadata, string(metadata), "tools metadata does not match expected")
})
})
}
func (s *ToolsetsSuite) TestDefaultToolsetsToolsInOpenShift() {
s.Run("Default configuration toolsets in OpenShift", func() {
s.Handle(&test.InOpenShiftHandler{})
s.InitMcpClient()
tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
s.Run("ListTools returns tools", func() {
s.NotNil(tools, "Expected tools from ListTools")
s.NoError(err, "Expected no error from ListTools")
})
s.Run("ListTools returns correct Tool metadata", func() {
expectedMetadata := test.ReadFile("testdata", "toolsets-full-tools-openshift.json")
metadata, err := json.MarshalIndent(tools.Tools, "", " ")
s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
s.JSONEq(expectedMetadata, string(metadata), "tools metadata does not match expected")
})
})
}
func (s *ToolsetsSuite) TestGranularToolsetsTools() {
testCases := []api.Toolset{
&core.Toolset{},
&config.Toolset{},
&helm.Toolset{},
}
for _, testCase := range testCases {
s.Run("Toolset "+testCase.GetName(), func() {
toolsets.Clear()
toolsets.Register(testCase)
s.Cfg.Toolsets = []string{testCase.GetName()}
s.InitMcpClient()
tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
s.Run("ListTools returns tools", func() {
s.NotNil(tools, "Expected tools from ListTools")
s.NoError(err, "Expected no error from ListTools")
})
s.Run("ListTools returns correct Tool metadata", func() {
expectedMetadata := test.ReadFile("testdata", "toolsets-"+testCase.GetName()+"-tools.json")
metadata, err := json.MarshalIndent(tools.Tools, "", " ")
s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
s.JSONEq(expectedMetadata, string(metadata), "tools metadata does not match expected")
})
})
}
}
func (s *ToolsetsSuite) TestInputSchemaEdgeCases() {
//https://github.com/containers/kubernetes-mcp-server/issues/340
s.Run("InputSchema for no-arg tool is object with empty properties", func() {
s.InitMcpClient()
tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
s.Run("ListTools returns tools", func() {
s.NotNil(tools, "Expected tools from ListTools")
s.NoError(err, "Expected no error from ListTools")
})
var namespacesList *mcp.Tool
for _, tool := range tools.Tools {
if tool.Name == "namespaces_list" {
namespacesList = &tool
break
}
}
s.Require().NotNil(namespacesList, "Expected namespaces_list from ListTools")
s.NotNil(namespacesList.InputSchema.Properties, "Expected namespaces_list.InputSchema.Properties not to be nil")
s.Empty(namespacesList.InputSchema.Properties, "Expected namespaces_list.InputSchema.Properties to be empty")
})
}
func (s *ToolsetsSuite) InitMcpClient() {
var err error
s.mcpServer, err = NewServer(Configuration{StaticConfig: s.Cfg})
s.Require().NoError(err, "Expected no error creating MCP server")
s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil))
}
func TestToolsets(t *testing.T) {
suite.Run(t, new(ToolsetsSuite))
}

View File

@@ -0,0 +1,57 @@
package config
import (
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func initConfiguration() []api.ServerTool {
tools := []api.ServerTool{
{Tool: api.Tool{
Name: "configuration_view",
Description: "Get the current Kubernetes configuration content as a kubeconfig YAML",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"minified": {
Type: "boolean",
Description: "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)",
},
},
},
Annotations: api.ToolAnnotations{
Title: "Configuration: View",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: configurationView},
}
return tools
}
func configurationView(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
minify := true
minified := params.GetArguments()["minified"]
if _, ok := minified.(bool); ok {
minify = minified.(bool)
}
ret, err := params.ConfigurationView(minify)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to get configuration: %v", err)), nil
}
configurationYaml, err := output.MarshalYaml(ret)
if err != nil {
err = fmt.Errorf("failed to get configuration: %v", err)
}
return api.NewToolCallResult(configurationYaml, err), nil
}

View 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{})
}

View File

@@ -0,0 +1,55 @@
package core
import (
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func initEvents() []api.ServerTool {
return []api.ServerTool{
{Tool: api.Tool{
Name: "events_list",
Description: "List all the Kubernetes events in the current cluster from all namespaces",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces",
},
},
},
Annotations: api.ToolAnnotations{
Title: "Events: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: eventsList},
}
}
func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := params.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
eventMap, err := params.EventsList(params, namespace.(string))
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
}
if len(eventMap) == 0 {
return api.NewToolCallResult("No events found", nil), nil
}
yamlEvents, err := output.MarshalYaml(eventMap)
if err != nil {
err = fmt.Errorf("failed to list events in all namespaces: %v", err)
}
return api.NewToolCallResult(fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), err), nil
}

View File

@@ -0,0 +1,68 @@
package core
import (
"context"
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)
func initNamespaces(o internalk8s.Openshift) []api.ServerTool {
ret := make([]api.ServerTool, 0)
ret = append(ret, api.ServerTool{
Tool: api.Tool{
Name: "namespaces_list",
Description: "List all the Kubernetes namespaces in the current cluster",
InputSchema: &jsonschema.Schema{
Type: "object",
},
Annotations: api.ToolAnnotations{
Title: "Namespaces: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: namespacesList,
})
if o.IsOpenShift(context.Background()) {
ret = append(ret, api.ServerTool{
Tool: api.Tool{
Name: "projects_list",
Description: "List all the OpenShift projects in the current cluster",
InputSchema: &jsonschema.Schema{
Type: "object",
},
Annotations: api.ToolAnnotations{
Title: "Projects: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: projectsList,
})
}
return ret
}
func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ret, err := params.NamespacesList(params, kubernetes.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil
}
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}
func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ret, err := params.ProjectsList(params, kubernetes.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %v", err)), nil
}
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}

457
pkg/toolsets/core/pods.go Normal file
View File

@@ -0,0 +1,457 @@
package core
import (
"bytes"
"errors"
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"k8s.io/kubectl/pkg/metricsutil"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func initPods() []api.ServerTool {
return []api.ServerTool{
{Tool: api.Tool{
Name: "pods_list",
Description: "List all the Kubernetes pods in the current cluster from all namespaces",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"labelSelector": {
Type: "string",
Description: "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",
Pattern: "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
},
},
},
Annotations: api.ToolAnnotations{
Title: "Pods: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: podsListInAllNamespaces},
{Tool: api.Tool{
Name: "pods_list_in_namespace",
Description: "List all the Kubernetes pods in the specified namespace in the current cluster",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Namespace to list pods from",
},
"labelSelector": {
Type: "string",
Description: "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",
Pattern: "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
},
},
Required: []string{"namespace"},
},
Annotations: api.ToolAnnotations{
Title: "Pods: List in Namespace",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: podsListInNamespace},
{Tool: api.Tool{
Name: "pods_get",
Description: "Get a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Namespace to get the Pod from",
},
"name": {
Type: "string",
Description: "Name of the Pod",
},
},
Required: []string{"name"},
},
Annotations: api.ToolAnnotations{
Title: "Pods: Get",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: podsGet},
{Tool: api.Tool{
Name: "pods_delete",
Description: "Delete a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Namespace to delete the Pod from",
},
"name": {
Type: "string",
Description: "Name of the Pod to delete",
},
},
Required: []string{"name"},
},
Annotations: api.ToolAnnotations{
Title: "Pods: Delete",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true),
},
}, Handler: podsDelete},
{Tool: api.Tool{
Name: "pods_top",
Description: "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",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"all_namespaces": {
Type: "boolean",
Description: "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",
Default: api.ToRawMessage(true),
},
"namespace": {
Type: "string",
Description: "Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)",
},
"name": {
Type: "string",
Description: "Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)",
},
"label_selector": {
Type: "string",
Description: "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)",
Pattern: "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
},
},
},
Annotations: api.ToolAnnotations{
Title: "Pods: Top",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true),
},
}, Handler: podsTop},
{Tool: api.Tool{
Name: "pods_exec",
Description: "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Namespace of the Pod where the command will be executed",
},
"name": {
Type: "string",
Description: "Name of the Pod where the command will be executed",
},
"command": {
Type: "array",
Description: "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\"]",
Items: &jsonschema.Schema{
Type: "string",
},
},
"container": {
Type: "string",
Description: "Name of the Pod container where the command will be executed (Optional)",
},
},
Required: []string{"name", "command"},
},
Annotations: api.ToolAnnotations{
Title: "Pods: Exec",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true), // Depending on the Pod's entrypoint, executing certain commands may kill the Pod
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: podsExec},
{Tool: api.Tool{
Name: "pods_log",
Description: "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Namespace to get the Pod logs from",
},
"name": {
Type: "string",
Description: "Name of the Pod to get the logs from",
},
"container": {
Type: "string",
Description: "Name of the Pod container to get the logs from (Optional)",
},
"tail": {
Type: "integer",
Description: "Number of lines to retrieve from the end of the logs (Optional, default: 100)",
Default: api.ToRawMessage(kubernetes.DefaultTailLines),
Minimum: ptr.To(float64(0)),
},
"previous": {
Type: "boolean",
Description: "Return previous terminated container logs (Optional)",
},
},
Required: []string{"name"},
},
Annotations: api.ToolAnnotations{
Title: "Pods: Log",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: podsLog},
{Tool: api.Tool{
Name: "pods_run",
Description: "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Namespace to run the Pod in",
},
"name": {
Type: "string",
Description: "Name of the Pod (Optional, random name if not provided)",
},
"image": {
Type: "string",
Description: "Container Image to run in the Pod",
},
"port": {
Type: "number",
Description: "TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)",
},
},
Required: []string{"image"},
},
Annotations: api.ToolAnnotations{
Title: "Pods: Run",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: podsRun},
}
}
func podsListInAllNamespaces(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
labelSelector := params.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: params.ListOutput.AsTable(),
}
if labelSelector != nil {
resourceListOptions.LabelSelector = labelSelector.(string)
}
ret, err := params.PodsListInAllNamespaces(params, resourceListOptions)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil
}
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}
func podsListInNamespace(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
return api.NewToolCallResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil
}
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: params.ListOutput.AsTable(),
}
labelSelector := params.GetArguments()["labelSelector"]
if labelSelector != nil {
resourceListOptions.LabelSelector = labelSelector.(string)
}
ret, err := params.PodsListInNamespace(params, ns.(string), resourceListOptions)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil
}
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}
func podsGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := params.GetArguments()["name"]
if name == nil {
return api.NewToolCallResult("", errors.New("failed to get pod, missing argument name")), nil
}
ret, err := params.PodsGet(params, ns.(string), name.(string))
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil
}
return api.NewToolCallResult(output.MarshalYaml(ret)), nil
}
func podsDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := params.GetArguments()["name"]
if name == nil {
return api.NewToolCallResult("", errors.New("failed to delete pod, missing argument name")), nil
}
ret, err := params.PodsDelete(params, ns.(string), name.(string))
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %v", name, ns, err)), nil
}
return api.NewToolCallResult(ret, err), nil
}
func podsTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true}
if v, ok := params.GetArguments()["namespace"].(string); ok {
podsTopOptions.Namespace = v
}
if v, ok := params.GetArguments()["all_namespaces"].(bool); ok {
podsTopOptions.AllNamespaces = v
}
if v, ok := params.GetArguments()["name"].(string); ok {
podsTopOptions.Name = v
}
if v, ok := params.GetArguments()["label_selector"].(string); ok {
podsTopOptions.LabelSelector = v
}
ret, err := params.PodsTop(params, podsTopOptions)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
}
buf := new(bytes.Buffer)
printer := metricsutil.NewTopCmdPrinter(buf, true)
err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
}
return api.NewToolCallResult(buf.String(), nil), nil
}
func podsExec(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := params.GetArguments()["name"]
if name == nil {
return api.NewToolCallResult("", errors.New("failed to exec in pod, missing argument name")), nil
}
container := params.GetArguments()["container"]
if container == nil {
container = ""
}
commandArg := params.GetArguments()["command"]
command := make([]string, 0)
if _, ok := commandArg.([]interface{}); ok {
for _, cmd := range commandArg.([]interface{}) {
if _, ok := cmd.(string); ok {
command = append(command, cmd.(string))
}
}
} else {
return api.NewToolCallResult("", errors.New("failed to exec in pod, invalid command argument")), nil
}
ret, err := params.PodsExec(params, ns.(string), name.(string), container.(string), command)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil
} else if ret == "" {
ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns)
}
return api.NewToolCallResult(ret, err), nil
}
func podsLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := params.GetArguments()["name"]
if name == nil {
return api.NewToolCallResult("", errors.New("failed to get pod log, missing argument name")), nil
}
container := params.GetArguments()["container"]
if container == nil {
container = ""
}
previous := params.GetArguments()["previous"]
var previousBool bool
if previous != nil {
previousBool = previous.(bool)
}
// Extract tailLines parameter
tail := params.GetArguments()["tail"]
var tailInt int64
if tail != nil {
// Convert to int64 - safely handle both float64 (JSON number) and int types
switch v := tail.(type) {
case float64:
tailInt = int64(v)
case int:
tailInt = int64(v)
case int64:
tailInt = v
default:
return api.NewToolCallResult("", fmt.Errorf("failed to parse tail parameter: expected integer, got %T", tail)), nil
}
}
ret, err := params.PodsLog(params.Context, ns.(string), name.(string), container.(string), previousBool, tailInt)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil
} else if ret == "" {
ret = fmt.Sprintf("The pod %s in namespace %s has not logged any message yet", name, ns)
}
return api.NewToolCallResult(ret, err), nil
}
func podsRun(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := params.GetArguments()["name"]
if name == nil {
name = ""
}
image := params.GetArguments()["image"]
if image == nil {
return api.NewToolCallResult("", errors.New("failed to run pod, missing argument image")), nil
}
port := params.GetArguments()["port"]
if port == nil {
port = float64(0)
}
resources, err := params.PodsRun(params, ns.(string), name.(string), image.(string), int32(port.(float64)))
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to run pod %s in namespace %s: %v", name, ns, err)), nil
}
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
err = fmt.Errorf("failed to run pod: %v", err)
}
return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
}

View File

@@ -0,0 +1,287 @@
package core
import (
"context"
"errors"
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func initResources(o internalk8s.Openshift) []api.ServerTool {
commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress"
if o.IsOpenShift(context.Background()) {
commonApiVersion += ", route.openshift.io/v1 Route"
}
commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion)
return []api.ServerTool{
{Tool: api.Tool{
Name: "resources_list",
Description: "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n" + commonApiVersion,
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"apiVersion": {
Type: "string",
Description: "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
},
"kind": {
Type: "string",
Description: "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)",
},
"namespace": {
Type: "string",
Description: "Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces",
},
"labelSelector": {
Type: "string",
Description: "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",
Pattern: "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
},
},
Required: []string{"apiVersion", "kind"},
},
Annotations: api.ToolAnnotations{
Title: "Resources: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: resourcesList},
{Tool: api.Tool{
Name: "resources_get",
Description: "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion,
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"apiVersion": {
Type: "string",
Description: "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
},
"kind": {
Type: "string",
Description: "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
},
"namespace": {
Type: "string",
Description: "Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace",
},
"name": {
Type: "string",
Description: "Name of the resource",
},
},
Required: []string{"apiVersion", "kind", "name"},
},
Annotations: api.ToolAnnotations{
Title: "Resources: Get",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: resourcesGet},
{Tool: api.Tool{
Name: "resources_create_or_update",
Description: "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n" + commonApiVersion,
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"resource": {
Type: "string",
Description: "A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec",
},
},
Required: []string{"resource"},
},
Annotations: api.ToolAnnotations{
Title: "Resources: Create or Update",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true),
},
}, Handler: resourcesCreateOrUpdate},
{Tool: api.Tool{
Name: "resources_delete",
Description: "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion,
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"apiVersion": {
Type: "string",
Description: "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
},
"kind": {
Type: "string",
Description: "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
},
"namespace": {
Type: "string",
Description: "Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace",
},
"name": {
Type: "string",
Description: "Name of the resource",
},
},
Required: []string{"apiVersion", "kind", "name"},
},
Annotations: api.ToolAnnotations{
Title: "Resources: Delete",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true),
},
}, Handler: resourcesDelete},
}
}
func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := params.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
labelSelector := params.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: params.ListOutput.AsTable(),
}
if labelSelector != nil {
l, ok := labelSelector.(string)
if !ok {
return api.NewToolCallResult("", fmt.Errorf("labelSelector is not a string")), nil
}
resourceListOptions.LabelSelector = l
}
gvk, err := parseGroupVersionKind(params.GetArguments())
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list resources, %s", err)), nil
}
ns, ok := namespace.(string)
if !ok {
return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
}
ret, err := params.ResourcesList(params, gvk, ns, resourceListOptions)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %v", err)), nil
}
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}
func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := params.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
gvk, err := parseGroupVersionKind(params.GetArguments())
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to get resource, %s", err)), nil
}
name := params.GetArguments()["name"]
if name == nil {
return api.NewToolCallResult("", errors.New("failed to get resource, missing argument name")), nil
}
ns, ok := namespace.(string)
if !ok {
return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
}
n, ok := name.(string)
if !ok {
return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
}
ret, err := params.ResourcesGet(params, gvk, ns, n)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %v", err)), nil
}
return api.NewToolCallResult(output.MarshalYaml(ret)), nil
}
func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
resource := params.GetArguments()["resource"]
if resource == nil || resource == "" {
return api.NewToolCallResult("", errors.New("failed to create or update resources, missing argument resource")), nil
}
r, ok := resource.(string)
if !ok {
return api.NewToolCallResult("", fmt.Errorf("resource is not a string")), nil
}
resources, err := params.ResourcesCreateOrUpdate(params, r)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
}
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
err = fmt.Errorf("failed to create or update resources:: %v", err)
}
return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
}
func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := params.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
gvk, err := parseGroupVersionKind(params.GetArguments())
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to delete resource, %s", err)), nil
}
name := params.GetArguments()["name"]
if name == nil {
return api.NewToolCallResult("", errors.New("failed to delete resource, missing argument name")), nil
}
ns, ok := namespace.(string)
if !ok {
return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
}
n, ok := name.(string)
if !ok {
return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
}
err = params.ResourcesDelete(params, gvk, ns, n)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
}
return api.NewToolCallResult("Resource deleted successfully", err), nil
}
func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) {
apiVersion := arguments["apiVersion"]
if apiVersion == nil {
return nil, errors.New("missing argument apiVersion")
}
kind := arguments["kind"]
if kind == nil {
return nil, errors.New("missing argument kind")
}
a, ok := apiVersion.(string)
if !ok {
return nil, fmt.Errorf("name is not a string")
}
gv, err := schema.ParseGroupVersion(a)
if err != nil {
return nil, errors.New("invalid argument apiVersion")
}
return &schema.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: kind.(string)}, nil
}

View 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{})
}

156
pkg/toolsets/helm/helm.go Normal file
View File

@@ -0,0 +1,156 @@
package helm
import (
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
)
func initHelm() []api.ServerTool {
return []api.ServerTool{
{Tool: api.Tool{
Name: "helm_install",
Description: "Install a Helm chart in the current or provided namespace",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"chart": {
Type: "string",
Description: "Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)",
},
"values": {
Type: "object",
Description: "Values to pass to the Helm chart (Optional)",
Properties: make(map[string]*jsonschema.Schema),
},
"name": {
Type: "string",
Description: "Name of the Helm release (Optional, random name if not provided)",
},
"namespace": {
Type: "string",
Description: "Namespace to install the Helm chart in (Optional, current namespace if not provided)",
},
},
Required: []string{"chart"},
},
Annotations: api.ToolAnnotations{
Title: "Helm: Install",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install
OpenWorldHint: ptr.To(true),
},
}, Handler: helmInstall},
{Tool: api.Tool{
Name: "helm_list",
Description: "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Namespace to list Helm releases from (Optional, all namespaces if not provided)",
},
"all_namespaces": {
Type: "boolean",
Description: "If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)",
},
},
},
Annotations: api.ToolAnnotations{
Title: "Helm: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: helmList},
{Tool: api.Tool{
Name: "helm_uninstall",
Description: "Uninstall a Helm release in the current or provided namespace",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: "Name of the Helm release to uninstall",
},
"namespace": {
Type: "string",
Description: "Namespace to uninstall the Helm release from (Optional, current namespace if not provided)",
},
},
Required: []string{"name"},
},
Annotations: api.ToolAnnotations{
Title: "Helm: Uninstall",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true),
},
}, Handler: helmUninstall},
}
}
func helmInstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
var chart string
ok := false
if chart, ok = params.GetArguments()["chart"].(string); !ok {
return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
}
values := map[string]interface{}{}
if v, ok := params.GetArguments()["values"].(map[string]interface{}); ok {
values = v
}
name := ""
if v, ok := params.GetArguments()["name"].(string); ok {
name = v
}
namespace := ""
if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v
}
ret, err := params.NewHelm().Install(params, chart, values, name, namespace)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
}
return api.NewToolCallResult(ret, err), nil
}
func helmList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
allNamespaces := false
if v, ok := params.GetArguments()["all_namespaces"].(bool); ok {
allNamespaces = v
}
namespace := ""
if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v
}
ret, err := params.NewHelm().List(namespace, allNamespaces)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil
}
return api.NewToolCallResult(ret, err), nil
}
func helmUninstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
var name string
ok := false
if name, ok = params.GetArguments()["name"].(string); !ok {
return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil
}
namespace := ""
if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v
}
ret, err := params.NewHelm().Uninstall(name, namespace)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil
}
return api.NewToolCallResult(ret, err), nil
}

View 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{})
}

51
pkg/toolsets/toolsets.go Normal file
View File

@@ -0,0 +1,51 @@
package toolsets
import (
"fmt"
"slices"
"strings"
"github.com/containers/kubernetes-mcp-server/pkg/api"
)
var toolsets []api.Toolset
// Clear removes all registered toolsets, TESTING PURPOSES ONLY.
func Clear() {
toolsets = []api.Toolset{}
}
func Register(toolset api.Toolset) {
toolsets = append(toolsets, toolset)
}
func Toolsets() []api.Toolset {
return toolsets
}
func ToolsetNames() []string {
names := make([]string, 0)
for _, toolset := range Toolsets() {
names = append(names, toolset.GetName())
}
slices.Sort(names)
return names
}
func ToolsetFromString(name string) api.Toolset {
for _, toolset := range Toolsets() {
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
}

View File

@@ -0,0 +1,97 @@
package toolsets
import (
"testing"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/stretchr/testify/suite"
)
type ToolsetsSuite struct {
suite.Suite
originalToolsets []api.Toolset
}
func (s *ToolsetsSuite) SetupTest() {
s.originalToolsets = Toolsets()
Clear()
}
func (s *ToolsetsSuite) TearDownTest() {
for _, toolset := range s.originalToolsets {
Register(toolset)
}
}
type TestToolset struct {
name string
description string
}
func (t *TestToolset) GetName() string { return t.name }
func (t *TestToolset) GetDescription() string { return t.description }
func (t *TestToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool { return nil }
var _ api.Toolset = (*TestToolset)(nil)
func (s *ToolsetsSuite) TestToolsetNames() {
s.Run("Returns empty list if no toolsets registered", func() {
s.Empty(ToolsetNames(), "Expected empty list of toolset names")
})
Register(&TestToolset{name: "z"})
Register(&TestToolset{name: "b"})
Register(&TestToolset{name: "1"})
s.Run("Returns sorted list of registered toolset names", func() {
names := ToolsetNames()
s.Equal([]string{"1", "b", "z"}, names, "Expected sorted list of toolset names")
})
}
func (s *ToolsetsSuite) TestToolsetFromString() {
s.Run("Returns nil if toolset not found", func() {
s.Nil(ToolsetFromString("non-existent"), "Expected nil for non-existent toolset")
})
s.Run("Returns the correct toolset if found", func() {
Register(&TestToolset{name: "existent"})
res := ToolsetFromString("existent")
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) {
suite.Run(t, new(ToolsetsSuite))
}

View File

@@ -1,5 +1,31 @@
# Changelog
## 3.4.0 (2025-06-27)
### Added
- #268: Added property to Constraints to include prereleases for Check and Validate
### Changed
- #263: Updated Go testing for 1.24, 1.23, and 1.22
- #269: Updated the error message handling for message case and wrapping errors
- #266: Restore the ability to have leading 0's when parsing with NewVersion.
Opt-out of this by setting CoerceNewVersion to false.
### Fixed
- #257: Fixed the CodeQL link (thanks @dmitris)
- #262: Restored detailed errors when failed to parse with NewVersion. Opt-out
of this by setting DetailedNewVersionErrors to false for faster performance.
- #267: Handle pre-releases for an "and" group if one constraint includes them
## 3.3.1 (2024-11-19)
### Fixed
- #253: Fix for allowing some version that were invalid
## 3.3.0 (2024-08-27)
### Added
@@ -137,7 +163,7 @@ functions. These are described in the added and changed sections below.
- #78: Fix unchecked error in example code (thanks @ravron)
- #70: Fix the handling of pre-releases and the 0.0.0 release edge case
- #97: Fixed copyright file for proper display on GitHub
- #107: Fix handling prerelease when sorting alphanum and num
- #107: Fix handling prerelease when sorting alphanum and num
- #109: Fixed where Validate sometimes returns wrong message on error
## 1.4.2 (2018-04-10)

View File

@@ -50,6 +50,18 @@ other versions, convert the version back into a string, and get the original
string. Getting the original string is useful if the semantic version was coerced
into a valid form.
There are package level variables that affect how `NewVersion` handles parsing.
- `CoerceNewVersion` is `true` by default. When set to `true` it coerces non-compliant
versions into SemVer. For example, allowing a leading 0 in a major, minor, or patch
part. This enables the use of CalVer in versions even when not compliant with SemVer.
When set to `false` less coercion work is done.
- `DetailedNewVersionErrors` provides more detailed errors. It only has an affect when
`CoerceNewVersion` is set to `false`. When `DetailedNewVersionErrors` is set to `true`
it can provide some more insight into why a version is invalid. Setting
`DetailedNewVersionErrors` to `false` is faster on performance but provides less
detailed error messages if a version fails to parse.
## Sorting Semantic Versions
A set of versions can be sorted using the `sort` package from the standard library.
@@ -160,6 +172,10 @@ means `>=1.2.3-BETA` will return `1.2.3-alpha`. What you might expect from case
sensitivity doesn't apply here. This is due to ASCII sort ordering which is what
the spec specifies.
The `Constraints` instance returned from `semver.NewConstraint()` has a property
`IncludePrerelease` that, when set to true, will return prerelease versions when calls
to `Check()` and `Validate()` are made.
### Hyphen Range Comparisons
There are multiple methods to handle ranges and the first is hyphens ranges.
@@ -250,7 +266,7 @@ or [create a pull request](https://github.com/Masterminds/semver/pulls).
Security is an important consideration for this project. The project currently
uses the following tools to help discover security issues:
* [CodeQL](https://github.com/Masterminds/semver)
* [CodeQL](https://codeql.github.com)
* [gosec](https://github.com/securego/gosec)
* Daily Fuzz testing

View File

@@ -12,6 +12,13 @@ import (
// checked against.
type Constraints struct {
constraints [][]*constraint
containsPre []bool
// IncludePrerelease specifies if pre-releases should be included in
// the results. Note, if a constraint range has a prerelease than
// prereleases will be included for that AND group even if this is
// set to false.
IncludePrerelease bool
}
// NewConstraint returns a Constraints instance that a Version instance can
@@ -22,11 +29,10 @@ func NewConstraint(c string) (*Constraints, error) {
c = rewriteRange(c)
ors := strings.Split(c, "||")
or := make([][]*constraint, len(ors))
lenors := len(ors)
or := make([][]*constraint, lenors)
hasPre := make([]bool, lenors)
for k, v := range ors {
// TODO: Find a way to validate and fetch all the constraints in a simpler form
// Validate the segment
if !validConstraintRegex.MatchString(v) {
return nil, fmt.Errorf("improper constraint: %s", v)
@@ -43,12 +49,22 @@ func NewConstraint(c string) (*Constraints, error) {
return nil, err
}
// If one of the constraints has a prerelease record this.
// This information is used when checking all in an "and"
// group to ensure they all check for prereleases.
if pc.con.pre != "" {
hasPre[k] = true
}
result[i] = pc
}
or[k] = result
}
o := &Constraints{constraints: or}
o := &Constraints{
constraints: or,
containsPre: hasPre,
}
return o, nil
}
@@ -57,10 +73,10 @@ func (cs Constraints) Check(v *Version) bool {
// TODO(mattfarina): For v4 of this library consolidate the Check and Validate
// functions as the underlying functions make that possible now.
// loop over the ORs and check the inner ANDs
for _, o := range cs.constraints {
for i, o := range cs.constraints {
joy := true
for _, c := range o {
if check, _ := c.check(v); !check {
if check, _ := c.check(v, (cs.IncludePrerelease || cs.containsPre[i])); !check {
joy = false
break
}
@@ -83,12 +99,12 @@ func (cs Constraints) Validate(v *Version) (bool, []error) {
// Capture the prerelease message only once. When it happens the first time
// this var is marked
var prerelesase bool
for _, o := range cs.constraints {
for i, o := range cs.constraints {
joy := true
for _, c := range o {
// Before running the check handle the case there the version is
// a prerelease and the check is not searching for prereleases.
if c.con.pre == "" && v.pre != "" {
if !(cs.IncludePrerelease || cs.containsPre[i]) && v.pre != "" {
if !prerelesase {
em := fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
e = append(e, em)
@@ -98,7 +114,7 @@ func (cs Constraints) Validate(v *Version) (bool, []error) {
} else {
if _, err := c.check(v); err != nil {
if _, err := c.check(v, (cs.IncludePrerelease || cs.containsPre[i])); err != nil {
e = append(e, err)
joy = false
}
@@ -227,8 +243,8 @@ type constraint struct {
}
// Check if a version meets the constraint
func (c *constraint) check(v *Version) (bool, error) {
return constraintOps[c.origfunc](v, c)
func (c *constraint) check(v *Version, includePre bool) (bool, error) {
return constraintOps[c.origfunc](v, c, includePre)
}
// String prints an individual constraint into a string
@@ -236,7 +252,7 @@ func (c *constraint) string() string {
return c.origfunc + c.orig
}
type cfunc func(v *Version, c *constraint) (bool, error)
type cfunc func(v *Version, c *constraint, includePre bool) (bool, error)
func parseConstraint(c string) (*constraint, error) {
if len(c) > 0 {
@@ -272,7 +288,7 @@ func parseConstraint(c string) (*constraint, error) {
// The constraintRegex should catch any regex parsing errors. So,
// we should never get here.
return nil, errors.New("constraint Parser Error")
return nil, errors.New("constraint parser error")
}
cs.con = con
@@ -290,7 +306,7 @@ func parseConstraint(c string) (*constraint, error) {
// The constraintRegex should catch any regex parsing errors. So,
// we should never get here.
return nil, errors.New("constraint Parser Error")
return nil, errors.New("constraint parser error")
}
cs := &constraint{
@@ -305,16 +321,14 @@ func parseConstraint(c string) (*constraint, error) {
}
// Constraint functions
func constraintNotEqual(v *Version, c *constraint) (bool, error) {
func constraintNotEqual(v *Version, c *constraint, includePre bool) (bool, error) {
// The existence of prereleases is checked at the group level and passed in.
// Exit early if the version has a prerelease but those are to be ignored.
if v.Prerelease() != "" && !includePre {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}
if c.dirty {
// If there is a pre-release on the version but the constraint isn't looking
// for them assume that pre-releases are not compatible. See issue 21 for
// more details.
if v.Prerelease() != "" && c.con.Prerelease() == "" {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}
if c.con.Major() != v.Major() {
return true, nil
}
@@ -345,12 +359,11 @@ func constraintNotEqual(v *Version, c *constraint) (bool, error) {
return true, nil
}
func constraintGreaterThan(v *Version, c *constraint) (bool, error) {
func constraintGreaterThan(v *Version, c *constraint, includePre bool) (bool, error) {
// If there is a pre-release on the version but the constraint isn't looking
// for them assume that pre-releases are not compatible. See issue 21 for
// more details.
if v.Prerelease() != "" && c.con.Prerelease() == "" {
// The existence of prereleases is checked at the group level and passed in.
// Exit early if the version has a prerelease but those are to be ignored.
if v.Prerelease() != "" && !includePre {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}
@@ -391,11 +404,10 @@ func constraintGreaterThan(v *Version, c *constraint) (bool, error) {
return false, fmt.Errorf("%s is less than or equal to %s", v, c.orig)
}
func constraintLessThan(v *Version, c *constraint) (bool, error) {
// If there is a pre-release on the version but the constraint isn't looking
// for them assume that pre-releases are not compatible. See issue 21 for
// more details.
if v.Prerelease() != "" && c.con.Prerelease() == "" {
func constraintLessThan(v *Version, c *constraint, includePre bool) (bool, error) {
// The existence of prereleases is checked at the group level and passed in.
// Exit early if the version has a prerelease but those are to be ignored.
if v.Prerelease() != "" && !includePre {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}
@@ -406,12 +418,11 @@ func constraintLessThan(v *Version, c *constraint) (bool, error) {
return false, fmt.Errorf("%s is greater than or equal to %s", v, c.orig)
}
func constraintGreaterThanEqual(v *Version, c *constraint) (bool, error) {
func constraintGreaterThanEqual(v *Version, c *constraint, includePre bool) (bool, error) {
// If there is a pre-release on the version but the constraint isn't looking
// for them assume that pre-releases are not compatible. See issue 21 for
// more details.
if v.Prerelease() != "" && c.con.Prerelease() == "" {
// The existence of prereleases is checked at the group level and passed in.
// Exit early if the version has a prerelease but those are to be ignored.
if v.Prerelease() != "" && !includePre {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}
@@ -422,11 +433,10 @@ func constraintGreaterThanEqual(v *Version, c *constraint) (bool, error) {
return false, fmt.Errorf("%s is less than %s", v, c.orig)
}
func constraintLessThanEqual(v *Version, c *constraint) (bool, error) {
// If there is a pre-release on the version but the constraint isn't looking
// for them assume that pre-releases are not compatible. See issue 21 for
// more details.
if v.Prerelease() != "" && c.con.Prerelease() == "" {
func constraintLessThanEqual(v *Version, c *constraint, includePre bool) (bool, error) {
// The existence of prereleases is checked at the group level and passed in.
// Exit early if the version has a prerelease but those are to be ignored.
if v.Prerelease() != "" && !includePre {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}
@@ -455,11 +465,10 @@ func constraintLessThanEqual(v *Version, c *constraint) (bool, error) {
// ~1.2, ~1.2.x, ~>1.2, ~>1.2.x --> >=1.2.0, <1.3.0
// ~1.2.3, ~>1.2.3 --> >=1.2.3, <1.3.0
// ~1.2.0, ~>1.2.0 --> >=1.2.0, <1.3.0
func constraintTilde(v *Version, c *constraint) (bool, error) {
// If there is a pre-release on the version but the constraint isn't looking
// for them assume that pre-releases are not compatible. See issue 21 for
// more details.
if v.Prerelease() != "" && c.con.Prerelease() == "" {
func constraintTilde(v *Version, c *constraint, includePre bool) (bool, error) {
// The existence of prereleases is checked at the group level and passed in.
// Exit early if the version has a prerelease but those are to be ignored.
if v.Prerelease() != "" && !includePre {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}
@@ -487,16 +496,15 @@ func constraintTilde(v *Version, c *constraint) (bool, error) {
// When there is a .x (dirty) status it automatically opts in to ~. Otherwise
// it's a straight =
func constraintTildeOrEqual(v *Version, c *constraint) (bool, error) {
// If there is a pre-release on the version but the constraint isn't looking
// for them assume that pre-releases are not compatible. See issue 21 for
// more details.
if v.Prerelease() != "" && c.con.Prerelease() == "" {
func constraintTildeOrEqual(v *Version, c *constraint, includePre bool) (bool, error) {
// The existence of prereleases is checked at the group level and passed in.
// Exit early if the version has a prerelease but those are to be ignored.
if v.Prerelease() != "" && !includePre {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}
if c.dirty {
return constraintTilde(v, c)
return constraintTilde(v, c, includePre)
}
eq := v.Equal(c.con)
@@ -516,11 +524,10 @@ func constraintTildeOrEqual(v *Version, c *constraint) (bool, error) {
// ^0.0.3 --> >=0.0.3 <0.0.4
// ^0.0 --> >=0.0.0 <0.1.0
// ^0 --> >=0.0.0 <1.0.0
func constraintCaret(v *Version, c *constraint) (bool, error) {
// If there is a pre-release on the version but the constraint isn't looking
// for them assume that pre-releases are not compatible. See issue 21 for
// more details.
if v.Prerelease() != "" && c.con.Prerelease() == "" {
func constraintCaret(v *Version, c *constraint, includePre bool) (bool, error) {
// The existence of prereleases is checked at the group level and passed in.
// Exit early if the version has a prerelease but those are to be ignored.
if v.Prerelease() != "" && !includePre {
return false, fmt.Errorf("%s is a prerelease version and the constraint is only looking for release versions", v)
}

View File

@@ -14,32 +14,52 @@ import (
// The compiled version of the regex created at init() is cached here so it
// only needs to be created once.
var versionRegex *regexp.Regexp
var looseVersionRegex *regexp.Regexp
// CoerceNewVersion sets if leading 0's are allowd in the version part. Leading 0's are
// not allowed in a valid semantic version. When set to true, NewVersion will coerce
// leading 0's into a valid version.
var CoerceNewVersion = true
// DetailedNewVersionErrors specifies if detailed errors are returned from the NewVersion
// function. This is used when CoerceNewVersion is set to false. If set to false
// ErrInvalidSemVer is returned for an invalid version. This does not apply to
// StrictNewVersion. Setting this function to false returns errors more quickly.
var DetailedNewVersionErrors = true
var (
// ErrInvalidSemVer is returned a version is found to be invalid when
// being parsed.
ErrInvalidSemVer = errors.New("Invalid Semantic Version")
ErrInvalidSemVer = errors.New("invalid semantic version")
// ErrEmptyString is returned when an empty string is passed in for parsing.
ErrEmptyString = errors.New("Version string empty")
ErrEmptyString = errors.New("version string empty")
// ErrInvalidCharacters is returned when invalid characters are found as
// part of a version
ErrInvalidCharacters = errors.New("Invalid characters in version")
ErrInvalidCharacters = errors.New("invalid characters in version")
// ErrSegmentStartsZero is returned when a version segment starts with 0.
// This is invalid in SemVer.
ErrSegmentStartsZero = errors.New("Version segment starts with 0")
ErrSegmentStartsZero = errors.New("version segment starts with 0")
// ErrInvalidMetadata is returned when the metadata is an invalid format
ErrInvalidMetadata = errors.New("Invalid Metadata string")
ErrInvalidMetadata = errors.New("invalid metadata string")
// ErrInvalidPrerelease is returned when the pre-release is an invalid format
ErrInvalidPrerelease = errors.New("Invalid Prerelease string")
ErrInvalidPrerelease = errors.New("invalid prerelease string")
)
// semVerRegex is the regular expression used to parse a semantic version.
const semVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` +
// This is not the official regex from the semver spec. It has been modified to allow for loose handling
// where versions like 2.1 are detected.
const semVerRegex string = `v?(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?` +
`(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?` +
`(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?`
// looseSemVerRegex is a regular expression that lets invalid semver expressions through
// with enough detail that certain errors can be checked for.
const looseSemVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` +
`(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` +
`(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?`
@@ -53,6 +73,7 @@ type Version struct {
func init() {
versionRegex = regexp.MustCompile("^" + semVerRegex + "$")
looseVersionRegex = regexp.MustCompile("^" + looseSemVerRegex + "$")
}
const (
@@ -140,7 +161,80 @@ func StrictNewVersion(v string) (*Version, error) {
// attempts to convert it to SemVer. If you want to validate it was a strict
// semantic version at parse time see StrictNewVersion().
func NewVersion(v string) (*Version, error) {
if CoerceNewVersion {
return coerceNewVersion(v)
}
m := versionRegex.FindStringSubmatch(v)
if m == nil {
// Disabling detailed errors is first so that it is in the fast path.
if !DetailedNewVersionErrors {
return nil, ErrInvalidSemVer
}
// Check for specific errors with the semver string and return a more detailed
// error.
m = looseVersionRegex.FindStringSubmatch(v)
if m == nil {
return nil, ErrInvalidSemVer
}
err := validateVersion(m)
if err != nil {
return nil, err
}
return nil, ErrInvalidSemVer
}
sv := &Version{
metadata: m[5],
pre: m[4],
original: v,
}
var err error
sv.major, err = strconv.ParseUint(m[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("error parsing version segment: %w", err)
}
if m[2] != "" {
sv.minor, err = strconv.ParseUint(m[2], 10, 64)
if err != nil {
return nil, fmt.Errorf("error parsing version segment: %w", err)
}
} else {
sv.minor = 0
}
if m[3] != "" {
sv.patch, err = strconv.ParseUint(m[3], 10, 64)
if err != nil {
return nil, fmt.Errorf("error parsing version segment: %w", err)
}
} else {
sv.patch = 0
}
// Perform some basic due diligence on the extra parts to ensure they are
// valid.
if sv.pre != "" {
if err = validatePrerelease(sv.pre); err != nil {
return nil, err
}
}
if sv.metadata != "" {
if err = validateMetadata(sv.metadata); err != nil {
return nil, err
}
}
return sv, nil
}
func coerceNewVersion(v string) (*Version, error) {
m := looseVersionRegex.FindStringSubmatch(v)
if m == nil {
return nil, ErrInvalidSemVer
}
@@ -154,13 +248,13 @@ func NewVersion(v string) (*Version, error) {
var err error
sv.major, err = strconv.ParseUint(m[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("Error parsing version segment: %s", err)
return nil, fmt.Errorf("error parsing version segment: %w", err)
}
if m[2] != "" {
sv.minor, err = strconv.ParseUint(strings.TrimPrefix(m[2], "."), 10, 64)
if err != nil {
return nil, fmt.Errorf("Error parsing version segment: %s", err)
return nil, fmt.Errorf("error parsing version segment: %w", err)
}
} else {
sv.minor = 0
@@ -169,7 +263,7 @@ func NewVersion(v string) (*Version, error) {
if m[3] != "" {
sv.patch, err = strconv.ParseUint(strings.TrimPrefix(m[3], "."), 10, 64)
if err != nil {
return nil, fmt.Errorf("Error parsing version segment: %s", err)
return nil, fmt.Errorf("error parsing version segment: %w", err)
}
} else {
sv.patch = 0
@@ -612,7 +706,9 @@ func containsOnly(s string, comp string) bool {
func validatePrerelease(p string) error {
eparts := strings.Split(p, ".")
for _, p := range eparts {
if containsOnly(p, num) {
if p == "" {
return ErrInvalidPrerelease
} else if containsOnly(p, num) {
if len(p) > 1 && p[0] == '0' {
return ErrSegmentStartsZero
}
@@ -631,9 +727,62 @@ func validatePrerelease(p string) error {
func validateMetadata(m string) error {
eparts := strings.Split(m, ".")
for _, p := range eparts {
if !containsOnly(p, allowed) {
if p == "" {
return ErrInvalidMetadata
} else if !containsOnly(p, allowed) {
return ErrInvalidMetadata
}
}
return nil
}
// validateVersion checks for common validation issues but may not catch all errors
func validateVersion(m []string) error {
var err error
var v string
if m[1] != "" {
if len(m[1]) > 1 && m[1][0] == '0' {
return ErrSegmentStartsZero
}
_, err = strconv.ParseUint(m[1], 10, 64)
if err != nil {
return fmt.Errorf("error parsing version segment: %w", err)
}
}
if m[2] != "" {
v = strings.TrimPrefix(m[2], ".")
if len(v) > 1 && v[0] == '0' {
return ErrSegmentStartsZero
}
_, err = strconv.ParseUint(v, 10, 64)
if err != nil {
return fmt.Errorf("error parsing version segment: %w", err)
}
}
if m[3] != "" {
v = strings.TrimPrefix(m[3], ".")
if len(v) > 1 && v[0] == '0' {
return ErrSegmentStartsZero
}
_, err = strconv.ParseUint(v, 10, 64)
if err != nil {
return fmt.Errorf("error parsing version segment: %w", err)
}
}
if m[5] != "" {
if err = validatePrerelease(m[5]); err != nil {
return err
}
}
if m[8] != "" {
if err = validateMetadata(m[8]); err != nil {
return err
}
}
return nil
}

21
vendor/github.com/google/jsonschema-go/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 JSON Schema Go Project Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,76 @@
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package jsonschema
import "maps"
// An annotations tracks certain properties computed by keywords that are used by validation.
// ("Annotation" is the spec's term.)
// In particular, the unevaluatedItems and unevaluatedProperties keywords need to know which
// items and properties were evaluated (validated successfully).
type annotations struct {
allItems bool // all items were evaluated
endIndex int // 1+largest index evaluated by prefixItems
evaluatedIndexes map[int]bool // set of indexes evaluated by contains
allProperties bool // all properties were evaluated
evaluatedProperties map[string]bool // set of properties evaluated by various keywords
}
// noteIndex marks i as evaluated.
func (a *annotations) noteIndex(i int) {
if a.evaluatedIndexes == nil {
a.evaluatedIndexes = map[int]bool{}
}
a.evaluatedIndexes[i] = true
}
// noteEndIndex marks items with index less than end as evaluated.
func (a *annotations) noteEndIndex(end int) {
if end > a.endIndex {
a.endIndex = end
}
}
// noteProperty marks prop as evaluated.
func (a *annotations) noteProperty(prop string) {
if a.evaluatedProperties == nil {
a.evaluatedProperties = map[string]bool{}
}
a.evaluatedProperties[prop] = true
}
// noteProperties marks all the properties in props as evaluated.
func (a *annotations) noteProperties(props map[string]bool) {
a.evaluatedProperties = merge(a.evaluatedProperties, props)
}
// merge adds b's annotations to a.
// a must not be nil.
func (a *annotations) merge(b *annotations) {
if b == nil {
return
}
if b.allItems {
a.allItems = true
}
if b.endIndex > a.endIndex {
a.endIndex = b.endIndex
}
a.evaluatedIndexes = merge(a.evaluatedIndexes, b.evaluatedIndexes)
if b.allProperties {
a.allProperties = true
}
a.evaluatedProperties = merge(a.evaluatedProperties, b.evaluatedProperties)
}
// merge adds t's keys to s and returns s.
// If s is nil, it returns a copy of t.
func merge[K comparable](s, t map[K]bool) map[K]bool {
if s == nil {
return maps.Clone(t)
}
maps.Copy(s, t)
return s
}

View File

@@ -0,0 +1,101 @@
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
/*
Package jsonschema is an implementation of the [JSON Schema specification],
a JSON-based format for describing the structure of JSON data.
The package can be used to read schemas for code generation, and to validate
data using the draft 2020-12 specification. Validation with other drafts
or custom meta-schemas is not supported.
Construct a [Schema] as you would any Go struct (for example, by writing
a struct literal), or unmarshal a JSON schema into a [Schema] in the usual
way (with [encoding/json], for instance). It can then be used for code
generation or other purposes without further processing.
You can also infer a schema from a Go struct.
# Resolution
A Schema can refer to other schemas, both inside and outside itself. These
references must be resolved before a schema can be used for validation.
Call [Schema.Resolve] to obtain a resolved schema (called a [Resolved]).
If the schema has external references, pass a [ResolveOptions] with a [Loader]
to load them. To validate default values in a schema, set
[ResolveOptions.ValidateDefaults] to true.
# Validation
Call [Resolved.Validate] to validate a JSON value. The value must be a
Go value that looks like the result of unmarshaling a JSON value into an
[any] or a struct. For example, the JSON value
{"name": "Al", "scores": [90, 80, 100]}
could be represented as the Go value
map[string]any{
"name": "Al",
"scores": []any{90, 80, 100},
}
or as a value of this type:
type Player struct {
Name string `json:"name"`
Scores []int `json:"scores"`
}
# Inference
The [For] function returns a [Schema] describing the given Go type.
Each field in the struct becomes a property of the schema.
The values of "json" tags are respected: the field's property name is taken
from the tag, and fields omitted from the JSON are omitted from the schema as
well.
For example, `jsonschema.For[Player]()` returns this schema:
{
"properties": {
"name": {
"type": "string"
},
"scores": {
"type": "array",
"items": {"type": "integer"}
}
"required": ["name", "scores"],
"additionalProperties": {"not": {}}
}
}
Use the "jsonschema" struct tag to provide a description for the property:
type Player struct {
Name string `json:"name" jsonschema:"player name"`
Scores []int `json:"scores" jsonschema:"scores of player's games"`
}
# Deviations from the specification
Regular expressions are processed with Go's regexp package, which differs
from ECMA 262, most significantly in not supporting back-references.
See [this table of differences] for more.
The "format" keyword described in [section 7 of the validation spec] is recorded
in the Schema, but is ignored during validation.
It does not even produce [annotations].
Use the "pattern" keyword instead: it will work more reliably across JSON Schema
implementations. See [learnjsonschema.com] for more recommendations about "format".
The content keywords described in [section 8 of the validation spec]
are recorded in the schema, but ignored during validation.
[JSON Schema specification]: https://json-schema.org
[section 7 of the validation spec]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
[section 8 of the validation spec]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8
[learnjsonschema.com]: https://www.learnjsonschema.com/2020-12/format-annotation/format/
[this table of differences]: https://github.com/dlclark/regexp2?tab=readme-ov-file#compare-regexp-and-regexp2
[annotations]: https://json-schema.org/draft/2020-12/json-schema-core#name-annotations
*/
package jsonschema

View File

@@ -0,0 +1,248 @@
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
// This file contains functions that infer a schema from a Go type.
package jsonschema
import (
"fmt"
"log/slog"
"maps"
"math/big"
"reflect"
"regexp"
"time"
)
// ForOptions are options for the [For] and [ForType] functions.
type ForOptions struct {
// If IgnoreInvalidTypes is true, fields that can't be represented as a JSON
// Schema are ignored instead of causing an error.
// This allows callers to adjust the resulting schema using custom knowledge.
// For example, an interface type where all the possible implementations are
// known can be described with "oneof".
IgnoreInvalidTypes bool
// TypeSchemas maps types to their schemas.
// If [For] encounters a type that is a key in this map, the
// corresponding value is used as the resulting schema (after cloning to
// ensure uniqueness).
// Types in this map override the default translations, as described
// in [For]'s documentation.
TypeSchemas map[reflect.Type]*Schema
}
// For constructs a JSON schema object for the given type argument.
// If non-nil, the provided options configure certain aspects of this contruction,
// described below.
// It translates Go types into compatible JSON schema types, as follows.
// These defaults can be overridden by [ForOptions.TypeSchemas].
//
// - Strings have schema type "string".
// - Bools have schema type "boolean".
// - Signed and unsigned integer types have schema type "integer".
// - Floating point types have schema type "number".
// - Slices and arrays have schema type "array", and a corresponding schema
// for items.
// - Maps with string key have schema type "object", and corresponding
// schema for additionalProperties.
// - Structs have schema type "object", and disallow additionalProperties.
// Their properties are derived from exported struct fields, using the
// struct field JSON name. Fields that are marked "omitempty" are
// considered optional; all other fields become required properties.
// - Some types in the standard library that implement json.Marshaler
// translate to schemas that match the values to which they marshal.
// For example, [time.Time] translates to the schema for strings.
//
// For will return an error if there is a cycle in the types.
//
// By default, For returns an error if t contains (possibly recursively) any of the
// following Go types, as they are incompatible with the JSON schema spec.
// If [ForOptions.IgnoreInvalidTypes] is true, then these types are ignored instead.
// - maps with key other than 'string'
// - function types
// - channel types
// - complex numbers
// - unsafe pointers
//
// This function recognizes struct field tags named "jsonschema".
// A jsonschema tag on a field is used as the description for the corresponding property.
// For future compatibility, descriptions must not start with "WORD=", where WORD is a
// sequence of non-whitespace characters.
func For[T any](opts *ForOptions) (*Schema, error) {
if opts == nil {
opts = &ForOptions{}
}
schemas := maps.Clone(initialSchemaMap)
// Add types from the options. They override the default ones.
maps.Copy(schemas, opts.TypeSchemas)
s, err := forType(reflect.TypeFor[T](), map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas)
if err != nil {
var z T
return nil, fmt.Errorf("For[%T](): %w", z, err)
}
return s, nil
}
// ForType is like [For], but takes a [reflect.Type]
func ForType(t reflect.Type, opts *ForOptions) (*Schema, error) {
schemas := maps.Clone(initialSchemaMap)
// Add types from the options. They override the default ones.
maps.Copy(schemas, opts.TypeSchemas)
s, err := forType(t, map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas)
if err != nil {
return nil, fmt.Errorf("ForType(%s): %w", t, err)
}
return s, nil
}
func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas map[reflect.Type]*Schema) (*Schema, error) {
// Follow pointers: the schema for *T is almost the same as for T, except that
// an explicit JSON "null" is allowed for the pointer.
allowNull := false
for t.Kind() == reflect.Pointer {
allowNull = true
t = t.Elem()
}
// Check for cycles
// User defined types have a name, so we can skip those that are natively defined
if t.Name() != "" {
if seen[t] {
return nil, fmt.Errorf("cycle detected for type %v", t)
}
seen[t] = true
defer delete(seen, t)
}
if s := schemas[t]; s != nil {
return s.CloneSchemas(), nil
}
var (
s = new(Schema)
err error
)
switch t.Kind() {
case reflect.Bool:
s.Type = "boolean"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Uintptr:
s.Type = "integer"
case reflect.Float32, reflect.Float64:
s.Type = "number"
case reflect.Interface:
// Unrestricted
case reflect.Map:
if t.Key().Kind() != reflect.String {
if ignore {
return nil, nil // ignore
}
return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind())
}
if t.Key().Kind() != reflect.String {
}
s.Type = "object"
s.AdditionalProperties, err = forType(t.Elem(), seen, ignore, schemas)
if err != nil {
return nil, fmt.Errorf("computing map value schema: %v", err)
}
if ignore && s.AdditionalProperties == nil {
// Ignore if the element type is invalid.
return nil, nil
}
case reflect.Slice, reflect.Array:
s.Type = "array"
s.Items, err = forType(t.Elem(), seen, ignore, schemas)
if err != nil {
return nil, fmt.Errorf("computing element schema: %v", err)
}
if ignore && s.Items == nil {
// Ignore if the element type is invalid.
return nil, nil
}
if t.Kind() == reflect.Array {
s.MinItems = Ptr(t.Len())
s.MaxItems = Ptr(t.Len())
}
case reflect.String:
s.Type = "string"
case reflect.Struct:
s.Type = "object"
// no additional properties are allowed
s.AdditionalProperties = falseSchema()
for _, field := range reflect.VisibleFields(t) {
if field.Anonymous {
continue
}
info := fieldJSONInfo(field)
if info.omit {
continue
}
if s.Properties == nil {
s.Properties = make(map[string]*Schema)
}
fs, err := forType(field.Type, seen, ignore, schemas)
if err != nil {
return nil, err
}
if ignore && fs == nil {
// Skip fields of invalid type.
continue
}
if tag, ok := field.Tag.Lookup("jsonschema"); ok {
if tag == "" {
return nil, fmt.Errorf("empty jsonschema tag on struct field %s.%s", t, field.Name)
}
if disallowedPrefixRegexp.MatchString(tag) {
return nil, fmt.Errorf("tag must not begin with 'WORD=': %q", tag)
}
fs.Description = tag
}
s.Properties[info.name] = fs
if !info.settings["omitempty"] && !info.settings["omitzero"] {
s.Required = append(s.Required, info.name)
}
}
default:
if ignore {
// Ignore.
return nil, nil
}
return nil, fmt.Errorf("type %v is unsupported by jsonschema", t)
}
if allowNull && s.Type != "" {
s.Types = []string{"null", s.Type}
s.Type = ""
}
return s, nil
}
// initialSchemaMap holds types from the standard library that have MarshalJSON methods.
var initialSchemaMap = make(map[reflect.Type]*Schema)
func init() {
ss := &Schema{Type: "string"}
initialSchemaMap[reflect.TypeFor[time.Time]()] = ss
initialSchemaMap[reflect.TypeFor[slog.Level]()] = ss
initialSchemaMap[reflect.TypeFor[big.Int]()] = &Schema{Types: []string{"null", "string"}}
initialSchemaMap[reflect.TypeFor[big.Rat]()] = ss
initialSchemaMap[reflect.TypeFor[big.Float]()] = ss
}
// Disallow jsonschema tag values beginning "WORD=", for future expansion.
var disallowedPrefixRegexp = regexp.MustCompile("^[^ \t\n]*=")

View File

@@ -0,0 +1,146 @@
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
// This file implements JSON Pointers.
// A JSON Pointer is a path that refers to one JSON value within another.
// If the path is empty, it refers to the root value.
// Otherwise, it is a sequence of slash-prefixed strings, like "/points/1/x",
// selecting successive properties (for JSON objects) or items (for JSON arrays).
// For example, when applied to this JSON value:
// {
// "points": [
// {"x": 1, "y": 2},
// {"x": 3, "y": 4}
// ]
// }
//
// the JSON Pointer "/points/1/x" refers to the number 3.
// See the spec at https://datatracker.ietf.org/doc/html/rfc6901.
package jsonschema
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
)
var (
jsonPointerEscaper = strings.NewReplacer("~", "~0", "/", "~1")
jsonPointerUnescaper = strings.NewReplacer("~0", "~", "~1", "/")
)
func escapeJSONPointerSegment(s string) string {
return jsonPointerEscaper.Replace(s)
}
func unescapeJSONPointerSegment(s string) string {
return jsonPointerUnescaper.Replace(s)
}
// parseJSONPointer splits a JSON Pointer into a sequence of segments. It doesn't
// convert strings to numbers, because that depends on the traversal: a segment
// is treated as a number when applied to an array, but a string when applied to
// an object. See section 4 of the spec.
func parseJSONPointer(ptr string) (segments []string, err error) {
if ptr == "" {
return nil, nil
}
if ptr[0] != '/' {
return nil, fmt.Errorf("JSON Pointer %q does not begin with '/'", ptr)
}
// Unlike file paths, consecutive slashes are not coalesced.
// Split is nicer than Cut here, because it gets a final "/" right.
segments = strings.Split(ptr[1:], "/")
if strings.Contains(ptr, "~") {
// Undo the simple escaping rules that allow one to include a slash in a segment.
for i := range segments {
segments[i] = unescapeJSONPointerSegment(segments[i])
}
}
return segments, nil
}
// dereferenceJSONPointer returns the Schema that sptr points to within s,
// or an error if none.
// This implementation suffices for JSON Schema: pointers are applied only to Schemas,
// and refer only to Schemas.
func dereferenceJSONPointer(s *Schema, sptr string) (_ *Schema, err error) {
defer wrapf(&err, "JSON Pointer %q", sptr)
segments, err := parseJSONPointer(sptr)
if err != nil {
return nil, err
}
v := reflect.ValueOf(s)
for _, seg := range segments {
switch v.Kind() {
case reflect.Pointer:
v = v.Elem()
if !v.IsValid() {
return nil, errors.New("navigated to nil reference")
}
fallthrough // if valid, can only be a pointer to a Schema
case reflect.Struct:
// The segment must refer to a field in a Schema.
if v.Type() != reflect.TypeFor[Schema]() {
return nil, fmt.Errorf("navigated to non-Schema %s", v.Type())
}
v = lookupSchemaField(v, seg)
if !v.IsValid() {
return nil, fmt.Errorf("no schema field %q", seg)
}
case reflect.Slice, reflect.Array:
// The segment must be an integer without leading zeroes that refers to an item in the
// slice or array.
if seg == "-" {
return nil, errors.New("the JSON Pointer array segment '-' is not supported")
}
if len(seg) > 1 && seg[0] == '0' {
return nil, fmt.Errorf("segment %q has leading zeroes", seg)
}
n, err := strconv.Atoi(seg)
if err != nil {
return nil, fmt.Errorf("invalid int: %q", seg)
}
if n < 0 || n >= v.Len() {
return nil, fmt.Errorf("index %d is out of bounds for array of length %d", n, v.Len())
}
v = v.Index(n)
// Cannot be invalid.
case reflect.Map:
// The segment must be a key in the map.
v = v.MapIndex(reflect.ValueOf(seg))
if !v.IsValid() {
return nil, fmt.Errorf("no key %q in map", seg)
}
default:
return nil, fmt.Errorf("value %s (%s) is not a schema, slice or map", v, v.Type())
}
}
if s, ok := v.Interface().(*Schema); ok {
return s, nil
}
return nil, fmt.Errorf("does not refer to a schema, but to a %s", v.Type())
}
// lookupSchemaField returns the value of the field with the given name in v,
// or the zero value if there is no such field or it is not of type Schema or *Schema.
func lookupSchemaField(v reflect.Value, name string) reflect.Value {
if name == "type" {
// The "type" keyword may refer to Type or Types.
// At most one will be non-zero.
if t := v.FieldByName("Type"); !t.IsZero() {
return t
}
return v.FieldByName("Types")
}
if sf, ok := schemaFieldMap[name]; ok {
return v.FieldByIndex(sf.Index)
}
return reflect.Value{}
}

View File

@@ -0,0 +1,548 @@
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
// This file deals with preparing a schema for validation, including various checks,
// optimizations, and the resolution of cross-schema references.
package jsonschema
import (
"errors"
"fmt"
"net/url"
"reflect"
"regexp"
"strings"
)
// A Resolved consists of a [Schema] along with associated information needed to
// validate documents against it.
// A Resolved has been validated against its meta-schema, and all its references
// (the $ref and $dynamicRef keywords) have been resolved to their referenced Schemas.
// Call [Schema.Resolve] to obtain a Resolved from a Schema.
type Resolved struct {
root *Schema
// map from $ids to their schemas
resolvedURIs map[string]*Schema
// map from schemas to additional info computed during resolution
resolvedInfos map[*Schema]*resolvedInfo
}
func newResolved(s *Schema) *Resolved {
return &Resolved{
root: s,
resolvedURIs: map[string]*Schema{},
resolvedInfos: map[*Schema]*resolvedInfo{},
}
}
// resolvedInfo holds information specific to a schema that is computed by [Schema.Resolve].
type resolvedInfo struct {
s *Schema
// The JSON Pointer path from the root schema to here.
// Used in errors.
path string
// The schema's base schema.
// If the schema is the root or has an ID, its base is itself.
// Otherwise, its base is the innermost enclosing schema whose base
// is itself.
// Intuitively, a base schema is one that can be referred to with a
// fragmentless URI.
base *Schema
// The URI for the schema, if it is the root or has an ID.
// Otherwise nil.
// Invariants:
// s.base.uri != nil.
// s.base == s <=> s.uri != nil
uri *url.URL
// The schema to which Ref refers.
resolvedRef *Schema
// If the schema has a dynamic ref, exactly one of the next two fields
// will be non-zero after successful resolution.
// The schema to which the dynamic ref refers when it acts lexically.
resolvedDynamicRef *Schema
// The anchor to look up on the stack when the dynamic ref acts dynamically.
dynamicRefAnchor string
// The following fields are independent of arguments to Schema.Resolved,
// so they could live on the Schema. We put them here for simplicity.
// The set of required properties.
isRequired map[string]bool
// Compiled regexps.
pattern *regexp.Regexp
patternProperties map[*regexp.Regexp]*Schema
// Map from anchors to subschemas.
anchors map[string]anchorInfo
}
// Schema returns the schema that was resolved.
// It must not be modified.
func (r *Resolved) Schema() *Schema { return r.root }
// schemaString returns a short string describing the schema.
func (r *Resolved) schemaString(s *Schema) string {
if s.ID != "" {
return s.ID
}
info := r.resolvedInfos[s]
if info.path != "" {
return info.path
}
return "<anonymous schema>"
}
// A Loader reads and unmarshals the schema at uri, if any.
type Loader func(uri *url.URL) (*Schema, error)
// ResolveOptions are options for [Schema.Resolve].
type ResolveOptions struct {
// BaseURI is the URI relative to which the root schema should be resolved.
// If non-empty, must be an absolute URI (one that starts with a scheme).
// It is resolved (in the URI sense; see [url.ResolveReference]) with root's
// $id property.
// If the resulting URI is not absolute, then the schema cannot contain
// relative URI references.
BaseURI string
// Loader loads schemas that are referred to by a $ref but are not under the
// root schema (remote references).
// If nil, resolving a remote reference will return an error.
Loader Loader
// ValidateDefaults determines whether to validate values of "default" keywords
// against their schemas.
// The [JSON Schema specification] does not require this, but it is recommended
// if defaults will be used.
//
// [JSON Schema specification]: https://json-schema.org/understanding-json-schema/reference/annotations
ValidateDefaults bool
}
// Resolve resolves all references within the schema and performs other tasks that
// prepare the schema for validation.
// If opts is nil, the default values are used.
// The schema must not be changed after Resolve is called.
// The same schema may be resolved multiple times.
func (root *Schema) Resolve(opts *ResolveOptions) (*Resolved, error) {
// There are up to five steps required to prepare a schema to validate.
// 1. Load: read the schema from somewhere and unmarshal it.
// This schema (root) may have been loaded or created in memory, but other schemas that
// come into the picture in step 4 will be loaded by the given loader.
// 2. Check: validate the schema against a meta-schema, and perform other well-formedness checks.
// Precompute some values along the way.
// 3. Resolve URIs: determine the base URI of the root and all its subschemas, and
// resolve (in the URI sense) all identifiers and anchors with their bases. This step results
// in a map from URIs to schemas within root.
// 4. Resolve references: all refs in the schemas are replaced with the schema they refer to.
// 5. (Optional.) If opts.ValidateDefaults is true, validate the defaults.
r := &resolver{loaded: map[string]*Resolved{}}
if opts != nil {
r.opts = *opts
}
var base *url.URL
if r.opts.BaseURI == "" {
base = &url.URL{} // so we can call ResolveReference on it
} else {
var err error
base, err = url.Parse(r.opts.BaseURI)
if err != nil {
return nil, fmt.Errorf("parsing base URI: %w", err)
}
}
if r.opts.Loader == nil {
r.opts.Loader = func(uri *url.URL) (*Schema, error) {
return nil, errors.New("cannot resolve remote schemas: no loader passed to Schema.Resolve")
}
}
resolved, err := r.resolve(root, base)
if err != nil {
return nil, err
}
if r.opts.ValidateDefaults {
if err := resolved.validateDefaults(); err != nil {
return nil, err
}
}
// TODO: before we return, throw away anything we don't need for validation.
return resolved, nil
}
// A resolver holds the state for resolution.
type resolver struct {
opts ResolveOptions
// A cache of loaded and partly resolved schemas. (They may not have had their
// refs resolved.) The cache ensures that the loader will never be called more
// than once with the same URI, and that reference cycles are handled properly.
loaded map[string]*Resolved
}
func (r *resolver) resolve(s *Schema, baseURI *url.URL) (*Resolved, error) {
if baseURI.Fragment != "" {
return nil, fmt.Errorf("base URI %s must not have a fragment", baseURI)
}
rs := newResolved(s)
if err := s.check(rs.resolvedInfos); err != nil {
return nil, err
}
if err := resolveURIs(rs, baseURI); err != nil {
return nil, err
}
// Remember the schema by both the URI we loaded it from and its canonical name,
// which may differ if the schema has an $id.
// We must set the map before calling resolveRefs, or ref cycles will cause unbounded recursion.
r.loaded[baseURI.String()] = rs
r.loaded[rs.resolvedInfos[s].uri.String()] = rs
if err := r.resolveRefs(rs); err != nil {
return nil, err
}
return rs, nil
}
func (root *Schema) check(infos map[*Schema]*resolvedInfo) error {
// Check for structural validity. Do this first and fail fast:
// bad structure will cause other code to panic.
if err := root.checkStructure(infos); err != nil {
return err
}
var errs []error
report := func(err error) { errs = append(errs, err) }
for ss := range root.all() {
ss.checkLocal(report, infos)
}
return errors.Join(errs...)
}
// checkStructure verifies that root and its subschemas form a tree.
// It also assigns each schema a unique path, to improve error messages.
func (root *Schema) checkStructure(infos map[*Schema]*resolvedInfo) error {
assert(len(infos) == 0, "non-empty infos")
var check func(reflect.Value, []byte) error
check = func(v reflect.Value, path []byte) error {
// For the purpose of error messages, the root schema has path "root"
// and other schemas' paths are their JSON Pointer from the root.
p := "root"
if len(path) > 0 {
p = string(path)
}
s := v.Interface().(*Schema)
if s == nil {
return fmt.Errorf("jsonschema: schema at %s is nil", p)
}
if info, ok := infos[s]; ok {
// We've seen s before.
// The schema graph at root is not a tree, but it needs to
// be because a schema's base must be unique.
// A cycle would also put Schema.all into an infinite recursion.
return fmt.Errorf("jsonschema: schemas at %s do not form a tree; %s appears more than once (also at %s)",
root, info.path, p)
}
infos[s] = &resolvedInfo{s: s, path: p}
for _, info := range schemaFieldInfos {
fv := v.Elem().FieldByIndex(info.sf.Index)
switch info.sf.Type {
case schemaType:
// A field that contains an individual schema.
// A nil is valid: it just means the field isn't present.
if !fv.IsNil() {
if err := check(fv, fmt.Appendf(path, "/%s", info.jsonName)); err != nil {
return err
}
}
case schemaSliceType:
for i := range fv.Len() {
if err := check(fv.Index(i), fmt.Appendf(path, "/%s/%d", info.jsonName, i)); err != nil {
return err
}
}
case schemaMapType:
iter := fv.MapRange()
for iter.Next() {
key := escapeJSONPointerSegment(iter.Key().String())
if err := check(iter.Value(), fmt.Appendf(path, "/%s/%s", info.jsonName, key)); err != nil {
return err
}
}
}
}
return nil
}
return check(reflect.ValueOf(root), make([]byte, 0, 256))
}
// checkLocal checks s for validity, independently of other schemas it may refer to.
// Since checking a regexp involves compiling it, checkLocal saves those compiled regexps
// in the schema for later use.
// It appends the errors it finds to errs.
func (s *Schema) checkLocal(report func(error), infos map[*Schema]*resolvedInfo) {
addf := func(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
report(fmt.Errorf("jsonschema.Schema: %s: %s", s, msg))
}
if s == nil {
addf("nil subschema")
return
}
if err := s.basicChecks(); err != nil {
report(err)
return
}
// TODO: validate the schema's properties,
// ideally by jsonschema-validating it against the meta-schema.
// Some properties are present so that Schemas can round-trip, but we do not
// validate them.
// Currently, it's just the $vocabulary property.
// As a special case, we can validate the 2020-12 meta-schema.
if s.Vocabulary != nil && s.Schema != draft202012 {
addf("cannot validate a schema with $vocabulary")
}
info := infos[s]
// Check and compile regexps.
if s.Pattern != "" {
re, err := regexp.Compile(s.Pattern)
if err != nil {
addf("pattern: %v", err)
} else {
info.pattern = re
}
}
if len(s.PatternProperties) > 0 {
info.patternProperties = map[*regexp.Regexp]*Schema{}
for reString, subschema := range s.PatternProperties {
re, err := regexp.Compile(reString)
if err != nil {
addf("patternProperties[%q]: %v", reString, err)
continue
}
info.patternProperties[re] = subschema
}
}
// Build a set of required properties, to avoid quadratic behavior when validating
// a struct.
if len(s.Required) > 0 {
info.isRequired = map[string]bool{}
for _, r := range s.Required {
info.isRequired[r] = true
}
}
}
// resolveURIs resolves the ids and anchors in all the schemas of root, relative
// to baseURI.
// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2, section
// 8.2.1.
//
// Every schema has a base URI and a parent base URI.
//
// The parent base URI is the base URI of the lexically enclosing schema, or for
// a root schema, the URI it was loaded from or the one supplied to [Schema.Resolve].
//
// If the schema has no $id property, the base URI of a schema is that of its parent.
// If the schema does have an $id, it must be a URI, possibly relative. The schema's
// base URI is the $id resolved (in the sense of [url.URL.ResolveReference]) against
// the parent base.
//
// As an example, consider this schema loaded from http://a.com/root.json (quotes omitted):
//
// {
// allOf: [
// {$id: "sub1.json", minLength: 5},
// {$id: "http://b.com", minimum: 10},
// {not: {maximum: 20}}
// ]
// }
//
// The base URIs are as follows. Schema locations are expressed in the JSON Pointer notation.
//
// schema base URI
// root http://a.com/root.json
// allOf/0 http://a.com/sub1.json
// allOf/1 http://b.com (absolute $id; doesn't matter that it's not under the loaded URI)
// allOf/2 http://a.com/root.json (inherited from parent)
// allOf/2/not http://a.com/root.json (inherited from parent)
func resolveURIs(rs *Resolved, baseURI *url.URL) error {
var resolve func(s, base *Schema) error
resolve = func(s, base *Schema) error {
info := rs.resolvedInfos[s]
baseInfo := rs.resolvedInfos[base]
// ids are scoped to the root.
if s.ID != "" {
// A non-empty ID establishes a new base.
idURI, err := url.Parse(s.ID)
if err != nil {
return err
}
if idURI.Fragment != "" {
return fmt.Errorf("$id %s must not have a fragment", s.ID)
}
// The base URI for this schema is its $id resolved against the parent base.
info.uri = baseInfo.uri.ResolveReference(idURI)
if !info.uri.IsAbs() {
return fmt.Errorf("$id %s does not resolve to an absolute URI (base is %q)", s.ID, baseInfo.uri)
}
rs.resolvedURIs[info.uri.String()] = s
base = s // needed for anchors
baseInfo = rs.resolvedInfos[base]
}
info.base = base
// Anchors and dynamic anchors are URI fragments that are scoped to their base.
// We treat them as keys in a map stored within the schema.
setAnchor := func(anchor string, dynamic bool) error {
if anchor != "" {
if _, ok := baseInfo.anchors[anchor]; ok {
return fmt.Errorf("duplicate anchor %q in %s", anchor, baseInfo.uri)
}
if baseInfo.anchors == nil {
baseInfo.anchors = map[string]anchorInfo{}
}
baseInfo.anchors[anchor] = anchorInfo{s, dynamic}
}
return nil
}
setAnchor(s.Anchor, false)
setAnchor(s.DynamicAnchor, true)
for c := range s.children() {
if err := resolve(c, base); err != nil {
return err
}
}
return nil
}
// Set the root URI to the base for now. If the root has an $id, this will change.
rs.resolvedInfos[rs.root].uri = baseURI
// The original base, even if changed, is still a valid way to refer to the root.
rs.resolvedURIs[baseURI.String()] = rs.root
return resolve(rs.root, rs.root)
}
// resolveRefs replaces every ref in the schemas with the schema it refers to.
// A reference that doesn't resolve within the schema may refer to some other schema
// that needs to be loaded.
func (r *resolver) resolveRefs(rs *Resolved) error {
for s := range rs.root.all() {
info := rs.resolvedInfos[s]
if s.Ref != "" {
refSchema, _, err := r.resolveRef(rs, s, s.Ref)
if err != nil {
return err
}
// Whether or not the anchor referred to by $ref fragment is dynamic,
// the ref still treats it lexically.
info.resolvedRef = refSchema
}
if s.DynamicRef != "" {
refSchema, frag, err := r.resolveRef(rs, s, s.DynamicRef)
if err != nil {
return err
}
if frag != "" {
// The dynamic ref's fragment points to a dynamic anchor.
// We must resolve the fragment at validation time.
info.dynamicRefAnchor = frag
} else {
// There is no dynamic anchor in the lexically referenced schema,
// so the dynamic ref behaves like a lexical ref.
info.resolvedDynamicRef = refSchema
}
}
}
return nil
}
// resolveRef resolves the reference ref, which is either s.Ref or s.DynamicRef.
func (r *resolver) resolveRef(rs *Resolved, s *Schema, ref string) (_ *Schema, dynamicFragment string, err error) {
refURI, err := url.Parse(ref)
if err != nil {
return nil, "", err
}
// URI-resolve the ref against the current base URI to get a complete URI.
base := rs.resolvedInfos[s].base
refURI = rs.resolvedInfos[base].uri.ResolveReference(refURI)
// The non-fragment part of a ref URI refers to the base URI of some schema.
// This part is the same for dynamic refs too: their non-fragment part resolves
// lexically.
u := *refURI
u.Fragment = ""
fraglessRefURI := &u
// Look it up locally.
referencedSchema := rs.resolvedURIs[fraglessRefURI.String()]
if referencedSchema == nil {
// The schema is remote. Maybe we've already loaded it.
// We assume that the non-fragment part of refURI refers to a top-level schema
// document. That is, we don't support the case exemplified by
// http://foo.com/bar.json/baz, where the document is in bar.json and
// the reference points to a subschema within it.
// TODO: support that case.
if lrs := r.loaded[fraglessRefURI.String()]; lrs != nil {
referencedSchema = lrs.root
} else {
// Try to load the schema.
ls, err := r.opts.Loader(fraglessRefURI)
if err != nil {
return nil, "", fmt.Errorf("loading %s: %w", fraglessRefURI, err)
}
lrs, err := r.resolve(ls, fraglessRefURI)
if err != nil {
return nil, "", err
}
referencedSchema = lrs.root
assert(referencedSchema != nil, "nil referenced schema")
// Copy the resolvedInfos from lrs into rs, without overwriting
// (hence we can't use maps.Insert).
for s, i := range lrs.resolvedInfos {
if rs.resolvedInfos[s] == nil {
rs.resolvedInfos[s] = i
}
}
}
}
frag := refURI.Fragment
// Look up frag in refSchema.
// frag is either a JSON Pointer or the name of an anchor.
// A JSON Pointer is either the empty string or begins with a '/',
// whereas anchors are always non-empty strings that don't contain slashes.
if frag != "" && !strings.HasPrefix(frag, "/") {
resInfo := rs.resolvedInfos[referencedSchema]
info, found := resInfo.anchors[frag]
if !found {
return nil, "", fmt.Errorf("no anchor %q in %s", frag, s)
}
if info.dynamic {
dynamicFragment = frag
}
return info.schema, dynamicFragment, nil
}
// frag is a JSON Pointer.
s, err = dereferenceJSONPointer(referencedSchema, frag)
return s, "", err
}

View File

@@ -0,0 +1,436 @@
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package jsonschema
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"fmt"
"iter"
"maps"
"math"
"reflect"
"slices"
)
// A Schema is a JSON schema object.
// It corresponds to the 2020-12 draft, as described in https://json-schema.org/draft/2020-12,
// specifically:
// - https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01
// - https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01
//
// A Schema value may have non-zero values for more than one field:
// all relevant non-zero fields are used for validation.
// There is one exception to provide more Go type-safety: the Type and Types fields
// are mutually exclusive.
//
// Since this struct is a Go representation of a JSON value, it inherits JSON's
// distinction between nil and empty. Nil slices and maps are considered absent,
// but empty ones are present and affect validation. For example,
//
// Schema{Enum: nil}
//
// is equivalent to an empty schema, so it validates every instance. But
//
// Schema{Enum: []any{}}
//
// requires equality to some slice element, so it vacuously rejects every instance.
type Schema struct {
// core
ID string `json:"$id,omitempty"`
Schema string `json:"$schema,omitempty"`
Ref string `json:"$ref,omitempty"`
Comment string `json:"$comment,omitempty"`
Defs map[string]*Schema `json:"$defs,omitempty"`
// definitions is deprecated but still allowed. It is a synonym for $defs.
Definitions map[string]*Schema `json:"definitions,omitempty"`
Anchor string `json:"$anchor,omitempty"`
DynamicAnchor string `json:"$dynamicAnchor,omitempty"`
DynamicRef string `json:"$dynamicRef,omitempty"`
Vocabulary map[string]bool `json:"$vocabulary,omitempty"`
// metadata
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Default json.RawMessage `json:"default,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
WriteOnly bool `json:"writeOnly,omitempty"`
Examples []any `json:"examples,omitempty"`
// validation
// Use Type for a single type, or Types for multiple types; never both.
Type string `json:"-"`
Types []string `json:"-"`
Enum []any `json:"enum,omitempty"`
// Const is *any because a JSON null (Go nil) is a valid value.
Const *any `json:"const,omitempty"`
MultipleOf *float64 `json:"multipleOf,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty"`
ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty"`
MinLength *int `json:"minLength,omitempty"`
MaxLength *int `json:"maxLength,omitempty"`
Pattern string `json:"pattern,omitempty"`
// arrays
PrefixItems []*Schema `json:"prefixItems,omitempty"`
Items *Schema `json:"items,omitempty"`
MinItems *int `json:"minItems,omitempty"`
MaxItems *int `json:"maxItems,omitempty"`
AdditionalItems *Schema `json:"additionalItems,omitempty"`
UniqueItems bool `json:"uniqueItems,omitempty"`
Contains *Schema `json:"contains,omitempty"`
MinContains *int `json:"minContains,omitempty"` // *int, not int: default is 1, not 0
MaxContains *int `json:"maxContains,omitempty"`
UnevaluatedItems *Schema `json:"unevaluatedItems,omitempty"`
// objects
MinProperties *int `json:"minProperties,omitempty"`
MaxProperties *int `json:"maxProperties,omitempty"`
Required []string `json:"required,omitempty"`
DependentRequired map[string][]string `json:"dependentRequired,omitempty"`
Properties map[string]*Schema `json:"properties,omitempty"`
PatternProperties map[string]*Schema `json:"patternProperties,omitempty"`
AdditionalProperties *Schema `json:"additionalProperties,omitempty"`
PropertyNames *Schema `json:"propertyNames,omitempty"`
UnevaluatedProperties *Schema `json:"unevaluatedProperties,omitempty"`
// logic
AllOf []*Schema `json:"allOf,omitempty"`
AnyOf []*Schema `json:"anyOf,omitempty"`
OneOf []*Schema `json:"oneOf,omitempty"`
Not *Schema `json:"not,omitempty"`
// conditional
If *Schema `json:"if,omitempty"`
Then *Schema `json:"then,omitempty"`
Else *Schema `json:"else,omitempty"`
DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"`
// other
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8
ContentEncoding string `json:"contentEncoding,omitempty"`
ContentMediaType string `json:"contentMediaType,omitempty"`
ContentSchema *Schema `json:"contentSchema,omitempty"`
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
Format string `json:"format,omitempty"`
// Extra allows for additional keywords beyond those specified.
Extra map[string]any `json:"-"`
}
// falseSchema returns a new Schema tree that fails to validate any value.
func falseSchema() *Schema {
return &Schema{Not: &Schema{}}
}
// anchorInfo records the subschema to which an anchor refers, and whether
// the anchor keyword is $anchor or $dynamicAnchor.
type anchorInfo struct {
schema *Schema
dynamic bool
}
// String returns a short description of the schema.
func (s *Schema) String() string {
if s.ID != "" {
return s.ID
}
if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" {
return fmt.Sprintf("anchor %s", a)
}
return "<anonymous schema>"
}
// CloneSchemas returns a copy of s.
// The copy is shallow except for sub-schemas, which are themelves copied with CloneSchemas.
// This allows both s and s.CloneSchemas() to appear as sub-schemas of the same parent.
func (s *Schema) CloneSchemas() *Schema {
if s == nil {
return nil
}
s2 := *s
v := reflect.ValueOf(&s2)
for _, info := range schemaFieldInfos {
fv := v.Elem().FieldByIndex(info.sf.Index)
switch info.sf.Type {
case schemaType:
sscss := fv.Interface().(*Schema)
fv.Set(reflect.ValueOf(sscss.CloneSchemas()))
case schemaSliceType:
slice := fv.Interface().([]*Schema)
slice = slices.Clone(slice)
for i, ss := range slice {
slice[i] = ss.CloneSchemas()
}
fv.Set(reflect.ValueOf(slice))
case schemaMapType:
m := fv.Interface().(map[string]*Schema)
m = maps.Clone(m)
for k, ss := range m {
m[k] = ss.CloneSchemas()
}
fv.Set(reflect.ValueOf(m))
}
}
return &s2
}
func (s *Schema) basicChecks() error {
if s.Type != "" && s.Types != nil {
return errors.New("both Type and Types are set; at most one should be")
}
if s.Defs != nil && s.Definitions != nil {
return errors.New("both Defs and Definitions are set; at most one should be")
}
return nil
}
type schemaWithoutMethods Schema // doesn't implement json.{Unm,M}arshaler
func (s *Schema) MarshalJSON() ([]byte, error) {
if err := s.basicChecks(); err != nil {
return nil, err
}
// Marshal either Type or Types as "type".
var typ any
switch {
case s.Type != "":
typ = s.Type
case s.Types != nil:
typ = s.Types
}
ms := struct {
Type any `json:"type,omitempty"`
*schemaWithoutMethods
}{
Type: typ,
schemaWithoutMethods: (*schemaWithoutMethods)(s),
}
bs, err := marshalStructWithMap(&ms, "Extra")
if err != nil {
return nil, err
}
// Marshal {} as true and {"not": {}} as false.
// It is wasteful to do this here instead of earlier, but much easier.
switch {
case bytes.Equal(bs, []byte(`{}`)):
bs = []byte("true")
case bytes.Equal(bs, []byte(`{"not":true}`)):
bs = []byte("false")
}
return bs, nil
}
func (s *Schema) UnmarshalJSON(data []byte) error {
// A JSON boolean is a valid schema.
var b bool
if err := json.Unmarshal(data, &b); err == nil {
if b {
// true is the empty schema, which validates everything.
*s = Schema{}
} else {
// false is the schema that validates nothing.
*s = *falseSchema()
}
return nil
}
ms := struct {
Type json.RawMessage `json:"type,omitempty"`
Const json.RawMessage `json:"const,omitempty"`
MinLength *integer `json:"minLength,omitempty"`
MaxLength *integer `json:"maxLength,omitempty"`
MinItems *integer `json:"minItems,omitempty"`
MaxItems *integer `json:"maxItems,omitempty"`
MinProperties *integer `json:"minProperties,omitempty"`
MaxProperties *integer `json:"maxProperties,omitempty"`
MinContains *integer `json:"minContains,omitempty"`
MaxContains *integer `json:"maxContains,omitempty"`
*schemaWithoutMethods
}{
schemaWithoutMethods: (*schemaWithoutMethods)(s),
}
if err := unmarshalStructWithMap(data, &ms, "Extra"); err != nil {
return err
}
// Unmarshal "type" as either Type or Types.
var err error
if len(ms.Type) > 0 {
switch ms.Type[0] {
case '"':
err = json.Unmarshal(ms.Type, &s.Type)
case '[':
err = json.Unmarshal(ms.Type, &s.Types)
default:
err = fmt.Errorf(`invalid value for "type": %q`, ms.Type)
}
}
if err != nil {
return err
}
unmarshalAnyPtr := func(p **any, raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
if bytes.Equal(raw, []byte("null")) {
*p = new(any)
return nil
}
return json.Unmarshal(raw, p)
}
// Setting Const to a pointer to null will marshal properly, but won't
// unmarshal: the *any is set to nil, not a pointer to nil.
if err := unmarshalAnyPtr(&s.Const, ms.Const); err != nil {
return err
}
set := func(dst **int, src *integer) {
if src != nil {
*dst = Ptr(int(*src))
}
}
set(&s.MinLength, ms.MinLength)
set(&s.MaxLength, ms.MaxLength)
set(&s.MinItems, ms.MinItems)
set(&s.MaxItems, ms.MaxItems)
set(&s.MinProperties, ms.MinProperties)
set(&s.MaxProperties, ms.MaxProperties)
set(&s.MinContains, ms.MinContains)
set(&s.MaxContains, ms.MaxContains)
return nil
}
type integer int32 // for the integer-valued fields of Schema
func (ip *integer) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
// nothing to do
return nil
}
// If there is a decimal point, src is a floating-point number.
var i int64
if bytes.ContainsRune(data, '.') {
var f float64
if err := json.Unmarshal(data, &f); err != nil {
return errors.New("not a number")
}
i = int64(f)
if float64(i) != f {
return errors.New("not an integer value")
}
} else {
if err := json.Unmarshal(data, &i); err != nil {
return errors.New("cannot be unmarshaled into an int")
}
}
// Ensure behavior is the same on both 32-bit and 64-bit systems.
if i < math.MinInt32 || i > math.MaxInt32 {
return errors.New("integer is out of range")
}
*ip = integer(i)
return nil
}
// Ptr returns a pointer to a new variable whose value is x.
func Ptr[T any](x T) *T { return &x }
// every applies f preorder to every schema under s including s.
// The second argument to f is the path to the schema appended to the argument path.
// It stops when f returns false.
func (s *Schema) every(f func(*Schema) bool) bool {
return f(s) && s.everyChild(func(s *Schema) bool { return s.every(f) })
}
// everyChild reports whether f is true for every immediate child schema of s.
func (s *Schema) everyChild(f func(*Schema) bool) bool {
v := reflect.ValueOf(s)
for _, info := range schemaFieldInfos {
fv := v.Elem().FieldByIndex(info.sf.Index)
switch info.sf.Type {
case schemaType:
// A field that contains an individual schema. A nil is valid: it just means the field isn't present.
c := fv.Interface().(*Schema)
if c != nil && !f(c) {
return false
}
case schemaSliceType:
slice := fv.Interface().([]*Schema)
for _, c := range slice {
if !f(c) {
return false
}
}
case schemaMapType:
// Sort keys for determinism.
m := fv.Interface().(map[string]*Schema)
for _, k := range slices.Sorted(maps.Keys(m)) {
if !f(m[k]) {
return false
}
}
}
}
return true
}
// all wraps every in an iterator.
func (s *Schema) all() iter.Seq[*Schema] {
return func(yield func(*Schema) bool) { s.every(yield) }
}
// children wraps everyChild in an iterator.
func (s *Schema) children() iter.Seq[*Schema] {
return func(yield func(*Schema) bool) { s.everyChild(yield) }
}
var (
schemaType = reflect.TypeFor[*Schema]()
schemaSliceType = reflect.TypeFor[[]*Schema]()
schemaMapType = reflect.TypeFor[map[string]*Schema]()
)
type structFieldInfo struct {
sf reflect.StructField
jsonName string
}
var (
// the visible fields of Schema that have a JSON name, sorted by that name
schemaFieldInfos []structFieldInfo
// map from JSON name to field
schemaFieldMap = map[string]reflect.StructField{}
)
func init() {
for _, sf := range reflect.VisibleFields(reflect.TypeFor[Schema]()) {
info := fieldJSONInfo(sf)
if !info.omit {
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, info.name})
}
}
slices.SortFunc(schemaFieldInfos, func(i1, i2 structFieldInfo) int {
return cmp.Compare(i1.jsonName, i2.jsonName)
})
for _, info := range schemaFieldInfos {
schemaFieldMap[info.jsonName] = info.sf
}
}

View File

@@ -0,0 +1,463 @@
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package jsonschema
import (
"bytes"
"cmp"
"encoding/binary"
"encoding/json"
"fmt"
"hash/maphash"
"math"
"math/big"
"reflect"
"slices"
"strings"
"sync"
)
// Equal reports whether two Go values representing JSON values are equal according
// to the JSON Schema spec.
// The values must not contain cycles.
// See https://json-schema.org/draft/2020-12/json-schema-core#section-4.2.2.
// It behaves like reflect.DeepEqual, except that numbers are compared according
// to mathematical equality.
func Equal(x, y any) bool {
return equalValue(reflect.ValueOf(x), reflect.ValueOf(y))
}
func equalValue(x, y reflect.Value) bool {
// Copied from src/reflect/deepequal.go, omitting the visited check (because JSON
// values are trees).
if !x.IsValid() || !y.IsValid() {
return x.IsValid() == y.IsValid()
}
// Treat numbers specially.
rx, ok1 := jsonNumber(x)
ry, ok2 := jsonNumber(y)
if ok1 && ok2 {
return rx.Cmp(ry) == 0
}
if x.Kind() != y.Kind() {
return false
}
switch x.Kind() {
case reflect.Array:
if x.Len() != y.Len() {
return false
}
for i := range x.Len() {
if !equalValue(x.Index(i), y.Index(i)) {
return false
}
}
return true
case reflect.Slice:
if x.IsNil() != y.IsNil() {
return false
}
if x.Len() != y.Len() {
return false
}
if x.UnsafePointer() == y.UnsafePointer() {
return true
}
// Special case for []byte, which is common.
if x.Type().Elem().Kind() == reflect.Uint8 && x.Type() == y.Type() {
return bytes.Equal(x.Bytes(), y.Bytes())
}
for i := range x.Len() {
if !equalValue(x.Index(i), y.Index(i)) {
return false
}
}
return true
case reflect.Interface:
if x.IsNil() || y.IsNil() {
return x.IsNil() == y.IsNil()
}
return equalValue(x.Elem(), y.Elem())
case reflect.Pointer:
if x.UnsafePointer() == y.UnsafePointer() {
return true
}
return equalValue(x.Elem(), y.Elem())
case reflect.Struct:
t := x.Type()
if t != y.Type() {
return false
}
for i := range t.NumField() {
sf := t.Field(i)
if !sf.IsExported() {
continue
}
if !equalValue(x.FieldByIndex(sf.Index), y.FieldByIndex(sf.Index)) {
return false
}
}
return true
case reflect.Map:
if x.IsNil() != y.IsNil() {
return false
}
if x.Len() != y.Len() {
return false
}
if x.UnsafePointer() == y.UnsafePointer() {
return true
}
iter := x.MapRange()
for iter.Next() {
vx := iter.Value()
vy := y.MapIndex(iter.Key())
if !vy.IsValid() || !equalValue(vx, vy) {
return false
}
}
return true
case reflect.Func:
if x.Type() != y.Type() {
return false
}
if x.IsNil() && y.IsNil() {
return true
}
panic("cannot compare functions")
case reflect.String:
return x.String() == y.String()
case reflect.Bool:
return x.Bool() == y.Bool()
// Ints, uints and floats handled in jsonNumber, at top of function.
default:
panic(fmt.Sprintf("unsupported kind: %s", x.Kind()))
}
}
// hashValue adds v to the data hashed by h. v must not have cycles.
// hashValue panics if the value contains functions or channels, or maps whose
// key type is not string.
// It ignores unexported fields of structs.
// Calls to hashValue with the equal values (in the sense
// of [Equal]) result in the same sequence of values written to the hash.
func hashValue(h *maphash.Hash, v reflect.Value) {
// TODO: replace writes of basic types with WriteComparable in 1.24.
writeUint := func(u uint64) {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], u)
h.Write(buf[:])
}
var write func(reflect.Value)
write = func(v reflect.Value) {
if r, ok := jsonNumber(v); ok {
// We want 1.0 and 1 to hash the same.
// big.Rats are always normalized, so they will be.
// We could do this more efficiently by handling the int and float cases
// separately, but that's premature.
writeUint(uint64(r.Sign() + 1))
h.Write(r.Num().Bytes())
h.Write(r.Denom().Bytes())
return
}
switch v.Kind() {
case reflect.Invalid:
h.WriteByte(0)
case reflect.String:
h.WriteString(v.String())
case reflect.Bool:
if v.Bool() {
h.WriteByte(1)
} else {
h.WriteByte(0)
}
case reflect.Complex64, reflect.Complex128:
c := v.Complex()
writeUint(math.Float64bits(real(c)))
writeUint(math.Float64bits(imag(c)))
case reflect.Array, reflect.Slice:
// Although we could treat []byte more efficiently,
// JSON values are unlikely to contain them.
writeUint(uint64(v.Len()))
for i := range v.Len() {
write(v.Index(i))
}
case reflect.Interface, reflect.Pointer:
write(v.Elem())
case reflect.Struct:
t := v.Type()
for i := range t.NumField() {
if sf := t.Field(i); sf.IsExported() {
write(v.FieldByIndex(sf.Index))
}
}
case reflect.Map:
if v.Type().Key().Kind() != reflect.String {
panic("map with non-string key")
}
// Sort the keys so the hash is deterministic.
keys := v.MapKeys()
// Write the length. That distinguishes between, say, two consecutive
// maps with disjoint keys from one map that has the items of both.
writeUint(uint64(len(keys)))
slices.SortFunc(keys, func(x, y reflect.Value) int { return cmp.Compare(x.String(), y.String()) })
for _, k := range keys {
write(k)
write(v.MapIndex(k))
}
// Ints, uints and floats handled in jsonNumber, at top of function.
default:
panic(fmt.Sprintf("unsupported kind: %s", v.Kind()))
}
}
write(v)
}
// jsonNumber converts a numeric value or a json.Number to a [big.Rat].
// If v is not a number, it returns nil, false.
func jsonNumber(v reflect.Value) (*big.Rat, bool) {
r := new(big.Rat)
switch {
case !v.IsValid():
return nil, false
case v.CanInt():
r.SetInt64(v.Int())
case v.CanUint():
r.SetUint64(v.Uint())
case v.CanFloat():
r.SetFloat64(v.Float())
default:
jn, ok := v.Interface().(json.Number)
if !ok {
return nil, false
}
if _, ok := r.SetString(jn.String()); !ok {
// This can fail in rare cases; for example, "1e9999999".
// That is a valid JSON number, since the spec puts no limit on the size
// of the exponent.
return nil, false
}
}
return r, true
}
// jsonType returns a string describing the type of the JSON value,
// as described in the JSON Schema specification:
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1.
// It returns "", false if the value is not valid JSON.
func jsonType(v reflect.Value) (string, bool) {
if !v.IsValid() {
// Not v.IsNil(): a nil []any is still a JSON array.
return "null", true
}
if v.CanInt() || v.CanUint() {
return "integer", true
}
if v.CanFloat() {
if _, f := math.Modf(v.Float()); f == 0 {
return "integer", true
}
return "number", true
}
switch v.Kind() {
case reflect.Bool:
return "boolean", true
case reflect.String:
return "string", true
case reflect.Slice, reflect.Array:
return "array", true
case reflect.Map, reflect.Struct:
return "object", true
default:
return "", false
}
}
func assert(cond bool, msg string) {
if !cond {
panic("assertion failed: " + msg)
}
}
// marshalStructWithMap marshals its first argument to JSON, treating the field named
// mapField as an embedded map. The first argument must be a pointer to
// a struct. The underlying type of mapField must be a map[string]any, and it must have
// a "-" json tag, meaning it will not be marshaled.
//
// For example, given this struct:
//
// type S struct {
// A int
// Extra map[string] any `json:"-"`
// }
//
// and this value:
//
// s := S{A: 1, Extra: map[string]any{"B": 2}}
//
// the call marshalJSONWithMap(s, "Extra") would return
//
// {"A": 1, "B": 2}
//
// It is an error if the map contains the same key as another struct field's
// JSON name.
//
// marshalStructWithMap calls json.Marshal on a value of type T, so T must not
// have a MarshalJSON method that calls this function, on pain of infinite regress.
//
// Note that there is a similar function in mcp/util.go, but they are not the same.
// Here the function requires `-` json tag, does not clear the mapField map,
// and handles embedded struct due to the implementation of jsonNames in this package.
//
// TODO: avoid this restriction on T by forcing it to marshal in a default way.
// See https://go.dev/play/p/EgXKJHxEx_R.
func marshalStructWithMap[T any](s *T, mapField string) ([]byte, error) {
// Marshal the struct and the map separately, and concatenate the bytes.
// This strategy is dramatically less complicated than
// constructing a synthetic struct or map with the combined keys.
if s == nil {
return []byte("null"), nil
}
s2 := *s
vMapField := reflect.ValueOf(&s2).Elem().FieldByName(mapField)
mapVal := vMapField.Interface().(map[string]any)
// Check for duplicates.
names := jsonNames(reflect.TypeFor[T]())
for key := range mapVal {
if names[key] {
return nil, fmt.Errorf("map key %q duplicates struct field", key)
}
}
structBytes, err := json.Marshal(s2)
if err != nil {
return nil, fmt.Errorf("marshalStructWithMap(%+v): %w", s, err)
}
if len(mapVal) == 0 {
return structBytes, nil
}
mapBytes, err := json.Marshal(mapVal)
if err != nil {
return nil, err
}
if len(structBytes) == 2 { // must be "{}"
return mapBytes, nil
}
// "{X}" + "{Y}" => "{X,Y}"
res := append(structBytes[:len(structBytes)-1], ',')
res = append(res, mapBytes[1:]...)
return res, nil
}
// unmarshalStructWithMap is the inverse of marshalStructWithMap.
// T has the same restrictions as in that function.
//
// Note that there is a similar function in mcp/util.go, but they are not the same.
// Here jsonNames also returns fields from embedded structs, hence this function
// handles embedded structs as well.
func unmarshalStructWithMap[T any](data []byte, v *T, mapField string) error {
// Unmarshal into the struct, ignoring unknown fields.
if err := json.Unmarshal(data, v); err != nil {
return err
}
// Unmarshal into the map.
m := map[string]any{}
if err := json.Unmarshal(data, &m); err != nil {
return err
}
// Delete from the map the fields of the struct.
for n := range jsonNames(reflect.TypeFor[T]()) {
delete(m, n)
}
if len(m) != 0 {
reflect.ValueOf(v).Elem().FieldByName(mapField).Set(reflect.ValueOf(m))
}
return nil
}
var jsonNamesMap sync.Map // from reflect.Type to map[string]bool
// jsonNames returns the set of JSON object keys that t will marshal into,
// including fields from embedded structs in t.
// t must be a struct type.
//
// Note that there is a similar function in mcp/util.go, but they are not the same
// Here the function recurses over embedded structs and includes fields from them.
func jsonNames(t reflect.Type) map[string]bool {
// Lock not necessary: at worst we'll duplicate work.
if val, ok := jsonNamesMap.Load(t); ok {
return val.(map[string]bool)
}
m := map[string]bool{}
for i := range t.NumField() {
field := t.Field(i)
// handle embedded structs
if field.Anonymous {
fieldType := field.Type
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
for n := range jsonNames(fieldType) {
m[n] = true
}
continue
}
info := fieldJSONInfo(field)
if !info.omit {
m[info.name] = true
}
}
jsonNamesMap.Store(t, m)
return m
}
type jsonInfo struct {
omit bool // unexported or first tag element is "-"
name string // Go field name or first tag element. Empty if omit is true.
settings map[string]bool // "omitempty", "omitzero", etc.
}
// fieldJSONInfo reports information about how encoding/json
// handles the given struct field.
// If the field is unexported, jsonInfo.omit is true and no other jsonInfo field
// is populated.
// If the field is exported and has no tag, then name is the field's name and all
// other fields are false.
// Otherwise, the information is obtained from the tag.
func fieldJSONInfo(f reflect.StructField) jsonInfo {
if !f.IsExported() {
return jsonInfo{omit: true}
}
info := jsonInfo{name: f.Name}
if tag, ok := f.Tag.Lookup("json"); ok {
name, rest, found := strings.Cut(tag, ",")
// "-" means omit, but "-," means the name is "-"
if name == "-" && !found {
return jsonInfo{omit: true}
}
if name != "" {
info.name = name
}
if len(rest) > 0 {
info.settings = map[string]bool{}
for _, s := range strings.Split(rest, ",") {
info.settings[s] = true
}
}
}
return info
}
// wrapf wraps *errp with the given formatted message if *errp is not nil.
func wrapf(errp *error, format string, args ...any) {
if *errp != nil {
*errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp)
}
}

View File

@@ -0,0 +1,789 @@
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package jsonschema
import (
"encoding/json"
"errors"
"fmt"
"hash/maphash"
"iter"
"math"
"math/big"
"reflect"
"slices"
"strings"
"sync"
"unicode/utf8"
)
// The value of the "$schema" keyword for the version that we can validate.
const draft202012 = "https://json-schema.org/draft/2020-12/schema"
// Validate validates the instance, which must be a JSON value, against the schema.
// It returns nil if validation is successful or an error if it is not.
// If the schema type is "object", instance can be a map[string]any or a struct.
func (rs *Resolved) Validate(instance any) error {
if s := rs.root.Schema; s != "" && s != draft202012 {
return fmt.Errorf("cannot validate version %s, only %s", s, draft202012)
}
st := &state{rs: rs}
return st.validate(reflect.ValueOf(instance), st.rs.root, nil)
}
// validateDefaults walks the schema tree. If it finds a default, it validates it
// against the schema containing it.
//
// TODO(jba): account for dynamic refs. This algorithm simple-mindedly
// treats each schema with a default as its own root.
func (rs *Resolved) validateDefaults() error {
if s := rs.root.Schema; s != "" && s != draft202012 {
return fmt.Errorf("cannot validate version %s, only %s", s, draft202012)
}
st := &state{rs: rs}
for s := range rs.root.all() {
// We checked for nil schemas in [Schema.Resolve].
assert(s != nil, "nil schema")
if s.DynamicRef != "" {
return fmt.Errorf("jsonschema: %s: validateDefaults does not support dynamic refs", rs.schemaString(s))
}
if s.Default != nil {
var d any
if err := json.Unmarshal(s.Default, &d); err != nil {
return fmt.Errorf("unmarshaling default value of schema %s: %w", rs.schemaString(s), err)
}
if err := st.validate(reflect.ValueOf(d), s, nil); err != nil {
return err
}
}
}
return nil
}
// state is the state of single call to ResolvedSchema.Validate.
type state struct {
rs *Resolved
// stack holds the schemas from recursive calls to validate.
// These are the "dynamic scopes" used to resolve dynamic references.
// https://json-schema.org/draft/2020-12/json-schema-core#scopes
stack []*Schema
}
// validate validates the reflected value of the instance.
func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *annotations) (err error) {
defer wrapf(&err, "validating %s", st.rs.schemaString(schema))
// Maintain a stack for dynamic schema resolution.
st.stack = append(st.stack, schema) // push
defer func() {
st.stack = st.stack[:len(st.stack)-1] // pop
}()
// We checked for nil schemas in [Schema.Resolve].
assert(schema != nil, "nil schema")
// Step through interfaces and pointers.
for instance.Kind() == reflect.Pointer || instance.Kind() == reflect.Interface {
instance = instance.Elem()
}
schemaInfo := st.rs.resolvedInfos[schema]
// type: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1
if schema.Type != "" || schema.Types != nil {
gotType, ok := jsonType(instance)
if !ok {
return fmt.Errorf("type: %v of type %[1]T is not a valid JSON value", instance)
}
if schema.Type != "" {
// "number" subsumes integers
if !(gotType == schema.Type ||
gotType == "integer" && schema.Type == "number") {
return fmt.Errorf("type: %v has type %q, want %q", instance, gotType, schema.Type)
}
} else {
if !(slices.Contains(schema.Types, gotType) || (gotType == "integer" && slices.Contains(schema.Types, "number"))) {
return fmt.Errorf("type: %v has type %q, want one of %q",
instance, gotType, strings.Join(schema.Types, ", "))
}
}
}
// enum: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.2
if schema.Enum != nil {
ok := false
for _, e := range schema.Enum {
if equalValue(reflect.ValueOf(e), instance) {
ok = true
break
}
}
if !ok {
return fmt.Errorf("enum: %v does not equal any of: %v", instance, schema.Enum)
}
}
// const: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.3
if schema.Const != nil {
if !equalValue(reflect.ValueOf(*schema.Const), instance) {
return fmt.Errorf("const: %v does not equal %v", instance, *schema.Const)
}
}
// numbers: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.2
if schema.MultipleOf != nil || schema.Minimum != nil || schema.Maximum != nil || schema.ExclusiveMinimum != nil || schema.ExclusiveMaximum != nil {
n, ok := jsonNumber(instance)
if ok { // these keywords don't apply to non-numbers
if schema.MultipleOf != nil {
// TODO: validate MultipleOf as non-zero.
// The test suite assumes floats.
nf, _ := n.Float64() // don't care if it's exact or not
if _, f := math.Modf(nf / *schema.MultipleOf); f != 0 {
return fmt.Errorf("multipleOf: %s is not a multiple of %f", n, *schema.MultipleOf)
}
}
m := new(big.Rat) // reuse for all of the following
cmp := func(f float64) int { return n.Cmp(m.SetFloat64(f)) }
if schema.Minimum != nil && cmp(*schema.Minimum) < 0 {
return fmt.Errorf("minimum: %s is less than %f", n, *schema.Minimum)
}
if schema.Maximum != nil && cmp(*schema.Maximum) > 0 {
return fmt.Errorf("maximum: %s is greater than %f", n, *schema.Maximum)
}
if schema.ExclusiveMinimum != nil && cmp(*schema.ExclusiveMinimum) <= 0 {
return fmt.Errorf("exclusiveMinimum: %s is less than or equal to %f", n, *schema.ExclusiveMinimum)
}
if schema.ExclusiveMaximum != nil && cmp(*schema.ExclusiveMaximum) >= 0 {
return fmt.Errorf("exclusiveMaximum: %s is greater than or equal to %f", n, *schema.ExclusiveMaximum)
}
}
}
// strings: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.3
if instance.Kind() == reflect.String && (schema.MinLength != nil || schema.MaxLength != nil || schema.Pattern != "") {
str := instance.String()
n := utf8.RuneCountInString(str)
if schema.MinLength != nil {
if m := *schema.MinLength; n < m {
return fmt.Errorf("minLength: %q contains %d Unicode code points, fewer than %d", str, n, m)
}
}
if schema.MaxLength != nil {
if m := *schema.MaxLength; n > m {
return fmt.Errorf("maxLength: %q contains %d Unicode code points, more than %d", str, n, m)
}
}
if schema.Pattern != "" && !schemaInfo.pattern.MatchString(str) {
return fmt.Errorf("pattern: %q does not match regular expression %q", str, schema.Pattern)
}
}
var anns annotations // all the annotations for this call and child calls
// $ref: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1
if schema.Ref != "" {
if err := st.validate(instance, schemaInfo.resolvedRef, &anns); err != nil {
return err
}
}
// $dynamicRef: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2
if schema.DynamicRef != "" {
// The ref behaves lexically or dynamically, but not both.
assert((schemaInfo.resolvedDynamicRef == nil) != (schemaInfo.dynamicRefAnchor == ""),
"DynamicRef not resolved properly")
if schemaInfo.resolvedDynamicRef != nil {
// Same as $ref.
if err := st.validate(instance, schemaInfo.resolvedDynamicRef, &anns); err != nil {
return err
}
} else {
// Dynamic behavior.
// Look for the base of the outermost schema on the stack with this dynamic
// anchor. (Yes, outermost: the one farthest from here. This the opposite
// of how ordinary dynamic variables behave.)
// Why the base of the schema being validated and not the schema itself?
// Because the base is the scope for anchors. In fact it's possible to
// refer to a schema that is not on the stack, but a child of some base
// on the stack.
// For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json.
var dynamicSchema *Schema
for _, s := range st.stack {
base := st.rs.resolvedInfos[s].base
info, ok := st.rs.resolvedInfos[base].anchors[schemaInfo.dynamicRefAnchor]
if ok && info.dynamic {
dynamicSchema = info.schema
break
}
}
if dynamicSchema == nil {
return fmt.Errorf("missing dynamic anchor %q", schemaInfo.dynamicRefAnchor)
}
if err := st.validate(instance, dynamicSchema, &anns); err != nil {
return err
}
}
}
// logic
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2
// These must happen before arrays and objects because if they evaluate an item or property,
// then the unevaluatedItems/Properties schemas don't apply to it.
// See https://json-schema.org/draft/2020-12/json-schema-core#section-11.2, paragraph 4.
//
// If any of these fail, then validation fails, even if there is an unevaluatedXXX
// keyword in the schema. The spec is unclear about this, but that is the intention.
valid := func(s *Schema, anns *annotations) bool { return st.validate(instance, s, anns) == nil }
if schema.AllOf != nil {
for _, ss := range schema.AllOf {
if err := st.validate(instance, ss, &anns); err != nil {
return err
}
}
}
if schema.AnyOf != nil {
// We must visit them all, to collect annotations.
ok := false
for _, ss := range schema.AnyOf {
if valid(ss, &anns) {
ok = true
}
}
if !ok {
return fmt.Errorf("anyOf: did not validate against any of %v", schema.AnyOf)
}
}
if schema.OneOf != nil {
// Exactly one.
var okSchema *Schema
for _, ss := range schema.OneOf {
if valid(ss, &anns) {
if okSchema != nil {
return fmt.Errorf("oneOf: validated against both %v and %v", okSchema, ss)
}
okSchema = ss
}
}
if okSchema == nil {
return fmt.Errorf("oneOf: did not validate against any of %v", schema.OneOf)
}
}
if schema.Not != nil {
// Ignore annotations from "not".
if valid(schema.Not, nil) {
return fmt.Errorf("not: validated against %v", schema.Not)
}
}
if schema.If != nil {
var ss *Schema
if valid(schema.If, &anns) {
ss = schema.Then
} else {
ss = schema.Else
}
if ss != nil {
if err := st.validate(instance, ss, &anns); err != nil {
return err
}
}
}
// arrays
// TODO(jba): consider arrays of structs.
if instance.Kind() == reflect.Array || instance.Kind() == reflect.Slice {
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1
// This validate call doesn't collect annotations for the items of the instance; they are separate
// instances in their own right.
// TODO(jba): if the test suite doesn't cover this case, add a test. For example, nested arrays.
for i, ischema := range schema.PrefixItems {
if i >= instance.Len() {
break // shorter is OK
}
if err := st.validate(instance.Index(i), ischema, nil); err != nil {
return err
}
}
anns.noteEndIndex(min(len(schema.PrefixItems), instance.Len()))
if schema.Items != nil {
for i := len(schema.PrefixItems); i < instance.Len(); i++ {
if err := st.validate(instance.Index(i), schema.Items, nil); err != nil {
return err
}
}
// Note that all the items in this array have been validated.
anns.allItems = true
}
nContains := 0
if schema.Contains != nil {
for i := range instance.Len() {
if err := st.validate(instance.Index(i), schema.Contains, nil); err == nil {
nContains++
anns.noteIndex(i)
}
}
if nContains == 0 && (schema.MinContains == nil || *schema.MinContains > 0) {
return fmt.Errorf("contains: %s does not have an item matching %s", instance, schema.Contains)
}
}
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.4
// TODO(jba): check that these next four keywords' values are integers.
if schema.MinContains != nil && schema.Contains != nil {
if m := *schema.MinContains; nContains < m {
return fmt.Errorf("minContains: contains validated %d items, less than %d", nContains, m)
}
}
if schema.MaxContains != nil && schema.Contains != nil {
if m := *schema.MaxContains; nContains > m {
return fmt.Errorf("maxContains: contains validated %d items, greater than %d", nContains, m)
}
}
if schema.MinItems != nil {
if m := *schema.MinItems; instance.Len() < m {
return fmt.Errorf("minItems: array length %d is less than %d", instance.Len(), m)
}
}
if schema.MaxItems != nil {
if m := *schema.MaxItems; instance.Len() > m {
return fmt.Errorf("maxItems: array length %d is greater than %d", instance.Len(), m)
}
}
if schema.UniqueItems {
if instance.Len() > 1 {
// Hash each item and compare the hashes.
// If two hashes differ, the items differ.
// If two hashes are the same, compare the collisions for equality.
// (The same logic as hash table lookup.)
// TODO(jba): Use container/hash.Map when it becomes available (https://go.dev/issue/69559),
hashes := map[uint64][]int{} // from hash to indices
seed := maphash.MakeSeed()
for i := range instance.Len() {
item := instance.Index(i)
var h maphash.Hash
h.SetSeed(seed)
hashValue(&h, item)
hv := h.Sum64()
if sames := hashes[hv]; len(sames) > 0 {
for _, j := range sames {
if equalValue(item, instance.Index(j)) {
return fmt.Errorf("uniqueItems: array items %d and %d are equal", i, j)
}
}
}
hashes[hv] = append(hashes[hv], i)
}
}
}
// https://json-schema.org/draft/2020-12/json-schema-core#section-11.2
if schema.UnevaluatedItems != nil && !anns.allItems {
// Apply this subschema to all items in the array that haven't been successfully validated.
// That includes validations by subschemas on the same instance, like allOf.
for i := anns.endIndex; i < instance.Len(); i++ {
if !anns.evaluatedIndexes[i] {
if err := st.validate(instance.Index(i), schema.UnevaluatedItems, nil); err != nil {
return err
}
}
}
anns.allItems = true
}
}
// objects
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2
// Validating structs is problematic. See https://github.com/google/jsonschema-go/issues/23.
if instance.Kind() == reflect.Struct {
return errors.New("cannot validate against a struct; see https://github.com/google/jsonschema-go/issues/23 for details")
}
if instance.Kind() == reflect.Map {
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
return fmt.Errorf("map key type %s is not a string", kt)
}
// Track the evaluated properties for just this schema, to support additionalProperties.
// If we used anns here, then we'd be including properties evaluated in subschemas
// from allOf, etc., which additionalProperties shouldn't observe.
evalProps := map[string]bool{}
for prop, subschema := range schema.Properties {
val := property(instance, prop)
if !val.IsValid() {
// It's OK if the instance doesn't have the property.
continue
}
// If the instance is a struct and an optional property has the zero
// value, then we could interpret it as present or missing. Be generous:
// assume it's missing, and thus always validates successfully.
if instance.Kind() == reflect.Struct && val.IsZero() && !schemaInfo.isRequired[prop] {
continue
}
if err := st.validate(val, subschema, nil); err != nil {
return err
}
evalProps[prop] = true
}
if len(schema.PatternProperties) > 0 {
for prop, val := range properties(instance) {
// Check every matching pattern.
for re, schema := range schemaInfo.patternProperties {
if re.MatchString(prop) {
if err := st.validate(val, schema, nil); err != nil {
return err
}
evalProps[prop] = true
}
}
}
}
if schema.AdditionalProperties != nil {
// Special case for a better error message when additional properties is
// 'falsy'
//
// If additionalProperties is {"not":{}} (which is how we
// unmarshal "false"), we can produce a better error message that
// summarizes all the extra properties. Otherwise, we fall back to the
// default validation.
//
// Note: this is much faster than comparing with falseSchema using Equal.
isFalsy := schema.AdditionalProperties.Not != nil && reflect.ValueOf(*schema.AdditionalProperties.Not).IsZero()
if isFalsy {
var disallowed []string
for prop := range properties(instance) {
if !evalProps[prop] {
disallowed = append(disallowed, prop)
}
}
if len(disallowed) > 0 {
return fmt.Errorf("unexpected additional properties %q", disallowed)
}
} else {
// Apply to all properties not handled above.
for prop, val := range properties(instance) {
if !evalProps[prop] {
if err := st.validate(val, schema.AdditionalProperties, nil); err != nil {
return err
}
evalProps[prop] = true
}
}
}
}
anns.noteProperties(evalProps)
if schema.PropertyNames != nil {
// Note: properties unnecessarily fetches each value. We could define a propertyNames function
// if performance ever matters.
for prop := range properties(instance) {
if err := st.validate(reflect.ValueOf(prop), schema.PropertyNames, nil); err != nil {
return err
}
}
}
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.5
var min, max int
if schema.MinProperties != nil || schema.MaxProperties != nil {
min, max = numPropertiesBounds(instance, schemaInfo.isRequired)
}
if schema.MinProperties != nil {
if n, m := max, *schema.MinProperties; n < m {
return fmt.Errorf("minProperties: object has %d properties, less than %d", n, m)
}
}
if schema.MaxProperties != nil {
if n, m := min, *schema.MaxProperties; n > m {
return fmt.Errorf("maxProperties: object has %d properties, greater than %d", n, m)
}
}
hasProperty := func(prop string) bool {
return property(instance, prop).IsValid()
}
missingProperties := func(props []string) []string {
var missing []string
for _, p := range props {
if !hasProperty(p) {
missing = append(missing, p)
}
}
return missing
}
if schema.Required != nil {
if m := missingProperties(schema.Required); len(m) > 0 {
return fmt.Errorf("required: missing properties: %q", m)
}
}
if schema.DependentRequired != nil {
// "Validation succeeds if, for each name that appears in both the instance
// and as a name within this keyword's value, every item in the corresponding
// array is also the name of a property in the instance." §6.5.4
for dprop, reqs := range schema.DependentRequired {
if hasProperty(dprop) {
if m := missingProperties(reqs); len(m) > 0 {
return fmt.Errorf("dependentRequired[%q]: missing properties %q", dprop, m)
}
}
}
}
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.2.4
if schema.DependentSchemas != nil {
// This does not collect annotations, although it seems like it should.
for dprop, ss := range schema.DependentSchemas {
if hasProperty(dprop) {
// TODO: include dependentSchemas[dprop] in the errors.
err := st.validate(instance, ss, &anns)
if err != nil {
return err
}
}
}
}
if schema.UnevaluatedProperties != nil && !anns.allProperties {
// This looks a lot like AdditionalProperties, but depends on in-place keywords like allOf
// in addition to sibling keywords.
for prop, val := range properties(instance) {
if !anns.evaluatedProperties[prop] {
if err := st.validate(val, schema.UnevaluatedProperties, nil); err != nil {
return err
}
}
}
// The spec says the annotation should be the set of evaluated properties, but we can optimize
// by setting a single boolean, since after this succeeds all properties will be validated.
// See https://json-schema.slack.com/archives/CT7FF623C/p1745592564381459.
anns.allProperties = true
}
}
if callerAnns != nil {
// Our caller wants to know what we've validated.
callerAnns.merge(&anns)
}
return nil
}
// resolveDynamicRef returns the schema referred to by the argument schema's
// $dynamicRef value.
// It returns an error if the dynamic reference has no referent.
// If there is no $dynamicRef, resolveDynamicRef returns nil, nil.
// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2.
func (st *state) resolveDynamicRef(schema *Schema) (*Schema, error) {
if schema.DynamicRef == "" {
return nil, nil
}
info := st.rs.resolvedInfos[schema]
// The ref behaves lexically or dynamically, but not both.
assert((info.resolvedDynamicRef == nil) != (info.dynamicRefAnchor == ""),
"DynamicRef not statically resolved properly")
if r := info.resolvedDynamicRef; r != nil {
// Same as $ref.
return r, nil
}
// Dynamic behavior.
// Look for the base of the outermost schema on the stack with this dynamic
// anchor. (Yes, outermost: the one farthest from here. This the opposite
// of how ordinary dynamic variables behave.)
// Why the base of the schema being validated and not the schema itself?
// Because the base is the scope for anchors. In fact it's possible to
// refer to a schema that is not on the stack, but a child of some base
// on the stack.
// For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json.
for _, s := range st.stack {
base := st.rs.resolvedInfos[s].base
info, ok := st.rs.resolvedInfos[base].anchors[info.dynamicRefAnchor]
if ok && info.dynamic {
return info.schema, nil
}
}
return nil, fmt.Errorf("missing dynamic anchor %q", info.dynamicRefAnchor)
}
// ApplyDefaults modifies an instance by applying the schema's defaults to it. If
// a schema or sub-schema has a default, then a corresponding zero instance value
// is set to the default.
//
// The JSON Schema specification does not describe how defaults should be interpreted.
// This method honors defaults only on properties, and only those that are not required.
// If the instance is a map and the property is missing, the property is added to
// the map with the default.
// If the instance is a struct, the field corresponding to the property exists, and
// its value is zero, the field is set to the default.
// ApplyDefaults can panic if a default cannot be assigned to a field.
//
// The argument must be a pointer to the instance.
// (In case we decide that top-level defaults are meaningful.)
//
// It is recommended to first call Resolve with a ValidateDefaults option of true,
// then call this method, and lastly call Validate.
func (rs *Resolved) ApplyDefaults(instancep any) error {
// TODO(jba): consider what defaults on top-level or array instances might mean.
// TODO(jba): follow $ref and $dynamicRef
// TODO(jba): apply defaults on sub-schemas to corresponding sub-instances.
st := &state{rs: rs}
return st.applyDefaults(reflect.ValueOf(instancep), rs.root)
}
// Leave this as a potentially recursive helper function, because we'll surely want
// to apply defaults on sub-schemas someday.
func (st *state) applyDefaults(instancep reflect.Value, schema *Schema) (err error) {
defer wrapf(&err, "applyDefaults: schema %s, instance %v", st.rs.schemaString(schema), instancep)
schemaInfo := st.rs.resolvedInfos[schema]
instance := instancep.Elem()
if instance.Kind() == reflect.Interface && instance.IsValid() {
// If we unmarshalled into 'any', the default object unmarshalling will be map[string]any.
instance = instance.Elem()
}
if instance.Kind() == reflect.Map || instance.Kind() == reflect.Struct {
if instance.Kind() == reflect.Map {
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
return fmt.Errorf("map key type %s is not a string", kt)
}
}
for prop, subschema := range schema.Properties {
// Ignore defaults on required properties. (A required property shouldn't have a default.)
if schemaInfo.isRequired[prop] {
continue
}
val := property(instance, prop)
switch instance.Kind() {
case reflect.Map:
// If there is a default for this property, and the map key is missing,
// set the map value to the default.
if subschema.Default != nil && !val.IsValid() {
// Create an lvalue, since map values aren't addressable.
lvalue := reflect.New(instance.Type().Elem())
if err := json.Unmarshal(subschema.Default, lvalue.Interface()); err != nil {
return err
}
instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem())
}
case reflect.Struct:
// If there is a default for this property, and the field exists but is zero,
// set the field to the default.
if subschema.Default != nil && val.IsValid() && val.IsZero() {
if err := json.Unmarshal(subschema.Default, val.Addr().Interface()); err != nil {
return err
}
}
default:
panic(fmt.Sprintf("applyDefaults: property %s: bad value %s of kind %s",
prop, instance, instance.Kind()))
}
}
}
return nil
}
// property returns the value of the property of v with the given name, or the invalid
// reflect.Value if there is none.
// If v is a map, the property is the value of the map whose key is name.
// If v is a struct, the property is the value of the field with the given name according
// to the encoding/json package (see [jsonName]).
// If v is anything else, property panics.
func property(v reflect.Value, name string) reflect.Value {
switch v.Kind() {
case reflect.Map:
return v.MapIndex(reflect.ValueOf(name))
case reflect.Struct:
props := structPropertiesOf(v.Type())
// Ignore nonexistent properties.
if sf, ok := props[name]; ok {
return v.FieldByIndex(sf.Index)
}
return reflect.Value{}
default:
panic(fmt.Sprintf("property(%q): bad value %s of kind %s", name, v, v.Kind()))
}
}
// properties returns an iterator over the names and values of all properties
// in v, which must be a map or a struct.
// If a struct, zero-valued properties that are marked omitempty or omitzero
// are excluded.
func properties(v reflect.Value) iter.Seq2[string, reflect.Value] {
return func(yield func(string, reflect.Value) bool) {
switch v.Kind() {
case reflect.Map:
for k, e := range v.Seq2() {
if !yield(k.String(), e) {
return
}
}
case reflect.Struct:
for name, sf := range structPropertiesOf(v.Type()) {
val := v.FieldByIndex(sf.Index)
if val.IsZero() {
info := fieldJSONInfo(sf)
if info.settings["omitempty"] || info.settings["omitzero"] {
continue
}
}
if !yield(name, val) {
return
}
}
default:
panic(fmt.Sprintf("bad value %s of kind %s", v, v.Kind()))
}
}
}
// numPropertiesBounds returns bounds on the number of v's properties.
// v must be a map or a struct.
// If v is a map, both bounds are the map's size.
// If v is a struct, the max is the number of struct properties.
// But since we don't know whether a zero value indicates a missing optional property
// or not, be generous and use the number of non-zero properties as the min.
func numPropertiesBounds(v reflect.Value, isRequired map[string]bool) (int, int) {
switch v.Kind() {
case reflect.Map:
return v.Len(), v.Len()
case reflect.Struct:
sp := structPropertiesOf(v.Type())
min := 0
for prop, sf := range sp {
if !v.FieldByIndex(sf.Index).IsZero() || isRequired[prop] {
min++
}
}
return min, len(sp)
default:
panic(fmt.Sprintf("properties: bad value: %s of kind %s", v, v.Kind()))
}
}
// A propertyMap is a map from property name to struct field index.
type propertyMap = map[string]reflect.StructField
var structProperties sync.Map // from reflect.Type to propertyMap
// structPropertiesOf returns the JSON Schema properties for the struct type t.
// The caller must not mutate the result.
func structPropertiesOf(t reflect.Type) propertyMap {
// Mutex not necessary: at worst we'll recompute the same value.
if props, ok := structProperties.Load(t); ok {
return props.(propertyMap)
}
props := map[string]reflect.StructField{}
for _, sf := range reflect.VisibleFields(t) {
if sf.Anonymous {
continue
}
info := fieldJSONInfo(sf)
if !info.omit {
props[info.name] = sf
}
}
structProperties.Store(t, props)
return props
}

View File

@@ -25,6 +25,7 @@ type Client struct {
serverCapabilities mcp.ServerCapabilities
protocolVersion string
samplingHandler SamplingHandler
elicitationHandler ElicitationHandler
}
type ClientOption func(*Client)
@@ -44,6 +45,14 @@ func WithSamplingHandler(handler SamplingHandler) ClientOption {
}
}
// WithElicitationHandler sets the elicitation handler for the client.
// When set, the client will declare elicitation capability during initialization.
func WithElicitationHandler(handler ElicitationHandler) ClientOption {
return func(c *Client) {
c.elicitationHandler = handler
}
}
// WithSession assumes a MCP Session has already been initialized
func WithSession() ClientOption {
return func(c *Client) {
@@ -77,9 +86,16 @@ func (c *Client) Start(ctx context.Context) error {
if c.transport == nil {
return fmt.Errorf("transport is nil")
}
err := c.transport.Start(ctx)
if err != nil {
return err
if _, ok := c.transport.(*transport.Stdio); !ok {
// the stdio transport from NewStdioMCPClientWithOptions
// is already started, dont start again.
//
// Start the transport for other transport types
err := c.transport.Start(ctx)
if err != nil {
return err
}
}
c.transport.SetNotificationHandler(func(notification mcp.JSONRPCNotification) {
@@ -167,6 +183,10 @@ func (c *Client) Initialize(
if c.samplingHandler != nil {
capabilities.Sampling = &struct{}{}
}
// Add elicitation capability if handler is configured
if c.elicitationHandler != nil {
capabilities.Elicitation = &struct{}{}
}
// Ensure we send a params object with all required fields
params := struct {
@@ -451,11 +471,15 @@ func (c *Client) Complete(
}
// handleIncomingRequest processes incoming requests from the server.
// This is the main entry point for server-to-client requests like sampling.
// This is the main entry point for server-to-client requests like sampling and elicitation.
func (c *Client) handleIncomingRequest(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
switch request.Method {
case string(mcp.MethodSamplingCreateMessage):
return c.handleSamplingRequestTransport(ctx, request)
case string(mcp.MethodElicitationCreate):
return c.handleElicitationRequestTransport(ctx, request)
case string(mcp.MethodPing):
return c.handlePingRequestTransport(ctx, request)
default:
return nil, fmt.Errorf("unsupported request method: %s", request.Method)
}
@@ -508,6 +532,64 @@ func (c *Client) handleSamplingRequestTransport(ctx context.Context, request tra
return response, nil
}
// handleElicitationRequestTransport handles elicitation requests at the transport level.
func (c *Client) handleElicitationRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
if c.elicitationHandler == nil {
return nil, fmt.Errorf("no elicitation handler configured")
}
// Parse the request parameters
var params mcp.ElicitationParams
if request.Params != nil {
paramsBytes, err := json.Marshal(request.Params)
if err != nil {
return nil, fmt.Errorf("failed to marshal params: %w", err)
}
if err := json.Unmarshal(paramsBytes, &params); err != nil {
return nil, fmt.Errorf("failed to unmarshal params: %w", err)
}
}
// Create the MCP request
mcpRequest := mcp.ElicitationRequest{
Request: mcp.Request{
Method: string(mcp.MethodElicitationCreate),
},
Params: params,
}
// Call the elicitation handler
result, err := c.elicitationHandler.Elicit(ctx, mcpRequest)
if err != nil {
return nil, err
}
// Marshal the result
resultBytes, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal result: %w", err)
}
// Create the transport response
response := &transport.JSONRPCResponse{
JSONRPC: mcp.JSONRPC_VERSION,
ID: request.ID,
Result: json.RawMessage(resultBytes),
}
return response, nil
}
func (c *Client) handlePingRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
b, _ := json.Marshal(&mcp.EmptyResult{})
return &transport.JSONRPCResponse{
JSONRPC: mcp.JSONRPC_VERSION,
ID: request.ID,
Result: b,
}, nil
}
func listByPage[T any](
ctx context.Context,
client *Client,

View File

@@ -0,0 +1,19 @@
package client
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
)
// ElicitationHandler defines the interface for handling elicitation requests from servers.
// Clients can implement this interface to request additional information from users.
type ElicitationHandler interface {
// Elicit handles an elicitation request from the server and returns the user's response.
// The implementation should:
// 1. Present the request message to the user
// 2. Validate input against the requested schema
// 3. Allow the user to accept, decline, or cancel
// 4. Return the appropriate response
Elicit(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error)
}

View File

@@ -12,7 +12,7 @@ import (
// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication.
// Returns an error if the subprocess cannot be started or the pipes cannot be created.
//
// NOTICE: NewStdioMCPClient will start the connection automatically. Don't call the Start method manually.
// NOTICE: NewStdioMCPClient will start the connection automatically.
// This is for backward compatibility.
func NewStdioMCPClient(
command string,
@@ -28,7 +28,6 @@ func NewStdioMCPClient(
// such as setting a custom command function.
//
// NOTICE: NewStdioMCPClientWithOptions automatically starts the underlying transport.
// Don't call the Start method manually.
// This is for backward compatibility.
func NewStdioMCPClientWithOptions(
command string,

View File

@@ -11,10 +11,11 @@ import (
)
type InProcessTransport struct {
server *server.MCPServer
samplingHandler server.SamplingHandler
session *server.InProcessSession
sessionID string
server *server.MCPServer
samplingHandler server.SamplingHandler
elicitationHandler server.ElicitationHandler
session *server.InProcessSession
sessionID string
onNotification func(mcp.JSONRPCNotification)
notifyMu sync.RWMutex
@@ -28,6 +29,12 @@ func WithSamplingHandler(handler server.SamplingHandler) InProcessOption {
}
}
func WithElicitationHandler(handler server.ElicitationHandler) InProcessOption {
return func(t *InProcessTransport) {
t.elicitationHandler = handler
}
}
func NewInProcessTransport(server *server.MCPServer) *InProcessTransport {
return &InProcessTransport{
server: server,
@@ -48,9 +55,9 @@ func NewInProcessTransportWithOptions(server *server.MCPServer, opts ...InProces
}
func (c *InProcessTransport) Start(ctx context.Context) error {
// Create and register session if we have a sampling handler
if c.samplingHandler != nil {
c.session = server.NewInProcessSession(c.sessionID, c.samplingHandler)
// Create and register session if we have handlers
if c.samplingHandler != nil || c.elicitationHandler != nil {
c.session = server.NewInProcessSessionWithHandlers(c.sessionID, c.samplingHandler, c.elicitationHandler)
if err := c.server.RegisterSession(ctx, c.session); err != nil {
return fmt.Errorf("failed to register session: %w", err)
}

View File

@@ -14,6 +14,9 @@ import (
"time"
)
// ErrNoToken is returned when no token is available in the token store
var ErrNoToken = errors.New("no token available")
// OAuthConfig holds the OAuth configuration for the client
type OAuthConfig struct {
// ClientID is the OAuth client ID
@@ -33,12 +36,26 @@ type OAuthConfig struct {
PKCEEnabled bool
}
// TokenStore is an interface for storing and retrieving OAuth tokens
// TokenStore is an interface for storing and retrieving OAuth tokens.
//
// Implementations must:
// - Honor context cancellation and deadlines, returning context.Canceled
// or context.DeadlineExceeded as appropriate
// - Return ErrNoToken (or a sentinel error that wraps it) when no token
// is available, rather than conflating this with other operational errors
// - Properly propagate all other errors (database failures, I/O errors, etc.)
// - Check ctx.Done() before performing operations and return ctx.Err() if cancelled
type TokenStore interface {
// GetToken returns the current token
GetToken() (*Token, error)
// SaveToken saves a token
SaveToken(token *Token) error
// GetToken returns the current token.
// Returns ErrNoToken if no token is available.
// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled.
// Returns other errors for operational failures (I/O, database, etc.).
GetToken(ctx context.Context) (*Token, error)
// SaveToken saves a token.
// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled.
// Returns other errors for operational failures (I/O, database, etc.).
SaveToken(ctx context.Context, token *Token) error
}
// Token represents an OAuth token
@@ -76,18 +93,27 @@ func NewMemoryTokenStore() *MemoryTokenStore {
return &MemoryTokenStore{}
}
// GetToken returns the current token
func (s *MemoryTokenStore) GetToken() (*Token, error) {
// GetToken returns the current token.
// Returns ErrNoToken if no token is available.
// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled.
func (s *MemoryTokenStore) GetToken(ctx context.Context) (*Token, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.token == nil {
return nil, errors.New("no token available")
return nil, ErrNoToken
}
return s.token, nil
}
// SaveToken saves a token
func (s *MemoryTokenStore) SaveToken(token *Token) error {
// SaveToken saves a token.
// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled.
func (s *MemoryTokenStore) SaveToken(ctx context.Context, token *Token) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.token = token
@@ -150,7 +176,10 @@ func (h *OAuthHandler) GetAuthorizationHeader(ctx context.Context) (string, erro
// getValidToken returns a valid token, refreshing if necessary
func (h *OAuthHandler) getValidToken(ctx context.Context) (*Token, error) {
token, err := h.config.TokenStore.GetToken()
token, err := h.config.TokenStore.GetToken(ctx)
if err != nil && !errors.Is(err, ErrNoToken) {
return nil, err
}
if err == nil && !token.IsExpired() && token.AccessToken != "" {
return token, nil
}
@@ -218,13 +247,12 @@ func (h *OAuthHandler) refreshToken(ctx context.Context, refreshToken string) (*
}
// If no new refresh token is provided, keep the old one
oldToken, _ := h.config.TokenStore.GetToken()
if tokenResp.RefreshToken == "" && oldToken != nil {
tokenResp.RefreshToken = oldToken.RefreshToken
if tokenResp.RefreshToken == "" {
tokenResp.RefreshToken = refreshToken
}
// Save the token
if err := h.config.TokenStore.SaveToken(&tokenResp); err != nil {
if err := h.config.TokenStore.SaveToken(ctx, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to save token: %w", err)
}
@@ -637,7 +665,7 @@ func (h *OAuthHandler) ProcessAuthorizationResponse(ctx context.Context, code, s
}
// Save the token
if err := h.config.TokenStore.SaveToken(&tokenResp); err != nil {
if err := h.config.TokenStore.SaveToken(ctx, &tokenResp); err != nil {
return fmt.Errorf("failed to save token: %w", err)
}

View File

@@ -36,12 +36,12 @@ type SSE struct {
headerFunc HTTPHeaderFunc
logger util.Logger
started atomic.Bool
closed atomic.Bool
cancelSSEStream context.CancelFunc
protocolVersion atomic.Value // string
onConnectionLost func(error)
connectionLostMu sync.RWMutex
started atomic.Bool
closed atomic.Bool
cancelSSEStream context.CancelFunc
protocolVersion atomic.Value // string
onConnectionLost func(error)
connectionLostMu sync.RWMutex
// OAuth support
oauthHandler *OAuthHandler
@@ -220,7 +220,7 @@ func (c *SSE) readSSE(reader io.ReadCloser) {
c.connectionLostMu.RLock()
handler := c.onConnectionLost
c.connectionLostMu.RUnlock()
if handler != nil {
// This is not actually an error - HTTP2 idle timeout disconnection
handler(err)

View File

@@ -27,7 +27,7 @@ type Stdio struct {
cmd *exec.Cmd
cmdFunc CommandFunc
stdin io.WriteCloser
stdout *bufio.Reader
stdout *bufio.Scanner
stderr io.ReadCloser
responses map[string]chan *JSONRPCResponse
mu sync.RWMutex
@@ -72,7 +72,7 @@ func WithCommandLogger(logger util.Logger) StdioOption {
func NewIO(input io.Reader, output io.WriteCloser, logging io.ReadCloser) *Stdio {
return &Stdio{
stdin: output,
stdout: bufio.NewReader(input),
stdout: bufio.NewScanner(input),
stderr: logging,
responses: make(map[string]chan *JSONRPCResponse),
@@ -180,7 +180,7 @@ func (c *Stdio) spawnCommand(ctx context.Context) error {
c.cmd = cmd
c.stdin = stdin
c.stderr = stderr
c.stdout = bufio.NewReader(stdout)
c.stdout = bufio.NewScanner(stdout)
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start command: %w", err)
@@ -200,11 +200,15 @@ func (c *Stdio) Close() error {
// cancel all in-flight request
close(c.done)
if err := c.stdin.Close(); err != nil {
return fmt.Errorf("failed to close stdin: %w", err)
if c.stdin != nil {
if err := c.stdin.Close(); err != nil {
return fmt.Errorf("failed to close stdin: %w", err)
}
}
if err := c.stderr.Close(); err != nil {
return fmt.Errorf("failed to close stderr: %w", err)
if c.stderr != nil {
if err := c.stderr.Close(); err != nil {
return fmt.Errorf("failed to close stderr: %w", err)
}
}
if c.cmd != nil {
@@ -247,14 +251,15 @@ func (c *Stdio) readResponses() {
case <-c.done:
return
default:
line, err := c.stdout.ReadString('\n')
if err != nil {
if err != io.EOF && !errors.Is(err, context.Canceled) {
if !c.stdout.Scan() {
err := c.stdout.Err()
if err != nil && !errors.Is(err, context.Canceled) {
c.logger.Errorf("Error reading from stdout: %v", err)
}
return
}
line := c.stdout.Text()
// First try to parse as a generic message to check for ID field
var baseMessage struct {
JSONRPC string `json:"jsonrpc"`

View File

@@ -425,7 +425,7 @@ func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCl
// Try to unmarshal as a response first
var message JSONRPCResponse
if err := json.Unmarshal([]byte(data), &message); err != nil {
c.logger.Errorf("failed to unmarshal message: %v", err)
c.logger.Infof("failed to unmarshal message (non-fatal): %v", err, "message", data)
return
}
@@ -605,7 +605,7 @@ func (c *StreamableHTTP) listenForever(ctx context.Context) {
connectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := c.createGETConnectionToServer(connectCtx)
cancel()
if errors.Is(err, ErrGetMethodNotAllowed) {
// server does not support listening
c.logger.Errorf("server does not support listening")
@@ -621,7 +621,7 @@ func (c *StreamableHTTP) listenForever(ctx context.Context) {
if err != nil {
c.logger.Errorf("failed to listen to server. retry in 1 second: %v", err)
}
// Use context-aware sleep
select {
case <-time.After(retryInterval):
@@ -704,15 +704,15 @@ func (c *StreamableHTTP) handleIncomingRequest(ctx context.Context, request JSON
// Create a new context with timeout for request handling, respecting parent context
requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
response, err := handler(requestCtx, request)
if err != nil {
c.logger.Errorf("error handling request %s: %v", request.Method, err)
// Determine appropriate JSON-RPC error code based on error type
var errorCode int
var errorMessage string
// Check for specific sampling-related errors
if errors.Is(err, context.Canceled) {
errorCode = -32800 // Request cancelled
@@ -731,7 +731,7 @@ func (c *StreamableHTTP) handleIncomingRequest(ctx context.Context, request JSON
errorMessage = err.Error()
}
}
// Send error response
errorResponse := &JSONRPCResponse{
JSONRPC: "2.0",

View File

@@ -565,6 +565,8 @@ type Tool struct {
InputSchema ToolInputSchema `json:"inputSchema"`
// Alternative to InputSchema - allows arbitrary JSON Schema to be provided
RawInputSchema json.RawMessage `json:"-"` // Hide this from JSON marshaling
// A JSON Schema object defining the expected output returned by the tool .
OutputSchema ToolOutputSchema `json:"outputSchema,omitempty"`
// Optional JSON Schema defining expected output structure
RawOutputSchema json.RawMessage `json:"-"` // Hide this from JSON marshaling
// Optional properties describing tool behavior
@@ -601,7 +603,12 @@ func (t Tool) MarshalJSON() ([]byte, error) {
// Add output schema if present
if t.RawOutputSchema != nil {
if t.OutputSchema.Type != "" {
return nil, fmt.Errorf("tool %s has both OutputSchema and RawOutputSchema set: %w", t.Name, errToolSchemaConflict)
}
m["outputSchema"] = t.RawOutputSchema
} else if t.OutputSchema.Type != "" { // If no output schema is specified, do not return anything
m["outputSchema"] = t.OutputSchema
}
m["annotations"] = t.Annotations
@@ -609,15 +616,19 @@ func (t Tool) MarshalJSON() ([]byte, error) {
return json.Marshal(m)
}
type ToolInputSchema struct {
// ToolArgumentsSchema represents a JSON Schema for tool arguments.
type ToolArgumentsSchema struct {
Defs map[string]any `json:"$defs,omitempty"`
Type string `json:"type"`
Properties map[string]any `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
}
type ToolInputSchema ToolArgumentsSchema // For retro-compatibility
type ToolOutputSchema ToolArgumentsSchema
// MarshalJSON implements the json.Marshaler interface for ToolInputSchema.
func (tis ToolInputSchema) MarshalJSON() ([]byte, error) {
func (tis ToolArgumentsSchema) MarshalJSON() ([]byte, error) {
m := make(map[string]any)
m["type"] = tis.Type
@@ -780,7 +791,15 @@ func WithOutputSchema[T any]() ToolOption {
return
}
t.RawOutputSchema = json.RawMessage(mcpSchema)
// Retrieve the schema from raw JSON
if err := json.Unmarshal(mcpSchema, &t.OutputSchema); err != nil {
// Skip and maintain backward compatibility
return
}
// Always set the type to "object" as of the current MCP spec
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema
t.OutputSchema.Type = "object"
}
}

View File

@@ -56,6 +56,10 @@ const (
// https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging
MethodSetLogLevel MCPMethod = "logging/setLevel"
// MethodElicitationCreate requests additional information from the user during interactions.
// https://modelcontextprotocol.io/docs/concepts/elicitation
MethodElicitationCreate MCPMethod = "elicitation/create"
// MethodNotificationResourcesListChanged notifies when the list of available resources changes.
// https://modelcontextprotocol.io/specification/2025-03-26/server/resources#list-changed-notification
MethodNotificationResourcesListChanged = "notifications/resources/list_changed"
@@ -462,6 +466,8 @@ type ClientCapabilities struct {
} `json:"roots,omitempty"`
// Present if the client supports sampling from an LLM.
Sampling *struct{} `json:"sampling,omitempty"`
// Present if the client supports elicitation requests from the server.
Elicitation *struct{} `json:"elicitation,omitempty"`
}
// ServerCapabilities represents capabilities that a server may support. Known
@@ -492,6 +498,8 @@ type ServerCapabilities struct {
// Whether this server supports notifications for changes to the tool list.
ListChanged bool `json:"listChanged,omitempty"`
} `json:"tools,omitempty"`
// Present if the server supports elicitation requests to the client.
Elicitation *struct{} `json:"elicitation,omitempty"`
}
// Implementation describes the name and version of an MCP implementation.
@@ -814,6 +822,50 @@ func (l LoggingLevel) ShouldSendTo(minLevel LoggingLevel) bool {
return ia >= ib
}
/* Elicitation */
// ElicitationRequest is a request from the server to the client to request additional
// information from the user during an interaction.
type ElicitationRequest struct {
Request
Params ElicitationParams `json:"params"`
}
// ElicitationParams contains the parameters for an elicitation request.
type ElicitationParams struct {
// A human-readable message explaining what information is being requested and why.
Message string `json:"message"`
// A JSON Schema defining the expected structure of the user's response.
RequestedSchema any `json:"requestedSchema"`
}
// ElicitationResult represents the result of an elicitation request.
type ElicitationResult struct {
Result
ElicitationResponse
}
// ElicitationResponse represents the user's response to an elicitation request.
type ElicitationResponse struct {
// Action indicates whether the user accepted, declined, or cancelled.
Action ElicitationResponseAction `json:"action"`
// Content contains the user's response data if they accepted.
// Should conform to the requestedSchema from the ElicitationRequest.
Content any `json:"content,omitempty"`
}
// ElicitationResponseAction indicates how the user responded to an elicitation request.
type ElicitationResponseAction string
const (
// ElicitationResponseActionAccept indicates the user provided the requested information.
ElicitationResponseActionAccept ElicitationResponseAction = "accept"
// ElicitationResponseActionDecline indicates the user explicitly declined to provide information.
ElicitationResponseActionDecline ElicitationResponseAction = "decline"
// ElicitationResponseActionCancel indicates the user cancelled without making a choice.
ElicitationResponseActionCancel ElicitationResponseAction = "cancel"
)
/* Sampling */
const (

View File

@@ -8,54 +8,54 @@ import (
)
// ClientRequest types
var _ ClientRequest = &PingRequest{}
var _ ClientRequest = &InitializeRequest{}
var _ ClientRequest = &CompleteRequest{}
var _ ClientRequest = &SetLevelRequest{}
var _ ClientRequest = &GetPromptRequest{}
var _ ClientRequest = &ListPromptsRequest{}
var _ ClientRequest = &ListResourcesRequest{}
var _ ClientRequest = &ReadResourceRequest{}
var _ ClientRequest = &SubscribeRequest{}
var _ ClientRequest = &UnsubscribeRequest{}
var _ ClientRequest = &CallToolRequest{}
var _ ClientRequest = &ListToolsRequest{}
var _ ClientRequest = (*PingRequest)(nil)
var _ ClientRequest = (*InitializeRequest)(nil)
var _ ClientRequest = (*CompleteRequest)(nil)
var _ ClientRequest = (*SetLevelRequest)(nil)
var _ ClientRequest = (*GetPromptRequest)(nil)
var _ ClientRequest = (*ListPromptsRequest)(nil)
var _ ClientRequest = (*ListResourcesRequest)(nil)
var _ ClientRequest = (*ReadResourceRequest)(nil)
var _ ClientRequest = (*SubscribeRequest)(nil)
var _ ClientRequest = (*UnsubscribeRequest)(nil)
var _ ClientRequest = (*CallToolRequest)(nil)
var _ ClientRequest = (*ListToolsRequest)(nil)
// ClientNotification types
var _ ClientNotification = &CancelledNotification{}
var _ ClientNotification = &ProgressNotification{}
var _ ClientNotification = &InitializedNotification{}
var _ ClientNotification = &RootsListChangedNotification{}
var _ ClientNotification = (*CancelledNotification)(nil)
var _ ClientNotification = (*ProgressNotification)(nil)
var _ ClientNotification = (*InitializedNotification)(nil)
var _ ClientNotification = (*RootsListChangedNotification)(nil)
// ClientResult types
var _ ClientResult = &EmptyResult{}
var _ ClientResult = &CreateMessageResult{}
var _ ClientResult = &ListRootsResult{}
var _ ClientResult = (*EmptyResult)(nil)
var _ ClientResult = (*CreateMessageResult)(nil)
var _ ClientResult = (*ListRootsResult)(nil)
// ServerRequest types
var _ ServerRequest = &PingRequest{}
var _ ServerRequest = &CreateMessageRequest{}
var _ ServerRequest = &ListRootsRequest{}
var _ ServerRequest = (*PingRequest)(nil)
var _ ServerRequest = (*CreateMessageRequest)(nil)
var _ ServerRequest = (*ListRootsRequest)(nil)
// ServerNotification types
var _ ServerNotification = &CancelledNotification{}
var _ ServerNotification = &ProgressNotification{}
var _ ServerNotification = &LoggingMessageNotification{}
var _ ServerNotification = &ResourceUpdatedNotification{}
var _ ServerNotification = &ResourceListChangedNotification{}
var _ ServerNotification = &ToolListChangedNotification{}
var _ ServerNotification = &PromptListChangedNotification{}
var _ ServerNotification = (*CancelledNotification)(nil)
var _ ServerNotification = (*ProgressNotification)(nil)
var _ ServerNotification = (*LoggingMessageNotification)(nil)
var _ ServerNotification = (*ResourceUpdatedNotification)(nil)
var _ ServerNotification = (*ResourceListChangedNotification)(nil)
var _ ServerNotification = (*ToolListChangedNotification)(nil)
var _ ServerNotification = (*PromptListChangedNotification)(nil)
// ServerResult types
var _ ServerResult = &EmptyResult{}
var _ ServerResult = &InitializeResult{}
var _ ServerResult = &CompleteResult{}
var _ ServerResult = &GetPromptResult{}
var _ ServerResult = &ListPromptsResult{}
var _ ServerResult = &ListResourcesResult{}
var _ ServerResult = &ReadResourceResult{}
var _ ServerResult = &CallToolResult{}
var _ ServerResult = &ListToolsResult{}
var _ ServerResult = (*EmptyResult)(nil)
var _ ServerResult = (*InitializeResult)(nil)
var _ ServerResult = (*CompleteResult)(nil)
var _ ServerResult = (*GetPromptResult)(nil)
var _ ServerResult = (*ListPromptsResult)(nil)
var _ ServerResult = (*ListResourcesResult)(nil)
var _ ServerResult = (*ReadResourceResult)(nil)
var _ ServerResult = (*CallToolResult)(nil)
var _ ServerResult = (*ListToolsResult)(nil)
// Helper functions for type assertions
@@ -253,6 +253,24 @@ func NewToolResultText(text string) *CallToolResult {
}
}
// NewToolResultJSON creates a new CallToolResult with a JSON content.
func NewToolResultJSON[T any](data T) (*CallToolResult, error) {
b, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("unable to marshal JSON: %w", err)
}
return &CallToolResult{
Content: []Content{
TextContent{
Type: ContentTypeText,
Text: string(b),
},
},
StructuredContent: data,
}, nil
}
// NewToolResultStructured creates a new CallToolResult with structured content.
// It includes both the structured content and a text representation for backward compatibility.
func NewToolResultStructured(structured any, fallbackText string) *CallToolResult {

View File

@@ -0,0 +1,32 @@
package server
import (
"context"
"errors"
"github.com/mark3labs/mcp-go/mcp"
)
var (
// ErrNoActiveSession is returned when there is no active session in the context
ErrNoActiveSession = errors.New("no active session")
// ErrElicitationNotSupported is returned when the session does not support elicitation
ErrElicitationNotSupported = errors.New("session does not support elicitation")
)
// RequestElicitation sends an elicitation request to the client.
// The client must have declared elicitation capability during initialization.
// The session must implement SessionWithElicitation to support this operation.
func (s *MCPServer) RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) {
session := ClientSessionFromContext(ctx)
if session == nil {
return nil, ErrNoActiveSession
}
// Check if the session supports elicitation requests
if elicitationSession, ok := session.(SessionWithElicitation); ok {
return elicitationSession.RequestElicitation(ctx, request)
}
return nil, ErrElicitationNotSupported
}

View File

@@ -15,6 +15,11 @@ type SamplingHandler interface {
CreateMessage(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error)
}
// ElicitationHandler defines the interface for handling elicitation requests from servers.
type ElicitationHandler interface {
Elicit(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error)
}
type InProcessSession struct {
sessionID string
notifications chan mcp.JSONRPCNotification
@@ -23,6 +28,7 @@ type InProcessSession struct {
clientInfo atomic.Value
clientCapabilities atomic.Value
samplingHandler SamplingHandler
elicitationHandler ElicitationHandler
mu sync.RWMutex
}
@@ -34,6 +40,15 @@ func NewInProcessSession(sessionID string, samplingHandler SamplingHandler) *InP
}
}
func NewInProcessSessionWithHandlers(sessionID string, samplingHandler SamplingHandler, elicitationHandler ElicitationHandler) *InProcessSession {
return &InProcessSession{
sessionID: sessionID,
notifications: make(chan mcp.JSONRPCNotification, 100),
samplingHandler: samplingHandler,
elicitationHandler: elicitationHandler,
}
}
func (s *InProcessSession) SessionID() string {
return s.sessionID
}
@@ -101,6 +116,18 @@ func (s *InProcessSession) RequestSampling(ctx context.Context, request mcp.Crea
return handler.CreateMessage(ctx, request)
}
func (s *InProcessSession) RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) {
s.mu.RLock()
handler := s.elicitationHandler
s.mu.RUnlock()
if handler == nil {
return nil, fmt.Errorf("no elicitation handler available")
}
return handler.Elicit(ctx, request)
}
// GenerateInProcessSessionID generates a unique session ID for inprocess clients
func GenerateInProcessSessionID() string {
return fmt.Sprintf("inprocess-%d", time.Now().UnixNano())
@@ -108,8 +135,9 @@ func GenerateInProcessSessionID() string {
// Ensure interface compliance
var (
_ ClientSession = (*InProcessSession)(nil)
_ SessionWithLogging = (*InProcessSession)(nil)
_ SessionWithClientInfo = (*InProcessSession)(nil)
_ SessionWithSampling = (*InProcessSession)(nil)
_ ClientSession = (*InProcessSession)(nil)
_ SessionWithLogging = (*InProcessSession)(nil)
_ SessionWithClientInfo = (*InProcessSession)(nil)
_ SessionWithSampling = (*InProcessSession)(nil)
_ SessionWithElicitation = (*InProcessSession)(nil)
)

View File

@@ -12,7 +12,7 @@ import (
func (s *MCPServer) EnableSampling() {
s.capabilitiesMu.Lock()
defer s.capabilitiesMu.Unlock()
enabled := true
s.capabilities.sampling = &enabled
}

View File

@@ -43,6 +43,9 @@ type ToolHandlerFunc func(ctx context.Context, request mcp.CallToolRequest) (*mc
// ToolHandlerMiddleware is a middleware function that wraps a ToolHandlerFunc.
type ToolHandlerMiddleware func(ToolHandlerFunc) ToolHandlerFunc
// ResourceHandlerMiddleware is a middleware function that wraps a ResourceHandlerFunc.
type ResourceHandlerMiddleware func(ResourceHandlerFunc) ResourceHandlerFunc
// ToolFilterFunc is a function that filters tools based on context, typically using session information.
type ToolFilterFunc func(ctx context.Context, tools []mcp.Tool) []mcp.Tool
@@ -144,28 +147,30 @@ type NotificationHandlerFunc func(ctx context.Context, notification mcp.JSONRPCN
type MCPServer struct {
// Separate mutexes for different resource types
resourcesMu sync.RWMutex
resourceMiddlewareMu sync.RWMutex
promptsMu sync.RWMutex
toolsMu sync.RWMutex
middlewareMu sync.RWMutex
toolMiddlewareMu sync.RWMutex
notificationHandlersMu sync.RWMutex
capabilitiesMu sync.RWMutex
toolFiltersMu sync.RWMutex
name string
version string
instructions string
resources map[string]resourceEntry
resourceTemplates map[string]resourceTemplateEntry
prompts map[string]mcp.Prompt
promptHandlers map[string]PromptHandlerFunc
tools map[string]ServerTool
toolHandlerMiddlewares []ToolHandlerMiddleware
toolFilters []ToolFilterFunc
notificationHandlers map[string]NotificationHandlerFunc
capabilities serverCapabilities
paginationLimit *int
sessions sync.Map
hooks *Hooks
name string
version string
instructions string
resources map[string]resourceEntry
resourceTemplates map[string]resourceTemplateEntry
prompts map[string]mcp.Prompt
promptHandlers map[string]PromptHandlerFunc
tools map[string]ServerTool
toolHandlerMiddlewares []ToolHandlerMiddleware
resourceHandlerMiddlewares []ResourceHandlerMiddleware
toolFilters []ToolFilterFunc
notificationHandlers map[string]NotificationHandlerFunc
capabilities serverCapabilities
paginationLimit *int
sessions sync.Map
hooks *Hooks
}
// WithPaginationLimit sets the pagination limit for the server.
@@ -177,11 +182,12 @@ func WithPaginationLimit(limit int) ServerOption {
// serverCapabilities defines the supported features of the MCP server
type serverCapabilities struct {
tools *toolCapabilities
resources *resourceCapabilities
prompts *promptCapabilities
logging *bool
sampling *bool
tools *toolCapabilities
resources *resourceCapabilities
prompts *promptCapabilities
logging *bool
sampling *bool
elicitation *bool
}
// resourceCapabilities defines the supported resource-related features
@@ -217,12 +223,42 @@ func WithToolHandlerMiddleware(
toolHandlerMiddleware ToolHandlerMiddleware,
) ServerOption {
return func(s *MCPServer) {
s.middlewareMu.Lock()
s.toolMiddlewareMu.Lock()
s.toolHandlerMiddlewares = append(s.toolHandlerMiddlewares, toolHandlerMiddleware)
s.middlewareMu.Unlock()
s.toolMiddlewareMu.Unlock()
}
}
// WithResourceHandlerMiddleware allows adding a middleware for the
// resource handler call chain.
func WithResourceHandlerMiddleware(
resourceHandlerMiddleware ResourceHandlerMiddleware,
) ServerOption {
return func(s *MCPServer) {
s.resourceMiddlewareMu.Lock()
s.resourceHandlerMiddlewares = append(s.resourceHandlerMiddlewares, resourceHandlerMiddleware)
s.resourceMiddlewareMu.Unlock()
}
}
// WithResourceRecovery adds a middleware that recovers from panics in resource handlers.
func WithResourceRecovery() ServerOption {
return WithResourceHandlerMiddleware(func(next ResourceHandlerFunc) ResourceHandlerFunc {
return func(ctx context.Context, request mcp.ReadResourceRequest) (result []mcp.ResourceContents, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf(
"panic recovered in %s resource handler: %v",
request.Params.URI,
r,
)
}
}()
return next(ctx, request)
}
})
}
// WithToolFilter adds a filter function that will be applied to tools before they are returned in list_tools
func WithToolFilter(
toolFilter ToolFilterFunc,
@@ -288,6 +324,13 @@ func WithLogging() ServerOption {
}
}
// WithElicitation enables elicitation capabilities for the server
func WithElicitation() ServerOption {
return func(s *MCPServer) {
s.capabilities.elicitation = mcp.ToBoolPtr(true)
}
}
// WithInstructions sets the server instructions for the client returned in the initialize response
func WithInstructions(instructions string) ServerOption {
return func(s *MCPServer) {
@@ -301,14 +344,16 @@ func NewMCPServer(
opts ...ServerOption,
) *MCPServer {
s := &MCPServer{
resources: make(map[string]resourceEntry),
resourceTemplates: make(map[string]resourceTemplateEntry),
prompts: make(map[string]mcp.Prompt),
promptHandlers: make(map[string]PromptHandlerFunc),
tools: make(map[string]ServerTool),
name: name,
version: version,
notificationHandlers: make(map[string]NotificationHandlerFunc),
resources: make(map[string]resourceEntry),
resourceTemplates: make(map[string]resourceTemplateEntry),
prompts: make(map[string]mcp.Prompt),
promptHandlers: make(map[string]PromptHandlerFunc),
tools: make(map[string]ServerTool),
toolHandlerMiddlewares: make([]ToolHandlerMiddleware, 0),
resourceHandlerMiddlewares: make([]ResourceHandlerMiddleware, 0),
name: name,
version: version,
notificationHandlers: make(map[string]NotificationHandlerFunc),
capabilities: serverCapabilities{
tools: nil,
resources: nil,
@@ -555,6 +600,30 @@ func (s *MCPServer) SetTools(tools ...ServerTool) {
s.AddTools(tools...)
}
// GetTool retrieves the specified tool
func (s *MCPServer) GetTool(toolName string) *ServerTool {
s.toolsMu.RLock()
defer s.toolsMu.RUnlock()
if tool, ok := s.tools[toolName]; ok {
return &tool
}
return nil
}
func (s *MCPServer) ListTools() map[string]*ServerTool {
s.toolsMu.RLock()
defer s.toolsMu.RUnlock()
if len(s.tools) == 0 {
return nil
}
// Create a copy to prevent external modification
toolsCopy := make(map[string]*ServerTool, len(s.tools))
for name, tool := range s.tools {
toolsCopy[name] = &tool
}
return toolsCopy
}
// DeleteTools removes tools from the server
func (s *MCPServer) DeleteTools(names ...string) {
s.toolsMu.Lock()
@@ -628,6 +697,10 @@ func (s *MCPServer) handleInitialize(
capabilities.Sampling = &struct{}{}
}
if s.capabilities.elicitation != nil && *s.capabilities.elicitation {
capabilities.Elicitation = &struct{}{}
}
result := mcp.InitializeResult{
ProtocolVersion: s.protocolVersion(request.Params.ProtocolVersion),
ServerInfo: mcp.Implementation{
@@ -838,7 +911,17 @@ func (s *MCPServer) handleReadResource(
if entry, ok := s.resources[request.Params.URI]; ok {
handler := entry.handler
s.resourcesMu.RUnlock()
contents, err := handler(ctx, request)
finalHandler := handler
s.resourceMiddlewareMu.RLock()
mw := s.resourceHandlerMiddlewares
// Apply middlewares in reverse order
for i := len(mw) - 1; i >= 0; i-- {
finalHandler = mw[i](finalHandler)
}
s.resourceMiddlewareMu.RUnlock()
contents, err := finalHandler(ctx, request)
if err != nil {
return nil, &requestError{
id: id,
@@ -1092,14 +1175,14 @@ func (s *MCPServer) handleToolCall(
finalHandler := tool.Handler
s.middlewareMu.RLock()
s.toolMiddlewareMu.RLock()
mw := s.toolHandlerMiddlewares
// Apply middlewares in reverse order
for i := len(mw) - 1; i >= 0; i-- {
finalHandler = mw[i](finalHandler)
}
s.middlewareMu.RUnlock()
s.toolMiddlewareMu.RUnlock()
result, err := finalHandler(ctx, request)
if err != nil {

View File

@@ -52,6 +52,13 @@ type SessionWithClientInfo interface {
SetClientCapabilities(clientCapabilities mcp.ClientCapabilities)
}
// SessionWithElicitation is an extension of ClientSession that can send elicitation requests
type SessionWithElicitation interface {
ClientSession
// RequestElicitation sends an elicitation request to the client and waits for response
RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error)
}
// SessionWithStreamableHTTPConfig extends ClientSession to support streamable HTTP transport configurations
type SessionWithStreamableHTTPConfig interface {
ClientSession

View File

@@ -92,16 +92,17 @@ func WithQueueSize(size int) StdioOption {
// stdioSession is a static client session, since stdio has only one client.
type stdioSession struct {
notifications chan mcp.JSONRPCNotification
initialized atomic.Bool
loggingLevel atomic.Value
clientInfo atomic.Value // stores session-specific client info
clientCapabilities atomic.Value // stores session-specific client capabilities
writer io.Writer // for sending requests to client
requestID atomic.Int64 // for generating unique request IDs
mu sync.RWMutex // protects writer
pendingRequests map[int64]chan *samplingResponse // for tracking pending sampling requests
pendingMu sync.RWMutex // protects pendingRequests
notifications chan mcp.JSONRPCNotification
initialized atomic.Bool
loggingLevel atomic.Value
clientInfo atomic.Value // stores session-specific client info
clientCapabilities atomic.Value // stores session-specific client capabilities
writer io.Writer // for sending requests to client
requestID atomic.Int64 // for generating unique request IDs
mu sync.RWMutex // protects writer
pendingRequests map[int64]chan *samplingResponse // for tracking pending sampling requests
pendingElicitations map[int64]chan *elicitationResponse // for tracking pending elicitation requests
pendingMu sync.RWMutex // protects pendingRequests and pendingElicitations
}
// samplingResponse represents a response to a sampling request
@@ -110,6 +111,12 @@ type samplingResponse struct {
err error
}
// elicitationResponse represents a response to an elicitation request
type elicitationResponse struct {
result *mcp.ElicitationResult
err error
}
func (s *stdioSession) SessionID() string {
return "stdio"
}
@@ -229,6 +236,69 @@ func (s *stdioSession) RequestSampling(ctx context.Context, request mcp.CreateMe
}
}
// RequestElicitation sends an elicitation request to the client and waits for the response.
func (s *stdioSession) RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) {
s.mu.RLock()
writer := s.writer
s.mu.RUnlock()
if writer == nil {
return nil, fmt.Errorf("no writer available for sending requests")
}
// Generate a unique request ID
id := s.requestID.Add(1)
// Create a response channel for this request
responseChan := make(chan *elicitationResponse, 1)
s.pendingMu.Lock()
s.pendingElicitations[id] = responseChan
s.pendingMu.Unlock()
// Cleanup function to remove the pending request
cleanup := func() {
s.pendingMu.Lock()
delete(s.pendingElicitations, id)
s.pendingMu.Unlock()
}
defer cleanup()
// Create the JSON-RPC request
jsonRPCRequest := struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params mcp.ElicitationParams `json:"params"`
}{
JSONRPC: mcp.JSONRPC_VERSION,
ID: id,
Method: string(mcp.MethodElicitationCreate),
Params: request.Params,
}
// Marshal and send the request
requestBytes, err := json.Marshal(jsonRPCRequest)
if err != nil {
return nil, fmt.Errorf("failed to marshal elicitation request: %w", err)
}
requestBytes = append(requestBytes, '\n')
if _, err := writer.Write(requestBytes); err != nil {
return nil, fmt.Errorf("failed to write elicitation request: %w", err)
}
// Wait for the response or context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
case response := <-responseChan:
if response.err != nil {
return nil, response.err
}
return response.result, nil
}
}
// SetWriter sets the writer for sending requests to the client.
func (s *stdioSession) SetWriter(writer io.Writer) {
s.mu.Lock()
@@ -237,15 +307,17 @@ func (s *stdioSession) SetWriter(writer io.Writer) {
}
var (
_ ClientSession = (*stdioSession)(nil)
_ SessionWithLogging = (*stdioSession)(nil)
_ SessionWithClientInfo = (*stdioSession)(nil)
_ SessionWithSampling = (*stdioSession)(nil)
_ ClientSession = (*stdioSession)(nil)
_ SessionWithLogging = (*stdioSession)(nil)
_ SessionWithClientInfo = (*stdioSession)(nil)
_ SessionWithSampling = (*stdioSession)(nil)
_ SessionWithElicitation = (*stdioSession)(nil)
)
var stdioSessionInstance = stdioSession{
notifications: make(chan mcp.JSONRPCNotification, 100),
pendingRequests: make(map[int64]chan *samplingResponse),
notifications: make(chan mcp.JSONRPCNotification, 100),
pendingRequests: make(map[int64]chan *samplingResponse),
pendingElicitations: make(map[int64]chan *elicitationResponse),
}
// NewStdioServer creates a new stdio server wrapper around an MCPServer.
@@ -445,6 +517,11 @@ func (s *StdioServer) processMessage(
return nil
}
// Check if this is a response to an elicitation request
if s.handleElicitationResponse(rawMessage) {
return nil
}
// Check if this is a tool call that might need sampling (and thus should be processed concurrently)
var baseMessage struct {
Method string `json:"method"`
@@ -543,6 +620,67 @@ func (s *stdioSession) handleSamplingResponse(rawMessage json.RawMessage) bool {
return true
}
// handleElicitationResponse checks if the message is a response to an elicitation request
// and routes it to the appropriate pending request channel.
func (s *StdioServer) handleElicitationResponse(rawMessage json.RawMessage) bool {
return stdioSessionInstance.handleElicitationResponse(rawMessage)
}
// handleElicitationResponse handles incoming elicitation responses for this session
func (s *stdioSession) handleElicitationResponse(rawMessage json.RawMessage) bool {
// Try to parse as a JSON-RPC response
var response struct {
JSONRPC string `json:"jsonrpc"`
ID json.Number `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
if err := json.Unmarshal(rawMessage, &response); err != nil {
return false
}
// Parse the ID as int64
id, err := response.ID.Int64()
if err != nil || (response.Result == nil && response.Error == nil) {
return false
}
// Check if we have a pending elicitation request with this ID
s.pendingMu.RLock()
responseChan, exists := s.pendingElicitations[id]
s.pendingMu.RUnlock()
if !exists {
return false
}
// Parse and send the response
elicitationResp := &elicitationResponse{}
if response.Error != nil {
elicitationResp.err = fmt.Errorf("elicitation request failed: %s", response.Error.Message)
} else {
var result mcp.ElicitationResult
if err := json.Unmarshal(response.Result, &result); err != nil {
elicitationResp.err = fmt.Errorf("failed to unmarshal elicitation response: %w", err)
} else {
elicitationResp.result = &result
}
}
// Send the response (non-blocking)
select {
case responseChan <- elicitationResp:
default:
// Channel is full or closed, ignore
}
return true
}
// writeResponse marshals and writes a JSON-RPC response message followed by a newline.
// Returns an error if marshaling or writing fails.
func (s *StdioServer) writeResponse(

View File

@@ -1,6 +1,7 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -8,10 +9,12 @@ import (
"mime"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"unicode"
"github.com/google/uuid"
"github.com/mark3labs/mcp-go/mcp"
@@ -93,6 +96,15 @@ func WithLogger(logger util.Logger) StreamableHTTPOption {
}
}
// WithTLSCert sets the TLS certificate and key files for HTTPS support.
// Both certFile and keyFile must be provided to enable TLS.
func WithTLSCert(certFile, keyFile string) StreamableHTTPOption {
return func(s *StreamableHTTPServer) {
s.tlsCertFile = certFile
s.tlsKeyFile = keyFile
}
}
// StreamableHTTPServer implements a Streamable-http based MCP server.
// It communicates with clients over HTTP protocol, supporting both direct HTTP responses, and SSE streams.
// https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
@@ -131,6 +143,9 @@ type StreamableHTTPServer struct {
listenHeartbeatInterval time.Duration
logger util.Logger
sessionLogLevels *sessionLogLevelsStore
tlsCertFile string
tlsKeyFile string
}
// NewStreamableHTTPServer creates a new streamable-http server instance
@@ -188,6 +203,19 @@ func (s *StreamableHTTPServer) Start(addr string) error {
srv := s.httpServer
s.mu.Unlock()
if s.tlsCertFile != "" || s.tlsKeyFile != "" {
if s.tlsCertFile == "" || s.tlsKeyFile == "" {
return fmt.Errorf("both TLS cert and key must be provided")
}
if _, err := os.Stat(s.tlsCertFile); err != nil {
return fmt.Errorf("failed to find TLS certificate file: %w", err)
}
if _, err := os.Stat(s.tlsKeyFile); err != nil {
return fmt.Errorf("failed to find TLS key file: %w", err)
}
return srv.ListenAndServeTLS(s.tlsCertFile, s.tlsKeyFile)
}
return srv.ListenAndServe()
}
@@ -236,10 +264,18 @@ func (s *StreamableHTTPServer) handlePost(w http.ResponseWriter, r *http.Request
return
}
// detect empty ping response, skip session ID validation
isPingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil &&
(isJSONEmpty(jsonMessage.Result) && isJSONEmpty(jsonMessage.Error))
if isPingResponse {
return
}
// Check if this is a sampling response (has result/error but no method)
isSamplingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil &&
isSamplingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil &&
(jsonMessage.Result != nil || jsonMessage.Error != nil)
isInitializeRequest := jsonMessage.Method == mcp.MethodInitialize
// Handle sampling responses separately
@@ -390,7 +426,7 @@ func (s *StreamableHTTPServer) handleGet(w http.ResponseWriter, r *http.Request)
return
}
defer s.server.UnregisterSession(r.Context(), sessionID)
// Register session for sampling response delivery
s.activeSessions.Store(sessionID, session)
defer s.activeSessions.Delete(sessionID)
@@ -437,6 +473,21 @@ func (s *StreamableHTTPServer) handleGet(w http.ResponseWriter, r *http.Request)
case <-done:
return
}
case elicitationReq := <-session.elicitationRequestChan:
// Send elicitation request to client via SSE
jsonrpcRequest := mcp.JSONRPCRequest{
JSONRPC: "2.0",
ID: mcp.NewRequestId(elicitationReq.requestID),
Request: mcp.Request{
Method: string(mcp.MethodElicitationCreate),
},
Params: elicitationReq.request.Params,
}
select {
case writeChan <- jsonrpcRequest:
case <-done:
return
}
case <-done:
return
}
@@ -576,12 +627,6 @@ func (s *StreamableHTTPServer) handleSamplingResponse(w http.ResponseWriter, r *
}
} else if responseMessage.Result != nil {
// Parse result
var result mcp.CreateMessageResult
if err := json.Unmarshal(responseMessage.Result, &result); err != nil {
response.err = fmt.Errorf("failed to parse sampling result: %v", err)
} else {
response.result = &result
}
} else {
response.err = fmt.Errorf("sampling response has neither result nor error")
}
@@ -728,10 +773,17 @@ type samplingRequestItem struct {
type samplingResponseItem struct {
requestID int64
result *mcp.CreateMessageResult
result json.RawMessage
err error
}
// Elicitation support types for HTTP transport
type elicitationRequestItem struct {
requestID int64
request mcp.ElicitationRequest
response chan samplingResponseItem
}
// streamableHttpSession is a session for streamable-http transport
// When in POST handlers(request/notification), it's ephemeral, and only exists in the life of the request handler.
// When in GET handlers(listening), it's a real session, and will be registered in the MCP server.
@@ -743,18 +795,21 @@ type streamableHttpSession struct {
logLevels *sessionLogLevelsStore
// Sampling support for bidirectional communication
samplingRequestChan chan samplingRequestItem // server -> client sampling requests
samplingRequests sync.Map // requestID -> pending sampling request context
requestIDCounter atomic.Int64 // for generating unique request IDs
samplingRequestChan chan samplingRequestItem // server -> client sampling requests
elicitationRequestChan chan elicitationRequestItem // server -> client elicitation requests
samplingRequests sync.Map // requestID -> pending sampling request context
requestIDCounter atomic.Int64 // for generating unique request IDs
}
func newStreamableHttpSession(sessionID string, toolStore *sessionToolsStore, levels *sessionLogLevelsStore) *streamableHttpSession {
s := &streamableHttpSession{
sessionID: sessionID,
notificationChannel: make(chan mcp.JSONRPCNotification, 100),
tools: toolStore,
logLevels: levels,
samplingRequestChan: make(chan samplingRequestItem, 10),
sessionID: sessionID,
notificationChannel: make(chan mcp.JSONRPCNotification, 100),
tools: toolStore,
logLevels: levels,
samplingRequestChan: make(chan samplingRequestItem, 10),
elicitationRequestChan: make(chan elicitationRequestItem, 10),
}
return s
}
@@ -810,21 +865,21 @@ var _ SessionWithStreamableHTTPConfig = (*streamableHttpSession)(nil)
func (s *streamableHttpSession) RequestSampling(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) {
// Generate unique request ID
requestID := s.requestIDCounter.Add(1)
// Create response channel for this specific request
responseChan := make(chan samplingResponseItem, 1)
// Create the sampling request item
samplingRequest := samplingRequestItem{
requestID: requestID,
request: request,
response: responseChan,
}
// Store the pending request
s.samplingRequests.Store(requestID, responseChan)
defer s.samplingRequests.Delete(requestID)
// Send the sampling request via the channel (non-blocking)
select {
case s.samplingRequestChan <- samplingRequest:
@@ -834,20 +889,70 @@ func (s *streamableHttpSession) RequestSampling(ctx context.Context, request mcp
default:
return nil, fmt.Errorf("sampling request queue is full - server overloaded")
}
// Wait for response or context cancellation
select {
case response := <-responseChan:
if response.err != nil {
return nil, response.err
}
return response.result, nil
var result mcp.CreateMessageResult
if err := json.Unmarshal(response.result, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal sampling response: %v", err)
}
return &result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// RequestElicitation implements SessionWithElicitation interface for HTTP transport
func (s *streamableHttpSession) RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) {
// Generate unique request ID
requestID := s.requestIDCounter.Add(1)
// Create response channel for this specific request
responseChan := make(chan samplingResponseItem, 1)
// Create the sampling request item
elicitationRequest := elicitationRequestItem{
requestID: requestID,
request: request,
response: responseChan,
}
// Store the pending request
s.samplingRequests.Store(requestID, responseChan)
defer s.samplingRequests.Delete(requestID)
// Send the sampling request via the channel (non-blocking)
select {
case s.elicitationRequestChan <- elicitationRequest:
// Request queued successfully
case <-ctx.Done():
return nil, ctx.Err()
default:
return nil, fmt.Errorf("elicitation request queue is full - server overloaded")
}
// Wait for response or context cancellation
select {
case response := <-responseChan:
if response.err != nil {
return nil, response.err
}
var result mcp.ElicitationResult
if err := json.Unmarshal(response.result, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal elicitation response: %v", err)
}
return &result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
var _ SessionWithSampling = (*streamableHttpSession)(nil)
var _ SessionWithElicitation = (*streamableHttpSession)(nil)
// --- session id manager ---
@@ -911,3 +1016,52 @@ func NewTestStreamableHTTPServer(server *MCPServer, opts ...StreamableHTTPOption
testServer := httptest.NewServer(sseServer)
return testServer
}
// isJSONEmpty reports whether the provided JSON value is "empty":
// - null
// - empty object: {}
// - empty array: []
//
// It also treats nil/whitespace-only input as empty.
// It does NOT treat 0, false, "" or non-empty composites as empty.
func isJSONEmpty(data json.RawMessage) bool {
if len(data) == 0 {
return true
}
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return true
}
switch trimmed[0] {
case '{':
if len(trimmed) == 2 && trimmed[1] == '}' {
return true
}
for i := 1; i < len(trimmed); i++ {
if !unicode.IsSpace(rune(trimmed[i])) {
return trimmed[i] == '}'
}
}
case '[':
if len(trimmed) == 2 && trimmed[1] == ']' {
return true
}
for i := 1; i < len(trimmed); i++ {
if !unicode.IsSpace(rune(trimmed[i])) {
return trimmed[i] == ']'
}
}
case '"': // treat "" as not empty
return false
case 'n': // null
return len(trimmed) == 4 &&
trimmed[1] == 'u' &&
trimmed[2] == 'l' &&
trimmed[3] == 'l'
}
return false
}

View File

@@ -10,3 +10,6 @@ trim_trailing_whitespace = true
[*.go]
indent_style = tab
[{*.yml,*.yaml}]
indent_size = 2

View File

@@ -1,18 +1,48 @@
linters-settings:
gci:
sections:
- standard
- default
- prefix(github.com/spf13/afero)
version: "2"
run:
timeout: 10m
linters:
disable-all: true
enable:
- gci
- gofmt
- gofumpt
- staticcheck
enable:
- govet
- ineffassign
- misspell
- nolintlint
# - revive
- staticcheck
- unused
issues:
exclude-dirs:
- gcsfs/internal/stiface
disable:
- errcheck
# - staticcheck
settings:
misspell:
locale: US
nolintlint:
allow-unused: false # report any unused nolint directives
require-specific: false # don't require nolint directives to be specific about which linter is being skipped
exclusions:
paths:
- gcsfs/internal/stiface
formatters:
enable:
- gci
- gofmt
- gofumpt
- goimports
- golines
settings:
gci:
sections:
- standard
- default
- localmodule
exclusions:
paths:
- gcsfs/internal/stiface

View File

@@ -1,479 +1,474 @@
![afero logo-sm](https://cloud.githubusercontent.com/assets/173412/11490338/d50e16dc-97a5-11e5-8b12-019a300d0fcb.png)
A FileSystem Abstraction System for Go
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/spf13/afero/ci.yaml?branch=master&style=flat-square)](https://github.com/spf13/afero/actions?query=workflow%3ACI)
[![Join the chat at https://gitter.im/spf13/afero](https://badges.gitter.im/Dev%20Chat.svg)](https://gitter.im/spf13/afero?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
[![Go Report Card](https://goreportcard.com/badge/github.com/spf13/afero?style=flat-square)](https://goreportcard.com/report/github.com/spf13/afero)
![Go Version](https://img.shields.io/badge/go%20version-%3E=1.23-61CFDD.svg?style=flat-square)
[![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/spf13/afero)](https://pkg.go.dev/mod/github.com/spf13/afero)
# Overview
Afero is a filesystem framework providing a simple, uniform and universal API
interacting with any filesystem, as an abstraction layer providing interfaces,
types and methods. Afero has an exceptionally clean interface and simple design
without needless constructors or initialization methods.
Afero is also a library providing a base set of interoperable backend
filesystems that make it easy to work with, while retaining all the power
and benefit of the os and ioutil packages.
Afero provides significant improvements over using the os package alone, most
notably the ability to create mock and testing filesystems without relying on the disk.
It is suitable for use in any situation where you would consider using the OS
package as it provides an additional abstraction that makes it easy to use a
memory backed file system during testing. It also adds support for the http
filesystem for full interoperability.
<img src="https://cloud.githubusercontent.com/assets/173412/11490338/d50e16dc-97a5-11e5-8b12-019a300d0fcb.png" alt="afero logo-sm"/>
## Afero Features
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/spf13/afero/ci.yaml?branch=master&amp;style=flat-square)](https://github.com/spf13/afero/actions?query=workflow%3ACI)
[![GoDoc](https://pkg.go.dev/badge/mod/github.com/spf13/afero)](https://pkg.go.dev/mod/github.com/spf13/afero)
[![Go Report Card](https://goreportcard.com/badge/github.com/spf13/afero)](https://goreportcard.com/report/github.com/spf13/afero)
![Go Version](https://img.shields.io/badge/go%20version-%3E=1.23-61CFDD.svg?style=flat-square")
* A single consistent API for accessing a variety of filesystems
* Interoperation between a variety of file system types
* A set of interfaces to encourage and enforce interoperability between backends
* An atomic cross platform memory backed file system
* Support for compositional (union) file systems by combining multiple file systems acting as one
* Specialized backends which modify existing filesystems (Read Only, Regexp filtered)
* A set of utility functions ported from io, ioutil & hugo to be afero aware
* Wrapper for go 1.16 filesystem abstraction `io/fs.FS`
# Using Afero
# Afero: The Universal Filesystem Abstraction for Go
Afero is easy to use and easier to adopt.
Afero is a powerful and extensible filesystem abstraction system for Go. It provides a single, unified API for interacting with diverse filesystems—including the local disk, memory, archives, and network storage.
A few different ways you could use Afero:
Afero acts as a drop-in replacement for the standard `os` package, enabling you to write modular code that is agnostic to the underlying storage, dramatically simplifies testing, and allows for sophisticated architectural patterns through filesystem composition.
* Use the interfaces alone to define your own file system.
* Wrapper for the OS packages.
* Define different filesystems for different parts of your application.
* Use Afero for mock filesystems while testing
## Why Afero?
## Step 1: Install Afero
Afero elevates filesystem interaction beyond simple file reading and writing, offering solutions for testability, flexibility, and advanced architecture.
First use go get to install the latest version of the library.
🔑 **Key Features:**
$ go get github.com/spf13/afero
* **Universal API:** Write your code once. Run it against the local OS, in-memory storage, ZIP/TAR archives, or remote systems (SFTP, GCS).
* **Ultimate Testability:** Utilize `MemMapFs`, a fully concurrent-safe, read/write in-memory filesystem. Write fast, isolated, and reliable unit tests without touching the physical disk or worrying about cleanup.
* **Powerful Composition:** Afero's hidden superpower. Layer filesystems on top of each other to create sophisticated behaviors:
* **Sandboxing:** Use `CopyOnWriteFs` to create temporary scratch spaces that isolate changes from the base filesystem.
* **Caching:** Use `CacheOnReadFs` to automatically layer a fast cache (like memory) over a slow backend (like a network drive).
* **Security Jails:** Use `BasePathFs` to restrict application access to a specific subdirectory (chroot).
* **`os` Package Compatibility:** Afero mirrors the functions in the standard `os` package, making adoption and refactoring seamless.
* **`io/fs` Compatibility:** Fully compatible with the Go standard library's `io/fs` interfaces.
## Installation
```bash
go get github.com/spf13/afero
```
Next include Afero in your application.
```go
import "github.com/spf13/afero"
```
## Step 2: Declare a backend
## Quick Start: The Power of Abstraction
First define a package variable and set it to a pointer to a filesystem.
```go
var AppFs = afero.NewMemMapFs()
The core of Afero is the `afero.Fs` interface. By designing your functions to accept this interface rather than calling `os.*` functions directly, your code instantly becomes more flexible and testable.
or
### 1. Refactor Your Code
var AppFs = afero.NewOsFs()
```
It is important to note that if you repeat the composite literal you
will be using a completely new and isolated filesystem. In the case of
OsFs it will still use the same underlying filesystem but will reduce
the ability to drop in other filesystems as desired.
## Step 3: Use it like you would the OS package
Throughout your application use any function and method like you normally
would.
So if my application before had:
```go
os.Open("/tmp/foo")
```
We would replace it with:
```go
AppFs.Open("/tmp/foo")
```
`AppFs` being the variable we defined above.
## List of all available functions
File System Methods Available:
```go
Chmod(name string, mode os.FileMode) : error
Chown(name string, uid, gid int) : error
Chtimes(name string, atime time.Time, mtime time.Time) : error
Create(name string) : File, error
Mkdir(name string, perm os.FileMode) : error
MkdirAll(path string, perm os.FileMode) : error
Name() : string
Open(name string) : File, error
OpenFile(name string, flag int, perm os.FileMode) : File, error
Remove(name string) : error
RemoveAll(path string) : error
Rename(oldname, newname string) : error
Stat(name string) : os.FileInfo, error
```
File Interfaces and Methods Available:
```go
io.Closer
io.Reader
io.ReaderAt
io.Seeker
io.Writer
io.WriterAt
Name() : string
Readdir(count int) : []os.FileInfo, error
Readdirnames(n int) : []string, error
Stat() : os.FileInfo, error
Sync() : error
Truncate(size int64) : error
WriteString(s string) : ret int, err error
```
In some applications it may make sense to define a new package that
simply exports the file system variable for easy access from anywhere.
## Using Afero's utility functions
Afero provides a set of functions to make it easier to use the underlying file systems.
These functions have been primarily ported from io & ioutil with some developed for Hugo.
The afero utilities support all afero compatible backends.
The list of utilities includes:
Change functions that rely on the `os` package to accept `afero.Fs`.
```go
DirExists(path string) (bool, error)
Exists(path string) (bool, error)
FileContainsBytes(filename string, subslice []byte) (bool, error)
GetTempDir(subPath string) string
IsDir(path string) (bool, error)
IsEmpty(path string) (bool, error)
ReadDir(dirname string) ([]os.FileInfo, error)
ReadFile(filename string) ([]byte, error)
SafeWriteReader(path string, r io.Reader) (err error)
TempDir(dir, prefix string) (name string, err error)
TempFile(dir, prefix string) (f File, err error)
Walk(root string, walkFn filepath.WalkFunc) error
WriteFile(filename string, data []byte, perm os.FileMode) error
WriteReader(path string, r io.Reader) (err error)
```
For a complete list see [Afero's GoDoc](https://godoc.org/github.com/spf13/afero)
// Before: Coupled to the OS and difficult to test
// func ProcessConfiguration(path string) error {
// data, err := os.ReadFile(path)
// ...
// }
They are available under two different approaches to use. You can either call
them directly where the first parameter of each function will be the file
system, or you can declare a new `Afero`, a custom type used to bind these
functions as methods to a given filesystem.
import "github.com/spf13/afero"
### Calling utilities directly
```go
fs := new(afero.MemMapFs)
f, err := afero.TempFile(fs,"", "ioutil-test")
```
### Calling via Afero
```go
fs := afero.NewMemMapFs()
afs := &afero.Afero{Fs: fs}
f, err := afs.TempFile("", "ioutil-test")
```
## Using Afero for Testing
There is a large benefit to using a mock filesystem for testing. It has a
completely blank state every time it is initialized and can be easily
reproducible regardless of OS. You could create files to your hearts content
and the file access would be fast while also saving you from all the annoying
issues with deleting temporary files, Windows file locking, etc. The MemMapFs
backend is perfect for testing.
* Much faster than performing I/O operations on disk
* Avoid security issues and permissions
* Far more control. 'rm -rf /' with confidence
* Test setup is far more easier to do
* No test cleanup needed
One way to accomplish this is to define a variable as mentioned above.
In your application this will be set to afero.NewOsFs() during testing you
can set it to afero.NewMemMapFs().
It wouldn't be uncommon to have each test initialize a blank slate memory
backend. To do this I would define my `appFS = afero.NewOsFs()` somewhere
appropriate in my application code. This approach ensures that Tests are order
independent, with no test relying on the state left by an earlier test.
Then in my tests I would initialize a new MemMapFs for each test:
```go
func TestExist(t *testing.T) {
appFS := afero.NewMemMapFs()
// create test files and directories
appFS.MkdirAll("src/a", 0755)
afero.WriteFile(appFS, "src/a/b", []byte("file b"), 0644)
afero.WriteFile(appFS, "src/c", []byte("file c"), 0644)
name := "src/c"
_, err := appFS.Stat(name)
if os.IsNotExist(err) {
t.Errorf("file \"%s\" does not exist.\n", name)
}
// After: Decoupled, flexible, and testable
func ProcessConfiguration(fs afero.Fs, path string) error {
// Use Afero utility functions which mirror os/ioutil
data, err := afero.ReadFile(fs, path)
// ... process the data
return err
}
```
# Available Backends
### 2. Usage in Production
## Operating System Native
### OsFs
The first is simply a wrapper around the native OS calls. This makes it
very easy to use as all of the calls are the same as the existing OS
calls. It also makes it trivial to have your code use the OS during
operation and a mock filesystem during testing or as needed.
In your production environment, inject the `OsFs` backend, which wraps the standard operating system calls.
```go
appfs := afero.NewOsFs()
appfs.MkdirAll("src/a", 0755)
func main() {
// Use the real OS filesystem
AppFs := afero.NewOsFs()
ProcessConfiguration(AppFs, "/etc/myapp.conf")
}
```
## Memory Backed Storage
### 3. Usage in Testing
### MemMapFs
Afero also provides a fully atomic memory backed filesystem perfect for use in
mocking and to speed up unnecessary disk io when persistence isnt
necessary. It is fully concurrent and will work within go routines
safely.
In your tests, inject `MemMapFs`. This provides a blazing-fast, isolated, in-memory filesystem that requires no disk I/O and no cleanup.
```go
mm := afero.NewMemMapFs()
mm.MkdirAll("src/a", 0755)
func TestProcessConfiguration(t *testing.T) {
// Use the in-memory filesystem
AppFs := afero.NewMemMapFs()
// Pre-populate the memory filesystem for the test
configPath := "/test/config.json"
afero.WriteFile(AppFs, configPath, []byte(`{"feature": true}`), 0644)
// Run the test entirely in memory
err := ProcessConfiguration(AppFs, configPath)
if err != nil {
t.Fatal(err)
}
}
```
#### InMemoryFile
## Afero's Superpower: Composition
As part of MemMapFs, Afero also provides an atomic, fully concurrent memory
backed file implementation. This can be used in other memory backed file
systems with ease. Plans are to add a radix tree memory stored file
system using InMemoryFile.
Afero's most unique feature is its ability to combine filesystems. This allows you to build complex behaviors out of simple components, keeping your application logic clean.
## Network Interfaces
### Example 1: Sandboxing with Copy-on-Write
### SftpFs
Afero has experimental support for secure file transfer protocol (sftp). Which can
be used to perform file operations over a encrypted channel.
### GCSFs
Afero has experimental support for Google Cloud Storage (GCS). You can either set the
`GOOGLE_APPLICATION_CREDENTIALS_JSON` env variable to your JSON credentials or use `opts` in
`NewGcsFS` to configure access to your GCS bucket.
Some known limitations of the existing implementation:
* No Chmod support - The GCS ACL could probably be mapped to *nix style permissions but that would add another level of complexity and is ignored in this version.
* No Chtimes support - Could be simulated with attributes (gcs a/m-times are set implicitly) but that's is left for another version.
* Not thread safe - Also assumes all file operations are done through the same instance of the GcsFs. File operations between different GcsFs instances are not guaranteed to be consistent.
## Filtering Backends
### BasePathFs
The BasePathFs restricts all operations to a given path within an Fs.
The given file name to the operations on this Fs will be prepended with
the base path before calling the source Fs.
Create a temporary environment where an application can "modify" system files without affecting the actual disk.
```go
bp := afero.NewBasePathFs(afero.NewOsFs(), "/base/path")
// 1. The base layer is the real OS, made read-only for safety.
baseFs := afero.NewReadOnlyFs(afero.NewOsFs())
// 2. The overlay layer is a temporary in-memory filesystem for changes.
overlayFs := afero.NewMemMapFs()
// 3. Combine them. Reads fall through to the base; writes only hit the overlay.
sandboxFs := afero.NewCopyOnWriteFs(baseFs, overlayFs)
// The application can now "modify" /etc/hosts, but the changes are isolated in memory.
afero.WriteFile(sandboxFs, "/etc/hosts", []byte("127.0.0.1 sandboxed-app"), 0644)
// The real /etc/hosts on disk is untouched.
```
### ReadOnlyFs
### Example 2: Caching a Slow Filesystem
A thin wrapper around the source Fs providing a read only view.
Improve performance by layering a fast cache (like memory) over a slow backend (like a network drive or cloud storage).
```go
fs := afero.NewReadOnlyFs(afero.NewOsFs())
_, err := fs.Create("/file.txt")
// err = syscall.EPERM
import "time"
// Assume 'remoteFs' is a slow backend (e.g., SFTP or GCS)
var remoteFs afero.Fs
// 'cacheFs' is a fast in-memory backend
cacheFs := afero.NewMemMapFs()
// Create the caching layer. Cache items for 5 minutes upon first read.
cachedFs := afero.NewCacheOnReadFs(remoteFs, cacheFs, 5*time.Minute)
// The first read is slow (fetches from remote, then caches)
data1, _ := afero.ReadFile(cachedFs, "data.json")
// The second read is instant (serves from memory cache)
data2, _ := afero.ReadFile(cachedFs, "data.json")
```
# RegexpFs
### Example 3: Security Jails (chroot)
A filtered view on file names, any file NOT matching
the passed regexp will be treated as non-existing.
Files not matching the regexp provided will not be created.
Directories are not filtered.
Restrict an application component's access to a specific subdirectory.
```go
fs := afero.NewRegexpFs(afero.NewMemMapFs(), regexp.MustCompile(`\.txt$`))
_, err := fs.Create("/file.html")
// err = syscall.ENOENT
osFs := afero.NewOsFs()
// Create a filesystem rooted at /home/user/public
// The application cannot access anything above this directory.
jailedFs := afero.NewBasePathFs(osFs, "/home/user/public")
// To the application, this is reading "/"
// In reality, it's reading "/home/user/public/"
dirInfo, err := afero.ReadDir(jailedFs, "/")
// Attempts to access parent directories fail
_, err = jailedFs.Open("../secrets.txt") // Returns an error
```
### HttpFs
## Real-World Use Cases
Afero provides an http compatible backend which can wrap any of the existing
backends.
### Build Cloud-Agnostic Applications
The Http package requires a slightly specific version of Open which
returns an http.File type.
Afero provides an httpFs file system which satisfies this requirement.
Any Afero FileSystem can be used as an httpFs.
Write applications that seamlessly work with different storage backends:
```go
httpFs := afero.NewHttpFs(<ExistingFS>)
fileserver := http.FileServer(httpFs.Dir(<PATH>))
http.Handle("/", fileserver)
type DocumentProcessor struct {
fs afero.Fs
}
func NewDocumentProcessor(fs afero.Fs) *DocumentProcessor {
return &DocumentProcessor{fs: fs}
}
func (p *DocumentProcessor) Process(inputPath, outputPath string) error {
// This code works whether fs is local disk, cloud storage, or memory
content, err := afero.ReadFile(p.fs, inputPath)
if err != nil {
return err
}
processed := processContent(content)
return afero.WriteFile(p.fs, outputPath, processed, 0644)
}
// Use with local filesystem
processor := NewDocumentProcessor(afero.NewOsFs())
// Use with Google Cloud Storage
processor := NewDocumentProcessor(gcsFS)
// Use with in-memory filesystem for testing
processor := NewDocumentProcessor(afero.NewMemMapFs())
```
## Composite Backends
### Treating Archives as Filesystems
Afero provides the ability have two filesystems (or more) act as a single
file system.
### CacheOnReadFs
The CacheOnReadFs will lazily make copies of any accessed files from the base
layer into the overlay. Subsequent reads will be pulled from the overlay
directly permitting the request is within the cache duration of when it was
created in the overlay.
If the base filesystem is writeable, any changes to files will be
done first to the base, then to the overlay layer. Write calls to open file
handles like `Write()` or `Truncate()` to the overlay first.
To writing files to the overlay only, you can use the overlay Fs directly (not
via the union Fs).
Cache files in the layer for the given time.Duration, a cache duration of 0
means "forever" meaning the file will not be re-requested from the base ever.
A read-only base will make the overlay also read-only but still copy files
from the base to the overlay when they're not present (or outdated) in the
caching layer.
Read files directly from `.zip` or `.tar` archives without unpacking them to disk first.
```go
base := afero.NewOsFs()
layer := afero.NewMemMapFs()
ufs := afero.NewCacheOnReadFs(base, layer, 100 * time.Second)
import (
"archive/zip"
"github.com/spf13/afero/zipfs"
)
// Assume 'zipReader' is a *zip.Reader initialized from a file or memory
var zipReader *zip.Reader
// Create a read-only ZipFs
archiveFS := zipfs.New(zipReader)
// Read a file from within the archive using the standard Afero API
content, err := afero.ReadFile(archiveFS, "/docs/readme.md")
```
### CopyOnWriteFs()
### Serving Any Filesystem over HTTP
The CopyOnWriteFs is a read only base file system with a potentially
writeable layer on top.
Read operations will first look in the overlay and if not found there, will
serve the file from the base.
Changes to the file system will only be made in the overlay.
Any attempt to modify a file found only in the base will copy the file to the
overlay layer before modification (including opening a file with a writable
handle).
Removing and Renaming files present only in the base layer is not currently
permitted. If a file is present in the base layer and the overlay, only the
overlay will be removed/renamed.
Use `HttpFs` to expose any Afero filesystem—even one created dynamically in memory—through a standard Go web server.
```go
base := afero.NewOsFs()
roBase := afero.NewReadOnlyFs(base)
ufs := afero.NewCopyOnWriteFs(roBase, afero.NewMemMapFs())
import (
"net/http"
"github.com/spf13/afero"
)
fh, _ = ufs.Create("/home/test/file2.txt")
fh.WriteString("This is a test")
fh.Close()
func main() {
memFS := afero.NewMemMapFs()
afero.WriteFile(memFS, "index.html", []byte("<h1>Hello from Memory!</h1>"), 0644)
// Wrap the memory filesystem to make it compatible with http.FileServer.
httpFS := afero.NewHttpFs(memFS)
http.Handle("/", http.FileServer(httpFS.Dir("/")))
http.ListenAndServe(":8080", nil)
}
```
In this example all write operations will only occur in memory (MemMapFs)
leaving the base filesystem (OsFs) untouched.
### Testing Made Simple
One of Afero's greatest strengths is making filesystem-dependent code easily testable:
## Desired/possible backends
```go
func SaveUserData(fs afero.Fs, userID string, data []byte) error {
filename := fmt.Sprintf("users/%s.json", userID)
return afero.WriteFile(fs, filename, data, 0644)
}
The following is a short list of possible backends we hope someone will
implement:
func TestSaveUserData(t *testing.T) {
// Create a clean, fast, in-memory filesystem for testing
testFS := afero.NewMemMapFs()
userData := []byte(`{"name": "John", "email": "john@example.com"}`)
err := SaveUserData(testFS, "123", userData)
if err != nil {
t.Fatalf("SaveUserData failed: %v", err)
}
// Verify the file was saved correctly
saved, err := afero.ReadFile(testFS, "users/123.json")
if err != nil {
t.Fatalf("Failed to read saved file: %v", err)
}
if string(saved) != string(userData) {
t.Errorf("Data mismatch: got %s, want %s", saved, userData)
}
}
```
* SSH
* S3
**Benefits of testing with Afero:**
-**Fast** - No disk I/O, tests run in memory
- 🔄 **Reliable** - Each test starts with a clean slate
- 🧹 **No cleanup** - Memory is automatically freed
- 🔒 **Safe** - Can't accidentally modify real files
- 🏃 **Parallel** - Tests can run concurrently without conflicts
# About the project
## Backend Reference
## What's in the name
| Type | Backend | Constructor | Description | Status |
| :--- | :--- | :--- | :--- | :--- |
| **Core** | **OsFs** | `afero.NewOsFs()` | Interacts with the real operating system filesystem. Use in production. | ✅ Official |
| | **MemMapFs** | `afero.NewMemMapFs()` | A fast, atomic, concurrent-safe, in-memory filesystem. Ideal for testing. | ✅ Official |
| **Composition** | **CopyOnWriteFs**| `afero.NewCopyOnWriteFs(base, overlay)` | A read-only base with a writable overlay. Ideal for sandboxing. | ✅ Official |
| | **CacheOnReadFs**| `afero.NewCacheOnReadFs(base, cache, ttl)` | Lazily caches files from a slow base into a fast layer on first read. | ✅ Official |
| | **BasePathFs** | `afero.NewBasePathFs(source, path)` | Restricts operations to a subdirectory (chroot/jail). | ✅ Official |
| | **ReadOnlyFs** | `afero.NewReadOnlyFs(source)` | Provides a read-only view, preventing any modifications. | ✅ Official |
| | **RegexpFs** | `afero.NewRegexpFs(source, regexp)` | Filters a filesystem, only showing files that match a regex. | ✅ Official |
| **Utility** | **HttpFs** | `afero.NewHttpFs(source)` | Wraps any Afero filesystem to be served via `http.FileServer`. | ✅ Official |
| **Archives** | **ZipFs** | `zipfs.New(zipReader)` | Read-only access to files within a ZIP archive. | ✅ Official |
| | **TarFs** | `tarfs.New(tarReader)` | Read-only access to files within a TAR archive. | ✅ Official |
| **Network** | **GcsFs** | `gcsfs.NewGcsFs(...)` | Google Cloud Storage backend. | ⚡ Experimental |
| | **SftpFs** | `sftpfs.New(...)` | SFTP backend. | ⚡ Experimental |
| **3rd Party Cloud** | **S3Fs** | [`fclairamb/afero-s3`](https://github.com/fclairamb/afero-s3) | Production-ready S3 backend built on official AWS SDK. | 🔹 3rd Party |
| | **MinioFs** | [`cpyun/afero-minio`](https://github.com/cpyun/afero-minio) | MinIO object storage backend with S3 compatibility. | 🔹 3rd Party |
| | **DriveFs** | [`fclairamb/afero-gdrive`](https://github.com/fclairamb/afero-gdrive) | Google Drive backend with streaming support. | 🔹 3rd Party |
| | **DropboxFs** | [`fclairamb/afero-dropbox`](https://github.com/fclairamb/afero-dropbox) | Dropbox backend with streaming support. | 🔹 3rd Party |
| **3rd Party Specialized** | **GitFs** | [`tobiash/go-gitfs`](https://github.com/tobiash/go-gitfs) | Git repository filesystem (read-only, Afero compatible). | 🔹 3rd Party |
| | **DockerFs** | [`unmango/aferox`](https://github.com/unmango/aferox) | Docker container filesystem access. | 🔹 3rd Party |
| | **GitHubFs** | [`unmango/aferox`](https://github.com/unmango/aferox) | GitHub repository and releases filesystem. | 🔹 3rd Party |
| | **FilterFs** | [`unmango/aferox`](https://github.com/unmango/aferox) | Filesystem filtering with predicates. | 🔹 3rd Party |
| | **IgnoreFs** | [`unmango/aferox`](https://github.com/unmango/aferox) | .gitignore-aware filtering filesystem. | 🔹 3rd Party |
| | **FUSEFs** | [`JakWai01/sile-fystem`](https://github.com/JakWai01/sile-fystem) | Generic FUSE implementation using any Afero backend. | 🔹 3rd Party |
Afero comes from the latin roots Ad-Facere.
## Afero vs. `io/fs` (Go 1.16+)
**"Ad"** is a prefix meaning "to".
Go 1.16 introduced the `io/fs` package, which provides a standard abstraction for **read-only** filesystems.
**"Facere"** is a form of the root "faciō" making "make or do".
Afero complements `io/fs` by focusing on different needs:
The literal meaning of afero is "to make" or "to do" which seems very fitting
for a library that allows one to make files and directories and do things with them.
* **Use `io/fs` when:** You only need to read files and want to conform strictly to the standard library interfaces.
* **Use Afero when:**
* Your application needs to **create, write, modify, or delete** files.
* You need to test complex read/write interactions (e.g., renaming, concurrent writes).
* You need advanced compositional features (Copy-on-Write, Caching, etc.).
The English word that shares the same roots as Afero is "affair". Affair shares
the same concept but as a noun it means "something that is made or done" or "an
object of a particular type".
Afero is fully compatible with `io/fs`. You can wrap any Afero filesystem to satisfy the `fs.FS` interface using `afero.NewIOFS`:
It's also nice that unlike some of my other libraries (hugo, cobra, viper) it
Googles very well.
```go
import "io/fs"
## Release Notes
// Create an Afero filesystem (writable)
var myAferoFs afero.Fs = afero.NewMemMapFs()
See the [Releases Page](https://github.com/spf13/afero/releases).
// Convert it to a standard library fs.FS (read-only view)
var myIoFs fs.FS = afero.NewIOFS(myAferoFs)
```
## Third-Party Backends & Ecosystem
The Afero community has developed numerous backends and tools that extend the library's capabilities. Below are curated, well-maintained options organized by maturity and reliability.
### Featured Community Backends
These are mature, reliable backends that we can confidently recommend for production use:
#### **Amazon S3** - [`fclairamb/afero-s3`](https://github.com/fclairamb/afero-s3)
Production-ready S3 backend built on the official AWS SDK for Go.
```go
import "github.com/fclairamb/afero-s3"
s3fs := s3.NewFs(bucket, session)
```
#### **MinIO** - [`cpyun/afero-minio`](https://github.com/cpyun/afero-minio)
MinIO object storage backend providing S3-compatible object storage with deduplication and optimization features.
```go
import "github.com/cpyun/afero-minio"
minioFs := miniofs.NewMinioFs(ctx, "minio://endpoint/bucket")
```
### Community & Specialized Backends
#### Cloud Storage
- **Google Drive** - [`fclairamb/afero-gdrive`](https://github.com/fclairamb/afero-gdrive)
Streaming support; no write-seeking or POSIX permissions; no files listing cache
- **Dropbox** - [`fclairamb/afero-dropbox`](https://github.com/fclairamb/afero-dropbox)
Streaming support; no write-seeking or POSIX permissions
#### Version Control Systems
- **Git Repositories** - [`tobiash/go-gitfs`](https://github.com/tobiash/go-gitfs)
Read-only filesystem abstraction for Git repositories. Works with bare repositories and provides filesystem view of any git reference. Uses go-git for repository access.
#### Container and Remote Systems
- **Docker Containers** - [`unmango/aferox`](https://github.com/unmango/aferox)
Access Docker container filesystems as if they were local filesystems
- **GitHub API** - [`unmango/aferox`](https://github.com/unmango/aferox)
Turn GitHub repositories, releases, and assets into browsable filesystems
#### FUSE Integration
- **Generic FUSE** - [`JakWai01/sile-fystem`](https://github.com/JakWai01/sile-fystem)
Mount any Afero filesystem as a FUSE filesystem, allowing any Afero backend to be used as a real mounted filesystem
#### Specialized Filesystems
- **FAT32 Support** - [`aligator/GoFAT`](https://github.com/aligator/GoFAT)
Pure Go FAT filesystem implementation (currently read-only)
### Interface Adapters & Utilities
**Cross-Interface Compatibility:**
- [`jfontan/go-billy-desfacer`](https://github.com/jfontan/go-billy-desfacer) - Adapter between Afero and go-billy interfaces (for go-git compatibility)
- [`Maldris/go-billy-afero`](https://github.com/Maldris/go-billy-afero) - Alternative wrapper for using Afero with go-billy
- [`c4milo/afero2billy`](https://github.com/c4milo/afero2billy) - Another Afero to billy filesystem adapter
**Working Directory Management:**
- [`carolynvs/aferox`](https://github.com/carolynvs/aferox) - Working directory-aware filesystem wrapper
**Advanced Filtering:**
- [`unmango/aferox`](https://github.com/unmango/aferox) includes multiple specialized filesystems:
- **FilterFs** - Predicate-based file filtering
- **IgnoreFs** - .gitignore-aware filtering
- **WriterFs** - Dump writes to io.Writer for debugging
#### Developer Tools & Utilities
**nhatthm Utility Suite** - Essential tools for Afero development:
- [`nhatthm/aferocopy`](https://github.com/nhatthm/aferocopy) - Copy files between any Afero filesystems
- [`nhatthm/aferomock`](https://github.com/nhatthm/aferomock) - Mocking toolkit for testing
- [`nhatthm/aferoassert`](https://github.com/nhatthm/aferoassert) - Assertion helpers for filesystem testing
### Ecosystem Showcase
**Windows Virtual Drives** - [`balazsgrill/potatodrive`](https://github.com/balazsgrill/potatodrive)
Mount any Afero filesystem as a Windows drive letter. Brilliant demonstration of Afero's power!
### Modern Asset Embedding (Go 1.16+)
Instead of third-party tools, use Go's native `//go:embed` with Afero:
```go
import (
"embed"
"github.com/spf13/afero"
)
//go:embed assets/*
var assetsFS embed.FS
func main() {
// Convert embedded files to Afero filesystem
fs := afero.FromIOFS(assetsFS)
// Use like any other Afero filesystem
content, _ := afero.ReadFile(fs, "assets/config.json")
}
```
## Contributing
1. Fork it
We welcome contributions! The project is mature, but we are actively looking for contributors to help implement and stabilize network/cloud backends.
* 🔥 **Microsoft Azure Blob Storage**
* 🔒 **Modern Encryption Backend** - Built on secure, contemporary crypto (not legacy EncFS)
* 🐙 **Canonical go-git Adapter** - Unified solution for Git integration
* 📡 **SSH/SCP Backend** - Secure remote file operations
* Stabilization of existing experimental backends (GCS, SFTP)
To contribute:
1. Fork the repository
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request
5. Create a new Pull Request
## Releasing
## 📄 License
As of version 1.14.0, Afero moved implementations with third-party libraries to
their own submodules.
Afero is released under the Apache 2.0 license. See [LICENSE.txt](https://github.com/spf13/afero/blob/master/LICENSE.txt) for details.
Releasing a new version now requires a few steps:
## 🔗 Additional Resources
```
VERSION=X.Y.Z
git tag -a v$VERSION -m "Release $VERSION"
git push origin v$VERSION
- [📖 Full API Documentation](https://pkg.go.dev/github.com/spf13/afero)
- [🎯 Examples Repository](https://github.com/spf13/afero/tree/master/examples)
- [📋 Release Notes](https://github.com/spf13/afero/releases)
- [❓ GitHub Discussions](https://github.com/spf13/afero/discussions)
cd gcsfs
go get github.com/spf13/afero@v$VERSION
go mod tidy
git commit -am "Update afero to v$VERSION"
git tag -a gcsfs/v$VERSION -m "Release gcsfs $VERSION"
git push origin gcsfs/v$VERSION
cd ..
---
cd sftpfs
go get github.com/spf13/afero@v$VERSION
go mod tidy
git commit -am "Update afero to v$VERSION"
git tag -a sftpfs/v$VERSION -m "Release sftpfs $VERSION"
git push origin sftpfs/v$VERSION
cd ..
git push
```
TODO: move these instructions to a Makefile or something
## Contributors
Names in no particular order:
* [spf13](https://github.com/spf13)
* [jaqx0r](https://github.com/jaqx0r)
* [mbertschler](https://github.com/mbertschler)
* [xor-gate](https://github.com/xor-gate)
## License
Afero is released under the Apache 2.0 license. See
[LICENSE.txt](https://github.com/spf13/afero/blob/master/LICENSE.txt)
*Afero comes from the Latin roots Ad-Facere, meaning "to make" or "to do" - fitting for a library that empowers you to make and do amazing things with filesystems.*

View File

@@ -34,7 +34,8 @@ func (u *CopyOnWriteFs) isBaseFile(name string) (bool, error) {
_, err := u.base.Stat(name)
if err != nil {
if oerr, ok := err.(*os.PathError); ok {
if oerr.Err == os.ErrNotExist || oerr.Err == syscall.ENOENT || oerr.Err == syscall.ENOTDIR {
if oerr.Err == os.ErrNotExist || oerr.Err == syscall.ENOENT ||
oerr.Err == syscall.ENOTDIR {
return false, nil
}
}
@@ -237,7 +238,11 @@ func (u *CopyOnWriteFs) OpenFile(name string, flag int, perm os.FileMode) (File,
return u.layer.OpenFile(name, flag, perm)
}
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOTDIR} // ...or os.ErrNotExist?
return nil, &os.PathError{
Op: "open",
Path: name,
Err: syscall.ENOTDIR,
} // ...or os.ErrNotExist?
}
if b {
return u.base.OpenFile(name, flag, perm)

View File

@@ -137,7 +137,7 @@ type readDirFile struct {
var _ fs.ReadDirFile = readDirFile{}
func (r readDirFile) ReadDir(n int) ([]fs.DirEntry, error) {
items, err := r.File.Readdir(n)
items, err := r.Readdir(n)
if err != nil {
return nil, err
}
@@ -161,7 +161,12 @@ var _ Fs = FromIOFS{}
func (f FromIOFS) Create(name string) (File, error) { return nil, notImplemented("create", name) }
func (f FromIOFS) Mkdir(name string, perm os.FileMode) error { return notImplemented("mkdir", name) }
func (f FromIOFS) Mkdir(
name string,
perm os.FileMode,
) error {
return notImplemented("mkdir", name)
}
func (f FromIOFS) MkdirAll(path string, perm os.FileMode) error {
return notImplemented("mkdirall", path)

View File

@@ -19,9 +19,9 @@ import (
// Lstater is an optional interface in Afero. It is only implemented by the
// filesystems saying so.
// It will call Lstat if the filesystem iself is, or it delegates to, the os filesystem.
// It will call Lstat if the filesystem itself is, or it delegates to, the os filesystem.
// Else it will call Stat.
// In addtion to the FileInfo, it will return a boolean telling whether Lstat was called or not.
// In addition to the FileInfo, it will return a boolean telling whether Lstat was called or not.
type Lstater interface {
LstatIfPossible(name string) (os.FileInfo, bool, error)
}

View File

@@ -150,7 +150,11 @@ func (f *File) Sync() error {
func (f *File) Readdir(count int) (res []os.FileInfo, err error) {
if !f.fileData.dir {
return nil, &os.PathError{Op: "readdir", Path: f.fileData.name, Err: errors.New("not a dir")}
return nil, &os.PathError{
Op: "readdir",
Path: f.fileData.name,
Err: errors.New("not a dir"),
}
}
var outLength int64
@@ -236,7 +240,11 @@ func (f *File) Truncate(size int64) error {
return ErrFileClosed
}
if f.readOnly {
return &os.PathError{Op: "truncate", Path: f.fileData.name, Err: errors.New("file handle is read only")}
return &os.PathError{
Op: "truncate",
Path: f.fileData.name,
Err: errors.New("file handle is read only"),
}
}
if size < 0 {
return ErrOutOfRange
@@ -273,7 +281,11 @@ func (f *File) Write(b []byte) (n int, err error) {
return 0, ErrFileClosed
}
if f.readOnly {
return 0, &os.PathError{Op: "write", Path: f.fileData.name, Err: errors.New("file handle is read only")}
return 0, &os.PathError{
Op: "write",
Path: f.fileData.name,
Err: errors.New("file handle is read only"),
}
}
n = len(b)
cur := atomic.LoadInt64(&f.at)
@@ -285,7 +297,9 @@ func (f *File) Write(b []byte) (n int, err error) {
tail = f.fileData.data[n+int(cur):]
}
if diff > 0 {
f.fileData.data = append(f.fileData.data, append(bytes.Repeat([]byte{0o0}, int(diff)), b...)...)
f.fileData.data = append(
f.fileData.data,
append(bytes.Repeat([]byte{0o0}, int(diff)), b...)...)
f.fileData.data = append(f.fileData.data, tail...)
} else {
f.fileData.data = append(f.fileData.data[:cur], b...)

View File

@@ -92,7 +92,8 @@ func (f *UnionFile) Seek(o int64, w int) (pos int64, err error) {
func (f *UnionFile) Write(s []byte) (n int, err error) {
if f.Layer != nil {
n, err = f.Layer.Write(s)
if err == nil && f.Base != nil { // hmm, do we have fixed size files where a write may hit the EOF mark?
if err == nil &&
f.Base != nil { // hmm, do we have fixed size files where a write may hit the EOF mark?
_, err = f.Base.Write(s)
}
return n, err
@@ -157,7 +158,7 @@ var defaultUnionMergeDirsFn = func(lofi, bofi []os.FileInfo) ([]os.FileInfo, err
// return a single view of the overlayed directories.
// At the end of the directory view, the error is io.EOF if c > 0.
func (f *UnionFile) Readdir(c int) (ofi []os.FileInfo, err error) {
var merge DirsMerger = f.Merger
merge := f.Merger
if merge == nil {
merge = defaultUnionMergeDirsFn
}

View File

@@ -113,11 +113,11 @@ func GetTempDir(fs Fs, subPath string) string {
if subPath != "" {
// preserve windows backslash :-(
if FilePathSeparator == "\\" {
subPath = strings.Replace(subPath, "\\", "____", -1)
subPath = strings.ReplaceAll(subPath, "\\", "____")
}
dir = dir + UnicodeSanitize((subPath))
if FilePathSeparator == "\\" {
dir = strings.Replace(dir, "____", "\\", -1)
dir = strings.ReplaceAll(dir, "____", "\\")
}
if exists, _ := Exists(fs, dir); exists {

View File

@@ -12,14 +12,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
version: "2"
run:
deadline: 5m
timeout: 5m
formatters:
enable:
- gofmt
- goimports
linters:
disable-all: true
default: none
enable:
#- bodyclose
# - deadcode ! deprecated since v1.49.0; replaced by 'unused'
#- depguard
#- dogsled
#- dupl
@@ -30,28 +36,24 @@ linters:
- goconst
- gocritic
#- gocyclo
- gofmt
- goimports
#- gomnd
#- goprintffuncname
- gosec
- gosimple
- govet
- ineffassign
#- lll
- misspell
#- mnd
#- nakedret
#- noctx
- nolintlint
#- rowserrcheck
#- scopelint
- staticcheck
#- structcheck ! deprecated since v1.49.0; replaced by 'unused'
- stylecheck
#- typecheck
- unconvert
#- unparam
- unused
# - varcheck ! deprecated since v1.49.0; replaced by 'unused'
#- whitespace
fast: false
exclusions:
presets:
- common-false-positives
- legacy
- std-error-handling

View File

@@ -1,8 +1,14 @@
![cobra logo](https://github.com/user-attachments/assets/cbc3adf8-0dff-46e9-a88d-5e2d971c169e)
<div align="center">
<a href="https://cobra.dev">
<img width="512" height="535" alt="cobra-logo" src="https://github.com/user-attachments/assets/c8bf9aad-b5ae-41d3-8899-d83baec10af8" />
</a>
</div>
Cobra is a library for creating powerful modern CLI applications.
<a href="https://cobra.dev">Visit Cobra.dev for extensive documentation</a>
Cobra is used in many Go projects such as [Kubernetes](https://kubernetes.io/),
[Hugo](https://gohugo.io), and [GitHub CLI](https://github.com/cli/cli) to
name a few. [This list](site/content/projects_using_cobra.md) contains a more extensive list of projects using Cobra.
@@ -11,6 +17,20 @@ name a few. [This list](site/content/projects_using_cobra.md) contains a more ex
[![Go Reference](https://pkg.go.dev/badge/github.com/spf13/cobra.svg)](https://pkg.go.dev/github.com/spf13/cobra)
[![Go Report Card](https://goreportcard.com/badge/github.com/spf13/cobra)](https://goreportcard.com/report/github.com/spf13/cobra)
[![Slack](https://img.shields.io/badge/Slack-cobra-brightgreen)](https://gophers.slack.com/archives/CD3LP1199)
<hr>
<div align="center" markdown="1">
<sup>Supported by:</sup>
<br>
<br>
<a href="https://www.warp.dev/cobra">
<img alt="Warp sponsorship" width="400" src="https://github.com/user-attachments/assets/ab8dd143-b0fd-4904-bdc5-dd7ecac94eae">
</a>
### [Warp, the AI terminal for devs](https://www.warp.dev/cobra)
[Try Cobra in Warp today](https://www.warp.dev/cobra)<br>
</div>
<hr>
# Overview

Some files were not shown because too many files have changed in this diff Show More