mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +03:00
Merge pull request #34 from ardaguclu/sync-downstream
NO-JIRA: Sync downstream with the latest changes in upstream
This commit is contained in:
@@ -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
|
||||
|
||||
4
Makefile
4
Makefile
@@ -111,3 +111,7 @@ golangci-lint: ## Download and install golangci-lint if not already installed
|
||||
.PHONY: lint
|
||||
lint: golangci-lint ## Lint the code
|
||||
$(GOLANGCI_LINT) run --verbose --print-resources-usage
|
||||
|
||||
.PHONY: update-readme-tools
|
||||
update-readme-tools: ## Update the README.md file with the latest toolsets
|
||||
go run ./internal/tools/update-readme/main.go README.md
|
||||
|
||||
162
README.md
162
README.md
@@ -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
48
go.mod
@@ -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
108
go.sum
@@ -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=
|
||||
|
||||
22
internal/test/kubernetes.go
Normal file
22
internal/test/kubernetes.go
Normal 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
52
internal/test/mcp.go
Normal 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)
|
||||
}
|
||||
@@ -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
21
internal/test/test.go
Normal 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)
|
||||
}
|
||||
99
internal/tools/update-readme/main.go
Normal file
99
internal/tools/update-readme/main.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
|
||||
|
||||
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
|
||||
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
|
||||
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
|
||||
)
|
||||
|
||||
type OpenShift struct{}
|
||||
|
||||
func (o *OpenShift) IsOpenShift(ctx context.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
var _ internalk8s.Openshift = (*OpenShift)(nil)
|
||||
|
||||
func main() {
|
||||
readme, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Available Toolsets
|
||||
toolsetsList := toolsets.Toolsets()
|
||||
maxNameLen, maxDescLen := len("Toolset"), len("Description")
|
||||
for _, toolset := range toolsetsList {
|
||||
nameLen := len(toolset.GetName())
|
||||
descLen := len(toolset.GetDescription())
|
||||
if nameLen > maxNameLen {
|
||||
maxNameLen = nameLen
|
||||
}
|
||||
if descLen > maxDescLen {
|
||||
maxDescLen = descLen
|
||||
}
|
||||
}
|
||||
availableToolsets := strings.Builder{}
|
||||
availableToolsets.WriteString(fmt.Sprintf("| %-*s | %-*s |\n", maxNameLen, "Toolset", maxDescLen, "Description"))
|
||||
availableToolsets.WriteString(fmt.Sprintf("|-%s-|-%s-|\n", strings.Repeat("-", maxNameLen), strings.Repeat("-", maxDescLen)))
|
||||
for _, toolset := range toolsetsList {
|
||||
availableToolsets.WriteString(fmt.Sprintf("| %-*s | %-*s |\n", maxNameLen, toolset.GetName(), maxDescLen, toolset.GetDescription()))
|
||||
}
|
||||
updated := replaceBetweenMarkers(
|
||||
string(readme),
|
||||
"<!-- AVAILABLE-TOOLSETS-START -->",
|
||||
"<!-- AVAILABLE-TOOLSETS-END -->",
|
||||
availableToolsets.String(),
|
||||
)
|
||||
|
||||
// Available Toolset Tools
|
||||
toolsetTools := strings.Builder{}
|
||||
for _, toolset := range toolsetsList {
|
||||
toolsetTools.WriteString("<details>\n\n<summary>" + toolset.GetName() + "</summary>\n\n")
|
||||
tools := toolset.GetTools(&OpenShift{})
|
||||
for _, tool := range tools {
|
||||
toolsetTools.WriteString(fmt.Sprintf("- **%s** - %s\n", tool.Tool.Name, tool.Tool.Description))
|
||||
for _, propName := range slices.Sorted(maps.Keys(tool.Tool.InputSchema.Properties)) {
|
||||
property := tool.Tool.InputSchema.Properties[propName]
|
||||
toolsetTools.WriteString(fmt.Sprintf(" - `%s` (`%s`)", propName, property.Type))
|
||||
if slices.Contains(tool.Tool.InputSchema.Required, propName) {
|
||||
toolsetTools.WriteString(" **(required)**")
|
||||
}
|
||||
toolsetTools.WriteString(fmt.Sprintf(" - %s\n", property.Description))
|
||||
}
|
||||
toolsetTools.WriteString("\n")
|
||||
}
|
||||
toolsetTools.WriteString("</details>\n\n")
|
||||
}
|
||||
updated = replaceBetweenMarkers(
|
||||
updated,
|
||||
"<!-- AVAILABLE-TOOLSETS-TOOLS-START -->",
|
||||
"<!-- AVAILABLE-TOOLSETS-TOOLS-END -->",
|
||||
toolsetTools.String(),
|
||||
)
|
||||
|
||||
if err := os.WriteFile(os.Args[1], []byte(updated), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceBetweenMarkers(content, startMarker, endMarker, replacement string) string {
|
||||
startIdx := strings.Index(content, startMarker)
|
||||
if startIdx == -1 {
|
||||
return content
|
||||
}
|
||||
endIdx := strings.Index(content, endMarker)
|
||||
if endIdx == -1 || endIdx <= startIdx {
|
||||
return content
|
||||
}
|
||||
return content[:startIdx+len(startMarker)] + "\n\n" + replacement + "\n" + content[endIdx:]
|
||||
}
|
||||
99
pkg/api/toolsets.go
Normal file
99
pkg/api/toolsets.go
Normal 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
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type StaticConfig struct {
|
||||
ReadOnly bool `toml:"read_only,omitempty"`
|
||||
// When true, disable tools annotated with destructiveHint=true
|
||||
DisableDestructive bool `toml:"disable_destructive,omitempty"`
|
||||
Toolsets []string `toml:"toolsets,omitempty"`
|
||||
EnabledTools []string `toml:"enabled_tools,omitempty"`
|
||||
DisabledTools []string `toml:"disabled_tools,omitempty"`
|
||||
|
||||
@@ -50,22 +51,32 @@ type StaticConfig struct {
|
||||
ServerURL string `toml:"server_url,omitempty"`
|
||||
}
|
||||
|
||||
func Default() *StaticConfig {
|
||||
return &StaticConfig{
|
||||
ListOutput: "table",
|
||||
Toolsets: []string{"core", "config", "helm"},
|
||||
}
|
||||
}
|
||||
|
||||
type GroupVersionKind struct {
|
||||
Group string `toml:"group"`
|
||||
Version string `toml:"version"`
|
||||
Kind string `toml:"kind,omitempty"`
|
||||
}
|
||||
|
||||
// ReadConfig reads the toml file and returns the StaticConfig.
|
||||
func ReadConfig(configPath string) (*StaticConfig, error) {
|
||||
// Read reads the toml file and returns the StaticConfig.
|
||||
func Read(configPath string) (*StaticConfig, error) {
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ReadToml(configData)
|
||||
}
|
||||
|
||||
var config *StaticConfig
|
||||
err = toml.Unmarshal(configData, &config)
|
||||
if err != nil {
|
||||
// ReadToml reads the toml data and returns the StaticConfig.
|
||||
func ReadToml(configData []byte) (*StaticConfig, error) {
|
||||
config := Default()
|
||||
if err := toml.Unmarshal(configData, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config, nil
|
||||
|
||||
@@ -1,156 +1,175 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
func TestReadConfigMissingFile(t *testing.T) {
|
||||
config, err := ReadConfig("non-existent-config.toml")
|
||||
t.Run("returns error for missing file", func(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing file, got nil")
|
||||
}
|
||||
if config != nil {
|
||||
t.Fatalf("Expected nil config for missing file, got %v", config)
|
||||
}
|
||||
type ConfigSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (s *ConfigSuite) TestReadConfigMissingFile() {
|
||||
config, err := Read("non-existent-config.toml")
|
||||
s.Run("returns error for missing file", func() {
|
||||
s.Require().NotNil(err, "Expected error for missing file, got nil")
|
||||
s.True(errors.Is(err, fs.ErrNotExist), "Expected ErrNotExist, got %v", err)
|
||||
})
|
||||
s.Run("returns nil config for missing file", func() {
|
||||
s.Nil(config, "Expected nil config for missing file")
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadConfigInvalid(t *testing.T) {
|
||||
invalidConfigPath := writeConfig(t, `
|
||||
[[denied_resources]]
|
||||
group = "apps"
|
||||
version = "v1"
|
||||
kind = "Deployment"
|
||||
[[denied_resources]]
|
||||
group = "rbac.authorization.k8s.io"
|
||||
version = "v1"
|
||||
kind = "Role
|
||||
`)
|
||||
func (s *ConfigSuite) TestReadConfigInvalid() {
|
||||
invalidConfigPath := s.writeConfig(`
|
||||
[[denied_resources]]
|
||||
group = "apps"
|
||||
version = "v1"
|
||||
kind = "Deployment"
|
||||
[[denied_resources]]
|
||||
group = "rbac.authorization.k8s.io"
|
||||
version = "v1"
|
||||
kind = "Role
|
||||
`)
|
||||
|
||||
config, err := ReadConfig(invalidConfigPath)
|
||||
t.Run("returns error for invalid file", func(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid file, got nil")
|
||||
}
|
||||
if config != nil {
|
||||
t.Fatalf("Expected nil config for invalid file, got %v", config)
|
||||
}
|
||||
config, err := Read(invalidConfigPath)
|
||||
s.Run("returns error for invalid file", func() {
|
||||
s.Require().NotNil(err, "Expected error for invalid file, got nil")
|
||||
})
|
||||
t.Run("error message contains toml error with line number", func(t *testing.T) {
|
||||
s.Run("error message contains toml error with line number", func() {
|
||||
expectedError := "toml: line 9"
|
||||
if err != nil && !strings.HasPrefix(err.Error(), expectedError) {
|
||||
t.Fatalf("Expected error message '%s' to contain line number, got %v", expectedError, err)
|
||||
s.Truef(strings.HasPrefix(err.Error(), expectedError), "Expected error message to contain line number, got %v", err)
|
||||
})
|
||||
s.Run("returns nil config for invalid file", func() {
|
||||
s.Nil(config, "Expected nil config for missing file")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ConfigSuite) TestReadConfigValid() {
|
||||
validConfigPath := s.writeConfig(`
|
||||
log_level = 1
|
||||
port = "9999"
|
||||
sse_base_url = "https://example.com"
|
||||
kubeconfig = "./path/to/config"
|
||||
list_output = "yaml"
|
||||
read_only = true
|
||||
disable_destructive = true
|
||||
|
||||
toolsets = ["core", "config", "helm", "metrics"]
|
||||
|
||||
enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
|
||||
disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
|
||||
|
||||
denied_resources = [
|
||||
{group = "apps", version = "v1", kind = "Deployment"},
|
||||
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
|
||||
]
|
||||
|
||||
`)
|
||||
|
||||
config, err := Read(validConfigPath)
|
||||
s.Require().NotNil(config)
|
||||
s.Run("reads and unmarshalls file", func() {
|
||||
s.Nil(err, "Expected nil error for valid file")
|
||||
s.Require().NotNil(config, "Expected non-nil config for valid file")
|
||||
})
|
||||
s.Run("log_level parsed correctly", func() {
|
||||
s.Equalf(1, config.LogLevel, "Expected LogLevel to be 1, got %d", config.LogLevel)
|
||||
})
|
||||
s.Run("port parsed correctly", func() {
|
||||
s.Equalf("9999", config.Port, "Expected Port to be 9999, got %s", config.Port)
|
||||
})
|
||||
s.Run("sse_base_url parsed correctly", func() {
|
||||
s.Equalf("https://example.com", config.SSEBaseURL, "Expected SSEBaseURL to be https://example.com, got %s", config.SSEBaseURL)
|
||||
})
|
||||
s.Run("kubeconfig parsed correctly", func() {
|
||||
s.Equalf("./path/to/config", config.KubeConfig, "Expected KubeConfig to be ./path/to/config, got %s", config.KubeConfig)
|
||||
})
|
||||
s.Run("list_output parsed correctly", func() {
|
||||
s.Equalf("yaml", config.ListOutput, "Expected ListOutput to be yaml, got %s", config.ListOutput)
|
||||
})
|
||||
s.Run("read_only parsed correctly", func() {
|
||||
s.Truef(config.ReadOnly, "Expected ReadOnly to be true, got %v", config.ReadOnly)
|
||||
})
|
||||
s.Run("disable_destructive parsed correctly", func() {
|
||||
s.Truef(config.DisableDestructive, "Expected DisableDestructive to be true, got %v", config.DisableDestructive)
|
||||
})
|
||||
s.Run("toolsets", func() {
|
||||
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
|
||||
for _, toolset := range []string{"core", "config", "helm", "metrics"} {
|
||||
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
|
||||
}
|
||||
})
|
||||
s.Run("enabled_tools", func() {
|
||||
s.Require().Lenf(config.EnabledTools, 8, "Expected 8 enabled tools, got %d", len(config.EnabledTools))
|
||||
for _, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
|
||||
s.Containsf(config.EnabledTools, tool, "Expected enabled tools to contain %s", tool)
|
||||
}
|
||||
})
|
||||
s.Run("disabled_tools", func() {
|
||||
s.Require().Lenf(config.DisabledTools, 5, "Expected 5 disabled tools, got %d", len(config.DisabledTools))
|
||||
for _, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
|
||||
s.Containsf(config.DisabledTools, tool, "Expected disabled tools to contain %s", tool)
|
||||
}
|
||||
})
|
||||
s.Run("denied_resources", func() {
|
||||
s.Require().Lenf(config.DeniedResources, 2, "Expected 2 denied resources, got %d", len(config.DeniedResources))
|
||||
s.Run("contains apps/v1/Deployment", func() {
|
||||
s.Contains(config.DeniedResources, GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
|
||||
"Expected denied resources to contain apps/v1/Deployment")
|
||||
})
|
||||
s.Run("contains rbac.authorization.k8s.io/v1/Role", func() {
|
||||
s.Contains(config.DeniedResources, GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "Role"},
|
||||
"Expected denied resources to contain rbac.authorization.k8s.io/v1/Role")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
|
||||
validConfigPath := s.writeConfig(`
|
||||
port = "1337"
|
||||
`)
|
||||
|
||||
config, err := Read(validConfigPath)
|
||||
s.Require().NotNil(config)
|
||||
s.Run("reads and unmarshalls file", func() {
|
||||
s.Nil(err, "Expected nil error for valid file")
|
||||
s.Require().NotNil(config, "Expected non-nil config for valid file")
|
||||
})
|
||||
s.Run("log_level defaulted correctly", func() {
|
||||
s.Equalf(0, config.LogLevel, "Expected LogLevel to be 0, got %d", config.LogLevel)
|
||||
})
|
||||
s.Run("port parsed correctly", func() {
|
||||
s.Equalf("1337", config.Port, "Expected Port to be 9999, got %s", config.Port)
|
||||
})
|
||||
s.Run("list_output defaulted correctly", func() {
|
||||
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
|
||||
})
|
||||
s.Run("toolsets defaulted correctly", func() {
|
||||
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
|
||||
for _, toolset := range []string{"core", "config", "helm"} {
|
||||
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadConfigValid(t *testing.T) {
|
||||
validConfigPath := writeConfig(t, `
|
||||
log_level = 1
|
||||
port = "9999"
|
||||
sse_base_url = "https://example.com"
|
||||
kubeconfig = "./path/to/config"
|
||||
list_output = "yaml"
|
||||
read_only = true
|
||||
disable_destructive = true
|
||||
|
||||
denied_resources = [
|
||||
{group = "apps", version = "v1", kind = "Deployment"},
|
||||
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
|
||||
]
|
||||
|
||||
enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
|
||||
disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
|
||||
`)
|
||||
|
||||
config, err := ReadConfig(validConfigPath)
|
||||
t.Run("reads and unmarshalls file", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ReadConfig returned an error for a valid file: %v", err)
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("ReadConfig returned a nil config for a valid file")
|
||||
}
|
||||
})
|
||||
t.Run("denied resources are parsed correctly", func(t *testing.T) {
|
||||
if len(config.DeniedResources) != 2 {
|
||||
t.Fatalf("Expected 2 denied resources, got %d", len(config.DeniedResources))
|
||||
}
|
||||
if config.DeniedResources[0].Group != "apps" ||
|
||||
config.DeniedResources[0].Version != "v1" ||
|
||||
config.DeniedResources[0].Kind != "Deployment" {
|
||||
t.Errorf("Unexpected denied resources: %v", config.DeniedResources[0])
|
||||
}
|
||||
})
|
||||
t.Run("log_level parsed correctly", func(t *testing.T) {
|
||||
if config.LogLevel != 1 {
|
||||
t.Fatalf("Unexpected log level: %v", config.LogLevel)
|
||||
}
|
||||
})
|
||||
t.Run("port parsed correctly", func(t *testing.T) {
|
||||
if config.Port != "9999" {
|
||||
t.Fatalf("Unexpected port value: %v", config.Port)
|
||||
}
|
||||
})
|
||||
t.Run("sse_base_url parsed correctly", func(t *testing.T) {
|
||||
if config.SSEBaseURL != "https://example.com" {
|
||||
t.Fatalf("Unexpected sse_base_url value: %v", config.SSEBaseURL)
|
||||
}
|
||||
})
|
||||
t.Run("kubeconfig parsed correctly", func(t *testing.T) {
|
||||
if config.KubeConfig != "./path/to/config" {
|
||||
t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig)
|
||||
}
|
||||
})
|
||||
t.Run("list_output parsed correctly", func(t *testing.T) {
|
||||
if config.ListOutput != "yaml" {
|
||||
t.Fatalf("Unexpected list_output value: %v", config.ListOutput)
|
||||
}
|
||||
})
|
||||
t.Run("read_only parsed correctly", func(t *testing.T) {
|
||||
if !config.ReadOnly {
|
||||
t.Fatalf("Unexpected read-only mode: %v", config.ReadOnly)
|
||||
}
|
||||
})
|
||||
t.Run("disable_destructive parsed correctly", func(t *testing.T) {
|
||||
if !config.DisableDestructive {
|
||||
t.Fatalf("Unexpected disable destructive: %v", config.DisableDestructive)
|
||||
}
|
||||
})
|
||||
t.Run("enabled_tools parsed correctly", func(t *testing.T) {
|
||||
if len(config.EnabledTools) != 8 {
|
||||
t.Fatalf("Unexpected enabled tools: %v", config.EnabledTools)
|
||||
|
||||
}
|
||||
for i, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
|
||||
if config.EnabledTools[i] != tool {
|
||||
t.Errorf("Expected enabled tool %d to be %s, got %s", i, tool, config.EnabledTools[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("disabled_tools parsed correctly", func(t *testing.T) {
|
||||
if len(config.DisabledTools) != 5 {
|
||||
t.Fatalf("Unexpected disabled tools: %v", config.DisabledTools)
|
||||
}
|
||||
for i, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
|
||||
if config.DisabledTools[i] != tool {
|
||||
t.Errorf("Expected disabled tool %d to be %s, got %s", i, tool, config.DisabledTools[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
func (s *ConfigSuite) writeConfig(content string) string {
|
||||
s.T().Helper()
|
||||
tempDir := s.T().TempDir()
|
||||
path := filepath.Join(tempDir, "config.toml")
|
||||
err := os.WriteFile(path, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write config file %s: %v", path, err)
|
||||
s.T().Fatalf("Failed to write config file %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
suite.Run(t, new(ConfigSuite))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,11 +53,12 @@ type Manager struct {
|
||||
CloseWatchKubeConfig CloseWatchKubeConfig
|
||||
}
|
||||
|
||||
var _ helm.Kubernetes = (*Manager)(nil)
|
||||
var _ Openshift = (*Manager)(nil)
|
||||
|
||||
var Scheme = scheme.Scheme
|
||||
var ParameterCodec = runtime.NewParameterCodec(Scheme)
|
||||
|
||||
var _ helm.Kubernetes = &Manager{}
|
||||
|
||||
func NewManager(config *config.StaticConfig) (*Manager, error) {
|
||||
k8s := &Manager{
|
||||
staticConfig: config,
|
||||
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
type Openshift interface {
|
||||
IsOpenShift(context.Context) bool
|
||||
}
|
||||
|
||||
func (m *Manager) IsOpenShift(_ context.Context) bool {
|
||||
// This method should be fast and not block (it's called at startup)
|
||||
_, err := m.discoveryClient.ServerResourcesForGroupVersion(schema.GroupVersion{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
118
pkg/mcp/helm.go
118
pkg/mcp/helm.go
@@ -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
|
||||
}
|
||||
@@ -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
60
pkg/mcp/m3labs.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
5
pkg/mcp/modules.go
Normal 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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}{})
|
||||
|
||||
330
pkg/mcp/pods.go
330
pkg/mcp/pods.go
@@ -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
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/internal/test"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/config"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/internal/test"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/config"
|
||||
)
|
||||
|
||||
func TestPodsExec(t *testing.T) {
|
||||
@@ -104,7 +105,9 @@ func TestPodsExec(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPodsExecDenied(t *testing.T) {
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
|
||||
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
|
||||
denied_resources = [ { version = "v1", kind = "Pod" } ]
|
||||
`)))
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
podsRun, _ := c.callTool("pods_exec", map[string]interface{}{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/internal/test"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/config"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/output"
|
||||
|
||||
@@ -179,7 +180,9 @@ func TestPodsListInNamespace(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPodsListDenied(t *testing.T) {
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
|
||||
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
|
||||
denied_resources = [ { version = "v1", kind = "Pod" } ]
|
||||
`)))
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
podsList, _ := c.callTool("pods_list", map[string]interface{}{})
|
||||
@@ -414,7 +417,9 @@ func TestPodsGet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPodsGetDenied(t *testing.T) {
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
|
||||
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
|
||||
denied_resources = [ { version = "v1", kind = "Pod" } ]
|
||||
`)))
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
podsGet, _ := c.callTool("pods_get", map[string]interface{}{"name": "a-pod-in-default"})
|
||||
@@ -564,7 +569,9 @@ func TestPodsDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPodsDeleteDenied(t *testing.T) {
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
|
||||
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
|
||||
denied_resources = [ { version = "v1", kind = "Pod" } ]
|
||||
`)))
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
podsDelete, _ := c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-in-default"})
|
||||
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
22
pkg/mcp/testdata/toolsets-config-tools.json
vendored
Normal file
22
pkg/mcp/testdata/toolsets-config-tools.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
422
pkg/mcp/testdata/toolsets-core-tools.json
vendored
Normal file
422
pkg/mcp/testdata/toolsets-core-tools.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
542
pkg/mcp/testdata/toolsets-full-tools-openshift.json
vendored
Normal file
542
pkg/mcp/testdata/toolsets-full-tools-openshift.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
528
pkg/mcp/testdata/toolsets-full-tools.json
vendored
Normal file
528
pkg/mcp/testdata/toolsets-full-tools.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
88
pkg/mcp/testdata/toolsets-helm-tools.json
vendored
Normal file
88
pkg/mcp/testdata/toolsets-helm-tools.json
vendored
Normal 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
159
pkg/mcp/toolsets_test.go
Normal 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))
|
||||
}
|
||||
57
pkg/toolsets/config/configuration.go
Normal file
57
pkg/toolsets/config/configuration.go
Normal 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
|
||||
}
|
||||
31
pkg/toolsets/config/toolset.go
Normal file
31
pkg/toolsets/config/toolset.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/api"
|
||||
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
|
||||
)
|
||||
|
||||
type Toolset struct{}
|
||||
|
||||
var _ api.Toolset = (*Toolset)(nil)
|
||||
|
||||
func (t *Toolset) GetName() string {
|
||||
return "config"
|
||||
}
|
||||
|
||||
func (t *Toolset) GetDescription() string {
|
||||
return "View and manage the current local Kubernetes configuration (kubeconfig)"
|
||||
}
|
||||
|
||||
func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool {
|
||||
return slices.Concat(
|
||||
initConfiguration(),
|
||||
)
|
||||
}
|
||||
|
||||
func init() {
|
||||
toolsets.Register(&Toolset{})
|
||||
}
|
||||
55
pkg/toolsets/core/events.go
Normal file
55
pkg/toolsets/core/events.go
Normal 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
|
||||
}
|
||||
68
pkg/toolsets/core/namespaces.go
Normal file
68
pkg/toolsets/core/namespaces.go
Normal 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
457
pkg/toolsets/core/pods.go
Normal 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
|
||||
}
|
||||
287
pkg/toolsets/core/resources.go
Normal file
287
pkg/toolsets/core/resources.go
Normal 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
|
||||
}
|
||||
34
pkg/toolsets/core/toolset.go
Normal file
34
pkg/toolsets/core/toolset.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/api"
|
||||
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
|
||||
)
|
||||
|
||||
type Toolset struct{}
|
||||
|
||||
var _ api.Toolset = (*Toolset)(nil)
|
||||
|
||||
func (t *Toolset) GetName() string {
|
||||
return "core"
|
||||
}
|
||||
|
||||
func (t *Toolset) GetDescription() string {
|
||||
return "Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.)"
|
||||
}
|
||||
|
||||
func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool {
|
||||
return slices.Concat(
|
||||
initEvents(),
|
||||
initNamespaces(o),
|
||||
initPods(),
|
||||
initResources(o),
|
||||
)
|
||||
}
|
||||
|
||||
func init() {
|
||||
toolsets.Register(&Toolset{})
|
||||
}
|
||||
156
pkg/toolsets/helm/helm.go
Normal file
156
pkg/toolsets/helm/helm.go
Normal 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
|
||||
}
|
||||
31
pkg/toolsets/helm/toolset.go
Normal file
31
pkg/toolsets/helm/toolset.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/api"
|
||||
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
|
||||
)
|
||||
|
||||
type Toolset struct{}
|
||||
|
||||
var _ api.Toolset = (*Toolset)(nil)
|
||||
|
||||
func (t *Toolset) GetName() string {
|
||||
return "helm"
|
||||
}
|
||||
|
||||
func (t *Toolset) GetDescription() string {
|
||||
return "Tools for managing Helm charts and releases"
|
||||
}
|
||||
|
||||
func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool {
|
||||
return slices.Concat(
|
||||
initHelm(),
|
||||
)
|
||||
}
|
||||
|
||||
func init() {
|
||||
toolsets.Register(&Toolset{})
|
||||
}
|
||||
51
pkg/toolsets/toolsets.go
Normal file
51
pkg/toolsets/toolsets.go
Normal 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
|
||||
}
|
||||
97
pkg/toolsets/toolsets_test.go
Normal file
97
pkg/toolsets/toolsets_test.go
Normal 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))
|
||||
}
|
||||
28
vendor/github.com/Masterminds/semver/v3/CHANGELOG.md
generated
vendored
28
vendor/github.com/Masterminds/semver/v3/CHANGELOG.md
generated
vendored
@@ -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)
|
||||
|
||||
18
vendor/github.com/Masterminds/semver/v3/README.md
generated
vendored
18
vendor/github.com/Masterminds/semver/v3/README.md
generated
vendored
@@ -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
|
||||
|
||||
|
||||
127
vendor/github.com/Masterminds/semver/v3/constraints.go
generated
vendored
127
vendor/github.com/Masterminds/semver/v3/constraints.go
generated
vendored
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
173
vendor/github.com/Masterminds/semver/v3/version.go
generated
vendored
173
vendor/github.com/Masterminds/semver/v3/version.go
generated
vendored
@@ -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
21
vendor/github.com/google/jsonschema-go/LICENSE
generated
vendored
Normal 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.
|
||||
76
vendor/github.com/google/jsonschema-go/jsonschema/annotations.go
generated
vendored
Normal file
76
vendor/github.com/google/jsonschema-go/jsonschema/annotations.go
generated
vendored
Normal 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
|
||||
}
|
||||
101
vendor/github.com/google/jsonschema-go/jsonschema/doc.go
generated
vendored
Normal file
101
vendor/github.com/google/jsonschema-go/jsonschema/doc.go
generated
vendored
Normal 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
|
||||
248
vendor/github.com/google/jsonschema-go/jsonschema/infer.go
generated
vendored
Normal file
248
vendor/github.com/google/jsonschema-go/jsonschema/infer.go
generated
vendored
Normal 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]*=")
|
||||
146
vendor/github.com/google/jsonschema-go/jsonschema/json_pointer.go
generated
vendored
Normal file
146
vendor/github.com/google/jsonschema-go/jsonschema/json_pointer.go
generated
vendored
Normal 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{}
|
||||
}
|
||||
548
vendor/github.com/google/jsonschema-go/jsonschema/resolve.go
generated
vendored
Normal file
548
vendor/github.com/google/jsonschema-go/jsonschema/resolve.go
generated
vendored
Normal 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
|
||||
}
|
||||
436
vendor/github.com/google/jsonschema-go/jsonschema/schema.go
generated
vendored
Normal file
436
vendor/github.com/google/jsonschema-go/jsonschema/schema.go
generated
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
463
vendor/github.com/google/jsonschema-go/jsonschema/util.go
generated
vendored
Normal file
463
vendor/github.com/google/jsonschema-go/jsonschema/util.go
generated
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
789
vendor/github.com/google/jsonschema-go/jsonschema/validate.go
generated
vendored
Normal file
789
vendor/github.com/google/jsonschema-go/jsonschema/validate.go
generated
vendored
Normal 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
|
||||
}
|
||||
90
vendor/github.com/mark3labs/mcp-go/client/client.go
generated
vendored
90
vendor/github.com/mark3labs/mcp-go/client/client.go
generated
vendored
@@ -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, ¶ms); 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,
|
||||
|
||||
19
vendor/github.com/mark3labs/mcp-go/client/elicitation.go
generated
vendored
Normal file
19
vendor/github.com/mark3labs/mcp-go/client/elicitation.go
generated
vendored
Normal 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)
|
||||
}
|
||||
3
vendor/github.com/mark3labs/mcp-go/client/stdio.go
generated
vendored
3
vendor/github.com/mark3labs/mcp-go/client/stdio.go
generated
vendored
@@ -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,
|
||||
|
||||
21
vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go
generated
vendored
21
vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go
generated
vendored
@@ -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)
|
||||
}
|
||||
|
||||
60
vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go
generated
vendored
60
vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go
generated
vendored
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
14
vendor/github.com/mark3labs/mcp-go/client/transport/sse.go
generated
vendored
14
vendor/github.com/mark3labs/mcp-go/client/transport/sse.go
generated
vendored
@@ -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)
|
||||
|
||||
25
vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go
generated
vendored
25
vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go
generated
vendored
@@ -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"`
|
||||
|
||||
14
vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go
generated
vendored
14
vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go
generated
vendored
@@ -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",
|
||||
|
||||
25
vendor/github.com/mark3labs/mcp-go/mcp/tools.go
generated
vendored
25
vendor/github.com/mark3labs/mcp-go/mcp/tools.go
generated
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
vendor/github.com/mark3labs/mcp-go/mcp/types.go
generated
vendored
52
vendor/github.com/mark3labs/mcp-go/mcp/types.go
generated
vendored
@@ -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 (
|
||||
|
||||
94
vendor/github.com/mark3labs/mcp-go/mcp/utils.go
generated
vendored
94
vendor/github.com/mark3labs/mcp-go/mcp/utils.go
generated
vendored
@@ -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 {
|
||||
|
||||
32
vendor/github.com/mark3labs/mcp-go/server/elicitation.go
generated
vendored
Normal file
32
vendor/github.com/mark3labs/mcp-go/server/elicitation.go
generated
vendored
Normal 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
|
||||
}
|
||||
36
vendor/github.com/mark3labs/mcp-go/server/inprocess_session.go
generated
vendored
36
vendor/github.com/mark3labs/mcp-go/server/inprocess_session.go
generated
vendored
@@ -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)
|
||||
)
|
||||
|
||||
2
vendor/github.com/mark3labs/mcp-go/server/sampling.go
generated
vendored
2
vendor/github.com/mark3labs/mcp-go/server/sampling.go
generated
vendored
@@ -12,7 +12,7 @@ import (
|
||||
func (s *MCPServer) EnableSampling() {
|
||||
s.capabilitiesMu.Lock()
|
||||
defer s.capabilitiesMu.Unlock()
|
||||
|
||||
|
||||
enabled := true
|
||||
s.capabilities.sampling = &enabled
|
||||
}
|
||||
|
||||
151
vendor/github.com/mark3labs/mcp-go/server/server.go
generated
vendored
151
vendor/github.com/mark3labs/mcp-go/server/server.go
generated
vendored
@@ -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 {
|
||||
|
||||
7
vendor/github.com/mark3labs/mcp-go/server/session.go
generated
vendored
7
vendor/github.com/mark3labs/mcp-go/server/session.go
generated
vendored
@@ -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
|
||||
|
||||
170
vendor/github.com/mark3labs/mcp-go/server/stdio.go
generated
vendored
170
vendor/github.com/mark3labs/mcp-go/server/stdio.go
generated
vendored
@@ -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(
|
||||
|
||||
202
vendor/github.com/mark3labs/mcp-go/server/streamable_http.go
generated
vendored
202
vendor/github.com/mark3labs/mcp-go/server/streamable_http.go
generated
vendored
@@ -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
|
||||
}
|
||||
|
||||
3
vendor/github.com/spf13/afero/.editorconfig
generated
vendored
3
vendor/github.com/spf13/afero/.editorconfig
generated
vendored
@@ -10,3 +10,6 @@ trim_trailing_whitespace = true
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[{*.yml,*.yaml}]
|
||||
indent_size = 2
|
||||
|
||||
60
vendor/github.com/spf13/afero/.golangci.yaml
generated
vendored
60
vendor/github.com/spf13/afero/.golangci.yaml
generated
vendored
@@ -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
|
||||
|
||||
763
vendor/github.com/spf13/afero/README.md
generated
vendored
763
vendor/github.com/spf13/afero/README.md
generated
vendored
@@ -1,479 +1,474 @@
|
||||

|
||||
|
||||
A FileSystem Abstraction System for Go
|
||||
|
||||
[](https://github.com/spf13/afero/actions?query=workflow%3ACI)
|
||||
[](https://gitter.im/spf13/afero?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](https://goreportcard.com/report/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
|
||||
[](https://github.com/spf13/afero/actions?query=workflow%3ACI)
|
||||
[](https://pkg.go.dev/mod/github.com/spf13/afero)
|
||||
[](https://goreportcard.com/report/github.com/spf13/afero)
|
||||

|
||||
|
||||
* 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 heart’s 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 isn’t
|
||||
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.*
|
||||
|
||||
9
vendor/github.com/spf13/afero/copyOnWriteFs.go
generated
vendored
9
vendor/github.com/spf13/afero/copyOnWriteFs.go
generated
vendored
@@ -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)
|
||||
|
||||
9
vendor/github.com/spf13/afero/iofs.go
generated
vendored
9
vendor/github.com/spf13/afero/iofs.go
generated
vendored
@@ -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)
|
||||
|
||||
4
vendor/github.com/spf13/afero/lstater.go
generated
vendored
4
vendor/github.com/spf13/afero/lstater.go
generated
vendored
@@ -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)
|
||||
}
|
||||
|
||||
22
vendor/github.com/spf13/afero/mem/file.go
generated
vendored
22
vendor/github.com/spf13/afero/mem/file.go
generated
vendored
@@ -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...)
|
||||
|
||||
5
vendor/github.com/spf13/afero/unionFile.go
generated
vendored
5
vendor/github.com/spf13/afero/unionFile.go
generated
vendored
@@ -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
|
||||
}
|
||||
|
||||
4
vendor/github.com/spf13/afero/util.go
generated
vendored
4
vendor/github.com/spf13/afero/util.go
generated
vendored
@@ -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 {
|
||||
|
||||
28
vendor/github.com/spf13/cobra/.golangci.yml
generated
vendored
28
vendor/github.com/spf13/cobra/.golangci.yml
generated
vendored
@@ -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
|
||||
|
||||
24
vendor/github.com/spf13/cobra/README.md
generated
vendored
24
vendor/github.com/spf13/cobra/README.md
generated
vendored
@@ -1,8 +1,14 @@
|
||||
|
||||

|
||||
<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
|
||||
[](https://pkg.go.dev/github.com/spf13/cobra)
|
||||
[](https://goreportcard.com/report/github.com/spf13/cobra)
|
||||
[](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
Reference in New Issue
Block a user