This is page 1 of 5. Use http://codebase.md/manusa/kubernetes-mcp-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── build.yaml
│ ├── release-image.yml
│ └── release.yaml
├── .gitignore
├── AGENTS.md
├── build
│ ├── keycloak.mk
│ ├── kind.mk
│ └── tools.mk
├── CLAUDE.md
├── cmd
│ └── kubernetes-mcp-server
│ ├── main_test.go
│ └── main.go
├── dev
│ └── config
│ ├── cert-manager
│ │ └── selfsigned-issuer.yaml
│ ├── ingress
│ │ └── nginx-ingress.yaml
│ ├── keycloak
│ │ ├── client-scopes
│ │ │ ├── groups.json
│ │ │ ├── mcp-openshift.json
│ │ │ └── mcp-server.json
│ │ ├── clients
│ │ │ ├── mcp-client.json
│ │ │ ├── mcp-server-update.json
│ │ │ ├── mcp-server.json
│ │ │ └── openshift.json
│ │ ├── deployment.yaml
│ │ ├── ingress.yaml
│ │ ├── mappers
│ │ │ ├── groups-membership.json
│ │ │ ├── mcp-server-audience.json
│ │ │ ├── openshift-audience.json
│ │ │ └── username.json
│ │ ├── rbac.yaml
│ │ ├── realm
│ │ │ ├── realm-create.json
│ │ │ └── realm-events-config.json
│ │ └── users
│ │ └── mcp.json
│ └── kind
│ └── cluster.yaml
├── Dockerfile
├── docs
│ └── images
│ ├── kubernetes-mcp-server-github-copilot.jpg
│ └── vibe-coding.jpg
├── go.mod
├── go.sum
├── hack
│ └── generate-placeholder-ca.sh
├── internal
│ ├── test
│ │ ├── env.go
│ │ ├── kubernetes.go
│ │ ├── mcp.go
│ │ ├── mock_server.go
│ │ └── test.go
│ └── tools
│ └── update-readme
│ └── main.go
├── LICENSE
├── Makefile
├── npm
│ ├── kubernetes-mcp-server
│ │ ├── bin
│ │ │ └── index.js
│ │ └── package.json
│ ├── kubernetes-mcp-server-darwin-amd64
│ │ └── package.json
│ ├── kubernetes-mcp-server-darwin-arm64
│ │ └── package.json
│ ├── kubernetes-mcp-server-linux-amd64
│ │ └── package.json
│ ├── kubernetes-mcp-server-linux-arm64
│ │ └── package.json
│ ├── kubernetes-mcp-server-windows-amd64
│ │ └── package.json
│ └── kubernetes-mcp-server-windows-arm64
│ └── package.json
├── pkg
│ ├── api
│ │ ├── toolsets_test.go
│ │ └── toolsets.go
│ ├── config
│ │ ├── config_default_overrides.go
│ │ ├── config_default.go
│ │ ├── config_test.go
│ │ ├── config.go
│ │ ├── provider_config_test.go
│ │ └── provider_config.go
│ ├── helm
│ │ └── helm.go
│ ├── http
│ │ ├── authorization_test.go
│ │ ├── authorization.go
│ │ ├── http_test.go
│ │ ├── http.go
│ │ ├── middleware.go
│ │ ├── sts_test.go
│ │ ├── sts.go
│ │ └── wellknown.go
│ ├── kubernetes
│ │ ├── accesscontrol_clientset.go
│ │ ├── accesscontrol_restmapper.go
│ │ ├── accesscontrol.go
│ │ ├── common_test.go
│ │ ├── configuration.go
│ │ ├── events.go
│ │ ├── impersonate_roundtripper.go
│ │ ├── kubernetes_derived_test.go
│ │ ├── kubernetes.go
│ │ ├── manager_test.go
│ │ ├── manager.go
│ │ ├── namespaces.go
│ │ ├── nodes.go
│ │ ├── openshift.go
│ │ ├── pods.go
│ │ ├── provider_kubeconfig_test.go
│ │ ├── provider_kubeconfig.go
│ │ ├── provider_registry_test.go
│ │ ├── provider_registry.go
│ │ ├── provider_single_test.go
│ │ ├── provider_single.go
│ │ ├── provider_test.go
│ │ ├── provider.go
│ │ ├── resources.go
│ │ └── token.go
│ ├── kubernetes-mcp-server
│ │ └── cmd
│ │ ├── root_test.go
│ │ ├── root.go
│ │ └── testdata
│ │ ├── empty-config.toml
│ │ └── valid-config.toml
│ ├── mcp
│ │ ├── common_test.go
│ │ ├── configuration_test.go
│ │ ├── events_test.go
│ │ ├── helm_test.go
│ │ ├── m3labs.go
│ │ ├── mcp_middleware_test.go
│ │ ├── mcp_test.go
│ │ ├── mcp_tools_test.go
│ │ ├── mcp.go
│ │ ├── modules.go
│ │ ├── namespaces_test.go
│ │ ├── nodes_test.go
│ │ ├── pods_exec_test.go
│ │ ├── pods_test.go
│ │ ├── pods_top_test.go
│ │ ├── resources_test.go
│ │ ├── testdata
│ │ │ ├── helm-chart-no-op
│ │ │ │ └── Chart.yaml
│ │ │ ├── helm-chart-secret
│ │ │ │ ├── Chart.yaml
│ │ │ │ └── templates
│ │ │ │ └── secret.yaml
│ │ │ ├── toolsets-config-tools.json
│ │ │ ├── toolsets-core-tools.json
│ │ │ ├── toolsets-full-tools-multicluster-enum.json
│ │ │ ├── toolsets-full-tools-multicluster.json
│ │ │ ├── toolsets-full-tools-openshift.json
│ │ │ ├── toolsets-full-tools.json
│ │ │ └── toolsets-helm-tools.json
│ │ ├── tool_filter_test.go
│ │ ├── tool_filter.go
│ │ ├── tool_mutator_test.go
│ │ ├── tool_mutator.go
│ │ └── toolsets_test.go
│ ├── output
│ │ ├── output_test.go
│ │ └── output.go
│ ├── toolsets
│ │ ├── config
│ │ │ ├── configuration.go
│ │ │ └── toolset.go
│ │ ├── core
│ │ │ ├── events.go
│ │ │ ├── namespaces.go
│ │ │ ├── nodes.go
│ │ │ ├── pods.go
│ │ │ ├── resources.go
│ │ │ └── toolset.go
│ │ ├── helm
│ │ │ ├── helm.go
│ │ │ └── toolset.go
│ │ ├── toolsets_test.go
│ │ └── toolsets.go
│ └── version
│ └── version.go
├── python
│ ├── kubernetes_mcp_server
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ └── kubernetes_mcp_server.py
│ ├── pyproject.toml
│ └── README.md
├── README.md
└── smithery.yaml
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | _output/
2 | .idea/
3 | .vscode/
4 | .docusaurus/
5 | node_modules/
6 |
7 | .npmrc
8 | kubernetes-mcp-server
9 | !cmd/kubernetes-mcp-server
10 | !pkg/kubernetes-mcp-server
11 | npm/kubernetes-mcp-server/README.md
12 | npm/kubernetes-mcp-server/LICENSE
13 | !npm/kubernetes-mcp-server
14 | kubernetes-mcp-server-darwin-amd64
15 | !npm/kubernetes-mcp-server-darwin-amd64/
16 | kubernetes-mcp-server-darwin-arm64
17 | !npm/kubernetes-mcp-server-darwin-arm64
18 | kubernetes-mcp-server-linux-amd64
19 | !npm/kubernetes-mcp-server-linux-amd64
20 | kubernetes-mcp-server-linux-arm64
21 | !npm/kubernetes-mcp-server-linux-arm64
22 | kubernetes-mcp-server-windows-amd64.exe
23 | kubernetes-mcp-server-windows-arm64.exe
24 |
25 | python/.venv/
26 | python/build/
27 | python/dist/
28 | python/kubernetes_mcp_server.egg-info/
29 | !python/kubernetes-mcp-server
30 |
```
--------------------------------------------------------------------------------
/python/README.md:
--------------------------------------------------------------------------------
```markdown
1 | ../README.md
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Kubernetes MCP Server
2 |
3 | [](https://github.com/containers/kubernetes-mcp-server/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/kubernetes-mcp-server)
5 | [](https://pypi.org/project/kubernetes-mcp-server/)
6 | [](https://github.com/containers/kubernetes-mcp-server/releases/latest)
7 | [](https://github.com/containers/kubernetes-mcp-server/actions/workflows/build.yaml)
8 |
9 | [✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🛠️ Tools](#tools-and-functionalities) | [🧑💻 Development](#development)
10 |
11 | https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e
12 |
13 | ## ✨ Features <a id="features"></a>
14 |
15 | 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**.
16 |
17 | - **✅ Configuration**:
18 | - Automatically detect changes in the Kubernetes configuration and update the MCP server.
19 | - **View** and manage the current [Kubernetes `.kube/config`](https://blog.marcnuri.com/where-is-my-default-kubeconfig-file) or in-cluster configuration.
20 | - **✅ Generic Kubernetes Resources**: Perform operations on **any** Kubernetes or OpenShift resource.
21 | - Any CRUD operation (Create or Update, Get, List, Delete).
22 | - **✅ Pods**: Perform Pod-specific operations.
23 | - **List** pods in all namespaces or in a specific namespace.
24 | - **Get** a pod by name from the specified namespace.
25 | - **Delete** a pod by name from the specified namespace.
26 | - **Show logs** for a pod by name from the specified namespace.
27 | - **Top** gets resource usage metrics for all pods or a specific pod in the specified namespace.
28 | - **Exec** into a pod and run a command.
29 | - **Run** a container image in a pod and optionally expose it.
30 | - **✅ Namespaces**: List Kubernetes Namespaces.
31 | - **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
32 | - **✅ Projects**: List OpenShift Projects.
33 | - **☸️ Helm**:
34 | - **Install** a Helm chart in the current or provided namespace.
35 | - **List** Helm releases in all namespaces or in a specific namespace.
36 | - **Uninstall** a Helm release in the current or provided namespace.
37 |
38 | Unlike other Kubernetes MCP server implementations, this **IS NOT** just a wrapper around `kubectl` or `helm` command-line tools.
39 | It is a **Go-based native implementation** that interacts directly with the Kubernetes API server.
40 |
41 | There is **NO NEED** for external dependencies or tools to be installed on the system.
42 | If you're using the native binaries you don't need to have Node or Python installed on your system.
43 |
44 | - **✅ Lightweight**: The server is distributed as a single native binary for Linux, macOS, and Windows.
45 | - **✅ High-Performance / Low-Latency**: Directly interacts with the Kubernetes API server without the overhead of calling and waiting for external commands.
46 | - **✅ Multi-Cluster**: Can interact with multiple Kubernetes clusters simultaneously (as defined in your kubeconfig files).
47 | - **✅ Cross-Platform**: Available as a native binary for Linux, macOS, and Windows, as well as an npm package, a Python package, and container/Docker image.
48 | - **✅ Configurable**: Supports [command-line arguments](#configuration) to configure the server behavior.
49 | - **✅ Well tested**: The server has an extensive test suite to ensure its reliability and correctness across different Kubernetes environments.
50 |
51 | ## 🚀 Getting Started <a id="getting-started"></a>
52 |
53 | ### Requirements
54 |
55 | - Access to a Kubernetes cluster.
56 |
57 | ### Claude Desktop
58 |
59 | #### Using npx
60 |
61 | If you have npm installed, this is the fastest way to get started with `kubernetes-mcp-server` on Claude Desktop.
62 |
63 | Open your `claude_desktop_config.json` and add the mcp server to the list of `mcpServers`:
64 | ``` json
65 | {
66 | "mcpServers": {
67 | "kubernetes": {
68 | "command": "npx",
69 | "args": [
70 | "-y",
71 | "kubernetes-mcp-server@latest"
72 | ]
73 | }
74 | }
75 | }
76 | ```
77 |
78 | ### VS Code / VS Code Insiders
79 |
80 | Install the Kubernetes MCP server extension in VS Code Insiders by pressing the following link:
81 |
82 | [<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522kubernetes%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522kubernetes-mcp-server%2540latest%2522%255D%257D)
83 | [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522kubernetes%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522kubernetes-mcp-server%2540latest%2522%255D%257D)
84 |
85 | Alternatively, you can install the extension manually by running the following command:
86 |
87 | ```shell
88 | # For VS Code
89 | code --add-mcp '{"name":"kubernetes","command":"npx","args":["kubernetes-mcp-server@latest"]}'
90 | # For VS Code Insiders
91 | code-insiders --add-mcp '{"name":"kubernetes","command":"npx","args":["kubernetes-mcp-server@latest"]}'
92 | ```
93 |
94 | ### Cursor
95 |
96 | Install the Kubernetes MCP server extension in Cursor by pressing the following link:
97 |
98 | [](https://cursor.com/en/install-mcp?name=kubernetes-mcp-server&config=eyJjb21tYW5kIjoibnB4IC15IGt1YmVybmV0ZXMtbWNwLXNlcnZlckBsYXRlc3QifQ%3D%3D)
99 |
100 | Alternatively, you can install the extension manually by editing the `mcp.json` file:
101 |
102 | ```json
103 | {
104 | "mcpServers": {
105 | "kubernetes-mcp-server": {
106 | "command": "npx",
107 | "args": ["-y", "kubernetes-mcp-server@latest"]
108 | }
109 | }
110 | }
111 | ```
112 |
113 | ### Goose CLI
114 |
115 | [Goose CLI](https://blog.marcnuri.com/goose-on-machine-ai-agent-cli-introduction) is the easiest (and cheapest) way to get rolling with artificial intelligence (AI) agents.
116 |
117 | #### Using npm
118 |
119 | If you have npm installed, this is the fastest way to get started with `kubernetes-mcp-server`.
120 |
121 | Open your goose `config.yaml` and add the mcp server to the list of `mcpServers`:
122 | ```yaml
123 | extensions:
124 | kubernetes:
125 | command: npx
126 | args:
127 | - -y
128 | - kubernetes-mcp-server@latest
129 |
130 | ```
131 |
132 | ## 🎥 Demos <a id="demos"></a>
133 |
134 | ### Diagnosing and automatically fixing an OpenShift Deployment
135 |
136 | Demo showcasing how Kubernetes MCP server is leveraged by Claude Desktop to automatically diagnose and fix a deployment in OpenShift without any user assistance.
137 |
138 | https://github.com/user-attachments/assets/a576176d-a142-4c19-b9aa-a83dc4b8d941
139 |
140 | ### _Vibe Coding_ a simple game and deploying it to OpenShift
141 |
142 | 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.
143 |
144 | <a href="https://www.youtube.com/watch?v=l05jQDSrzVI" target="_blank">
145 | <img src="docs/images/vibe-coding.jpg" alt="Vibe Coding: Build & Deploy a Game on Kubernetes" width="240" />
146 | </a>
147 |
148 | ### Supercharge GitHub Copilot with Kubernetes MCP Server in VS Code - One-Click Setup!
149 |
150 | In this demo, I'll show you how to set up Kubernetes MCP server in VS code just by clicking a link.
151 |
152 | <a href="https://youtu.be/AI4ljYMkgtA" target="_blank">
153 | <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" />
154 | </a>
155 |
156 | ## ⚙️ Configuration <a id="configuration"></a>
157 |
158 | The Kubernetes MCP server can be configured using command line (CLI) arguments.
159 |
160 | You can run the CLI executable either by using `npx`, `uvx`, or by downloading the [latest release binary](https://github.com/containers/kubernetes-mcp-server/releases/latest).
161 |
162 | ```shell
163 | # Run the Kubernetes MCP server using npx (in case you have npm and node installed)
164 | npx kubernetes-mcp-server@latest --help
165 | ```
166 |
167 | ```shell
168 | # Run the Kubernetes MCP server using uvx (in case you have uv and python installed)
169 | uvx kubernetes-mcp-server@latest --help
170 | ```
171 |
172 | ```shell
173 | # Run the Kubernetes MCP server using the latest release binary
174 | ./kubernetes-mcp-server --help
175 | ```
176 |
177 | ### Configuration Options
178 |
179 | | Option | Description |
180 | |---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
181 | | `--port` | Starts the MCP server in Streamable HTTP mode (path /mcp) and Server-Sent Event (SSE) (path /sse) mode and listens on the specified port . |
182 | | `--log-level` | Sets the logging level (values [from 0-9](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md)). Similar to [kubectl logging levels](https://kubernetes.io/docs/reference/kubectl/quick-reference/#kubectl-output-verbosity-and-debugging). |
183 | | `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
184 | | `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") |
185 | | `--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. |
186 | | `--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. |
187 | | `--toolsets` | Comma-separated list of toolsets to enable. Check the [🛠️ Tools and Functionalities](#tools-and-functionalities) section for more information. |
188 | | `--disable-multi-cluster` | If set, the MCP server will disable multi-cluster support and will only use the current context from the kubeconfig file. This is useful if you want to restrict the MCP server to a single cluster. |
189 |
190 | ## 🛠️ Tools and Functionalities <a id="tools-and-functionalities"></a>
191 |
192 | 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.
193 | This allows you to control which Kubernetes functionalities are available to your AI tools.
194 | Enabling only the toolsets you need can help reduce the context size and improve the LLM's tool selection accuracy.
195 |
196 | ### Available Toolsets
197 |
198 | The following sets of tools are available (all on by default):
199 |
200 | <!-- AVAILABLE-TOOLSETS-START -->
201 |
202 | | Toolset | Description |
203 | |---------|-------------------------------------------------------------------------------------|
204 | | config | View and manage the current local Kubernetes configuration (kubeconfig) |
205 | | core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
206 | | helm | Tools for managing Helm charts and releases |
207 |
208 | <!-- AVAILABLE-TOOLSETS-END -->
209 |
210 | ### Tools
211 |
212 | In case multi-cluster support is enabled (default) and you have access to multiple clusters, all applicable tools will include an additional `context` argument to specify the Kubernetes context (cluster) to use for that operation.
213 |
214 | <!-- AVAILABLE-TOOLSETS-TOOLS-START -->
215 |
216 | <details>
217 |
218 | <summary>config</summary>
219 |
220 | - **configuration_contexts_list** - List all available context names and associated server urls from the kubeconfig file
221 |
222 | - **configuration_view** - Get the current Kubernetes configuration content as a kubeconfig YAML
223 | - `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)
224 |
225 | </details>
226 |
227 | <details>
228 |
229 | <summary>core</summary>
230 |
231 | - **events_list** - List all the Kubernetes events in the current cluster from all namespaces
232 | - `namespace` (`string`) - Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces
233 |
234 | - **namespaces_list** - List all the Kubernetes namespaces in the current cluster
235 |
236 | - **projects_list** - List all the OpenShift projects in the current cluster
237 |
238 | - **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
239 | - `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
240 |
241 | - **pods_list_in_namespace** - List all the Kubernetes pods in the specified namespace in the current cluster
242 | - `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
243 | - `namespace` (`string`) **(required)** - Namespace to list pods from
244 |
245 | - **pods_get** - Get a Kubernetes Pod in the current or provided namespace with the provided name
246 | - `name` (`string`) **(required)** - Name of the Pod
247 | - `namespace` (`string`) - Namespace to get the Pod from
248 |
249 | - **pods_delete** - Delete a Kubernetes Pod in the current or provided namespace with the provided name
250 | - `name` (`string`) **(required)** - Name of the Pod to delete
251 | - `namespace` (`string`) - Namespace to delete the Pod from
252 |
253 | - **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
254 | - `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
255 | - `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)
256 | - `name` (`string`) - Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)
257 | - `namespace` (`string`) - Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)
258 |
259 | - **pods_exec** - Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command
260 | - `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"]
261 | - `container` (`string`) - Name of the Pod container where the command will be executed (Optional)
262 | - `name` (`string`) **(required)** - Name of the Pod where the command will be executed
263 | - `namespace` (`string`) - Namespace of the Pod where the command will be executed
264 |
265 | - **pods_log** - Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name
266 | - `container` (`string`) - Name of the Pod container to get the logs from (Optional)
267 | - `name` (`string`) **(required)** - Name of the Pod to get the logs from
268 | - `namespace` (`string`) - Namespace to get the Pod logs from
269 | - `previous` (`boolean`) - Return previous terminated container logs (Optional)
270 | - `tail` (`integer`) - Number of lines to retrieve from the end of the logs (Optional, default: 100)
271 |
272 | - **pods_run** - Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name
273 | - `image` (`string`) **(required)** - Container Image to run in the Pod
274 | - `name` (`string`) - Name of the Pod (Optional, random name if not provided)
275 | - `namespace` (`string`) - Namespace to run the Pod in
276 | - `port` (`number`) - TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)
277 |
278 | - **resources_list** - List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector
279 | (common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
280 | - `apiVersion` (`string`) **(required)** - apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
281 | - `kind` (`string`) **(required)** - kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)
282 | - `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
283 | - `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
284 |
285 | - **resources_get** - Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name
286 | (common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
287 | - `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
288 | - `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)
289 | - `name` (`string`) **(required)** - Name of the resource
290 | - `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
291 |
292 | - **resources_create_or_update** - Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource
293 | (common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
294 | - `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
295 |
296 | - **resources_delete** - Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name
297 | (common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress, route.openshift.io/v1 Route)
298 | - `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)
299 | - `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)
300 | - `name` (`string`) **(required)** - Name of the resource
301 | - `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
302 |
303 | </details>
304 |
305 | <details>
306 |
307 | <summary>helm</summary>
308 |
309 | - **helm_install** - Install a Helm chart in the current or provided namespace
310 | - `chart` (`string`) **(required)** - Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)
311 | - `name` (`string`) - Name of the Helm release (Optional, random name if not provided)
312 | - `namespace` (`string`) - Namespace to install the Helm chart in (Optional, current namespace if not provided)
313 | - `values` (`object`) - Values to pass to the Helm chart (Optional)
314 |
315 | - **helm_list** - List all the Helm releases in the current or provided namespace (or in all namespaces if specified)
316 | - `all_namespaces` (`boolean`) - If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)
317 | - `namespace` (`string`) - Namespace to list Helm releases from (Optional, all namespaces if not provided)
318 |
319 | - **helm_uninstall** - Uninstall a Helm release in the current or provided namespace
320 | - `name` (`string`) **(required)** - Name of the Helm release to uninstall
321 | - `namespace` (`string`) - Namespace to uninstall the Helm release from (Optional, current namespace if not provided)
322 |
323 | </details>
324 |
325 |
326 | <!-- AVAILABLE-TOOLSETS-TOOLS-END -->
327 |
328 | ## 🧑💻 Development <a id="development"></a>
329 |
330 | ### Running with mcp-inspector
331 |
332 | Compile the project and run the Kubernetes MCP server with [mcp-inspector](https://modelcontextprotocol.io/docs/tools/inspector) to inspect the MCP server.
333 |
334 | ```shell
335 | # Compile the project
336 | make build
337 | # Run the Kubernetes MCP server with mcp-inspector
338 | npx @modelcontextprotocol/inspector@latest $(pwd)/kubernetes-mcp-server
339 | ```
340 |
```
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
```markdown
1 | AGENTS.md
```
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
```markdown
1 | # Project Agents.md for Kubernetes MCP Server
2 |
3 | This Agents.md file provides comprehensive guidance for AI assistants and coding agents (like Claude, Gemini, Cursor, and others) to work with this codebase.
4 |
5 | This repository contains the kubernetes-mcp-server project,
6 | a powerful Go-based Model Context Protocol (MCP) server that provides native Kubernetes and OpenShift cluster management capabilities without external dependencies.
7 | This MCP server enables AI assistants (like Claude, Gemini, Cursor, and others) to interact with Kubernetes clusters using the Model Context Protocol (MCP).
8 |
9 | ## Project Structure and Repository layout
10 |
11 | - Go package layout follows the standard Go conventions:
12 | - `cmd/kubernetes-mcp-server/` – main application entry point using Cobra CLI framework.
13 | - `pkg/` – libraries grouped by domain.
14 | - `api/` - API-related functionality, tool definitions, and toolset interfaces.
15 | - `config/` – configuration management.
16 | - `helm/` - Helm chart operations integration.
17 | - `http/` - HTTP server and authorization middleware.
18 | - `kubernetes/` - Kubernetes client management, authentication, and access control.
19 | - `mcp/` - Model Context Protocol (MCP) server implementation with tool registration and STDIO/HTTP support.
20 | - `output/` - output formatting and rendering.
21 | - `toolsets/` - Toolset registration and management for MCP tools.
22 | - `version/` - Version information management.
23 | - `.github/` – GitHub-related configuration (Actions workflows, issue templates...).
24 | - `docs/` – documentation files.
25 | - `npm/` – Node packages that wraps the compiled binaries for distribution through npmjs.com.
26 | - `python/` – Python package providing a script that downloads the correct platform binary from the GitHub releases page and runs it for distribution through pypi.org.
27 | - `Dockerfile` - container image description file to distribute the server as a container image.
28 | - `Makefile` – tasks for building, formatting, linting and testing.
29 |
30 | ## Feature development
31 |
32 | Implement new functionality in the Go sources under `cmd/` and `pkg/`.
33 | The JavaScript (`npm/`) and Python (`python/`) directories only wrap the compiled binary for distribution (npm and PyPI).
34 | Most changes will not require touching them unless the version or packaging needs to be updated.
35 |
36 | ### Adding new MCP tools
37 |
38 | The project uses a toolset-based architecture for organizing MCP tools:
39 |
40 | - **Tool definitions** are created in `pkg/api/` using the `ServerTool` struct.
41 | - **Toolsets** group related tools together (e.g., config tools, core Kubernetes tools, Helm tools).
42 | - **Registration** happens in `pkg/toolsets/` where toolsets are registered at initialization.
43 | - Each toolset lives in its own subdirectory under `pkg/toolsets/` (e.g., `pkg/toolsets/config/`, `pkg/toolsets/core/`, `pkg/toolsets/helm/`).
44 |
45 | When adding a new tool:
46 | 1. Define the tool handler function that implements the tool's logic.
47 | 2. Create a `ServerTool` struct with the tool definition and handler.
48 | 3. Add the tool to an appropriate toolset (or create a new toolset if needed).
49 | 4. Register the toolset in `pkg/toolsets/` if it's a new toolset.
50 |
51 | ## Building
52 |
53 | Use the provided Makefile targets:
54 |
55 | ```bash
56 | # Format source and build the binary
57 | make build
58 |
59 | # Build for all supported platforms
60 | make build-all-platforms
61 | ```
62 |
63 | `make build` will run `go fmt` and `go mod tidy` before compiling.
64 | The resulting executable is `kubernetes-mcp-server`.
65 |
66 | ## Running
67 |
68 | The README demonstrates running the server via
69 | [`mcp-inspector`](https://modelcontextprotocol.io/docs/tools/inspector):
70 |
71 | ```bash
72 | make build
73 | npx @modelcontextprotocol/inspector@latest $(pwd)/kubernetes-mcp-server
74 | ```
75 |
76 | To run the server locally, you can use `npx`, `uvx` or execute the binary directly:
77 |
78 | ```bash
79 | # Using npx (Node.js package runner)
80 | npx -y kubernetes-mcp-server@latest
81 |
82 | # Using uvx (Python package runner)
83 | uvx kubernetes-mcp-server@latest
84 |
85 | # Binary execution
86 | ./kubernetes-mcp-server
87 | ```
88 |
89 | This MCP server is designed to run both locally and remotely.
90 |
91 | ### Local Execution
92 |
93 | When running locally, the server connects to a Kubernetes or OpenShift cluster using the kubeconfig file.
94 | It reads the kubeconfig from the `--kubeconfig` flag, the `KUBECONFIG` environment variable, or defaults to `~/.kube/config`.
95 |
96 | This means that `npx -y kubernetes-mcp-server@latest` on a workstation will talk to whatever cluster your current kubeconfig points to (e.g. a local Kind cluster).
97 |
98 | ### Remote Execution
99 |
100 | When running remotely, the server can be deployed as a container image in a Kubernetes or OpenShift cluster.
101 | The server can be run as a Deployment, StatefulSet, or any other Kubernetes resource that suits your needs.
102 | The server will automatically use the in-cluster configuration to connect to the Kubernetes API server.
103 |
104 | ## Tests
105 |
106 | Run all Go tests with:
107 |
108 | ```bash
109 | make test
110 | ```
111 |
112 | The test suite relies on the `setup-envtest` tooling from `sigs.k8s.io/controller-runtime`.
113 | The first run downloads a Kubernetes `envtest` environment from the internet, so network access is required.
114 | Without it some tests will fail during setup.
115 |
116 | ## Linting
117 |
118 | Static analysis is performed with `golangci-lint`:
119 |
120 | ```bash
121 | make lint
122 | ```
123 |
124 | The `lint` target downloads the specified `golangci-lint` version if it is not already present under `_output/tools/bin/`.
125 |
126 | ## Additional Makefile targets
127 |
128 | Beyond the basic build, test, and lint targets, the Makefile provides additional utilities:
129 |
130 | **Local Development:**
131 | ```bash
132 | # Setup a complete local development environment with Kind cluster
133 | make local-env-setup
134 |
135 | # Tear down the local Kind cluster
136 | make local-env-teardown
137 |
138 | # Show Keycloak status and connection info (for OIDC testing)
139 | make keycloak-status
140 |
141 | # Tail Keycloak logs
142 | make keycloak-logs
143 |
144 | # Install required development tools (like Kind) to ./_output/bin/
145 | make tools
146 | ```
147 |
148 | **Distribution and Publishing:**
149 | ```bash
150 | # Copy compiled binaries to each npm package
151 | make npm-copy-binaries
152 |
153 | # Publish the npm packages
154 | make npm-publish
155 |
156 | # Publish the Python packages
157 | make python-publish
158 |
159 | # Update README.md with the latest toolsets
160 | make update-readme-tools
161 | ```
162 |
163 | Run `make help` to see all available targets with descriptions.
164 |
165 | ## Dependencies
166 |
167 | When introducing new modules run `make tidy` so that `go.mod` and `go.sum` remain tidy.
168 |
169 | ## Coding style
170 |
171 | - Go modules target Go **1.24** (see `go.mod`).
172 | - Tests are written with the standard library `testing` package.
173 | - Build, test and lint steps are defined in the Makefile—keep them working.
174 |
175 | ## Distribution Methods
176 |
177 | The server is distributed as a binary executable, a Docker image, an npm package, and a Python package.
178 |
179 | - **Native binaries** for Linux, macOS, and Windows are available in the GitHub releases.
180 | - A **container image** (Docker) is built and pushed to the `quay.io/manusa/kubernetes_mcp_server` repository.
181 | - An **npm** package is available at [npmjs.com](https://www.npmjs.com/package/kubernetes-mcp-server).
182 | It wraps the platform-specific binary and provides a convenient way to run the server using `npx`.
183 | - A **Python** package is available at [pypi.org](https://pypi.org/project/kubernetes-mcp-server/).
184 | It provides a script that downloads the correct platform binary from the GitHub releases page and runs it.
185 | It provides a convenient way to run the server using `uvx` or `python -m kubernetes_mcp_server`.
186 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes-mcp-server/cmd/testdata/empty-config.toml:
--------------------------------------------------------------------------------
```toml
1 |
```
--------------------------------------------------------------------------------
/pkg/mcp/testdata/helm-chart-no-op/Chart.yaml:
--------------------------------------------------------------------------------
```yaml
1 | apiVersion: v1
2 | name: no-op
3 | version: 1.33.7
4 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/realm/realm-create.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "realm": "openshift",
3 | "enabled": true
4 | }
5 |
```
--------------------------------------------------------------------------------
/pkg/mcp/testdata/helm-chart-secret/Chart.yaml:
--------------------------------------------------------------------------------
```yaml
1 | apiVersion: v2
2 | name: secret-chart
3 | version: 0.1.0
4 | type: application
5 |
6 |
```
--------------------------------------------------------------------------------
/python/kubernetes_mcp_server/__main__.py:
--------------------------------------------------------------------------------
```python
1 | from .kubernetes_mcp_server import main
2 |
3 | if __name__ == "__main__":
4 | main()
5 |
```
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
```yaml
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gomod"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 | open-pull-requests-limit: 10
8 |
```
--------------------------------------------------------------------------------
/pkg/version/version.go:
--------------------------------------------------------------------------------
```go
1 | package version
2 |
3 | var CommitHash = "unknown"
4 | var BuildTime = "1970-01-01T00:00:00Z"
5 | var Version = "0.0.0"
6 | var BinaryName = "kubernetes-mcp-server"
7 |
```
--------------------------------------------------------------------------------
/python/kubernetes_mcp_server/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Kubernetes MCP Server (Model Context Protocol) with special support for OpenShift.
3 | """
4 | from .kubernetes_mcp_server import main
5 |
6 | __all__ = ['main']
7 |
8 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/client-scopes/groups.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "groups",
3 | "protocol": "openid-connect",
4 | "attributes": {
5 | "display.on.consent.screen": "false",
6 | "include.in.token.scope": "true"
7 | }
8 | }
9 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/client-scopes/mcp-server.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "mcp-server",
3 | "protocol": "openid-connect",
4 | "attributes": {
5 | "display.on.consent.screen": "false",
6 | "include.in.token.scope": "true"
7 | }
8 | }
9 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/client-scopes/mcp-openshift.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "mcp:openshift",
3 | "protocol": "openid-connect",
4 | "attributes": {
5 | "display.on.consent.screen": "false",
6 | "include.in.token.scope": "true"
7 | }
8 | }
9 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/realm/realm-events-config.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "realm": "openshift",
3 | "enabled": true,
4 | "eventsEnabled": true,
5 | "eventsListeners": ["jboss-logging"],
6 | "adminEventsEnabled": true,
7 | "adminEventsDetailsEnabled": true
8 | }
9 |
```
--------------------------------------------------------------------------------
/cmd/kubernetes-mcp-server/main_test.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "os"
5 | )
6 |
7 | func Example_version() {
8 | oldArgs := os.Args
9 | defer func() { os.Args = oldArgs }()
10 | os.Args = []string{"kubernetes-mcp-server", "--version"}
11 | main()
12 | // Output: 0.0.0
13 | }
14 |
```
--------------------------------------------------------------------------------
/pkg/config/config_default_overrides.go:
--------------------------------------------------------------------------------
```go
1 | package config
2 |
3 | func defaultOverrides() StaticConfig {
4 | return StaticConfig{
5 | // IMPORTANT: this file is used to override default config values in downstream builds.
6 | // This is intentionally left blank.
7 | }
8 | }
9 |
```
--------------------------------------------------------------------------------
/internal/test/env.go:
--------------------------------------------------------------------------------
```go
1 | package test
2 |
3 | import (
4 | "os"
5 | "strings"
6 | )
7 |
8 | func RestoreEnv(originalEnv []string) {
9 | os.Clearenv()
10 | for _, env := range originalEnv {
11 | if key, value, found := strings.Cut(env, "="); found {
12 | _ = os.Setenv(key, value)
13 | }
14 | }
15 | }
16 |
```
--------------------------------------------------------------------------------
/pkg/mcp/modules.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
4 | import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
5 | import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
6 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/mappers/openshift-audience.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "openshift-audience",
3 | "protocol": "openid-connect",
4 | "protocolMapper": "oidc-audience-mapper",
5 | "config": {
6 | "included.client.audience": "openshift",
7 | "id.token.claim": "true",
8 | "access.token.claim": "true"
9 | }
10 | }
11 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/mappers/mcp-server-audience.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "mcp-server-audience",
3 | "protocol": "openid-connect",
4 | "protocolMapper": "oidc-audience-mapper",
5 | "config": {
6 | "included.client.audience": "mcp-server",
7 | "id.token.claim": "true",
8 | "access.token.claim": "true"
9 | }
10 | }
11 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/token.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 |
6 | authenticationv1api "k8s.io/api/authentication/v1"
7 | )
8 |
9 | type TokenVerifier interface {
10 | VerifyToken(ctx context.Context, cluster, token, audience string) (*authenticationv1api.UserInfo, []string, error)
11 | }
12 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/users/mcp.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "username": "mcp",
3 | "email": "[email protected]",
4 | "firstName": "MCP",
5 | "lastName": "User",
6 | "enabled": true,
7 | "emailVerified": true,
8 | "credentials": [
9 | {
10 | "type": "password",
11 | "value": "mcp",
12 | "temporary": false
13 | }
14 | ]
15 | }
16 |
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
1 | # Smithery.ai configuration https://smithery.ai/docs/config#smitheryyaml
2 | startCommand:
3 | type: stdio
4 | configSchema:
5 | {}
6 | commandFunction:
7 | |-
8 | (config) => ({
9 | "command": "npx",
10 | "args": [
11 | "-y", "kubernetes-mcp-server@latest"
12 | ]
13 | })
14 |
```
--------------------------------------------------------------------------------
/pkg/mcp/testdata/helm-chart-secret/templates/secret.yaml:
--------------------------------------------------------------------------------
```yaml
1 | apiVersion: v1
2 | kind: Secret
3 | metadata:
4 | name: {{ .Release.Name }}-secret
5 | labels:
6 | app.kubernetes.io/managed-by: {{ .Release.Service }}
7 | app.kubernetes.io/instance: {{ .Release.Name }}
8 | type: Opaque
9 | data:
10 | username: {{ b64enc "aitana" }}
11 | password: {{ b64enc "alex" }}
12 |
13 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/mappers/groups-membership.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "groups",
3 | "protocol": "openid-connect",
4 | "protocolMapper": "oidc-group-membership-mapper",
5 | "config": {
6 | "claim.name": "groups",
7 | "full.path": "false",
8 | "id.token.claim": "true",
9 | "access.token.claim": "true",
10 | "userinfo.token.claim": "true"
11 | }
12 | }
13 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM golang:latest AS builder
2 |
3 | WORKDIR /app
4 |
5 | COPY ./ ./
6 | RUN make build
7 |
8 | FROM registry.access.redhat.com/ubi9/ubi-minimal:latest
9 | WORKDIR /app
10 | COPY --from=builder /app/kubernetes-mcp-server /app/kubernetes-mcp-server
11 | USER 65532:65532
12 | ENTRYPOINT ["/app/kubernetes-mcp-server", "--port", "8080"]
13 |
14 | EXPOSE 8080
15 |
```
--------------------------------------------------------------------------------
/npm/kubernetes-mcp-server-linux-amd64/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "kubernetes-mcp-server-linux-amd64",
3 | "version": "0.0.0",
4 | "description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/containers/kubernetes-mcp-server.git"
8 | },
9 | "os": [
10 | "linux"
11 | ],
12 | "cpu": [
13 | "x64"
14 | ]
15 | }
16 |
```
--------------------------------------------------------------------------------
/npm/kubernetes-mcp-server-darwin-amd64/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "kubernetes-mcp-server-darwin-amd64",
3 | "version": "0.0.0",
4 | "description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/containers/kubernetes-mcp-server.git"
8 | },
9 | "os": [
10 | "darwin"
11 | ],
12 | "cpu": [
13 | "x64"
14 | ]
15 | }
16 |
```
--------------------------------------------------------------------------------
/npm/kubernetes-mcp-server-linux-arm64/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "kubernetes-mcp-server-linux-arm64",
3 | "version": "0.0.0",
4 | "description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/containers/kubernetes-mcp-server.git"
8 | },
9 | "os": [
10 | "linux"
11 | ],
12 | "cpu": [
13 | "arm64"
14 | ]
15 | }
16 |
```
--------------------------------------------------------------------------------
/npm/kubernetes-mcp-server-windows-amd64/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "kubernetes-mcp-server-windows-amd64",
3 | "version": "0.0.0",
4 | "description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/containers/kubernetes-mcp-server.git"
8 | },
9 | "os": [
10 | "win32"
11 | ],
12 | "cpu": [
13 | "x64"
14 | ]
15 | }
16 |
```
--------------------------------------------------------------------------------
/npm/kubernetes-mcp-server-darwin-arm64/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "kubernetes-mcp-server-darwin-arm64",
3 | "version": "0.0.0",
4 | "description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/containers/kubernetes-mcp-server.git"
8 | },
9 | "os": [
10 | "darwin"
11 | ],
12 | "cpu": [
13 | "arm64"
14 | ]
15 | }
16 |
```
--------------------------------------------------------------------------------
/npm/kubernetes-mcp-server-windows-arm64/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "kubernetes-mcp-server-windows-arm64",
3 | "version": "0.0.0",
4 | "description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/containers/kubernetes-mcp-server.git"
8 | },
9 | "os": [
10 | "win32"
11 | ],
12 | "cpu": [
13 | "arm64"
14 | ]
15 | }
16 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/mappers/username.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "username",
3 | "protocol": "openid-connect",
4 | "protocolMapper": "oidc-usermodel-property-mapper",
5 | "config": {
6 | "userinfo.token.claim": "true",
7 | "user.attribute": "username",
8 | "id.token.claim": "true",
9 | "access.token.claim": "true",
10 | "claim.name": "preferred_username",
11 | "jsonType.label": "String"
12 | }
13 | }
14 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/clients/openshift.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "clientId": "openshift",
3 | "enabled": true,
4 | "publicClient": false,
5 | "standardFlowEnabled": true,
6 | "directAccessGrantsEnabled": true,
7 | "serviceAccountsEnabled": true,
8 | "authorizationServicesEnabled": false,
9 | "redirectUris": ["*"],
10 | "webOrigins": ["*"],
11 | "defaultClientScopes": ["profile", "email", "groups"],
12 | "optionalClientScopes": []
13 | }
14 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/clients/mcp-client.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "clientId": "mcp-client",
3 | "enabled": true,
4 | "publicClient": true,
5 | "standardFlowEnabled": true,
6 | "directAccessGrantsEnabled": true,
7 | "serviceAccountsEnabled": false,
8 | "authorizationServicesEnabled": false,
9 | "redirectUris": ["*"],
10 | "webOrigins": ["*"],
11 | "defaultClientScopes": ["profile", "email"],
12 | "optionalClientScopes": ["mcp-server"]
13 | }
14 |
```
--------------------------------------------------------------------------------
/internal/test/test.go:
--------------------------------------------------------------------------------
```go
1 | package test
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "runtime"
7 | )
8 |
9 | func Must[T any](v T, err error) T {
10 | if err != nil {
11 | panic(err)
12 | }
13 | return v
14 | }
15 |
16 | func ReadFile(path ...string) string {
17 | _, file, _, _ := runtime.Caller(1)
18 | filePath := filepath.Join(append([]string{filepath.Dir(file)}, path...)...)
19 | fileBytes := Must(os.ReadFile(filePath))
20 | return string(fileBytes)
21 | }
22 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/common_test.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | func TestMain(m *testing.M) {
9 | // Set up
10 | _ = os.Setenv("KUBECONFIG", "/dev/null") // Avoid interference from existing kubeconfig
11 | _ = os.Setenv("KUBERNETES_SERVICE_HOST", "") // Avoid interference from in-cluster config
12 | _ = os.Setenv("KUBERNETES_SERVICE_PORT", "") // Avoid interference from in-cluster config
13 |
14 | // Run tests
15 | code := m.Run()
16 |
17 | // Tear down
18 | os.Exit(code)
19 | }
20 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/openshift.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 |
6 | "k8s.io/apimachinery/pkg/runtime/schema"
7 | )
8 |
9 | type Openshift interface {
10 | IsOpenShift(context.Context) bool
11 | }
12 |
13 | func (m *Manager) IsOpenShift(_ context.Context) bool {
14 | // This method should be fast and not block (it's called at startup)
15 | _, err := m.discoveryClient.ServerResourcesForGroupVersion(schema.GroupVersion{
16 | Group: "project.openshift.io",
17 | Version: "v1",
18 | }.String())
19 | return err == nil
20 | }
21 |
```
--------------------------------------------------------------------------------
/cmd/kubernetes-mcp-server/main.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/spf13/pflag"
7 | "k8s.io/cli-runtime/pkg/genericiooptions"
8 |
9 | "github.com/containers/kubernetes-mcp-server/pkg/kubernetes-mcp-server/cmd"
10 | )
11 |
12 | func main() {
13 | flags := pflag.NewFlagSet("kubernetes-mcp-server", pflag.ExitOnError)
14 | pflag.CommandLine = flags
15 |
16 | root := cmd.NewMCPServer(genericiooptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr})
17 | if err := root.Execute(); err != nil {
18 | os.Exit(1)
19 | }
20 | }
21 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/impersonate_roundtripper.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import "net/http"
4 |
5 | // nolint:unused
6 | type impersonateRoundTripper struct {
7 | delegate http.RoundTripper
8 | }
9 |
10 | // nolint:unused
11 | func (irt *impersonateRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
12 | // TODO: Solution won't work with discoveryclient which uses context.TODO() instead of the passed-in context
13 | if v, ok := req.Context().Value(OAuthAuthorizationHeader).(string); ok {
14 | req.Header.Set("Authorization", v)
15 | }
16 | return irt.delegate.RoundTrip(req)
17 | }
18 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml:
--------------------------------------------------------------------------------
```toml
1 | log_level = 1
2 | port = "9999"
3 | kubeconfig = "test"
4 | list_output = "yaml"
5 | read_only = true
6 | disable_destructive = true
7 |
8 | denied_resources = [
9 | {group = "apps", version = "v1", kind = "Deployment"},
10 | {group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
11 | ]
12 |
13 | enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
14 | disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
15 |
16 |
```
--------------------------------------------------------------------------------
/internal/test/kubernetes.go:
--------------------------------------------------------------------------------
```go
1 | package test
2 |
3 | import (
4 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
5 | )
6 |
7 | func KubeConfigFake() *clientcmdapi.Config {
8 | fakeConfig := clientcmdapi.NewConfig()
9 | fakeConfig.Clusters["fake"] = clientcmdapi.NewCluster()
10 | fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443"
11 | fakeConfig.AuthInfos["fake"] = clientcmdapi.NewAuthInfo()
12 | fakeConfig.Contexts["fake-context"] = clientcmdapi.NewContext()
13 | fakeConfig.Contexts["fake-context"].Cluster = "fake"
14 | fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
15 | fakeConfig.CurrentContext = "fake-context"
16 | return fakeConfig
17 | }
18 |
```
--------------------------------------------------------------------------------
/dev/config/cert-manager/selfsigned-issuer.yaml:
--------------------------------------------------------------------------------
```yaml
1 | apiVersion: cert-manager.io/v1
2 | kind: ClusterIssuer
3 | metadata:
4 | name: selfsigned-issuer
5 | spec:
6 | selfSigned: {}
7 | ---
8 | apiVersion: cert-manager.io/v1
9 | kind: Certificate
10 | metadata:
11 | name: selfsigned-ca
12 | namespace: cert-manager
13 | spec:
14 | isCA: true
15 | commonName: selfsigned-ca
16 | secretName: selfsigned-ca-secret
17 | privateKey:
18 | algorithm: ECDSA
19 | size: 256
20 | issuerRef:
21 | name: selfsigned-issuer
22 | kind: ClusterIssuer
23 | group: cert-manager.io
24 | ---
25 | apiVersion: cert-manager.io/v1
26 | kind: ClusterIssuer
27 | metadata:
28 | name: selfsigned-ca-issuer
29 | spec:
30 | ca:
31 | secretName: selfsigned-ca-secret
32 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/helm/toolset.go:
--------------------------------------------------------------------------------
```go
1 | package helm
2 |
3 | import (
4 | "slices"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/api"
7 | internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
8 | "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
9 | )
10 |
11 | type Toolset struct{}
12 |
13 | var _ api.Toolset = (*Toolset)(nil)
14 |
15 | func (t *Toolset) GetName() string {
16 | return "helm"
17 | }
18 |
19 | func (t *Toolset) GetDescription() string {
20 | return "Tools for managing Helm charts and releases"
21 | }
22 |
23 | func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool {
24 | return slices.Concat(
25 | initHelm(),
26 | )
27 | }
28 |
29 | func init() {
30 | toolsets.Register(&Toolset{})
31 | }
32 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/clients/mcp-server.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "clientId": "mcp-server",
3 | "enabled": true,
4 | "publicClient": false,
5 | "standardFlowEnabled": true,
6 | "directAccessGrantsEnabled": true,
7 | "serviceAccountsEnabled": true,
8 | "authorizationServicesEnabled": false,
9 | "redirectUris": ["*"],
10 | "webOrigins": ["*"],
11 | "defaultClientScopes": ["profile", "email", "groups", "mcp-server"],
12 | "optionalClientScopes": ["mcp:openshift"],
13 | "attributes": {
14 | "oauth2.device.authorization.grant.enabled": "false",
15 | "oidc.ciba.grant.enabled": "false",
16 | "backchannel.logout.session.required": "true",
17 | "backchannel.logout.revoke.offline.tokens": "false"
18 | }
19 | }
20 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/namespaces.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 | "k8s.io/apimachinery/pkg/runtime"
6 | "k8s.io/apimachinery/pkg/runtime/schema"
7 | )
8 |
9 | func (k *Kubernetes) NamespacesList(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
10 | return k.ResourcesList(ctx, &schema.GroupVersionKind{
11 | Group: "", Version: "v1", Kind: "Namespace",
12 | }, "", options)
13 | }
14 |
15 | func (k *Kubernetes) ProjectsList(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
16 | return k.ResourcesList(ctx, &schema.GroupVersionKind{
17 | Group: "project.openshift.io", Version: "v1", Kind: "Project",
18 | }, "", options)
19 | }
20 |
```
--------------------------------------------------------------------------------
/hack/generate-placeholder-ca.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 | set -e
3 |
4 | # Generate a placeholder self-signed CA certificate for KIND cluster startup
5 | # This will be replaced with the real cert-manager CA after the cluster is created
6 |
7 | CERT_DIR="_output/cert-manager-ca"
8 | CA_CERT="$CERT_DIR/ca.crt"
9 | CA_KEY="$CERT_DIR/ca.key"
10 |
11 | mkdir -p "$CERT_DIR"
12 |
13 | # Generate a self-signed CA certificate (valid placeholder)
14 | openssl req -x509 -newkey rsa:2048 -nodes \
15 | -keyout "$CA_KEY" \
16 | -out "$CA_CERT" \
17 | -days 365 \
18 | -subj "/CN=placeholder-ca" \
19 | 2>/dev/null
20 |
21 | echo "✅ Placeholder CA certificate created at $CA_CERT"
22 | echo "⚠️ This will be replaced with cert-manager CA after cluster creation"
23 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/config/toolset.go:
--------------------------------------------------------------------------------
```go
1 | package config
2 |
3 | import (
4 | "slices"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/api"
7 | internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
8 | "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
9 | )
10 |
11 | type Toolset struct{}
12 |
13 | var _ api.Toolset = (*Toolset)(nil)
14 |
15 | func (t *Toolset) GetName() string {
16 | return "config"
17 | }
18 |
19 | func (t *Toolset) GetDescription() string {
20 | return "View and manage the current local Kubernetes configuration (kubeconfig)"
21 | }
22 |
23 | func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool {
24 | return slices.Concat(
25 | initConfiguration(),
26 | )
27 | }
28 |
29 | func init() {
30 | toolsets.Register(&Toolset{})
31 | }
32 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/clients/mcp-server-update.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "clientId": "mcp-server",
3 | "enabled": true,
4 | "publicClient": false,
5 | "standardFlowEnabled": true,
6 | "directAccessGrantsEnabled": true,
7 | "serviceAccountsEnabled": true,
8 | "authorizationServicesEnabled": false,
9 | "redirectUris": ["*"],
10 | "webOrigins": ["*"],
11 | "defaultClientScopes": ["profile", "email", "groups", "mcp-server"],
12 | "optionalClientScopes": ["mcp:openshift"],
13 | "attributes": {
14 | "oauth2.device.authorization.grant.enabled": "false",
15 | "oidc.ciba.grant.enabled": "false",
16 | "backchannel.logout.session.required": "true",
17 | "backchannel.logout.revoke.offline.tokens": "false",
18 | "standard.token.exchange.enabled": "true"
19 | }
20 | }
21 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/rbac.yaml:
--------------------------------------------------------------------------------
```yaml
1 | # RBAC ClusterRoleBinding for mcp user with OIDC authentication
2 | #
3 | # IMPORTANT: This requires Kubernetes API server to be configured with OIDC:
4 | # --oidc-issuer-url=https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift
5 | # --oidc-username-claim=preferred_username
6 | #
7 | # Without OIDC configuration, this binding will not work.
8 | #
9 | apiVersion: rbac.authorization.k8s.io/v1
10 | kind: ClusterRoleBinding
11 | metadata:
12 | name: oidc-mcp-cluster-admin
13 | roleRef:
14 | apiGroup: rbac.authorization.k8s.io
15 | kind: ClusterRole
16 | name: cluster-admin
17 | subjects:
18 | - apiGroup: rbac.authorization.k8s.io
19 | kind: User
20 | name: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift#mcp
21 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/core/toolset.go:
--------------------------------------------------------------------------------
```go
1 | package core
2 |
3 | import (
4 | "slices"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/api"
7 | internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
8 | "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
9 | )
10 |
11 | type Toolset struct{}
12 |
13 | var _ api.Toolset = (*Toolset)(nil)
14 |
15 | func (t *Toolset) GetName() string {
16 | return "core"
17 | }
18 |
19 | func (t *Toolset) GetDescription() string {
20 | return "Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.)"
21 | }
22 |
23 | func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool {
24 | return slices.Concat(
25 | initEvents(),
26 | initNamespaces(o),
27 | initNodes(),
28 | initPods(),
29 | initResources(o),
30 | )
31 | }
32 |
33 | func init() {
34 | toolsets.Register(&Toolset{})
35 | }
36 |
```
--------------------------------------------------------------------------------
/python/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [build-system]
2 | requires = ["setuptools>=42", "wheel"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "kubernetes-mcp-server"
7 | version = "0.0.0"
8 | description = "Kubernetes MCP Server (Model Context Protocol) with special support for OpenShift"
9 | readme = {file="README.md", content-type="text/markdown"}
10 | requires-python = ">=3.6"
11 | license = "Apache-2.0"
12 | authors = [
13 | { name = "Marc Nuri", email = "[email protected]" }
14 | ]
15 | classifiers = [
16 | "Programming Language :: Python :: 3",
17 | "Operating System :: OS Independent",
18 | ]
19 |
20 | [project.urls]
21 | Homepage = "https://github.com/containers/kubernetes-mcp-server"
22 | Repository = "https://github.com/containers/kubernetes-mcp-server"
23 |
24 | [project.scripts]
25 | kubernetes-mcp-server = "kubernetes_mcp_server:main"
26 |
```
--------------------------------------------------------------------------------
/pkg/mcp/testdata/toolsets-config-tools.json:
--------------------------------------------------------------------------------
```json
1 | [
2 | {
3 | "annotations": {
4 | "title": "Configuration: View",
5 | "readOnlyHint": true,
6 | "destructiveHint": false,
7 | "idempotentHint": false,
8 | "openWorldHint": true
9 | },
10 | "description": "Get the current Kubernetes configuration content as a kubeconfig YAML",
11 | "inputSchema": {
12 | "type": "object",
13 | "properties": {
14 | "minified": {
15 | "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)",
16 | "type": "boolean"
17 | }
18 | }
19 | },
20 | "name": "configuration_view"
21 | }
22 | ]
23 |
```
--------------------------------------------------------------------------------
/dev/config/kind/cluster.yaml:
--------------------------------------------------------------------------------
```yaml
1 | kind: Cluster
2 | apiVersion: kind.x-k8s.io/v1alpha4
3 | nodes:
4 | - role: control-plane
5 | extraMounts:
6 | - hostPath: ./_output/cert-manager-ca/ca.crt
7 | containerPath: /etc/kubernetes/pki/keycloak-ca.crt
8 | readOnly: true
9 | kubeadmConfigPatches:
10 | - |
11 | kind: InitConfiguration
12 | nodeRegistration:
13 | kubeletExtraArgs:
14 | node-labels: "ingress-ready=true"
15 |
16 | kind: ClusterConfiguration
17 | apiServer:
18 | extraArgs:
19 | oidc-issuer-url: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift
20 | oidc-client-id: openshift
21 | oidc-username-claim: preferred_username
22 | oidc-groups-claim: groups
23 | oidc-ca-file: /etc/kubernetes/pki/keycloak-ca.crt
24 | extraPortMappings:
25 | - containerPort: 80
26 | hostPort: 8000
27 | protocol: TCP
28 | - containerPort: 443
29 | hostPort: 8443
30 | protocol: TCP
31 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/nodes.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | )
7 |
8 | func (k *Kubernetes) NodesLog(ctx context.Context, name string, logPath string, tail int64) (string, error) {
9 | // Use the node proxy API to access logs from the kubelet
10 | // Common log paths:
11 | // - /var/log/kubelet.log - kubelet logs
12 | // - /var/log/kube-proxy.log - kube-proxy logs
13 | // - /var/log/containers/ - container logs
14 |
15 | req, err := k.AccessControlClientset().NodesLogs(ctx, name, logPath)
16 | if err != nil {
17 | return "", err
18 | }
19 |
20 | // Query parameters for tail
21 | if tail > 0 {
22 | req.Param("tailLines", fmt.Sprintf("%d", tail))
23 | }
24 |
25 | result := req.Do(ctx)
26 | if result.Error() != nil {
27 | return "", fmt.Errorf("failed to get node logs: %w", result.Error())
28 | }
29 |
30 | rawData, err := result.Raw()
31 | if err != nil {
32 | return "", fmt.Errorf("failed to read node log response: %w", err)
33 | }
34 |
35 | return string(rawData), nil
36 | }
37 |
```
--------------------------------------------------------------------------------
/pkg/config/provider_config.go:
--------------------------------------------------------------------------------
```go
1 | package config
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/BurntSushi/toml"
7 | )
8 |
9 | // ProviderConfig is the interface that all provider-specific configurations must implement.
10 | // Each provider registers a factory function to parse its config from TOML primitives
11 | type ProviderConfig interface {
12 | Validate() error
13 | }
14 |
15 | type ProviderConfigParser func(primitive toml.Primitive, md toml.MetaData) (ProviderConfig, error)
16 |
17 | var (
18 | providerConfigParsers = make(map[string]ProviderConfigParser)
19 | )
20 |
21 | func RegisterProviderConfig(strategy string, parser ProviderConfigParser) {
22 | if _, exists := providerConfigParsers[strategy]; exists {
23 | panic(fmt.Sprintf("provider config parser already registered for strategy '%s'", strategy))
24 | }
25 |
26 | providerConfigParsers[strategy] = parser
27 | }
28 |
29 | func getProviderConfigParser(strategy string) (ProviderConfigParser, bool) {
30 | provider, ok := providerConfigParsers[strategy]
31 |
32 | return provider, ok
33 | }
34 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/ingress.yaml:
--------------------------------------------------------------------------------
```yaml
1 | ---
2 | apiVersion: networking.k8s.io/v1
3 | kind: Ingress
4 | metadata:
5 | name: keycloak
6 | namespace: keycloak
7 | labels:
8 | app: keycloak
9 | annotations:
10 | cert-manager.io/cluster-issuer: "selfsigned-ca-issuer"
11 | nginx.ingress.kubernetes.io/ssl-redirect: "true"
12 | nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
13 | # Required for Keycloak 26.2.0+ to include port in issuer URLs
14 | nginx.ingress.kubernetes.io/configuration-snippet: |
15 | proxy_set_header X-Forwarded-Proto https;
16 | proxy_set_header X-Forwarded-Port 8443;
17 | proxy_set_header X-Forwarded-Host $host:8443;
18 | spec:
19 | ingressClassName: nginx
20 | tls:
21 | - hosts:
22 | - keycloak.127-0-0-1.sslip.io
23 | secretName: keycloak-tls-cert
24 | rules:
25 | - host: keycloak.127-0-0-1.sslip.io
26 | http:
27 | paths:
28 | - path: /
29 | pathType: Prefix
30 | backend:
31 | service:
32 | name: keycloak
33 | port:
34 | number: 80
35 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/accesscontrol.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "fmt"
5 |
6 | "k8s.io/apimachinery/pkg/runtime/schema"
7 |
8 | "github.com/containers/kubernetes-mcp-server/pkg/config"
9 | )
10 |
11 | // isAllowed checks the resource is in denied list or not.
12 | // If it is in denied list, this function returns false.
13 | func isAllowed(
14 | staticConfig *config.StaticConfig, // TODO: maybe just use the denied resource slice
15 | gvk *schema.GroupVersionKind,
16 | ) bool {
17 | if staticConfig == nil {
18 | return true
19 | }
20 |
21 | for _, val := range staticConfig.DeniedResources {
22 | // If kind is empty, that means Group/Version pair is denied entirely
23 | if val.Kind == "" {
24 | if gvk.Group == val.Group && gvk.Version == val.Version {
25 | return false
26 | }
27 | }
28 | if gvk.Group == val.Group &&
29 | gvk.Version == val.Version &&
30 | gvk.Kind == val.Kind {
31 | return false
32 | }
33 | }
34 |
35 | return true
36 | }
37 |
38 | func isNotAllowedError(gvk *schema.GroupVersionKind) error {
39 | return fmt.Errorf("resource not allowed: %s", gvk.String())
40 | }
41 |
```
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
```yaml
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 | paths-ignore:
8 | - '.gitignore'
9 | - 'LICENSE'
10 | - '*.md'
11 | pull_request:
12 | paths-ignore:
13 | - '.gitignore'
14 | - 'LICENSE'
15 | - '*.md'
16 |
17 | concurrency:
18 | # Only run once for latest commit per ref and cancel other (previous) runs.
19 | group: ${{ github.workflow }}-${{ github.ref }}
20 | cancel-in-progress: true
21 |
22 | env:
23 | GO_VERSION: 1.23
24 |
25 | defaults:
26 | run:
27 | shell: bash
28 |
29 | jobs:
30 | build:
31 | name: Build on ${{ matrix.os }}
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | os:
36 | - ubuntu-latest #x64
37 | - ubuntu-24.04-arm #arm64
38 | - windows-latest #x64
39 | - macos-13 #x64
40 | - macos-latest #arm64
41 | runs-on: ${{ matrix.os }}
42 | steps:
43 | - name: Checkout
44 | uses: actions/checkout@v4
45 | - uses: actions/setup-go@v5
46 | with:
47 | go-version: ${{ env.GO_VERSION }}
48 | - name: Build
49 | run: make build
50 | - name: Test
51 | run: make test
52 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/toolsets.go:
--------------------------------------------------------------------------------
```go
1 | package toolsets
2 |
3 | import (
4 | "fmt"
5 | "slices"
6 | "strings"
7 |
8 | "github.com/containers/kubernetes-mcp-server/pkg/api"
9 | )
10 |
11 | var toolsets []api.Toolset
12 |
13 | // Clear removes all registered toolsets, TESTING PURPOSES ONLY.
14 | func Clear() {
15 | toolsets = []api.Toolset{}
16 | }
17 |
18 | func Register(toolset api.Toolset) {
19 | toolsets = append(toolsets, toolset)
20 | }
21 |
22 | func Toolsets() []api.Toolset {
23 | return toolsets
24 | }
25 |
26 | func ToolsetNames() []string {
27 | names := make([]string, 0)
28 | for _, toolset := range Toolsets() {
29 | names = append(names, toolset.GetName())
30 | }
31 | slices.Sort(names)
32 | return names
33 | }
34 |
35 | func ToolsetFromString(name string) api.Toolset {
36 | for _, toolset := range Toolsets() {
37 | if toolset.GetName() == strings.TrimSpace(name) {
38 | return toolset
39 | }
40 | }
41 | return nil
42 | }
43 |
44 | func Validate(toolsets []string) error {
45 | for _, toolset := range toolsets {
46 | if ToolsetFromString(toolset) == nil {
47 | return fmt.Errorf("invalid toolset name: %s, valid names are: %s", toolset, strings.Join(ToolsetNames(), ", "))
48 | }
49 | }
50 | return nil
51 | }
52 |
```
--------------------------------------------------------------------------------
/pkg/output/output_test.go:
--------------------------------------------------------------------------------
```go
1 | package output
2 |
3 | import (
4 | "encoding/json"
5 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
6 | "regexp"
7 | "testing"
8 | )
9 |
10 | func TestPlainTextUnstructuredList(t *testing.T) {
11 | var podList unstructured.UnstructuredList
12 | _ = json.Unmarshal([]byte(`
13 | { "apiVersion": "v1", "kind": "PodList", "items": [{
14 | "apiVersion": "v1", "kind": "Pod",
15 | "metadata": {
16 | "name": "pod-1", "namespace": "default", "creationTimestamp": "2023-10-01T00:00:00Z", "labels": { "app": "nginx" }
17 | },
18 | "spec": { "containers": [{ "name": "container-1", "image": "marcnuri/chuck-norris" }] } }
19 | ]}`), &podList)
20 | out, err := Table.PrintObj(&podList)
21 | t.Run("processes the list", func(t *testing.T) {
22 | if err != nil {
23 | t.Fatalf("Error printing pod list: %v", err)
24 | }
25 | })
26 | t.Run("prints headers", func(t *testing.T) {
27 | expectedHeaders := "NAME\\s+AGE\\s+LABELS"
28 | if m, e := regexp.MatchString(expectedHeaders, out); !m || e != nil {
29 | t.Errorf("Expected headers '%s' not found in output: %s", expectedHeaders, out)
30 | }
31 | })
32 | }
33 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/kubernetes.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "k8s.io/apimachinery/pkg/runtime"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/helm"
7 | "k8s.io/client-go/kubernetes/scheme"
8 |
9 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
10 | )
11 |
12 | type HeaderKey string
13 |
14 | const (
15 | CustomAuthorizationHeader = HeaderKey("kubernetes-authorization")
16 | OAuthAuthorizationHeader = HeaderKey("Authorization")
17 |
18 | CustomUserAgent = "kubernetes-mcp-server/bearer-token-auth"
19 | )
20 |
21 | type CloseWatchKubeConfig func() error
22 |
23 | type Kubernetes struct {
24 | manager *Manager
25 | }
26 |
27 | // AccessControlClientset returns the access-controlled clientset
28 | // This ensures that any denied resources configured in the system are properly enforced
29 | func (k *Kubernetes) AccessControlClientset() *AccessControlClientset {
30 | return k.manager.accessControlClientSet
31 | }
32 |
33 | var Scheme = scheme.Scheme
34 | var ParameterCodec = runtime.NewParameterCodec(Scheme)
35 |
36 | func (k *Kubernetes) NewHelm() *helm.Helm {
37 | // This is a derived Kubernetes, so it already has the Helm initialized
38 | return helm.NewHelm(k.manager)
39 | }
40 |
```
--------------------------------------------------------------------------------
/npm/kubernetes-mcp-server/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "kubernetes-mcp-server",
3 | "version": "0.0.0",
4 | "description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
5 | "main": "./bin/index.js",
6 | "bin": {
7 | "kubernetes-mcp-server": "bin/index.js"
8 | },
9 | "optionalDependencies": {
10 | "kubernetes-mcp-server-darwin-amd64": "0.0.0",
11 | "kubernetes-mcp-server-darwin-arm64": "0.0.0",
12 | "kubernetes-mcp-server-linux-amd64": "0.0.0",
13 | "kubernetes-mcp-server-linux-arm64": "0.0.0",
14 | "kubernetes-mcp-server-windows-amd64": "0.0.0",
15 | "kubernetes-mcp-server-windows-arm64": "0.0.0"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/containers/kubernetes-mcp-server.git"
20 | },
21 | "keywords": [
22 | "mcp",
23 | "kubernetes",
24 | "openshift",
25 | "model context protocol",
26 | "model",
27 | "context",
28 | "protocol"
29 | ],
30 | "author": {
31 | "name": "Marc Nuri",
32 | "url": "https://www.marcnuri.com"
33 | },
34 | "license": "Apache-2.0",
35 | "bugs": {
36 | "url": "https://github.com/containers/kubernetes-mcp-server/issues"
37 | },
38 | "homepage": "https://github.com/containers/kubernetes-mcp-server#readme"
39 | }
40 |
```
--------------------------------------------------------------------------------
/pkg/mcp/tool_filter.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "github.com/containers/kubernetes-mcp-server/pkg/api"
5 | "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
6 | )
7 |
8 | // ToolFilter is a function that takes a ServerTool and returns a boolean indicating whether to include the tool
9 | type ToolFilter func(tool api.ServerTool) bool
10 |
11 | func CompositeFilter(filters ...ToolFilter) ToolFilter {
12 | return func(tool api.ServerTool) bool {
13 | for _, f := range filters {
14 | if !f(tool) {
15 | return false
16 | }
17 | }
18 |
19 | return true
20 | }
21 | }
22 |
23 | func ShouldIncludeTargetListTool(targetName string, targets []string) ToolFilter {
24 | return func(tool api.ServerTool) bool {
25 | if !tool.IsTargetListProvider() {
26 | return true
27 | }
28 | if len(targets) <= 1 {
29 | // there is no need to provide a tool to list the single available target
30 | return false
31 | }
32 |
33 | // TODO: this check should be removed or make more generic when we have other
34 | if tool.Tool.Name == "configuration_contexts_list" && targetName != kubernetes.KubeConfigTargetParameterName {
35 | // let's not include configuration_contexts_list if we aren't targeting contexts in our Provider
36 | return false
37 | }
38 |
39 | return true
40 | }
41 | }
42 |
```
--------------------------------------------------------------------------------
/pkg/config/config_default.go:
--------------------------------------------------------------------------------
```go
1 | package config
2 |
3 | import (
4 | "bytes"
5 |
6 | "github.com/BurntSushi/toml"
7 | )
8 |
9 | func Default() *StaticConfig {
10 | defaultConfig := StaticConfig{
11 | ListOutput: "table",
12 | Toolsets: []string{"core", "config", "helm"},
13 | }
14 | overrides := defaultOverrides()
15 | mergedConfig := mergeConfig(defaultConfig, overrides)
16 | return &mergedConfig
17 | }
18 |
19 | // HasDefaultOverrides indicates whether the internal defaultOverrides function
20 | // provides any overrides or an empty StaticConfig.
21 | func HasDefaultOverrides() bool {
22 | overrides := defaultOverrides()
23 | var buf bytes.Buffer
24 | if err := toml.NewEncoder(&buf).Encode(overrides); err != nil {
25 | // If marshaling fails, assume no overrides
26 | return false
27 | }
28 | return len(bytes.TrimSpace(buf.Bytes())) > 0
29 | }
30 |
31 | // mergeConfig applies non-zero values from override to base using TOML serialization
32 | // and returns the merged StaticConfig.
33 | // In case of any error during marshalling or unmarshalling, it returns the base config unchanged.
34 | func mergeConfig(base, override StaticConfig) StaticConfig {
35 | var overrideBuffer bytes.Buffer
36 | if err := toml.NewEncoder(&overrideBuffer).Encode(override); err != nil {
37 | // If marshaling fails, return base unchanged
38 | return base
39 | }
40 |
41 | _, _ = toml.NewDecoder(&overrideBuffer).Decode(&base)
42 | return base
43 | }
44 |
```
--------------------------------------------------------------------------------
/pkg/http/middleware.go:
--------------------------------------------------------------------------------
```go
1 | package http
2 |
3 | import (
4 | "bufio"
5 | "net"
6 | "net/http"
7 | "time"
8 |
9 | "k8s.io/klog/v2"
10 | )
11 |
12 | func RequestMiddleware(next http.Handler) http.Handler {
13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14 | if r.URL.Path == "/healthz" {
15 | next.ServeHTTP(w, r)
16 | return
17 | }
18 |
19 | start := time.Now()
20 |
21 | lrw := &loggingResponseWriter{
22 | ResponseWriter: w,
23 | statusCode: http.StatusOK,
24 | }
25 |
26 | next.ServeHTTP(lrw, r)
27 |
28 | duration := time.Since(start)
29 | klog.V(5).Infof("%s %s %d %v", r.Method, r.URL.Path, lrw.statusCode, duration)
30 | })
31 | }
32 |
33 | type loggingResponseWriter struct {
34 | http.ResponseWriter
35 | statusCode int
36 | headerWritten bool
37 | }
38 |
39 | func (lrw *loggingResponseWriter) WriteHeader(code int) {
40 | if !lrw.headerWritten {
41 | lrw.statusCode = code
42 | lrw.headerWritten = true
43 | lrw.ResponseWriter.WriteHeader(code)
44 | }
45 | }
46 |
47 | func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
48 | if !lrw.headerWritten {
49 | lrw.statusCode = http.StatusOK
50 | lrw.headerWritten = true
51 | }
52 | return lrw.ResponseWriter.Write(b)
53 | }
54 |
55 | func (lrw *loggingResponseWriter) Flush() {
56 | if flusher, ok := lrw.ResponseWriter.(http.Flusher); ok {
57 | flusher.Flush()
58 | }
59 | }
60 |
61 | func (lrw *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
62 | if hijacker, ok := lrw.ResponseWriter.(http.Hijacker); ok {
63 | return hijacker.Hijack()
64 | }
65 | return nil, nil, http.ErrNotSupported
66 | }
67 |
```
--------------------------------------------------------------------------------
/pkg/api/toolsets_test.go:
--------------------------------------------------------------------------------
```go
1 | package api
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/suite"
7 | "k8s.io/utils/ptr"
8 | )
9 |
10 | type ToolsetsSuite struct {
11 | suite.Suite
12 | }
13 |
14 | func (s *ToolsetsSuite) TestServerTool() {
15 | s.Run("IsClusterAware", func() {
16 | s.Run("defaults to true", func() {
17 | tool := &ServerTool{}
18 | s.True(tool.IsClusterAware(), "Expected IsClusterAware to be true by default")
19 | })
20 | s.Run("can be set to false", func() {
21 | tool := &ServerTool{ClusterAware: ptr.To(false)}
22 | s.False(tool.IsClusterAware(), "Expected IsClusterAware to be false when set to false")
23 | })
24 | s.Run("can be set to true", func() {
25 | tool := &ServerTool{ClusterAware: ptr.To(true)}
26 | s.True(tool.IsClusterAware(), "Expected IsClusterAware to be true when set to true")
27 | })
28 | })
29 | s.Run("IsTargetListProvider", func() {
30 | s.Run("defaults to false", func() {
31 | tool := &ServerTool{}
32 | s.False(tool.IsTargetListProvider(), "Expected IsTargetListProvider to be false by default")
33 | })
34 | s.Run("can be set to false", func() {
35 | tool := &ServerTool{TargetListProvider: ptr.To(false)}
36 | s.False(tool.IsTargetListProvider(), "Expected IsTargetListProvider to be false when set to false")
37 | })
38 | s.Run("can be set to true", func() {
39 | tool := &ServerTool{TargetListProvider: ptr.To(true)}
40 | s.True(tool.IsTargetListProvider(), "Expected IsTargetListProvider to be true when set to true")
41 | })
42 | })
43 | }
44 |
45 | func TestToolsets(t *testing.T) {
46 | suite.Run(t, new(ToolsetsSuite))
47 | }
48 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/provider.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/config"
7 | )
8 |
9 | type Provider interface {
10 | // Openshift extends the Openshift interface to provide OpenShift specific functionality to toolset providers
11 | // TODO: with the configurable toolset implementation and especially the multi-cluster approach
12 | // extending this interface might not be a good idea anymore.
13 | // For the kubecontext case, a user might be targeting both an OpenShift flavored cluster and a vanilla Kubernetes cluster.
14 | // See: https://github.com/containers/kubernetes-mcp-server/pull/372#discussion_r2421592315
15 | Openshift
16 | TokenVerifier
17 | GetTargets(ctx context.Context) ([]string, error)
18 | GetDerivedKubernetes(ctx context.Context, target string) (*Kubernetes, error)
19 | GetDefaultTarget() string
20 | GetTargetParameterName() string
21 | WatchTargets(func() error)
22 | Close()
23 | }
24 |
25 | func NewProvider(cfg *config.StaticConfig) (Provider, error) {
26 | strategy := resolveStrategy(cfg)
27 |
28 | factory, err := getProviderFactory(strategy)
29 | if err != nil {
30 | return nil, err
31 | }
32 |
33 | return factory(cfg)
34 | }
35 |
36 | func resolveStrategy(cfg *config.StaticConfig) string {
37 | if cfg.ClusterProviderStrategy != "" {
38 | return cfg.ClusterProviderStrategy
39 | }
40 |
41 | if cfg.KubeConfig != "" {
42 | return config.ClusterProviderKubeConfig
43 | }
44 |
45 | if _, inClusterConfigErr := InClusterConfig(); inClusterConfigErr == nil {
46 | return config.ClusterProviderInCluster
47 | }
48 |
49 | return config.ClusterProviderKubeConfig
50 | }
51 |
```
--------------------------------------------------------------------------------
/dev/config/keycloak/deployment.yaml:
--------------------------------------------------------------------------------
```yaml
1 | ---
2 | apiVersion: v1
3 | kind: Namespace
4 | metadata:
5 | name: keycloak
6 | ---
7 | apiVersion: apps/v1
8 | kind: Deployment
9 | metadata:
10 | name: keycloak
11 | namespace: keycloak
12 | labels:
13 | app: keycloak
14 | spec:
15 | replicas: 1
16 | selector:
17 | matchLabels:
18 | app: keycloak
19 | template:
20 | metadata:
21 | labels:
22 | app: keycloak
23 | spec:
24 | containers:
25 | - name: keycloak
26 | image: quay.io/keycloak/keycloak:26.4
27 | args: ["start-dev"]
28 | env:
29 | - name: KC_BOOTSTRAP_ADMIN_USERNAME
30 | value: "admin"
31 | - name: KC_BOOTSTRAP_ADMIN_PASSWORD
32 | value: "admin"
33 | - name: KC_HOSTNAME
34 | value: "https://keycloak.127-0-0-1.sslip.io:8443"
35 | - name: KC_HTTP_ENABLED
36 | value: "true"
37 | - name: KC_HEALTH_ENABLED
38 | value: "true"
39 | - name: KC_PROXY_HEADERS
40 | value: "xforwarded"
41 | ports:
42 | - name: http
43 | containerPort: 8080
44 | readinessProbe:
45 | httpGet:
46 | path: /health/ready
47 | port: 9000
48 | initialDelaySeconds: 30
49 | periodSeconds: 10
50 | livenessProbe:
51 | httpGet:
52 | path: /health/live
53 | port: 9000
54 | initialDelaySeconds: 60
55 | periodSeconds: 30
56 | ---
57 | apiVersion: v1
58 | kind: Service
59 | metadata:
60 | name: keycloak
61 | namespace: keycloak
62 | labels:
63 | app: keycloak
64 | spec:
65 | ports:
66 | - name: http
67 | port: 80
68 | targetPort: 8080
69 | selector:
70 | app: keycloak
71 | type: ClusterIP
72 |
```
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
```yaml
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | concurrency:
9 | # Only run once for latest commit per ref and cancel other (previous) runs.
10 | group: ${{ github.workflow }}-${{ github.ref }}
11 | cancel-in-progress: true
12 |
13 | env:
14 | GO_VERSION: 1.23
15 | UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }}
16 |
17 | permissions:
18 | contents: write
19 | id-token: write # Required for npmjs OIDC
20 | discussions: write
21 |
22 | jobs:
23 | release:
24 | name: Release
25 | runs-on: macos-latest
26 | steps:
27 | - name: Checkout
28 | uses: actions/checkout@v4
29 | - uses: actions/setup-go@v5
30 | with:
31 | go-version: ${{ env.GO_VERSION }}
32 | - name: Build
33 | run: make build-all-platforms
34 | - name: Upload artifacts
35 | uses: softprops/action-gh-release@v2
36 | with:
37 | generate_release_notes: true
38 | make_latest: true
39 | files: |
40 | LICENSE
41 | kubernetes-mcp-server-*
42 | # Ensure npm 11.5.1 or later is installed (required for https://docs.npmjs.com/trusted-publishers)
43 | - name: Setup node
44 | uses: actions/setup-node@v6
45 | with:
46 | node-version: 24
47 | registry-url: 'https://registry.npmjs.org'
48 | - name: Publish npm
49 | run:
50 | make npm-publish
51 | python:
52 | name: Release Python
53 | # Python logic requires the tag/release version to be available from GitHub
54 | needs: release
55 | runs-on: ubuntu-latest
56 | steps:
57 | - name: Checkout
58 | uses: actions/checkout@v4
59 | - uses: astral-sh/setup-uv@v5
60 | - name: Publish Python
61 | run:
62 | make python-publish
63 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/events.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 | v1 "k8s.io/api/core/v1"
6 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
7 | "k8s.io/apimachinery/pkg/runtime"
8 | "k8s.io/apimachinery/pkg/runtime/schema"
9 | "strings"
10 | )
11 |
12 | func (k *Kubernetes) EventsList(ctx context.Context, namespace string) ([]map[string]any, error) {
13 | var eventMap []map[string]any
14 | raw, err := k.ResourcesList(ctx, &schema.GroupVersionKind{
15 | Group: "", Version: "v1", Kind: "Event",
16 | }, namespace, ResourceListOptions{})
17 | if err != nil {
18 | return eventMap, err
19 | }
20 | unstructuredList := raw.(*unstructured.UnstructuredList)
21 | if len(unstructuredList.Items) == 0 {
22 | return eventMap, nil
23 | }
24 | for _, item := range unstructuredList.Items {
25 | event := &v1.Event{}
26 | if err = runtime.DefaultUnstructuredConverter.FromUnstructured(item.Object, event); err != nil {
27 | return eventMap, err
28 | }
29 | timestamp := event.EventTime.Time
30 | if timestamp.IsZero() && event.Series != nil {
31 | timestamp = event.Series.LastObservedTime.Time
32 | } else if timestamp.IsZero() && event.Count > 1 {
33 | timestamp = event.LastTimestamp.Time
34 | } else if timestamp.IsZero() {
35 | timestamp = event.FirstTimestamp.Time
36 | }
37 | eventMap = append(eventMap, map[string]any{
38 | "Namespace": event.Namespace,
39 | "Timestamp": timestamp.String(),
40 | "Type": event.Type,
41 | "Reason": event.Reason,
42 | "InvolvedObject": map[string]string{
43 | "apiVersion": event.InvolvedObject.APIVersion,
44 | "Kind": event.InvolvedObject.Kind,
45 | "Name": event.InvolvedObject.Name,
46 | },
47 | "Message": strings.TrimSpace(event.Message),
48 | })
49 | }
50 | return eventMap, nil
51 | }
52 |
```
--------------------------------------------------------------------------------
/internal/test/mcp.go:
--------------------------------------------------------------------------------
```go
1 | package test
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/mark3labs/mcp-go/client"
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/stretchr/testify/require"
11 | "golang.org/x/net/context"
12 | )
13 |
14 | type McpClient struct {
15 | ctx context.Context
16 | testServer *httptest.Server
17 | *client.Client
18 | }
19 |
20 | func NewMcpClient(t *testing.T, mcpHttpServer http.Handler) *McpClient {
21 | require.NotNil(t, mcpHttpServer, "McpHttpServer must be provided")
22 | var err error
23 | ret := &McpClient{ctx: t.Context()}
24 | ret.testServer = httptest.NewServer(mcpHttpServer)
25 | ret.Client, err = client.NewStreamableHttpClient(ret.testServer.URL + "/mcp")
26 | require.NoError(t, err, "Expected no error creating MCP client")
27 | err = ret.Start(t.Context())
28 | require.NoError(t, err, "Expected no error starting MCP client")
29 | initRequest := mcp.InitializeRequest{}
30 | initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
31 | initRequest.Params.ClientInfo = mcp.Implementation{Name: "test", Version: "1.33.7"}
32 | _, err = ret.Initialize(t.Context(), initRequest)
33 | require.NoError(t, err, "Expected no error initializing MCP client")
34 | return ret
35 | }
36 |
37 | func (m *McpClient) Close() {
38 | if m.Client != nil {
39 | _ = m.Client.Close()
40 | }
41 | if m.testServer != nil {
42 | m.testServer.Close()
43 | }
44 | }
45 |
46 | // CallTool helper function to call a tool by name with arguments
47 | func (m *McpClient) CallTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) {
48 | callToolRequest := mcp.CallToolRequest{}
49 | callToolRequest.Params.Name = name
50 | callToolRequest.Params.Arguments = args
51 | return m.Client.CallTool(m.ctx, callToolRequest)
52 | }
53 |
```
--------------------------------------------------------------------------------
/npm/kubernetes-mcp-server/bin/index.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 |
3 | const childProcess = require('child_process');
4 |
5 | const BINARY_MAP = {
6 | darwin_x64: {name: 'kubernetes-mcp-server-darwin-amd64', suffix: ''},
7 | darwin_arm64: {name: 'kubernetes-mcp-server-darwin-arm64', suffix: ''},
8 | linux_x64: {name: 'kubernetes-mcp-server-linux-amd64', suffix: ''},
9 | linux_arm64: {name: 'kubernetes-mcp-server-linux-arm64', suffix: ''},
10 | win32_x64: {name: 'kubernetes-mcp-server-windows-amd64', suffix: '.exe'},
11 | win32_arm64: {name: 'kubernetes-mcp-server-windows-arm64', suffix: '.exe'},
12 | };
13 |
14 | // Resolving will fail if the optionalDependency was not installed or the platform/arch is not supported
15 | const resolveBinaryPath = () => {
16 | try {
17 | const binary = BINARY_MAP[`${process.platform}_${process.arch}`];
18 | return require.resolve(`${binary.name}/bin/${binary.name}${binary.suffix}`);
19 | } catch (e) {
20 | throw new Error(`Could not resolve binary path for platform/arch: ${process.platform}/${process.arch}`);
21 | }
22 | };
23 |
24 | const child = childProcess.spawn(resolveBinaryPath(), process.argv.slice(2), {
25 | stdio: 'inherit',
26 | });
27 |
28 | const handleSignal = () => (signal) => {
29 | console.log(`Received ${signal}, terminating child process...`);
30 | if (child && !child.killed) {
31 | child.kill(signal);
32 | }
33 | };
34 |
35 | ['SIGTERM', 'SIGINT', 'SIGHUP'].forEach((signal) => {
36 | process.on(signal, handleSignal(signal));
37 | });
38 |
39 | child.on('close', (code, signal) => {
40 | if (signal) {
41 | console.log(`Child process terminated by signal: ${signal}`);
42 | process.exit(128 + (signal === 'SIGTERM' ? 15 : signal === 'SIGINT' ? 2 : 1));
43 | } else {
44 | process.exit(code || 0);
45 | }
46 | });
47 |
```
--------------------------------------------------------------------------------
/pkg/mcp/tool_mutator.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 |
7 | "github.com/containers/kubernetes-mcp-server/pkg/api"
8 | "github.com/google/jsonschema-go/jsonschema"
9 | )
10 |
11 | type ToolMutator func(tool api.ServerTool) api.ServerTool
12 |
13 | const maxTargetsInEnum = 5 // TODO: test and validate that this is a reasonable cutoff
14 |
15 | // WithTargetParameter adds a target selection parameter to the tool's input schema if the tool is cluster-aware
16 | func WithTargetParameter(defaultCluster, targetParameterName string, targets []string) ToolMutator {
17 | return func(tool api.ServerTool) api.ServerTool {
18 | if !tool.IsClusterAware() {
19 | return tool
20 | }
21 |
22 | if tool.Tool.InputSchema == nil {
23 | tool.Tool.InputSchema = &jsonschema.Schema{Type: "object"}
24 | }
25 |
26 | if tool.Tool.InputSchema.Properties == nil {
27 | tool.Tool.InputSchema.Properties = make(map[string]*jsonschema.Schema)
28 | }
29 |
30 | if len(targets) > 1 {
31 | tool.Tool.InputSchema.Properties[targetParameterName] = createTargetProperty(
32 | defaultCluster,
33 | targetParameterName,
34 | targets,
35 | )
36 | }
37 |
38 | return tool
39 | }
40 | }
41 |
42 | func createTargetProperty(defaultCluster, targetName string, targets []string) *jsonschema.Schema {
43 | baseSchema := &jsonschema.Schema{
44 | Type: "string",
45 | Description: fmt.Sprintf(
46 | "Optional parameter selecting which %s to run the tool in. Defaults to %s if not set",
47 | targetName,
48 | defaultCluster,
49 | ),
50 | }
51 |
52 | if len(targets) <= maxTargetsInEnum {
53 | // Sort clusters to ensure consistent enum ordering
54 | sort.Strings(targets)
55 |
56 | enumValues := make([]any, 0, len(targets))
57 | for _, c := range targets {
58 | enumValues = append(enumValues, c)
59 | }
60 | baseSchema.Enum = enumValues
61 | }
62 |
63 | return baseSchema
64 | }
65 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/core/events.go:
--------------------------------------------------------------------------------
```go
1 | package core
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/google/jsonschema-go/jsonschema"
7 | "k8s.io/utils/ptr"
8 |
9 | "github.com/containers/kubernetes-mcp-server/pkg/api"
10 | "github.com/containers/kubernetes-mcp-server/pkg/output"
11 | )
12 |
13 | func initEvents() []api.ServerTool {
14 | return []api.ServerTool{
15 | {Tool: api.Tool{
16 | Name: "events_list",
17 | Description: "List all the Kubernetes events in the current cluster from all namespaces",
18 | InputSchema: &jsonschema.Schema{
19 | Type: "object",
20 | Properties: map[string]*jsonschema.Schema{
21 | "namespace": {
22 | Type: "string",
23 | Description: "Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces",
24 | },
25 | },
26 | },
27 | Annotations: api.ToolAnnotations{
28 | Title: "Events: List",
29 | ReadOnlyHint: ptr.To(true),
30 | DestructiveHint: ptr.To(false),
31 | IdempotentHint: ptr.To(false),
32 | OpenWorldHint: ptr.To(true),
33 | },
34 | }, Handler: eventsList},
35 | }
36 | }
37 |
38 | func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
39 | namespace := params.GetArguments()["namespace"]
40 | if namespace == nil {
41 | namespace = ""
42 | }
43 | eventMap, err := params.EventsList(params, namespace.(string))
44 | if err != nil {
45 | return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
46 | }
47 | if len(eventMap) == 0 {
48 | return api.NewToolCallResult("# No events found", nil), nil
49 | }
50 | yamlEvents, err := output.MarshalYaml(eventMap)
51 | if err != nil {
52 | err = fmt.Errorf("failed to list events in all namespaces: %v", err)
53 | }
54 | return api.NewToolCallResult(fmt.Sprintf("# The following events (YAML format) were found:\n%s", yamlEvents), err), nil
55 | }
56 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/provider_registry.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 |
7 | "github.com/containers/kubernetes-mcp-server/pkg/config"
8 | )
9 |
10 | // ProviderFactory creates a new Provider instance for a given strategy.
11 | // Implementations should validate that the Manager is compatible with their strategy
12 | // (e.g., kubeconfig provider should reject in-cluster managers).
13 | type ProviderFactory func(cfg *config.StaticConfig) (Provider, error)
14 |
15 | var providerFactories = make(map[string]ProviderFactory)
16 |
17 | // RegisterProvider registers a provider factory for a given strategy name.
18 | // This should be called from init() functions in provider implementation files.
19 | // Panics if a provider is already registered for the given strategy.
20 | func RegisterProvider(strategy string, factory ProviderFactory) {
21 | if _, exists := providerFactories[strategy]; exists {
22 | panic(fmt.Sprintf("provider already registered for strategy '%s'", strategy))
23 | }
24 | providerFactories[strategy] = factory
25 | }
26 |
27 | // getProviderFactory retrieves a registered provider factory by strategy name.
28 | // Returns an error if no provider is registered for the given strategy.
29 | func getProviderFactory(strategy string) (ProviderFactory, error) {
30 | factory, ok := providerFactories[strategy]
31 | if !ok {
32 | available := GetRegisteredStrategies()
33 | return nil, fmt.Errorf("no provider registered for strategy '%s', available strategies: %v", strategy, available)
34 | }
35 | return factory, nil
36 | }
37 |
38 | // GetRegisteredStrategies returns a sorted list of all registered strategy names.
39 | // This is useful for error messages and debugging.
40 | func GetRegisteredStrategies() []string {
41 | strategies := make([]string, 0, len(providerFactories))
42 | for strategy := range providerFactories {
43 | strategies = append(strategies, strategy)
44 | }
45 | sort.Strings(strategies)
46 | return strategies
47 | }
48 |
```
--------------------------------------------------------------------------------
/pkg/http/sts.go:
--------------------------------------------------------------------------------
```go
1 | package http
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/coreos/go-oidc/v3/oidc"
7 | "golang.org/x/oauth2"
8 | "golang.org/x/oauth2/google/externalaccount"
9 |
10 | "github.com/containers/kubernetes-mcp-server/pkg/config"
11 | )
12 |
13 | type staticSubjectTokenSupplier struct {
14 | token string
15 | }
16 |
17 | func (s *staticSubjectTokenSupplier) SubjectToken(_ context.Context, _ externalaccount.SupplierOptions) (string, error) {
18 | return s.token, nil
19 | }
20 |
21 | var _ externalaccount.SubjectTokenSupplier = &staticSubjectTokenSupplier{}
22 |
23 | type SecurityTokenService struct {
24 | *oidc.Provider
25 | ClientId string
26 | ClientSecret string
27 | ExternalAccountAudience string
28 | ExternalAccountScopes []string
29 | }
30 |
31 | func NewFromConfig(config *config.StaticConfig, provider *oidc.Provider) *SecurityTokenService {
32 | return &SecurityTokenService{
33 | Provider: provider,
34 | ClientId: config.StsClientId,
35 | ClientSecret: config.StsClientSecret,
36 | ExternalAccountAudience: config.StsAudience,
37 | ExternalAccountScopes: config.StsScopes,
38 | }
39 | }
40 |
41 | func (sts *SecurityTokenService) IsEnabled() bool {
42 | return sts.Provider != nil && sts.ClientId != "" && sts.ExternalAccountAudience != ""
43 | }
44 |
45 | func (sts *SecurityTokenService) ExternalAccountTokenExchange(ctx context.Context, originalToken *oauth2.Token) (*oauth2.Token, error) {
46 | ts, err := externalaccount.NewTokenSource(ctx, externalaccount.Config{
47 | TokenURL: sts.Endpoint().TokenURL,
48 | ClientID: sts.ClientId,
49 | ClientSecret: sts.ClientSecret,
50 | Audience: sts.ExternalAccountAudience,
51 | SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token",
52 | SubjectTokenSupplier: &staticSubjectTokenSupplier{token: originalToken.AccessToken},
53 | Scopes: sts.ExternalAccountScopes,
54 | })
55 | if err != nil {
56 | return nil, err
57 | }
58 | return ts.Token()
59 | }
60 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/provider_registry_test.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/config"
7 | "github.com/stretchr/testify/suite"
8 | )
9 |
10 | type ProviderRegistryTestSuite struct {
11 | BaseProviderSuite
12 | }
13 |
14 | func (s *ProviderRegistryTestSuite) TestRegisterProvider() {
15 | s.Run("With no pre-existing provider, registers the provider", func() {
16 | RegisterProvider("test-strategy", func(cfg *config.StaticConfig) (Provider, error) {
17 | return nil, nil
18 | })
19 | _, exists := providerFactories["test-strategy"]
20 | s.True(exists, "Provider should be registered")
21 | })
22 | s.Run("With pre-existing provider, panics", func() {
23 | RegisterProvider("test-pre-existent", func(cfg *config.StaticConfig) (Provider, error) {
24 | return nil, nil
25 | })
26 | s.Panics(func() {
27 | RegisterProvider("test-pre-existent", func(cfg *config.StaticConfig) (Provider, error) {
28 | return nil, nil
29 | })
30 | }, "Registering a provider with an existing strategy should panic")
31 | })
32 | }
33 |
34 | func (s *ProviderRegistryTestSuite) TestGetRegisteredStrategies() {
35 | s.Run("With no registered providers, returns empty list", func() {
36 | providerFactories = make(map[string]ProviderFactory)
37 | strategies := GetRegisteredStrategies()
38 | s.Empty(strategies, "No strategies should be registered")
39 | })
40 | s.Run("With multiple registered providers, returns sorted list", func() {
41 | providerFactories = make(map[string]ProviderFactory)
42 | RegisterProvider("foo-strategy", func(cfg *config.StaticConfig) (Provider, error) {
43 | return nil, nil
44 | })
45 | RegisterProvider("bar-strategy", func(cfg *config.StaticConfig) (Provider, error) {
46 | return nil, nil
47 | })
48 | strategies := GetRegisteredStrategies()
49 | expected := []string{"bar-strategy", "foo-strategy"}
50 | s.Equal(expected, strategies, "Strategies should be sorted alphabetically")
51 | })
52 | }
53 |
54 | func TestProviderRegistry(t *testing.T) {
55 | suite.Run(t, new(ProviderRegistryTestSuite))
56 | }
57 |
```
--------------------------------------------------------------------------------
/.github/workflows/release-image.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Release as container image
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags:
8 | - '*'
9 |
10 | env:
11 | IMAGE_NAME: quay.io/manusa/kubernetes_mcp_server
12 | TAG: ${{ github.ref_name == 'main' && 'latest' || github.ref_type == 'tag' && github.ref_name && startsWith(github.ref_name, 'v') && github.ref_name || 'unknown' }}
13 |
14 | jobs:
15 | publish-platform-images:
16 | name: 'Publish: linux-${{ matrix.platform.tag }}'
17 | strategy:
18 | fail-fast: true
19 | matrix:
20 | platform:
21 | - runner: ubuntu-latest
22 | tag: amd64
23 | - runner: ubuntu-24.04-arm
24 | tag: arm64
25 | runs-on: ${{ matrix.platform.runner }}
26 | steps:
27 | - name: Checkout
28 | uses: actions/checkout@v4
29 | - name: Install Podman # Not available in arm64 image
30 | run: |
31 | sudo apt-get update
32 | sudo apt-get install -y podman
33 | - name: Quay Login
34 | run: |
35 | echo ${{ secrets.QUAY_PASSWORD }} | podman login quay.io -u ${{ secrets.QUAY_USERNAME }} --password-stdin
36 | - name: Build Image
37 | run: |
38 | podman build \
39 | --platform "linux/${{ matrix.platform.tag }}" \
40 | -f Dockerfile \
41 | -t "${{ env.IMAGE_NAME }}:${{ env.TAG }}-linux-${{ matrix.platform.tag }}" \
42 | .
43 | - name: Push Image
44 | run: |
45 | podman push \
46 | "${{ env.IMAGE_NAME }}:${{ env.TAG }}-linux-${{ matrix.platform.tag }}"
47 |
48 | publish-manifest:
49 | name: Publish Manifest
50 | runs-on: ubuntu-latest
51 | needs: publish-platform-images
52 | steps:
53 | - name: Quay Login
54 | run: |
55 | echo ${{ secrets.QUAY_PASSWORD }} | podman login quay.io -u ${{ secrets.QUAY_USERNAME }} --password-stdin
56 | - name: Create Manifest
57 | run: |
58 | podman manifest create \
59 | "${{ env.IMAGE_NAME }}:${{ env.TAG }}" \
60 | "${{ env.IMAGE_NAME }}:${{ env.TAG }}-linux-amd64" \
61 | "${{ env.IMAGE_NAME }}:${{ env.TAG }}-linux-arm64"
62 | - name: Push Manifest
63 | run: |
64 | podman manifest push \
65 | "${{ env.IMAGE_NAME }}:${{ env.TAG }}"
66 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/core/namespaces.go:
--------------------------------------------------------------------------------
```go
1 | package core
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/google/jsonschema-go/jsonschema"
8 | "k8s.io/utils/ptr"
9 |
10 | "github.com/containers/kubernetes-mcp-server/pkg/api"
11 | internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
12 | )
13 |
14 | func initNamespaces(o internalk8s.Openshift) []api.ServerTool {
15 | ret := make([]api.ServerTool, 0)
16 | ret = append(ret, api.ServerTool{
17 | Tool: api.Tool{
18 | Name: "namespaces_list",
19 | Description: "List all the Kubernetes namespaces in the current cluster",
20 | InputSchema: &jsonschema.Schema{
21 | Type: "object",
22 | },
23 | Annotations: api.ToolAnnotations{
24 | Title: "Namespaces: List",
25 | ReadOnlyHint: ptr.To(true),
26 | DestructiveHint: ptr.To(false),
27 | IdempotentHint: ptr.To(false),
28 | OpenWorldHint: ptr.To(true),
29 | },
30 | }, Handler: namespacesList,
31 | })
32 | if o.IsOpenShift(context.Background()) {
33 | ret = append(ret, api.ServerTool{
34 | Tool: api.Tool{
35 | Name: "projects_list",
36 | Description: "List all the OpenShift projects in the current cluster",
37 | InputSchema: &jsonschema.Schema{
38 | Type: "object",
39 | },
40 | Annotations: api.ToolAnnotations{
41 | Title: "Projects: List",
42 | ReadOnlyHint: ptr.To(true),
43 | DestructiveHint: ptr.To(false),
44 | IdempotentHint: ptr.To(false),
45 | OpenWorldHint: ptr.To(true),
46 | },
47 | }, Handler: projectsList,
48 | })
49 | }
50 | return ret
51 | }
52 |
53 | func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
54 | ret, err := params.NamespacesList(params, internalk8s.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
55 | if err != nil {
56 | return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil
57 | }
58 | return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
59 | }
60 |
61 | func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
62 | ret, err := params.ProjectsList(params, internalk8s.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
63 | if err != nil {
64 | return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %v", err)), nil
65 | }
66 | return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
67 | }
68 |
```
--------------------------------------------------------------------------------
/pkg/mcp/m3labs.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 |
11 | "github.com/containers/kubernetes-mcp-server/pkg/api"
12 | )
13 |
14 | func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.ServerTool, error) {
15 | m3labTools := make([]server.ServerTool, 0)
16 | for _, tool := range tools {
17 | m3labTool := mcp.Tool{
18 | Name: tool.Tool.Name,
19 | Description: tool.Tool.Description,
20 | Annotations: mcp.ToolAnnotation{
21 | Title: tool.Tool.Annotations.Title,
22 | ReadOnlyHint: tool.Tool.Annotations.ReadOnlyHint,
23 | DestructiveHint: tool.Tool.Annotations.DestructiveHint,
24 | IdempotentHint: tool.Tool.Annotations.IdempotentHint,
25 | OpenWorldHint: tool.Tool.Annotations.OpenWorldHint,
26 | },
27 | }
28 | if tool.Tool.InputSchema != nil {
29 | schema, err := json.Marshal(tool.Tool.InputSchema)
30 | if err != nil {
31 | return nil, fmt.Errorf("failed to marshal tool input schema for tool %s: %v", tool.Tool.Name, err)
32 | }
33 | // TODO: temporary fix to append an empty properties object (some client have trouble parsing a schema without properties)
34 | // As opposed, Gemini had trouble for a while when properties was present but empty.
35 | // https://github.com/containers/kubernetes-mcp-server/issues/340
36 | if string(schema) == `{"type":"object"}` {
37 | schema = []byte(`{"type":"object","properties":{}}`)
38 | }
39 | m3labTool.RawInputSchema = schema
40 | }
41 | m3labHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
42 | // get the correct derived Kubernetes client for the target specified in the request
43 | cluster := request.GetString(s.p.GetTargetParameterName(), s.p.GetDefaultTarget())
44 | k, err := s.p.GetDerivedKubernetes(ctx, cluster)
45 | if err != nil {
46 | return nil, err
47 | }
48 |
49 | result, err := tool.Handler(api.ToolHandlerParams{
50 | Context: ctx,
51 | Kubernetes: k,
52 | ToolCallRequest: request,
53 | ListOutput: s.configuration.ListOutput(),
54 | })
55 | if err != nil {
56 | return nil, err
57 | }
58 | return NewTextResult(result.Content, result.Error), nil
59 | }
60 | m3labTools = append(m3labTools, server.ServerTool{Tool: m3labTool, Handler: m3labHandler})
61 | }
62 | return m3labTools, nil
63 | }
64 |
```
--------------------------------------------------------------------------------
/pkg/http/http.go:
--------------------------------------------------------------------------------
```go
1 | package http
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 | "os"
8 | "os/signal"
9 | "syscall"
10 | "time"
11 |
12 | "github.com/coreos/go-oidc/v3/oidc"
13 |
14 | "k8s.io/klog/v2"
15 |
16 | "github.com/containers/kubernetes-mcp-server/pkg/config"
17 | "github.com/containers/kubernetes-mcp-server/pkg/mcp"
18 | )
19 |
20 | const (
21 | healthEndpoint = "/healthz"
22 | mcpEndpoint = "/mcp"
23 | sseEndpoint = "/sse"
24 | sseMessageEndpoint = "/message"
25 | )
26 |
27 | func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.StaticConfig, oidcProvider *oidc.Provider, httpClient *http.Client) error {
28 | mux := http.NewServeMux()
29 |
30 | wrappedMux := RequestMiddleware(
31 | AuthorizationMiddleware(staticConfig, oidcProvider, mcpServer, httpClient)(mux),
32 | )
33 |
34 | httpServer := &http.Server{
35 | Addr: ":" + staticConfig.Port,
36 | Handler: wrappedMux,
37 | }
38 |
39 | sseServer := mcpServer.ServeSse(staticConfig.SSEBaseURL, httpServer)
40 | streamableHttpServer := mcpServer.ServeHTTP(httpServer)
41 | mux.Handle(sseEndpoint, sseServer)
42 | mux.Handle(sseMessageEndpoint, sseServer)
43 | mux.Handle(mcpEndpoint, streamableHttpServer)
44 | mux.HandleFunc(healthEndpoint, func(w http.ResponseWriter, r *http.Request) {
45 | w.WriteHeader(http.StatusOK)
46 | })
47 | mux.Handle("/.well-known/", WellKnownHandler(staticConfig, httpClient))
48 |
49 | ctx, cancel := context.WithCancel(ctx)
50 | defer cancel()
51 |
52 | sigChan := make(chan os.Signal, 1)
53 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM)
54 |
55 | serverErr := make(chan error, 1)
56 | go func() {
57 | klog.V(0).Infof("Streaming and SSE HTTP servers starting on port %s and paths /mcp, /sse, /message", staticConfig.Port)
58 | if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
59 | serverErr <- err
60 | }
61 | }()
62 |
63 | select {
64 | case sig := <-sigChan:
65 | klog.V(0).Infof("Received signal %v, initiating graceful shutdown", sig)
66 | cancel()
67 | case <-ctx.Done():
68 | klog.V(0).Infof("Context cancelled, initiating graceful shutdown")
69 | case err := <-serverErr:
70 | klog.Errorf("HTTP server error: %v", err)
71 | return err
72 | }
73 |
74 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
75 | defer shutdownCancel()
76 |
77 | klog.V(0).Infof("Shutting down HTTP server gracefully...")
78 | if err := httpServer.Shutdown(shutdownCtx); err != nil {
79 | klog.Errorf("HTTP server shutdown error: %v", err)
80 | return err
81 | }
82 |
83 | klog.V(0).Infof("HTTP server shutdown complete")
84 | return nil
85 | }
86 |
```
--------------------------------------------------------------------------------
/pkg/mcp/mcp_middleware_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/mark3labs/mcp-go/client/transport"
9 | )
10 |
11 | func TestToolCallLogging(t *testing.T) {
12 | testCaseWithContext(t, &mcpContext{logLevel: 5}, func(c *mcpContext) {
13 | _, _ = c.callTool("configuration_view", map[string]interface{}{
14 | "minified": false,
15 | })
16 | t.Run("Logs tool name", func(t *testing.T) {
17 | expectedLog := "mcp tool call: configuration_view("
18 | if !strings.Contains(c.logBuffer.String(), expectedLog) {
19 | t.Errorf("Expected log to contain '%s', got: %s", expectedLog, c.logBuffer.String())
20 | }
21 | })
22 | t.Run("Logs tool call arguments", func(t *testing.T) {
23 | expected := `"mcp tool call: configuration_view\((.+)\)"`
24 | m := regexp.MustCompile(expected).FindStringSubmatch(c.logBuffer.String())
25 | if len(m) != 2 {
26 | t.Fatalf("Expected log entry to contain arguments, got %s", c.logBuffer.String())
27 | }
28 | if m[1] != "map[minified:false]" {
29 | t.Errorf("Expected log arguments to be 'map[minified:false]', got %s", m[1])
30 | }
31 | })
32 | })
33 | before := func(c *mcpContext) {
34 | c.clientOptions = append(c.clientOptions, transport.WithHeaders(map[string]string{
35 | "Accept-Encoding": "gzip",
36 | "Authorization": "Bearer should-not-be-logged",
37 | "authorization": "Bearer should-not-be-logged",
38 | "a-loggable-header": "should-be-logged",
39 | }))
40 | }
41 | testCaseWithContext(t, &mcpContext{logLevel: 7, before: before}, func(c *mcpContext) {
42 | _, _ = c.callTool("configuration_view", map[string]interface{}{
43 | "minified": false,
44 | })
45 | t.Run("Logs tool call headers", func(t *testing.T) {
46 | expectedLog := "mcp tool call headers: A-Loggable-Header: should-be-logged"
47 | if !strings.Contains(c.logBuffer.String(), expectedLog) {
48 | t.Errorf("Expected log to contain '%s', got: %s", expectedLog, c.logBuffer.String())
49 | }
50 | })
51 | sensitiveHeaders := []string{
52 | "Authorization:",
53 | // TODO: Add more sensitive headers as needed
54 | }
55 | t.Run("Does not log sensitive headers", func(t *testing.T) {
56 | for _, header := range sensitiveHeaders {
57 | if strings.Contains(c.logBuffer.String(), header) {
58 | t.Errorf("Log should not contain sensitive header '%s', got: %s", header, c.logBuffer.String())
59 | }
60 | }
61 | })
62 | t.Run("Does not log sensitive header values", func(t *testing.T) {
63 | if strings.Contains(c.logBuffer.String(), "should-not-be-logged") {
64 | t.Errorf("Log should not contain sensitive header value 'should-not-be-logged', got: %s", c.logBuffer.String())
65 | }
66 | })
67 | })
68 | }
69 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/core/nodes.go:
--------------------------------------------------------------------------------
```go
1 | package core
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | "github.com/google/jsonschema-go/jsonschema"
8 | "k8s.io/utils/ptr"
9 |
10 | "github.com/containers/kubernetes-mcp-server/pkg/api"
11 | )
12 |
13 | func initNodes() []api.ServerTool {
14 | return []api.ServerTool{
15 | {Tool: api.Tool{
16 | Name: "nodes_log",
17 | Description: "Get logs from a Kubernetes node (kubelet, kube-proxy, or other system logs). This accesses node logs through the Kubernetes API proxy to the kubelet",
18 | InputSchema: &jsonschema.Schema{
19 | Type: "object",
20 | Properties: map[string]*jsonschema.Schema{
21 | "name": {
22 | Type: "string",
23 | Description: "Name of the node to get logs from",
24 | },
25 | "log_path": {
26 | Type: "string",
27 | Description: "Path to the log file on the node (e.g. 'kubelet.log', 'kube-proxy.log'). Default is 'kubelet.log'",
28 | Default: api.ToRawMessage("kubelet.log"),
29 | },
30 | "tail": {
31 | Type: "integer",
32 | Description: "Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)",
33 | Default: api.ToRawMessage(100),
34 | Minimum: ptr.To(float64(0)),
35 | },
36 | },
37 | Required: []string{"name"},
38 | },
39 | Annotations: api.ToolAnnotations{
40 | Title: "Node: Log",
41 | ReadOnlyHint: ptr.To(true),
42 | DestructiveHint: ptr.To(false),
43 | IdempotentHint: ptr.To(false),
44 | OpenWorldHint: ptr.To(true),
45 | },
46 | }, Handler: nodesLog},
47 | }
48 | }
49 |
50 | func nodesLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
51 | name, ok := params.GetArguments()["name"].(string)
52 | if !ok || name == "" {
53 | return api.NewToolCallResult("", errors.New("failed to get node log, missing argument name")), nil
54 | }
55 | logPath, ok := params.GetArguments()["log_path"].(string)
56 | if !ok || logPath == "" {
57 | logPath = "kubelet.log"
58 | }
59 | tail := params.GetArguments()["tail"]
60 | var tailInt int64
61 | if tail != nil {
62 | // Convert to int64 - safely handle both float64 (JSON number) and int types
63 | switch v := tail.(type) {
64 | case float64:
65 | tailInt = int64(v)
66 | case int:
67 | case int64:
68 | tailInt = v
69 | default:
70 | return api.NewToolCallResult("", fmt.Errorf("failed to parse tail parameter: expected integer, got %T", tail)), nil
71 | }
72 | }
73 | ret, err := params.NodesLog(params, name, logPath, tailInt)
74 | if err != nil {
75 | return api.NewToolCallResult("", fmt.Errorf("failed to get node log for %s: %v", name, err)), nil
76 | } else if ret == "" {
77 | ret = fmt.Sprintf("The node %s has not logged any message yet or the log file is empty", name)
78 | }
79 | return api.NewToolCallResult(ret, nil), nil
80 | }
81 |
```
--------------------------------------------------------------------------------
/pkg/mcp/testdata/toolsets-helm-tools.json:
--------------------------------------------------------------------------------
```json
1 | [
2 | {
3 | "annotations": {
4 | "title": "Helm: Install",
5 | "readOnlyHint": false,
6 | "destructiveHint": false,
7 | "idempotentHint": false,
8 | "openWorldHint": true
9 | },
10 | "description": "Install a Helm chart in the current or provided namespace",
11 | "inputSchema": {
12 | "type": "object",
13 | "properties": {
14 | "chart": {
15 | "description": "Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)",
16 | "type": "string"
17 | },
18 | "name": {
19 | "description": "Name of the Helm release (Optional, random name if not provided)",
20 | "type": "string"
21 | },
22 | "namespace": {
23 | "description": "Namespace to install the Helm chart in (Optional, current namespace if not provided)",
24 | "type": "string"
25 | },
26 | "values": {
27 | "description": "Values to pass to the Helm chart (Optional)",
28 | "type": "object"
29 | }
30 | },
31 | "required": [
32 | "chart"
33 | ]
34 | },
35 | "name": "helm_install"
36 | },
37 | {
38 | "annotations": {
39 | "title": "Helm: List",
40 | "readOnlyHint": true,
41 | "destructiveHint": false,
42 | "idempotentHint": false,
43 | "openWorldHint": true
44 | },
45 | "description": "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)",
46 | "inputSchema": {
47 | "type": "object",
48 | "properties": {
49 | "all_namespaces": {
50 | "description": "If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)",
51 | "type": "boolean"
52 | },
53 | "namespace": {
54 | "description": "Namespace to list Helm releases from (Optional, all namespaces if not provided)",
55 | "type": "string"
56 | }
57 | }
58 | },
59 | "name": "helm_list"
60 | },
61 | {
62 | "annotations": {
63 | "title": "Helm: Uninstall",
64 | "readOnlyHint": false,
65 | "destructiveHint": true,
66 | "idempotentHint": true,
67 | "openWorldHint": true
68 | },
69 | "description": "Uninstall a Helm release in the current or provided namespace",
70 | "inputSchema": {
71 | "type": "object",
72 | "properties": {
73 | "name": {
74 | "description": "Name of the Helm release to uninstall",
75 | "type": "string"
76 | },
77 | "namespace": {
78 | "description": "Namespace to uninstall the Helm release from (Optional, current namespace if not provided)",
79 | "type": "string"
80 | }
81 | },
82 | "required": [
83 | "name"
84 | ]
85 | },
86 | "name": "helm_uninstall"
87 | }
88 | ]
89 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/accesscontrol_restmapper.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "k8s.io/apimachinery/pkg/api/meta"
5 | "k8s.io/apimachinery/pkg/runtime/schema"
6 | "k8s.io/client-go/restmapper"
7 |
8 | "github.com/containers/kubernetes-mcp-server/pkg/config"
9 | )
10 |
11 | type AccessControlRESTMapper struct {
12 | delegate *restmapper.DeferredDiscoveryRESTMapper
13 | staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
14 | }
15 |
16 | var _ meta.RESTMapper = &AccessControlRESTMapper{}
17 |
18 | func (a AccessControlRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
19 | gvk, err := a.delegate.KindFor(resource)
20 | if err != nil {
21 | return schema.GroupVersionKind{}, err
22 | }
23 | if !isAllowed(a.staticConfig, &gvk) {
24 | return schema.GroupVersionKind{}, isNotAllowedError(&gvk)
25 | }
26 | return gvk, nil
27 | }
28 |
29 | func (a AccessControlRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
30 | gvks, err := a.delegate.KindsFor(resource)
31 | if err != nil {
32 | return nil, err
33 | }
34 | for i := range gvks {
35 | if !isAllowed(a.staticConfig, &gvks[i]) {
36 | return nil, isNotAllowedError(&gvks[i])
37 | }
38 | }
39 | return gvks, nil
40 | }
41 |
42 | func (a AccessControlRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) {
43 | return a.delegate.ResourceFor(input)
44 | }
45 |
46 | func (a AccessControlRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) {
47 | return a.delegate.ResourcesFor(input)
48 | }
49 |
50 | func (a AccessControlRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
51 | for _, version := range versions {
52 | gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind}
53 | if !isAllowed(a.staticConfig, gvk) {
54 | return nil, isNotAllowedError(gvk)
55 | }
56 | }
57 | return a.delegate.RESTMapping(gk, versions...)
58 | }
59 |
60 | func (a AccessControlRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
61 | for _, version := range versions {
62 | gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind}
63 | if !isAllowed(a.staticConfig, gvk) {
64 | return nil, isNotAllowedError(gvk)
65 | }
66 | }
67 | return a.delegate.RESTMappings(gk, versions...)
68 | }
69 |
70 | func (a AccessControlRESTMapper) ResourceSingularizer(resource string) (singular string, err error) {
71 | return a.delegate.ResourceSingularizer(resource)
72 | }
73 |
74 | func (a AccessControlRESTMapper) Reset() {
75 | a.delegate.Reset()
76 | }
77 |
78 | func NewAccessControlRESTMapper(delegate *restmapper.DeferredDiscoveryRESTMapper, staticConfig *config.StaticConfig) *AccessControlRESTMapper {
79 | return &AccessControlRESTMapper{delegate: delegate, staticConfig: staticConfig}
80 | }
81 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/provider_single.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/containers/kubernetes-mcp-server/pkg/config"
9 | authenticationv1api "k8s.io/api/authentication/v1"
10 | )
11 |
12 | // singleClusterProvider implements Provider for managing a single
13 | // Kubernetes cluster. Used for in-cluster deployments or when multi-cluster
14 | // support is disabled.
15 | type singleClusterProvider struct {
16 | strategy string
17 | manager *Manager
18 | }
19 |
20 | var _ Provider = &singleClusterProvider{}
21 |
22 | func init() {
23 | RegisterProvider(config.ClusterProviderInCluster, newSingleClusterProvider(config.ClusterProviderInCluster))
24 | RegisterProvider(config.ClusterProviderDisabled, newSingleClusterProvider(config.ClusterProviderDisabled))
25 | }
26 |
27 | // newSingleClusterProvider creates a provider that manages a single cluster.
28 | // When used within a cluster or with an 'in-cluster' strategy, it uses an InClusterManager.
29 | // Otherwise, it uses a KubeconfigManager.
30 | func newSingleClusterProvider(strategy string) ProviderFactory {
31 | return func(cfg *config.StaticConfig) (Provider, error) {
32 | if cfg != nil && cfg.KubeConfig != "" && strategy == config.ClusterProviderInCluster {
33 | return nil, fmt.Errorf("kubeconfig file %s cannot be used with the in-cluster ClusterProviderStrategy", cfg.KubeConfig)
34 | }
35 |
36 | var m *Manager
37 | var err error
38 | if strategy == config.ClusterProviderInCluster || IsInCluster(cfg) {
39 | m, err = NewInClusterManager(cfg)
40 | } else {
41 | m, err = NewKubeconfigManager(cfg, "")
42 | }
43 | if err != nil {
44 | if errors.Is(err, ErrorInClusterNotInCluster) {
45 | return nil, fmt.Errorf("server must be deployed in cluster for the %s ClusterProviderStrategy: %v", strategy, err)
46 | }
47 | return nil, err
48 | }
49 |
50 | return &singleClusterProvider{
51 | manager: m,
52 | strategy: strategy,
53 | }, nil
54 | }
55 | }
56 |
57 | func (p *singleClusterProvider) IsOpenShift(ctx context.Context) bool {
58 | return p.manager.IsOpenShift(ctx)
59 | }
60 |
61 | func (p *singleClusterProvider) VerifyToken(ctx context.Context, target, token, audience string) (*authenticationv1api.UserInfo, []string, error) {
62 | if target != "" {
63 | return nil, nil, fmt.Errorf("unable to get manager for other context/cluster with %s strategy", p.strategy)
64 | }
65 | return p.manager.VerifyToken(ctx, token, audience)
66 | }
67 |
68 | func (p *singleClusterProvider) GetTargets(_ context.Context) ([]string, error) {
69 | return []string{""}, nil
70 | }
71 |
72 | func (p *singleClusterProvider) GetDerivedKubernetes(ctx context.Context, target string) (*Kubernetes, error) {
73 | if target != "" {
74 | return nil, fmt.Errorf("unable to get manager for other context/cluster with %s strategy", p.strategy)
75 | }
76 |
77 | return p.manager.Derived(ctx)
78 | }
79 |
80 | func (p *singleClusterProvider) GetDefaultTarget() string {
81 | return ""
82 | }
83 |
84 | func (p *singleClusterProvider) GetTargetParameterName() string {
85 | return ""
86 | }
87 |
88 | func (p *singleClusterProvider) WatchTargets(watch func() error) {
89 | p.manager.WatchKubeConfig(watch)
90 | }
91 |
92 | func (p *singleClusterProvider) Close() {
93 | p.manager.Close()
94 | }
95 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/configuration.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "github.com/containers/kubernetes-mcp-server/pkg/config"
5 | "k8s.io/apimachinery/pkg/runtime"
6 | "k8s.io/client-go/rest"
7 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
8 | "k8s.io/client-go/tools/clientcmd/api/latest"
9 | )
10 |
11 | const inClusterKubeConfigDefaultContext = "in-cluster"
12 |
13 | // InClusterConfig is a variable that holds the function to get the in-cluster config
14 | // Exposed for testing
15 | var InClusterConfig = func() (*rest.Config, error) {
16 | // TODO use kubernetes.default.svc instead of resolved server
17 | // Currently running into: `http: server gave HTTP response to HTTPS client`
18 | inClusterConfig, err := rest.InClusterConfig()
19 | if inClusterConfig != nil {
20 | inClusterConfig.Host = "https://kubernetes.default.svc"
21 | }
22 | return inClusterConfig, err
23 | }
24 |
25 | func IsInCluster(cfg *config.StaticConfig) bool {
26 | // Even if running in-cluster, if a kubeconfig is provided, we consider it as out-of-cluster
27 | if cfg != nil && cfg.KubeConfig != "" {
28 | return false
29 | }
30 | restConfig, err := InClusterConfig()
31 | return err == nil && restConfig != nil
32 | }
33 |
34 | func (k *Kubernetes) NamespaceOrDefault(namespace string) string {
35 | return k.manager.NamespaceOrDefault(namespace)
36 | }
37 |
38 | // ConfigurationContextsDefault returns the current context name
39 | // TODO: Should be moved to the Provider level ?
40 | func (k *Kubernetes) ConfigurationContextsDefault() (string, error) {
41 | cfg, err := k.manager.clientCmdConfig.RawConfig()
42 | if err != nil {
43 | return "", err
44 | }
45 | return cfg.CurrentContext, nil
46 | }
47 |
48 | // ConfigurationContextsList returns the list of available context names
49 | // TODO: Should be moved to the Provider level ?
50 | func (k *Kubernetes) ConfigurationContextsList() (map[string]string, error) {
51 | cfg, err := k.manager.clientCmdConfig.RawConfig()
52 | if err != nil {
53 | return nil, err
54 | }
55 | contexts := make(map[string]string, len(cfg.Contexts))
56 | for name, context := range cfg.Contexts {
57 | cluster, ok := cfg.Clusters[context.Cluster]
58 | if !ok || cluster.Server == "" {
59 | contexts[name] = "unknown"
60 | } else {
61 | contexts[name] = cluster.Server
62 | }
63 | }
64 | return contexts, nil
65 | }
66 |
67 | // ConfigurationView returns the current kubeconfig content as a kubeconfig YAML
68 | // If minify is true, keeps only the current-context and the relevant pieces of the configuration for that context.
69 | // If minify is false, all contexts, clusters, auth-infos, and users are returned in the configuration.
70 | // TODO: Should be moved to the Provider level ?
71 | func (k *Kubernetes) ConfigurationView(minify bool) (runtime.Object, error) {
72 | var cfg clientcmdapi.Config
73 | var err error
74 | if cfg, err = k.manager.clientCmdConfig.RawConfig(); err != nil {
75 | return nil, err
76 | }
77 | if minify {
78 | if err = clientcmdapi.MinifyConfig(&cfg); err != nil {
79 | return nil, err
80 | }
81 | }
82 | //nolint:staticcheck
83 | if err = clientcmdapi.FlattenConfig(&cfg); err != nil {
84 | // ignore error
85 | //return "", err
86 | }
87 | return latest.Scheme.ConvertToVersion(&cfg, latest.ExternalVersion)
88 | }
89 |
```
--------------------------------------------------------------------------------
/pkg/mcp/tool_filter_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/api"
7 | "github.com/stretchr/testify/suite"
8 | "k8s.io/utils/ptr"
9 | )
10 |
11 | type ToolFilterSuite struct {
12 | suite.Suite
13 | }
14 |
15 | func (s *ToolFilterSuite) TestToolFilterType() {
16 | s.Run("ToolFilter type can be used as function", func() {
17 | var mutator ToolFilter = func(tool api.ServerTool) bool {
18 | return tool.Tool.Name == "included"
19 | }
20 | s.Run("returns true for included tool", func() {
21 | tool := api.ServerTool{Tool: api.Tool{Name: "included"}}
22 | s.True(mutator(tool))
23 | })
24 | s.Run("returns false for excluded tool", func() {
25 | tool := api.ServerTool{Tool: api.Tool{Name: "excluded"}}
26 | s.False(mutator(tool))
27 | })
28 | })
29 | }
30 |
31 | func (s *ToolFilterSuite) TestCompositeFilter() {
32 | s.Run("returns true if all filters return true", func() {
33 | filter := CompositeFilter(
34 | func(tool api.ServerTool) bool { return true },
35 | func(tool api.ServerTool) bool { return true },
36 | )
37 | tool := api.ServerTool{Tool: api.Tool{Name: "test"}}
38 | s.True(filter(tool))
39 | })
40 | s.Run("returns false if any filter returns false", func() {
41 | filter := CompositeFilter(
42 | func(tool api.ServerTool) bool { return true },
43 | func(tool api.ServerTool) bool { return false },
44 | )
45 | tool := api.ServerTool{Tool: api.Tool{Name: "test"}}
46 | s.False(filter(tool))
47 | })
48 | }
49 |
50 | func (s *ToolFilterSuite) TestShouldIncludeTargetListTool() {
51 | s.Run("non-target-list-provider tools: returns true ", func() {
52 | filter := ShouldIncludeTargetListTool("any", []string{"a", "b", "c", "d", "e", "f"})
53 | tool := api.ServerTool{Tool: api.Tool{Name: "test"}, TargetListProvider: ptr.To(false)}
54 | s.True(filter(tool))
55 | })
56 | s.Run("target-list-provider tools", func() {
57 | s.Run("with targets == 1: returns false", func() {
58 | filter := ShouldIncludeTargetListTool("any", []string{"1"})
59 | tool := api.ServerTool{Tool: api.Tool{Name: "test"}, TargetListProvider: ptr.To(true)}
60 | s.False(filter(tool))
61 | })
62 | s.Run("with targets == 1", func() {
63 | s.Run("and tool is configuration_contexts_list and targetName is not context: returns false", func() {
64 | filter := ShouldIncludeTargetListTool("not_context", []string{"1"})
65 | tool := api.ServerTool{Tool: api.Tool{Name: "configuration_contexts_list"}, TargetListProvider: ptr.To(true)}
66 | s.False(filter(tool))
67 | })
68 | s.Run("and tool is configuration_contexts_list and targetName is context: returns false", func() {
69 | filter := ShouldIncludeTargetListTool("context", []string{"1"})
70 | tool := api.ServerTool{Tool: api.Tool{Name: "configuration_contexts_list"}, TargetListProvider: ptr.To(true)}
71 | s.False(filter(tool))
72 | })
73 | s.Run("and tool is not configuration_contexts_list: returns false", func() {
74 | filter := ShouldIncludeTargetListTool("any", []string{"1"})
75 | tool := api.ServerTool{Tool: api.Tool{Name: "other_tool"}, TargetListProvider: ptr.To(true)}
76 | s.False(filter(tool))
77 | })
78 | })
79 | })
80 | }
81 |
82 | func TestToolFilter(t *testing.T) {
83 | suite.Run(t, new(ToolFilterSuite))
84 | }
85 |
```
--------------------------------------------------------------------------------
/pkg/http/wellknown.go:
--------------------------------------------------------------------------------
```go
1 | package http
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "strings"
8 |
9 | "github.com/containers/kubernetes-mcp-server/pkg/config"
10 | )
11 |
12 | const (
13 | oauthAuthorizationServerEndpoint = "/.well-known/oauth-authorization-server"
14 | oauthProtectedResourceEndpoint = "/.well-known/oauth-protected-resource"
15 | openIDConfigurationEndpoint = "/.well-known/openid-configuration"
16 | )
17 |
18 | var WellKnownEndpoints = []string{
19 | oauthAuthorizationServerEndpoint,
20 | oauthProtectedResourceEndpoint,
21 | openIDConfigurationEndpoint,
22 | }
23 |
24 | type WellKnown struct {
25 | authorizationUrl string
26 | scopesSupported []string
27 | disableDynamicClientRegistration bool
28 | httpClient *http.Client
29 | }
30 |
31 | var _ http.Handler = &WellKnown{}
32 |
33 | func WellKnownHandler(staticConfig *config.StaticConfig, httpClient *http.Client) http.Handler {
34 | authorizationUrl := staticConfig.AuthorizationURL
35 | if authorizationUrl != "" && strings.HasSuffix(authorizationUrl, "/") {
36 | authorizationUrl = strings.TrimSuffix(authorizationUrl, "/")
37 | }
38 | if httpClient == nil {
39 | httpClient = http.DefaultClient
40 | }
41 | return &WellKnown{
42 | authorizationUrl: authorizationUrl,
43 | disableDynamicClientRegistration: staticConfig.DisableDynamicClientRegistration,
44 | scopesSupported: staticConfig.OAuthScopes,
45 | httpClient: httpClient,
46 | }
47 | }
48 |
49 | func (w WellKnown) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
50 | if w.authorizationUrl == "" {
51 | http.Error(writer, "Authorization URL is not configured", http.StatusNotFound)
52 | return
53 | }
54 | req, err := http.NewRequest(request.Method, w.authorizationUrl+request.URL.EscapedPath(), nil)
55 | if err != nil {
56 | http.Error(writer, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
57 | return
58 | }
59 | for key, values := range request.Header {
60 | for _, value := range values {
61 | req.Header.Add(key, value)
62 | }
63 | }
64 | resp, err := w.httpClient.Do(req.WithContext(request.Context()))
65 | if err != nil {
66 | http.Error(writer, "Failed to perform request: "+err.Error(), http.StatusInternalServerError)
67 | return
68 | }
69 | defer func() { _ = resp.Body.Close() }()
70 | var resourceMetadata map[string]interface{}
71 | err = json.NewDecoder(resp.Body).Decode(&resourceMetadata)
72 | if err != nil {
73 | http.Error(writer, "Failed to read response body: "+err.Error(), http.StatusInternalServerError)
74 | return
75 | }
76 | if w.disableDynamicClientRegistration {
77 | delete(resourceMetadata, "registration_endpoint")
78 | resourceMetadata["require_request_uri_registration"] = false
79 | }
80 | if len(w.scopesSupported) > 0 {
81 | resourceMetadata["scopes_supported"] = w.scopesSupported
82 | }
83 | body, err := json.Marshal(resourceMetadata)
84 | if err != nil {
85 | http.Error(writer, "Failed to marshal response body: "+err.Error(), http.StatusInternalServerError)
86 | return
87 | }
88 | for key, values := range resp.Header {
89 | for _, value := range values {
90 | writer.Header().Add(key, value)
91 | }
92 | }
93 | writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
94 | writer.WriteHeader(resp.StatusCode)
95 | _, _ = writer.Write(body)
96 | }
97 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/toolsets_test.go:
--------------------------------------------------------------------------------
```go
1 | package toolsets
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/api"
7 | "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
8 | "github.com/stretchr/testify/suite"
9 | )
10 |
11 | type ToolsetsSuite struct {
12 | suite.Suite
13 | originalToolsets []api.Toolset
14 | }
15 |
16 | func (s *ToolsetsSuite) SetupTest() {
17 | s.originalToolsets = Toolsets()
18 | Clear()
19 | }
20 |
21 | func (s *ToolsetsSuite) TearDownTest() {
22 | for _, toolset := range s.originalToolsets {
23 | Register(toolset)
24 | }
25 | }
26 |
27 | type TestToolset struct {
28 | name string
29 | description string
30 | }
31 |
32 | func (t *TestToolset) GetName() string { return t.name }
33 |
34 | func (t *TestToolset) GetDescription() string { return t.description }
35 |
36 | func (t *TestToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool { return nil }
37 |
38 | var _ api.Toolset = (*TestToolset)(nil)
39 |
40 | func (s *ToolsetsSuite) TestToolsetNames() {
41 | s.Run("Returns empty list if no toolsets registered", func() {
42 | s.Empty(ToolsetNames(), "Expected empty list of toolset names")
43 | })
44 |
45 | Register(&TestToolset{name: "z"})
46 | Register(&TestToolset{name: "b"})
47 | Register(&TestToolset{name: "1"})
48 | s.Run("Returns sorted list of registered toolset names", func() {
49 | names := ToolsetNames()
50 | s.Equal([]string{"1", "b", "z"}, names, "Expected sorted list of toolset names")
51 | })
52 | }
53 |
54 | func (s *ToolsetsSuite) TestToolsetFromString() {
55 | s.Run("Returns nil if toolset not found", func() {
56 | s.Nil(ToolsetFromString("non-existent"), "Expected nil for non-existent toolset")
57 | })
58 | s.Run("Returns the correct toolset if found", func() {
59 | Register(&TestToolset{name: "existent"})
60 | res := ToolsetFromString("existent")
61 | s.NotNil(res, "Expected to find the registered toolset")
62 | s.Equal("existent", res.GetName(), "Expected to find the registered toolset by name")
63 | })
64 | s.Run("Returns the correct toolset if found after trimming spaces", func() {
65 | Register(&TestToolset{name: "no-spaces"})
66 | res := ToolsetFromString(" no-spaces ")
67 | s.NotNil(res, "Expected to find the registered toolset")
68 | s.Equal("no-spaces", res.GetName(), "Expected to find the registered toolset by name")
69 | })
70 | }
71 |
72 | func (s *ToolsetsSuite) TestValidate() {
73 | s.Run("Returns nil for empty toolset list", func() {
74 | s.Nil(Validate([]string{}), "Expected nil for empty toolset list")
75 | })
76 | s.Run("Returns error for invalid toolset name", func() {
77 | err := Validate([]string{"invalid"})
78 | s.NotNil(err, "Expected error for invalid toolset name")
79 | s.Contains(err.Error(), "invalid toolset name: invalid", "Expected error message to contain invalid toolset name")
80 | })
81 | s.Run("Returns nil for valid toolset names", func() {
82 | Register(&TestToolset{name: "valid-1"})
83 | Register(&TestToolset{name: "valid-2"})
84 | err := Validate([]string{"valid-1", "valid-2"})
85 | s.Nil(err, "Expected nil for valid toolset names")
86 | })
87 | s.Run("Returns error if any toolset name is invalid", func() {
88 | Register(&TestToolset{name: "valid"})
89 | err := Validate([]string{"valid", "invalid"})
90 | s.NotNil(err, "Expected error if any toolset name is invalid")
91 | s.Contains(err.Error(), "invalid toolset name: invalid", "Expected error message to contain invalid toolset name")
92 | })
93 | }
94 |
95 | func TestToolsets(t *testing.T) {
96 | suite.Run(t, new(ToolsetsSuite))
97 | }
98 |
```
--------------------------------------------------------------------------------
/python/kubernetes_mcp_server/kubernetes_mcp_server.py:
--------------------------------------------------------------------------------
```python
1 | import os
2 | import platform
3 | import subprocess
4 | import sys
5 | from pathlib import Path
6 | import shutil
7 | import tempfile
8 | import urllib.request
9 |
10 | if sys.version_info >= (3, 8):
11 | from importlib.metadata import version
12 | else:
13 | from importlib_metadata import version
14 |
15 | __version__ = version("kubernetes-mcp-server")
16 |
17 | def get_platform_binary():
18 | """Determine the correct binary for the current platform."""
19 | system = platform.system().lower()
20 | arch = platform.machine().lower()
21 |
22 | # Normalize architecture names
23 | if arch in ["x86_64", "amd64"]:
24 | arch = "amd64"
25 | elif arch in ["arm64", "aarch64"]:
26 | arch = "arm64"
27 | else:
28 | raise RuntimeError(f"Unsupported architecture: {arch}")
29 |
30 | if system == "darwin":
31 | return f"kubernetes-mcp-server-darwin-{arch}"
32 | elif system == "linux":
33 | return f"kubernetes-mcp-server-linux-{arch}"
34 | elif system == "windows":
35 | return f"kubernetes-mcp-server-windows-{arch}.exe"
36 | else:
37 | raise RuntimeError(f"Unsupported operating system: {system}")
38 |
39 | def download_binary(binary_version="latest", destination=None):
40 | """Download the correct binary for the current platform."""
41 | binary_name = get_platform_binary()
42 | if destination is None:
43 | destination = Path.home() / ".kubernetes-mcp-server" / "bin" / binary_version
44 |
45 | destination = Path(destination)
46 | destination.mkdir(parents=True, exist_ok=True)
47 | binary_path = destination / binary_name
48 |
49 | if binary_path.exists():
50 | return binary_path
51 |
52 | base_url = "https://github.com/containers/kubernetes-mcp-server/releases"
53 | if binary_version == "latest":
54 | release_url = f"{base_url}/latest/download/{binary_name}"
55 | else:
56 | release_url = f"{base_url}/download/v{binary_version}/{binary_name}"
57 |
58 | # Download the binary
59 | print(f"Downloading {binary_name} from {release_url}")
60 | with tempfile.NamedTemporaryFile(delete=False) as temp_file:
61 | try:
62 | with urllib.request.urlopen(release_url) as response:
63 | shutil.copyfileobj(response, temp_file)
64 | temp_file.close()
65 |
66 | # Move to destination and make executable
67 | shutil.move(temp_file.name, binary_path)
68 | binary_path.chmod(binary_path.stat().st_mode | 0o755) # Make executable
69 |
70 | return binary_path
71 | except Exception as e:
72 | os.unlink(temp_file.name)
73 | raise RuntimeError(f"Failed to download binary: {e}")
74 |
75 | def execute(args=None):
76 | """Download and execute the kubernetes-mcp-server binary."""
77 | if args is None:
78 | args = []
79 |
80 | try:
81 | binary_path = download_binary(binary_version=__version__)
82 | cmd = [str(binary_path)] + args
83 |
84 | # Execute the binary with the provided arguments
85 | process = subprocess.run(cmd)
86 | return process.returncode
87 | except Exception as e:
88 | print(f"Error executing kubernetes-mcp-server: {e}", file=sys.stderr)
89 | return 1
90 |
91 | if __name__ == "__main__":
92 | sys.exit(execute(sys.argv[1:]))
93 |
94 |
95 | def main():
96 | """Main function to execute the kubernetes-mcp-server binary."""
97 | args = sys.argv[1:] if len(sys.argv) > 1 else []
98 | return execute(args)
99 |
```
--------------------------------------------------------------------------------
/pkg/output/output.go:
--------------------------------------------------------------------------------
```go
1 | package output
2 |
3 | import (
4 | "bytes"
5 |
6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
8 | "k8s.io/apimachinery/pkg/runtime"
9 | "k8s.io/cli-runtime/pkg/printers"
10 | yml "sigs.k8s.io/yaml"
11 | )
12 |
13 | var Yaml = &yaml{}
14 |
15 | var Table = &table{}
16 |
17 | type Output interface {
18 | // GetName returns the name of the output format, will be used by the CLI to identify the output format.
19 | GetName() string
20 | // AsTable true if the kubernetes request should be made with the `application/json;as=Table;v=0.1` header.
21 | AsTable() bool
22 | // PrintObj prints the given object as a string.
23 | PrintObj(obj runtime.Unstructured) (string, error)
24 | }
25 |
26 | var Outputs = []Output{
27 | Yaml,
28 | Table,
29 | }
30 |
31 | var Names []string
32 |
33 | func FromString(name string) Output {
34 | for _, output := range Outputs {
35 | if output.GetName() == name {
36 | return output
37 | }
38 | }
39 | return nil
40 | }
41 |
42 | type yaml struct{}
43 |
44 | func (p *yaml) GetName() string {
45 | return "yaml"
46 | }
47 | func (p *yaml) AsTable() bool {
48 | return false
49 | }
50 | func (p *yaml) PrintObj(obj runtime.Unstructured) (string, error) {
51 | return MarshalYaml(obj)
52 | }
53 |
54 | type table struct{}
55 |
56 | func (p *table) GetName() string {
57 | return "table"
58 | }
59 | func (p *table) AsTable() bool {
60 | return true
61 | }
62 | func (p *table) PrintObj(obj runtime.Unstructured) (string, error) {
63 | var objectToPrint runtime.Object = obj
64 | withNamespace := false
65 | if obj.GetObjectKind().GroupVersionKind() == metav1.SchemeGroupVersion.WithKind("Table") {
66 | t := &metav1.Table{}
67 | if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), t); err == nil {
68 | objectToPrint = t
69 | // Process the Raw object to retrieve the complete metadata (see kubectl/pkg/printers/table_printer.go)
70 | for i := range t.Rows {
71 | row := &t.Rows[i]
72 | if row.Object.Raw == nil || row.Object.Object != nil {
73 | continue
74 | }
75 | row.Object.Object, err = runtime.Decode(unstructured.UnstructuredJSONScheme, row.Object.Raw)
76 | // Print namespace if at least one row has it (object is namespaced)
77 | if err == nil && !withNamespace {
78 | switch rowObject := row.Object.Object.(type) {
79 | case *unstructured.Unstructured:
80 | withNamespace = rowObject.GetNamespace() != ""
81 | }
82 | }
83 | }
84 | }
85 | }
86 | buf := new(bytes.Buffer)
87 | // TablePrinter is mutable and not thread-safe, must create a new instance each time.
88 | printer := printers.NewTablePrinter(printers.PrintOptions{
89 | WithNamespace: withNamespace,
90 | WithKind: true,
91 | Wide: true,
92 | ShowLabels: true,
93 | })
94 | err := printer.PrintObj(objectToPrint, buf)
95 | return buf.String(), err
96 | }
97 |
98 | func MarshalYaml(v any) (string, error) {
99 | switch t := v.(type) {
100 | //case unstructured.UnstructuredList:
101 | // for i := range t.Items {
102 | // t.Items[i].SetManagedFields(nil)
103 | // }
104 | // v = t.Items
105 | case *unstructured.UnstructuredList:
106 | for i := range t.Items {
107 | t.Items[i].SetManagedFields(nil)
108 | }
109 | v = t.Items
110 | //case unstructured.Unstructured:
111 | // t.SetManagedFields(nil)
112 | case *unstructured.Unstructured:
113 | t.SetManagedFields(nil)
114 | }
115 | ret, err := yml.Marshal(v)
116 | if err != nil {
117 | return "", err
118 | }
119 | return string(ret), nil
120 | }
121 |
122 | func init() {
123 | Names = make([]string, 0)
124 | for _, output := range Outputs {
125 | Names = append(Names, output.GetName())
126 | }
127 | }
128 |
```
--------------------------------------------------------------------------------
/internal/tools/update-readme/main.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "maps"
7 | "os"
8 | "path/filepath"
9 | "slices"
10 | "strings"
11 |
12 | internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
13 | "github.com/containers/kubernetes-mcp-server/pkg/toolsets"
14 |
15 | _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
16 | _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
17 | _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
18 | )
19 |
20 | type OpenShift struct{}
21 |
22 | func (o *OpenShift) IsOpenShift(ctx context.Context) bool {
23 | return true
24 | }
25 |
26 | var _ internalk8s.Openshift = (*OpenShift)(nil)
27 |
28 | func main() {
29 | // Snyk reports false positive unless we flow the args through filepath.Clean and filepath.Localize in this specific order
30 | var err error
31 | localReadmePath := filepath.Clean(os.Args[1])
32 | localReadmePath, err = filepath.Localize(localReadmePath)
33 | if err != nil {
34 | panic(err)
35 | }
36 | readme, err := os.ReadFile(localReadmePath)
37 | if err != nil {
38 | panic(err)
39 | }
40 | // Available Toolsets
41 | toolsetsList := toolsets.Toolsets()
42 | maxNameLen, maxDescLen := len("Toolset"), len("Description")
43 | for _, toolset := range toolsetsList {
44 | nameLen := len(toolset.GetName())
45 | descLen := len(toolset.GetDescription())
46 | if nameLen > maxNameLen {
47 | maxNameLen = nameLen
48 | }
49 | if descLen > maxDescLen {
50 | maxDescLen = descLen
51 | }
52 | }
53 | availableToolsets := strings.Builder{}
54 | availableToolsets.WriteString(fmt.Sprintf("| %-*s | %-*s |\n", maxNameLen, "Toolset", maxDescLen, "Description"))
55 | availableToolsets.WriteString(fmt.Sprintf("|-%s-|-%s-|\n", strings.Repeat("-", maxNameLen), strings.Repeat("-", maxDescLen)))
56 | for _, toolset := range toolsetsList {
57 | availableToolsets.WriteString(fmt.Sprintf("| %-*s | %-*s |\n", maxNameLen, toolset.GetName(), maxDescLen, toolset.GetDescription()))
58 | }
59 | updated := replaceBetweenMarkers(
60 | string(readme),
61 | "<!-- AVAILABLE-TOOLSETS-START -->",
62 | "<!-- AVAILABLE-TOOLSETS-END -->",
63 | availableToolsets.String(),
64 | )
65 |
66 | // Available Toolset Tools
67 | toolsetTools := strings.Builder{}
68 | for _, toolset := range toolsetsList {
69 | toolsetTools.WriteString("<details>\n\n<summary>" + toolset.GetName() + "</summary>\n\n")
70 | tools := toolset.GetTools(&OpenShift{})
71 | for _, tool := range tools {
72 | toolsetTools.WriteString(fmt.Sprintf("- **%s** - %s\n", tool.Tool.Name, tool.Tool.Description))
73 | for _, propName := range slices.Sorted(maps.Keys(tool.Tool.InputSchema.Properties)) {
74 | property := tool.Tool.InputSchema.Properties[propName]
75 | toolsetTools.WriteString(fmt.Sprintf(" - `%s` (`%s`)", propName, property.Type))
76 | if slices.Contains(tool.Tool.InputSchema.Required, propName) {
77 | toolsetTools.WriteString(" **(required)**")
78 | }
79 | toolsetTools.WriteString(fmt.Sprintf(" - %s\n", property.Description))
80 | }
81 | toolsetTools.WriteString("\n")
82 | }
83 | toolsetTools.WriteString("</details>\n\n")
84 | }
85 | updated = replaceBetweenMarkers(
86 | updated,
87 | "<!-- AVAILABLE-TOOLSETS-TOOLS-START -->",
88 | "<!-- AVAILABLE-TOOLSETS-TOOLS-END -->",
89 | toolsetTools.String(),
90 | )
91 |
92 | if err := os.WriteFile(localReadmePath, []byte(updated), 0o644); err != nil {
93 | panic(err)
94 | }
95 | }
96 |
97 | func replaceBetweenMarkers(content, startMarker, endMarker, replacement string) string {
98 | startIdx := strings.Index(content, startMarker)
99 | if startIdx == -1 {
100 | return content
101 | }
102 | endIdx := strings.Index(content, endMarker)
103 | if endIdx == -1 || endIdx <= startIdx {
104 | return content
105 | }
106 | return content[:startIdx+len(startMarker)] + "\n\n" + replacement + "\n" + content[endIdx:]
107 | }
108 |
```
--------------------------------------------------------------------------------
/pkg/api/toolsets.go:
--------------------------------------------------------------------------------
```go
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 |
7 | internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
8 | "github.com/containers/kubernetes-mcp-server/pkg/output"
9 | "github.com/google/jsonschema-go/jsonschema"
10 | )
11 |
12 | type ServerTool struct {
13 | Tool Tool
14 | Handler ToolHandlerFunc
15 | ClusterAware *bool
16 | TargetListProvider *bool
17 | }
18 |
19 | // IsClusterAware indicates whether the tool can accept a "cluster" or "context" parameter
20 | // to operate on a specific Kubernetes cluster context.
21 | // Defaults to true if not explicitly set
22 | func (s *ServerTool) IsClusterAware() bool {
23 | if s.ClusterAware != nil {
24 | return *s.ClusterAware
25 | }
26 | return true
27 | }
28 |
29 | // IsTargetListProvider indicates whether the tool is used to provide a list of targets (clusters/contexts)
30 | // Defaults to false if not explicitly set
31 | func (s *ServerTool) IsTargetListProvider() bool {
32 | if s.TargetListProvider != nil {
33 | return *s.TargetListProvider
34 | }
35 | return false
36 | }
37 |
38 | type Toolset interface {
39 | // GetName returns the name of the toolset.
40 | // Used to identify the toolset in configuration, logs, and command-line arguments.
41 | // Examples: "core", "metrics", "helm"
42 | GetName() string
43 | GetDescription() string
44 | GetTools(o internalk8s.Openshift) []ServerTool
45 | }
46 |
47 | type ToolCallRequest interface {
48 | GetArguments() map[string]any
49 | }
50 |
51 | type ToolCallResult struct {
52 | // Raw content returned by the tool.
53 | Content string
54 | // Error (non-protocol) to send back to the LLM.
55 | Error error
56 | }
57 |
58 | func NewToolCallResult(content string, err error) *ToolCallResult {
59 | return &ToolCallResult{
60 | Content: content,
61 | Error: err,
62 | }
63 | }
64 |
65 | type ToolHandlerParams struct {
66 | context.Context
67 | *internalk8s.Kubernetes
68 | ToolCallRequest
69 | ListOutput output.Output
70 | }
71 |
72 | type ToolHandlerFunc func(params ToolHandlerParams) (*ToolCallResult, error)
73 |
74 | type Tool struct {
75 | // The name of the tool.
76 | // Intended for programmatic or logical use, but used as a display name in past
77 | // specs or fallback (if title isn't present).
78 | Name string `json:"name"`
79 | // A human-readable description of the tool.
80 | //
81 | // This can be used by clients to improve the LLM's understanding of available
82 | // tools. It can be thought of like a "hint" to the model.
83 | Description string `json:"description,omitempty"`
84 | // Additional tool information.
85 | Annotations ToolAnnotations `json:"annotations"`
86 | // A JSON Schema object defining the expected parameters for the tool.
87 | InputSchema *jsonschema.Schema
88 | }
89 |
90 | type ToolAnnotations struct {
91 | // Human-readable title for the tool
92 | Title string `json:"title,omitempty"`
93 | // If true, the tool does not modify its environment.
94 | ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
95 | // If true, the tool may perform destructive updates to its environment. If
96 | // false, the tool performs only additive updates.
97 | //
98 | // (This property is meaningful only when ReadOnlyHint == false.)
99 | DestructiveHint *bool `json:"destructiveHint,omitempty"`
100 | // If true, calling the tool repeatedly with the same arguments will have no
101 | // additional effect on its environment.
102 | //
103 | // (This property is meaningful only when ReadOnlyHint == false.)
104 | IdempotentHint *bool `json:"idempotentHint,omitempty"`
105 | // If true, this tool may interact with an "open world" of external entities. If
106 | // false, the tool's domain of interaction is closed. For example, the world of
107 | // a web search tool is open, whereas that of a memory tool is not.
108 | OpenWorldHint *bool `json:"openWorldHint,omitempty"`
109 | }
110 |
111 | func ToRawMessage(v any) json.RawMessage {
112 | if v == nil {
113 | return nil
114 | }
115 | b, err := json.Marshal(v)
116 | if err != nil {
117 | return nil
118 | }
119 | return b
120 | }
121 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/provider_kubeconfig.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/containers/kubernetes-mcp-server/pkg/config"
9 | authenticationv1api "k8s.io/api/authentication/v1"
10 | )
11 |
12 | // KubeConfigTargetParameterName is the parameter name used to specify
13 | // the kubeconfig context when using the kubeconfig cluster provider strategy.
14 | const KubeConfigTargetParameterName = "context"
15 |
16 | // kubeConfigClusterProvider implements Provider for managing multiple
17 | // Kubernetes clusters using different contexts from a kubeconfig file.
18 | // It lazily initializes managers for each context as they are requested.
19 | type kubeConfigClusterProvider struct {
20 | defaultContext string
21 | managers map[string]*Manager
22 | }
23 |
24 | var _ Provider = &kubeConfigClusterProvider{}
25 |
26 | func init() {
27 | RegisterProvider(config.ClusterProviderKubeConfig, newKubeConfigClusterProvider)
28 | }
29 |
30 | // newKubeConfigClusterProvider creates a provider that manages multiple clusters
31 | // via kubeconfig contexts.
32 | // Internally, it leverages a KubeconfigManager for each context, initializing them
33 | // lazily when requested.
34 | func newKubeConfigClusterProvider(cfg *config.StaticConfig) (Provider, error) {
35 | m, err := NewKubeconfigManager(cfg, "")
36 | if err != nil {
37 | if errors.Is(err, ErrorKubeconfigInClusterNotAllowed) {
38 | return nil, fmt.Errorf("kubeconfig ClusterProviderStrategy is invalid for in-cluster deployments: %v", err)
39 | }
40 | return nil, err
41 | }
42 |
43 | rawConfig, err := m.clientCmdConfig.RawConfig()
44 | if err != nil {
45 | return nil, err
46 | }
47 |
48 | allClusterManagers := map[string]*Manager{
49 | rawConfig.CurrentContext: m, // we already initialized a manager for the default context, let's use it
50 | }
51 |
52 | for name := range rawConfig.Contexts {
53 | if name == rawConfig.CurrentContext {
54 | continue // already initialized this, don't want to set it to nil
55 | }
56 |
57 | allClusterManagers[name] = nil
58 | }
59 |
60 | return &kubeConfigClusterProvider{
61 | defaultContext: rawConfig.CurrentContext,
62 | managers: allClusterManagers,
63 | }, nil
64 | }
65 |
66 | func (p *kubeConfigClusterProvider) managerForContext(context string) (*Manager, error) {
67 | m, ok := p.managers[context]
68 | if ok && m != nil {
69 | return m, nil
70 | }
71 |
72 | baseManager := p.managers[p.defaultContext]
73 |
74 | m, err := NewKubeconfigManager(baseManager.staticConfig, context)
75 | if err != nil {
76 | return nil, err
77 | }
78 |
79 | p.managers[context] = m
80 |
81 | return m, nil
82 | }
83 |
84 | func (p *kubeConfigClusterProvider) IsOpenShift(ctx context.Context) bool {
85 | return p.managers[p.defaultContext].IsOpenShift(ctx)
86 | }
87 |
88 | func (p *kubeConfigClusterProvider) VerifyToken(ctx context.Context, context, token, audience string) (*authenticationv1api.UserInfo, []string, error) {
89 | m, err := p.managerForContext(context)
90 | if err != nil {
91 | return nil, nil, err
92 | }
93 | return m.VerifyToken(ctx, token, audience)
94 | }
95 |
96 | func (p *kubeConfigClusterProvider) GetTargets(_ context.Context) ([]string, error) {
97 | contextNames := make([]string, 0, len(p.managers))
98 | for contextName := range p.managers {
99 | contextNames = append(contextNames, contextName)
100 | }
101 |
102 | return contextNames, nil
103 | }
104 |
105 | func (p *kubeConfigClusterProvider) GetTargetParameterName() string {
106 | return KubeConfigTargetParameterName
107 | }
108 |
109 | func (p *kubeConfigClusterProvider) GetDerivedKubernetes(ctx context.Context, context string) (*Kubernetes, error) {
110 | m, err := p.managerForContext(context)
111 | if err != nil {
112 | return nil, err
113 | }
114 | return m.Derived(ctx)
115 | }
116 |
117 | func (p *kubeConfigClusterProvider) GetDefaultTarget() string {
118 | return p.defaultContext
119 | }
120 |
121 | func (p *kubeConfigClusterProvider) WatchTargets(onKubeConfigChanged func() error) {
122 | m := p.managers[p.defaultContext]
123 |
124 | m.WatchKubeConfig(onKubeConfigChanged)
125 | }
126 |
127 | func (p *kubeConfigClusterProvider) Close() {
128 | m := p.managers[p.defaultContext]
129 |
130 | m.Close()
131 | }
132 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/config/configuration.go:
--------------------------------------------------------------------------------
```go
1 | package config
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/google/jsonschema-go/jsonschema"
7 | "k8s.io/utils/ptr"
8 |
9 | "github.com/containers/kubernetes-mcp-server/pkg/api"
10 | "github.com/containers/kubernetes-mcp-server/pkg/output"
11 | )
12 |
13 | func initConfiguration() []api.ServerTool {
14 | tools := []api.ServerTool{
15 | {
16 | Tool: api.Tool{
17 | Name: "configuration_contexts_list",
18 | Description: "List all available context names and associated server urls from the kubeconfig file",
19 | InputSchema: &jsonschema.Schema{
20 | Type: "object",
21 | },
22 | Annotations: api.ToolAnnotations{
23 | Title: "Configuration: Contexts List",
24 | ReadOnlyHint: ptr.To(true),
25 | DestructiveHint: ptr.To(false),
26 | IdempotentHint: ptr.To(true),
27 | OpenWorldHint: ptr.To(false),
28 | },
29 | },
30 | ClusterAware: ptr.To(false),
31 | TargetListProvider: ptr.To(true),
32 | Handler: contextsList,
33 | },
34 | {
35 | Tool: api.Tool{
36 | Name: "configuration_view",
37 | Description: "Get the current Kubernetes configuration content as a kubeconfig YAML",
38 | InputSchema: &jsonschema.Schema{
39 | Type: "object",
40 | Properties: map[string]*jsonschema.Schema{
41 | "minified": {
42 | Type: "boolean",
43 | Description: "Return a minified version of the configuration. " +
44 | "If set to true, keeps only the current-context and the relevant pieces of the configuration for that context. " +
45 | "If set to false, all contexts, clusters, auth-infos, and users are returned in the configuration. " +
46 | "(Optional, default true)",
47 | },
48 | },
49 | },
50 | Annotations: api.ToolAnnotations{
51 | Title: "Configuration: View",
52 | ReadOnlyHint: ptr.To(true),
53 | DestructiveHint: ptr.To(false),
54 | IdempotentHint: ptr.To(false),
55 | OpenWorldHint: ptr.To(true),
56 | },
57 | },
58 | ClusterAware: ptr.To(false),
59 | Handler: configurationView,
60 | },
61 | }
62 | return tools
63 | }
64 |
65 | func contextsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
66 | contexts, err := params.ConfigurationContextsList()
67 | if err != nil {
68 | return api.NewToolCallResult("", fmt.Errorf("failed to list contexts: %v", err)), nil
69 | }
70 |
71 | if len(contexts) == 0 {
72 | return api.NewToolCallResult("No contexts found in kubeconfig", nil), nil
73 | }
74 |
75 | defaultContext, err := params.ConfigurationContextsDefault()
76 | if err != nil {
77 | return api.NewToolCallResult("", fmt.Errorf("failed to get default context: %v", err)), nil
78 | }
79 |
80 | result := fmt.Sprintf("Available Kubernetes contexts (%d total, default: %s):\n\n", len(contexts), defaultContext)
81 | result += "Format: [*] CONTEXT_NAME -> SERVER_URL\n"
82 | result += " (* indicates the default context used in tools if context is not set)\n\n"
83 | result += "Contexts:\n---------\n"
84 | for context, server := range contexts {
85 | marker := " "
86 | if context == defaultContext {
87 | marker = "*"
88 | }
89 |
90 | result += fmt.Sprintf("%s%s -> %s\n", marker, context, server)
91 | }
92 | result += "---------\n\n"
93 |
94 | result += "To use a specific context with any tool, set the 'context' parameter in the tool call arguments"
95 |
96 | // TODO: Review output format, current is not parseable and might not be ideal for LLM consumption
97 | return api.NewToolCallResult(result, nil), nil
98 | }
99 |
100 | func configurationView(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
101 | minify := true
102 | minified := params.GetArguments()["minified"]
103 | if _, ok := minified.(bool); ok {
104 | minify = minified.(bool)
105 | }
106 | ret, err := params.ConfigurationView(minify)
107 | if err != nil {
108 | return api.NewToolCallResult("", fmt.Errorf("failed to get configuration: %v", err)), nil
109 | }
110 | configurationYaml, err := output.MarshalYaml(ret)
111 | if err != nil {
112 | err = fmt.Errorf("failed to get configuration: %v", err)
113 | }
114 | return api.NewToolCallResult(configurationYaml, err), nil
115 | }
116 |
```