mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +03:00
Merge pull request #41 from matzew/sync-downstream
NO-JIRA: Sync downstream with the latest changes in upstream
This commit is contained in:
1
.snyk
1
.snyk
@@ -3,6 +3,7 @@
|
||||
# https://docs.snyk.io/snyk-cli/commands/ignore
|
||||
exclude:
|
||||
global:
|
||||
- internal/tools/update-readme/main.go
|
||||
- vendor/**
|
||||
- "**/*_test.go"
|
||||
- python/**
|
||||
|
||||
4
Makefile
4
Makefile
@@ -47,12 +47,12 @@ clean: ## Clean up all build artifacts
|
||||
rm -rf $(CLEAN_TARGETS)
|
||||
|
||||
.PHONY: build
|
||||
build: clean tidy format ## Build the project
|
||||
build: clean tidy format lint ## Build the project
|
||||
go build $(COMMON_BUILD_ARGS) -o $(BINARY_NAME) ./cmd/kubernetes-mcp-server
|
||||
|
||||
|
||||
.PHONY: build-all-platforms
|
||||
build-all-platforms: clean tidy format ## Build the project for all platforms
|
||||
build-all-platforms: clean tidy format lint ## Build the project for all platforms
|
||||
$(foreach os,$(OSES),$(foreach arch,$(ARCHS), \
|
||||
GOOS=$(os) GOARCH=$(arch) go build $(COMMON_BUILD_ARGS) -o $(BINARY_NAME)-$(os)-$(arch)$(if $(findstring windows,$(os)),.exe,) ./cmd/kubernetes-mcp-server; \
|
||||
))
|
||||
|
||||
355
README.md
355
README.md
@@ -1,31 +1,39 @@
|
||||
# OpenShift MCP Server
|
||||
# Kubernetes MCP Server
|
||||
|
||||
OpenShift MCP Server is currently under development.
|
||||
[](https://github.com/containers/kubernetes-mcp-server/blob/main/LICENSE)
|
||||
[](https://www.npmjs.com/package/kubernetes-mcp-server)
|
||||
[](https://pypi.org/project/kubernetes-mcp-server/)
|
||||
[](https://github.com/containers/kubernetes-mcp-server/releases/latest)
|
||||
[](https://github.com/containers/kubernetes-mcp-server/actions/workflows/build.yaml)
|
||||
|
||||
[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🛠️ Tools](#tools-and-functionalities) | [🧑💻 Development](#development)
|
||||
|
||||
https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e
|
||||
|
||||
## ✨ Features <a id="features"></a>
|
||||
|
||||
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.
|
||||
@@ -86,7 +94,7 @@ code-insiders --add-mcp '{"name":"kubernetes","command":"npx","args":["kubernete
|
||||
|
||||
Install the Kubernetes MCP server extension in Cursor by pressing the following link:
|
||||
|
||||
[](https://cursor.com/install-mcp?name=kubernetes-mcp-server&config=JTdCJTIyY29tbWFuZCUyMiUzQSUyMm5weCUyMC15JTIwa3ViZXJuZXRlcy1tY3Atc2VydmVyJTQwbGF0ZXN0JTIyJTdE)
|
||||
[](https://cursor.com/en/install-mcp?name=kubernetes-mcp-server&config=eyJjb21tYW5kIjoibnB4IC15IGt1YmVybmV0ZXMtbWNwLXNlcnZlckBsYXRlc3QifQ%3D%3D)
|
||||
|
||||
Alternatively, you can install the extension manually by editing the `mcp.json` file:
|
||||
|
||||
@@ -120,6 +128,30 @@ extensions:
|
||||
|
||||
```
|
||||
|
||||
## 🎥 Demos <a id="demos"></a>
|
||||
|
||||
### Diagnosing and automatically fixing an OpenShift Deployment
|
||||
|
||||
Demo showcasing how Kubernetes MCP server is leveraged by Claude Desktop to automatically diagnose and fix a deployment in OpenShift without any user assistance.
|
||||
|
||||
https://github.com/user-attachments/assets/a576176d-a142-4c19-b9aa-a83dc4b8d941
|
||||
|
||||
### _Vibe Coding_ a simple game and deploying it to OpenShift
|
||||
|
||||
In this demo, I walk you through the process of _Vibe Coding_ a simple game using VS Code and how to leverage [Podman MCP server](https://github.com/manusa/podman-mcp-server) and Kubernetes MCP server to deploy it to OpenShift.
|
||||
|
||||
<a href="https://www.youtube.com/watch?v=l05jQDSrzVI" target="_blank">
|
||||
<img src="docs/images/vibe-coding.jpg" alt="Vibe Coding: Build & Deploy a Game on Kubernetes" width="240" />
|
||||
</a>
|
||||
|
||||
### Supercharge GitHub Copilot with Kubernetes MCP Server in VS Code - One-Click Setup!
|
||||
|
||||
In this demo, I'll show you how to set up Kubernetes MCP server in VS code just by clicking a link.
|
||||
|
||||
<a href="https://youtu.be/AI4ljYMkgtA" target="_blank">
|
||||
<img src="docs/images/kubernetes-mcp-server-github-copilot.jpg" alt="Supercharge GitHub Copilot with Kubernetes MCP Server in VS Code - One-Click Setup!" width="240" />
|
||||
</a>
|
||||
|
||||
## ⚙️ Configuration <a id="configuration"></a>
|
||||
|
||||
The Kubernetes MCP server can be configured using command line (CLI) arguments.
|
||||
@@ -151,240 +183,141 @@ uvx kubernetes-mcp-server@latest --help
|
||||
| `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") |
|
||||
| `--read-only` | If set, the MCP server will run in read-only mode, meaning it will not allow any write operations (create, update, delete) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without making changes. |
|
||||
| `--disable-destructive` | If set, the MCP server will disable all destructive operations (delete, update, etc.) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without accidentally making changes. This option has no effect when `--read-only` is used. |
|
||||
| `--toolsets` | Comma-separated list of toolsets to enable. Check the [🛠️ Tools and Functionalities](#tools-and-functionalities) section for more information. |
|
||||
|
||||
## 🛠️ Tools <a id="tools"></a>
|
||||
## 🛠️ Tools and Functionalities <a id="tools-and-functionalities"></a>
|
||||
|
||||
### `configuration_view`
|
||||
The Kubernetes MCP server supports enabling or disabling specific groups of tools and functionalities (tools, resources, prompts, and so on) via the `--toolsets` command-line flag or `toolsets` configuration option.
|
||||
This allows you to control which Kubernetes functionalities are available to your AI tools.
|
||||
Enabling only the toolsets you need can help reduce the context size and improve the LLM's tool selection accuracy.
|
||||
|
||||
Get the current Kubernetes configuration content as a kubeconfig YAML
|
||||
### Available Toolsets
|
||||
|
||||
**Parameters:**
|
||||
- `minified` (`boolean`, optional, default: `true`)
|
||||
- Return a minified version of the configuration
|
||||
- If `true`, keeps only the current-context and relevant configuration pieces
|
||||
- If `false`, returns all contexts, clusters, auth-infos, and users
|
||||
The following sets of tools are available (all on by default):
|
||||
|
||||
### `events_list`
|
||||
<!-- AVAILABLE-TOOLSETS-START -->
|
||||
|
||||
List all the Kubernetes events in the current cluster from all namespaces
|
||||
| Toolset | Description |
|
||||
|---------|-------------------------------------------------------------------------------------|
|
||||
| config | View and manage the current local Kubernetes configuration (kubeconfig) |
|
||||
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
|
||||
| helm | Tools for managing Helm charts and releases |
|
||||
|
||||
**Parameters:**
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to retrieve the events from. If not provided, will list events from all namespaces
|
||||
<!-- AVAILABLE-TOOLSETS-END -->
|
||||
|
||||
### `helm_install`
|
||||
### Tools
|
||||
|
||||
Install a Helm chart in the current or provided namespace with the provided name and chart
|
||||
<!-- AVAILABLE-TOOLSETS-TOOLS-START -->
|
||||
|
||||
**Parameters:**
|
||||
- `chart` (`string`, required)
|
||||
- Name of the Helm chart to install
|
||||
- Can be a local path or a remote URL
|
||||
- Example: `./my-chart.tgz` or `https://example.com/my-chart.tgz`
|
||||
- `values` (`object`, optional)
|
||||
- Values to pass to the Helm chart
|
||||
- Example: `{"key": "value"}`
|
||||
- `name` (`string`, optional)
|
||||
- Name of the Helm release
|
||||
- Random name if not provided
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to install the Helm chart in
|
||||
- If not provided, will use the configured namespace
|
||||
<details>
|
||||
|
||||
### `helm_list`
|
||||
<summary>config</summary>
|
||||
|
||||
List all the Helm releases in the current or provided namespace (or in all namespaces if specified)
|
||||
- **configuration_view** - Get the current Kubernetes configuration content as a kubeconfig YAML
|
||||
- `minified` (`boolean`) - Return a minified version of the configuration. If set to true, keeps only the current-context and the relevant pieces of the configuration for that context. If set to false, all contexts, clusters, auth-infos, and users are returned in the configuration. (Optional, default true)
|
||||
|
||||
**Parameters:**
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to list the Helm releases from
|
||||
- If not provided, will use the configured namespace
|
||||
- `all_namespaces` (`boolean`, optional)
|
||||
- If `true`, will list Helm releases from all namespaces
|
||||
- If `false`, will list Helm releases from the specified namespace
|
||||
</details>
|
||||
|
||||
### `helm_uninstall`
|
||||
<details>
|
||||
|
||||
Uninstall a Helm release in the current or provided namespace with the provided name
|
||||
<summary>core</summary>
|
||||
|
||||
**Parameters:**
|
||||
- `name` (`string`, required)
|
||||
- Name of the Helm release to uninstall
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to uninstall the Helm release from
|
||||
- If not provided, will use the configured namespace
|
||||
- **events_list** - List all the Kubernetes events in the current cluster from all namespaces
|
||||
- `namespace` (`string`) - Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces
|
||||
|
||||
### `namespaces_list`
|
||||
- **namespaces_list** - List all the Kubernetes namespaces in the current cluster
|
||||
|
||||
List all the Kubernetes namespaces in the current cluster
|
||||
- **projects_list** - List all the OpenShift projects in the current cluster
|
||||
|
||||
**Parameters:** None
|
||||
- **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
|
||||
- `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
|
||||
|
||||
### `pods_delete`
|
||||
- **pods_list_in_namespace** - List all the Kubernetes pods in the specified namespace in the current cluster
|
||||
- `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
|
||||
- `namespace` (`string`) **(required)** - Namespace to list pods from
|
||||
|
||||
Delete a Kubernetes Pod in the current or provided namespace with the provided name
|
||||
- **pods_get** - Get a Kubernetes Pod in the current or provided namespace with the provided name
|
||||
- `name` (`string`) **(required)** - Name of the Pod
|
||||
- `namespace` (`string`) - Namespace to get the Pod from
|
||||
|
||||
**Parameters:**
|
||||
- `name` (`string`, required)
|
||||
- Name of the Pod to delete
|
||||
- `namespace` (`string`, required)
|
||||
- Namespace to delete the Pod from
|
||||
- **pods_delete** - Delete a Kubernetes Pod in the current or provided namespace with the provided name
|
||||
- `name` (`string`) **(required)** - Name of the Pod to delete
|
||||
- `namespace` (`string`) - Namespace to delete the Pod from
|
||||
|
||||
### `pods_exec`
|
||||
- **pods_top** - List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace
|
||||
- `all_namespaces` (`boolean`) - If true, list the resource consumption for all Pods in all namespaces. If false, list the resource consumption for Pods in the provided namespace or the current namespace
|
||||
- `label_selector` (`string`) - Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label (Optional, only applicable when name is not provided)
|
||||
- `name` (`string`) - Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)
|
||||
- `namespace` (`string`) - Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)
|
||||
|
||||
Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command
|
||||
- **pods_exec** - Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command
|
||||
- `command` (`array`) **(required)** - Command to execute in the Pod container. The first item is the command to be run, and the rest are the arguments to that command. Example: ["ls", "-l", "/tmp"]
|
||||
- `container` (`string`) - Name of the Pod container where the command will be executed (Optional)
|
||||
- `name` (`string`) **(required)** - Name of the Pod where the command will be executed
|
||||
- `namespace` (`string`) - Namespace of the Pod where the command will be executed
|
||||
|
||||
**Parameters:**
|
||||
- `command` (`string[]`, required)
|
||||
- Command to execute in the Pod container
|
||||
- First item is the command, rest are arguments
|
||||
- Example: `["ls", "-l", "/tmp"]`
|
||||
- `name` (string, required)
|
||||
- Name of the Pod
|
||||
- `namespace` (string, required)
|
||||
- Namespace of the Pod
|
||||
- `container` (`string`, optional)
|
||||
- Name of the Pod container to get logs from
|
||||
- **pods_log** - Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name
|
||||
- `container` (`string`) - Name of the Pod container to get the logs from (Optional)
|
||||
- `name` (`string`) **(required)** - Name of the Pod to get the logs from
|
||||
- `namespace` (`string`) - Namespace to get the Pod logs from
|
||||
- `previous` (`boolean`) - Return previous terminated container logs (Optional)
|
||||
- `tail` (`integer`) - Number of lines to retrieve from the end of the logs (Optional, default: 100)
|
||||
|
||||
### `pods_get`
|
||||
- **pods_run** - Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name
|
||||
- `image` (`string`) **(required)** - Container Image to run in the Pod
|
||||
- `name` (`string`) - Name of the Pod (Optional, random name if not provided)
|
||||
- `namespace` (`string`) - Namespace to run the Pod in
|
||||
- `port` (`number`) - TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)
|
||||
|
||||
Get a Kubernetes Pod in the current or provided namespace with the provided name
|
||||
- **resources_list** - List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector
|
||||
(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
|
||||
- `apiVersion` (`string`) **(required)** - apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
|
||||
- `kind` (`string`) **(required)** - kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)
|
||||
- `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
|
||||
- `namespace` (`string`) - Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces
|
||||
|
||||
**Parameters:**
|
||||
- `name` (`string`, required)
|
||||
- Name of the Pod
|
||||
- `namespace` (`string`, required)
|
||||
- Namespace to get the Pod from
|
||||
- **resources_get** - Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name
|
||||
(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
|
||||
- `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
|
||||
- `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)
|
||||
- `name` (`string`) **(required)** - Name of the resource
|
||||
- `namespace` (`string`) - Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace
|
||||
|
||||
### `pods_list`
|
||||
- **resources_create_or_update** - Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource
|
||||
(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
|
||||
- `resource` (`string`) **(required)** - A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec
|
||||
|
||||
List all the Kubernetes pods in the current cluster from all namespaces
|
||||
- **resources_delete** - Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name
|
||||
(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
|
||||
- `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
|
||||
- `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)
|
||||
- `name` (`string`) **(required)** - Name of the resource
|
||||
- `namespace` (`string`) - Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace
|
||||
|
||||
**Parameters:**
|
||||
- `labelSelector` (`string`, optional)
|
||||
- Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label
|
||||
</details>
|
||||
|
||||
### `pods_list_in_namespace`
|
||||
<details>
|
||||
|
||||
List all the Kubernetes pods in the specified namespace in the current cluster
|
||||
<summary>helm</summary>
|
||||
|
||||
**Parameters:**
|
||||
- `namespace` (`string`, required)
|
||||
- Namespace to list pods from
|
||||
- `labelSelector` (`string`, optional)
|
||||
- Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label
|
||||
- **helm_install** - Install a Helm chart in the current or provided namespace
|
||||
- `chart` (`string`) **(required)** - Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)
|
||||
- `name` (`string`) - Name of the Helm release (Optional, random name if not provided)
|
||||
- `namespace` (`string`) - Namespace to install the Helm chart in (Optional, current namespace if not provided)
|
||||
- `values` (`object`) - Values to pass to the Helm chart (Optional)
|
||||
|
||||
### `pods_log`
|
||||
- **helm_list** - List all the Helm releases in the current or provided namespace (or in all namespaces if specified)
|
||||
- `all_namespaces` (`boolean`) - If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)
|
||||
- `namespace` (`string`) - Namespace to list Helm releases from (Optional, all namespaces if not provided)
|
||||
|
||||
Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name
|
||||
- **helm_uninstall** - Uninstall a Helm release in the current or provided namespace
|
||||
- `name` (`string`) **(required)** - Name of the Helm release to uninstall
|
||||
- `namespace` (`string`) - Namespace to uninstall the Helm release from (Optional, current namespace if not provided)
|
||||
|
||||
**Parameters:**
|
||||
- `name` (`string`, required)
|
||||
- Name of the Pod to get logs from
|
||||
- `namespace` (`string`, required)
|
||||
- Namespace to get the Pod logs from
|
||||
- `container` (`string`, optional)
|
||||
- Name of the Pod container to get logs from
|
||||
</details>
|
||||
|
||||
### `pods_run`
|
||||
|
||||
Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name
|
||||
|
||||
**Parameters:**
|
||||
- `image` (`string`, required)
|
||||
- Container Image to run in the Pod
|
||||
- `namespace` (`string`, required)
|
||||
- Namespace to run the Pod in
|
||||
- `name` (`string`, optional)
|
||||
- Name of the Pod (random name if not provided)
|
||||
- `port` (`number`, optional)
|
||||
- TCP/IP port to expose from the Pod container
|
||||
- No port exposed if not provided
|
||||
|
||||
### `pods_top`
|
||||
|
||||
Lists the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace
|
||||
|
||||
**Parameters:**
|
||||
- `all_namespaces` (`boolean`, optional, default: `true`)
|
||||
- If `true`, lists resource consumption for Pods in all namespaces
|
||||
- If `false`, lists resource consumption for Pods in the configured or provided namespace
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to list the Pod resources from
|
||||
- If not provided, will list Pods from the configured namespace (in case all_namespaces is false)
|
||||
- `name` (`string`, optional)
|
||||
- Name of the Pod to get resource consumption from
|
||||
- If not provided, will list resource consumption for all Pods in the applicable namespace(s)
|
||||
- `label_selector` (`string`, optional)
|
||||
- Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label (Optional, only applicable when name is not provided)
|
||||
|
||||
### `projects_list`
|
||||
|
||||
List all the OpenShift projects in the current cluster
|
||||
|
||||
### `resources_create_or_update`
|
||||
|
||||
Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource
|
||||
|
||||
**Parameters:**
|
||||
- `resource` (`string`, required)
|
||||
- A JSON or YAML containing a representation of the Kubernetes resource
|
||||
- Should include top-level fields such as apiVersion, kind, metadata, and spec
|
||||
|
||||
**Common apiVersion and kind include:**
|
||||
- v1 Pod
|
||||
- v1 Service
|
||||
- v1 Node
|
||||
- apps/v1 Deployment
|
||||
- networking.k8s.io/v1 Ingress
|
||||
|
||||
### `resources_delete`
|
||||
|
||||
Delete a Kubernetes resource in the current cluster
|
||||
|
||||
**Parameters:**
|
||||
- `apiVersion` (`string`, required)
|
||||
- apiVersion of the resource (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`)
|
||||
- `kind` (`string`, required)
|
||||
- kind of the resource (e.g., `Pod`, `Service`, `Deployment`, `Ingress`)
|
||||
- `name` (`string`, required)
|
||||
- Name of the resource
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to delete the namespaced resource from
|
||||
- Ignored for cluster-scoped resources
|
||||
- Uses configured namespace if not provided
|
||||
|
||||
### `resources_get`
|
||||
|
||||
Get a Kubernetes resource in the current cluster
|
||||
|
||||
**Parameters:**
|
||||
- `apiVersion` (`string`, required)
|
||||
- apiVersion of the resource (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`)
|
||||
- `kind` (`string`, required)
|
||||
- kind of the resource (e.g., `Pod`, `Service`, `Deployment`, `Ingress`)
|
||||
- `name` (`string`, required)
|
||||
- Name of the resource
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to retrieve the namespaced resource from
|
||||
- Ignored for cluster-scoped resources
|
||||
- Uses configured namespace if not provided
|
||||
|
||||
### `resources_list`
|
||||
|
||||
List Kubernetes resources and objects in the current cluster
|
||||
|
||||
**Parameters:**
|
||||
- `apiVersion` (`string`, required)
|
||||
- apiVersion of the resources (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`)
|
||||
- `kind` (`string`, required)
|
||||
- kind of the resources (e.g., `Pod`, `Service`, `Deployment`, `Ingress`)
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to retrieve the namespaced resources from
|
||||
- Ignored for cluster-scoped resources
|
||||
- Lists resources from all namespaces if not provided
|
||||
- `labelSelector` (`string`, optional)
|
||||
- Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label.
|
||||
<!-- AVAILABLE-TOOLSETS-TOOLS-END -->
|
||||
|
||||
## 🧑💻 Development <a id="development"></a>
|
||||
|
||||
|
||||
6
go.mod
6
go.mod
@@ -4,11 +4,11 @@ go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/coreos/go-oidc/v3 v3.16.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.2
|
||||
github.com/go-jose/go-jose/v4 v4.1.3
|
||||
github.com/google/jsonschema-go v0.3.0
|
||||
github.com/mark3labs/mcp-go v0.40.0
|
||||
github.com/mark3labs/mcp-go v0.41.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
|
||||
12
go.sum
12
go.sum
@@ -48,8 +48,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -99,8 +99,8 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
|
||||
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -187,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.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU=
|
||||
github.com/mark3labs/mcp-go v0.40.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
|
||||
github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA=
|
||||
github.com/mark3labs/mcp-go v0.41.1/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=
|
||||
|
||||
@@ -8,15 +8,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -73,10 +73,14 @@ func (m *MockServer) Kubeconfig() *api.Config {
|
||||
}
|
||||
|
||||
func (m *MockServer) KubeconfigFile(t *testing.T) string {
|
||||
kubeconfig := filepath.Join(t.TempDir(), "config")
|
||||
err := clientcmd.WriteToFile(*m.Kubeconfig(), kubeconfig)
|
||||
return KubeconfigFile(t, m.Kubeconfig())
|
||||
}
|
||||
|
||||
func KubeconfigFile(t *testing.T, kubeconfig *api.Config) string {
|
||||
kubeconfigFile := filepath.Join(t.TempDir(), "config")
|
||||
err := clientcmd.WriteToFile(*kubeconfig, kubeconfigFile)
|
||||
require.NoError(t, err, "Expected no error writing kubeconfig file")
|
||||
return kubeconfig
|
||||
return kubeconfigFile
|
||||
}
|
||||
|
||||
func WriteObject(w http.ResponseWriter, obj runtime.Object) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -25,7 +26,14 @@ func (o *OpenShift) IsOpenShift(ctx context.Context) bool {
|
||||
var _ internalk8s.Openshift = (*OpenShift)(nil)
|
||||
|
||||
func main() {
|
||||
readme, err := os.ReadFile(os.Args[1])
|
||||
// Snyk reports false positive unless we flow the args through filepath.Clean and filepath.Localize in this specific order
|
||||
var err error
|
||||
localReadmePath := filepath.Clean(os.Args[1])
|
||||
localReadmePath, err = filepath.Localize(localReadmePath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
readme, err := os.ReadFile(localReadmePath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -81,7 +89,7 @@ func main() {
|
||||
toolsetTools.String(),
|
||||
)
|
||||
|
||||
if err := os.WriteFile(os.Args[1], []byte(updated), 0o644); err != nil {
|
||||
if err := os.WriteFile(localReadmePath, []byte(updated), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,29 @@ import (
|
||||
)
|
||||
|
||||
type ServerTool struct {
|
||||
Tool Tool
|
||||
Handler ToolHandlerFunc
|
||||
Tool Tool
|
||||
Handler ToolHandlerFunc
|
||||
ClusterAware *bool
|
||||
TargetListProvider *bool
|
||||
}
|
||||
|
||||
// IsClusterAware indicates whether the tool can accept a "cluster" or "context" parameter
|
||||
// to operate on a specific Kubernetes cluster context.
|
||||
// Defaults to true if not explicitly set
|
||||
func (s *ServerTool) IsClusterAware() bool {
|
||||
if s.ClusterAware != nil {
|
||||
return *s.ClusterAware
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsTargetListProvider indicates whether the tool is used to provide a list of targets (clusters/contexts)
|
||||
// Defaults to false if not explicitly set
|
||||
func (s *ServerTool) IsTargetListProvider() bool {
|
||||
if s.TargetListProvider != nil {
|
||||
return *s.TargetListProvider
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Toolset interface {
|
||||
|
||||
47
pkg/api/toolsets_test.go
Normal file
47
pkg/api/toolsets_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"k8s.io/utils/ptr"
|
||||
)
|
||||
|
||||
type ToolsetsSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (s *ToolsetsSuite) TestServerTool() {
|
||||
s.Run("IsClusterAware", func() {
|
||||
s.Run("defaults to true", func() {
|
||||
tool := &ServerTool{}
|
||||
s.True(tool.IsClusterAware(), "Expected IsClusterAware to be true by default")
|
||||
})
|
||||
s.Run("can be set to false", func() {
|
||||
tool := &ServerTool{ClusterAware: ptr.To(false)}
|
||||
s.False(tool.IsClusterAware(), "Expected IsClusterAware to be false when set to false")
|
||||
})
|
||||
s.Run("can be set to true", func() {
|
||||
tool := &ServerTool{ClusterAware: ptr.To(true)}
|
||||
s.True(tool.IsClusterAware(), "Expected IsClusterAware to be true when set to true")
|
||||
})
|
||||
})
|
||||
s.Run("IsTargetListProvider", func() {
|
||||
s.Run("defaults to false", func() {
|
||||
tool := &ServerTool{}
|
||||
s.False(tool.IsTargetListProvider(), "Expected IsTargetListProvider to be false by default")
|
||||
})
|
||||
s.Run("can be set to false", func() {
|
||||
tool := &ServerTool{TargetListProvider: ptr.To(false)}
|
||||
s.False(tool.IsTargetListProvider(), "Expected IsTargetListProvider to be false when set to false")
|
||||
})
|
||||
s.Run("can be set to true", func() {
|
||||
tool := &ServerTool{TargetListProvider: ptr.To(true)}
|
||||
s.True(tool.IsTargetListProvider(), "Expected IsTargetListProvider to be true when set to true")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolsets(t *testing.T) {
|
||||
suite.Run(t, new(ToolsetsSuite))
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import (
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
const (
|
||||
ClusterProviderKubeConfig = "kubeconfig"
|
||||
ClusterProviderInCluster = "in-cluster"
|
||||
)
|
||||
|
||||
// StaticConfig is the configuration for the server.
|
||||
// It allows to configure server specific settings and tools to be enabled or disabled.
|
||||
type StaticConfig struct {
|
||||
@@ -49,6 +54,12 @@ type StaticConfig struct {
|
||||
StsScopes []string `toml:"sts_scopes,omitempty"`
|
||||
CertificateAuthority string `toml:"certificate_authority,omitempty"`
|
||||
ServerURL string `toml:"server_url,omitempty"`
|
||||
// ClusterProviderStrategy is how the server finds clusters.
|
||||
// If set to "kubeconfig", the clusters will be loaded from those in the kubeconfig.
|
||||
// If set to "in-cluster", the server will use the in cluster config
|
||||
ClusterProviderStrategy string `toml:"cluster_provider_strategy,omitempty"`
|
||||
// ClusterContexts is which context should be used for each cluster
|
||||
ClusterContexts map[string]string `toml:"cluster_contexts"`
|
||||
}
|
||||
|
||||
func Default() *StaticConfig {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -20,7 +23,50 @@ import (
|
||||
|
||||
type KubernetesApiTokenVerifier interface {
|
||||
// KubernetesApiVerifyToken TODO: clarify proper implementation
|
||||
KubernetesApiVerifyToken(ctx context.Context, token, audience string) (*authenticationapiv1.UserInfo, []string, error)
|
||||
KubernetesApiVerifyToken(ctx context.Context, token, audience, cluster string) (*authenticationapiv1.UserInfo, []string, error)
|
||||
// GetTargetParameterName returns the parameter name used for target identification in MCP requests
|
||||
GetTargetParameterName() string
|
||||
}
|
||||
|
||||
// extractTargetFromRequest extracts cluster parameter from MCP request body
|
||||
func extractTargetFromRequest(r *http.Request, targetName string) (string, error) {
|
||||
if r.Body == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Read the body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Restore the body for downstream handlers
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
// Parse the MCP request
|
||||
var mcpRequest struct {
|
||||
Params struct {
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &mcpRequest); err != nil {
|
||||
// If we can't parse the request, just return empty cluster (will use default)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Extract target parameter
|
||||
if cluster, ok := mcpRequest.Params.Arguments[targetName].(string); ok {
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// write401 sends a 401/Unauthorized response with WWW-Authenticate header.
|
||||
func write401(w http.ResponseWriter, wwwAuthenticateHeader, errorType, message string) {
|
||||
w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+fmt.Sprintf(`, error="%s"`, errorType))
|
||||
http.Error(w, message, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// AuthorizationMiddleware validates the OAuth flow for protected resources.
|
||||
@@ -82,9 +128,7 @@ func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oi
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
klog.V(1).Infof("Authentication failed - missing or invalid bearer token: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
||||
|
||||
w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+", error=\"missing_token\"")
|
||||
http.Error(w, "Unauthorized: Bearer token required", http.StatusUnauthorized)
|
||||
write401(w, wwwAuthenticateHeader, "missing_token", "Unauthorized: Bearer token required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,13 +172,16 @@ func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oi
|
||||
}
|
||||
// Kubernetes API Server TokenReview validation
|
||||
if err == nil && staticConfig.ValidateToken {
|
||||
err = claims.ValidateWithKubernetesApi(r.Context(), staticConfig.OAuthAudience, verifier)
|
||||
targetParameterName := verifier.GetTargetParameterName()
|
||||
cluster, clusterErr := extractTargetFromRequest(r, targetParameterName)
|
||||
if clusterErr != nil {
|
||||
klog.V(2).Infof("Failed to extract cluster from request, using default: %v", clusterErr)
|
||||
}
|
||||
err = claims.ValidateWithKubernetesApi(r.Context(), staticConfig.OAuthAudience, cluster, verifier)
|
||||
}
|
||||
if err != nil {
|
||||
klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
|
||||
|
||||
w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+", error=\"invalid_token\"")
|
||||
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
|
||||
write401(w, wwwAuthenticateHeader, "invalid_token", "Unauthorized: Invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,9 +245,9 @@ func (c *JWTClaims) ValidateWithProvider(ctx context.Context, audience string, p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *JWTClaims) ValidateWithKubernetesApi(ctx context.Context, audience string, verifier KubernetesApiTokenVerifier) error {
|
||||
func (c *JWTClaims) ValidateWithKubernetesApi(ctx context.Context, audience, cluster string, verifier KubernetesApiTokenVerifier) error {
|
||||
if verifier != nil {
|
||||
_, _, err := verifier.KubernetesApiVerifyToken(ctx, c.Token, audience)
|
||||
_, _, err := verifier.KubernetesApiVerifyToken(ctx, c.Token, audience, cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("kubernetes API token validation error: %v", err)
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ func TestHealthCheck(t *testing.T) {
|
||||
})
|
||||
})
|
||||
// Health exposed even when require Authorization
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get health check endpoint with OAuth: %v", err)
|
||||
@@ -313,7 +313,7 @@ func TestWellKnownReverseProxy(t *testing.T) {
|
||||
".well-known/openid-configuration",
|
||||
}
|
||||
// With No Authorization URL configured
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
for _, path := range cases {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
@@ -333,7 +333,12 @@ func TestWellKnownReverseProxy(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`NOT A JSON PAYLOAD`))
|
||||
}))
|
||||
t.Cleanup(invalidPayloadServer.Close)
|
||||
invalidPayloadConfig := &config.StaticConfig{AuthorizationURL: invalidPayloadServer.URL, RequireOAuth: true, ValidateToken: true}
|
||||
invalidPayloadConfig := &config.StaticConfig{
|
||||
AuthorizationURL: invalidPayloadServer.URL,
|
||||
RequireOAuth: true,
|
||||
ValidateToken: true,
|
||||
ClusterProviderStrategy: config.ClusterProviderKubeConfig,
|
||||
}
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: invalidPayloadConfig}, func(ctx *httpContext) {
|
||||
for _, path := range cases {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
|
||||
@@ -358,7 +363,12 @@ func TestWellKnownReverseProxy(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"issuer": "https://example.com","scopes_supported":["mcp-server"]}`))
|
||||
}))
|
||||
t.Cleanup(testServer.Close)
|
||||
staticConfig := &config.StaticConfig{AuthorizationURL: testServer.URL, RequireOAuth: true, ValidateToken: true}
|
||||
staticConfig := &config.StaticConfig{
|
||||
AuthorizationURL: testServer.URL,
|
||||
RequireOAuth: true,
|
||||
ValidateToken: true,
|
||||
ClusterProviderStrategy: config.ClusterProviderKubeConfig,
|
||||
}
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: staticConfig}, func(ctx *httpContext) {
|
||||
for _, path := range cases {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
|
||||
@@ -401,7 +411,12 @@ func TestWellKnownOverrides(t *testing.T) {
|
||||
}`))
|
||||
}))
|
||||
t.Cleanup(testServer.Close)
|
||||
baseConfig := config.StaticConfig{AuthorizationURL: testServer.URL, RequireOAuth: true, ValidateToken: true}
|
||||
baseConfig := config.StaticConfig{
|
||||
AuthorizationURL: testServer.URL,
|
||||
RequireOAuth: true,
|
||||
ValidateToken: true,
|
||||
ClusterProviderStrategy: config.ClusterProviderKubeConfig,
|
||||
}
|
||||
// With Dynamic Client Registration disabled
|
||||
disableDynamicRegistrationConfig := baseConfig
|
||||
disableDynamicRegistrationConfig.DisableDynamicClientRegistration = true
|
||||
@@ -488,7 +503,7 @@ func TestMiddlewareLogging(t *testing.T) {
|
||||
|
||||
func TestAuthorizationUnauthorized(t *testing.T) {
|
||||
// Missing Authorization header
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get protected endpoint: %v", err)
|
||||
@@ -513,7 +528,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
|
||||
})
|
||||
})
|
||||
// Authorization header without Bearer prefix
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
@@ -538,7 +553,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
|
||||
})
|
||||
})
|
||||
// Invalid Authorization header
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
@@ -569,7 +584,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
|
||||
})
|
||||
})
|
||||
// Expired Authorization Bearer token
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
@@ -600,7 +615,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
|
||||
})
|
||||
})
|
||||
// Invalid audience claim Bearer token
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "expected-audience", ValidateToken: true}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "expected-audience", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
@@ -633,7 +648,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
|
||||
// Failed OIDC validation
|
||||
oidcTestServer := NewOidcTestServer(t)
|
||||
t.Cleanup(oidcTestServer.Close)
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
@@ -670,7 +685,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
|
||||
"aud": "mcp-server"
|
||||
}`
|
||||
validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims)
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
@@ -703,7 +718,7 @@ func TestAuthorizationUnauthorized(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthorizationRequireOAuthFalse(t *testing.T) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: false}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: false, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get protected endpoint: %v", err)
|
||||
@@ -728,7 +743,7 @@ func TestAuthorizationRawToken(t *testing.T) {
|
||||
{"mcp-server", true}, // Audience set, validation enabled
|
||||
}
|
||||
for _, c := range cases {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: c.audience, ValidateToken: c.validateToken}}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: c.audience, ValidateToken: c.validateToken, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
|
||||
tokenReviewed := false
|
||||
ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
|
||||
@@ -777,7 +792,7 @@ func TestAuthorizationOidcToken(t *testing.T) {
|
||||
validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims)
|
||||
cases := []bool{false, true}
|
||||
for _, validateToken := range cases {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: validateToken}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: validateToken, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
|
||||
tokenReviewed := false
|
||||
ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
|
||||
@@ -833,13 +848,14 @@ func TestAuthorizationOidcTokenExchange(t *testing.T) {
|
||||
cases := []bool{false, true}
|
||||
for _, validateToken := range cases {
|
||||
staticConfig := &config.StaticConfig{
|
||||
RequireOAuth: true,
|
||||
OAuthAudience: "mcp-server",
|
||||
ValidateToken: validateToken,
|
||||
StsClientId: "test-sts-client-id",
|
||||
StsClientSecret: "test-sts-client-secret",
|
||||
StsAudience: "backend-audience",
|
||||
StsScopes: []string{"backend-scope"},
|
||||
RequireOAuth: true,
|
||||
OAuthAudience: "mcp-server",
|
||||
ValidateToken: validateToken,
|
||||
StsClientId: "test-sts-client-id",
|
||||
StsClientSecret: "test-sts-client-secret",
|
||||
StsAudience: "backend-audience",
|
||||
StsScopes: []string{"backend-scope"},
|
||||
ClusterProviderStrategy: config.ClusterProviderKubeConfig,
|
||||
}
|
||||
testCaseWithContext(t, &httpContext{StaticConfig: staticConfig, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
|
||||
tokenReviewed := false
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"k8s.io/client-go/tools/clientcmd/api/latest"
|
||||
)
|
||||
|
||||
const inClusterKubeConfigDefaultContext = "in-cluster"
|
||||
|
||||
// InClusterConfig is a variable that holds the function to get the in-cluster config
|
||||
// Exposed for testing
|
||||
var InClusterConfig = func() (*rest.Config, error) {
|
||||
@@ -81,6 +83,45 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig {
|
||||
return m.clientCmdConfig
|
||||
}
|
||||
|
||||
// ConfigurationContextsDefault returns the current context name
|
||||
// TODO: Should be moved to the Provider level ?
|
||||
func (k *Kubernetes) ConfigurationContextsDefault() (string, error) {
|
||||
if k.manager.IsInCluster() {
|
||||
return inClusterKubeConfigDefaultContext, nil
|
||||
}
|
||||
cfg, err := k.manager.clientCmdConfig.RawConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.CurrentContext, nil
|
||||
}
|
||||
|
||||
// ConfigurationContextsList returns the list of available context names
|
||||
// TODO: Should be moved to the Provider level ?
|
||||
func (k *Kubernetes) ConfigurationContextsList() (map[string]string, error) {
|
||||
if k.manager.IsInCluster() {
|
||||
return map[string]string{inClusterKubeConfigDefaultContext: ""}, nil
|
||||
}
|
||||
cfg, err := k.manager.clientCmdConfig.RawConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contexts := make(map[string]string, len(cfg.Contexts))
|
||||
for name, context := range cfg.Contexts {
|
||||
cluster, ok := cfg.Clusters[context.Cluster]
|
||||
if !ok || cluster.Server == "" {
|
||||
contexts[name] = "unknown"
|
||||
} else {
|
||||
contexts[name] = cluster.Server
|
||||
}
|
||||
}
|
||||
return contexts, nil
|
||||
}
|
||||
|
||||
// ConfigurationView returns the current kubeconfig content as a kubeconfig YAML
|
||||
// If minify is true, keeps only the current-context and the relevant pieces of the configuration for that context.
|
||||
// If minify is false, all contexts, clusters, auth-infos, and users are returned in the configuration.
|
||||
// TODO: Should be moved to the Provider level ?
|
||||
func (k *Kubernetes) ConfigurationView(minify bool) (runtime.Object, error) {
|
||||
var cfg clientcmdapi.Config
|
||||
var err error
|
||||
@@ -93,11 +134,11 @@ func (k *Kubernetes) ConfigurationView(minify bool) (runtime.Object, error) {
|
||||
cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{
|
||||
Token: k.manager.cfg.BearerToken,
|
||||
}
|
||||
cfg.Contexts["context"] = &clientcmdapi.Context{
|
||||
cfg.Contexts[inClusterKubeConfigDefaultContext] = &clientcmdapi.Context{
|
||||
Cluster: "cluster",
|
||||
AuthInfo: "user",
|
||||
}
|
||||
cfg.CurrentContext = "context"
|
||||
cfg.CurrentContext = inClusterKubeConfigDefaultContext
|
||||
} else if cfg, err = k.manager.clientCmdConfig.RawConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
231
pkg/kubernetes/provider.go
Normal file
231
pkg/kubernetes/provider.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/config"
|
||||
"k8s.io/client-go/discovery/cached/memory"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/restmapper"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
const (
|
||||
KubeConfigTargetParameterName = "context"
|
||||
)
|
||||
|
||||
type ManagerProvider interface {
|
||||
GetTargets(ctx context.Context) ([]string, error)
|
||||
GetManagerFor(ctx context.Context, target string) (*Manager, error)
|
||||
GetDefaultTarget() string
|
||||
GetTargetParameterName() string
|
||||
WatchTargets(func() error)
|
||||
Close()
|
||||
}
|
||||
|
||||
type kubeConfigClusterProvider struct {
|
||||
defaultContext string
|
||||
managers map[string]*Manager
|
||||
}
|
||||
|
||||
var _ ManagerProvider = &kubeConfigClusterProvider{}
|
||||
|
||||
type inClusterProvider struct {
|
||||
manager *Manager
|
||||
}
|
||||
|
||||
var _ ManagerProvider = &inClusterProvider{}
|
||||
|
||||
func NewManagerProvider(cfg *config.StaticConfig) (ManagerProvider, error) {
|
||||
m, err := NewManager(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch resolveStrategy(cfg, m) {
|
||||
case config.ClusterProviderKubeConfig:
|
||||
return newKubeConfigClusterProvider(m)
|
||||
case config.ClusterProviderInCluster:
|
||||
return newInClusterProvider(m)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid ClusterProviderStrategy '%s', must be 'kubeconfig' or 'in-cluster'", cfg.ClusterProviderStrategy)
|
||||
}
|
||||
}
|
||||
|
||||
func newKubeConfigClusterProvider(m *Manager) (*kubeConfigClusterProvider, error) {
|
||||
// Handle in-cluster mode
|
||||
if m.IsInCluster() {
|
||||
return nil, fmt.Errorf("kubeconfig ClusterProviderStrategy is invalid for in-cluster deployments")
|
||||
}
|
||||
|
||||
rawConfig, err := m.clientCmdConfig.RawConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allClusterManagers := map[string]*Manager{
|
||||
rawConfig.CurrentContext: m, // we already initialized a manager for the default context, let's use it
|
||||
}
|
||||
|
||||
for name := range rawConfig.Contexts {
|
||||
if name == rawConfig.CurrentContext {
|
||||
continue // already initialized this, don't want to set it to nil
|
||||
}
|
||||
|
||||
allClusterManagers[name] = nil
|
||||
}
|
||||
|
||||
return &kubeConfigClusterProvider{
|
||||
defaultContext: rawConfig.CurrentContext,
|
||||
managers: allClusterManagers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newInClusterProvider(m *Manager) (*inClusterProvider, error) {
|
||||
return &inClusterProvider{
|
||||
manager: m,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (k *kubeConfigClusterProvider) GetTargets(ctx context.Context) ([]string, error) {
|
||||
contextNames := make([]string, 0, len(k.managers))
|
||||
for cluster := range k.managers {
|
||||
contextNames = append(contextNames, cluster)
|
||||
}
|
||||
|
||||
return contextNames, nil
|
||||
}
|
||||
|
||||
func (k *kubeConfigClusterProvider) GetTargetParameterName() string {
|
||||
return KubeConfigTargetParameterName
|
||||
}
|
||||
|
||||
func (k *kubeConfigClusterProvider) GetManagerFor(ctx context.Context, context string) (*Manager, error) {
|
||||
m, ok := k.managers[context]
|
||||
if ok && m != nil {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
baseManager := k.managers[k.defaultContext]
|
||||
|
||||
if baseManager.IsInCluster() {
|
||||
// In cluster mode, so context switching is not applicable
|
||||
return baseManager, nil
|
||||
}
|
||||
|
||||
m, err := baseManager.newForContext(context)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k.managers[context] = m
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (k *kubeConfigClusterProvider) GetDefaultTarget() string {
|
||||
return k.defaultContext
|
||||
}
|
||||
|
||||
func (k *kubeConfigClusterProvider) WatchTargets(onKubeConfigChanged func() error) {
|
||||
m := k.managers[k.defaultContext]
|
||||
|
||||
m.WatchKubeConfig(onKubeConfigChanged)
|
||||
}
|
||||
|
||||
func (k *kubeConfigClusterProvider) Close() {
|
||||
m := k.managers[k.defaultContext]
|
||||
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func (i *inClusterProvider) GetTargets(ctx context.Context) ([]string, error) {
|
||||
return []string{""}, nil
|
||||
}
|
||||
|
||||
func (i *inClusterProvider) GetManagerFor(ctx context.Context, target string) (*Manager, error) {
|
||||
if target != "" {
|
||||
return nil, fmt.Errorf("unable to get manager for other context/cluster with in-cluster strategy")
|
||||
}
|
||||
|
||||
return i.manager, nil
|
||||
}
|
||||
|
||||
func (i *inClusterProvider) GetDefaultTarget() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (i *inClusterProvider) GetTargetParameterName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (i *inClusterProvider) WatchTargets(watch func() error) {
|
||||
i.manager.WatchKubeConfig(watch)
|
||||
}
|
||||
|
||||
func (i *inClusterProvider) Close() {
|
||||
i.manager.Close()
|
||||
}
|
||||
|
||||
func (m *Manager) newForContext(context string) (*Manager, error) {
|
||||
pathOptions := clientcmd.NewDefaultPathOptions()
|
||||
if m.staticConfig.KubeConfig != "" {
|
||||
pathOptions.LoadingRules.ExplicitPath = m.staticConfig.KubeConfig
|
||||
}
|
||||
|
||||
clientCmdConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||
pathOptions.LoadingRules,
|
||||
&clientcmd.ConfigOverrides{
|
||||
CurrentContext: context,
|
||||
},
|
||||
)
|
||||
|
||||
cfg, err := clientCmdConfig.ClientConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.UserAgent == "" {
|
||||
cfg.UserAgent = rest.DefaultKubernetesUserAgent()
|
||||
}
|
||||
|
||||
manager := &Manager{
|
||||
cfg: cfg,
|
||||
clientCmdConfig: clientCmdConfig,
|
||||
staticConfig: m.staticConfig,
|
||||
}
|
||||
|
||||
// Initialize clients for new manager
|
||||
manager.accessControlClientSet, err = NewAccessControlClientset(manager.cfg, manager.staticConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager.discoveryClient = memory.NewMemCacheClient(manager.accessControlClientSet.DiscoveryClient())
|
||||
|
||||
manager.accessControlRESTMapper = NewAccessControlRESTMapper(
|
||||
restmapper.NewDeferredDiscoveryRESTMapper(manager.discoveryClient),
|
||||
manager.staticConfig,
|
||||
)
|
||||
|
||||
manager.dynamicClient, err = dynamic.NewForConfig(manager.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func resolveStrategy(cfg *config.StaticConfig, m *Manager) string {
|
||||
if cfg.ClusterProviderStrategy != "" {
|
||||
return cfg.ClusterProviderStrategy
|
||||
}
|
||||
|
||||
if m.IsInCluster() {
|
||||
return config.ClusterProviderInCluster
|
||||
}
|
||||
|
||||
return config.ClusterProviderKubeConfig
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func (c *mcpContext) withKubeConfig(rc *rest.Config) *clientcmdapi.Config {
|
||||
_ = clientcmd.WriteToFile(*fakeConfig, kubeConfig)
|
||||
_ = os.Setenv("KUBECONFIG", kubeConfig)
|
||||
if c.mcpServer != nil {
|
||||
if err := c.mcpServer.reloadKubernetesClient(); err != nil {
|
||||
if err := c.mcpServer.reloadKubernetesClusterProvider(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -436,7 +436,7 @@ func (s *BaseMcpSuite) SetupTest() {
|
||||
|
||||
func (s *BaseMcpSuite) TearDownTest() {
|
||||
if s.McpClient != nil {
|
||||
s.McpClient.Close()
|
||||
s.Close()
|
||||
}
|
||||
if s.mcpServer != nil {
|
||||
s.mcpServer.Close()
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"k8s.io/client-go/rest"
|
||||
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||
v1 "k8s.io/client-go/tools/clientcmd/api/v1"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
@@ -22,7 +24,37 @@ func (s *ConfigurationSuite) SetupTest() {
|
||||
// Use mock server for predictable kubeconfig content
|
||||
mockServer := test.NewMockServer()
|
||||
s.T().Cleanup(mockServer.Close)
|
||||
s.Cfg.KubeConfig = mockServer.KubeconfigFile(s.T())
|
||||
kubeconfig := mockServer.Kubeconfig()
|
||||
for i := 0; i < 10; i++ {
|
||||
// Add multiple fake contexts to force configuration_contexts_list tool to appear
|
||||
// and test minification in configuration_view tool
|
||||
name := fmt.Sprintf("cluster-%d", i)
|
||||
kubeconfig.Contexts[name] = clientcmdapi.NewContext()
|
||||
kubeconfig.Clusters[name+"-cluster"] = clientcmdapi.NewCluster()
|
||||
kubeconfig.AuthInfos[name+"-auth"] = clientcmdapi.NewAuthInfo()
|
||||
kubeconfig.Contexts[name].Cluster = name + "-cluster"
|
||||
kubeconfig.Contexts[name].AuthInfo = name + "-auth"
|
||||
}
|
||||
s.Cfg.KubeConfig = test.KubeconfigFile(s.T(), kubeconfig)
|
||||
}
|
||||
|
||||
func (s *ConfigurationSuite) TestContextsList() {
|
||||
s.InitMcpClient()
|
||||
s.Run("configuration_contexts_list", func() {
|
||||
toolResult, err := s.CallTool("configuration_contexts_list", map[string]interface{}{})
|
||||
s.Run("returns contexts", func() {
|
||||
s.Nilf(err, "call tool failed %v", err)
|
||||
})
|
||||
s.Require().NotNil(toolResult, "Expected tool result from call")
|
||||
s.Lenf(toolResult.Content, 1, "invalid tool result content length %v", len(toolResult.Content))
|
||||
s.Run("contains context count", func() {
|
||||
s.Regexpf(`^Available Kubernetes contexts \(11 total`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool count result content %v", toolResult.Content[0].(mcp.TextContent).Text)
|
||||
})
|
||||
s.Run("contains default context name", func() {
|
||||
s.Regexpf(`^Available Kubernetes contexts \(\d+ total, default: fake-context\)`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool context default result content %v", toolResult.Content[0].(mcp.TextContent).Text)
|
||||
s.Regexpf(`(?m)^\*fake-context -> http:\/\/127\.0\.0\.1:\d*$`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool context default result content %v", toolResult.Content[0].(mcp.TextContent).Text)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ConfigurationSuite) TestConfigurationView() {
|
||||
@@ -70,19 +102,23 @@ func (s *ConfigurationSuite) TestConfigurationView() {
|
||||
s.Nilf(err, "invalid tool result content %v", err)
|
||||
})
|
||||
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)
|
||||
s.Lenf(decoded.Contexts, 11, "invalid context count, expected 12, got %v", len(decoded.Contexts))
|
||||
s.Equalf("cluster-0", decoded.Contexts[0].Name, "cluster-0 not found: %v", decoded.Contexts)
|
||||
s.Equalf("cluster-0-cluster", decoded.Contexts[0].Context.Cluster, "cluster-0-cluster not found: %v", decoded.Contexts)
|
||||
s.Equalf("cluster-0-auth", decoded.Contexts[0].Context.AuthInfo, "cluster-0-auth not found: %v", decoded.Contexts)
|
||||
s.Equalf("fake", decoded.Contexts[10].Context.Cluster, "fake not found: %v", decoded.Contexts)
|
||||
s.Equalf("fake", decoded.Contexts[10].Context.AuthInfo, "fake not found: %v", decoded.Contexts)
|
||||
s.Equalf("fake-context", decoded.Contexts[10].Name, "fake-context 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)
|
||||
s.Lenf(decoded.Clusters, 11, "invalid cluster count, expected 2, got %v", len(decoded.Clusters))
|
||||
s.Equalf("cluster-0-cluster", decoded.Clusters[0].Name, "cluster-0-cluster not found: %v", decoded.Clusters)
|
||||
s.Equalf("fake", decoded.Clusters[10].Name, "fake not found: %v", decoded.Clusters)
|
||||
})
|
||||
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)
|
||||
s.Lenf(decoded.AuthInfos, 11, "invalid auth info count, expected 2, got %v", len(decoded.AuthInfos))
|
||||
s.Equalf("cluster-0-auth", decoded.AuthInfos[0].Name, "cluster-0-auth not found: %v", decoded.AuthInfos)
|
||||
s.Equalf("fake", decoded.AuthInfos[10].Name, "fake not found: %v", decoded.AuthInfos)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -109,11 +145,11 @@ func (s *ConfigurationSuite) TestConfigurationViewInCluster() {
|
||||
s.Nilf(err, "invalid tool result content %v", err)
|
||||
})
|
||||
s.Run("returns current-context", func() {
|
||||
s.Equalf("context", decoded.CurrentContext, "context not found: %v", decoded.CurrentContext)
|
||||
s.Equalf("in-cluster", decoded.CurrentContext, "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("context", decoded.Contexts[0].Name, "context not found: %v", decoded.Contexts)
|
||||
s.Equalf("in-cluster", 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)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
type EventsSuite struct {
|
||||
@@ -24,7 +26,7 @@ func (s *EventsSuite) TestEventsList() {
|
||||
s.Falsef(toolResult.IsError, "call tool failed")
|
||||
})
|
||||
s.Run("returns no events message", func() {
|
||||
s.Equal("No events found", toolResult.Content[0].(mcp.TextContent).Text)
|
||||
s.Equal("# No events found", toolResult.Content[0].(mcp.TextContent).Text)
|
||||
})
|
||||
})
|
||||
s.Run("events_list (with events)", func() {
|
||||
@@ -50,8 +52,16 @@ func (s *EventsSuite) TestEventsList() {
|
||||
s.Nilf(err, "call tool failed %v", err)
|
||||
s.Falsef(toolResult.IsError, "call tool failed")
|
||||
})
|
||||
s.Run("has yaml comment indicating output format", func() {
|
||||
s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "# The following events (YAML format) were found:\n"), "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
|
||||
})
|
||||
var decoded []v1.Event
|
||||
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
|
||||
s.Run("has yaml content", func() {
|
||||
s.Nilf(err, "unmarshal failed %v", err)
|
||||
})
|
||||
s.Run("returns all events", func() {
|
||||
s.Equalf("The following events (YAML format) were found:\n"+
|
||||
s.YAMLEqf(""+
|
||||
"- InvolvedObject:\n"+
|
||||
" Kind: Pod\n"+
|
||||
" Name: a-pod\n"+
|
||||
@@ -83,8 +93,16 @@ func (s *EventsSuite) TestEventsList() {
|
||||
s.Nilf(err, "call tool failed %v", err)
|
||||
s.Falsef(toolResult.IsError, "call tool failed")
|
||||
})
|
||||
s.Run("has yaml comment indicating output format", func() {
|
||||
s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "# The following events (YAML format) were found:\n"), "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
|
||||
})
|
||||
var decoded []v1.Event
|
||||
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
|
||||
s.Run("has yaml content", func() {
|
||||
s.Nilf(err, "unmarshal failed %v", err)
|
||||
})
|
||||
s.Run("returns events from namespace", func() {
|
||||
s.Equalf("The following events (YAML format) were found:\n"+
|
||||
s.YAMLEqf(""+
|
||||
"- InvolvedObject:\n"+
|
||||
" Kind: Pod\n"+
|
||||
" Name: a-pod\n"+
|
||||
|
||||
@@ -39,10 +39,19 @@ func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.S
|
||||
m3labTool.RawInputSchema = schema
|
||||
}
|
||||
m3labHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
k, err := s.k.Derived(ctx)
|
||||
// get the correct internalk8s.Manager for the target specified in the request
|
||||
cluster := request.GetString(s.p.GetTargetParameterName(), s.p.GetDefaultTarget())
|
||||
m, err := s.p.GetManagerFor(ctx, cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// derive the manager based on auth on top of the settings for the cluster
|
||||
k, err := m.Derived(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := tool.Handler(api.ToolHandlerParams{
|
||||
Context: ctx,
|
||||
Kubernetes: k,
|
||||
|
||||
@@ -48,16 +48,16 @@ func (c *Configuration) ListOutput() output.Output {
|
||||
}
|
||||
|
||||
func (c *Configuration) isToolApplicable(tool api.ServerTool) bool {
|
||||
if c.StaticConfig.ReadOnly && !ptr.Deref(tool.Tool.Annotations.ReadOnlyHint, false) {
|
||||
if c.ReadOnly && !ptr.Deref(tool.Tool.Annotations.ReadOnlyHint, false) {
|
||||
return false
|
||||
}
|
||||
if c.StaticConfig.DisableDestructive && ptr.Deref(tool.Tool.Annotations.DestructiveHint, false) {
|
||||
if c.DisableDestructive && ptr.Deref(tool.Tool.Annotations.DestructiveHint, false) {
|
||||
return false
|
||||
}
|
||||
if c.StaticConfig.EnabledTools != nil && !slices.Contains(c.StaticConfig.EnabledTools, tool.Tool.Name) {
|
||||
if c.EnabledTools != nil && !slices.Contains(c.EnabledTools, tool.Tool.Name) {
|
||||
return false
|
||||
}
|
||||
if c.StaticConfig.DisabledTools != nil && slices.Contains(c.StaticConfig.DisabledTools, tool.Tool.Name) {
|
||||
if c.DisabledTools != nil && slices.Contains(c.DisabledTools, tool.Tool.Name) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -67,7 +67,7 @@ type Server struct {
|
||||
configuration *Configuration
|
||||
server *server.MCPServer
|
||||
enabledTools []string
|
||||
k *internalk8s.Manager
|
||||
p internalk8s.ManagerProvider
|
||||
}
|
||||
|
||||
func NewServer(configuration Configuration) (*Server, error) {
|
||||
@@ -79,7 +79,7 @@ func NewServer(configuration Configuration) (*Server, error) {
|
||||
server.WithLogging(),
|
||||
server.WithToolHandlerMiddleware(toolCallLoggingMiddleware),
|
||||
)
|
||||
if configuration.StaticConfig.RequireOAuth && false { // TODO: Disabled scope auth validation for now
|
||||
if configuration.RequireOAuth && false { // TODO: Disabled scope auth validation for now
|
||||
serverOptions = append(serverOptions, server.WithToolHandlerMiddleware(toolScopedAuthorizationMiddleware))
|
||||
}
|
||||
|
||||
@@ -91,26 +91,57 @@ func NewServer(configuration Configuration) (*Server, error) {
|
||||
serverOptions...,
|
||||
),
|
||||
}
|
||||
if err := s.reloadKubernetesClient(); err != nil {
|
||||
if err := s.reloadKubernetesClusterProvider(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.k.WatchKubeConfig(s.reloadKubernetesClient)
|
||||
s.p.WatchTargets(s.reloadKubernetesClusterProvider)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) reloadKubernetesClient() error {
|
||||
k, err := internalk8s.NewManager(s.configuration.StaticConfig)
|
||||
func (s *Server) reloadKubernetesClusterProvider() error {
|
||||
ctx := context.Background()
|
||||
p, err := internalk8s.NewManagerProvider(s.configuration.StaticConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.k = k
|
||||
|
||||
// close the old provider
|
||||
if s.p != nil {
|
||||
s.p.Close()
|
||||
}
|
||||
|
||||
s.p = p
|
||||
|
||||
k, err := s.p.GetManagerFor(ctx, s.p.GetDefaultTarget())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targets, err := p.GetTargets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filter := CompositeFilter(
|
||||
s.configuration.isToolApplicable,
|
||||
ShouldIncludeTargetListTool(p.GetTargetParameterName(), targets),
|
||||
)
|
||||
|
||||
mutator := WithTargetParameter(
|
||||
p.GetDefaultTarget(),
|
||||
p.GetTargetParameterName(),
|
||||
targets,
|
||||
)
|
||||
|
||||
applicableTools := make([]api.ServerTool, 0)
|
||||
for _, toolset := range s.configuration.Toolsets() {
|
||||
for _, tool := range toolset.GetTools(s.k) {
|
||||
if !s.configuration.isToolApplicable(tool) {
|
||||
for _, tool := range toolset.GetTools(k) {
|
||||
tool := mutator(tool)
|
||||
if !filter(tool) {
|
||||
continue
|
||||
}
|
||||
|
||||
applicableTools = append(applicableTools, tool)
|
||||
s.enabledTools = append(s.enabledTools, tool.Tool.Name)
|
||||
}
|
||||
@@ -119,7 +150,11 @@ func (s *Server) reloadKubernetesClient() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert tools: %v", err)
|
||||
}
|
||||
|
||||
s.server.SetTools(m3labsServerTools...)
|
||||
|
||||
// start new watch
|
||||
s.p.WatchTargets(s.reloadKubernetesClusterProvider)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -146,20 +181,32 @@ func (s *Server) ServeHTTP(httpServer *http.Server) *server.StreamableHTTPServer
|
||||
}
|
||||
|
||||
// KubernetesApiVerifyToken verifies the given token with the audience by
|
||||
// sending an TokenReview request to API Server.
|
||||
func (s *Server) KubernetesApiVerifyToken(ctx context.Context, token string, audience string) (*authenticationapiv1.UserInfo, []string, error) {
|
||||
if s.k == nil {
|
||||
return nil, nil, fmt.Errorf("kubernetes manager is not initialized")
|
||||
// sending an TokenReview request to API Server for the specified cluster.
|
||||
func (s *Server) KubernetesApiVerifyToken(ctx context.Context, token string, audience string, cluster string) (*authenticationapiv1.UserInfo, []string, error) {
|
||||
if s.p == nil {
|
||||
return nil, nil, fmt.Errorf("kubernetes cluster provider is not initialized")
|
||||
}
|
||||
return s.k.VerifyToken(ctx, token, audience)
|
||||
|
||||
// Use provided cluster or default
|
||||
if cluster == "" {
|
||||
cluster = s.p.GetDefaultTarget()
|
||||
}
|
||||
|
||||
// Get the cluster manager for the specified cluster
|
||||
m, err := s.p.GetManagerFor(ctx, cluster)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return m.VerifyToken(ctx, token, audience)
|
||||
}
|
||||
|
||||
// GetKubernetesAPIServerHost returns the Kubernetes API server host from the configuration.
|
||||
func (s *Server) GetKubernetesAPIServerHost() string {
|
||||
if s.k == nil {
|
||||
return ""
|
||||
// GetTargetParameterName returns the parameter name used for target identification in MCP requests
|
||||
func (s *Server) GetTargetParameterName() string {
|
||||
if s.p == nil {
|
||||
return "" // fallback for uninitialized provider
|
||||
}
|
||||
return s.k.GetAPIServerHost()
|
||||
return s.p.GetTargetParameterName()
|
||||
}
|
||||
|
||||
func (s *Server) GetEnabledTools() []string {
|
||||
@@ -167,8 +214,8 @@ func (s *Server) GetEnabledTools() []string {
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
if s.k != nil {
|
||||
s.k.Close()
|
||||
if s.p != nil {
|
||||
s.p.Close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
680
pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
vendored
Normal file
680
pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
vendored
Normal file
@@ -0,0 +1,680 @@
|
||||
[
|
||||
{
|
||||
"annotations": {
|
||||
"title": "Configuration: Contexts List",
|
||||
"readOnlyHint": true,
|
||||
"destructiveHint": false,
|
||||
"idempotentHint": true,
|
||||
"openWorldHint": false
|
||||
},
|
||||
"description": "List all available context names and associated server urls from the kubeconfig file",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
},
|
||||
"name": "configuration_contexts_list"
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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",
|
||||
"properties": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"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": "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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"enum": [
|
||||
"extra-cluster",
|
||||
"fake-context"
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
612
pkg/mcp/testdata/toolsets-full-tools-multicluster.json
vendored
Normal file
612
pkg/mcp/testdata/toolsets-full-tools-multicluster.json
vendored
Normal file
@@ -0,0 +1,612 @@
|
||||
[
|
||||
{
|
||||
"annotations": {
|
||||
"title": "Configuration: Contexts List",
|
||||
"readOnlyHint": true,
|
||||
"destructiveHint": false,
|
||||
"idempotentHint": true,
|
||||
"openWorldHint": false
|
||||
},
|
||||
"description": "List all available context names and associated server urls from the kubeconfig file",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
},
|
||||
"name": "configuration_contexts_list"
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
},
|
||||
"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",
|
||||
"properties": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"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": "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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
},
|
||||
"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": {
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"type": "string"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"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"
|
||||
},
|
||||
"context": {
|
||||
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
41
pkg/mcp/tool_filter.go
Normal file
41
pkg/mcp/tool_filter.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/api"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
|
||||
)
|
||||
|
||||
// ToolFilter is a function that takes a ServerTool and returns a boolean indicating whether to include the tool
|
||||
type ToolFilter func(tool api.ServerTool) bool
|
||||
|
||||
func CompositeFilter(filters ...ToolFilter) ToolFilter {
|
||||
return func(tool api.ServerTool) bool {
|
||||
for _, f := range filters {
|
||||
if !f(tool) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldIncludeTargetListTool(targetName string, targets []string) ToolFilter {
|
||||
return func(tool api.ServerTool) bool {
|
||||
if !tool.IsTargetListProvider() {
|
||||
return true
|
||||
}
|
||||
if len(targets) <= 1 {
|
||||
// there is no need to provide a tool to list the single available target
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: this check should be removed or make more generic when we have other
|
||||
if tool.Tool.Name == "configuration_contexts_list" && targetName != kubernetes.KubeConfigTargetParameterName {
|
||||
// let's not include configuration_contexts_list if we aren't targeting contexts in our ManagerProvider
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
84
pkg/mcp/tool_filter_test.go
Normal file
84
pkg/mcp/tool_filter_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/api"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"k8s.io/utils/ptr"
|
||||
)
|
||||
|
||||
type ToolFilterSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (s *ToolFilterSuite) TestToolFilterType() {
|
||||
s.Run("ToolFilter type can be used as function", func() {
|
||||
var mutator ToolFilter = func(tool api.ServerTool) bool {
|
||||
return tool.Tool.Name == "included"
|
||||
}
|
||||
s.Run("returns true for included tool", func() {
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "included"}}
|
||||
s.True(mutator(tool))
|
||||
})
|
||||
s.Run("returns false for excluded tool", func() {
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "excluded"}}
|
||||
s.False(mutator(tool))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ToolFilterSuite) TestCompositeFilter() {
|
||||
s.Run("returns true if all filters return true", func() {
|
||||
filter := CompositeFilter(
|
||||
func(tool api.ServerTool) bool { return true },
|
||||
func(tool api.ServerTool) bool { return true },
|
||||
)
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "test"}}
|
||||
s.True(filter(tool))
|
||||
})
|
||||
s.Run("returns false if any filter returns false", func() {
|
||||
filter := CompositeFilter(
|
||||
func(tool api.ServerTool) bool { return true },
|
||||
func(tool api.ServerTool) bool { return false },
|
||||
)
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "test"}}
|
||||
s.False(filter(tool))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ToolFilterSuite) TestShouldIncludeTargetListTool() {
|
||||
s.Run("non-target-list-provider tools: returns true ", func() {
|
||||
filter := ShouldIncludeTargetListTool("any", []string{"a", "b", "c", "d", "e", "f"})
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "test"}, TargetListProvider: ptr.To(false)}
|
||||
s.True(filter(tool))
|
||||
})
|
||||
s.Run("target-list-provider tools", func() {
|
||||
s.Run("with targets == 1: returns false", func() {
|
||||
filter := ShouldIncludeTargetListTool("any", []string{"1"})
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "test"}, TargetListProvider: ptr.To(true)}
|
||||
s.False(filter(tool))
|
||||
})
|
||||
s.Run("with targets == 1", func() {
|
||||
s.Run("and tool is configuration_contexts_list and targetName is not context: returns false", func() {
|
||||
filter := ShouldIncludeTargetListTool("not_context", []string{"1"})
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "configuration_contexts_list"}, TargetListProvider: ptr.To(true)}
|
||||
s.False(filter(tool))
|
||||
})
|
||||
s.Run("and tool is configuration_contexts_list and targetName is context: returns false", func() {
|
||||
filter := ShouldIncludeTargetListTool("context", []string{"1"})
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "configuration_contexts_list"}, TargetListProvider: ptr.To(true)}
|
||||
s.False(filter(tool))
|
||||
})
|
||||
s.Run("and tool is not configuration_contexts_list: returns false", func() {
|
||||
filter := ShouldIncludeTargetListTool("any", []string{"1"})
|
||||
tool := api.ServerTool{Tool: api.Tool{Name: "other_tool"}, TargetListProvider: ptr.To(true)}
|
||||
s.False(filter(tool))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolFilter(t *testing.T) {
|
||||
suite.Run(t, new(ToolFilterSuite))
|
||||
}
|
||||
64
pkg/mcp/tool_mutator.go
Normal file
64
pkg/mcp/tool_mutator.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/api"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
)
|
||||
|
||||
type ToolMutator func(tool api.ServerTool) api.ServerTool
|
||||
|
||||
const maxTargetsInEnum = 5 // TODO: test and validate that this is a reasonable cutoff
|
||||
|
||||
// WithTargetParameter adds a target selection parameter to the tool's input schema if the tool is cluster-aware
|
||||
func WithTargetParameter(defaultCluster, targetParameterName string, targets []string) ToolMutator {
|
||||
return func(tool api.ServerTool) api.ServerTool {
|
||||
if !tool.IsClusterAware() {
|
||||
return tool
|
||||
}
|
||||
|
||||
if tool.Tool.InputSchema == nil {
|
||||
tool.Tool.InputSchema = &jsonschema.Schema{Type: "object"}
|
||||
}
|
||||
|
||||
if tool.Tool.InputSchema.Properties == nil {
|
||||
tool.Tool.InputSchema.Properties = make(map[string]*jsonschema.Schema)
|
||||
}
|
||||
|
||||
if len(targets) > 1 {
|
||||
tool.Tool.InputSchema.Properties[targetParameterName] = createTargetProperty(
|
||||
defaultCluster,
|
||||
targetParameterName,
|
||||
targets,
|
||||
)
|
||||
}
|
||||
|
||||
return tool
|
||||
}
|
||||
}
|
||||
|
||||
func createTargetProperty(defaultCluster, targetName string, targets []string) *jsonschema.Schema {
|
||||
baseSchema := &jsonschema.Schema{
|
||||
Type: "string",
|
||||
Description: fmt.Sprintf(
|
||||
"Optional parameter selecting which %s to run the tool in. Defaults to %s if not set",
|
||||
targetName,
|
||||
defaultCluster,
|
||||
),
|
||||
}
|
||||
|
||||
if len(targets) <= maxTargetsInEnum {
|
||||
// Sort clusters to ensure consistent enum ordering
|
||||
sort.Strings(targets)
|
||||
|
||||
enumValues := make([]any, 0, len(targets))
|
||||
for _, c := range targets {
|
||||
enumValues = append(enumValues, c)
|
||||
}
|
||||
baseSchema.Enum = enumValues
|
||||
}
|
||||
|
||||
return baseSchema
|
||||
}
|
||||
347
pkg/mcp/tool_mutator_test.go
Normal file
347
pkg/mcp/tool_mutator_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/api"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"k8s.io/utils/ptr"
|
||||
)
|
||||
|
||||
// createTestTool creates a basic ServerTool for testing
|
||||
func createTestTool(name string) api.ServerTool {
|
||||
return api.ServerTool{
|
||||
Tool: api.Tool{
|
||||
Name: name,
|
||||
Description: "A test tool",
|
||||
InputSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: make(map[string]*jsonschema.Schema),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createTestToolWithNilSchema creates a ServerTool with nil InputSchema for testing
|
||||
func createTestToolWithNilSchema(name string) api.ServerTool {
|
||||
return api.ServerTool{
|
||||
Tool: api.Tool{
|
||||
Name: name,
|
||||
Description: "A test tool",
|
||||
InputSchema: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createTestToolWithNilProperties creates a ServerTool with nil Properties for testing
|
||||
func createTestToolWithNilProperties(name string) api.ServerTool {
|
||||
return api.ServerTool{
|
||||
Tool: api.Tool{
|
||||
Name: name,
|
||||
Description: "A test tool",
|
||||
InputSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createTestToolWithExistingProperties creates a ServerTool with existing properties for testing
|
||||
func createTestToolWithExistingProperties(name string) api.ServerTool {
|
||||
return api.ServerTool{
|
||||
Tool: api.Tool{
|
||||
Name: name,
|
||||
Description: "A test tool",
|
||||
InputSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"existing-prop": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithClusterParameter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultCluster string
|
||||
targetParameterName string
|
||||
clusters []string
|
||||
toolName string
|
||||
toolFactory func(string) api.ServerTool
|
||||
expectCluster bool
|
||||
expectEnum bool
|
||||
enumCount int
|
||||
}{
|
||||
{
|
||||
name: "adds cluster parameter when multiple clusters provided",
|
||||
defaultCluster: "default-cluster",
|
||||
clusters: []string{"cluster1", "cluster2", "cluster3"},
|
||||
toolName: "test-tool",
|
||||
toolFactory: createTestTool,
|
||||
expectCluster: true,
|
||||
expectEnum: true,
|
||||
enumCount: 3,
|
||||
},
|
||||
{
|
||||
name: "does not add cluster parameter when single cluster provided",
|
||||
defaultCluster: "default-cluster",
|
||||
clusters: []string{"single-cluster"},
|
||||
toolName: "test-tool",
|
||||
toolFactory: createTestTool,
|
||||
expectCluster: false,
|
||||
expectEnum: false,
|
||||
enumCount: 0,
|
||||
},
|
||||
{
|
||||
name: "creates InputSchema when nil",
|
||||
defaultCluster: "default-cluster",
|
||||
clusters: []string{"cluster1", "cluster2"},
|
||||
toolName: "test-tool",
|
||||
toolFactory: createTestToolWithNilSchema,
|
||||
expectCluster: true,
|
||||
expectEnum: true,
|
||||
enumCount: 2,
|
||||
},
|
||||
{
|
||||
name: "creates Properties map when nil",
|
||||
defaultCluster: "default-cluster",
|
||||
clusters: []string{"cluster1", "cluster2"},
|
||||
toolName: "test-tool",
|
||||
toolFactory: createTestToolWithNilProperties,
|
||||
expectCluster: true,
|
||||
expectEnum: true,
|
||||
enumCount: 2,
|
||||
},
|
||||
{
|
||||
name: "preserves existing properties",
|
||||
defaultCluster: "default-cluster",
|
||||
clusters: []string{"cluster1", "cluster2"},
|
||||
toolName: "test-tool",
|
||||
toolFactory: createTestToolWithExistingProperties,
|
||||
expectCluster: true,
|
||||
expectEnum: true,
|
||||
enumCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.targetParameterName == "" {
|
||||
tt.targetParameterName = "cluster"
|
||||
}
|
||||
mutator := WithTargetParameter(tt.defaultCluster, tt.targetParameterName, tt.clusters)
|
||||
tool := tt.toolFactory(tt.toolName)
|
||||
originalTool := tool // Keep reference to check if tool was unchanged
|
||||
|
||||
result := mutator(tool)
|
||||
|
||||
if !tt.expectCluster {
|
||||
if tt.toolName == "skip-this-tool" {
|
||||
// For skipped tools, the entire tool should be unchanged
|
||||
assert.Equal(t, originalTool, result)
|
||||
} else {
|
||||
// For single cluster, schema should exist but no cluster property
|
||||
require.NotNil(t, result.Tool.InputSchema)
|
||||
require.NotNil(t, result.Tool.InputSchema.Properties)
|
||||
_, exists := result.Tool.InputSchema.Properties["cluster"]
|
||||
assert.False(t, exists, "cluster property should not exist")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Common assertions for cases where cluster parameter should be added
|
||||
require.NotNil(t, result.Tool.InputSchema)
|
||||
assert.Equal(t, "object", result.Tool.InputSchema.Type)
|
||||
require.NotNil(t, result.Tool.InputSchema.Properties)
|
||||
|
||||
clusterProperty, exists := result.Tool.InputSchema.Properties["cluster"]
|
||||
assert.True(t, exists, "cluster property should exist")
|
||||
assert.NotNil(t, clusterProperty)
|
||||
assert.Equal(t, "string", clusterProperty.Type)
|
||||
assert.Contains(t, clusterProperty.Description, tt.defaultCluster)
|
||||
|
||||
if tt.expectEnum {
|
||||
assert.NotNil(t, clusterProperty.Enum)
|
||||
assert.Equal(t, tt.enumCount, len(clusterProperty.Enum))
|
||||
for _, cluster := range tt.clusters {
|
||||
assert.Contains(t, clusterProperty.Enum, cluster)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClusterProperty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultCluster string
|
||||
targetName string
|
||||
clusters []string
|
||||
expectEnum bool
|
||||
expectedCount int
|
||||
}{
|
||||
{
|
||||
name: "creates property with enum when clusters <= maxClustersInEnum",
|
||||
defaultCluster: "default",
|
||||
targetName: "cluster",
|
||||
clusters: []string{"cluster1", "cluster2", "cluster3"},
|
||||
expectEnum: true,
|
||||
expectedCount: 3,
|
||||
},
|
||||
{
|
||||
name: "creates property without enum when clusters > maxClustersInEnum",
|
||||
defaultCluster: "default",
|
||||
targetName: "cluster",
|
||||
clusters: make([]string, maxTargetsInEnum+5), // 20 clusters
|
||||
expectEnum: false,
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "creates property with exact maxClustersInEnum clusters",
|
||||
defaultCluster: "default",
|
||||
targetName: "cluster",
|
||||
clusters: make([]string, maxTargetsInEnum),
|
||||
expectEnum: true,
|
||||
expectedCount: maxTargetsInEnum,
|
||||
},
|
||||
{
|
||||
name: "handles single cluster",
|
||||
defaultCluster: "default",
|
||||
targetName: "cluster",
|
||||
clusters: []string{"single-cluster"},
|
||||
expectEnum: true,
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "handles empty clusters list",
|
||||
defaultCluster: "default",
|
||||
targetName: "cluster",
|
||||
clusters: []string{},
|
||||
expectEnum: true,
|
||||
expectedCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Initialize clusters with names if they were created with make()
|
||||
if len(tt.clusters) > 3 && tt.clusters[0] == "" {
|
||||
for i := range tt.clusters {
|
||||
tt.clusters[i] = "cluster" + string(rune('A'+i))
|
||||
}
|
||||
}
|
||||
|
||||
property := createTargetProperty(tt.defaultCluster, tt.targetName, tt.clusters)
|
||||
|
||||
assert.Equal(t, "string", property.Type)
|
||||
assert.Contains(t, property.Description, tt.defaultCluster)
|
||||
assert.Contains(t, property.Description, "Defaults to "+tt.defaultCluster+" if not set")
|
||||
|
||||
if tt.expectEnum {
|
||||
assert.NotNil(t, property.Enum, "enum should be created")
|
||||
assert.Equal(t, tt.expectedCount, len(property.Enum))
|
||||
if tt.expectedCount > 0 && tt.expectedCount <= 3 {
|
||||
// Only check specific values for small, predefined lists
|
||||
for _, cluster := range tt.clusters {
|
||||
assert.Contains(t, property.Enum, cluster)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.Nil(t, property.Enum, "enum should not be created for too many clusters")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolMutatorType(t *testing.T) {
|
||||
t.Run("ToolMutator type can be used as function", func(t *testing.T) {
|
||||
var mutator ToolMutator = func(tool api.ServerTool) api.ServerTool {
|
||||
tool.Tool.Name = "modified-" + tool.Tool.Name
|
||||
return tool
|
||||
}
|
||||
|
||||
originalTool := createTestTool("original")
|
||||
result := mutator(originalTool)
|
||||
assert.Equal(t, "modified-original", result.Tool.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMaxClustersInEnumConstant(t *testing.T) {
|
||||
t.Run("maxClustersInEnum has expected value", func(t *testing.T) {
|
||||
assert.Equal(t, 5, maxTargetsInEnum, "maxClustersInEnum should be 5")
|
||||
})
|
||||
}
|
||||
|
||||
type TargetParameterToolMutatorSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (s *TargetParameterToolMutatorSuite) TestClusterAwareTool() {
|
||||
tm := WithTargetParameter("default-cluster", "cluster", []string{"cluster-1", "cluster-2", "cluster-3"})
|
||||
tool := createTestTool("cluster-aware-tool")
|
||||
// Tools are cluster-aware by default
|
||||
tm(tool)
|
||||
s.Require().NotNil(tool.Tool.InputSchema.Properties)
|
||||
s.Run("adds cluster parameter", func() {
|
||||
s.NotNil(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to be added")
|
||||
})
|
||||
s.Run("adds correct description", func() {
|
||||
desc := tool.Tool.InputSchema.Properties["cluster"].Description
|
||||
s.Contains(desc, "Optional parameter selecting which cluster to run the tool in", "Expected description to mention cluster selection")
|
||||
s.Contains(desc, "Defaults to default-cluster if not set", "Expected description to mention default cluster")
|
||||
})
|
||||
s.Run("adds enum with clusters", func() {
|
||||
s.Require().NotNil(tool.Tool.InputSchema.Properties["cluster"])
|
||||
enum := tool.Tool.InputSchema.Properties["cluster"].Enum
|
||||
s.NotNilf(enum, "Expected enum to be set")
|
||||
s.Equal(3, len(enum), "Expected enum to have 3 entries")
|
||||
s.Contains(enum, "cluster-1", "Expected enum to contain cluster-1")
|
||||
s.Contains(enum, "cluster-2", "Expected enum to contain cluster-2")
|
||||
s.Contains(enum, "cluster-3", "Expected enum to contain cluster-3")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TargetParameterToolMutatorSuite) TestClusterAwareToolSingleCluster() {
|
||||
tm := WithTargetParameter("default", "cluster", []string{"only-cluster"})
|
||||
tool := createTestTool("cluster-aware-tool-single-cluster")
|
||||
// Tools are cluster-aware by default
|
||||
tm(tool)
|
||||
s.Run("does not add cluster parameter for single cluster", func() {
|
||||
s.Nilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to not be added for single cluster")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TargetParameterToolMutatorSuite) TestClusterAwareToolMultipleClusters() {
|
||||
tm := WithTargetParameter("default", "cluster", []string{"cluster-1", "cluster-2", "cluster-3", "cluster-4", "cluster-5", "cluster-6"})
|
||||
tool := createTestTool("cluster-aware-tool-multiple-clusters")
|
||||
// Tools are cluster-aware by default
|
||||
tm(tool)
|
||||
s.Run("adds cluster parameter", func() {
|
||||
s.NotNilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to be added")
|
||||
})
|
||||
s.Run("does not add enum when list of clusters is > 5", func() {
|
||||
s.Require().NotNil(tool.Tool.InputSchema.Properties["cluster"])
|
||||
enum := tool.Tool.InputSchema.Properties["cluster"].Enum
|
||||
s.Nilf(enum, "Expected enum to not be set for too many clusters")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TargetParameterToolMutatorSuite) TestNonClusterAwareTool() {
|
||||
tm := WithTargetParameter("default", "cluster", []string{"cluster-1", "cluster-2"})
|
||||
tool := createTestTool("non-cluster-aware-tool")
|
||||
tool.ClusterAware = ptr.To(false)
|
||||
tm(tool)
|
||||
s.Run("does not add cluster parameter", func() {
|
||||
s.Nilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to not be added")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTargetParameterToolMutator(t *testing.T) {
|
||||
suite.Run(t, new(TargetParameterToolMutatorSuite))
|
||||
}
|
||||
@@ -2,11 +2,9 @@ package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"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"
|
||||
@@ -14,6 +12,9 @@ import (
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
|
||||
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/stretchr/testify/suite"
|
||||
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
type ToolsetsSuite struct {
|
||||
@@ -29,7 +30,7 @@ func (s *ToolsetsSuite) SetupTest() {
|
||||
s.originalToolsets = toolsets.Toolsets()
|
||||
s.MockServer = test.NewMockServer()
|
||||
s.Cfg = configuration.Default()
|
||||
s.Cfg.KubeConfig = s.MockServer.KubeconfigFile(s.T())
|
||||
s.Cfg.KubeConfig = s.KubeconfigFile(s.T())
|
||||
}
|
||||
|
||||
func (s *ToolsetsSuite) TearDownTest() {
|
||||
@@ -98,6 +99,50 @@ func (s *ToolsetsSuite) TestDefaultToolsetsToolsInOpenShift() {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ToolsetsSuite) TestDefaultToolsetsToolsInMultiCluster() {
|
||||
s.Run("Default configuration toolsets in multi-cluster (with 11 clusters)", func() {
|
||||
kubeconfig := s.Kubeconfig()
|
||||
for i := 0; i < 10; i++ {
|
||||
// Add multiple fake contexts to force multi-cluster behavior
|
||||
kubeconfig.Contexts[strconv.Itoa(i)] = clientcmdapi.NewContext()
|
||||
}
|
||||
s.Cfg.KubeConfig = test.KubeconfigFile(s.T(), kubeconfig)
|
||||
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-multicluster.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) TestDefaultToolsetsToolsInMultiClusterEnum() {
|
||||
s.Run("Default configuration toolsets in multi-cluster (with 2 clusters)", func() {
|
||||
kubeconfig := s.Kubeconfig()
|
||||
// Add additional cluster to force multi-cluster behavior with enum parameter
|
||||
kubeconfig.Contexts["extra-cluster"] = clientcmdapi.NewContext()
|
||||
s.Cfg.KubeConfig = test.KubeconfigFile(s.T(), kubeconfig)
|
||||
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-multicluster-enum.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{},
|
||||
|
||||
@@ -12,33 +12,91 @@ import (
|
||||
|
||||
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)",
|
||||
},
|
||||
{
|
||||
Tool: api.Tool{
|
||||
Name: "configuration_contexts_list",
|
||||
Description: "List all available context names and associated server urls from the kubeconfig file",
|
||||
InputSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
},
|
||||
Annotations: api.ToolAnnotations{
|
||||
Title: "Configuration: Contexts List",
|
||||
ReadOnlyHint: ptr.To(true),
|
||||
DestructiveHint: ptr.To(false),
|
||||
IdempotentHint: ptr.To(true),
|
||||
OpenWorldHint: ptr.To(false),
|
||||
},
|
||||
},
|
||||
Annotations: api.ToolAnnotations{
|
||||
Title: "Configuration: View",
|
||||
ReadOnlyHint: ptr.To(true),
|
||||
DestructiveHint: ptr.To(false),
|
||||
IdempotentHint: ptr.To(false),
|
||||
OpenWorldHint: ptr.To(true),
|
||||
ClusterAware: ptr.To(false),
|
||||
TargetListProvider: ptr.To(true),
|
||||
Handler: contextsList,
|
||||
},
|
||||
{
|
||||
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},
|
||||
ClusterAware: ptr.To(false),
|
||||
Handler: configurationView,
|
||||
},
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func contextsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
|
||||
contexts, err := params.ConfigurationContextsList()
|
||||
if err != nil {
|
||||
return api.NewToolCallResult("", fmt.Errorf("failed to list contexts: %v", err)), nil
|
||||
}
|
||||
|
||||
if len(contexts) == 0 {
|
||||
return api.NewToolCallResult("No contexts found in kubeconfig", nil), nil
|
||||
}
|
||||
|
||||
defaultContext, err := params.ConfigurationContextsDefault()
|
||||
if err != nil {
|
||||
return api.NewToolCallResult("", fmt.Errorf("failed to get default context: %v", err)), nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Available Kubernetes contexts (%d total, default: %s):\n\n", len(contexts), defaultContext)
|
||||
result += "Format: [*] CONTEXT_NAME -> SERVER_URL\n"
|
||||
result += " (* indicates the default context used in tools if context is not set)\n\n"
|
||||
result += "Contexts:\n---------\n"
|
||||
for context, server := range contexts {
|
||||
marker := " "
|
||||
if context == defaultContext {
|
||||
marker = "*"
|
||||
}
|
||||
|
||||
result += fmt.Sprintf("%s%s -> %s\n", marker, context, server)
|
||||
}
|
||||
result += "---------\n\n"
|
||||
|
||||
result += "To use a specific context with any tool, set the 'context' parameter in the tool call arguments"
|
||||
|
||||
// TODO: Review output format, current is not parseable and might not be ideal for LLM consumption
|
||||
return api.NewToolCallResult(result, nil), nil
|
||||
}
|
||||
|
||||
func configurationView(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
|
||||
minify := true
|
||||
minified := params.GetArguments()["minified"]
|
||||
|
||||
@@ -45,11 +45,11 @@ func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
|
||||
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
|
||||
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
|
||||
return api.NewToolCallResult(fmt.Sprintf("# The following events (YAML format) were found:\n%s", yamlEvents), err), nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -52,7 +51,7 @@ func initNamespaces(o internalk8s.Openshift) []api.ServerTool {
|
||||
}
|
||||
|
||||
func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
|
||||
ret, err := params.NamespacesList(params, kubernetes.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
|
||||
ret, err := params.NamespacesList(params, internalk8s.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
|
||||
if err != nil {
|
||||
return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil
|
||||
}
|
||||
@@ -60,7 +59,7 @@ func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
|
||||
}
|
||||
|
||||
func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
|
||||
ret, err := params.ProjectsList(params, kubernetes.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
|
||||
ret, err := params.ProjectsList(params, internalk8s.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
|
||||
if err != nil {
|
||||
return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %v", err)), nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -152,7 +151,7 @@ func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
|
||||
namespace = ""
|
||||
}
|
||||
labelSelector := params.GetArguments()["labelSelector"]
|
||||
resourceListOptions := kubernetes.ResourceListOptions{
|
||||
resourceListOptions := internalk8s.ResourceListOptions{
|
||||
AsTable: params.ListOutput.AsTable(),
|
||||
}
|
||||
|
||||
|
||||
10
vendor/github.com/coreos/go-oidc/v3/oidc/jwks.go
generated
vendored
10
vendor/github.com/coreos/go-oidc/v3/oidc/jwks.go
generated
vendored
@@ -11,7 +11,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
)
|
||||
@@ -57,16 +56,12 @@ func (s *StaticKeySet) VerifySignature(ctx context.Context, jwt string) ([]byte,
|
||||
// The returned KeySet is a long lived verifier that caches keys based on any
|
||||
// keys change. Reuse a common remote key set instead of creating new ones as needed.
|
||||
func NewRemoteKeySet(ctx context.Context, jwksURL string) *RemoteKeySet {
|
||||
return newRemoteKeySet(ctx, jwksURL, time.Now)
|
||||
return newRemoteKeySet(ctx, jwksURL)
|
||||
}
|
||||
|
||||
func newRemoteKeySet(ctx context.Context, jwksURL string, now func() time.Time) *RemoteKeySet {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
func newRemoteKeySet(ctx context.Context, jwksURL string) *RemoteKeySet {
|
||||
return &RemoteKeySet{
|
||||
jwksURL: jwksURL,
|
||||
now: now,
|
||||
// For historical reasons, this package uses contexts for configuration, not just
|
||||
// cancellation. In hindsight, this was a bad idea.
|
||||
//
|
||||
@@ -81,7 +76,6 @@ func newRemoteKeySet(ctx context.Context, jwksURL string, now func() time.Time)
|
||||
// a jwks_uri endpoint.
|
||||
type RemoteKeySet struct {
|
||||
jwksURL string
|
||||
now func() time.Time
|
||||
|
||||
// Used for configuration. Cancelation is ignored.
|
||||
ctx context.Context
|
||||
|
||||
2
vendor/github.com/go-jose/go-jose/v4/README.md
generated
vendored
2
vendor/github.com/go-jose/go-jose/v4/README.md
generated
vendored
@@ -37,7 +37,7 @@ Version 4 is the current stable version:
|
||||
import "github.com/go-jose/go-jose/v4"
|
||||
|
||||
It supports at least the current and previous Golang release. Currently it
|
||||
requires Golang 1.23.
|
||||
requires Golang 1.24.
|
||||
|
||||
Version 3 is only receiving critical security updates. Migration to Version 4 is recommended.
|
||||
|
||||
|
||||
16
vendor/github.com/go-jose/go-jose/v4/crypter.go
generated
vendored
16
vendor/github.com/go-jose/go-jose/v4/crypter.go
generated
vendored
@@ -454,13 +454,9 @@ func (obj JSONWebEncryption) Decrypt(decryptionKey interface{}) ([]byte, error)
|
||||
return nil, errors.New("go-jose/go-jose: too many recipients in payload; expecting only one")
|
||||
}
|
||||
|
||||
critical, err := headers.getCritical()
|
||||
err := headers.checkNoCritical()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid crit header")
|
||||
}
|
||||
|
||||
if len(critical) > 0 {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: unsupported crit header")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := tryJWKS(decryptionKey, obj.Header)
|
||||
@@ -527,13 +523,9 @@ func (obj JSONWebEncryption) Decrypt(decryptionKey interface{}) ([]byte, error)
|
||||
func (obj JSONWebEncryption) DecryptMulti(decryptionKey interface{}) (int, Header, []byte, error) {
|
||||
globalHeaders := obj.mergedHeaders(nil)
|
||||
|
||||
critical, err := globalHeaders.getCritical()
|
||||
err := globalHeaders.checkNoCritical()
|
||||
if err != nil {
|
||||
return -1, Header{}, nil, fmt.Errorf("go-jose/go-jose: invalid crit header")
|
||||
}
|
||||
|
||||
if len(critical) > 0 {
|
||||
return -1, Header{}, nil, fmt.Errorf("go-jose/go-jose: unsupported crit header")
|
||||
return -1, Header{}, nil, err
|
||||
}
|
||||
|
||||
key, err := tryJWKS(decryptionKey, obj.Header)
|
||||
|
||||
34
vendor/github.com/go-jose/go-jose/v4/shared.go
generated
vendored
34
vendor/github.com/go-jose/go-jose/v4/shared.go
generated
vendored
@@ -22,6 +22,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/json"
|
||||
)
|
||||
|
||||
@@ -76,6 +77,9 @@ var (
|
||||
|
||||
// ErrUnsupportedEllipticCurve indicates unsupported or unknown elliptic curve has been found.
|
||||
ErrUnsupportedEllipticCurve = errors.New("go-jose/go-jose: unsupported/unknown elliptic curve")
|
||||
|
||||
// ErrUnsupportedCriticalHeader is returned when a header is marked critical but not supported by go-jose.
|
||||
ErrUnsupportedCriticalHeader = errors.New("go-jose/go-jose: unsupported critical header")
|
||||
)
|
||||
|
||||
// Key management algorithms
|
||||
@@ -166,8 +170,8 @@ const (
|
||||
)
|
||||
|
||||
// supportedCritical is the set of supported extensions that are understood and processed.
|
||||
var supportedCritical = map[string]bool{
|
||||
headerB64: true,
|
||||
var supportedCritical = map[string]struct{}{
|
||||
headerB64: {},
|
||||
}
|
||||
|
||||
// rawHeader represents the JOSE header for JWE/JWS objects (used for parsing).
|
||||
@@ -345,6 +349,32 @@ func (parsed rawHeader) getCritical() ([]string, error) {
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// checkNoCritical verifies there are no critical headers present.
|
||||
func (parsed rawHeader) checkNoCritical() error {
|
||||
if _, ok := parsed[headerCritical]; ok {
|
||||
return ErrUnsupportedCriticalHeader
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSupportedCritical verifies there are no unsupported critical headers.
|
||||
// Supported headers are passed in as a set: map of names to empty structs
|
||||
func (parsed rawHeader) checkSupportedCritical(supported map[string]struct{}) error {
|
||||
crit, err := parsed.getCritical()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, name := range crit {
|
||||
if _, ok := supported[name]; !ok {
|
||||
return ErrUnsupportedCriticalHeader
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getS2C extracts parsed "p2c" from the raw JSON.
|
||||
func (parsed rawHeader) getP2C() (int, error) {
|
||||
v := parsed[headerP2C]
|
||||
|
||||
44
vendor/github.com/go-jose/go-jose/v4/signing.go
generated
vendored
44
vendor/github.com/go-jose/go-jose/v4/signing.go
generated
vendored
@@ -404,15 +404,23 @@ func (obj JSONWebSignature) DetachedVerify(payload []byte, verificationKey inter
|
||||
}
|
||||
|
||||
signature := obj.Signatures[0]
|
||||
headers := signature.mergedHeaders()
|
||||
critical, err := headers.getCritical()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
if signature.header != nil {
|
||||
// Per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.11,
|
||||
// 4.1.11. "crit" (Critical) Header Parameter
|
||||
// "When used, this Header Parameter MUST be integrity
|
||||
// protected; therefore, it MUST occur only within the JWS
|
||||
// Protected Header."
|
||||
err = signature.header.checkNoCritical()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range critical {
|
||||
if !supportedCritical[name] {
|
||||
return ErrCryptoFailure
|
||||
if signature.protected != nil {
|
||||
err = signature.protected.checkSupportedCritical(supportedCritical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +429,7 @@ func (obj JSONWebSignature) DetachedVerify(payload []byte, verificationKey inter
|
||||
return ErrCryptoFailure
|
||||
}
|
||||
|
||||
headers := signature.mergedHeaders()
|
||||
alg := headers.getSignatureAlgorithm()
|
||||
err = verifier.verifyPayload(input, signature.Signature, alg)
|
||||
if err == nil {
|
||||
@@ -469,14 +478,22 @@ func (obj JSONWebSignature) DetachedVerifyMulti(payload []byte, verificationKey
|
||||
|
||||
outer:
|
||||
for i, signature := range obj.Signatures {
|
||||
headers := signature.mergedHeaders()
|
||||
critical, err := headers.getCritical()
|
||||
if err != nil {
|
||||
continue
|
||||
if signature.header != nil {
|
||||
// Per https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.11,
|
||||
// 4.1.11. "crit" (Critical) Header Parameter
|
||||
// "When used, this Header Parameter MUST be integrity
|
||||
// protected; therefore, it MUST occur only within the JWS
|
||||
// Protected Header."
|
||||
err = signature.header.checkNoCritical()
|
||||
if err != nil {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range critical {
|
||||
if !supportedCritical[name] {
|
||||
if signature.protected != nil {
|
||||
// Check for only supported critical headers
|
||||
err = signature.protected.checkSupportedCritical(supportedCritical)
|
||||
if err != nil {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
@@ -486,6 +503,7 @@ outer:
|
||||
continue
|
||||
}
|
||||
|
||||
headers := signature.mergedHeaders()
|
||||
alg := headers.getSignatureAlgorithm()
|
||||
err = verifier.verifyPayload(input, signature.Signature, alg)
|
||||
if err == nil {
|
||||
|
||||
5
vendor/github.com/go-jose/go-jose/v4/symmetric.go
generated
vendored
5
vendor/github.com/go-jose/go-jose/v4/symmetric.go
generated
vendored
@@ -21,6 +21,7 @@ import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
@@ -328,7 +329,7 @@ func (ctx *symmetricKeyCipher) encryptKey(cek []byte, alg KeyAlgorithm) (recipie
|
||||
|
||||
// derive key
|
||||
keyLen, h := getPbkdf2Params(alg)
|
||||
key, err := pbkdf2Key(h, string(ctx.key), salt, ctx.p2c, keyLen)
|
||||
key, err := pbkdf2.Key(h, string(ctx.key), salt, ctx.p2c, keyLen)
|
||||
if err != nil {
|
||||
return recipientInfo{}, nil
|
||||
}
|
||||
@@ -433,7 +434,7 @@ func (ctx *symmetricKeyCipher) decryptKey(headers rawHeader, recipient *recipien
|
||||
|
||||
// derive key
|
||||
keyLen, h := getPbkdf2Params(alg)
|
||||
key, err := pbkdf2Key(h, string(ctx.key), salt, p2c, keyLen)
|
||||
key, err := pbkdf2.Key(h, string(ctx.key), salt, p2c, keyLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
28
vendor/github.com/go-jose/go-jose/v4/symmetric_go124.go
generated
vendored
28
vendor/github.com/go-jose/go-jose/v4/symmetric_go124.go
generated
vendored
@@ -1,28 +0,0 @@
|
||||
//go:build go1.24
|
||||
|
||||
/*-
|
||||
* Copyright 2014 Square Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package jose
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"hash"
|
||||
)
|
||||
|
||||
func pbkdf2Key(h func() hash.Hash, password string, salt []byte, iter, keyLen int) ([]byte, error) {
|
||||
return pbkdf2.Key(h, password, salt, iter, keyLen)
|
||||
}
|
||||
29
vendor/github.com/go-jose/go-jose/v4/symmetric_legacy.go
generated
vendored
29
vendor/github.com/go-jose/go-jose/v4/symmetric_legacy.go
generated
vendored
@@ -1,29 +0,0 @@
|
||||
//go:build !go1.24
|
||||
|
||||
/*-
|
||||
* Copyright 2014 Square Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package jose
|
||||
|
||||
import (
|
||||
"hash"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
func pbkdf2Key(h func() hash.Hash, password string, salt []byte, iter, keyLen int) ([]byte, error) {
|
||||
return pbkdf2.Key([]byte(password), salt, iter, keyLen, h), nil
|
||||
}
|
||||
21
vendor/github.com/mark3labs/mcp-go/client/client.go
generated
vendored
21
vendor/github.com/mark3labs/mcp-go/client/client.go
generated
vendored
@@ -3,7 +3,6 @@ package client
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
@@ -166,7 +165,7 @@ func (c *Client) sendRequest(
|
||||
}
|
||||
|
||||
if response.Error != nil {
|
||||
return nil, errors.New(response.Error.Message)
|
||||
return nil, response.Error.AsError()
|
||||
}
|
||||
|
||||
return &response.Result, nil
|
||||
@@ -524,11 +523,7 @@ func (c *Client) handleSamplingRequestTransport(ctx context.Context, request tra
|
||||
}
|
||||
|
||||
// Create the transport response
|
||||
response := &transport.JSONRPCResponse{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: request.ID,
|
||||
Result: json.RawMessage(resultBytes),
|
||||
}
|
||||
response := transport.NewJSONRPCResultResponse(request.ID, json.RawMessage(resultBytes))
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -572,22 +567,14 @@ func (c *Client) handleElicitationRequestTransport(ctx context.Context, request
|
||||
}
|
||||
|
||||
// Create the transport response
|
||||
response := &transport.JSONRPCResponse{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: request.ID,
|
||||
Result: json.RawMessage(resultBytes),
|
||||
}
|
||||
response := transport.NewJSONRPCResultResponse(request.ID, 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
|
||||
return transport.NewJSONRPCResultResponse(request.ID, b), nil
|
||||
}
|
||||
|
||||
func listByPage[T any](
|
||||
|
||||
2
vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go
generated
vendored
2
vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go
generated
vendored
@@ -82,7 +82,7 @@ func (c *InProcessTransport) SendRequest(ctx context.Context, request JSONRPCReq
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal response message: %w", err)
|
||||
}
|
||||
rpcResp := JSONRPCResponse{}
|
||||
var rpcResp JSONRPCResponse
|
||||
err = json.Unmarshal(respByte, &rpcResp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response message: %w", err)
|
||||
|
||||
15
vendor/github.com/mark3labs/mcp-go/client/transport/interface.go
generated
vendored
15
vendor/github.com/mark3labs/mcp-go/client/transport/interface.go
generated
vendored
@@ -61,13 +61,12 @@ type JSONRPCRequest struct {
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// JSONRPCResponse represents a JSON-RPC 2.0 response message.
|
||||
// Use NewJSONRPCResultResponse to create a JSONRPCResponse with a result.
|
||||
// Use NewJSONRPCErrorResponse to create a JSONRPCResponse with an error.
|
||||
type JSONRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID mcp.RequestId `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
} `json:"error,omitempty"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID mcp.RequestId `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *mcp.JSONRPCErrorDetails `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
7
vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go
generated
vendored
7
vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go
generated
vendored
@@ -371,8 +371,13 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// If we can't get the protected resource metadata, fall back to default endpoints
|
||||
// If we can't get the protected resource metadata, try OAuth Authorization Server discovery
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.fetchMetadataFromURL(ctx, baseURL+"/.well-known/oauth-authorization-server")
|
||||
if h.serverMetadata != nil {
|
||||
return
|
||||
}
|
||||
// If that also fails, fall back to default endpoints
|
||||
metadata, err := h.getDefaultEndpoints(baseURL)
|
||||
if err != nil {
|
||||
h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", err)
|
||||
|
||||
59
vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go
generated
vendored
59
vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go
generated
vendored
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -27,7 +28,7 @@ type Stdio struct {
|
||||
cmd *exec.Cmd
|
||||
cmdFunc CommandFunc
|
||||
stdin io.WriteCloser
|
||||
stdout *bufio.Scanner
|
||||
stdout *bufio.Reader
|
||||
stderr io.ReadCloser
|
||||
responses map[string]chan *JSONRPCResponse
|
||||
mu sync.RWMutex
|
||||
@@ -72,7 +73,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.NewScanner(input),
|
||||
stdout: bufio.NewReader(input),
|
||||
stderr: logging,
|
||||
|
||||
responses: make(map[string]chan *JSONRPCResponse),
|
||||
@@ -180,7 +181,7 @@ func (c *Stdio) spawnCommand(ctx context.Context) error {
|
||||
c.cmd = cmd
|
||||
c.stdin = stdin
|
||||
c.stderr = stderr
|
||||
c.stdout = bufio.NewScanner(stdout)
|
||||
c.stdout = bufio.NewReader(stdout)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start command: %w", err)
|
||||
@@ -251,15 +252,15 @@ func (c *Stdio) readResponses() {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
if !c.stdout.Scan() {
|
||||
err := c.stdout.Err()
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
line, err := c.stdout.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF && !errors.Is(err, context.Canceled) {
|
||||
c.logger.Errorf("Error reading from stdout: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
line := c.stdout.Text()
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
// First try to parse as a generic message to check for ID field
|
||||
var baseMessage struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
@@ -402,18 +403,12 @@ func (c *Stdio) handleIncomingRequest(request JSONRPCRequest) {
|
||||
|
||||
if handler == nil {
|
||||
// Send error response if no handler is configured
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: request.ID,
|
||||
Error: &struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Code: mcp.METHOD_NOT_FOUND,
|
||||
Message: "No request handler configured",
|
||||
},
|
||||
}
|
||||
errorResponse := *NewJSONRPCErrorResponse(
|
||||
request.ID,
|
||||
mcp.METHOD_NOT_FOUND,
|
||||
"No request handler configured",
|
||||
nil,
|
||||
)
|
||||
c.sendResponse(errorResponse)
|
||||
return
|
||||
}
|
||||
@@ -427,18 +422,7 @@ func (c *Stdio) handleIncomingRequest(request JSONRPCRequest) {
|
||||
// Check if context is already cancelled before processing
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: request.ID,
|
||||
Error: &struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Code: mcp.INTERNAL_ERROR,
|
||||
Message: ctx.Err().Error(),
|
||||
},
|
||||
}
|
||||
errorResponse := *NewJSONRPCErrorResponse(request.ID, mcp.INTERNAL_ERROR, ctx.Err().Error(), nil)
|
||||
c.sendResponse(errorResponse)
|
||||
return
|
||||
default:
|
||||
@@ -446,18 +430,7 @@ func (c *Stdio) handleIncomingRequest(request JSONRPCRequest) {
|
||||
|
||||
response, err := handler(ctx, request)
|
||||
if err != nil {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: request.ID,
|
||||
Error: &struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Code: mcp.INTERNAL_ERROR,
|
||||
Message: err.Error(),
|
||||
},
|
||||
}
|
||||
errorResponse := *NewJSONRPCErrorResponse(request.ID, mcp.INTERNAL_ERROR, err.Error(), nil)
|
||||
c.sendResponse(errorResponse)
|
||||
return
|
||||
}
|
||||
|
||||
51
vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go
generated
vendored
51
vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go
generated
vendored
@@ -406,13 +406,6 @@ func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCl
|
||||
// Create a channel for this specific request
|
||||
responseChan := make(chan *JSONRPCResponse, 1)
|
||||
|
||||
// Add timeout context for request processing if not already set
|
||||
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 30*time.Second {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
@@ -601,8 +594,7 @@ func (c *StreamableHTTP) IsOAuthEnabled() bool {
|
||||
func (c *StreamableHTTP) listenForever(ctx context.Context) {
|
||||
c.logger.Infof("listening to server forever")
|
||||
for {
|
||||
// Add timeout for individual connection attempts
|
||||
connectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
connectCtx, cancel := context.WithCancel(ctx)
|
||||
err := c.createGETConnectionToServer(connectCtx)
|
||||
cancel()
|
||||
|
||||
@@ -683,18 +675,12 @@ func (c *StreamableHTTP) handleIncomingRequest(ctx context.Context, request JSON
|
||||
if handler == nil {
|
||||
c.logger.Errorf("received request from server but no handler set: %s", request.Method)
|
||||
// Send method not found error
|
||||
errorResponse := &JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: request.ID,
|
||||
Error: &struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Code: -32601, // Method not found
|
||||
Message: fmt.Sprintf("no handler configured for method: %s", request.Method),
|
||||
},
|
||||
}
|
||||
errorResponse := NewJSONRPCErrorResponse(
|
||||
request.ID,
|
||||
mcp.METHOD_NOT_FOUND,
|
||||
fmt.Sprintf("no handler configured for method: %s", request.Method),
|
||||
nil,
|
||||
)
|
||||
c.sendResponseToServer(ctx, errorResponse)
|
||||
return
|
||||
}
|
||||
@@ -715,36 +701,25 @@ func (c *StreamableHTTP) handleIncomingRequest(ctx context.Context, request JSON
|
||||
|
||||
// Check for specific sampling-related errors
|
||||
if errors.Is(err, context.Canceled) {
|
||||
errorCode = -32800 // Request cancelled
|
||||
errorCode = mcp.REQUEST_INTERRUPTED
|
||||
errorMessage = "request was cancelled"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
errorCode = -32800 // Request timeout
|
||||
errorCode = mcp.REQUEST_INTERRUPTED
|
||||
errorMessage = "request timed out"
|
||||
} else {
|
||||
// Generic error cases
|
||||
switch request.Method {
|
||||
case string(mcp.MethodSamplingCreateMessage):
|
||||
errorCode = -32603 // Internal error
|
||||
errorCode = mcp.INTERNAL_ERROR
|
||||
errorMessage = fmt.Sprintf("sampling request failed: %v", err)
|
||||
default:
|
||||
errorCode = -32603 // Internal error
|
||||
errorCode = mcp.INTERNAL_ERROR
|
||||
errorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// Send error response
|
||||
errorResponse := &JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: request.ID,
|
||||
Error: &struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Code: errorCode,
|
||||
Message: errorMessage,
|
||||
},
|
||||
}
|
||||
errorResponse := NewJSONRPCErrorResponse(request.ID, errorCode, errorMessage, nil)
|
||||
c.sendResponseToServer(requestCtx, errorResponse)
|
||||
return
|
||||
}
|
||||
@@ -771,7 +746,7 @@ func (c *StreamableHTTP) sendResponseToServer(ctx context.Context, response *JSO
|
||||
ctx, cancel := c.contextAwareOfClientClose(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(responseBody), "application/json")
|
||||
resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(responseBody), "application/json, text/event-stream")
|
||||
if err != nil {
|
||||
c.logger.Errorf("failed to send response to server: %v", err)
|
||||
return
|
||||
|
||||
26
vendor/github.com/mark3labs/mcp-go/client/transport/utils.go
generated
vendored
Normal file
26
vendor/github.com/mark3labs/mcp-go/client/transport/utils.go
generated
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// NewJSONRPCErrorResponse creates a new JSONRPCResponse with an error.
|
||||
func NewJSONRPCErrorResponse(id mcp.RequestId, code int, message string, data any) *JSONRPCResponse {
|
||||
details := mcp.NewJSONRPCErrorDetails(code, message, data)
|
||||
return &JSONRPCResponse{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: id,
|
||||
Error: &details,
|
||||
}
|
||||
}
|
||||
|
||||
// NewJSONRPCResultResponse creates a new JSONRPCResponse with a result.
|
||||
func NewJSONRPCResultResponse(id mcp.RequestId, result json.RawMessage) *JSONRPCResponse {
|
||||
return &JSONRPCResponse{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: id,
|
||||
Result: result,
|
||||
}
|
||||
}
|
||||
62
vendor/github.com/mark3labs/mcp-go/mcp/errors.go
generated
vendored
62
vendor/github.com/mark3labs/mcp-go/mcp/errors.go
generated
vendored
@@ -1,6 +1,33 @@
|
||||
package mcp
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Sentinel errors for common JSON-RPC error codes.
|
||||
var (
|
||||
// ErrParseError indicates a JSON parsing error (code: PARSE_ERROR).
|
||||
ErrParseError = errors.New("parse error")
|
||||
|
||||
// ErrInvalidRequest indicates an invalid JSON-RPC request (code: INVALID_REQUEST).
|
||||
ErrInvalidRequest = errors.New("invalid request")
|
||||
|
||||
// ErrMethodNotFound indicates the requested method does not exist (code: METHOD_NOT_FOUND).
|
||||
ErrMethodNotFound = errors.New("method not found")
|
||||
|
||||
// ErrInvalidParams indicates invalid method parameters (code: INVALID_PARAMS).
|
||||
ErrInvalidParams = errors.New("invalid params")
|
||||
|
||||
// ErrInternalError indicates an internal JSON-RPC error (code: INTERNAL_ERROR).
|
||||
ErrInternalError = errors.New("internal error")
|
||||
|
||||
// ErrRequestInterrupted indicates a request was cancelled or timed out (code: REQUEST_INTERRUPTED).
|
||||
ErrRequestInterrupted = errors.New("request interrupted")
|
||||
|
||||
// ErrResourceNotFound indicates a requested resource was not found (code: RESOURCE_NOT_FOUND).
|
||||
ErrResourceNotFound = errors.New("resource not found")
|
||||
)
|
||||
|
||||
// UnsupportedProtocolVersionError is returned when the server responds with
|
||||
// a protocol version that the client doesn't support.
|
||||
@@ -23,3 +50,36 @@ func IsUnsupportedProtocolVersion(err error) bool {
|
||||
_, ok := err.(UnsupportedProtocolVersionError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// AsError maps JSONRPCErrorDetails to a Go error.
|
||||
// Returns sentinel errors wrapped with custom messages for known codes.
|
||||
// Defaults to a generic error with the original message when the code is not mapped.
|
||||
func (e *JSONRPCErrorDetails) AsError() error {
|
||||
var err error
|
||||
|
||||
switch e.Code {
|
||||
case PARSE_ERROR:
|
||||
err = ErrParseError
|
||||
case INVALID_REQUEST:
|
||||
err = ErrInvalidRequest
|
||||
case METHOD_NOT_FOUND:
|
||||
err = ErrMethodNotFound
|
||||
case INVALID_PARAMS:
|
||||
err = ErrInvalidParams
|
||||
case INTERNAL_ERROR:
|
||||
err = ErrInternalError
|
||||
case REQUEST_INTERRUPTED:
|
||||
err = ErrRequestInterrupted
|
||||
case RESOURCE_NOT_FOUND:
|
||||
err = ErrResourceNotFound
|
||||
default:
|
||||
return errors.New(e.Message)
|
||||
}
|
||||
|
||||
// Wrap the sentinel error with the custom message if it differs from the sentinel.
|
||||
if e.Message != "" && e.Message != err.Error() {
|
||||
return fmt.Errorf("%w: %s", err, e.Message)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
53
vendor/github.com/mark3labs/mcp-go/mcp/types.go
generated
vendored
53
vendor/github.com/mark3labs/mcp-go/mcp/types.go
generated
vendored
@@ -6,9 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strconv"
|
||||
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/yosida95/uritemplate/v3"
|
||||
)
|
||||
@@ -298,7 +297,6 @@ func (r RequestId) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
|
||||
func (r *RequestId) UnmarshalJSON(data []byte) error {
|
||||
|
||||
if string(data) == "null" {
|
||||
r.value = nil
|
||||
return nil
|
||||
@@ -348,31 +346,48 @@ type JSONRPCResponse struct {
|
||||
|
||||
// JSONRPCError represents a non-successful (error) response to a request.
|
||||
type JSONRPCError struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID RequestId `json:"id"`
|
||||
Error struct {
|
||||
// The error type that occurred.
|
||||
Code int `json:"code"`
|
||||
// A short description of the error. The message SHOULD be limited
|
||||
// to a concise single sentence.
|
||||
Message string `json:"message"`
|
||||
// Additional information about the error. The value of this member
|
||||
// is defined by the sender (e.g. detailed error information, nested errors etc.).
|
||||
Data any `json:"data,omitempty"`
|
||||
} `json:"error"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID RequestId `json:"id"`
|
||||
Error JSONRPCErrorDetails `json:"error"`
|
||||
}
|
||||
|
||||
// JSONRPCErrorDetails represents a JSON-RPC error for Go error handling.
|
||||
// This is separate from the JSONRPCError type which represents the full JSON-RPC error response structure.
|
||||
type JSONRPCErrorDetails struct {
|
||||
// The error type that occurred.
|
||||
Code int `json:"code"`
|
||||
// A short description of the error. The message SHOULD be limited
|
||||
// to a concise single sentence.
|
||||
Message string `json:"message"`
|
||||
// Additional information about the error. The value of this member
|
||||
// is defined by the sender (e.g. detailed error information, nested errors etc.).
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Standard JSON-RPC error codes
|
||||
const (
|
||||
PARSE_ERROR = -32700
|
||||
INVALID_REQUEST = -32600
|
||||
// PARSE_ERROR indicates invalid JSON was received by the server.
|
||||
PARSE_ERROR = -32700
|
||||
|
||||
// INVALID_REQUEST indicates the JSON sent is not a valid Request object.
|
||||
INVALID_REQUEST = -32600
|
||||
|
||||
// METHOD_NOT_FOUND indicates the method does not exist/is not available.
|
||||
METHOD_NOT_FOUND = -32601
|
||||
INVALID_PARAMS = -32602
|
||||
INTERNAL_ERROR = -32603
|
||||
|
||||
// INVALID_PARAMS indicates invalid method parameter(s).
|
||||
INVALID_PARAMS = -32602
|
||||
|
||||
// INTERNAL_ERROR indicates internal JSON-RPC error.
|
||||
INTERNAL_ERROR = -32603
|
||||
|
||||
// REQUEST_INTERRUPTED indicates a request was cancelled or timed out.
|
||||
REQUEST_INTERRUPTED = -32800
|
||||
)
|
||||
|
||||
// MCP error codes
|
||||
const (
|
||||
// RESOURCE_NOT_FOUND indicates a requested resource was not found.
|
||||
RESOURCE_NOT_FOUND = -32002
|
||||
)
|
||||
|
||||
|
||||
168
vendor/github.com/mark3labs/mcp-go/mcp/utils.go
generated
vendored
168
vendor/github.com/mark3labs/mcp-go/mcp/utils.go
generated
vendored
@@ -8,54 +8,66 @@ import (
|
||||
)
|
||||
|
||||
// ClientRequest types
|
||||
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)
|
||||
var (
|
||||
_ ClientRequest = (*PingRequest)(nil)
|
||||
_ ClientRequest = (*InitializeRequest)(nil)
|
||||
_ ClientRequest = (*CompleteRequest)(nil)
|
||||
_ ClientRequest = (*SetLevelRequest)(nil)
|
||||
_ ClientRequest = (*GetPromptRequest)(nil)
|
||||
_ ClientRequest = (*ListPromptsRequest)(nil)
|
||||
_ ClientRequest = (*ListResourcesRequest)(nil)
|
||||
_ ClientRequest = (*ReadResourceRequest)(nil)
|
||||
_ ClientRequest = (*SubscribeRequest)(nil)
|
||||
_ ClientRequest = (*UnsubscribeRequest)(nil)
|
||||
_ ClientRequest = (*CallToolRequest)(nil)
|
||||
_ ClientRequest = (*ListToolsRequest)(nil)
|
||||
)
|
||||
|
||||
// ClientNotification types
|
||||
var _ ClientNotification = (*CancelledNotification)(nil)
|
||||
var _ ClientNotification = (*ProgressNotification)(nil)
|
||||
var _ ClientNotification = (*InitializedNotification)(nil)
|
||||
var _ ClientNotification = (*RootsListChangedNotification)(nil)
|
||||
var (
|
||||
_ ClientNotification = (*CancelledNotification)(nil)
|
||||
_ ClientNotification = (*ProgressNotification)(nil)
|
||||
_ ClientNotification = (*InitializedNotification)(nil)
|
||||
_ ClientNotification = (*RootsListChangedNotification)(nil)
|
||||
)
|
||||
|
||||
// ClientResult types
|
||||
var _ ClientResult = (*EmptyResult)(nil)
|
||||
var _ ClientResult = (*CreateMessageResult)(nil)
|
||||
var _ ClientResult = (*ListRootsResult)(nil)
|
||||
var (
|
||||
_ ClientResult = (*EmptyResult)(nil)
|
||||
_ ClientResult = (*CreateMessageResult)(nil)
|
||||
_ ClientResult = (*ListRootsResult)(nil)
|
||||
)
|
||||
|
||||
// ServerRequest types
|
||||
var _ ServerRequest = (*PingRequest)(nil)
|
||||
var _ ServerRequest = (*CreateMessageRequest)(nil)
|
||||
var _ ServerRequest = (*ListRootsRequest)(nil)
|
||||
var (
|
||||
_ ServerRequest = (*PingRequest)(nil)
|
||||
_ ServerRequest = (*CreateMessageRequest)(nil)
|
||||
_ ServerRequest = (*ListRootsRequest)(nil)
|
||||
)
|
||||
|
||||
// ServerNotification types
|
||||
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)
|
||||
var (
|
||||
_ ServerNotification = (*CancelledNotification)(nil)
|
||||
_ ServerNotification = (*ProgressNotification)(nil)
|
||||
_ ServerNotification = (*LoggingMessageNotification)(nil)
|
||||
_ ServerNotification = (*ResourceUpdatedNotification)(nil)
|
||||
_ ServerNotification = (*ResourceListChangedNotification)(nil)
|
||||
_ ServerNotification = (*ToolListChangedNotification)(nil)
|
||||
_ ServerNotification = (*PromptListChangedNotification)(nil)
|
||||
)
|
||||
|
||||
// ServerResult types
|
||||
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)
|
||||
var (
|
||||
_ ServerResult = (*EmptyResult)(nil)
|
||||
_ ServerResult = (*InitializeResult)(nil)
|
||||
_ ServerResult = (*CompleteResult)(nil)
|
||||
_ ServerResult = (*GetPromptResult)(nil)
|
||||
_ ServerResult = (*ListPromptsResult)(nil)
|
||||
_ ServerResult = (*ListResourcesResult)(nil)
|
||||
_ ServerResult = (*ReadResourceResult)(nil)
|
||||
_ ServerResult = (*CallToolResult)(nil)
|
||||
_ ServerResult = (*ListToolsResult)(nil)
|
||||
)
|
||||
|
||||
// Helper functions for type assertions
|
||||
|
||||
@@ -100,7 +112,10 @@ func AsBlobResourceContents(content any) (*BlobResourceContents, bool) {
|
||||
|
||||
// Helper function for JSON-RPC
|
||||
|
||||
// NewJSONRPCResponse creates a new JSONRPCResponse with the given id and result
|
||||
// NewJSONRPCResponse creates a new JSONRPCResponse with the given id and result.
|
||||
// NOTE: This function expects a Result struct, but JSONRPCResponse.Result is typed as `any`.
|
||||
// The Result struct wraps the actual result data with optional metadata.
|
||||
// For direct result assignment, use NewJSONRPCResultResponse instead.
|
||||
func NewJSONRPCResponse(id RequestId, result Result) JSONRPCResponse {
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: JSONRPC_VERSION,
|
||||
@@ -109,6 +124,25 @@ func NewJSONRPCResponse(id RequestId, result Result) JSONRPCResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// NewJSONRPCResultResponse creates a new JSONRPCResponse with the given id and result.
|
||||
// This function accepts any type for the result, matching the JSONRPCResponse.Result field type.
|
||||
func NewJSONRPCResultResponse(id RequestId, result any) JSONRPCResponse {
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: JSONRPC_VERSION,
|
||||
ID: id,
|
||||
Result: result,
|
||||
}
|
||||
}
|
||||
|
||||
// NewJSONRPCErrorDetails creates a new JSONRPCErrorDetails with the given code, message, and data.
|
||||
func NewJSONRPCErrorDetails(code int, message string, data any) JSONRPCErrorDetails {
|
||||
return JSONRPCErrorDetails{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// NewJSONRPCError creates a new JSONRPCResponse with the given id, code, and message
|
||||
func NewJSONRPCError(
|
||||
id RequestId,
|
||||
@@ -119,15 +153,7 @@ func NewJSONRPCError(
|
||||
return JSONRPCError{
|
||||
JSONRPC: JSONRPC_VERSION,
|
||||
ID: id,
|
||||
Error: struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: data,
|
||||
},
|
||||
Error: NewJSONRPCErrorDetails(code, message, data),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,6 +536,27 @@ func ExtractString(data map[string]any, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func ParseAnnotations(data map[string]any) *Annotations {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
annotations := &Annotations{}
|
||||
if value, ok := data["priority"]; ok {
|
||||
annotations.Priority = cast.ToFloat64(value)
|
||||
}
|
||||
|
||||
if value, ok := data["audience"]; ok {
|
||||
for _, a := range cast.ToStringSlice(value) {
|
||||
a := Role(a)
|
||||
if a == RoleUser || a == RoleAssistant {
|
||||
annotations.Audience = append(annotations.Audience, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return annotations
|
||||
|
||||
}
|
||||
|
||||
func ExtractMap(data map[string]any, key string) map[string]any {
|
||||
if value, ok := data[key]; ok {
|
||||
if m, ok := value.(map[string]any); ok {
|
||||
@@ -522,10 +569,17 @@ func ExtractMap(data map[string]any, key string) map[string]any {
|
||||
func ParseContent(contentMap map[string]any) (Content, error) {
|
||||
contentType := ExtractString(contentMap, "type")
|
||||
|
||||
var annotations *Annotations
|
||||
if annotationsMap := ExtractMap(contentMap, "annotations"); annotationsMap != nil {
|
||||
annotations = ParseAnnotations(annotationsMap)
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case ContentTypeText:
|
||||
text := ExtractString(contentMap, "text")
|
||||
return NewTextContent(text), nil
|
||||
c := NewTextContent(text)
|
||||
c.Annotations = annotations
|
||||
return c, nil
|
||||
|
||||
case ContentTypeImage:
|
||||
data := ExtractString(contentMap, "data")
|
||||
@@ -533,7 +587,9 @@ func ParseContent(contentMap map[string]any) (Content, error) {
|
||||
if data == "" || mimeType == "" {
|
||||
return nil, fmt.Errorf("image data or mimeType is missing")
|
||||
}
|
||||
return NewImageContent(data, mimeType), nil
|
||||
c := NewImageContent(data, mimeType)
|
||||
c.Annotations = annotations
|
||||
return c, nil
|
||||
|
||||
case ContentTypeAudio:
|
||||
data := ExtractString(contentMap, "data")
|
||||
@@ -541,7 +597,9 @@ func ParseContent(contentMap map[string]any) (Content, error) {
|
||||
if data == "" || mimeType == "" {
|
||||
return nil, fmt.Errorf("audio data or mimeType is missing")
|
||||
}
|
||||
return NewAudioContent(data, mimeType), nil
|
||||
c := NewAudioContent(data, mimeType)
|
||||
c.Annotations = annotations
|
||||
return c, nil
|
||||
|
||||
case ContentTypeLink:
|
||||
uri := ExtractString(contentMap, "uri")
|
||||
@@ -551,7 +609,9 @@ func ParseContent(contentMap map[string]any) (Content, error) {
|
||||
if uri == "" || name == "" {
|
||||
return nil, fmt.Errorf("resource_link uri or name is missing")
|
||||
}
|
||||
return NewResourceLink(uri, name, description, mimeType), nil
|
||||
c := NewResourceLink(uri, name, description, mimeType)
|
||||
c.Annotations = annotations
|
||||
return c, nil
|
||||
|
||||
case ContentTypeResource:
|
||||
resourceMap := ExtractMap(contentMap, "resource")
|
||||
@@ -564,7 +624,9 @@ func ParseContent(contentMap map[string]any) (Content, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewEmbeddedResource(resourceContents), nil
|
||||
c := NewEmbeddedResource(resourceContents)
|
||||
c.Annotations = annotations
|
||||
return c, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported content type: %s", contentType)
|
||||
|
||||
24
vendor/github.com/mark3labs/mcp-go/server/server.go
generated
vendored
24
vendor/github.com/mark3labs/mcp-go/server/server.go
generated
vendored
@@ -124,14 +124,7 @@ func (e *requestError) ToJSONRPCError() mcp.JSONRPCError {
|
||||
return mcp.JSONRPCError{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: mcp.NewRequestId(e.id),
|
||||
Error: struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}{
|
||||
Code: e.code,
|
||||
Message: e.err.Error(),
|
||||
},
|
||||
Error: mcp.NewJSONRPCErrorDetails(e.code, e.err.Error(), nil),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1211,11 +1204,7 @@ func (s *MCPServer) handleNotification(
|
||||
}
|
||||
|
||||
func createResponse(id any, result any) mcp.JSONRPCMessage {
|
||||
return mcp.JSONRPCResponse{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: mcp.NewRequestId(id),
|
||||
Result: result,
|
||||
}
|
||||
return mcp.NewJSONRPCResultResponse(mcp.NewRequestId(id), result)
|
||||
}
|
||||
|
||||
func createErrorResponse(
|
||||
@@ -1226,13 +1215,6 @@ func createErrorResponse(
|
||||
return mcp.JSONRPCError{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: mcp.NewRequestId(id),
|
||||
Error: struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
Error: mcp.NewJSONRPCErrorDetails(code, message, nil),
|
||||
}
|
||||
}
|
||||
|
||||
10
vendor/modules.txt
vendored
10
vendor/modules.txt
vendored
@@ -67,8 +67,8 @@ github.com/containerd/log
|
||||
# github.com/containerd/platforms v0.2.1
|
||||
## explicit; go 1.20
|
||||
github.com/containerd/platforms
|
||||
# github.com/coreos/go-oidc/v3 v3.15.0
|
||||
## explicit; go 1.23.0
|
||||
# github.com/coreos/go-oidc/v3 v3.16.0
|
||||
## explicit; go 1.24.0
|
||||
github.com/coreos/go-oidc/v3/oidc
|
||||
github.com/coreos/go-oidc/v3/oidc/oidctest
|
||||
# github.com/cyphar/filepath-securejoin v0.4.1
|
||||
@@ -107,8 +107,8 @@ github.com/go-errors/errors
|
||||
# github.com/go-gorp/gorp/v3 v3.1.0
|
||||
## explicit; go 1.18
|
||||
github.com/go-gorp/gorp/v3
|
||||
# github.com/go-jose/go-jose/v4 v4.1.2
|
||||
## explicit; go 1.23.0
|
||||
# github.com/go-jose/go-jose/v4 v4.1.3
|
||||
## explicit; go 1.24.0
|
||||
github.com/go-jose/go-jose/v4
|
||||
github.com/go-jose/go-jose/v4/cipher
|
||||
github.com/go-jose/go-jose/v4/json
|
||||
@@ -228,7 +228,7 @@ github.com/liggitt/tabwriter
|
||||
github.com/mailru/easyjson/buffer
|
||||
github.com/mailru/easyjson/jlexer
|
||||
github.com/mailru/easyjson/jwriter
|
||||
# github.com/mark3labs/mcp-go v0.40.0
|
||||
# github.com/mark3labs/mcp-go v0.41.1
|
||||
## explicit; go 1.23
|
||||
github.com/mark3labs/mcp-go/client
|
||||
github.com/mark3labs/mcp-go/client/transport
|
||||
|
||||
Reference in New Issue
Block a user