├── .github ├── dependabot.yml └── workflows │ ├── build.yaml │ ├── release-image.yml │ └── release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── kubernetes-mcp-server │ ├── main.go │ └── main_test.go ├── docs └── images │ ├── kubernetes-mcp-server-github-copilot.jpg │ └── vibe-coding.jpg ├── go.mod ├── go.sum ├── npm ├── 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 └── kubernetes-mcp-server │ ├── bin │ └── index.js │ └── package.json ├── pkg ├── helm │ └── helm.go ├── kubernetes-mcp-server │ └── cmd │ │ ├── root.go │ │ └── root_test.go ├── kubernetes │ ├── configuration.go │ ├── configuration_test.go │ ├── events.go │ ├── impersonate_roundtripper.go │ ├── kubernetes.go │ ├── namespaces.go │ ├── openshift.go │ ├── pods.go │ └── resources.go ├── mcp │ ├── common_test.go │ ├── configuration.go │ ├── configuration_test.go │ ├── events.go │ ├── events_test.go │ ├── helm.go │ ├── helm_test.go │ ├── mcp.go │ ├── mcp_test.go │ ├── mock_server_test.go │ ├── namespaces.go │ ├── namespaces_test.go │ ├── pods.go │ ├── pods_exec_test.go │ ├── pods_test.go │ ├── profiles.go │ ├── profiles_test.go │ ├── resources.go │ ├── resources_test.go │ └── testdata │ │ └── helm-chart-no-op │ │ └── Chart.yaml └── version │ └── version.go ├── python ├── README.md ├── kubernetes_mcp_server │ ├── __init__.py │ ├── __main__.py │ └── kubernetes_mcp_server.py └── pyproject.toml └── smithery.yaml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/build.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 | -------------------------------------------------------------------------------- /.github/workflows/release-image.yml: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /.github/workflows/release.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 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 16 | UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }} 17 | 18 | permissions: 19 | contents: write 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 | - name: Publish npm 43 | run: 44 | make npm-publish 45 | python: 46 | name: Release Python 47 | # Python logic requires the tag/release version to be available from GitHub 48 | needs: release 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout 52 | uses: actions/checkout@v4 53 | - uses: astral-sh/setup-uv@v5 54 | - name: Publish Python 55 | run: 56 | make python-publish 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .docusaurus/ 3 | node_modules/ 4 | 5 | .npmrc 6 | kubernetes-mcp-server 7 | !cmd/kubernetes-mcp-server 8 | !pkg/kubernetes-mcp-server 9 | npm/kubernetes-mcp-server/README.md 10 | npm/kubernetes-mcp-server/LICENSE 11 | !npm/kubernetes-mcp-server 12 | kubernetes-mcp-server-darwin-amd64 13 | !npm/kubernetes-mcp-server-darwin-amd64/ 14 | kubernetes-mcp-server-darwin-arm64 15 | !npm/kubernetes-mcp-server-darwin-arm64 16 | kubernetes-mcp-server-linux-amd64 17 | !npm/kubernetes-mcp-server-linux-amd64 18 | kubernetes-mcp-server-linux-arm64 19 | !npm/kubernetes-mcp-server-linux-arm64 20 | kubernetes-mcp-server-windows-amd64.exe 21 | kubernetes-mcp-server-windows-arm64.exe 22 | 23 | python/.venv/ 24 | python/build/ 25 | python/dist/ 26 | python/kubernetes_mcp_server.egg-info/ 27 | !python/kubernetes-mcp-server 28 | -------------------------------------------------------------------------------- /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 | ENTRYPOINT ["/app/kubernetes-mcp-server", "--sse-port", "8080"] 12 | 13 | EXPOSE 8080 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # If you update this file, please follow 2 | # https://suva.sh/posts/well-documented-makefiles 3 | 4 | .DEFAULT_GOAL := help 5 | 6 | PACKAGE = $(shell go list -m) 7 | GIT_COMMIT_HASH = $(shell git rev-parse HEAD) 8 | GIT_VERSION = $(shell git describe --tags --always --dirty) 9 | BUILD_TIME = $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') 10 | BINARY_NAME = kubernetes-mcp-server 11 | LD_FLAGS = -s -w \ 12 | -X '$(PACKAGE)/pkg/version.CommitHash=$(GIT_COMMIT_HASH)' \ 13 | -X '$(PACKAGE)/pkg/version.Version=$(GIT_VERSION)' \ 14 | -X '$(PACKAGE)/pkg/version.BuildTime=$(BUILD_TIME)' \ 15 | -X '$(PACKAGE)/pkg/version.BinaryName=$(BINARY_NAME)' 16 | COMMON_BUILD_ARGS = -ldflags "$(LD_FLAGS)" 17 | 18 | # NPM version should not append the -dirty flag 19 | NPM_VERSION ?= $(shell echo $(shell git describe --tags --always) | sed 's/^v//') 20 | OSES = darwin linux windows 21 | ARCHS = amd64 arm64 22 | 23 | CLEAN_TARGETS := 24 | CLEAN_TARGETS += '$(BINARY_NAME)' 25 | CLEAN_TARGETS += $(foreach os,$(OSES),$(foreach arch,$(ARCHS),$(BINARY_NAME)-$(os)-$(arch)$(if $(findstring windows,$(os)),.exe,))) 26 | CLEAN_TARGETS += $(foreach os,$(OSES),$(foreach arch,$(ARCHS),./npm/$(BINARY_NAME)-$(os)-$(arch)/bin/)) 27 | CLEAN_TARGETS += ./npm/kubernetes-mcp-server/.npmrc ./npm/kubernetes-mcp-server/LICENSE ./npm/kubernetes-mcp-server/README.md 28 | CLEAN_TARGETS += $(foreach os,$(OSES),$(foreach arch,$(ARCHS),./npm/$(BINARY_NAME)-$(os)-$(arch)/.npmrc)) 29 | 30 | # The help will print out all targets with their descriptions organized bellow their categories. The categories are represented by `##@` and the target descriptions by `##`. 31 | # The awk commands is responsible to read the entire set of makefiles included in this invocation, looking for lines of the file as xyz: ## something, and then pretty-format the target and help. Then, if there's a line with ##@ something, that gets pretty-printed as a category. 32 | # More info over the usage of ANSI control characters for terminal formatting: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 33 | # More info over awk command: http://linuxcommand.org/lc3_adv_awk.php 34 | # 35 | # Notice that we have a little modification on the awk command to support slash in the recipe name: 36 | # origin: /^[a-zA-Z_0-9-]+:.*?##/ 37 | # modified /^[a-zA-Z_0-9\/\.-]+:.*?##/ 38 | .PHONY: help 39 | help: ## Display this help 40 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9\/\.-]+:.*?##/ { printf " \033[36m%-21s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 41 | 42 | .PHONY: clean 43 | clean: ## Clean up all build artifacts 44 | rm -rf $(CLEAN_TARGETS) 45 | 46 | .PHONY: build 47 | build: clean tidy format ## Build the project 48 | go build $(COMMON_BUILD_ARGS) -o $(BINARY_NAME) ./cmd/kubernetes-mcp-server 49 | 50 | 51 | .PHONY: build-all-platforms 52 | build-all-platforms: clean tidy format ## Build the project for all platforms 53 | $(foreach os,$(OSES),$(foreach arch,$(ARCHS), \ 54 | GOOS=$(os) GOARCH=$(arch) go build $(COMMON_BUILD_ARGS) -o $(BINARY_NAME)-$(os)-$(arch)$(if $(findstring windows,$(os)),.exe,) ./cmd/kubernetes-mcp-server; \ 55 | )) 56 | 57 | .PHONY: npm-copy-binaries 58 | npm-copy-binaries: build-all-platforms ## Copy the binaries to each npm package 59 | $(foreach os,$(OSES),$(foreach arch,$(ARCHS), \ 60 | EXECUTABLE=./$(BINARY_NAME)-$(os)-$(arch)$(if $(findstring windows,$(os)),.exe,); \ 61 | DIRNAME=$(BINARY_NAME)-$(os)-$(arch); \ 62 | mkdir -p ./npm/$$DIRNAME/bin; \ 63 | cp $$EXECUTABLE ./npm/$$DIRNAME/bin/; \ 64 | )) 65 | 66 | .PHONY: npm-publish 67 | npm-publish: npm-copy-binaries ## Publish the npm packages 68 | $(foreach os,$(OSES),$(foreach arch,$(ARCHS), \ 69 | DIRNAME="$(BINARY_NAME)-$(os)-$(arch)"; \ 70 | cd npm/$$DIRNAME; \ 71 | echo '//registry.npmjs.org/:_authToken=$(NPM_TOKEN)' >> .npmrc; \ 72 | jq '.version = "$(NPM_VERSION)"' package.json > tmp.json && mv tmp.json package.json; \ 73 | npm publish; \ 74 | cd ../..; \ 75 | )) 76 | cp README.md LICENSE ./npm/kubernetes-mcp-server/ 77 | echo '//registry.npmjs.org/:_authToken=$(NPM_TOKEN)' >> ./npm/kubernetes-mcp-server/.npmrc 78 | jq '.version = "$(NPM_VERSION)"' ./npm/kubernetes-mcp-server/package.json > tmp.json && mv tmp.json ./npm/kubernetes-mcp-server/package.json; \ 79 | jq '.optionalDependencies |= with_entries(.value = "$(NPM_VERSION)")' ./npm/kubernetes-mcp-server/package.json > tmp.json && mv tmp.json ./npm/kubernetes-mcp-server/package.json; \ 80 | cd npm/kubernetes-mcp-server && npm publish 81 | 82 | .PHONY: python-publish 83 | python-publish: ## Publish the python packages 84 | cd ./python && \ 85 | sed -i "s/version = \".*\"/version = \"$(NPM_VERSION)\"/" pyproject.toml && \ 86 | uv build && \ 87 | uv publish 88 | 89 | .PHONY: test 90 | test: ## Run the tests 91 | go test -count=1 -v ./... 92 | 93 | .PHONY: format 94 | format: ## Format the code 95 | go fmt ./... 96 | 97 | .PHONY: tidy 98 | tidy: ## Tidy up the go modules 99 | go mod tidy 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes MCP Server 2 | 3 | [![GitHub License](https://img.shields.io/github/license/manusa/kubernetes-mcp-server)](https://github.com/manusa/kubernetes-mcp-server/blob/main/LICENSE) 4 | [![npm](https://img.shields.io/npm/v/kubernetes-mcp-server)](https://www.npmjs.com/package/kubernetes-mcp-server) 5 | [![PyPI - Version](https://img.shields.io/pypi/v/kubernetes-mcp-server)](https://pypi.org/project/kubernetes-mcp-server/) 6 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/manusa/kubernetes-mcp-server?sort=semver)](https://github.com/manusa/kubernetes-mcp-server/releases/latest) 7 | [![Build](https://github.com/manusa/kubernetes-mcp-server/actions/workflows/build.yaml/badge.svg)](https://github.com/manusa/kubernetes-mcp-server/actions/workflows/build.yaml) 8 | 9 | [✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🛠️ Tools](#tools) | [🧑‍💻 Development](#development) 10 | 11 | https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e 12 | 13 | ## ✨ Features 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 | - **Exec** into a pod and run a command. 28 | - **Run** a container image in a pod and optionally expose it. 29 | - **✅ Namespaces**: List Kubernetes Namespaces. 30 | - **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace. 31 | - **✅ Projects**: List OpenShift Projects. 32 | - **☸️ Helm**: 33 | - **Install** a Helm chart in the current or provided namespace. 34 | - **List** Helm releases in all namespaces or in a specific namespace. 35 | - **Uninstall** a Helm release in the current or provided namespace. 36 | 37 | Unlike other Kubernetes MCP server implementations, this **IS NOT** just a wrapper around `kubectl` or `helm` command-line tools. 38 | It is a **Go-based native implementation** that interacts directly with the Kubernetes API server. 39 | 40 | There is **NO NEED** for external dependencies or tools to be installed on the system. 41 | If you're using the native binaries you don't need to have Node or Python installed on your system. 42 | 43 | - **✅ Lightweight**: The server is distributed as a single native binary for Linux, macOS, and Windows. 44 | - **✅ High-Performance / Low-Latency**: Directly interacts with the Kubernetes API server without the overhead of calling and waiting for external commands. 45 | - **✅ 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. 46 | - **✅ Configurable**: Supports [command-line arguments](#configuration) to configure the server behavior. 47 | - **✅ Well tested**: The server has an extensive test suite to ensure its reliability and correctness across different Kubernetes environments. 48 | 49 | ## 🚀 Getting Started 50 | 51 | ### Requirements 52 | 53 | - Access to a Kubernetes cluster. 54 | 55 | ### Claude Desktop 56 | 57 | #### Using npx 58 | 59 | If you have npm installed, this is the fastest way to get started with `kubernetes-mcp-server` on Claude Desktop. 60 | 61 | Open your `claude_desktop_config.json` and add the mcp server to the list of `mcpServers`: 62 | ``` json 63 | { 64 | "mcpServers": { 65 | "kubernetes": { 66 | "command": "npx", 67 | "args": [ 68 | "-y", 69 | "kubernetes-mcp-server@latest" 70 | ] 71 | } 72 | } 73 | } 74 | ``` 75 | 76 | ### VS Code / VS Code Insiders 77 | 78 | Install the Kubernetes MCP server extension in VS Code Insiders by pressing the following link: 79 | 80 | [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) 81 | [Install in VS Code Insiders](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) 82 | 83 | Alternatively, you can install the extension manually by running the following command: 84 | 85 | ```shell 86 | # For VS Code 87 | code --add-mcp '{"name":"kubernetes","command":"npx","args":["kubernetes-mcp-server@latest"]}' 88 | # For VS Code Insiders 89 | code-insiders --add-mcp '{"name":"kubernetes","command":"npx","args":["kubernetes-mcp-server@latest"]}' 90 | ``` 91 | 92 | ### Goose CLI 93 | 94 | [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. 95 | 96 | #### Using npm 97 | 98 | If you have npm installed, this is the fastest way to get started with `kubernetes-mcp-server`. 99 | 100 | Open your goose `config.yaml` and add the mcp server to the list of `mcpServers`: 101 | ```yaml 102 | extensions: 103 | kubernetes: 104 | command: npx 105 | args: 106 | - -y 107 | - kubernetes-mcp-server@latest 108 | 109 | ``` 110 | 111 | ## 🎥 Demos 112 | 113 | ### Diagnosing and automatically fixing an OpenShift Deployment 114 | 115 | Demo showcasing how Kubernetes MCP server is leveraged by Claude Desktop to automatically diagnose and fix a deployment in OpenShift without any user assistance. 116 | 117 | https://github.com/user-attachments/assets/a576176d-a142-4c19-b9aa-a83dc4b8d941 118 | 119 | ### _Vibe Coding_ a simple game and deploying it to OpenShift 120 | 121 | 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. 122 | 123 | 124 | Vibe Coding: Build & Deploy a Game on Kubernetes 125 | 126 | 127 | ### Supercharge GitHub Copilot with Kubernetes MCP Server in VS Code - One-Click Setup! 128 | 129 | In this demo, I'll show you how to set up Kubernetes MCP server in VS code just by clicking a link. 130 | 131 | 132 | Supercharge GitHub Copilot with Kubernetes MCP Server in VS Code - One-Click Setup! 133 | 134 | 135 | ## ⚙️ Configuration 136 | 137 | The Kubernetes MCP server can be configured using command line (CLI) arguments. 138 | 139 | You can run the CLI executable either by using `npx`, `uvx`, or by downloading the [latest release binary](https://github.com/manusa/kubernetes-mcp-server/releases/latest). 140 | 141 | ```shell 142 | # Run the Kubernetes MCP server using npx (in case you have npm and node installed) 143 | npx kubernetes-mcp-server@latest --help 144 | ``` 145 | 146 | ```shell 147 | # Run the Kubernetes MCP server using uvx (in case you have uv and python installed) 148 | uvx kubernetes-mcp-server@latest --help 149 | ``` 150 | 151 | ```shell 152 | # Run the Kubernetes MCP server using the latest release binary 153 | ./kubernetes-mcp-server --help 154 | ``` 155 | 156 | ### Configuration Options 157 | 158 | | Option | Description | 159 | |-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 160 | | `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. | 161 | | `--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). | 162 | | `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). | 163 | | `--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. | 164 | | `--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. | 165 | 166 | ## 🛠️ Tools 167 | 168 | ### `configuration_view` 169 | 170 | Get the current Kubernetes configuration content as a kubeconfig YAML 171 | 172 | **Parameters:** 173 | - `minified` (`boolean`, optional, default: `true`) 174 | - Return a minified version of the configuration 175 | - If `true`, keeps only the current-context and relevant configuration pieces 176 | - If `false`, returns all contexts, clusters, auth-infos, and users 177 | 178 | ### `events_list` 179 | 180 | List all the Kubernetes events in the current cluster from all namespaces 181 | 182 | **Parameters:** 183 | - `namespace` (`string`, optional) 184 | - Namespace to retrieve the events from. If not provided, will list events from all namespaces 185 | 186 | ### `helm_install` 187 | 188 | Install a Helm chart in the current or provided namespace with the provided name and chart 189 | 190 | **Parameters:** 191 | - `chart` (`string`, required) 192 | - Name of the Helm chart to install 193 | - Can be a local path or a remote URL 194 | - Example: `./my-chart.tgz` or `https://example.com/my-chart.tgz` 195 | - `values` (`object`, optional) 196 | - Values to pass to the Helm chart 197 | - Example: `{"key": "value"}` 198 | - `name` (`string`, optional) 199 | - Name of the Helm release 200 | - Random name if not provided 201 | - `namespace` (`string`, optional) 202 | - Namespace to install the Helm chart in 203 | - If not provided, will use the configured namespace 204 | 205 | ### `helm_list` 206 | 207 | List all the Helm releases in the current or provided namespace (or in all namespaces if specified) 208 | 209 | **Parameters:** 210 | - `namespace` (`string`, optional) 211 | - Namespace to list the Helm releases from 212 | - If not provided, will use the configured namespace 213 | - `all_namespaces` (`boolean`, optional) 214 | - If `true`, will list Helm releases from all namespaces 215 | - If `false`, will list Helm releases from the specified namespace 216 | 217 | ### `helm_uninstall` 218 | 219 | Uninstall a Helm release in the current or provided namespace with the provided name 220 | 221 | **Parameters:** 222 | - `name` (`string`, required) 223 | - Name of the Helm release to uninstall 224 | - `namespace` (`string`, optional) 225 | - Namespace to uninstall the Helm release from 226 | - If not provided, will use the configured namespace 227 | 228 | ### `namespaces_list` 229 | 230 | List all the Kubernetes namespaces in the current cluster 231 | 232 | **Parameters:** None 233 | 234 | ### `pods_delete` 235 | 236 | Delete a Kubernetes Pod in the current or provided namespace with the provided name 237 | 238 | **Parameters:** 239 | - `name` (`string`, required) 240 | - Name of the Pod to delete 241 | - `namespace` (`string`, required) 242 | - Namespace to delete the Pod from 243 | 244 | ### `pods_exec` 245 | 246 | Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command 247 | 248 | **Parameters:** 249 | - `command` (`string[]`, required) 250 | - Command to execute in the Pod container 251 | - First item is the command, rest are arguments 252 | - Example: `["ls", "-l", "/tmp"]` 253 | - `name` (string, required) 254 | - Name of the Pod 255 | - `namespace` (string, required) 256 | - Namespace of the Pod 257 | - `container` (`string`, optional) 258 | - Name of the Pod container to get logs from 259 | 260 | ### `pods_get` 261 | 262 | Get a Kubernetes Pod in the current or provided namespace with the provided name 263 | 264 | **Parameters:** 265 | - `name` (`string`, required) 266 | - Name of the Pod 267 | - `namespace` (`string`, required) 268 | - Namespace to get the Pod from 269 | 270 | ### `pods_list` 271 | 272 | List all the Kubernetes pods in the current cluster from all namespaces 273 | 274 | **Parameters:** 275 | - `labelSelector` (`string`, optional) 276 | - Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label 277 | 278 | ### `pods_list_in_namespace` 279 | 280 | List all the Kubernetes pods in the specified namespace in the current cluster 281 | 282 | **Parameters:** 283 | - `namespace` (`string`, required) 284 | - Namespace to list pods from 285 | - `labelSelector` (`string`, optional) 286 | - Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label 287 | 288 | ### `pods_log` 289 | 290 | Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name 291 | 292 | **Parameters:** 293 | - `name` (`string`, required) 294 | - Name of the Pod to get logs from 295 | - `namespace` (`string`, required) 296 | - Namespace to get the Pod logs from 297 | - `container` (`string`, optional) 298 | - Name of the Pod container to get logs from 299 | 300 | ### `pods_run` 301 | 302 | Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name 303 | 304 | **Parameters:** 305 | - `image` (`string`, required) 306 | - Container Image to run in the Pod 307 | - `namespace` (`string`, required) 308 | - Namespace to run the Pod in 309 | - `name` (`string`, optional) 310 | - Name of the Pod (random name if not provided) 311 | - `port` (`number`, optional) 312 | - TCP/IP port to expose from the Pod container 313 | - No port exposed if not provided 314 | 315 | ### `projects_list` 316 | 317 | List all the OpenShift projects in the current cluster 318 | 319 | ### `resources_create_or_update` 320 | 321 | Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource 322 | 323 | **Parameters:** 324 | - `resource` (`string`, required) 325 | - A JSON or YAML containing a representation of the Kubernetes resource 326 | - Should include top-level fields such as apiVersion, kind, metadata, and spec 327 | 328 | **Common apiVersion and kind include:** 329 | - v1 Pod 330 | - v1 Service 331 | - v1 Node 332 | - apps/v1 Deployment 333 | - networking.k8s.io/v1 Ingress 334 | 335 | ### `resources_delete` 336 | 337 | Delete a Kubernetes resource in the current cluster 338 | 339 | **Parameters:** 340 | - `apiVersion` (`string`, required) 341 | - apiVersion of the resource (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`) 342 | - `kind` (`string`, required) 343 | - kind of the resource (e.g., `Pod`, `Service`, `Deployment`, `Ingress`) 344 | - `name` (`string`, required) 345 | - Name of the resource 346 | - `namespace` (`string`, optional) 347 | - Namespace to delete the namespaced resource from 348 | - Ignored for cluster-scoped resources 349 | - Uses configured namespace if not provided 350 | 351 | ### `resources_get` 352 | 353 | Get a Kubernetes resource in the current cluster 354 | 355 | **Parameters:** 356 | - `apiVersion` (`string`, required) 357 | - apiVersion of the resource (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`) 358 | - `kind` (`string`, required) 359 | - kind of the resource (e.g., `Pod`, `Service`, `Deployment`, `Ingress`) 360 | - `name` (`string`, required) 361 | - Name of the resource 362 | - `namespace` (`string`, optional) 363 | - Namespace to retrieve the namespaced resource from 364 | - Ignored for cluster-scoped resources 365 | - Uses configured namespace if not provided 366 | 367 | ### `resources_list` 368 | 369 | List Kubernetes resources and objects in the current cluster 370 | 371 | **Parameters:** 372 | - `apiVersion` (`string`, required) 373 | - apiVersion of the resources (e.g., `v1`, `apps/v1`, `networking.k8s.io/v1`) 374 | - `kind` (`string`, required) 375 | - kind of the resources (e.g., `Pod`, `Service`, `Deployment`, `Ingress`) 376 | - `namespace` (`string`, optional) 377 | - Namespace to retrieve the namespaced resources from 378 | - Ignored for cluster-scoped resources 379 | - Lists resources from all namespaces if not provided 380 | - `labelSelector` (`string`, optional) 381 | - Kubernetes label selector (e.g., 'app=myapp,env=prod' or 'app in (myapp,yourapp)'). Use this option to filter the pods by label. 382 | 383 | ## 🧑‍💻 Development 384 | 385 | ### Running with mcp-inspector 386 | 387 | Compile the project and run the Kubernetes MCP server with [mcp-inspector](https://modelcontextprotocol.io/docs/tools/inspector) to inspect the MCP server. 388 | 389 | ```shell 390 | # Compile the project 391 | make build 392 | # Run the Kubernetes MCP server with mcp-inspector 393 | npx @modelcontextprotocol/inspector@latest $(pwd)/kubernetes-mcp-server 394 | ``` 395 | -------------------------------------------------------------------------------- /cmd/kubernetes-mcp-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/manusa/kubernetes-mcp-server/pkg/kubernetes-mcp-server/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /cmd/kubernetes-mcp-server/main_test.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 | -------------------------------------------------------------------------------- /docs/images/kubernetes-mcp-server-github-copilot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manusa/kubernetes-mcp-server/6da90015a11bdfe42f2c526b714bbdd44a40674f/docs/images/kubernetes-mcp-server-github-copilot.jpg -------------------------------------------------------------------------------- /docs/images/vibe-coding.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manusa/kubernetes-mcp-server/6da90015a11bdfe42f2c526b714bbdd44a40674f/docs/images/vibe-coding.jpg -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/manusa/kubernetes-mcp-server 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.9.0 7 | github.com/mark3labs/mcp-go v0.31.0 8 | github.com/pkg/errors v0.9.1 9 | github.com/spf13/afero v1.14.0 10 | github.com/spf13/cobra v1.9.1 11 | github.com/spf13/viper v1.20.1 12 | golang.org/x/net v0.40.0 13 | golang.org/x/sync v0.14.0 14 | helm.sh/helm/v3 v3.18.2 15 | k8s.io/api v0.33.1 16 | k8s.io/apiextensions-apiserver v0.33.1 17 | k8s.io/apimachinery v0.33.1 18 | k8s.io/cli-runtime v0.33.1 19 | k8s.io/client-go v0.33.1 20 | k8s.io/klog/v2 v2.130.1 21 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 22 | sigs.k8s.io/controller-runtime v0.21.0 23 | sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664 24 | sigs.k8s.io/yaml v1.4.0 25 | ) 26 | 27 | require ( 28 | dario.cat/mergo v1.0.1 // indirect 29 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 30 | github.com/BurntSushi/toml v1.5.0 // indirect 31 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 32 | github.com/Masterminds/goutils v1.1.1 // indirect 33 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 34 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 35 | github.com/Masterminds/squirrel v1.5.4 // indirect 36 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 37 | github.com/blang/semver/v4 v4.0.0 // indirect 38 | github.com/chai2010/gettext-go v1.0.2 // indirect 39 | github.com/containerd/containerd v1.7.27 // indirect 40 | github.com/containerd/errdefs v0.3.0 // indirect 41 | github.com/containerd/log v0.1.0 // indirect 42 | github.com/containerd/platforms v0.2.1 // indirect 43 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 44 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 45 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 46 | github.com/evanphx/json-patch v5.9.11+incompatible // indirect 47 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 48 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 49 | github.com/fatih/color v1.13.0 // indirect 50 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 51 | github.com/go-errors/errors v1.4.2 // indirect 52 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 53 | github.com/go-logr/logr v1.4.2 // indirect 54 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 55 | github.com/go-openapi/jsonreference v0.20.2 // indirect 56 | github.com/go-openapi/swag v0.23.0 // indirect 57 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 58 | github.com/gobwas/glob v0.2.3 // indirect 59 | github.com/gogo/protobuf v1.3.2 // indirect 60 | github.com/google/btree v1.1.3 // indirect 61 | github.com/google/gnostic-models v0.6.9 // indirect 62 | github.com/google/go-cmp v0.7.0 // indirect 63 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 64 | github.com/google/uuid v1.6.0 // indirect 65 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 66 | github.com/gosuri/uitable v0.0.4 // indirect 67 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 68 | github.com/hashicorp/errwrap v1.1.0 // indirect 69 | github.com/hashicorp/go-multierror v1.1.1 // indirect 70 | github.com/huandu/xstrings v1.5.0 // indirect 71 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 72 | github.com/jmoiron/sqlx v1.4.0 // indirect 73 | github.com/josharian/intern v1.0.0 // indirect 74 | github.com/json-iterator/go v1.1.12 // indirect 75 | github.com/klauspost/compress v1.18.0 // indirect 76 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 77 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 78 | github.com/lib/pq v1.10.9 // indirect 79 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 80 | github.com/mailru/easyjson v0.7.7 // indirect 81 | github.com/mattn/go-colorable v0.1.13 // indirect 82 | github.com/mattn/go-isatty v0.0.17 // indirect 83 | github.com/mattn/go-runewidth v0.0.9 // indirect 84 | github.com/mitchellh/copystructure v1.2.0 // indirect 85 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 86 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 87 | github.com/moby/spdystream v0.5.0 // indirect 88 | github.com/moby/term v0.5.2 // indirect 89 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 90 | github.com/modern-go/reflect2 v1.0.2 // indirect 91 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 92 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 93 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 94 | github.com/opencontainers/go-digest v1.0.0 // indirect 95 | github.com/opencontainers/image-spec v1.1.1 // indirect 96 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 97 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 98 | github.com/rubenv/sql-migrate v1.8.0 // indirect 99 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 100 | github.com/sagikazarmark/locafero v0.7.0 // indirect 101 | github.com/shopspring/decimal v1.4.0 // indirect 102 | github.com/sirupsen/logrus v1.9.3 // indirect 103 | github.com/sourcegraph/conc v0.3.0 // indirect 104 | github.com/spf13/cast v1.7.1 // indirect 105 | github.com/spf13/pflag v1.0.6 // indirect 106 | github.com/subosito/gotenv v1.6.0 // indirect 107 | github.com/x448/float16 v0.8.4 // indirect 108 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 109 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 110 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 111 | github.com/xlab/treeprint v1.2.0 // indirect 112 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 113 | go.uber.org/multierr v1.11.0 // indirect 114 | golang.org/x/crypto v0.38.0 // indirect 115 | golang.org/x/oauth2 v0.28.0 // indirect 116 | golang.org/x/sys v0.33.0 // indirect 117 | golang.org/x/term v0.32.0 // indirect 118 | golang.org/x/text v0.25.0 // indirect 119 | golang.org/x/time v0.9.0 // indirect 120 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect 121 | google.golang.org/grpc v1.68.1 // indirect 122 | google.golang.org/protobuf v1.36.5 // indirect 123 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 124 | gopkg.in/inf.v0 v0.9.1 // indirect 125 | gopkg.in/yaml.v3 v3.0.1 // indirect 126 | k8s.io/apiserver v0.33.1 // indirect 127 | k8s.io/component-base v0.33.1 // indirect 128 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 129 | k8s.io/kubectl v0.33.0 // indirect 130 | oras.land/oras-go/v2 v2.5.0 // indirect 131 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 132 | sigs.k8s.io/kustomize/api v0.19.0 // indirect 133 | sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect 134 | sigs.k8s.io/randfill v1.0.0 // indirect 135 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 136 | ) 137 | -------------------------------------------------------------------------------- /npm/kubernetes-mcp-server-darwin-amd64/package.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 | "os": [ 6 | "darwin" 7 | ], 8 | "cpu": [ 9 | "x64" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /npm/kubernetes-mcp-server-darwin-arm64/package.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 | "os": [ 6 | "darwin" 7 | ], 8 | "cpu": [ 9 | "arm64" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /npm/kubernetes-mcp-server-linux-amd64/package.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 | "os": [ 6 | "linux" 7 | ], 8 | "cpu": [ 9 | "x64" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /npm/kubernetes-mcp-server-linux-arm64/package.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 | "os": [ 6 | "linux" 7 | ], 8 | "cpu": [ 9 | "arm64" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /npm/kubernetes-mcp-server-windows-amd64/package.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 | "os": [ 6 | "win32" 7 | ], 8 | "cpu": [ 9 | "x64" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /npm/kubernetes-mcp-server-windows-arm64/package.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 | "os": [ 6 | "win32" 7 | ], 8 | "cpu": [ 9 | "arm64" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /npm/kubernetes-mcp-server/bin/index.js: -------------------------------------------------------------------------------- 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 | childProcess.execFileSync(resolveBinaryPath(), process.argv.slice(2), { 25 | stdio: 'inherit', 26 | }); 27 | 28 | -------------------------------------------------------------------------------- /npm/kubernetes-mcp-server/package.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/manusa/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/manusa/kubernetes-mcp-server/issues" 37 | }, 38 | "homepage": "https://github.com/manusa/kubernetes-mcp-server#readme" 39 | } 40 | -------------------------------------------------------------------------------- /pkg/helm/helm.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "helm.sh/helm/v3/pkg/action" 7 | "helm.sh/helm/v3/pkg/chart/loader" 8 | "helm.sh/helm/v3/pkg/cli" 9 | "helm.sh/helm/v3/pkg/registry" 10 | "helm.sh/helm/v3/pkg/release" 11 | "k8s.io/cli-runtime/pkg/genericclioptions" 12 | "log" 13 | "sigs.k8s.io/yaml" 14 | "time" 15 | ) 16 | 17 | type Kubernetes interface { 18 | genericclioptions.RESTClientGetter 19 | NamespaceOrDefault(namespace string) string 20 | } 21 | 22 | type Helm struct { 23 | kubernetes Kubernetes 24 | } 25 | 26 | // NewHelm creates a new Helm instance 27 | func NewHelm(kubernetes Kubernetes) *Helm { 28 | return &Helm{kubernetes: kubernetes} 29 | } 30 | 31 | func (h *Helm) Install(ctx context.Context, chart string, values map[string]interface{}, name string, namespace string) (string, error) { 32 | cfg, err := h.newAction(h.kubernetes.NamespaceOrDefault(namespace), false) 33 | if err != nil { 34 | return "", err 35 | } 36 | install := action.NewInstall(cfg) 37 | if name == "" { 38 | install.GenerateName = true 39 | install.ReleaseName, _, _ = install.NameAndChart([]string{chart}) 40 | } else { 41 | install.ReleaseName = name 42 | } 43 | install.Namespace = h.kubernetes.NamespaceOrDefault(namespace) 44 | install.Wait = true 45 | install.Timeout = 5 * time.Minute 46 | install.DryRun = false 47 | 48 | chartRequested, err := install.ChartPathOptions.LocateChart(chart, cli.New()) 49 | if err != nil { 50 | return "", err 51 | } 52 | chartLoaded, err := loader.Load(chartRequested) 53 | 54 | installedRelease, err := install.RunWithContext(ctx, chartLoaded, values) 55 | if err != nil { 56 | return "", err 57 | } 58 | ret, err := yaml.Marshal(simplify(installedRelease)) 59 | if err != nil { 60 | return "", err 61 | } 62 | return string(ret), nil 63 | } 64 | 65 | // List lists all the releases for the specified namespace (or current namespace if). Or allNamespaces is true, it lists all releases across all namespaces. 66 | func (h *Helm) List(namespace string, allNamespaces bool) (string, error) { 67 | cfg, err := h.newAction(namespace, allNamespaces) 68 | if err != nil { 69 | return "", err 70 | } 71 | list := action.NewList(cfg) 72 | list.AllNamespaces = allNamespaces 73 | releases, err := list.Run() 74 | if err != nil { 75 | return "", err 76 | } else if len(releases) == 0 { 77 | return "No Helm releases found", nil 78 | } 79 | ret, err := yaml.Marshal(simplify(releases...)) 80 | if err != nil { 81 | return "", err 82 | } 83 | return string(ret), nil 84 | } 85 | 86 | func (h *Helm) Uninstall(name string, namespace string) (string, error) { 87 | cfg, err := h.newAction(h.kubernetes.NamespaceOrDefault(namespace), false) 88 | if err != nil { 89 | return "", err 90 | } 91 | uninstall := action.NewUninstall(cfg) 92 | uninstall.IgnoreNotFound = true 93 | uninstall.Wait = true 94 | uninstall.Timeout = 5 * time.Minute 95 | uninstalledRelease, err := uninstall.Run(name) 96 | if uninstalledRelease == nil && err == nil { 97 | return fmt.Sprintf("Release %s not found", name), nil 98 | } else if err != nil { 99 | return "", err 100 | } 101 | return fmt.Sprintf("Uninstalled release %s %s", uninstalledRelease.Release.Name, uninstalledRelease.Info), nil 102 | } 103 | 104 | func (h *Helm) newAction(namespace string, allNamespaces bool) (*action.Configuration, error) { 105 | cfg := new(action.Configuration) 106 | applicableNamespace := "" 107 | if !allNamespaces { 108 | applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace) 109 | } 110 | registryClient, err := registry.NewClient() 111 | if err != nil { 112 | return nil, err 113 | } 114 | cfg.RegistryClient = registryClient 115 | return cfg, cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf) 116 | } 117 | 118 | func simplify(release ...*release.Release) []map[string]interface{} { 119 | ret := make([]map[string]interface{}, len(release)) 120 | for i, r := range release { 121 | ret[i] = map[string]interface{}{ 122 | "name": r.Name, 123 | "namespace": r.Namespace, 124 | "revision": r.Version, 125 | } 126 | if r.Chart != nil { 127 | ret[i]["chart"] = r.Chart.Metadata.Name 128 | ret[i]["chartVersion"] = r.Chart.Metadata.Version 129 | ret[i]["appVersion"] = r.Chart.Metadata.AppVersion 130 | } 131 | if r.Info != nil { 132 | ret[i]["status"] = r.Info.Status.String() 133 | if !r.Info.LastDeployed.IsZero() { 134 | ret[i]["lastDeployed"] = r.Info.LastDeployed.Format(time.RFC1123Z) 135 | } 136 | } 137 | } 138 | return ret 139 | } 140 | -------------------------------------------------------------------------------- /pkg/kubernetes-mcp-server/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "github.com/manusa/kubernetes-mcp-server/pkg/mcp" 8 | "github.com/manusa/kubernetes-mcp-server/pkg/version" 9 | "github.com/mark3labs/mcp-go/server" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | "golang.org/x/net/context" 13 | "k8s.io/klog/v2" 14 | "k8s.io/klog/v2/textlogger" 15 | "os" 16 | "strconv" 17 | "strings" 18 | ) 19 | 20 | var rootCmd = &cobra.Command{ 21 | Use: "kubernetes-mcp-server [command] [options]", 22 | Short: "Kubernetes Model Context Protocol (MCP) server", 23 | Long: ` 24 | Kubernetes Model Context Protocol (MCP) server 25 | 26 | # show this help 27 | kubernetes-mcp-server -h 28 | 29 | # shows version information 30 | kubernetes-mcp-server --version 31 | 32 | # start STDIO server 33 | kubernetes-mcp-server 34 | 35 | # start a SSE server on port 8080 36 | kubernetes-mcp-server --sse-port 8080 37 | 38 | # start a SSE server on port 8443 with a public HTTPS host of example.com 39 | kubernetes-mcp-server --sse-port 8443 --sse-base-url https://example.com:8443 40 | 41 | # TODO: add more examples`, 42 | Run: func(cmd *cobra.Command, args []string) { 43 | initLogging() 44 | profile := mcp.ProfileFromString(viper.GetString("profile")) 45 | if profile == nil { 46 | fmt.Printf("Invalid profile name: %s, valid names are: %s\n", viper.GetString("profile"), strings.Join(mcp.ProfileNames, ", ")) 47 | os.Exit(1) 48 | } 49 | klog.V(1).Info("Starting kubernetes-mcp-server") 50 | klog.V(1).Infof(" - Profile: %s", profile.GetName()) 51 | klog.V(1).Infof(" - Read-only mode: %t", viper.GetBool("read-only")) 52 | klog.V(1).Infof(" - Disable destructive tools: %t", viper.GetBool("disable-destructive")) 53 | if viper.GetBool("version") { 54 | fmt.Println(version.Version) 55 | return 56 | } 57 | mcpServer, err := mcp.NewSever(mcp.Configuration{ 58 | Profile: profile, 59 | ReadOnly: viper.GetBool("read-only"), 60 | DisableDestructive: viper.GetBool("disable-destructive"), 61 | Kubeconfig: viper.GetString("kubeconfig"), 62 | }) 63 | if err != nil { 64 | fmt.Printf("Failed to initialize MCP server: %v\n", err) 65 | os.Exit(1) 66 | } 67 | defer mcpServer.Close() 68 | 69 | var sseServer *server.SSEServer 70 | if ssePort := viper.GetInt("sse-port"); ssePort > 0 { 71 | sseServer = mcpServer.ServeSse(viper.GetString("sse-base-url")) 72 | defer func() { _ = sseServer.Shutdown(cmd.Context()) }() 73 | klog.V(0).Infof("SSE server starting on port %d", ssePort) 74 | if err := sseServer.Start(fmt.Sprintf(":%d", ssePort)); err != nil { 75 | klog.Errorf("Failed to start SSE server: %s", err) 76 | return 77 | } 78 | } 79 | if err := mcpServer.ServeStdio(); err != nil && !errors.Is(err, context.Canceled) { 80 | panic(err) 81 | } 82 | }, 83 | } 84 | 85 | func Execute() { 86 | if err := rootCmd.Execute(); err != nil { 87 | klog.Errorf("Failed to execute command: %s", err) 88 | os.Exit(1) 89 | } 90 | } 91 | 92 | func initLogging() { 93 | flagSet := flag.NewFlagSet("kubernetes-mcp-server", flag.ContinueOnError) 94 | klog.InitFlags(flagSet) 95 | loggerOptions := []textlogger.ConfigOption{textlogger.Output(os.Stdout)} 96 | if logLevel := viper.GetInt("log-level"); logLevel >= 0 { 97 | loggerOptions = append(loggerOptions, textlogger.Verbosity(logLevel)) 98 | _ = flagSet.Parse([]string{"--v", strconv.Itoa(logLevel)}) 99 | } 100 | logger := textlogger.NewLogger(textlogger.NewConfig(loggerOptions...)) 101 | klog.SetLoggerWithOptions(logger) 102 | } 103 | 104 | type profileFlag struct { 105 | mcp.Profile 106 | } 107 | 108 | func (p *profileFlag) String() string { 109 | return p.GetName() 110 | } 111 | 112 | func (p *profileFlag) Set(v string) error { 113 | p.Profile = mcp.ProfileFromString(v) 114 | if p.Profile != nil { 115 | return nil 116 | } 117 | return fmt.Errorf("invalid profile name: %s, valid names are: %s", v, mcp.ProfileNames) 118 | } 119 | 120 | func (p *profileFlag) Type() string { 121 | return "profile" 122 | } 123 | 124 | func init() { 125 | rootCmd.Flags().BoolP("version", "v", false, "Print version information and quit") 126 | rootCmd.Flags().IntP("log-level", "", 0, "Set the log level (from 0 to 9)") 127 | rootCmd.Flags().IntP("sse-port", "", 0, "Start a SSE server on the specified port") 128 | rootCmd.Flags().StringP("sse-base-url", "", "", "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)") 129 | rootCmd.Flags().StringP("kubeconfig", "", "", "Path to the kubeconfig file to use for authentication") 130 | rootCmd.Flags().String("profile", "full", "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")") 131 | rootCmd.Flags().Bool("read-only", false, "If true, only tools annotated with readOnlyHint=true are exposed") 132 | rootCmd.Flags().Bool("disable-destructive", false, "If true, tools annotated with destructiveHint=true are disabled") 133 | _ = viper.BindPFlags(rootCmd.Flags()) 134 | } 135 | -------------------------------------------------------------------------------- /pkg/kubernetes-mcp-server/cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func captureOutput(f func() error) (string, error) { 11 | originalOut := os.Stdout 12 | defer func() { 13 | os.Stdout = originalOut 14 | }() 15 | r, w, _ := os.Pipe() 16 | os.Stdout = w 17 | err := f() 18 | _ = w.Close() 19 | out, _ := io.ReadAll(r) 20 | return string(out), err 21 | } 22 | 23 | func TestVersion(t *testing.T) { 24 | rootCmd.SetArgs([]string{"--version"}) 25 | version, err := captureOutput(rootCmd.Execute) 26 | if version != "0.0.0\n" { 27 | t.Fatalf("Expected version 0.0.0, got %s %v", version, err) 28 | } 29 | } 30 | 31 | func TestDefaultProfile(t *testing.T) { 32 | rootCmd.SetArgs([]string{"--version", "--log-level=1"}) 33 | out, err := captureOutput(rootCmd.Execute) 34 | if !strings.Contains(out, "- Profile: full") { 35 | t.Fatalf("Expected profile 'full', got %s %v", out, err) 36 | } 37 | } 38 | 39 | func TestDefaultReadOnly(t *testing.T) { 40 | rootCmd.SetArgs([]string{"--version", "--log-level=1"}) 41 | out, err := captureOutput(rootCmd.Execute) 42 | if !strings.Contains(out, " - Read-only mode: false") { 43 | t.Fatalf("Expected read-only mode false, got %s %v", out, err) 44 | } 45 | } 46 | 47 | func TestDefaultDisableDestructive(t *testing.T) { 48 | rootCmd.SetArgs([]string{"--version", "--log-level=1"}) 49 | out, err := captureOutput(rootCmd.Execute) 50 | if !strings.Contains(out, " - Disable destructive tools: false") { 51 | t.Fatalf("Expected disable destructive false, got %s %v", out, err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/kubernetes/configuration.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "k8s.io/client-go/rest" 5 | "k8s.io/client-go/tools/clientcmd" 6 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api" 7 | "k8s.io/client-go/tools/clientcmd/api/latest" 8 | ) 9 | 10 | // InClusterConfig is a variable that holds the function to get the in-cluster config 11 | // Exposed for testing 12 | var InClusterConfig = func() (*rest.Config, error) { 13 | // TODO use kubernetes.default.svc instead of resolved server 14 | // Currently running into: `http: server gave HTTP response to HTTPS client` 15 | inClusterConfig, err := rest.InClusterConfig() 16 | if inClusterConfig != nil { 17 | inClusterConfig.Host = "https://kubernetes.default.svc" 18 | } 19 | return inClusterConfig, err 20 | } 21 | 22 | // resolveKubernetesConfigurations resolves the required kubernetes configurations and sets them in the Kubernetes struct 23 | func resolveKubernetesConfigurations(kubernetes *Kubernetes) error { 24 | // Always set clientCmdConfig 25 | pathOptions := clientcmd.NewDefaultPathOptions() 26 | if kubernetes.Kubeconfig != "" { 27 | pathOptions.LoadingRules.ExplicitPath = kubernetes.Kubeconfig 28 | } 29 | kubernetes.clientCmdConfig = clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 30 | pathOptions.LoadingRules, 31 | &clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}}) 32 | var err error 33 | if kubernetes.IsInCluster() { 34 | kubernetes.cfg, err = InClusterConfig() 35 | if err == nil && kubernetes.cfg != nil { 36 | return nil 37 | } 38 | } 39 | // Out of cluster 40 | kubernetes.cfg, err = kubernetes.clientCmdConfig.ClientConfig() 41 | if kubernetes.cfg != nil && kubernetes.cfg.UserAgent == "" { 42 | kubernetes.cfg.UserAgent = rest.DefaultKubernetesUserAgent() 43 | } 44 | return err 45 | } 46 | 47 | func (k *Kubernetes) IsInCluster() bool { 48 | if k.Kubeconfig != "" { 49 | return false 50 | } 51 | cfg, err := InClusterConfig() 52 | return err == nil && cfg != nil 53 | } 54 | 55 | func (k *Kubernetes) configuredNamespace() string { 56 | if ns, _, nsErr := k.clientCmdConfig.Namespace(); nsErr == nil { 57 | return ns 58 | } 59 | return "" 60 | } 61 | 62 | func (k *Kubernetes) NamespaceOrDefault(namespace string) string { 63 | if namespace == "" { 64 | return k.configuredNamespace() 65 | } 66 | return namespace 67 | } 68 | 69 | // ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter) 70 | func (k *Kubernetes) ToRESTConfig() (*rest.Config, error) { 71 | return k.cfg, nil 72 | } 73 | 74 | // ToRawKubeConfigLoader returns the clientcmd.ClientConfig object (genericclioptions.RESTClientGetter) 75 | func (k *Kubernetes) ToRawKubeConfigLoader() clientcmd.ClientConfig { 76 | return k.clientCmdConfig 77 | } 78 | 79 | func (k *Kubernetes) ConfigurationView(minify bool) (string, error) { 80 | var cfg clientcmdapi.Config 81 | var err error 82 | if k.IsInCluster() { 83 | cfg = *clientcmdapi.NewConfig() 84 | cfg.Clusters["cluster"] = &clientcmdapi.Cluster{ 85 | Server: k.cfg.Host, 86 | InsecureSkipTLSVerify: k.cfg.Insecure, 87 | } 88 | cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{ 89 | Token: k.cfg.BearerToken, 90 | } 91 | cfg.Contexts["context"] = &clientcmdapi.Context{ 92 | Cluster: "cluster", 93 | AuthInfo: "user", 94 | } 95 | cfg.CurrentContext = "context" 96 | } else if cfg, err = k.clientCmdConfig.RawConfig(); err != nil { 97 | return "", err 98 | } 99 | if minify { 100 | if err = clientcmdapi.MinifyConfig(&cfg); err != nil { 101 | return "", err 102 | } 103 | } 104 | if err = clientcmdapi.FlattenConfig(&cfg); err != nil { 105 | // ignore error 106 | //return "", err 107 | } 108 | convertedObj, err := latest.Scheme.ConvertToVersion(&cfg, latest.ExternalVersion) 109 | if err != nil { 110 | return "", err 111 | } 112 | return marshal(convertedObj) 113 | } 114 | -------------------------------------------------------------------------------- /pkg/kubernetes/configuration_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "errors" 5 | "k8s.io/client-go/rest" 6 | "os" 7 | "path" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestKubernetes_IsInCluster(t *testing.T) { 14 | t.Run("with explicit kubeconfig", func(t *testing.T) { 15 | k := Kubernetes{ 16 | Kubeconfig: "kubeconfig", 17 | } 18 | if k.IsInCluster() { 19 | t.Errorf("expected not in cluster, got in cluster") 20 | } 21 | }) 22 | t.Run("with empty kubeconfig and in cluster", func(t *testing.T) { 23 | originalFunction := InClusterConfig 24 | InClusterConfig = func() (*rest.Config, error) { 25 | return &rest.Config{}, nil 26 | } 27 | defer func() { 28 | InClusterConfig = originalFunction 29 | }() 30 | k := Kubernetes{ 31 | Kubeconfig: "", 32 | } 33 | if !k.IsInCluster() { 34 | t.Errorf("expected in cluster, got not in cluster") 35 | } 36 | }) 37 | t.Run("with empty kubeconfig and not in cluster (empty)", func(t *testing.T) { 38 | originalFunction := InClusterConfig 39 | InClusterConfig = func() (*rest.Config, error) { 40 | return nil, nil 41 | } 42 | defer func() { 43 | InClusterConfig = originalFunction 44 | }() 45 | k := Kubernetes{ 46 | Kubeconfig: "", 47 | } 48 | if k.IsInCluster() { 49 | t.Errorf("expected not in cluster, got in cluster") 50 | } 51 | }) 52 | t.Run("with empty kubeconfig and not in cluster (error)", func(t *testing.T) { 53 | originalFunction := InClusterConfig 54 | InClusterConfig = func() (*rest.Config, error) { 55 | return nil, errors.New("error") 56 | } 57 | defer func() { 58 | InClusterConfig = originalFunction 59 | }() 60 | k := Kubernetes{ 61 | Kubeconfig: "", 62 | } 63 | if k.IsInCluster() { 64 | t.Errorf("expected not in cluster, got in cluster") 65 | } 66 | }) 67 | } 68 | 69 | func TestKubernetes_ResolveKubernetesConfigurations_Explicit(t *testing.T) { 70 | t.Run("with missing file", func(t *testing.T) { 71 | if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { 72 | t.Skip("Skipping test on non-linux platforms") 73 | } 74 | tempDir := t.TempDir() 75 | k := Kubernetes{Kubeconfig: path.Join(tempDir, "config")} 76 | err := resolveKubernetesConfigurations(&k) 77 | if err == nil { 78 | t.Errorf("expected error, got nil") 79 | } 80 | if !errors.Is(err, os.ErrNotExist) { 81 | t.Errorf("expected file not found error, got %v", err) 82 | } 83 | if !strings.HasSuffix(err.Error(), ": no such file or directory") { 84 | t.Errorf("expected file not found error, got %v", err) 85 | } 86 | }) 87 | t.Run("with empty file", func(t *testing.T) { 88 | tempDir := t.TempDir() 89 | kubeconfigPath := path.Join(tempDir, "config") 90 | if err := os.WriteFile(kubeconfigPath, []byte(""), 0644); err != nil { 91 | t.Fatalf("failed to create kubeconfig file: %v", err) 92 | } 93 | k := Kubernetes{Kubeconfig: kubeconfigPath} 94 | err := resolveKubernetesConfigurations(&k) 95 | if err == nil { 96 | t.Errorf("expected error, got nil") 97 | } 98 | if !strings.Contains(err.Error(), "no configuration has been provided") { 99 | t.Errorf("expected no kubeconfig error, got %v", err) 100 | } 101 | }) 102 | t.Run("with valid file", func(t *testing.T) { 103 | tempDir := t.TempDir() 104 | kubeconfigPath := path.Join(tempDir, "config") 105 | kubeconfigContent := ` 106 | apiVersion: v1 107 | kind: Config 108 | clusters: 109 | - cluster: 110 | server: https://example.com 111 | name: example-cluster 112 | contexts: 113 | - context: 114 | cluster: example-cluster 115 | user: example-user 116 | name: example-context 117 | current-context: example-context 118 | users: 119 | - name: example-user 120 | user: 121 | token: example-token 122 | ` 123 | if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0644); err != nil { 124 | t.Fatalf("failed to create kubeconfig file: %v", err) 125 | } 126 | k := Kubernetes{Kubeconfig: kubeconfigPath} 127 | err := resolveKubernetesConfigurations(&k) 128 | if err != nil { 129 | t.Fatalf("expected no error, got %v", err) 130 | } 131 | if k.cfg == nil { 132 | t.Errorf("expected non-nil config, got nil") 133 | } 134 | if k.cfg.Host != "https://example.com" { 135 | t.Errorf("expected host https://example.com, got %s", k.cfg.Host) 136 | } 137 | }) 138 | } 139 | -------------------------------------------------------------------------------- /pkg/kubernetes/events.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | v1 "k8s.io/api/core/v1" 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) (string, error) { 13 | unstructuredList, err := k.resourcesList(ctx, &schema.GroupVersionKind{ 14 | Group: "", Version: "v1", Kind: "Event", 15 | }, namespace, "") 16 | if err != nil { 17 | return "", err 18 | } 19 | if len(unstructuredList.Items) == 0 { 20 | return "No events found", nil 21 | } 22 | var eventMap []map[string]any 23 | for _, item := range unstructuredList.Items { 24 | event := &v1.Event{} 25 | if err = runtime.DefaultUnstructuredConverter.FromUnstructured(item.Object, event); err != nil { 26 | return "", err 27 | } 28 | timestamp := event.EventTime.Time 29 | if timestamp.IsZero() && event.Series != nil { 30 | timestamp = event.Series.LastObservedTime.Time 31 | } else if timestamp.IsZero() && event.Count > 1 { 32 | timestamp = event.LastTimestamp.Time 33 | } else if timestamp.IsZero() { 34 | timestamp = event.FirstTimestamp.Time 35 | } 36 | eventMap = append(eventMap, map[string]any{ 37 | "Namespace": event.Namespace, 38 | "Timestamp": timestamp.String(), 39 | "Type": event.Type, 40 | "Reason": event.Reason, 41 | "InvolvedObject": map[string]string{ 42 | "apiVersion": event.InvolvedObject.APIVersion, 43 | "Kind": event.InvolvedObject.Kind, 44 | "Name": event.InvolvedObject.Name, 45 | }, 46 | "Message": strings.TrimSpace(event.Message), 47 | }) 48 | } 49 | yamlEvents, err := marshal(eventMap) 50 | if err != nil { 51 | return "", err 52 | } 53 | return fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/kubernetes/impersonate_roundtripper.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import "net/http" 4 | 5 | type impersonateRoundTripper struct { 6 | delegate http.RoundTripper 7 | } 8 | 9 | func (irt *impersonateRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 10 | // TODO: Solution won't work with discoveryclient which uses context.TODO() instead of the passed-in context 11 | if v, ok := req.Context().Value(AuthorizationHeader).(string); ok { 12 | req.Header.Set("Authorization", v) 13 | } 14 | return irt.delegate.RoundTrip(req) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/kubernetes/kubernetes.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "github.com/fsnotify/fsnotify" 6 | "github.com/manusa/kubernetes-mcp-server/pkg/helm" 7 | v1 "k8s.io/api/core/v1" 8 | "k8s.io/apimachinery/pkg/api/meta" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | "k8s.io/apimachinery/pkg/runtime" 11 | "k8s.io/client-go/discovery" 12 | "k8s.io/client-go/discovery/cached/memory" 13 | "k8s.io/client-go/dynamic" 14 | "k8s.io/client-go/kubernetes" 15 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 16 | "k8s.io/client-go/rest" 17 | "k8s.io/client-go/restmapper" 18 | "k8s.io/client-go/tools/clientcmd" 19 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api" 20 | "k8s.io/klog/v2" 21 | "sigs.k8s.io/yaml" 22 | "strings" 23 | ) 24 | 25 | const ( 26 | AuthorizationHeader = "kubernetes-authorization" 27 | ) 28 | 29 | type CloseWatchKubeConfig func() error 30 | 31 | type Kubernetes struct { 32 | // Kubeconfig path override 33 | Kubeconfig string 34 | cfg *rest.Config 35 | clientCmdConfig clientcmd.ClientConfig 36 | CloseWatchKubeConfig CloseWatchKubeConfig 37 | scheme *runtime.Scheme 38 | parameterCodec runtime.ParameterCodec 39 | clientSet kubernetes.Interface 40 | discoveryClient discovery.CachedDiscoveryInterface 41 | deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper 42 | dynamicClient *dynamic.DynamicClient 43 | Helm *helm.Helm 44 | } 45 | 46 | func NewKubernetes(kubeconfig string) (*Kubernetes, error) { 47 | k8s := &Kubernetes{ 48 | Kubeconfig: kubeconfig, 49 | } 50 | if err := resolveKubernetesConfigurations(k8s); err != nil { 51 | return nil, err 52 | } 53 | // TODO: Won't work because not all client-go clients use the shared context (e.g. discovery client uses context.TODO()) 54 | //k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { 55 | // return &impersonateRoundTripper{original} 56 | //}) 57 | var err error 58 | k8s.clientSet, err = kubernetes.NewForConfig(k8s.cfg) 59 | if err != nil { 60 | return nil, err 61 | } 62 | k8s.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(k8s.clientSet.CoreV1().RESTClient())) 63 | k8s.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient) 64 | k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg) 65 | if err != nil { 66 | return nil, err 67 | } 68 | k8s.scheme = runtime.NewScheme() 69 | if err = v1.AddToScheme(k8s.scheme); err != nil { 70 | return nil, err 71 | } 72 | k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme) 73 | k8s.Helm = helm.NewHelm(k8s) 74 | return k8s, nil 75 | } 76 | 77 | func (k *Kubernetes) WatchKubeConfig(onKubeConfigChange func() error) { 78 | if k.clientCmdConfig == nil { 79 | return 80 | } 81 | kubeConfigFiles := k.clientCmdConfig.ConfigAccess().GetLoadingPrecedence() 82 | if len(kubeConfigFiles) == 0 { 83 | return 84 | } 85 | watcher, err := fsnotify.NewWatcher() 86 | if err != nil { 87 | return 88 | } 89 | for _, file := range kubeConfigFiles { 90 | _ = watcher.Add(file) 91 | } 92 | go func() { 93 | for { 94 | select { 95 | case _, ok := <-watcher.Events: 96 | if !ok { 97 | return 98 | } 99 | _ = onKubeConfigChange() 100 | case _, ok := <-watcher.Errors: 101 | if !ok { 102 | return 103 | } 104 | } 105 | } 106 | }() 107 | if k.CloseWatchKubeConfig != nil { 108 | _ = k.CloseWatchKubeConfig() 109 | } 110 | k.CloseWatchKubeConfig = watcher.Close 111 | } 112 | 113 | func (k *Kubernetes) Close() { 114 | if k.CloseWatchKubeConfig != nil { 115 | _ = k.CloseWatchKubeConfig() 116 | } 117 | } 118 | 119 | func (k *Kubernetes) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { 120 | return k.discoveryClient, nil 121 | } 122 | 123 | func (k *Kubernetes) ToRESTMapper() (meta.RESTMapper, error) { 124 | return k.deferredDiscoveryRESTMapper, nil 125 | } 126 | 127 | func (k *Kubernetes) Derived(ctx context.Context) *Kubernetes { 128 | authorization, ok := ctx.Value(AuthorizationHeader).(string) 129 | if !ok || !strings.HasPrefix(authorization, "Bearer ") { 130 | return k 131 | } 132 | klog.V(5).Infof("%s header found (Bearer), using provided bearer token", AuthorizationHeader) 133 | derivedCfg := rest.CopyConfig(k.cfg) 134 | derivedCfg.BearerToken = strings.TrimPrefix(authorization, "Bearer ") 135 | derivedCfg.BearerTokenFile = "" 136 | derivedCfg.Username = "" 137 | derivedCfg.Password = "" 138 | derivedCfg.AuthProvider = nil 139 | derivedCfg.AuthConfigPersister = nil 140 | derivedCfg.ExecProvider = nil 141 | derivedCfg.Impersonate = rest.ImpersonationConfig{} 142 | clientCmdApiConfig, err := k.clientCmdConfig.RawConfig() 143 | if err != nil { 144 | return k 145 | } 146 | clientCmdApiConfig.AuthInfos = make(map[string]*clientcmdapi.AuthInfo) 147 | derived := &Kubernetes{ 148 | Kubeconfig: k.Kubeconfig, 149 | clientCmdConfig: clientcmd.NewDefaultClientConfig(clientCmdApiConfig, nil), 150 | cfg: derivedCfg, 151 | scheme: k.scheme, 152 | parameterCodec: k.parameterCodec, 153 | } 154 | derived.clientSet, err = kubernetes.NewForConfig(derived.cfg) 155 | if err != nil { 156 | return k 157 | } 158 | derived.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(derived.clientSet.CoreV1().RESTClient())) 159 | derived.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.discoveryClient) 160 | derived.dynamicClient, err = dynamic.NewForConfig(derived.cfg) 161 | if err != nil { 162 | return k 163 | } 164 | derived.Helm = helm.NewHelm(derived) 165 | return derived 166 | } 167 | 168 | func marshal(v any) (string, error) { 169 | switch t := v.(type) { 170 | case []unstructured.Unstructured: 171 | for i := range t { 172 | t[i].SetManagedFields(nil) 173 | } 174 | case []*unstructured.Unstructured: 175 | for i := range t { 176 | t[i].SetManagedFields(nil) 177 | } 178 | case unstructured.Unstructured: 179 | t.SetManagedFields(nil) 180 | case *unstructured.Unstructured: 181 | t.SetManagedFields(nil) 182 | } 183 | ret, err := yaml.Marshal(v) 184 | if err != nil { 185 | return "", err 186 | } 187 | return string(ret), nil 188 | } 189 | -------------------------------------------------------------------------------- /pkg/kubernetes/namespaces.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "k8s.io/apimachinery/pkg/runtime/schema" 6 | ) 7 | 8 | func (k *Kubernetes) NamespacesList(ctx context.Context) (string, error) { 9 | return k.ResourcesList(ctx, &schema.GroupVersionKind{ 10 | Group: "", Version: "v1", Kind: "Namespace", 11 | }, "") 12 | } 13 | 14 | func (k *Kubernetes) ProjectsList(ctx context.Context) (string, error) { 15 | return k.ResourcesList(ctx, &schema.GroupVersionKind{ 16 | Group: "project.openshift.io", Version: "v1", Kind: "Project", 17 | }, "") 18 | } 19 | -------------------------------------------------------------------------------- /pkg/kubernetes/openshift.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | "k8s.io/apimachinery/pkg/runtime/schema" 7 | ) 8 | 9 | func (k *Kubernetes) IsOpenShift(ctx context.Context) bool { 10 | // This method should be fast and not block (it's called at startup) 11 | timeoutSeconds := int64(5) 12 | if _, err := k.dynamicClient.Resource(schema.GroupVersionResource{ 13 | Group: "project.openshift.io", 14 | Version: "v1", 15 | Resource: "projects", 16 | }).List(ctx, metav1.ListOptions{Limit: 1, TimeoutSeconds: &timeoutSeconds}); err == nil { 17 | return true 18 | } 19 | return false 20 | } 21 | -------------------------------------------------------------------------------- /pkg/kubernetes/pods.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | 8 | "github.com/manusa/kubernetes-mcp-server/pkg/version" 9 | v1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | labelutil "k8s.io/apimachinery/pkg/labels" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/runtime/schema" 15 | "k8s.io/apimachinery/pkg/util/httpstream" 16 | "k8s.io/apimachinery/pkg/util/intstr" 17 | "k8s.io/apimachinery/pkg/util/rand" 18 | "k8s.io/client-go/tools/remotecommand" 19 | ) 20 | 21 | func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, labelSelector string) (string, error) { 22 | return k.ResourcesList(ctx, &schema.GroupVersionKind{ 23 | Group: "", Version: "v1", Kind: "Pod", 24 | }, "", labelSelector) 25 | } 26 | 27 | func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, labelSelector string) (string, error) { 28 | return k.ResourcesList(ctx, &schema.GroupVersionKind{ 29 | Group: "", Version: "v1", Kind: "Pod", 30 | }, namespace, labelSelector) 31 | } 32 | 33 | func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (string, error) { 34 | return k.ResourcesGet(ctx, &schema.GroupVersionKind{ 35 | Group: "", Version: "v1", Kind: "Pod", 36 | }, k.NamespaceOrDefault(namespace), name) 37 | } 38 | 39 | func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) { 40 | namespace = k.NamespaceOrDefault(namespace) 41 | pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | isManaged := pod.GetLabels()[AppKubernetesManagedBy] == version.BinaryName 47 | managedLabelSelector := labelutil.Set{ 48 | AppKubernetesManagedBy: version.BinaryName, 49 | AppKubernetesName: pod.GetLabels()[AppKubernetesName], 50 | }.AsSelector() 51 | 52 | // Delete managed service 53 | if isManaged { 54 | if sl, _ := k.clientSet.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{ 55 | LabelSelector: managedLabelSelector.String(), 56 | }); sl != nil { 57 | for _, svc := range sl.Items { 58 | _ = k.clientSet.CoreV1().Services(namespace).Delete(ctx, svc.Name, metav1.DeleteOptions{}) 59 | } 60 | } 61 | } 62 | 63 | // Delete managed Route 64 | if isManaged && k.supportsGroupVersion("route.openshift.io/v1") { 65 | routeResources := k.dynamicClient. 66 | Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}). 67 | Namespace(namespace) 68 | if rl, _ := routeResources.List(ctx, metav1.ListOptions{ 69 | LabelSelector: managedLabelSelector.String(), 70 | }); rl != nil { 71 | for _, route := range rl.Items { 72 | _ = routeResources.Delete(ctx, route.GetName(), metav1.DeleteOptions{}) 73 | } 74 | } 75 | 76 | } 77 | return "Pod deleted successfully", 78 | k.clientSet.CoreV1().Pods(namespace).Delete(ctx, name, metav1.DeleteOptions{}) 79 | } 80 | 81 | func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string) (string, error) { 82 | tailLines := int64(256) 83 | req := k.clientSet.CoreV1().Pods(k.NamespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{ 84 | TailLines: &tailLines, 85 | Container: container, 86 | }) 87 | res := req.Do(ctx) 88 | if res.Error() != nil { 89 | return "", res.Error() 90 | } 91 | rawData, err := res.Raw() 92 | if err != nil { 93 | return "", err 94 | } 95 | return string(rawData), nil 96 | } 97 | 98 | func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, port int32) (string, error) { 99 | if name == "" { 100 | name = version.BinaryName + "-run-" + rand.String(5) 101 | } 102 | labels := map[string]string{ 103 | AppKubernetesName: name, 104 | AppKubernetesComponent: name, 105 | AppKubernetesManagedBy: version.BinaryName, 106 | AppKubernetesPartOf: version.BinaryName + "-run-sandbox", 107 | } 108 | // NewPod 109 | var resources []any 110 | pod := &v1.Pod{ 111 | TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"}, 112 | ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels}, 113 | Spec: v1.PodSpec{Containers: []v1.Container{{ 114 | Name: name, 115 | Image: image, 116 | ImagePullPolicy: v1.PullAlways, 117 | }}}, 118 | } 119 | resources = append(resources, pod) 120 | if port > 0 { 121 | pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}} 122 | resources = append(resources, &v1.Service{ 123 | TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}, 124 | ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels}, 125 | Spec: v1.ServiceSpec{ 126 | Selector: labels, 127 | Type: v1.ServiceTypeClusterIP, 128 | Ports: []v1.ServicePort{{Port: port, TargetPort: intstr.FromInt32(port)}}, 129 | }, 130 | }) 131 | } 132 | if port > 0 && k.supportsGroupVersion("route.openshift.io/v1") { 133 | resources = append(resources, &unstructured.Unstructured{ 134 | Object: map[string]interface{}{ 135 | "apiVersion": "route.openshift.io/v1", 136 | "kind": "Route", 137 | "metadata": map[string]interface{}{ 138 | "name": name, 139 | "namespace": k.NamespaceOrDefault(namespace), 140 | "labels": labels, 141 | }, 142 | "spec": map[string]interface{}{ 143 | "to": map[string]interface{}{ 144 | "kind": "Service", 145 | "name": name, 146 | "weight": 100, 147 | }, 148 | "port": map[string]interface{}{ 149 | "targetPort": intstr.FromInt32(port), 150 | }, 151 | "tls": map[string]interface{}{ 152 | "termination": "edge", 153 | "insecureEdgeTerminationPolicy": "Redirect", 154 | }, 155 | }, 156 | }, 157 | }) 158 | 159 | } 160 | 161 | // Convert the objects to Unstructured and reuse resourcesCreateOrUpdate functionality 162 | converter := runtime.DefaultUnstructuredConverter 163 | var toCreate []*unstructured.Unstructured 164 | for _, obj := range resources { 165 | m, err := converter.ToUnstructured(obj) 166 | if err != nil { 167 | return "", err 168 | } 169 | u := &unstructured.Unstructured{} 170 | if err = converter.FromUnstructured(m, u); err != nil { 171 | return "", err 172 | } 173 | toCreate = append(toCreate, u) 174 | } 175 | return k.resourcesCreateOrUpdate(ctx, toCreate) 176 | } 177 | 178 | func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) { 179 | namespace = k.NamespaceOrDefault(namespace) 180 | pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) 181 | if err != nil { 182 | return "", err 183 | } 184 | // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L350-L352 185 | if pod.Status.Phase == v1.PodSucceeded || pod.Status.Phase == v1.PodFailed { 186 | return "", fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", pod.Status.Phase) 187 | } 188 | if container == "" { 189 | container = pod.Spec.Containers[0].Name 190 | } 191 | podExecOptions := &v1.PodExecOptions{ 192 | Container: container, 193 | Command: command, 194 | Stdout: true, 195 | Stderr: true, 196 | } 197 | executor, err := k.createExecutor(namespace, name, podExecOptions) 198 | if err != nil { 199 | return "", err 200 | } 201 | stdout := bytes.NewBuffer(make([]byte, 0)) 202 | stderr := bytes.NewBuffer(make([]byte, 0)) 203 | if err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ 204 | Stdout: stdout, Stderr: stderr, Tty: false, 205 | }); err != nil { 206 | return "", err 207 | } 208 | if stdout.Len() > 0 { 209 | return stdout.String(), nil 210 | } 211 | if stderr.Len() > 0 { 212 | return stderr.String(), nil 213 | } 214 | return "", nil 215 | } 216 | 217 | func (k *Kubernetes) createExecutor(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) { 218 | // Compute URL 219 | // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397 220 | req := k.clientSet.CoreV1().RESTClient(). 221 | Post(). 222 | Resource("pods"). 223 | Namespace(namespace). 224 | Name(name). 225 | SubResource("exec") 226 | req.VersionedParams(podExecOptions, k.parameterCodec) 227 | spdyExec, err := remotecommand.NewSPDYExecutor(k.cfg, "POST", req.URL()) 228 | if err != nil { 229 | return nil, err 230 | } 231 | webSocketExec, err := remotecommand.NewWebSocketExecutor(k.cfg, "GET", req.URL().String()) 232 | if err != nil { 233 | return nil, err 234 | } 235 | return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool { 236 | return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err) 237 | }) 238 | } 239 | -------------------------------------------------------------------------------- /pkg/kubernetes/resources.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/manusa/kubernetes-mcp-server/pkg/version" 9 | authv1 "k8s.io/api/authorization/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "k8s.io/apimachinery/pkg/runtime/schema" 13 | "k8s.io/apimachinery/pkg/util/yaml" 14 | ) 15 | 16 | const ( 17 | AppKubernetesComponent = "app.kubernetes.io/component" 18 | AppKubernetesManagedBy = "app.kubernetes.io/managed-by" 19 | AppKubernetesName = "app.kubernetes.io/name" 20 | AppKubernetesPartOf = "app.kubernetes.io/part-of" 21 | ) 22 | 23 | func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, labelSelector ...string) (string, error) { 24 | var selector string 25 | if len(labelSelector) > 0 { 26 | selector = labelSelector[0] 27 | } 28 | rl, err := k.resourcesList(ctx, gvk, namespace, selector) 29 | if err != nil { 30 | return "", err 31 | } 32 | return marshal(rl.Items) 33 | } 34 | 35 | func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (string, error) { 36 | gvr, err := k.resourceFor(gvk) 37 | if err != nil { 38 | return "", err 39 | } 40 | // If it's a namespaced resource and namespace wasn't provided, try to use the default configured one 41 | if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { 42 | namespace = k.NamespaceOrDefault(namespace) 43 | } 44 | rg, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) 45 | if err != nil { 46 | return "", err 47 | } 48 | return marshal(rg) 49 | } 50 | 51 | func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) (string, error) { 52 | separator := regexp.MustCompile(`\r?\n---\r?\n`) 53 | resources := separator.Split(resource, -1) 54 | var parsedResources []*unstructured.Unstructured 55 | for _, r := range resources { 56 | var obj unstructured.Unstructured 57 | if err := yaml.NewYAMLToJSONDecoder(strings.NewReader(r)).Decode(&obj); err != nil { 58 | return "", err 59 | } 60 | parsedResources = append(parsedResources, &obj) 61 | } 62 | return k.resourcesCreateOrUpdate(ctx, parsedResources) 63 | } 64 | 65 | func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) error { 66 | gvr, err := k.resourceFor(gvk) 67 | if err != nil { 68 | return err 69 | } 70 | // If it's a namespaced resource and namespace wasn't provided, try to use the default configured one 71 | if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { 72 | namespace = k.NamespaceOrDefault(namespace) 73 | } 74 | return k.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) 75 | } 76 | 77 | func (k *Kubernetes) resourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, labelSelector string) (*unstructured.UnstructuredList, error) { 78 | gvr, err := k.resourceFor(gvk) 79 | if err != nil { 80 | return nil, err 81 | } 82 | // Check if operation is allowed for all namespaces (applicable for namespaced resources) 83 | isNamespaced, _ := k.isNamespaced(gvk) 84 | if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" { 85 | namespace = k.configuredNamespace() 86 | } 87 | return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{ 88 | LabelSelector: labelSelector, 89 | }) 90 | } 91 | 92 | func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) (string, error) { 93 | for i, obj := range resources { 94 | gvk := obj.GroupVersionKind() 95 | gvr, rErr := k.resourceFor(&gvk) 96 | if rErr != nil { 97 | return "", rErr 98 | } 99 | namespace := obj.GetNamespace() 100 | // If it's a namespaced resource and namespace wasn't provided, try to use the default configured one 101 | if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced { 102 | namespace = k.NamespaceOrDefault(namespace) 103 | } 104 | resources[i], rErr = k.dynamicClient.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{ 105 | FieldManager: version.BinaryName, 106 | }) 107 | if rErr != nil { 108 | return "", rErr 109 | } 110 | // Clear the cache to ensure the next operation is performed on the latest exposed APIs 111 | if gvk.Kind == "CustomResourceDefinition" { 112 | k.deferredDiscoveryRESTMapper.Reset() 113 | } 114 | } 115 | marshalledYaml, err := marshal(resources) 116 | if err != nil { 117 | return "", err 118 | } 119 | return "# The following resources (YAML) have been created or updated successfully\n" + marshalledYaml, nil 120 | } 121 | 122 | func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) { 123 | m, err := k.deferredDiscoveryRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) 124 | if err != nil { 125 | return nil, err 126 | } 127 | return &m.Resource, nil 128 | } 129 | 130 | func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) { 131 | apiResourceList, err := k.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String()) 132 | if err != nil { 133 | return false, err 134 | } 135 | for _, apiResource := range apiResourceList.APIResources { 136 | if apiResource.Kind == gvk.Kind { 137 | return apiResource.Namespaced, nil 138 | } 139 | } 140 | return false, nil 141 | } 142 | 143 | func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool { 144 | if _, err := k.discoveryClient.ServerResourcesForGroupVersion(groupVersion); err != nil { 145 | return false 146 | } 147 | return true 148 | } 149 | 150 | func (k *Kubernetes) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool { 151 | response, err := k.clientSet.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authv1.SelfSubjectAccessReview{ 152 | Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{ 153 | Namespace: namespace, 154 | Verb: verb, 155 | Group: gvr.Group, 156 | Version: gvr.Version, 157 | Resource: gvr.Resource, 158 | }}, 159 | }, metav1.CreateOptions{}) 160 | if err != nil { 161 | // TODO: maybe return the error too 162 | return false 163 | } 164 | return response.Status.Allowed 165 | } 166 | -------------------------------------------------------------------------------- /pkg/mcp/common_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/mark3labs/mcp-go/client" 8 | "github.com/mark3labs/mcp-go/client/transport" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | "github.com/pkg/errors" 12 | "github.com/spf13/afero" 13 | "golang.org/x/sync/errgroup" 14 | corev1 "k8s.io/api/core/v1" 15 | rbacv1 "k8s.io/api/rbac/v1" 16 | apiextensionsv1spec "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 17 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" 18 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | "k8s.io/apimachinery/pkg/runtime/schema" 20 | "k8s.io/apimachinery/pkg/runtime/serializer" 21 | "k8s.io/apimachinery/pkg/watch" 22 | "k8s.io/client-go/kubernetes" 23 | "k8s.io/client-go/rest" 24 | "k8s.io/client-go/scale" 25 | "k8s.io/client-go/tools/clientcmd" 26 | "k8s.io/client-go/tools/clientcmd/api" 27 | toolswatch "k8s.io/client-go/tools/watch" 28 | "k8s.io/utils/ptr" 29 | "net/http/httptest" 30 | "os" 31 | "path/filepath" 32 | "runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/envtest" 34 | "sigs.k8s.io/controller-runtime/tools/setup-envtest/env" 35 | "sigs.k8s.io/controller-runtime/tools/setup-envtest/remote" 36 | "sigs.k8s.io/controller-runtime/tools/setup-envtest/store" 37 | "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" 38 | "sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows" 39 | "testing" 40 | "time" 41 | ) 42 | 43 | // envTest has an expensive setup, so we only want to do it once per entire test run. 44 | var envTest *envtest.Environment 45 | var envTestRestConfig *rest.Config 46 | var envTestUser = envtest.User{Name: "test-user", Groups: []string{"test:users"}} 47 | 48 | func TestMain(m *testing.M) { 49 | // Set up 50 | envTestDir, err := store.DefaultStoreDir() 51 | if err != nil { 52 | panic(err) 53 | } 54 | envTestEnv := &env.Env{ 55 | FS: afero.Afero{Fs: afero.NewOsFs()}, 56 | Out: os.Stdout, 57 | Client: &remote.HTTPClient{ 58 | IndexURL: remote.DefaultIndexURL, 59 | }, 60 | Platform: versions.PlatformItem{ 61 | Platform: versions.Platform{ 62 | OS: runtime.GOOS, 63 | Arch: runtime.GOARCH, 64 | }, 65 | }, 66 | Version: versions.AnyVersion, 67 | Store: store.NewAt(envTestDir), 68 | } 69 | envTestEnv.CheckCoherence() 70 | workflows.Use{}.Do(envTestEnv) 71 | versionDir := envTestEnv.Platform.Platform.BaseName(*envTestEnv.Version.AsConcrete()) 72 | envTest = &envtest.Environment{ 73 | BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir), 74 | } 75 | adminSystemMasterBaseConfig, _ := envTest.Start() 76 | au, err := envTest.AddUser(envTestUser, adminSystemMasterBaseConfig) 77 | if err != nil { 78 | panic(err) 79 | } 80 | envTestRestConfig = au.Config() 81 | 82 | //Create test data as administrator 83 | ctx := context.Background() 84 | restoreAuth(ctx) 85 | createTestData(ctx) 86 | 87 | // Test! 88 | code := m.Run() 89 | 90 | // Tear down 91 | if envTest != nil { 92 | _ = envTest.Stop() 93 | } 94 | os.Exit(code) 95 | } 96 | 97 | type mcpContext struct { 98 | profile Profile 99 | readOnly bool 100 | disableDestructive bool 101 | clientOptions []transport.ClientOption 102 | before func(*mcpContext) 103 | after func(*mcpContext) 104 | ctx context.Context 105 | tempDir string 106 | cancel context.CancelFunc 107 | mcpServer *Server 108 | mcpHttpServer *httptest.Server 109 | mcpClient *client.Client 110 | } 111 | 112 | func (c *mcpContext) beforeEach(t *testing.T) { 113 | var err error 114 | c.ctx, c.cancel = context.WithCancel(t.Context()) 115 | c.tempDir = t.TempDir() 116 | c.withKubeConfig(nil) 117 | if c.profile == nil { 118 | c.profile = &FullProfile{} 119 | } 120 | if c.before != nil { 121 | c.before(c) 122 | } 123 | if c.mcpServer, err = NewSever(Configuration{ 124 | Profile: c.profile, ReadOnly: c.readOnly, DisableDestructive: c.disableDestructive, 125 | }); err != nil { 126 | t.Fatal(err) 127 | return 128 | } 129 | c.mcpHttpServer = server.NewTestServer(c.mcpServer.server, server.WithSSEContextFunc(contextFunc)) 130 | if c.mcpClient, err = client.NewSSEMCPClient(c.mcpHttpServer.URL+"/sse", c.clientOptions...); err != nil { 131 | t.Fatal(err) 132 | return 133 | } 134 | if err = c.mcpClient.Start(c.ctx); err != nil { 135 | t.Fatal(err) 136 | return 137 | } 138 | initRequest := mcp.InitializeRequest{} 139 | initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION 140 | initRequest.Params.ClientInfo = mcp.Implementation{Name: "test", Version: "1.33.7"} 141 | _, err = c.mcpClient.Initialize(c.ctx, initRequest) 142 | if err != nil { 143 | t.Fatal(err) 144 | return 145 | } 146 | } 147 | 148 | func (c *mcpContext) afterEach() { 149 | if c.after != nil { 150 | c.after(c) 151 | } 152 | c.cancel() 153 | c.mcpServer.Close() 154 | _ = c.mcpClient.Close() 155 | c.mcpHttpServer.Close() 156 | } 157 | 158 | func testCase(t *testing.T, test func(c *mcpContext)) { 159 | testCaseWithContext(t, &mcpContext{profile: &FullProfile{}}, test) 160 | } 161 | 162 | func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpContext)) { 163 | mcpCtx.beforeEach(t) 164 | defer mcpCtx.afterEach() 165 | test(mcpCtx) 166 | } 167 | 168 | // withKubeConfig sets up a fake kubeconfig in the temp directory based on the provided rest.Config 169 | func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config { 170 | fakeConfig := api.NewConfig() 171 | fakeConfig.Clusters["fake"] = api.NewCluster() 172 | fakeConfig.Clusters["fake"].Server = "https://example.com" 173 | fakeConfig.Clusters["additional-cluster"] = api.NewCluster() 174 | fakeConfig.AuthInfos["fake"] = api.NewAuthInfo() 175 | fakeConfig.AuthInfos["additional-auth"] = api.NewAuthInfo() 176 | if rc != nil { 177 | fakeConfig.Clusters["fake"].Server = rc.Host 178 | fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.TLSClientConfig.CAData 179 | fakeConfig.AuthInfos["fake"].ClientKeyData = rc.TLSClientConfig.KeyData 180 | fakeConfig.AuthInfos["fake"].ClientCertificateData = rc.TLSClientConfig.CertData 181 | } 182 | fakeConfig.Contexts["fake-context"] = api.NewContext() 183 | fakeConfig.Contexts["fake-context"].Cluster = "fake" 184 | fakeConfig.Contexts["fake-context"].AuthInfo = "fake" 185 | fakeConfig.Contexts["additional-context"] = api.NewContext() 186 | fakeConfig.Contexts["additional-context"].Cluster = "additional-cluster" 187 | fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth" 188 | fakeConfig.CurrentContext = "fake-context" 189 | kubeConfig := filepath.Join(c.tempDir, "config") 190 | _ = clientcmd.WriteToFile(*fakeConfig, kubeConfig) 191 | _ = os.Setenv("KUBECONFIG", kubeConfig) 192 | if c.mcpServer != nil { 193 | if err := c.mcpServer.reloadKubernetesClient(); err != nil { 194 | panic(err) 195 | } 196 | } 197 | return fakeConfig 198 | } 199 | 200 | // withEnvTest sets up the environment for kubeconfig to be used with envTest 201 | func (c *mcpContext) withEnvTest() { 202 | c.withKubeConfig(envTestRestConfig) 203 | } 204 | 205 | // inOpenShift sets up the kubernetes environment to seem to be running OpenShift 206 | func inOpenShift(c *mcpContext) { 207 | c.withEnvTest() 208 | crdTemplate := ` 209 | { 210 | "apiVersion": "apiextensions.k8s.io/v1", 211 | "kind": "CustomResourceDefinition", 212 | "metadata": {"name": "%s"}, 213 | "spec": { 214 | "group": "%s", 215 | "versions": [{ 216 | "name": "v1","served": true,"storage": true, 217 | "schema": {"openAPIV3Schema": {"type": "object","x-kubernetes-preserve-unknown-fields": true}} 218 | }], 219 | "scope": "%s", 220 | "names": {"plural": "%s","singular": "%s","kind": "%s"} 221 | } 222 | }` 223 | tasks, _ := errgroup.WithContext(c.ctx) 224 | tasks.Go(func() error { 225 | return c.crdApply(fmt.Sprintf(crdTemplate, "projects.project.openshift.io", "project.openshift.io", 226 | "Cluster", "projects", "project", "Project")) 227 | }) 228 | tasks.Go(func() error { 229 | return c.crdApply(fmt.Sprintf(crdTemplate, "routes.route.openshift.io", "route.openshift.io", 230 | "Namespaced", "routes", "route", "Route")) 231 | }) 232 | if err := tasks.Wait(); err != nil { 233 | panic(err) 234 | } 235 | } 236 | 237 | // inOpenShiftClear clears the kubernetes environment so it no longer seems to be running OpenShift 238 | func inOpenShiftClear(c *mcpContext) { 239 | tasks, _ := errgroup.WithContext(c.ctx) 240 | tasks.Go(func() error { return c.crdDelete("projects.project.openshift.io") }) 241 | tasks.Go(func() error { return c.crdDelete("routes.route.openshift.io") }) 242 | if err := tasks.Wait(); err != nil { 243 | panic(err) 244 | } 245 | } 246 | 247 | // newKubernetesClient creates a new Kubernetes client with the envTest kubeconfig 248 | func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset { 249 | return kubernetes.NewForConfigOrDie(envTestRestConfig) 250 | } 251 | 252 | func (c *mcpContext) newRestClient(groupVersion *schema.GroupVersion) *rest.RESTClient { 253 | config := *envTestRestConfig 254 | config.GroupVersion = groupVersion 255 | config.APIPath = "/api" 256 | config.NegotiatedSerializer = serializer.NewCodecFactory(scale.NewScaleConverter().Scheme()).WithoutConversion() 257 | rc, err := rest.RESTClientFor(&config) 258 | if err != nil { 259 | panic(err) 260 | } 261 | return rc 262 | } 263 | 264 | // newApiExtensionsClient creates a new ApiExtensions client with the envTest kubeconfig 265 | func (c *mcpContext) newApiExtensionsClient() *apiextensionsv1.ApiextensionsV1Client { 266 | return apiextensionsv1.NewForConfigOrDie(envTestRestConfig) 267 | } 268 | 269 | // crdApply creates a CRD from the provided resource string and waits for it to be established 270 | func (c *mcpContext) crdApply(resource string) error { 271 | apiExtensionsV1Client := c.newApiExtensionsClient() 272 | var crd = &apiextensionsv1spec.CustomResourceDefinition{} 273 | err := json.Unmarshal([]byte(resource), crd) 274 | _, err = apiExtensionsV1Client.CustomResourceDefinitions().Create(c.ctx, crd, metav1.CreateOptions{}) 275 | if err != nil { 276 | return fmt.Errorf("failed to create CRD %v", err) 277 | } 278 | c.crdWaitUntilReady(crd.Name) 279 | return nil 280 | } 281 | 282 | // crdDelete deletes a CRD by name and waits for it to be removed 283 | func (c *mcpContext) crdDelete(name string) error { 284 | apiExtensionsV1Client := c.newApiExtensionsClient() 285 | err := apiExtensionsV1Client.CustomResourceDefinitions().Delete(c.ctx, name, metav1.DeleteOptions{ 286 | GracePeriodSeconds: ptr.To(int64(0)), 287 | }) 288 | iteration := 0 289 | for iteration < 100 { 290 | if _, derr := apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, name, metav1.GetOptions{}); derr != nil { 291 | break 292 | } 293 | time.Sleep(5 * time.Millisecond) 294 | iteration++ 295 | } 296 | if err != nil { 297 | return errors.Wrap(err, "failed to delete CRD") 298 | } 299 | return nil 300 | } 301 | 302 | // crdWaitUntilReady waits for a CRD to be established 303 | func (c *mcpContext) crdWaitUntilReady(name string) { 304 | watcher, err := c.newApiExtensionsClient().CustomResourceDefinitions().Watch(c.ctx, metav1.ListOptions{ 305 | FieldSelector: "metadata.name=" + name, 306 | }) 307 | if err != nil { 308 | panic(fmt.Errorf("failed to watch CRD %v", err)) 309 | } 310 | _, err = toolswatch.UntilWithoutRetry(c.ctx, watcher, func(event watch.Event) (bool, error) { 311 | for _, c := range event.Object.(*apiextensionsv1spec.CustomResourceDefinition).Status.Conditions { 312 | if c.Type == apiextensionsv1spec.Established && c.Status == apiextensionsv1spec.ConditionTrue { 313 | return true, nil 314 | } 315 | } 316 | return false, nil 317 | }) 318 | if err != nil { 319 | panic(fmt.Errorf("failed to wait for CRD %v", err)) 320 | } 321 | } 322 | 323 | // callTool helper function to call a tool by name with arguments 324 | func (c *mcpContext) callTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) { 325 | callToolRequest := mcp.CallToolRequest{} 326 | callToolRequest.Params.Name = name 327 | callToolRequest.Params.Arguments = args 328 | return c.mcpClient.CallTool(c.ctx, callToolRequest) 329 | } 330 | 331 | func restoreAuth(ctx context.Context) { 332 | kubernetesAdmin := kubernetes.NewForConfigOrDie(envTest.Config) 333 | // Authorization 334 | _, _ = kubernetesAdmin.RbacV1().ClusterRoles().Update(ctx, &rbacv1.ClusterRole{ 335 | ObjectMeta: metav1.ObjectMeta{Name: "allow-all"}, 336 | Rules: []rbacv1.PolicyRule{{ 337 | Verbs: []string{"*"}, 338 | APIGroups: []string{"*"}, 339 | Resources: []string{"*"}, 340 | }}, 341 | }, metav1.UpdateOptions{}) 342 | _, _ = kubernetesAdmin.RbacV1().ClusterRoleBindings().Update(ctx, &rbacv1.ClusterRoleBinding{ 343 | ObjectMeta: metav1.ObjectMeta{Name: "allow-all"}, 344 | Subjects: []rbacv1.Subject{{Kind: "Group", Name: envTestUser.Groups[0]}}, 345 | RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "allow-all"}, 346 | }, metav1.UpdateOptions{}) 347 | } 348 | 349 | func createTestData(ctx context.Context) { 350 | kubernetesAdmin := kubernetes.NewForConfigOrDie(envTestRestConfig) 351 | // Namespaces 352 | _, _ = kubernetesAdmin.CoreV1().Namespaces(). 353 | Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}}, metav1.CreateOptions{}) 354 | _, _ = kubernetesAdmin.CoreV1().Namespaces(). 355 | Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}}, metav1.CreateOptions{}) 356 | _, _ = kubernetesAdmin.CoreV1().Namespaces(). 357 | Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-to-delete"}}, metav1.CreateOptions{}) 358 | _, _ = kubernetesAdmin.CoreV1().Pods("default").Create(ctx, &corev1.Pod{ 359 | ObjectMeta: metav1.ObjectMeta{ 360 | Name: "a-pod-in-default", 361 | Labels: map[string]string{"app": "nginx"}, 362 | }, 363 | Spec: corev1.PodSpec{ 364 | Containers: []corev1.Container{ 365 | { 366 | Name: "nginx", 367 | Image: "nginx", 368 | }, 369 | }, 370 | }, 371 | }, metav1.CreateOptions{}) 372 | // Pods for listing 373 | _, _ = kubernetesAdmin.CoreV1().Pods("ns-1").Create(ctx, &corev1.Pod{ 374 | ObjectMeta: metav1.ObjectMeta{ 375 | Name: "a-pod-in-ns-1", 376 | }, 377 | Spec: corev1.PodSpec{ 378 | Containers: []corev1.Container{ 379 | { 380 | Name: "nginx", 381 | Image: "nginx", 382 | }, 383 | }, 384 | }, 385 | }, metav1.CreateOptions{}) 386 | _, _ = kubernetesAdmin.CoreV1().Pods("ns-2").Create(ctx, &corev1.Pod{ 387 | ObjectMeta: metav1.ObjectMeta{ 388 | Name: "a-pod-in-ns-2", 389 | }, 390 | Spec: corev1.PodSpec{ 391 | Containers: []corev1.Container{ 392 | { 393 | Name: "nginx", 394 | Image: "nginx", 395 | }, 396 | }, 397 | }, 398 | }, metav1.CreateOptions{}) 399 | _, _ = kubernetesAdmin.CoreV1().ConfigMaps("default"). 400 | Create(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a-configmap-to-delete"}}, metav1.CreateOptions{}) 401 | } 402 | -------------------------------------------------------------------------------- /pkg/mcp/configuration.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mark3labs/mcp-go/mcp" 7 | "github.com/mark3labs/mcp-go/server" 8 | ) 9 | 10 | func (s *Server) initConfiguration() []server.ServerTool { 11 | tools := []server.ServerTool{ 12 | {mcp.NewTool("configuration_view", 13 | mcp.WithDescription("Get the current Kubernetes configuration content as a kubeconfig YAML"), 14 | mcp.WithBoolean("minified", mcp.Description("Return a minified version of the configuration. "+ 15 | "If set to true, keeps only the current-context and the relevant pieces of the configuration for that context. "+ 16 | "If set to false, all contexts, clusters, auth-infos, and users are returned in the configuration. "+ 17 | "(Optional, default true)")), 18 | // Tool annotations 19 | mcp.WithTitleAnnotation("Configuration: View"), 20 | mcp.WithReadOnlyHintAnnotation(true), 21 | mcp.WithDestructiveHintAnnotation(false), 22 | mcp.WithOpenWorldHintAnnotation(true), 23 | ), s.configurationView}, 24 | } 25 | return tools 26 | } 27 | 28 | func (s *Server) configurationView(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 29 | minify := true 30 | minified := ctr.GetArguments()["minified"] 31 | if _, ok := minified.(bool); ok { 32 | minify = minified.(bool) 33 | } 34 | ret, err := s.k.ConfigurationView(minify) 35 | if err != nil { 36 | err = fmt.Errorf("failed to get configuration: %v", err) 37 | } 38 | return NewTextResult(ret, err), nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/mcp/configuration_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "github.com/manusa/kubernetes-mcp-server/pkg/kubernetes" 5 | "github.com/mark3labs/mcp-go/mcp" 6 | "k8s.io/client-go/rest" 7 | v1 "k8s.io/client-go/tools/clientcmd/api/v1" 8 | "sigs.k8s.io/yaml" 9 | "testing" 10 | ) 11 | 12 | func TestConfigurationView(t *testing.T) { 13 | testCase(t, func(c *mcpContext) { 14 | toolResult, err := c.callTool("configuration_view", map[string]interface{}{}) 15 | t.Run("configuration_view returns configuration", func(t *testing.T) { 16 | if err != nil { 17 | t.Fatalf("call tool failed %v", err) 18 | } 19 | }) 20 | var decoded *v1.Config 21 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) 22 | t.Run("configuration_view has yaml content", func(t *testing.T) { 23 | if err != nil { 24 | t.Fatalf("invalid tool result content %v", err) 25 | } 26 | }) 27 | t.Run("configuration_view returns current-context", func(t *testing.T) { 28 | if decoded.CurrentContext != "fake-context" { 29 | t.Errorf("fake-context not found: %v", decoded.CurrentContext) 30 | } 31 | }) 32 | t.Run("configuration_view returns context info", func(t *testing.T) { 33 | if len(decoded.Contexts) != 1 { 34 | t.Errorf("invalid context count, expected 1, got %v", len(decoded.Contexts)) 35 | } 36 | if decoded.Contexts[0].Name != "fake-context" { 37 | t.Errorf("fake-context not found: %v", decoded.Contexts) 38 | } 39 | if decoded.Contexts[0].Context.Cluster != "fake" { 40 | t.Errorf("fake-cluster not found: %v", decoded.Contexts) 41 | } 42 | if decoded.Contexts[0].Context.AuthInfo != "fake" { 43 | t.Errorf("fake-auth not found: %v", decoded.Contexts) 44 | } 45 | }) 46 | t.Run("configuration_view returns cluster info", func(t *testing.T) { 47 | if len(decoded.Clusters) != 1 { 48 | t.Errorf("invalid cluster count, expected 1, got %v", len(decoded.Clusters)) 49 | } 50 | if decoded.Clusters[0].Name != "fake" { 51 | t.Errorf("fake-cluster not found: %v", decoded.Clusters) 52 | } 53 | if decoded.Clusters[0].Cluster.Server != "https://example.com" { 54 | t.Errorf("fake-server not found: %v", decoded.Clusters) 55 | } 56 | }) 57 | t.Run("configuration_view returns auth info", func(t *testing.T) { 58 | if len(decoded.AuthInfos) != 1 { 59 | t.Errorf("invalid auth info count, expected 1, got %v", len(decoded.AuthInfos)) 60 | } 61 | if decoded.AuthInfos[0].Name != "fake" { 62 | t.Errorf("fake-auth not found: %v", decoded.AuthInfos) 63 | } 64 | }) 65 | toolResult, err = c.callTool("configuration_view", map[string]interface{}{ 66 | "minified": false, 67 | }) 68 | t.Run("configuration_view with minified=false returns configuration", func(t *testing.T) { 69 | if err != nil { 70 | t.Fatalf("call tool failed %v", err) 71 | } 72 | }) 73 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) 74 | t.Run("configuration_view with minified=false has yaml content", func(t *testing.T) { 75 | if err != nil { 76 | t.Fatalf("invalid tool result content %v", err) 77 | } 78 | }) 79 | t.Run("configuration_view with minified=false returns additional context info", func(t *testing.T) { 80 | if len(decoded.Contexts) != 2 { 81 | t.Errorf("invalid context count, expected2, got %v", len(decoded.Contexts)) 82 | } 83 | if decoded.Contexts[0].Name != "additional-context" { 84 | t.Errorf("additional-context not found: %v", decoded.Contexts) 85 | } 86 | if decoded.Contexts[0].Context.Cluster != "additional-cluster" { 87 | t.Errorf("additional-cluster not found: %v", decoded.Contexts) 88 | } 89 | if decoded.Contexts[0].Context.AuthInfo != "additional-auth" { 90 | t.Errorf("additional-auth not found: %v", decoded.Contexts) 91 | } 92 | if decoded.Contexts[1].Name != "fake-context" { 93 | t.Errorf("fake-context not found: %v", decoded.Contexts) 94 | } 95 | }) 96 | t.Run("configuration_view with minified=false returns cluster info", func(t *testing.T) { 97 | if len(decoded.Clusters) != 2 { 98 | t.Errorf("invalid cluster count, expected 2, got %v", len(decoded.Clusters)) 99 | } 100 | if decoded.Clusters[0].Name != "additional-cluster" { 101 | t.Errorf("additional-cluster not found: %v", decoded.Clusters) 102 | } 103 | }) 104 | t.Run("configuration_view with minified=false returns auth info", func(t *testing.T) { 105 | if len(decoded.AuthInfos) != 2 { 106 | t.Errorf("invalid auth info count, expected 2, got %v", len(decoded.AuthInfos)) 107 | } 108 | if decoded.AuthInfos[0].Name != "additional-auth" { 109 | t.Errorf("additional-auth not found: %v", decoded.AuthInfos) 110 | } 111 | }) 112 | }) 113 | } 114 | 115 | func TestConfigurationViewInCluster(t *testing.T) { 116 | kubernetes.InClusterConfig = func() (*rest.Config, error) { 117 | return &rest.Config{ 118 | Host: "https://kubernetes.default.svc", 119 | BearerToken: "fake-token", 120 | }, nil 121 | } 122 | defer func() { 123 | kubernetes.InClusterConfig = rest.InClusterConfig 124 | }() 125 | testCase(t, func(c *mcpContext) { 126 | toolResult, err := c.callTool("configuration_view", map[string]interface{}{}) 127 | t.Run("configuration_view returns configuration", func(t *testing.T) { 128 | if err != nil { 129 | t.Fatalf("call tool failed %v", err) 130 | } 131 | }) 132 | var decoded *v1.Config 133 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) 134 | t.Run("configuration_view has yaml content", func(t *testing.T) { 135 | if err != nil { 136 | t.Fatalf("invalid tool result content %v", err) 137 | } 138 | }) 139 | t.Run("configuration_view returns current-context", func(t *testing.T) { 140 | if decoded.CurrentContext != "context" { 141 | t.Fatalf("context not found: %v", decoded.CurrentContext) 142 | } 143 | }) 144 | t.Run("configuration_view returns context info", func(t *testing.T) { 145 | if len(decoded.Contexts) != 1 { 146 | t.Fatalf("invalid context count, expected 1, got %v", len(decoded.Contexts)) 147 | } 148 | if decoded.Contexts[0].Name != "context" { 149 | t.Fatalf("context not found: %v", decoded.Contexts) 150 | } 151 | if decoded.Contexts[0].Context.Cluster != "cluster" { 152 | t.Fatalf("cluster not found: %v", decoded.Contexts) 153 | } 154 | if decoded.Contexts[0].Context.AuthInfo != "user" { 155 | t.Fatalf("user not found: %v", decoded.Contexts) 156 | } 157 | }) 158 | t.Run("configuration_view returns cluster info", func(t *testing.T) { 159 | if len(decoded.Clusters) != 1 { 160 | t.Fatalf("invalid cluster count, expected 1, got %v", len(decoded.Clusters)) 161 | } 162 | if decoded.Clusters[0].Name != "cluster" { 163 | t.Fatalf("cluster not found: %v", decoded.Clusters) 164 | } 165 | if decoded.Clusters[0].Cluster.Server != "https://kubernetes.default.svc" { 166 | t.Fatalf("server not found: %v", decoded.Clusters) 167 | } 168 | }) 169 | t.Run("configuration_view returns auth info", func(t *testing.T) { 170 | if len(decoded.AuthInfos) != 1 { 171 | t.Fatalf("invalid auth info count, expected 1, got %v", len(decoded.AuthInfos)) 172 | } 173 | if decoded.AuthInfos[0].Name != "user" { 174 | t.Fatalf("user not found: %v", decoded.AuthInfos) 175 | } 176 | }) 177 | }) 178 | } 179 | -------------------------------------------------------------------------------- /pkg/mcp/events.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mark3labs/mcp-go/mcp" 7 | "github.com/mark3labs/mcp-go/server" 8 | ) 9 | 10 | func (s *Server) initEvents() []server.ServerTool { 11 | return []server.ServerTool{ 12 | {mcp.NewTool("events_list", 13 | mcp.WithDescription("List all the Kubernetes events in the current cluster from all namespaces"), 14 | mcp.WithString("namespace", 15 | mcp.Description("Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces")), 16 | // Tool annotations 17 | mcp.WithTitleAnnotation("Events: List"), 18 | mcp.WithReadOnlyHintAnnotation(true), 19 | mcp.WithDestructiveHintAnnotation(false), 20 | mcp.WithOpenWorldHintAnnotation(true), 21 | ), s.eventsList}, 22 | } 23 | } 24 | 25 | func (s *Server) eventsList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 26 | namespace := ctr.GetArguments()["namespace"] 27 | if namespace == nil { 28 | namespace = "" 29 | } 30 | ret, err := s.k.Derived(ctx).EventsList(ctx, namespace.(string)) 31 | if err != nil { 32 | return NewTextResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil 33 | } 34 | return NewTextResult(ret, err), nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/mcp/events_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | v1 "k8s.io/api/core/v1" 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "testing" 8 | ) 9 | 10 | func TestEventsList(t *testing.T) { 11 | testCase(t, func(c *mcpContext) { 12 | c.withEnvTest() 13 | toolResult, err := c.callTool("events_list", map[string]interface{}{}) 14 | t.Run("events_list with no events returns OK", func(t *testing.T) { 15 | if err != nil { 16 | t.Fatalf("call tool failed %v", err) 17 | } 18 | if toolResult.IsError { 19 | t.Fatalf("call tool failed") 20 | } 21 | if toolResult.Content[0].(mcp.TextContent).Text != "No events found" { 22 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 23 | } 24 | }) 25 | client := c.newKubernetesClient() 26 | for _, ns := range []string{"default", "ns-1"} { 27 | _, _ = client.CoreV1().Events(ns).Create(c.ctx, &v1.Event{ 28 | ObjectMeta: metav1.ObjectMeta{ 29 | Name: "an-event-in-" + ns, 30 | }, 31 | InvolvedObject: v1.ObjectReference{ 32 | APIVersion: "v1", 33 | Kind: "Pod", 34 | Name: "a-pod", 35 | Namespace: ns, 36 | }, 37 | Type: "Normal", 38 | Message: "The event message", 39 | }, metav1.CreateOptions{}) 40 | } 41 | toolResult, err = c.callTool("events_list", map[string]interface{}{}) 42 | t.Run("events_list with events returns all OK", func(t *testing.T) { 43 | if err != nil { 44 | t.Fatalf("call tool failed %v", err) 45 | } 46 | if toolResult.IsError { 47 | t.Fatalf("call tool failed") 48 | } 49 | if toolResult.Content[0].(mcp.TextContent).Text != "The following events (YAML format) were found:\n"+ 50 | "- InvolvedObject:\n"+ 51 | " Kind: Pod\n"+ 52 | " Name: a-pod\n"+ 53 | " apiVersion: v1\n"+ 54 | " Message: The event message\n"+ 55 | " Namespace: default\n"+ 56 | " Reason: \"\"\n"+ 57 | " Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+ 58 | " Type: Normal\n"+ 59 | "- InvolvedObject:\n"+ 60 | " Kind: Pod\n"+ 61 | " Name: a-pod\n"+ 62 | " apiVersion: v1\n"+ 63 | " Message: The event message\n"+ 64 | " Namespace: ns-1\n"+ 65 | " Reason: \"\"\n"+ 66 | " Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+ 67 | " Type: Normal\n" { 68 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 69 | } 70 | }) 71 | toolResult, err = c.callTool("events_list", map[string]interface{}{ 72 | "namespace": "ns-1", 73 | }) 74 | t.Run("events_list in namespace with events returns from namespace OK", func(t *testing.T) { 75 | if err != nil { 76 | t.Fatalf("call tool failed %v", err) 77 | } 78 | if toolResult.IsError { 79 | t.Fatalf("call tool failed") 80 | } 81 | if toolResult.Content[0].(mcp.TextContent).Text != "The following events (YAML format) were found:\n"+ 82 | "- InvolvedObject:\n"+ 83 | " Kind: Pod\n"+ 84 | " Name: a-pod\n"+ 85 | " apiVersion: v1\n"+ 86 | " Message: The event message\n"+ 87 | " Namespace: ns-1\n"+ 88 | " Reason: \"\"\n"+ 89 | " Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+ 90 | " Type: Normal\n" { 91 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 92 | } 93 | }) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/mcp/helm.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mark3labs/mcp-go/mcp" 7 | "github.com/mark3labs/mcp-go/server" 8 | ) 9 | 10 | func (s *Server) initHelm() []server.ServerTool { 11 | return []server.ServerTool{ 12 | {mcp.NewTool("helm_install", 13 | mcp.WithDescription("Install a Helm chart in the current or provided namespace"), 14 | mcp.WithString("chart", mcp.Description("Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)"), mcp.Required()), 15 | mcp.WithObject("values", mcp.Description("Values to pass to the Helm chart (Optional)")), 16 | mcp.WithString("name", mcp.Description("Name of the Helm release (Optional, random name if not provided)")), 17 | mcp.WithString("namespace", mcp.Description("Namespace to install the Helm chart in (Optional, current namespace if not provided)")), 18 | // Tool annotations 19 | mcp.WithTitleAnnotation("Helm: Install"), 20 | mcp.WithReadOnlyHintAnnotation(false), 21 | mcp.WithDestructiveHintAnnotation(false), 22 | mcp.WithIdempotentHintAnnotation(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install 23 | mcp.WithOpenWorldHintAnnotation(true), 24 | ), s.helmInstall}, 25 | {mcp.NewTool("helm_list", 26 | mcp.WithDescription("List all the Helm releases in the current or provided namespace (or in all namespaces if specified)"), 27 | mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from (Optional, all namespaces if not provided)")), 28 | mcp.WithBoolean("all_namespaces", mcp.Description("If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)")), 29 | // Tool annotations 30 | mcp.WithTitleAnnotation("Helm: List"), 31 | mcp.WithReadOnlyHintAnnotation(true), 32 | mcp.WithDestructiveHintAnnotation(false), 33 | mcp.WithOpenWorldHintAnnotation(true), 34 | ), s.helmList}, 35 | {mcp.NewTool("helm_uninstall", 36 | mcp.WithDescription("Uninstall a Helm release in the current or provided namespace"), 37 | mcp.WithString("name", mcp.Description("Name of the Helm release to uninstall"), mcp.Required()), 38 | mcp.WithString("namespace", mcp.Description("Namespace to uninstall the Helm release from (Optional, current namespace if not provided)")), 39 | // Tool annotations 40 | mcp.WithTitleAnnotation("Helm: Uninstall"), 41 | mcp.WithReadOnlyHintAnnotation(false), 42 | mcp.WithDestructiveHintAnnotation(true), 43 | mcp.WithIdempotentHintAnnotation(true), 44 | mcp.WithOpenWorldHintAnnotation(true), 45 | ), s.helmUninstall}, 46 | } 47 | } 48 | 49 | func (s *Server) helmInstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 50 | var chart string 51 | ok := false 52 | if chart, ok = ctr.GetArguments()["chart"].(string); !ok { 53 | return NewTextResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil 54 | } 55 | values := map[string]interface{}{} 56 | if v, ok := ctr.GetArguments()["values"].(map[string]interface{}); ok { 57 | values = v 58 | } 59 | name := "" 60 | if v, ok := ctr.GetArguments()["name"].(string); ok { 61 | name = v 62 | } 63 | namespace := "" 64 | if v, ok := ctr.GetArguments()["namespace"].(string); ok { 65 | namespace = v 66 | } 67 | ret, err := s.k.Derived(ctx).Helm.Install(ctx, chart, values, name, namespace) 68 | if err != nil { 69 | return NewTextResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil 70 | } 71 | return NewTextResult(ret, err), nil 72 | } 73 | 74 | func (s *Server) helmList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 75 | allNamespaces := false 76 | if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok { 77 | allNamespaces = v 78 | } 79 | namespace := "" 80 | if v, ok := ctr.GetArguments()["namespace"].(string); ok { 81 | namespace = v 82 | } 83 | ret, err := s.k.Derived(ctx).Helm.List(namespace, allNamespaces) 84 | if err != nil { 85 | return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil 86 | } 87 | return NewTextResult(ret, err), nil 88 | } 89 | 90 | func (s *Server) helmUninstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 91 | var name string 92 | ok := false 93 | if name, ok = ctr.GetArguments()["name"].(string); !ok { 94 | return NewTextResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil 95 | } 96 | namespace := "" 97 | if v, ok := ctr.GetArguments()["namespace"].(string); ok { 98 | namespace = v 99 | } 100 | ret, err := s.k.Derived(ctx).Helm.Uninstall(name, namespace) 101 | if err != nil { 102 | return NewTextResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil 103 | } 104 | return NewTextResult(ret, err), nil 105 | } 106 | -------------------------------------------------------------------------------- /pkg/mcp/helm_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "github.com/mark3labs/mcp-go/mcp" 7 | corev1 "k8s.io/api/core/v1" 8 | "k8s.io/apimachinery/pkg/api/errors" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/client-go/kubernetes" 11 | "path/filepath" 12 | "runtime" 13 | "sigs.k8s.io/yaml" 14 | "strings" 15 | "testing" 16 | ) 17 | 18 | func TestHelmInstall(t *testing.T) { 19 | testCase(t, func(c *mcpContext) { 20 | c.withEnvTest() 21 | _, file, _, _ := runtime.Caller(0) 22 | chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-no-op") 23 | toolResult, err := c.callTool("helm_install", map[string]interface{}{ 24 | "chart": chartPath, 25 | }) 26 | t.Run("helm_install with local chart and no release name, returns installed chart", func(t *testing.T) { 27 | if err != nil { 28 | t.Fatalf("call tool failed %v", err) 29 | } 30 | if toolResult.IsError { 31 | t.Fatalf("call tool failed") 32 | } 33 | var decoded []map[string]interface{} 34 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) 35 | if err != nil { 36 | t.Fatalf("invalid tool result content %v", err) 37 | } 38 | if !strings.HasPrefix(decoded[0]["name"].(string), "helm-chart-no-op-") { 39 | t.Fatalf("invalid helm install name, expected no-op-*, got %v", decoded[0]["name"]) 40 | } 41 | if decoded[0]["namespace"] != "default" { 42 | t.Fatalf("invalid helm install namespace, expected default, got %v", decoded[0]["namespace"]) 43 | } 44 | if decoded[0]["chart"] != "no-op" { 45 | t.Fatalf("invalid helm install name, expected release name, got empty") 46 | } 47 | if decoded[0]["chartVersion"] != "1.33.7" { 48 | t.Fatalf("invalid helm install version, expected 1.33.7, got empty") 49 | } 50 | if decoded[0]["status"] != "deployed" { 51 | t.Fatalf("invalid helm install status, expected deployed, got %v", decoded[0]["status"]) 52 | } 53 | if decoded[0]["revision"] != float64(1) { 54 | t.Fatalf("invalid helm install revision, expected 1, got %v", decoded[0]["revision"]) 55 | } 56 | }) 57 | }) 58 | } 59 | 60 | func TestHelmList(t *testing.T) { 61 | testCase(t, func(c *mcpContext) { 62 | c.withEnvTest() 63 | kc := c.newKubernetesClient() 64 | clearHelmReleases(c.ctx, kc) 65 | toolResult, err := c.callTool("helm_list", map[string]interface{}{}) 66 | t.Run("helm_list with no releases, returns not found", func(t *testing.T) { 67 | if err != nil { 68 | t.Fatalf("call tool failed %v", err) 69 | } 70 | if toolResult.IsError { 71 | t.Fatalf("call tool failed") 72 | } 73 | if toolResult.Content[0].(mcp.TextContent).Text != "No Helm releases found" { 74 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 75 | } 76 | }) 77 | _, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{ 78 | ObjectMeta: metav1.ObjectMeta{ 79 | Name: "sh.helm.release.v1.release-to-list", 80 | Labels: map[string]string{"owner": "helm", "name": "release-to-list"}, 81 | }, 82 | Data: map[string][]byte{ 83 | "release": []byte(base64.StdEncoding.EncodeToString([]byte("{" + 84 | "\"name\":\"release-to-list\"," + 85 | "\"info\":{\"status\":\"deployed\"}" + 86 | "}"))), 87 | }, 88 | }, metav1.CreateOptions{}) 89 | toolResult, err = c.callTool("helm_list", map[string]interface{}{}) 90 | t.Run("helm_list with deployed release, returns release", func(t *testing.T) { 91 | if err != nil { 92 | t.Fatalf("call tool failed %v", err) 93 | } 94 | if toolResult.IsError { 95 | t.Fatalf("call tool failed") 96 | } 97 | var decoded []map[string]interface{} 98 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) 99 | if err != nil { 100 | t.Fatalf("invalid tool result content %v", err) 101 | } 102 | if len(decoded) != 1 { 103 | t.Fatalf("invalid helm list count, expected 1, got %v", len(decoded)) 104 | } 105 | if decoded[0]["name"] != "release-to-list" { 106 | t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"]) 107 | } 108 | if decoded[0]["status"] != "deployed" { 109 | t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"]) 110 | } 111 | }) 112 | toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1"}) 113 | t.Run("helm_list with deployed release in other namespaces, returns not found", func(t *testing.T) { 114 | if err != nil { 115 | t.Fatalf("call tool failed %v", err) 116 | } 117 | if toolResult.IsError { 118 | t.Fatalf("call tool failed") 119 | } 120 | if toolResult.Content[0].(mcp.TextContent).Text != "No Helm releases found" { 121 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 122 | } 123 | }) 124 | toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1", "all_namespaces": true}) 125 | t.Run("helm_list with deployed release in all namespaces, returns release", func(t *testing.T) { 126 | if err != nil { 127 | t.Fatalf("call tool failed %v", err) 128 | } 129 | if toolResult.IsError { 130 | t.Fatalf("call tool failed") 131 | } 132 | var decoded []map[string]interface{} 133 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) 134 | if err != nil { 135 | t.Fatalf("invalid tool result content %v", err) 136 | } 137 | if len(decoded) != 1 { 138 | t.Fatalf("invalid helm list count, expected 1, got %v", len(decoded)) 139 | } 140 | if decoded[0]["name"] != "release-to-list" { 141 | t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"]) 142 | } 143 | if decoded[0]["status"] != "deployed" { 144 | t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"]) 145 | } 146 | }) 147 | }) 148 | } 149 | 150 | func TestHelmUninstall(t *testing.T) { 151 | testCase(t, func(c *mcpContext) { 152 | c.withEnvTest() 153 | kc := c.newKubernetesClient() 154 | clearHelmReleases(c.ctx, kc) 155 | toolResult, err := c.callTool("helm_uninstall", map[string]interface{}{ 156 | "name": "release-to-uninstall", 157 | }) 158 | t.Run("helm_uninstall with no releases, returns not found", func(t *testing.T) { 159 | if err != nil { 160 | t.Fatalf("call tool failed %v", err) 161 | } 162 | if toolResult.IsError { 163 | t.Fatalf("call tool failed") 164 | } 165 | if toolResult.Content[0].(mcp.TextContent).Text != "Release release-to-uninstall not found" { 166 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 167 | } 168 | }) 169 | _, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{ 170 | ObjectMeta: metav1.ObjectMeta{ 171 | Name: "sh.helm.release.v1.existent-release-to-uninstall.v0", 172 | Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"}, 173 | }, 174 | Data: map[string][]byte{ 175 | "release": []byte(base64.StdEncoding.EncodeToString([]byte("{" + 176 | "\"name\":\"existent-release-to-uninstall\"," + 177 | "\"info\":{\"status\":\"deployed\"}" + 178 | "}"))), 179 | }, 180 | }, metav1.CreateOptions{}) 181 | toolResult, err = c.callTool("helm_uninstall", map[string]interface{}{ 182 | "name": "existent-release-to-uninstall", 183 | }) 184 | t.Run("helm_uninstall with deployed release, returns uninstalled", func(t *testing.T) { 185 | if err != nil { 186 | t.Fatalf("call tool failed %v", err) 187 | } 188 | if toolResult.IsError { 189 | t.Fatalf("call tool failed") 190 | } 191 | if !strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "Uninstalled release existent-release-to-uninstall") { 192 | t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text) 193 | } 194 | _, err = kc.CoreV1().Secrets("default").Get(c.ctx, "sh.helm.release.v1.existent-release-to-uninstall.v0", metav1.GetOptions{}) 195 | if !errors.IsNotFound(err) { 196 | t.Fatalf("expected release to be deleted, but it still exists") 197 | } 198 | }) 199 | }) 200 | } 201 | 202 | func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) { 203 | secrets, _ := kc.CoreV1().Secrets("default").List(ctx, metav1.ListOptions{}) 204 | for _, secret := range secrets.Items { 205 | if strings.HasPrefix(secret.Name, "sh.helm.release.v1.") { 206 | _ = kc.CoreV1().Secrets("default").Delete(ctx, secret.Name, metav1.DeleteOptions{}) 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /pkg/mcp/mcp.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "github.com/manusa/kubernetes-mcp-server/pkg/kubernetes" 6 | "github.com/manusa/kubernetes-mcp-server/pkg/version" 7 | "github.com/mark3labs/mcp-go/mcp" 8 | "github.com/mark3labs/mcp-go/server" 9 | "net/http" 10 | ) 11 | 12 | type Configuration struct { 13 | Profile Profile 14 | // When true, expose only tools annotated with readOnlyHint=true 15 | ReadOnly bool 16 | // When true, disable tools annotated with destructiveHint=true 17 | DisableDestructive bool 18 | Kubeconfig string 19 | } 20 | 21 | type Server struct { 22 | configuration *Configuration 23 | server *server.MCPServer 24 | k *kubernetes.Kubernetes 25 | } 26 | 27 | func NewSever(configuration Configuration) (*Server, error) { 28 | s := &Server{ 29 | configuration: &configuration, 30 | server: server.NewMCPServer( 31 | version.BinaryName, 32 | version.Version, 33 | server.WithResourceCapabilities(true, true), 34 | server.WithPromptCapabilities(true), 35 | server.WithToolCapabilities(true), 36 | server.WithLogging(), 37 | ), 38 | } 39 | if err := s.reloadKubernetesClient(); err != nil { 40 | return nil, err 41 | } 42 | s.k.WatchKubeConfig(s.reloadKubernetesClient) 43 | return s, nil 44 | } 45 | 46 | func isFalse(value *bool) bool { 47 | return value == nil || !*value 48 | } 49 | 50 | func (s *Server) reloadKubernetesClient() error { 51 | k, err := kubernetes.NewKubernetes(s.configuration.Kubeconfig) 52 | if err != nil { 53 | return err 54 | } 55 | s.k = k 56 | applicableTools := make([]server.ServerTool, 0) 57 | for _, tool := range s.configuration.Profile.GetTools(s) { 58 | if s.configuration.ReadOnly && isFalse(tool.Tool.Annotations.ReadOnlyHint) { 59 | continue 60 | } 61 | if s.configuration.DisableDestructive && isFalse(tool.Tool.Annotations.ReadOnlyHint) && !isFalse(tool.Tool.Annotations.DestructiveHint) { 62 | continue 63 | } 64 | applicableTools = append(applicableTools, tool) 65 | } 66 | s.server.SetTools(applicableTools...) 67 | return nil 68 | } 69 | 70 | func (s *Server) ServeStdio() error { 71 | return server.ServeStdio(s.server) 72 | } 73 | 74 | func (s *Server) ServeSse(baseUrl string) *server.SSEServer { 75 | options := make([]server.SSEOption, 0) 76 | options = append(options, server.WithSSEContextFunc(contextFunc)) 77 | if baseUrl != "" { 78 | options = append(options, server.WithBaseURL(baseUrl)) 79 | } 80 | return server.NewSSEServer(s.server, options...) 81 | } 82 | 83 | func (s *Server) Close() { 84 | if s.k != nil { 85 | s.k.Close() 86 | } 87 | } 88 | 89 | func NewTextResult(content string, err error) *mcp.CallToolResult { 90 | if err != nil { 91 | return &mcp.CallToolResult{ 92 | IsError: true, 93 | Content: []mcp.Content{ 94 | mcp.TextContent{ 95 | Type: "text", 96 | Text: err.Error(), 97 | }, 98 | }, 99 | } 100 | } 101 | return &mcp.CallToolResult{ 102 | Content: []mcp.Content{ 103 | mcp.TextContent{ 104 | Type: "text", 105 | Text: content, 106 | }, 107 | }, 108 | } 109 | } 110 | 111 | func contextFunc(ctx context.Context, r *http.Request) context.Context { 112 | return context.WithValue(ctx, kubernetes.AuthorizationHeader, r.Header.Get(kubernetes.AuthorizationHeader)) 113 | } 114 | -------------------------------------------------------------------------------- /pkg/mcp/mcp_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "github.com/mark3labs/mcp-go/client" 6 | "github.com/mark3labs/mcp-go/mcp" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestWatchKubeConfig(t *testing.T) { 16 | if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { 17 | t.Skip("Skipping test on non-linux platforms") 18 | } 19 | testCase(t, func(c *mcpContext) { 20 | // Given 21 | withTimeout, cancel := context.WithTimeout(c.ctx, 5*time.Second) 22 | defer cancel() 23 | var notification *mcp.JSONRPCNotification 24 | c.mcpClient.OnNotification(func(n mcp.JSONRPCNotification) { 25 | notification = &n 26 | }) 27 | // When 28 | f, _ := os.OpenFile(filepath.Join(c.tempDir, "config"), os.O_APPEND|os.O_WRONLY, 0644) 29 | _, _ = f.WriteString("\n") 30 | for { 31 | if notification != nil { 32 | break 33 | } 34 | select { 35 | case <-withTimeout.Done(): 36 | break 37 | default: 38 | time.Sleep(100 * time.Millisecond) 39 | } 40 | } 41 | // Then 42 | t.Run("WatchKubeConfig notifies tools change", func(t *testing.T) { 43 | if notification == nil { 44 | t.Fatalf("WatchKubeConfig did not notify") 45 | } 46 | if notification.Method != "notifications/tools/list_changed" { 47 | t.Fatalf("WatchKubeConfig did not notify tools change, got %s", notification.Method) 48 | } 49 | }) 50 | }) 51 | } 52 | 53 | func TestReadOnly(t *testing.T) { 54 | readOnlyServer := func(c *mcpContext) { c.readOnly = true } 55 | testCaseWithContext(t, &mcpContext{before: readOnlyServer}, func(c *mcpContext) { 56 | tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) 57 | t.Run("ListTools returns tools", func(t *testing.T) { 58 | if err != nil { 59 | t.Fatalf("call ListTools failed %v", err) 60 | } 61 | }) 62 | t.Run("ListTools returns only read-only tools", func(t *testing.T) { 63 | for _, tool := range tools.Tools { 64 | if tool.Annotations.ReadOnlyHint == nil || !*tool.Annotations.ReadOnlyHint { 65 | t.Errorf("Tool %s is not read-only but should be", tool.Name) 66 | } 67 | if tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint { 68 | t.Errorf("Tool %s is destructive but should not be in read-only mode", tool.Name) 69 | } 70 | } 71 | }) 72 | }) 73 | } 74 | 75 | func TestDisableDestructive(t *testing.T) { 76 | disableDestructiveServer := func(c *mcpContext) { c.disableDestructive = true } 77 | testCaseWithContext(t, &mcpContext{before: disableDestructiveServer}, func(c *mcpContext) { 78 | tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) 79 | t.Run("ListTools returns tools", func(t *testing.T) { 80 | if err != nil { 81 | t.Fatalf("call ListTools failed %v", err) 82 | } 83 | }) 84 | t.Run("ListTools does not return destructive tools", func(t *testing.T) { 85 | for _, tool := range tools.Tools { 86 | if tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint { 87 | t.Errorf("Tool %s is destructive but should not be", tool.Name) 88 | } 89 | } 90 | }) 91 | }) 92 | } 93 | 94 | func TestSseHeaders(t *testing.T) { 95 | mockServer := NewMockServer() 96 | defer mockServer.Close() 97 | before := func(c *mcpContext) { 98 | c.withKubeConfig(mockServer.config) 99 | c.clientOptions = append(c.clientOptions, client.WithHeaders(map[string]string{"kubernetes-authorization": "Bearer a-token-from-mcp-client"})) 100 | } 101 | pathHeaders := make(map[string]http.Header, 0) 102 | mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 103 | pathHeaders[req.URL.Path] = req.Header.Clone() 104 | // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-) 105 | if req.URL.Path == "/api" { 106 | w.Header().Set("Content-Type", "application/json") 107 | _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`)) 108 | return 109 | } 110 | // Request Performed by DiscoveryClient to Kube API (Get API Groups) 111 | if req.URL.Path == "/apis" { 112 | w.Header().Set("Content-Type", "application/json") 113 | //w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[{"name":"apps","versions":[{"groupVersion":"apps/v1","version":"v1"}],"preferredVersion":{"groupVersion":"apps/v1","version":"v1"}}]}`)) 114 | _, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`)) 115 | return 116 | } 117 | // Request Performed by DiscoveryClient to Kube API (Get API Resources) 118 | if req.URL.Path == "/api/v1" { 119 | w.Header().Set("Content-Type", "application/json") 120 | _, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`)) 121 | return 122 | } 123 | // Request Performed by DynamicClient 124 | if req.URL.Path == "/api/v1/namespaces/default/pods" { 125 | w.Header().Set("Content-Type", "application/json") 126 | _, _ = w.Write([]byte(`{"kind":"PodList","apiVersion":"v1","items":[]}`)) 127 | return 128 | } 129 | // Request Performed by kubernetes.Interface 130 | if req.URL.Path == "/api/v1/namespaces/default/pods/a-pod-to-delete" { 131 | w.WriteHeader(200) 132 | return 133 | } 134 | w.WriteHeader(404) 135 | })) 136 | testCaseWithContext(t, &mcpContext{before: before}, func(c *mcpContext) { 137 | c.callTool("pods_list", map[string]interface{}{}) 138 | t.Run("DiscoveryClient propagates headers to Kube API", func(t *testing.T) { 139 | if len(pathHeaders) == 0 { 140 | t.Fatalf("No requests were made to Kube API") 141 | } 142 | if pathHeaders["/api"] == nil || pathHeaders["/api"].Get("Authorization") != "Bearer a-token-from-mcp-client" { 143 | t.Fatalf("Overridden header Authorization not found in request to /api") 144 | } 145 | if pathHeaders["/apis"] == nil || pathHeaders["/apis"].Get("Authorization") != "Bearer a-token-from-mcp-client" { 146 | t.Fatalf("Overridden header Authorization not found in request to /apis") 147 | } 148 | if pathHeaders["/api/v1"] == nil || pathHeaders["/api/v1"].Get("Authorization") != "Bearer a-token-from-mcp-client" { 149 | t.Fatalf("Overridden header Authorization not found in request to /api/v1") 150 | } 151 | }) 152 | t.Run("DynamicClient propagates headers to Kube API", func(t *testing.T) { 153 | if len(pathHeaders) == 0 { 154 | t.Fatalf("No requests were made to Kube API") 155 | } 156 | if pathHeaders["/api/v1/namespaces/default/pods"] == nil || pathHeaders["/api/v1/namespaces/default/pods"].Get("Authorization") != "Bearer a-token-from-mcp-client" { 157 | t.Fatalf("Overridden header Authorization not found in request to /api/v1/namespaces/default/pods") 158 | } 159 | }) 160 | c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-to-delete"}) 161 | t.Run("kubernetes.Interface propagates headers to Kube API", func(t *testing.T) { 162 | if len(pathHeaders) == 0 { 163 | t.Fatalf("No requests were made to Kube API") 164 | } 165 | if pathHeaders["/api/v1/namespaces/default/pods/a-pod-to-delete"] == nil || pathHeaders["/api/v1/namespaces/default/pods/a-pod-to-delete"].Get("Authorization") != "Bearer a-token-from-mcp-client" { 166 | t.Fatalf("Overridden header Authorization not found in request to /api/v1/namespaces/default/pods/a-pod-to-delete") 167 | } 168 | }) 169 | }) 170 | } 171 | -------------------------------------------------------------------------------- /pkg/mcp/mock_server_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | v1 "k8s.io/api/core/v1" 8 | apierrors "k8s.io/apimachinery/pkg/api/errors" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | "k8s.io/apimachinery/pkg/runtime/serializer" 11 | "k8s.io/apimachinery/pkg/util/httpstream" 12 | "k8s.io/apimachinery/pkg/util/httpstream/spdy" 13 | "k8s.io/client-go/rest" 14 | "net/http" 15 | "net/http/httptest" 16 | ) 17 | 18 | type MockServer struct { 19 | server *httptest.Server 20 | config *rest.Config 21 | restHandlers []http.HandlerFunc 22 | } 23 | 24 | func NewMockServer() *MockServer { 25 | ms := &MockServer{} 26 | scheme := runtime.NewScheme() 27 | codecs := serializer.NewCodecFactory(scheme) 28 | ms.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 29 | for _, handler := range ms.restHandlers { 30 | handler(w, req) 31 | } 32 | })) 33 | ms.config = &rest.Config{ 34 | Host: ms.server.URL, 35 | APIPath: "/api", 36 | ContentConfig: rest.ContentConfig{ 37 | NegotiatedSerializer: codecs, 38 | ContentType: runtime.ContentTypeJSON, 39 | GroupVersion: &v1.SchemeGroupVersion, 40 | }, 41 | } 42 | ms.restHandlers = make([]http.HandlerFunc, 0) 43 | return ms 44 | } 45 | 46 | func (m *MockServer) Close() { 47 | m.server.Close() 48 | } 49 | 50 | func (m *MockServer) Handle(handler http.Handler) { 51 | m.restHandlers = append(m.restHandlers, handler.ServeHTTP) 52 | } 53 | 54 | func writeObject(w http.ResponseWriter, obj runtime.Object) { 55 | w.Header().Set("Content-Type", runtime.ContentTypeJSON) 56 | if err := json.NewEncoder(w).Encode(obj); err != nil { 57 | http.Error(w, err.Error(), http.StatusInternalServerError) 58 | } 59 | w.WriteHeader(http.StatusOK) 60 | } 61 | 62 | type streamAndReply struct { 63 | httpstream.Stream 64 | replySent <-chan struct{} 65 | } 66 | 67 | type streamContext struct { 68 | conn io.Closer 69 | stdinStream io.ReadCloser 70 | stdoutStream io.WriteCloser 71 | stderrStream io.WriteCloser 72 | writeStatus func(status *apierrors.StatusError) error 73 | } 74 | 75 | type StreamOptions struct { 76 | Stdin io.Reader 77 | Stdout io.Writer 78 | Stderr io.Writer 79 | } 80 | 81 | func v4WriteStatusFunc(stream io.Writer) func(status *apierrors.StatusError) error { 82 | return func(status *apierrors.StatusError) error { 83 | bs, err := json.Marshal(status.Status()) 84 | if err != nil { 85 | return err 86 | } 87 | _, err = stream.Write(bs) 88 | return err 89 | } 90 | } 91 | func createHTTPStreams(w http.ResponseWriter, req *http.Request, opts *StreamOptions) (*streamContext, error) { 92 | _, err := httpstream.Handshake(req, w, []string{"v4.channel.k8s.io"}) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | upgrader := spdy.NewResponseUpgrader() 98 | streamCh := make(chan streamAndReply) 99 | conn := upgrader.UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error { 100 | streamCh <- streamAndReply{Stream: stream, replySent: replySent} 101 | return nil 102 | }) 103 | ctx := &streamContext{ 104 | conn: conn, 105 | } 106 | 107 | // wait for stream 108 | replyChan := make(chan struct{}, 4) 109 | defer close(replyChan) 110 | receivedStreams := 0 111 | expectedStreams := 1 112 | if opts.Stdout != nil { 113 | expectedStreams++ 114 | } 115 | if opts.Stdin != nil { 116 | expectedStreams++ 117 | } 118 | if opts.Stderr != nil { 119 | expectedStreams++ 120 | } 121 | WaitForStreams: 122 | for { 123 | select { 124 | case stream := <-streamCh: 125 | streamType := stream.Headers().Get(v1.StreamType) 126 | switch streamType { 127 | case v1.StreamTypeError: 128 | replyChan <- struct{}{} 129 | ctx.writeStatus = v4WriteStatusFunc(stream) 130 | case v1.StreamTypeStdout: 131 | replyChan <- struct{}{} 132 | ctx.stdoutStream = stream 133 | case v1.StreamTypeStdin: 134 | replyChan <- struct{}{} 135 | ctx.stdinStream = stream 136 | case v1.StreamTypeStderr: 137 | replyChan <- struct{}{} 138 | ctx.stderrStream = stream 139 | default: 140 | // add other stream ... 141 | return nil, errors.New("unimplemented stream type") 142 | } 143 | case <-replyChan: 144 | receivedStreams++ 145 | if receivedStreams == expectedStreams { 146 | break WaitForStreams 147 | } 148 | } 149 | } 150 | 151 | return ctx, nil 152 | } 153 | -------------------------------------------------------------------------------- /pkg/mcp/namespaces.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mark3labs/mcp-go/mcp" 7 | "github.com/mark3labs/mcp-go/server" 8 | ) 9 | 10 | func (s *Server) initNamespaces() []server.ServerTool { 11 | ret := make([]server.ServerTool, 0) 12 | ret = append(ret, server.ServerTool{ 13 | Tool: mcp.NewTool("namespaces_list", 14 | mcp.WithDescription("List all the Kubernetes namespaces in the current cluster"), 15 | // Tool annotations 16 | mcp.WithTitleAnnotation("Namespaces: List"), 17 | mcp.WithReadOnlyHintAnnotation(true), 18 | mcp.WithDestructiveHintAnnotation(false), 19 | mcp.WithOpenWorldHintAnnotation(true), 20 | ), Handler: s.namespacesList, 21 | }) 22 | if s.k.IsOpenShift(context.Background()) { 23 | ret = append(ret, server.ServerTool{ 24 | Tool: mcp.NewTool("projects_list", 25 | mcp.WithDescription("List all the OpenShift projects in the current cluster"), 26 | // Tool annotations 27 | mcp.WithTitleAnnotation("Projects: List"), 28 | mcp.WithReadOnlyHintAnnotation(true), 29 | mcp.WithDestructiveHintAnnotation(false), 30 | mcp.WithOpenWorldHintAnnotation(true), 31 | ), Handler: s.projectsList, 32 | }) 33 | } 34 | return ret 35 | } 36 | 37 | func (s *Server) namespacesList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { 38 | ret, err := s.k.Derived(ctx).NamespacesList(ctx) 39 | if err != nil { 40 | err = fmt.Errorf("failed to list namespaces: %v", err) 41 | } 42 | return NewTextResult(ret, err), nil 43 | } 44 | 45 | func (s *Server) projectsList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { 46 | ret, err := s.k.Derived(ctx).ProjectsList(ctx) 47 | if err != nil { 48 | err = fmt.Errorf("failed to list projects: %v", err) 49 | } 50 | return NewTextResult(ret, err), nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/mcp/namespaces_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "k8s.io/client-go/dynamic" 9 | "sigs.k8s.io/yaml" 10 | "slices" 11 | "testing" 12 | ) 13 | 14 | func TestNamespacesList(t *testing.T) { 15 | testCase(t, func(c *mcpContext) { 16 | c.withEnvTest() 17 | toolResult, err := c.callTool("namespaces_list", map[string]interface{}{}) 18 | t.Run("namespaces_list returns namespace list", func(t *testing.T) { 19 | if err != nil { 20 | t.Fatalf("call tool failed %v", err) 21 | } 22 | if toolResult.IsError { 23 | t.Fatalf("call tool failed") 24 | } 25 | }) 26 | var decoded []unstructured.Unstructured 27 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) 28 | t.Run("namespaces_list has yaml content", func(t *testing.T) { 29 | if err != nil { 30 | t.Fatalf("invalid tool result content %v", err) 31 | } 32 | }) 33 | t.Run("namespaces_list returns at least 3 items", func(t *testing.T) { 34 | if len(decoded) < 3 { 35 | t.Errorf("invalid namespace count, expected at least 3, got %v", len(decoded)) 36 | } 37 | for _, expectedNamespace := range []string{"default", "ns-1", "ns-2"} { 38 | idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool { 39 | return ns.GetName() == expectedNamespace 40 | }) 41 | if idx == -1 { 42 | t.Errorf("namespace %s not found in the list", expectedNamespace) 43 | } 44 | } 45 | }) 46 | }) 47 | } 48 | 49 | func TestProjectsListInOpenShift(t *testing.T) { 50 | testCaseWithContext(t, &mcpContext{before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) { 51 | dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) 52 | _, _ = dynamicClient.Resource(schema.GroupVersionResource{Group: "project.openshift.io", Version: "v1", Resource: "projects"}). 53 | Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{ 54 | "apiVersion": "project.openshift.io/v1", 55 | "kind": "Project", 56 | "metadata": map[string]interface{}{ 57 | "name": "an-openshift-project", 58 | }, 59 | }}, metav1.CreateOptions{}) 60 | toolResult, err := c.callTool("projects_list", map[string]interface{}{}) 61 | t.Run("projects_list returns project list", func(t *testing.T) { 62 | if err != nil { 63 | t.Fatalf("call tool failed %v", err) 64 | } 65 | if toolResult.IsError { 66 | t.Fatalf("call tool failed") 67 | } 68 | }) 69 | var decoded []unstructured.Unstructured 70 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) 71 | t.Run("projects_list has yaml content", func(t *testing.T) { 72 | if err != nil { 73 | t.Fatalf("invalid tool result content %v", err) 74 | } 75 | }) 76 | t.Run("projects_list returns at least 1 items", func(t *testing.T) { 77 | if len(decoded) < 1 { 78 | t.Errorf("invalid project count, expected at least 1, got %v", len(decoded)) 79 | } 80 | idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool { 81 | return ns.GetName() == "an-openshift-project" 82 | }) 83 | if idx == -1 { 84 | t.Errorf("namespace %s not found in the list", "an-openshift-project") 85 | } 86 | }) 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/mcp/pods.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | ) 11 | 12 | func (s *Server) initPods() []server.ServerTool { 13 | return []server.ServerTool{ 14 | {Tool: mcp.NewTool("pods_list", 15 | mcp.WithDescription("List all the Kubernetes pods in the current cluster from all namespaces"), 16 | mcp.WithString("labelSelector", mcp.Description("Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")), 17 | // Tool annotations 18 | mcp.WithTitleAnnotation("Pods: List"), 19 | mcp.WithReadOnlyHintAnnotation(true), 20 | mcp.WithDestructiveHintAnnotation(false), 21 | mcp.WithOpenWorldHintAnnotation(true), 22 | ), Handler: s.podsListInAllNamespaces}, 23 | {Tool: mcp.NewTool("pods_list_in_namespace", 24 | mcp.WithDescription("List all the Kubernetes pods in the specified namespace in the current cluster"), 25 | mcp.WithString("namespace", mcp.Description("Namespace to list pods from"), mcp.Required()), 26 | mcp.WithString("labelSelector", mcp.Description("Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")), 27 | // Tool annotations 28 | mcp.WithTitleAnnotation("Pods: List in Namespace"), 29 | mcp.WithReadOnlyHintAnnotation(true), 30 | mcp.WithDestructiveHintAnnotation(false), 31 | mcp.WithOpenWorldHintAnnotation(true), 32 | ), Handler: s.podsListInNamespace}, 33 | {Tool: mcp.NewTool("pods_get", 34 | mcp.WithDescription("Get a Kubernetes Pod in the current or provided namespace with the provided name"), 35 | mcp.WithString("namespace", mcp.Description("Namespace to get the Pod from")), 36 | mcp.WithString("name", mcp.Description("Name of the Pod"), mcp.Required()), 37 | // Tool annotations 38 | mcp.WithTitleAnnotation("Pods: Get"), 39 | mcp.WithReadOnlyHintAnnotation(true), 40 | mcp.WithDestructiveHintAnnotation(false), 41 | mcp.WithOpenWorldHintAnnotation(true), 42 | ), Handler: s.podsGet}, 43 | {Tool: mcp.NewTool("pods_delete", 44 | mcp.WithDescription("Delete a Kubernetes Pod in the current or provided namespace with the provided name"), 45 | mcp.WithString("namespace", mcp.Description("Namespace to delete the Pod from")), 46 | mcp.WithString("name", mcp.Description("Name of the Pod to delete"), mcp.Required()), 47 | // Tool annotations 48 | mcp.WithTitleAnnotation("Pods: Delete"), 49 | mcp.WithReadOnlyHintAnnotation(false), 50 | mcp.WithDestructiveHintAnnotation(true), 51 | mcp.WithIdempotentHintAnnotation(true), 52 | mcp.WithOpenWorldHintAnnotation(true), 53 | ), Handler: s.podsDelete}, 54 | {Tool: mcp.NewTool("pods_exec", 55 | mcp.WithDescription("Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command"), 56 | mcp.WithString("namespace", mcp.Description("Namespace of the Pod where the command will be executed")), 57 | mcp.WithString("name", mcp.Description("Name of the Pod where the command will be executed"), mcp.Required()), 58 | mcp.WithArray("command", mcp.Description("Command to execute in the Pod container. "+ 59 | "The first item is the command to be run, and the rest are the arguments to that command. "+ 60 | `Example: ["ls", "-l", "/tmp"]`), 61 | // TODO: manual fix to ensure that the items property gets initialized (Gemini) 62 | // https://www.googlecloudcommunity.com/gc/AI-ML/Gemini-API-400-Bad-Request-Array-fields-breaks-function-calling/m-p/769835?nobounce 63 | func(schema map[string]interface{}) { 64 | schema["type"] = "array" 65 | schema["items"] = map[string]interface{}{ 66 | "type": "string", 67 | } 68 | }, 69 | mcp.Required(), 70 | ), 71 | mcp.WithString("container", mcp.Description("Name of the Pod container where the command will be executed (Optional)")), 72 | // Tool annotations 73 | mcp.WithTitleAnnotation("Pods: Exec"), 74 | mcp.WithReadOnlyHintAnnotation(false), 75 | mcp.WithDestructiveHintAnnotation(true), // Depending on the Pod's entrypoint, executing certain commands may kill the Pod 76 | mcp.WithIdempotentHintAnnotation(false), 77 | mcp.WithOpenWorldHintAnnotation(true), 78 | ), Handler: s.podsExec}, 79 | {Tool: mcp.NewTool("pods_log", 80 | mcp.WithDescription("Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name"), 81 | mcp.WithString("namespace", mcp.Description("Namespace to get the Pod logs from")), 82 | mcp.WithString("name", mcp.Description("Name of the Pod to get the logs from"), mcp.Required()), 83 | mcp.WithString("container", mcp.Description("Name of the Pod container to get the logs from (Optional)")), 84 | // Tool annotations 85 | mcp.WithTitleAnnotation("Pods: Log"), 86 | mcp.WithReadOnlyHintAnnotation(true), 87 | mcp.WithDestructiveHintAnnotation(false), 88 | mcp.WithOpenWorldHintAnnotation(true), 89 | ), Handler: s.podsLog}, 90 | {Tool: mcp.NewTool("pods_run", 91 | mcp.WithDescription("Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name"), 92 | mcp.WithString("namespace", mcp.Description("Namespace to run the Pod in")), 93 | mcp.WithString("name", mcp.Description("Name of the Pod (Optional, random name if not provided)")), 94 | mcp.WithString("image", mcp.Description("Container Image to run in the Pod"), mcp.Required()), 95 | mcp.WithNumber("port", mcp.Description("TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)")), 96 | // Tool annotations 97 | mcp.WithTitleAnnotation("Pods: Run"), 98 | mcp.WithReadOnlyHintAnnotation(false), 99 | mcp.WithDestructiveHintAnnotation(false), 100 | mcp.WithIdempotentHintAnnotation(false), 101 | mcp.WithOpenWorldHintAnnotation(true), 102 | ), Handler: s.podsRun}, 103 | } 104 | } 105 | 106 | func (s *Server) podsListInAllNamespaces(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 107 | labelSelector := ctr.GetArguments()["labelSelector"] 108 | var selector string 109 | if labelSelector != nil { 110 | selector = labelSelector.(string) 111 | } 112 | 113 | ret, err := s.k.Derived(ctx).PodsListInAllNamespaces(ctx, selector) 114 | if err != nil { 115 | return NewTextResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil 116 | } 117 | return NewTextResult(ret, err), nil 118 | } 119 | 120 | func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 121 | ns := ctr.GetArguments()["namespace"] 122 | if ns == nil { 123 | return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil 124 | } 125 | labelSelector := ctr.GetArguments()["labelSelector"] 126 | var selector string 127 | if labelSelector != nil { 128 | selector = labelSelector.(string) 129 | } 130 | ret, err := s.k.Derived(ctx).PodsListInNamespace(ctx, ns.(string), selector) 131 | if err != nil { 132 | return NewTextResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil 133 | } 134 | return NewTextResult(ret, err), nil 135 | } 136 | 137 | func (s *Server) podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 138 | ns := ctr.GetArguments()["namespace"] 139 | if ns == nil { 140 | ns = "" 141 | } 142 | name := ctr.GetArguments()["name"] 143 | if name == nil { 144 | return NewTextResult("", errors.New("failed to get pod, missing argument name")), nil 145 | } 146 | ret, err := s.k.Derived(ctx).PodsGet(ctx, ns.(string), name.(string)) 147 | if err != nil { 148 | return NewTextResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil 149 | } 150 | return NewTextResult(ret, err), nil 151 | } 152 | 153 | func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 154 | ns := ctr.GetArguments()["namespace"] 155 | if ns == nil { 156 | ns = "" 157 | } 158 | name := ctr.GetArguments()["name"] 159 | if name == nil { 160 | return NewTextResult("", errors.New("failed to delete pod, missing argument name")), nil 161 | } 162 | ret, err := s.k.Derived(ctx).PodsDelete(ctx, ns.(string), name.(string)) 163 | if err != nil { 164 | return NewTextResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %v", name, ns, err)), nil 165 | } 166 | return NewTextResult(ret, err), nil 167 | } 168 | 169 | func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 170 | ns := ctr.GetArguments()["namespace"] 171 | if ns == nil { 172 | ns = "" 173 | } 174 | name := ctr.GetArguments()["name"] 175 | if name == nil { 176 | return NewTextResult("", errors.New("failed to exec in pod, missing argument name")), nil 177 | } 178 | container := ctr.GetArguments()["container"] 179 | if container == nil { 180 | container = "" 181 | } 182 | commandArg := ctr.GetArguments()["command"] 183 | command := make([]string, 0) 184 | if _, ok := commandArg.([]interface{}); ok { 185 | for _, cmd := range commandArg.([]interface{}) { 186 | if _, ok := cmd.(string); ok { 187 | command = append(command, cmd.(string)) 188 | } 189 | } 190 | } else { 191 | return NewTextResult("", errors.New("failed to exec in pod, invalid command argument")), nil 192 | } 193 | ret, err := s.k.Derived(ctx).PodsExec(ctx, ns.(string), name.(string), container.(string), command) 194 | if err != nil { 195 | return NewTextResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil 196 | } else if ret == "" { 197 | ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns) 198 | } 199 | return NewTextResult(ret, err), nil 200 | } 201 | 202 | func (s *Server) podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 203 | ns := ctr.GetArguments()["namespace"] 204 | if ns == nil { 205 | ns = "" 206 | } 207 | name := ctr.GetArguments()["name"] 208 | if name == nil { 209 | return NewTextResult("", errors.New("failed to get pod log, missing argument name")), nil 210 | } 211 | container := ctr.GetArguments()["container"] 212 | if container == nil { 213 | container = "" 214 | } 215 | ret, err := s.k.Derived(ctx).PodsLog(ctx, ns.(string), name.(string), container.(string)) 216 | if err != nil { 217 | return NewTextResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil 218 | } else if ret == "" { 219 | ret = fmt.Sprintf("The pod %s in namespace %s has not logged any message yet", name, ns) 220 | } 221 | return NewTextResult(ret, err), nil 222 | } 223 | 224 | func (s *Server) podsRun(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 225 | ns := ctr.GetArguments()["namespace"] 226 | if ns == nil { 227 | ns = "" 228 | } 229 | name := ctr.GetArguments()["name"] 230 | if name == nil { 231 | name = "" 232 | } 233 | image := ctr.GetArguments()["image"] 234 | if image == nil { 235 | return NewTextResult("", errors.New("failed to run pod, missing argument image")), nil 236 | } 237 | port := ctr.GetArguments()["port"] 238 | if port == nil { 239 | port = float64(0) 240 | } 241 | ret, err := s.k.Derived(ctx).PodsRun(ctx, ns.(string), name.(string), image.(string), int32(port.(float64))) 242 | if err != nil { 243 | return NewTextResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil 244 | } 245 | return NewTextResult(ret, err), nil 246 | } 247 | -------------------------------------------------------------------------------- /pkg/mcp/pods_exec_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "bytes" 5 | "github.com/mark3labs/mcp-go/mcp" 6 | "io" 7 | v1 "k8s.io/api/core/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "net/http" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | func TestPodsExec(t *testing.T) { 15 | testCase(t, func(c *mcpContext) { 16 | mockServer := NewMockServer() 17 | defer mockServer.Close() 18 | c.withKubeConfig(mockServer.config) 19 | mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 20 | if req.URL.Path != "/api/v1/namespaces/default/pods/pod-to-exec/exec" { 21 | return 22 | } 23 | var stdin, stdout bytes.Buffer 24 | ctx, err := createHTTPStreams(w, req, &StreamOptions{ 25 | Stdin: &stdin, 26 | Stdout: &stdout, 27 | }) 28 | if err != nil { 29 | w.WriteHeader(http.StatusInternalServerError) 30 | _, _ = w.Write([]byte(err.Error())) 31 | return 32 | } 33 | defer func(conn io.Closer) { _ = conn.Close() }(ctx.conn) 34 | _, _ = io.WriteString(ctx.stdoutStream, "command:"+strings.Join(req.URL.Query()["command"], " ")+"\n") 35 | _, _ = io.WriteString(ctx.stdoutStream, "container:"+strings.Join(req.URL.Query()["container"], " ")+"\n") 36 | })) 37 | mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 38 | if req.URL.Path != "/api/v1/namespaces/default/pods/pod-to-exec" { 39 | return 40 | } 41 | writeObject(w, &v1.Pod{ 42 | ObjectMeta: metav1.ObjectMeta{ 43 | Namespace: "default", 44 | Name: "pod-to-exec", 45 | }, 46 | Spec: v1.PodSpec{Containers: []v1.Container{{Name: "container-to-exec"}}}, 47 | }) 48 | })) 49 | podsExecNilNamespace, err := c.callTool("pods_exec", map[string]interface{}{ 50 | "name": "pod-to-exec", 51 | "command": []interface{}{"ls", "-l"}, 52 | }) 53 | t.Run("pods_exec with name and nil namespace returns command output", func(t *testing.T) { 54 | if err != nil { 55 | t.Fatalf("call tool failed %v", err) 56 | } 57 | if podsExecNilNamespace.IsError { 58 | t.Fatalf("call tool failed") 59 | } 60 | if !strings.Contains(podsExecNilNamespace.Content[0].(mcp.TextContent).Text, "command:ls -l\n") { 61 | t.Errorf("unexpected result %v", podsExecNilNamespace.Content[0].(mcp.TextContent).Text) 62 | } 63 | }) 64 | podsExecInNamespace, err := c.callTool("pods_exec", map[string]interface{}{ 65 | "namespace": "default", 66 | "name": "pod-to-exec", 67 | "command": []interface{}{"ls", "-l"}, 68 | }) 69 | t.Run("pods_exec with name and namespace returns command output", func(t *testing.T) { 70 | if err != nil { 71 | t.Fatalf("call tool failed %v", err) 72 | } 73 | if podsExecInNamespace.IsError { 74 | t.Fatalf("call tool failed") 75 | } 76 | if !strings.Contains(podsExecNilNamespace.Content[0].(mcp.TextContent).Text, "command:ls -l\n") { 77 | t.Errorf("unexpected result %v", podsExecInNamespace.Content[0].(mcp.TextContent).Text) 78 | } 79 | }) 80 | podsExecInNamespaceAndContainer, err := c.callTool("pods_exec", map[string]interface{}{ 81 | "namespace": "default", 82 | "name": "pod-to-exec", 83 | "command": []interface{}{"ls", "-l"}, 84 | "container": "a-specific-container", 85 | }) 86 | t.Run("pods_exec with name, namespace, and container returns command output", func(t *testing.T) { 87 | if err != nil { 88 | t.Fatalf("call tool failed %v", err) 89 | } 90 | if podsExecInNamespaceAndContainer.IsError { 91 | t.Fatalf("call tool failed") 92 | } 93 | if !strings.Contains(podsExecInNamespaceAndContainer.Content[0].(mcp.TextContent).Text, "command:ls -l\n") { 94 | t.Errorf("unexpected result %v", podsExecInNamespaceAndContainer.Content[0].(mcp.TextContent).Text) 95 | } 96 | if !strings.Contains(podsExecInNamespaceAndContainer.Content[0].(mcp.TextContent).Text, "container:a-specific-container\n") { 97 | t.Errorf("expected container name not found %v", podsExecInNamespaceAndContainer.Content[0].(mcp.TextContent).Text) 98 | } 99 | }) 100 | 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /pkg/mcp/profiles.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/server" 5 | "slices" 6 | ) 7 | 8 | var Profiles = []Profile{ 9 | &FullProfile{}, 10 | } 11 | 12 | var ProfileNames []string 13 | 14 | type Profile interface { 15 | GetName() string 16 | GetDescription() string 17 | GetTools(s *Server) []server.ServerTool 18 | } 19 | 20 | func ProfileFromString(name string) Profile { 21 | for _, profile := range Profiles { 22 | if profile.GetName() == name { 23 | return profile 24 | } 25 | } 26 | return nil 27 | } 28 | 29 | type FullProfile struct{} 30 | 31 | func (p *FullProfile) GetName() string { 32 | return "full" 33 | } 34 | func (p *FullProfile) GetDescription() string { 35 | return "Complete profile with all tools and extended outputs" 36 | } 37 | func (p *FullProfile) GetTools(s *Server) []server.ServerTool { 38 | return slices.Concat( 39 | s.initConfiguration(), 40 | s.initEvents(), 41 | s.initNamespaces(), 42 | s.initPods(), 43 | s.initResources(), 44 | s.initHelm(), 45 | ) 46 | } 47 | 48 | func init() { 49 | ProfileNames = make([]string, 0) 50 | for _, profile := range Profiles { 51 | ProfileNames = append(ProfileNames, profile.GetName()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/mcp/profiles_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/mcp" 5 | "slices" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestFullProfileTools(t *testing.T) { 11 | expectedNames := []string{ 12 | "configuration_view", 13 | "events_list", 14 | "helm_install", 15 | "helm_list", 16 | "helm_uninstall", 17 | "namespaces_list", 18 | "pods_list", 19 | "pods_list_in_namespace", 20 | "pods_get", 21 | "pods_delete", 22 | "pods_log", 23 | "pods_run", 24 | "pods_exec", 25 | "resources_list", 26 | "resources_get", 27 | "resources_create_or_update", 28 | "resources_delete", 29 | } 30 | mcpCtx := &mcpContext{profile: &FullProfile{}} 31 | testCaseWithContext(t, mcpCtx, func(c *mcpContext) { 32 | tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) 33 | t.Run("ListTools returns tools", func(t *testing.T) { 34 | if err != nil { 35 | t.Fatalf("call ListTools failed %v", err) 36 | return 37 | } 38 | }) 39 | nameSet := make(map[string]bool) 40 | for _, tool := range tools.Tools { 41 | nameSet[tool.Name] = true 42 | } 43 | for _, name := range expectedNames { 44 | t.Run("ListTools has "+name+" tool", func(t *testing.T) { 45 | if nameSet[name] != true { 46 | t.Fatalf("tool %s not found", name) 47 | return 48 | } 49 | }) 50 | } 51 | }) 52 | } 53 | 54 | func TestFullProfileToolsInOpenShift(t *testing.T) { 55 | mcpCtx := &mcpContext{ 56 | profile: &FullProfile{}, 57 | before: inOpenShift, 58 | after: inOpenShiftClear, 59 | } 60 | testCaseWithContext(t, mcpCtx, func(c *mcpContext) { 61 | tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) 62 | t.Run("ListTools returns tools", func(t *testing.T) { 63 | if err != nil { 64 | t.Fatalf("call ListTools failed %v", err) 65 | } 66 | }) 67 | t.Run("ListTools contains projects_list tool", func(t *testing.T) { 68 | idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool { 69 | return tool.Name == "projects_list" 70 | }) 71 | if idx == -1 { 72 | t.Fatalf("tool projects_list not found") 73 | } 74 | }) 75 | t.Run("ListTools has resources_list tool with OpenShift hint", func(t *testing.T) { 76 | idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool { 77 | return tool.Name == "resources_list" 78 | }) 79 | if idx == -1 { 80 | t.Fatalf("tool resources_list not found") 81 | } 82 | if !strings.Contains(tools.Tools[idx].Description, ", route.openshift.io/v1 Route") { 83 | t.Fatalf("tool resources_list does not have OpenShift hint, got %s", tools.Tools[9].Description) 84 | } 85 | }) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /pkg/mcp/resources.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | "k8s.io/apimachinery/pkg/runtime/schema" 11 | ) 12 | 13 | func (s *Server) initResources() []server.ServerTool { 14 | commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress" 15 | if s.k.IsOpenShift(context.Background()) { 16 | commonApiVersion += ", route.openshift.io/v1 Route" 17 | } 18 | commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion) 19 | return []server.ServerTool{ 20 | {Tool: mcp.NewTool("resources_list", 21 | mcp.WithDescription("List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n"+ 22 | commonApiVersion), 23 | mcp.WithString("apiVersion", 24 | mcp.Description("apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)"), 25 | mcp.Required(), 26 | ), 27 | mcp.WithString("kind", 28 | mcp.Description("kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)"), 29 | mcp.Required(), 30 | ), 31 | mcp.WithString("namespace", 32 | mcp.Description("Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces")), 33 | mcp.WithString("labelSelector", 34 | mcp.Description("Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")), 35 | // Tool annotations 36 | mcp.WithTitleAnnotation("Resources: List"), 37 | mcp.WithReadOnlyHintAnnotation(true), 38 | mcp.WithDestructiveHintAnnotation(false), 39 | mcp.WithOpenWorldHintAnnotation(true), 40 | ), Handler: s.resourcesList}, 41 | {Tool: mcp.NewTool("resources_get", 42 | mcp.WithDescription("Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n"+ 43 | commonApiVersion), 44 | mcp.WithString("apiVersion", 45 | mcp.Description("apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)"), 46 | mcp.Required(), 47 | ), 48 | mcp.WithString("kind", 49 | mcp.Description("kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)"), 50 | mcp.Required(), 51 | ), 52 | mcp.WithString("namespace", 53 | mcp.Description("Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace"), 54 | ), 55 | mcp.WithString("name", mcp.Description("Name of the resource"), mcp.Required()), 56 | // Tool annotations 57 | mcp.WithTitleAnnotation("Resources: Get"), 58 | mcp.WithReadOnlyHintAnnotation(true), 59 | mcp.WithDestructiveHintAnnotation(false), 60 | mcp.WithOpenWorldHintAnnotation(true), 61 | ), Handler: s.resourcesGet}, 62 | {Tool: mcp.NewTool("resources_create_or_update", 63 | mcp.WithDescription("Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n"+ 64 | commonApiVersion), 65 | mcp.WithString("resource", 66 | mcp.Description("A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec"), 67 | mcp.Required(), 68 | ), 69 | // Tool annotations 70 | mcp.WithTitleAnnotation("Resources: Create or Update"), 71 | mcp.WithReadOnlyHintAnnotation(false), 72 | mcp.WithDestructiveHintAnnotation(true), 73 | mcp.WithIdempotentHintAnnotation(true), 74 | mcp.WithOpenWorldHintAnnotation(true), 75 | ), Handler: s.resourcesCreateOrUpdate}, 76 | {Tool: mcp.NewTool("resources_delete", 77 | mcp.WithDescription("Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n"+ 78 | commonApiVersion), 79 | mcp.WithString("apiVersion", 80 | mcp.Description("apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)"), 81 | mcp.Required(), 82 | ), 83 | mcp.WithString("kind", 84 | mcp.Description("kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)"), 85 | mcp.Required(), 86 | ), 87 | mcp.WithString("namespace", 88 | mcp.Description("Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace"), 89 | ), 90 | mcp.WithString("name", mcp.Description("Name of the resource"), mcp.Required()), 91 | // Tool annotations 92 | mcp.WithTitleAnnotation("Resources: Delete"), 93 | mcp.WithReadOnlyHintAnnotation(false), 94 | mcp.WithDestructiveHintAnnotation(true), 95 | mcp.WithIdempotentHintAnnotation(true), 96 | mcp.WithOpenWorldHintAnnotation(true), 97 | ), Handler: s.resourcesDelete}, 98 | } 99 | } 100 | 101 | func (s *Server) resourcesList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 102 | namespace := ctr.GetArguments()["namespace"] 103 | if namespace == nil { 104 | namespace = "" 105 | } 106 | labelSelector := ctr.GetArguments()["labelSelector"] 107 | if labelSelector == nil { 108 | labelSelector = "" 109 | } 110 | gvk, err := parseGroupVersionKind(ctr.GetArguments()) 111 | if err != nil { 112 | return NewTextResult("", fmt.Errorf("failed to list resources, %s", err)), nil 113 | } 114 | ret, err := s.k.Derived(ctx).ResourcesList(ctx, gvk, namespace.(string), labelSelector.(string)) 115 | if err != nil { 116 | return NewTextResult("", fmt.Errorf("failed to list resources: %v", err)), nil 117 | } 118 | return NewTextResult(ret, err), nil 119 | } 120 | 121 | func (s *Server) resourcesGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 122 | namespace := ctr.GetArguments()["namespace"] 123 | if namespace == nil { 124 | namespace = "" 125 | } 126 | gvk, err := parseGroupVersionKind(ctr.GetArguments()) 127 | if err != nil { 128 | return NewTextResult("", fmt.Errorf("failed to get resource, %s", err)), nil 129 | } 130 | name := ctr.GetArguments()["name"] 131 | if name == nil { 132 | return NewTextResult("", errors.New("failed to get resource, missing argument name")), nil 133 | } 134 | ret, err := s.k.Derived(ctx).ResourcesGet(ctx, gvk, namespace.(string), name.(string)) 135 | if err != nil { 136 | return NewTextResult("", fmt.Errorf("failed to get resource: %v", err)), nil 137 | } 138 | return NewTextResult(ret, err), nil 139 | } 140 | 141 | func (s *Server) resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 142 | resource := ctr.GetArguments()["resource"] 143 | if resource == nil || resource == "" { 144 | return NewTextResult("", errors.New("failed to create or update resources, missing argument resource")), nil 145 | } 146 | ret, err := s.k.Derived(ctx).ResourcesCreateOrUpdate(ctx, resource.(string)) 147 | if err != nil { 148 | return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil 149 | } 150 | return NewTextResult(ret, err), nil 151 | } 152 | 153 | func (s *Server) resourcesDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { 154 | namespace := ctr.GetArguments()["namespace"] 155 | if namespace == nil { 156 | namespace = "" 157 | } 158 | gvk, err := parseGroupVersionKind(ctr.GetArguments()) 159 | if err != nil { 160 | return NewTextResult("", fmt.Errorf("failed to delete resource, %s", err)), nil 161 | } 162 | name := ctr.GetArguments()["name"] 163 | if name == nil { 164 | return NewTextResult("", errors.New("failed to delete resource, missing argument name")), nil 165 | } 166 | err = s.k.Derived(ctx).ResourcesDelete(ctx, gvk, namespace.(string), name.(string)) 167 | if err != nil { 168 | return NewTextResult("", fmt.Errorf("failed to delete resource: %v", err)), nil 169 | } 170 | return NewTextResult("Resource deleted successfully", err), nil 171 | } 172 | 173 | func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) { 174 | apiVersion := arguments["apiVersion"] 175 | if apiVersion == nil { 176 | return nil, errors.New("missing argument apiVersion") 177 | } 178 | kind := arguments["kind"] 179 | if kind == nil { 180 | return nil, errors.New("missing argument kind") 181 | } 182 | gv, err := schema.ParseGroupVersion(apiVersion.(string)) 183 | if err != nil { 184 | return nil, errors.New("invalid argument apiVersion") 185 | } 186 | return &schema.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: kind.(string)}, nil 187 | } 188 | -------------------------------------------------------------------------------- /pkg/mcp/resources_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/mark3labs/mcp-go/mcp" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | "k8s.io/apimachinery/pkg/runtime/schema" 11 | "k8s.io/client-go/dynamic" 12 | "sigs.k8s.io/yaml" 13 | ) 14 | 15 | func TestResourcesList(t *testing.T) { 16 | testCase(t, func(c *mcpContext) { 17 | c.withEnvTest() 18 | t.Run("resources_list with missing apiVersion returns error", func(t *testing.T) { 19 | toolResult, _ := c.callTool("resources_list", map[string]interface{}{}) 20 | if !toolResult.IsError { 21 | t.Fatalf("call tool should fail") 22 | return 23 | } 24 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, missing argument apiVersion" { 25 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 26 | return 27 | } 28 | }) 29 | t.Run("resources_list with missing kind returns error", func(t *testing.T) { 30 | toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1"}) 31 | if !toolResult.IsError { 32 | t.Fatalf("call tool should fail") 33 | return 34 | } 35 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, missing argument kind" { 36 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 37 | return 38 | } 39 | }) 40 | t.Run("resources_list with invalid apiVersion returns error", func(t *testing.T) { 41 | toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod"}) 42 | if !toolResult.IsError { 43 | t.Fatalf("call tool should fail") 44 | return 45 | } 46 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, invalid argument apiVersion" { 47 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 48 | return 49 | } 50 | }) 51 | t.Run("resources_list with nonexistent apiVersion returns error", func(t *testing.T) { 52 | toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom"}) 53 | if !toolResult.IsError { 54 | t.Fatalf("call tool should fail") 55 | return 56 | } 57 | if toolResult.Content[0].(mcp.TextContent).Text != `failed to list resources: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` { 58 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 59 | return 60 | } 61 | }) 62 | namespaces, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) 63 | t.Run("resources_list returns namespaces", func(t *testing.T) { 64 | if err != nil { 65 | t.Fatalf("call tool failed %v", err) 66 | return 67 | } 68 | if namespaces.IsError { 69 | t.Fatalf("call tool failed") 70 | return 71 | } 72 | }) 73 | var decodedNamespaces []unstructured.Unstructured 74 | err = yaml.Unmarshal([]byte(namespaces.Content[0].(mcp.TextContent).Text), &decodedNamespaces) 75 | t.Run("resources_list has yaml content", func(t *testing.T) { 76 | if err != nil { 77 | t.Fatalf("invalid tool result content %v", err) 78 | return 79 | } 80 | }) 81 | t.Run("resources_list returns more than 2 items", func(t *testing.T) { 82 | if len(decodedNamespaces) < 3 { 83 | t.Fatalf("invalid namespace count, expected >2, got %v", len(decodedNamespaces)) 84 | return 85 | } 86 | }) 87 | 88 | // Test label selector functionality 89 | t.Run("resources_list with label selector returns filtered pods", func(t *testing.T) { 90 | 91 | // List pods with label selector 92 | result, err := c.callTool("resources_list", map[string]interface{}{ 93 | "apiVersion": "v1", 94 | "kind": "Pod", 95 | "namespace": "default", 96 | "labelSelector": "app=nginx", 97 | }) 98 | 99 | if err != nil { 100 | t.Fatalf("call tool failed %v", err) 101 | return 102 | } 103 | if result.IsError { 104 | t.Fatalf("call tool failed") 105 | return 106 | } 107 | 108 | var decodedPods []unstructured.Unstructured 109 | err = yaml.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &decodedPods) 110 | if err != nil { 111 | t.Fatalf("invalid tool result content %v", err) 112 | return 113 | } 114 | 115 | // Verify only the pod with matching label is returned 116 | if len(decodedPods) != 1 { 117 | t.Fatalf("expected 1 pod, got %d", len(decodedPods)) 118 | return 119 | } 120 | 121 | if decodedPods[0].GetName() != "a-pod-in-default" { 122 | t.Fatalf("expected pod-with-label, got %s", decodedPods[0].GetName()) 123 | return 124 | } 125 | 126 | // Test that multiple label selectors work 127 | result, err = c.callTool("resources_list", map[string]interface{}{ 128 | "apiVersion": "v1", 129 | "kind": "Pod", 130 | "namespace": "default", 131 | "labelSelector": "test-label=test-value,another=value", 132 | }) 133 | 134 | if err != nil { 135 | t.Fatalf("call tool failed %v", err) 136 | return 137 | } 138 | if result.IsError { 139 | t.Fatalf("call tool failed") 140 | return 141 | } 142 | 143 | err = yaml.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &decodedPods) 144 | if err != nil { 145 | t.Fatalf("invalid tool result content %v", err) 146 | return 147 | } 148 | 149 | // Verify no pods match multiple label selector 150 | if len(decodedPods) != 0 { 151 | t.Fatalf("expected 0 pods, got %d", len(decodedPods)) 152 | return 153 | } 154 | }) 155 | }) 156 | } 157 | 158 | func TestResourcesGet(t *testing.T) { 159 | testCase(t, func(c *mcpContext) { 160 | c.withEnvTest() 161 | t.Run("resources_get with missing apiVersion returns error", func(t *testing.T) { 162 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{}) 163 | if !toolResult.IsError { 164 | t.Fatalf("call tool should fail") 165 | return 166 | } 167 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument apiVersion" { 168 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 169 | return 170 | } 171 | }) 172 | t.Run("resources_get with missing kind returns error", func(t *testing.T) { 173 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1"}) 174 | if !toolResult.IsError { 175 | t.Fatalf("call tool should fail") 176 | return 177 | } 178 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument kind" { 179 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 180 | return 181 | } 182 | }) 183 | t.Run("resources_get with invalid apiVersion returns error", func(t *testing.T) { 184 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"}) 185 | if !toolResult.IsError { 186 | t.Fatalf("call tool should fail") 187 | return 188 | } 189 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, invalid argument apiVersion" { 190 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 191 | return 192 | } 193 | }) 194 | t.Run("resources_get with nonexistent apiVersion returns error", func(t *testing.T) { 195 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"}) 196 | if !toolResult.IsError { 197 | t.Fatalf("call tool should fail") 198 | return 199 | } 200 | if toolResult.Content[0].(mcp.TextContent).Text != `failed to get resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` { 201 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 202 | return 203 | } 204 | }) 205 | t.Run("resources_get with missing name returns error", func(t *testing.T) { 206 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) 207 | if !toolResult.IsError { 208 | t.Fatalf("call tool should fail") 209 | return 210 | } 211 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument name" { 212 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 213 | return 214 | } 215 | }) 216 | namespace, err := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"}) 217 | t.Run("resources_get returns namespace", func(t *testing.T) { 218 | if err != nil { 219 | t.Fatalf("call tool failed %v", err) 220 | return 221 | } 222 | if namespace.IsError { 223 | t.Fatalf("call tool failed") 224 | return 225 | } 226 | }) 227 | var decodedNamespace unstructured.Unstructured 228 | err = yaml.Unmarshal([]byte(namespace.Content[0].(mcp.TextContent).Text), &decodedNamespace) 229 | t.Run("resources_get has yaml content", func(t *testing.T) { 230 | if err != nil { 231 | t.Fatalf("invalid tool result content %v", err) 232 | return 233 | } 234 | }) 235 | t.Run("resources_get returns default namespace", func(t *testing.T) { 236 | if decodedNamespace.GetName() != "default" { 237 | t.Fatalf("invalid namespace name, expected default, got %v", decodedNamespace.GetName()) 238 | return 239 | } 240 | }) 241 | }) 242 | } 243 | 244 | func TestResourcesCreateOrUpdate(t *testing.T) { 245 | testCase(t, func(c *mcpContext) { 246 | c.withEnvTest() 247 | t.Run("resources_create_or_update with nil resource returns error", func(t *testing.T) { 248 | toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{}) 249 | if toolResult.IsError != true { 250 | t.Fatalf("call tool should fail") 251 | return 252 | } 253 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to create or update resources, missing argument resource" { 254 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 255 | return 256 | } 257 | }) 258 | t.Run("resources_create_or_update with empty resource returns error", func(t *testing.T) { 259 | toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": ""}) 260 | if toolResult.IsError != true { 261 | t.Fatalf("call tool should fail") 262 | return 263 | } 264 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to create or update resources, missing argument resource" { 265 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 266 | return 267 | } 268 | }) 269 | client := c.newKubernetesClient() 270 | configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n" 271 | resourcesCreateOrUpdateCm1, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml}) 272 | t.Run("resources_create_or_update with valid namespaced yaml resource returns success", func(t *testing.T) { 273 | if err != nil { 274 | t.Fatalf("call tool failed %v", err) 275 | return 276 | } 277 | if resourcesCreateOrUpdateCm1.IsError { 278 | t.Errorf("call tool failed") 279 | return 280 | } 281 | }) 282 | var decodedCreateOrUpdateCm1 []unstructured.Unstructured 283 | err = yaml.Unmarshal([]byte(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text), &decodedCreateOrUpdateCm1) 284 | t.Run("resources_create_or_update with valid namespaced yaml resource returns yaml content", func(t *testing.T) { 285 | if err != nil { 286 | t.Errorf("invalid tool result content %v", err) 287 | return 288 | } 289 | if !strings.HasPrefix(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text, "# The following resources (YAML) have been created or updated successfully") { 290 | t.Errorf("Excpected success message, got %v", resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text) 291 | return 292 | } 293 | if len(decodedCreateOrUpdateCm1) != 1 { 294 | t.Errorf("invalid resource count, expected 1, got %v", len(decodedCreateOrUpdateCm1)) 295 | return 296 | } 297 | if decodedCreateOrUpdateCm1[0].GetName() != "a-cm-created-or-updated" { 298 | t.Errorf("invalid resource name, expected a-cm-created-or-updated, got %v", decodedCreateOrUpdateCm1[0].GetName()) 299 | return 300 | } 301 | if decodedCreateOrUpdateCm1[0].GetUID() == "" { 302 | t.Errorf("invalid uid, got %v", decodedCreateOrUpdateCm1[0].GetUID()) 303 | return 304 | } 305 | }) 306 | t.Run("resources_create_or_update with valid namespaced yaml resource creates ConfigMap", func(t *testing.T) { 307 | cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated", metav1.GetOptions{}) 308 | if cm == nil { 309 | t.Fatalf("ConfigMap not found") 310 | return 311 | } 312 | }) 313 | configMapJson := "{\"apiVersion\": \"v1\", \"kind\": \"ConfigMap\", \"metadata\": {\"name\": \"a-cm-created-or-updated-2\", \"namespace\": \"default\"}}" 314 | resourcesCreateOrUpdateCm2, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapJson}) 315 | t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) { 316 | if err != nil { 317 | t.Fatalf("call tool failed %v", err) 318 | return 319 | } 320 | if resourcesCreateOrUpdateCm2.IsError { 321 | t.Fatalf("call tool failed") 322 | return 323 | } 324 | }) 325 | t.Run("resources_create_or_update with valid namespaced json resource creates config map", func(t *testing.T) { 326 | cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated-2", metav1.GetOptions{}) 327 | if cm == nil { 328 | t.Fatalf("ConfigMap not found") 329 | return 330 | } 331 | }) 332 | customResourceDefinitionJson := ` 333 | { 334 | "apiVersion": "apiextensions.k8s.io/v1", 335 | "kind": "CustomResourceDefinition", 336 | "metadata": {"name": "customs.example.com"}, 337 | "spec": { 338 | "group": "example.com", 339 | "versions": [{ 340 | "name": "v1","served": true,"storage": true, 341 | "schema": {"openAPIV3Schema": {"type": "object"}} 342 | }], 343 | "scope": "Namespaced", 344 | "names": {"plural": "customs","singular": "custom","kind": "Custom"} 345 | } 346 | }` 347 | resourcesCreateOrUpdateCrd, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customResourceDefinitionJson}) 348 | t.Run("resources_create_or_update with valid cluster-scoped json resource returns success", func(t *testing.T) { 349 | if err != nil { 350 | t.Fatalf("call tool failed %v", err) 351 | return 352 | } 353 | if resourcesCreateOrUpdateCrd.IsError { 354 | t.Fatalf("call tool failed") 355 | return 356 | } 357 | }) 358 | t.Run("resources_create_or_update with valid cluster-scoped json resource creates custom resource definition", func(t *testing.T) { 359 | apiExtensionsV1Client := c.newApiExtensionsClient() 360 | _, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, "customs.example.com", metav1.GetOptions{}) 361 | if err != nil { 362 | t.Fatalf("custom resource definition not found") 363 | return 364 | } 365 | }) 366 | c.crdWaitUntilReady("customs.example.com") 367 | customJson := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\"}}" 368 | resourcesCreateOrUpdateCustom, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJson}) 369 | t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) { 370 | if err != nil { 371 | t.Fatalf("call tool failed %v", err) 372 | return 373 | } 374 | if resourcesCreateOrUpdateCustom.IsError { 375 | t.Fatalf("call tool failed, got: %v", resourcesCreateOrUpdateCustom.Content) 376 | return 377 | } 378 | }) 379 | t.Run("resources_create_or_update with valid namespaced json resource creates custom resource", func(t *testing.T) { 380 | dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) 381 | _, err = dynamicClient. 382 | Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}). 383 | Namespace("default"). 384 | Get(c.ctx, "a-custom-resource", metav1.GetOptions{}) 385 | if err != nil { 386 | t.Fatalf("custom resource not found") 387 | return 388 | } 389 | }) 390 | customJsonUpdated := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\",\"annotations\": {\"updated\": \"true\"}}}" 391 | resourcesCreateOrUpdateCustomUpdated, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJsonUpdated}) 392 | t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) { 393 | if err != nil { 394 | t.Fatalf("call tool failed %v", err) 395 | return 396 | } 397 | if resourcesCreateOrUpdateCustomUpdated.IsError { 398 | t.Fatalf("call tool failed") 399 | return 400 | } 401 | }) 402 | t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) { 403 | dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) 404 | customResource, _ := dynamicClient. 405 | Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}). 406 | Namespace("default"). 407 | Get(c.ctx, "a-custom-resource", metav1.GetOptions{}) 408 | if customResource == nil { 409 | t.Fatalf("custom resource not found") 410 | return 411 | } 412 | annotations := customResource.GetAnnotations() 413 | if annotations == nil || annotations["updated"] != "true" { 414 | t.Fatalf("custom resource not updated") 415 | return 416 | } 417 | }) 418 | }) 419 | } 420 | 421 | func TestResourcesDelete(t *testing.T) { 422 | testCase(t, func(c *mcpContext) { 423 | c.withEnvTest() 424 | t.Run("resources_delete with missing apiVersion returns error", func(t *testing.T) { 425 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{}) 426 | if !toolResult.IsError { 427 | t.Fatalf("call tool should fail") 428 | return 429 | } 430 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument apiVersion" { 431 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 432 | return 433 | } 434 | }) 435 | t.Run("resources_delete with missing kind returns error", func(t *testing.T) { 436 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1"}) 437 | if !toolResult.IsError { 438 | t.Fatalf("call tool should fail") 439 | return 440 | } 441 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument kind" { 442 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 443 | return 444 | } 445 | }) 446 | t.Run("resources_delete with invalid apiVersion returns error", func(t *testing.T) { 447 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"}) 448 | if !toolResult.IsError { 449 | t.Fatalf("call tool should fail") 450 | return 451 | } 452 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, invalid argument apiVersion" { 453 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 454 | return 455 | } 456 | }) 457 | t.Run("resources_delete with nonexistent apiVersion returns error", func(t *testing.T) { 458 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"}) 459 | if !toolResult.IsError { 460 | t.Fatalf("call tool should fail") 461 | return 462 | } 463 | if toolResult.Content[0].(mcp.TextContent).Text != `failed to delete resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` { 464 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 465 | return 466 | } 467 | }) 468 | t.Run("resources_delete with missing name returns error", func(t *testing.T) { 469 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) 470 | if !toolResult.IsError { 471 | t.Fatalf("call tool should fail") 472 | return 473 | } 474 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument name" { 475 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 476 | return 477 | } 478 | }) 479 | t.Run("resources_delete with nonexistent resource returns error", func(t *testing.T) { 480 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "nonexistent-configmap"}) 481 | if !toolResult.IsError { 482 | t.Fatalf("call tool should fail") 483 | return 484 | } 485 | if toolResult.Content[0].(mcp.TextContent).Text != `failed to delete resource: configmaps "nonexistent-configmap" not found` { 486 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) 487 | return 488 | } 489 | }) 490 | resourcesDeleteCm, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "a-configmap-to-delete"}) 491 | t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) { 492 | if err != nil { 493 | t.Fatalf("call tool failed %v", err) 494 | return 495 | } 496 | if resourcesDeleteCm.IsError { 497 | t.Fatalf("call tool failed") 498 | return 499 | } 500 | if resourcesDeleteCm.Content[0].(mcp.TextContent).Text != "Resource deleted successfully" { 501 | t.Fatalf("invalid tool result content got: %v", resourcesDeleteCm.Content[0].(mcp.TextContent).Text) 502 | return 503 | } 504 | }) 505 | client := c.newKubernetesClient() 506 | t.Run("resources_delete with valid namespaced resource deletes ConfigMap", func(t *testing.T) { 507 | _, err := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-configmap-to-delete", metav1.GetOptions{}) 508 | if err == nil { 509 | t.Fatalf("ConfigMap not deleted") 510 | return 511 | } 512 | }) 513 | resourcesDeleteNamespace, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "ns-to-delete"}) 514 | t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) { 515 | if err != nil { 516 | t.Fatalf("call tool failed %v", err) 517 | return 518 | } 519 | if resourcesDeleteNamespace.IsError { 520 | t.Fatalf("call tool failed") 521 | return 522 | } 523 | if resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text != "Resource deleted successfully" { 524 | t.Fatalf("invalid tool result content got: %v", resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text) 525 | return 526 | } 527 | }) 528 | t.Run("resources_delete with valid namespaced resource deletes Namespace", func(t *testing.T) { 529 | ns, err := client.CoreV1().Namespaces().Get(c.ctx, "ns-to-delete", metav1.GetOptions{}) 530 | if err == nil && ns != nil && ns.ObjectMeta.DeletionTimestamp == nil { 531 | t.Fatalf("Namespace not deleted") 532 | return 533 | } 534 | }) 535 | }) 536 | } 537 | -------------------------------------------------------------------------------- /pkg/mcp/testdata/helm-chart-no-op/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: no-op 3 | version: 1.33.7 4 | -------------------------------------------------------------------------------- /pkg/version/version.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/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /python/kubernetes_mcp_server/__init__.py: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /python/kubernetes_mcp_server/__main__.py: -------------------------------------------------------------------------------- 1 | from .kubernetes_mcp_server import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /python/kubernetes_mcp_server/kubernetes_mcp_server.py: -------------------------------------------------------------------------------- 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/manusa/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 | -------------------------------------------------------------------------------- /python/pyproject.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 = "marc@marcnuri.com" } 14 | ] 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "Operating System :: OS Independent", 18 | ] 19 | 20 | [project.urls] 21 | Homepage = "https://github.com/manusa/kubernetes-mcp-server" 22 | Repository = "https://github.com/manusa/kubernetes-mcp-server" 23 | 24 | [project.scripts] 25 | kubernetes-mcp-server = "kubernetes_mcp_server:main" 26 | -------------------------------------------------------------------------------- /smithery.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 | --------------------------------------------------------------------------------