├── .githooks ├── pre-commit └── pre-push ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yaml │ ├── golangci-lint.yaml │ ├── install.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .goreleaser.yaml ├── .vscode └── settings.json ├── CLAUDE.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── docs └── images │ ├── claude-desktop-logs.png │ └── logo.png ├── go.mod ├── go.sum ├── internal ├── config │ ├── options.go │ └── options_test.go ├── content │ └── json.go ├── k8s │ ├── apps │ │ └── v1 │ │ │ └── deployment │ │ │ ├── get_deployment_test.yaml │ │ │ ├── list_deployments.go │ │ │ ├── list_deployments_test.yaml │ │ │ └── test_manifests │ │ │ └── test_deployment.yaml │ ├── client.go │ ├── core │ │ └── v1 │ │ │ ├── node │ │ │ ├── get_node_test.yaml │ │ │ └── list_nodes_test.yaml │ │ │ ├── pod │ │ │ ├── get_pod_test.yaml │ │ │ ├── list_pods_test.yaml │ │ │ └── pod_exec_test.yaml │ │ │ └── service │ │ │ ├── list_services.go │ │ │ └── list_services_test.yaml │ ├── list_mapping │ │ └── interface.go │ ├── list_mappings.go │ ├── mock │ │ └── pool_mock.go │ └── pool.go ├── prompts │ ├── namespaces.go │ └── pods.go ├── resources │ └── contexts.go ├── tests │ └── assert_text_content.go ├── tools │ ├── contexts.go │ ├── error.go │ ├── events.go │ ├── get_resource_tool.go │ ├── json_content.go │ ├── list_resources_tool.go │ ├── namespaces.go │ ├── nodes.go │ ├── pod_exec_cmd.go │ ├── pod_logs.go │ └── pod_logs_test.go └── utils │ ├── age.go │ ├── error_response.go │ ├── ptr.go │ └── sanitize.go ├── main.go ├── main_test.go ├── packages ├── .gitignore ├── npm-mcp-k8s-darwin-arm64 │ ├── .npmrc │ └── package.json ├── npm-mcp-k8s-darwin-x64 │ ├── .npmrc │ └── package.json ├── npm-mcp-k8s-linux-arm64 │ ├── .npmrc │ └── package.json ├── npm-mcp-k8s-linux-x64 │ ├── .npmrc │ └── package.json ├── npm-mcp-k8s-win32-arm64 │ ├── .npmrc │ └── package.json ├── npm-mcp-k8s-win32-x64 │ ├── .npmrc │ └── package.json ├── npm-mcp-k8s │ ├── .npmrc │ ├── README.md │ ├── bin │ │ └── cli │ ├── index.js │ └── package.json ├── publish_npm.sh └── update_versions.sh ├── smithery.yaml ├── synf.toml ├── testdata ├── allowed_contexts │ └── allowed_contexts_test.yaml ├── initialize │ └── init_test.yaml ├── k8s_contexts │ ├── kubeconfig │ └── list_k8s_contexts_test.yaml ├── list_prompts_test.yaml ├── list_tools_test.yaml └── with_k3d │ ├── .gitignore │ ├── get_k8s_pod_logs_test.yaml │ ├── list_k8s_contexts_test.yaml │ ├── list_k8s_events_test.yaml │ ├── list_k8s_namespaces_test.yaml │ └── list_k8s_nodes_test.yaml └── tools ├── generate-mocks.sh ├── inspector ├── .gitignore ├── package-lock.json ├── package.json └── run.sh ├── release.sh └── use-context.sh /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | golangci-lint run --timeout 5m 5 | -------------------------------------------------------------------------------- /.githooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go test -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'strowk/mcp-k8s-go' 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | run: gh pr merge --auto --merge "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | run-name: Linting Go code 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | pull_request: 8 | 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-go@v5 16 | with: 17 | go-version: stable 18 | - name: golangci-lint 19 | uses: golangci/golangci-lint-action@v7 20 | with: 21 | version: v2.0.2 22 | args: --timeout 5m 23 | -------------------------------------------------------------------------------- /.github/workflows/install.yaml: -------------------------------------------------------------------------------- 1 | # This is Github workflow that installs mcp-k8s from npm to various platforms and tests if it was installed 2 | 3 | name: Install mcp-k8s 4 | 5 | on: 6 | schedule: 7 | # check that the install works periodically 8 | - cron: '0 0 * * 5' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | install: 13 | strategy: 14 | matrix: 15 | os: 16 | - windows-latest 17 | - ubuntu-latest 18 | - macos-latest 19 | arch: 20 | - x64 21 | - arm64 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - name: Install mcp-k8s from docker 25 | if: ${{ matrix.os == 'ubuntu-latest' }} 26 | run: | 27 | docker pull mcpk8s/server:latest 28 | docker run --rm mcpk8s/server:latest version 29 | 30 | - name: Install mcp-k8s from npm 31 | run: | 32 | npm install -g @strowk/mcp-k8s 33 | mcp-k8s version 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release mcp-k8s 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | call-run-tests: 14 | uses: strowk/mcp-k8s-go/.github/workflows/test.yaml@main 15 | release: 16 | needs: call-run-tests 17 | services: 18 | registry: 19 | image: registry:2 20 | ports: 21 | - 5000:5000 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Set up Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: '1.24' 33 | 34 | - name: Install goreleaser 35 | run: | 36 | go install github.com/goreleaser/goreleaser/v2@v2.8.1 37 | which goreleaser 38 | 39 | - name: Run goreleaser build 40 | run: | 41 | goreleaser release --clean 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: Set up Docker Buildx 46 | uses: docker/setup-buildx-action@v3 47 | with: 48 | driver-opts: network=host 49 | 50 | - name: Login to Docker Hub 51 | uses: docker/login-action@v3 52 | with: 53 | username: mcpk8s 54 | password: ${{ secrets.DOCKERHUB_TOKEN }} 55 | 56 | # This bit is a workaround because docker can only do what we need 57 | # when pushing directly from build to docker hub (i.e push by digest) 58 | # and corresponding github action is unable to push without building 59 | - name: Precreate temp context dir 60 | run: | 61 | mkdir -p /tmp/mcpk8s/arm64 62 | echo 'FROM localhost:5000/mcpk8s/server:tmp-linux-arm64' > /tmp/mcpk8s/arm64/Dockerfile 63 | mkdir -p /tmp/mcpk8s/amd64 64 | echo 'FROM localhost:5000/mcpk8s/server:tmp-linux-amd64' > /tmp/mcpk8s/amd64/Dockerfile 65 | 66 | - name: Build and push arm64 by digest 67 | id: build-arm64 68 | uses: docker/build-push-action@v6 69 | with: 70 | context: /tmp/mcpk8s/arm64 71 | platforms: linux/arm64 72 | tags: mcpk8s/server 73 | outputs: type=image,push-by-digest=true,name-canonical=true,push=true 74 | 75 | - name: Build and push amd64 by digest 76 | id: build-amd64 77 | uses: docker/build-push-action@v6 78 | with: 79 | context: /tmp/mcpk8s/amd64 80 | platforms: linux/amd64 81 | tags: mcpk8s/server 82 | outputs: type=image,push-by-digest=true,name-canonical=true,push=true 83 | 84 | - name: Create manifest list and push 85 | run: | 86 | goreleaser_tag=$(cat dist/metadata.json | jq -r '.tag') 87 | echo "goreleaser_tag: ${goreleaser_tag}" 88 | 89 | # the magic here is simply taking outputs from build- actions 90 | # and removing sha256: prefix from them, cause we need to pass 91 | # digests further to imagetools without the prefix 92 | 93 | digest_arm64="${{ steps.build-arm64.outputs.digest }}" 94 | digest_arm64="${digest_arm64#sha256:}" 95 | digest_amd64="${{ steps.build-amd64.outputs.digest }}" 96 | digest_amd64="${digest_amd64#sha256:}" 97 | 98 | echo "crate multi-arch image for tag mcpk8s/server:${goreleaser_tag} with images:" 99 | echo " arm64: mcpk8s/server@sha256:${digest_arm64}" 100 | echo " amd64: mcpk8s/server@sha256:${digest_amd64}" 101 | 102 | docker buildx imagetools create \ 103 | -t mcpk8s/server:${goreleaser_tag} \ 104 | -t mcpk8s/server:latest \ 105 | mcpk8s/server@sha256:${digest_arm64} \ 106 | mcpk8s/server@sha256:${digest_amd64} 107 | 108 | - name: Publish to npm 109 | env: 110 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 111 | run: | 112 | chmod +x ./packages/publish_npm.sh 113 | ./packages/publish_npm.sh 114 | 115 | install: 116 | strategy: 117 | matrix: 118 | os: 119 | - windows-latest 120 | - ubuntu-latest 121 | - macos-latest 122 | arch: 123 | - x64 124 | - arm64 125 | runs-on: ${{ matrix.os }} 126 | needs: release 127 | steps: 128 | - name: Checkout 129 | uses: actions/checkout@v2 130 | 131 | - name: Install mcp-k8s 132 | run: | 133 | npm install -g @strowk/mcp-k8s 134 | 135 | - name: Test mcp-k8s 136 | run: | 137 | mcp-k8s version 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | run-name: Running tests 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | workflow_call: 8 | pull_request: 9 | 10 | jobs: 11 | run-tests: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-go@v5 16 | with: 17 | go-version: '1.24' 18 | # Install kubectl 19 | - run: 'curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"' 20 | # Install k3d 21 | - run: 'curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash' 22 | # Run tests 23 | - run: go test -v ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | dev.log.yaml 3 | .aider* 4 | mcp-k8s-go 5 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - linux 23 | - windows 24 | - darwin 25 | 26 | dockers: 27 | - image_templates: 28 | - localhost:5000/mcpk8s/server:tmp-linux-amd64 29 | skip_push: false 30 | goarch: amd64 31 | build_flag_templates: 32 | - "--platform=linux/amd64" 33 | - "--label=org.opencontainers.image.version={{.Version}}" 34 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 35 | - "--label=org.opencontainers.image.created={{.Date}}" 36 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 37 | - image_templates: 38 | - localhost:5000/mcpk8s/server:tmp-linux-arm64 39 | skip_push: false 40 | goarch: arm64 41 | build_flag_templates: 42 | - "--platform=linux/arm64" 43 | - "--label=org.opencontainers.image.version={{.Version}}" 44 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 45 | - "--label=org.opencontainers.image.created={{.Date}}" 46 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 47 | 48 | archives: 49 | - format: tar.gz 50 | # this name template makes the OS and Arch compatible with the results of `uname`. 51 | name_template: >- 52 | {{ .ProjectName }}_ 53 | {{- title .Os }}_ 54 | {{- if eq .Arch "amd64" }}x86_64 55 | {{- else if eq .Arch "386" }}i386 56 | {{- else }}{{ .Arch }}{{ end }} 57 | {{- if .Arm }}v{{ .Arm }}{{ end }} 58 | # use zip for windows archives 59 | format_overrides: 60 | - goos: windows 61 | format: zip 62 | 63 | changelog: 64 | sort: asc 65 | filters: 66 | include: 67 | - "^added:" 68 | - "^fixed:" 69 | - "^changed:" 70 | - "^removed:" 71 | - "^deprecated:" 72 | 73 | release: 74 | prerelease: auto 75 | 76 | git: 77 | ignore_tags: 78 | # This will make goreleaser to build changelogs between releases 79 | # instead of from prerelease to release. 80 | - "{{ if not .Prerelease}}*beta*{{ end }}" 81 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Go tests would start Kubernetes cluster in k3d and that takes time 3 | "go.testTimeout": "10m", 4 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md - MCP K8S Go Development Guide 2 | 3 | ## Build & Test Commands 4 | - Build: `go build` 5 | - Run with hot reload: `arelo -p '**/*.go' -i '**/.*' -i '**/*_test.go' -- go run main.go` 6 | - Run all tests: `go test` 7 | - Run single test: `go test -run '^TestName$'` (example: `go test -run '^TestListContexts$'`) 8 | - Generate mocks: `tools/generate-mocks.sh` 9 | - Lint: `golangci-lint run` 10 | 11 | ## Code Style Guidelines 12 | - **Imports**: Standard Go import organization (stdlib, external, internal) 13 | - **Error Handling**: Return errors explicitly; prefer wrapping with context 14 | - **Naming**: Use Go conventions (CamelCase for exported, camelCase for unexported) 15 | - **Testing**: Use YAML test files in testdata directory with foxytest package 16 | - **Types**: Use strong typing; prefer interfaces for dependencies 17 | - **Documentation**: Document all exported functions and types 18 | - **Structure**: Follow k8s-like API structure in internal/k8s package 19 | - **Dependencies**: Use dependency injection with fx framework 20 | - **Context**: Pass kubernetes contexts explicitly as parameters -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | ## Contributing 4 | 5 | I welcome any contributions to this project. Please follow the guidelines below. 6 | 7 | ## Issues 8 | 9 | If you find a bug or have a feature request, please open an issue in Github. If you are able to provide test to reproduce the issue, that would be very helpful. See further testing guidelines below. 10 | 11 | If you would like to help and want ideas for what to work on, please check the issues labelled as [help wanted](https://github.com/strowk/mcp-k8s-go/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22). 12 | 13 | ## Coding 14 | 15 | This project uses Go modules, so you should be able to clone the repository to any location on your machine. 16 | 17 | To build the project, you can run: 18 | 19 | ```bash 20 | go build 21 | ``` 22 | 23 | ## Testing 24 | 25 | This project uses [k3d](https://k3d.io/) to run Kubernetes cluster locally for testing. 26 | As code is written in Go, you would also need to have Go installed. 27 | 28 | To run tests, you need to have `k3d` and `kubectl` installed and working (for which you also need Docker). 29 | 30 | During the test run, a new k3d cluster will be created and then deleted after the tests are finished. In some situations the cluster might not be deleted properly, so you might need to delete it manually by running: 31 | 32 | ```bash 33 | k3d cluster delete mcp-k8s-integration-test 34 | ``` 35 | 36 | To run tests, execute the following command: 37 | 38 | ```bash 39 | go test 40 | ``` 41 | 42 | First run might take longer, as some images are being downloaded. 43 | Consequent runs would still not be very fast, as the whole k3d cluster is being created and deleted for each test run. 44 | 45 | Some limited amount of tests do not require k3d cluster to be running, for example `TestListContexts` and `TestListTools`. 46 | 47 | Here is an example of running only `TestListContexts` test: 48 | 49 | ```bash 50 | go test -run '^TestListContexts$' 51 | ``` 52 | 53 | ### Adding new test 54 | 55 | To describe a test case, this project uses foxytest package with every test being a separate YAML document. 56 | 57 | Check tests in testdata directory for examples: 58 | - [list_tools_test.yaml](testdata/list_tools_test.yaml) - test for listing tools 59 | - [get_k8s_pod_logs_test.yaml](testdata/with_k3d/get_k8s_pod_logs_test.yaml) - test for listing logs of a pod 60 | 61 | These tests are single or multi document YAML files, where each document is a separate test case with name in "case" field, "in" and "out" for input and expected output being jsonrpc2 requests and responses. 62 | 63 | For new tests which are related to one particular resource, files should be located under `internal/k8s///` folder, for example `internal/k8s/apps/v1/deployment`. If you create new such folder, then you would need to add it in the list of test suites in `TestInK3dCluster` function in [main_test.go](main_test.go) file. 64 | 65 | In addition to describing test case, you might need to setup some resources in Kubernetes cluster. 66 | For this you have to place YAML files describing these resources in test suite subfolder called `test_manifests`. For example when tests within `internal/k8s/apps/v1/deployment` package are run, test manifests should be in `internal/k8s/apps/v1/deployment/test_manifests` folder and would be applied to the cluster before that test suite is run. 67 | 68 | ## Linting 69 | 70 | This project uses [golangci-lint](https://golangci-lint.run/) for linting. 71 | Version in use currently is `v2.0.2`. 72 | 73 | Once you have installed linter, you can run it with the following command: 74 | 75 | ```bash 76 | golangci-lint run 77 | ``` 78 | 79 | ## Development 80 | 81 | ### Hot Reloading Setup With Logging 82 | 83 | #### TL;DR 84 | 85 | Install [synf](https://github.com/strowk/synf?tab=readme-ov-file#installation) and mcptee: 86 | 87 | ```bash 88 | go install github.com/strowk/mcptee@latest 89 | ``` 90 | 91 | Then you can use command like this: 92 | 93 | ```bash 94 | mcptee dev.log.yaml synf dev . 95 | ``` 96 | , or if you configure this with some client, probably with full path to the project (replace `C:/work/mcp-k8s-go` with path to where project repository is cloned): 97 | 98 | ```bash 99 | mcptee C:/work/mcp-k8s-go/dev.log.yaml synf dev C:/work/mcp-k8s-go 100 | ``` 101 | 102 | This would be for Claude: 103 | 104 | ```json 105 | { 106 | "mcpServers": { 107 | "mcp_k8s_dev": { 108 | "command": "mcptee", 109 | "args": [ 110 | "C:/work/mcp-k8s-go/dev.log.yaml", 111 | "synf", 112 | "dev", 113 | "C:/work/mcp-k8s-go" 114 | ] 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | #### Long Version 121 | 122 | You can run the project with automatic reload if you firstly install [synf](https://github.com/strowk/synf?tab=readme-ov-file#installation) tool. 123 | 124 | Then simple command 125 | 126 | ```bash 127 | synf dev . 128 | ``` 129 | 130 | would start the project's process, you might need to wait a bit before passing any input, as synf would be building the project. 131 | 132 | Now you could start sending requests to the server, for example this would list available tools: 133 | 134 | ```json 135 | { "jsonrpc": "2.0", "method": "tools/list", "id": 1, "params": {} } 136 | ``` 137 | 138 | If you want output to be prettified, you can use `jq` and start the server like this: 139 | 140 | ```bash 141 | synf dev . | jq 142 | ``` 143 | 144 | Whenever you change any go files, synf would automatically rebuild the project and restart the server, you might need to send some empty lines though to know whether it is up. 145 | Once empty lines would result in error response, you would know that server is up. 146 | 147 | You can also use it with inspector, for example: 148 | 149 | ```bash 150 | npx @modelcontextprotocol/inspector synf dev . 151 | ``` 152 | 153 | , then open url that inspector would print and Connect to the server, you would have inspector UI to send requests and see responses, while at the same time having automatic reload of the server on any code changes. 154 | 155 | Command (example path for windows) in order to use it from any location (useful for providing to any clients, which are part of another program): 156 | 157 | ```bash 158 | mcptee log.yaml synf dev C:/work/mcp-k8s-go 159 | ``` 160 | 161 | Synf would make sure that client receives list_update notification whenever the server is restarted, which should make clients that support this to pick it up automatically. 162 | 163 | If you would also like to capture communication between the server and client, you can use `mcptee` tool. 164 | You can install it with `go install github.com/strowk/mcptee@latest` command. 165 | 166 | `mcptee` would capture all the communication between the server and client, and write it to a YAML file to use for debugging. 167 | 168 | For example: `mcptee log.yaml synf dev .` would start server with hot reloading and logging to `log.yaml` file. 169 | 170 | ## Git Hooks 171 | 172 | Setup git hooks used in project by running: 173 | 174 | ```bash 175 | git config core.hooksPath .githooks 176 | ``` 177 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static-debian12 2 | USER nonroot:nonroot 3 | COPY --chown=nonroot:nonroot mcp-k8s-go / 4 | ENTRYPOINT ["/mcp-k8s-go"] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Timur Sultanaev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Golang-based MCP server connecting to Kubernetes

2 | 3 |

4 | 5 |
6 | MCP K8S Go 7 |

8 | 9 |

10 | Features ⚙ 11 | Browse With Inspector ⚙ 12 | Use With Claude ⚙ 13 | Contributing ↗ ⚙ 14 | About MCP ↗ 15 |

16 | 17 |

18 | 19 | 20 | 21 |
22 | latest release badge 23 | npm downloads badge 24 | Go Reference 25 | license badge 26 |

27 | 28 | ## Features 29 | 30 | MCP 💬 prompt 🗂️ resource 🤖 tool 31 | 32 | - 🗂️🤖 List Kubernetes contexts 33 | - 💬🤖 List Kubernetes namespaces 34 | - 🤖 List and get any Kubernetes resources 35 | - includes custom mappings for resources like pods, services, deployments, but any resource can be listed and retrieved 36 | - 🤖 List Kubernetes nodes 37 | - 💬 List Kubernetes pods 38 | - 🤖 Get Kubernetes events 39 | - 🤖 Get Kubernetes pod logs 40 | - 🤖 Run command in Kubernetes pod 41 | 42 | ## Browse With Inspector 43 | 44 | To use latest published version with Inspector you can run this: 45 | 46 | ```bash 47 | npx @modelcontextprotocol/inspector npx @strowk/mcp-k8s 48 | ``` 49 | 50 | ## Use With Claude 51 | 52 |
53 | Demo Usage 54 | 55 | 56 | Following chat with Claude Desktop demonstrates how it looks when selected particular context as a resource and then asked to check pod logs for errors in kube-system namespace: 57 | 58 | ![Claude Desktop](docs/images/claude-desktop-logs.png) 59 | 60 |
61 | 62 | To use this MCP server with Claude Desktop (or any other client) you might need to choose which way of installation to use. 63 | 64 | You have multiple options: 65 | 66 | | | Smithery | mcp-get | Pre-built NPM | Pre-built in Github | From sources | Using Docker | 67 | | ------------ | -------------------------------------- | ------------------------------------ | ---------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------ | ---------------------------------------- | 68 | | Claude Setup | Auto | Auto | Manual | Manual | Manual | Manual | 69 | | Prerequisite | Node.js | Node.js | Node.js | None | Golang | Docker | 70 | 71 | ### Using Smithery 72 | 73 | To install MCP K8S Go for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@strowk/mcp-k8s): 74 | 75 | ```bash 76 | npx -y @smithery/cli install @strowk/mcp-k8s --client claude 77 | ``` 78 | 79 | ### Using mcp-get 80 | 81 | To install MCP K8S Go for Claude Desktop automatically via [mcp-get](https://mcp-get.com/packages/%40strowk%2Fmcp-k8s): 82 | 83 | ```bash 84 | npx @michaellatman/mcp-get@latest install @strowk/mcp-k8s 85 | ``` 86 | 87 | ### Manually with prebuilt binaries 88 | 89 | #### Prebuilt from npm 90 | 91 | Use this if you have npm installed and want to use pre-built binaries: 92 | 93 | ```bash 94 | npm install -g @strowk/mcp-k8s 95 | ``` 96 | 97 | Then check version by running `mcp-k8s --version` and if this printed installed version, you can proceed to add configuration to `claude_desktop_config.json` file: 98 | 99 | ```json 100 | { 101 | "mcpServers": { 102 | "mcp_k8s": { 103 | "command": "mcp-k8s", 104 | "args": [] 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | , or using `npx` with any client: 111 | 112 | ```bash 113 | npx @strowk/mcp-k8s 114 | ``` 115 | 116 | For example for Claude: 117 | 118 | ```json 119 | { 120 | "mcpServers": { 121 | "mcp_k8s": { 122 | "command": "npx", 123 | "args": [ 124 | "@strowk/mcp-k8s" 125 | ] 126 | } 127 | } 128 | } 129 | ``` 130 | 131 | #### From GitHub releases 132 | 133 | Head to [GitHub releases](https://github.com/strowk/mcp-k8s-go/releases) and download the latest release for your platform. 134 | 135 | Unpack the archive, which would contain binary named `mcp-k8s-go`, put that binary somewhere in your PATH and then add the following configuration to the `claude_desktop_config.json` file: 136 | 137 | ```json 138 | { 139 | "mcpServers": { 140 | "mcp_k8s": { 141 | "command": "mcp-k8s-go", 142 | "args": [] 143 | } 144 | } 145 | } 146 | ``` 147 | 148 | ### Building from source 149 | 150 | You would need Golang installed to build this project: 151 | 152 | ```bash 153 | go get github.com/strowk/mcp-k8s-go 154 | go install github.com/strowk/mcp-k8s-go 155 | ``` 156 | 157 | , and then add the following configuration to the `claude_desktop_config.json` file: 158 | 159 | ```json 160 | { 161 | "mcpServers": { 162 | "mcp_k8s_go": { 163 | "command": "mcp-k8s-go", 164 | "args": [] 165 | } 166 | } 167 | } 168 | ``` 169 | 170 | ### Using Docker 171 | 172 | This server is built and published to Docker Hub since 0.3.1-beta.2 release with multi-arch images available for linux/amd64 and linux/arm64 architectures. 173 | 174 | You can use latest tag f.e like this: 175 | 176 | ```bash 177 | docker run -i -v ~/.kube/config:/home/nonroot/.kube/config --rm mcpk8s/server:latest 178 | ``` 179 | 180 | Windows users might need to replace `~/.kube/config` with `//c/Users//.kube/config` at least in Git Bash. 181 | 182 | For Claude: 183 | 184 | ```json 185 | { 186 | "mcpServers": { 187 | "mcp_k8s_go": { 188 | "command": "docker", 189 | "args": [ 190 | "run", 191 | "-i", 192 | "-v", 193 | "~/.kube/config:/home/nonroot/.kube/config", 194 | "--rm", 195 | "mcpk8s/server:latest" 196 | ] 197 | } 198 | } 199 | } 200 | ``` 201 | 202 | ### Environment Variables and Command-line Options 203 | 204 | The following environment variables are used by the MCP server: 205 | 206 | - `KUBECONFIG`: Path to your Kubernetes configuration file (optional, defaults to ~/.kube/config) 207 | 208 | The following command-line options are supported: 209 | 210 | - `--allowed-contexts=`: Comma-separated list of allowed Kubernetes contexts that users can access. If not specified, all contexts are allowed. 211 | - `--help`: Display help information 212 | - `--version`: Display version information 213 | -------------------------------------------------------------------------------- /docs/images/claude-desktop-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strowk/mcp-k8s-go/1667e8e3b18ef10f592b820c371eb2495c03152e/docs/images/claude-desktop-logs.png -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strowk/mcp-k8s-go/1667e8e3b18ef10f592b820c371eb2495c03152e/docs/images/logo.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/strowk/mcp-k8s-go 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/stretchr/testify v1.10.0 7 | github.com/strowk/foxy-contexts v0.1.0-beta.5 8 | go.uber.org/fx v1.24.0 9 | go.uber.org/mock v0.5.2 10 | go.uber.org/zap v1.27.0 11 | k8s.io/api v0.33.1 12 | k8s.io/apimachinery v0.33.1 13 | k8s.io/client-go v0.33.1 14 | ) 15 | 16 | // Can use this to develop a bit faster when changing the library: 17 | // replace github.com/strowk/foxy-contexts => ../foxy-contexts 18 | 19 | require ( 20 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 21 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 22 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 23 | github.com/go-logr/logr v1.4.2 // indirect 24 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 25 | github.com/go-openapi/jsonreference v0.20.2 // indirect 26 | github.com/go-openapi/swag v0.23.0 // indirect 27 | github.com/gogo/protobuf v1.3.2 // indirect 28 | github.com/google/gnostic-models v0.6.9 // indirect 29 | github.com/google/go-cmp v0.7.0 // indirect 30 | github.com/google/uuid v1.6.0 // indirect 31 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 32 | github.com/josharian/intern v1.0.0 // indirect 33 | github.com/json-iterator/go v1.1.12 // indirect 34 | github.com/labstack/echo/v4 v4.12.0 // indirect 35 | github.com/labstack/gommon v0.4.2 // indirect 36 | github.com/mailru/easyjson v0.7.7 // indirect 37 | github.com/mattn/go-colorable v0.1.13 // indirect 38 | github.com/mattn/go-isatty v0.0.20 // indirect 39 | github.com/moby/spdystream v0.5.0 // indirect 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 41 | github.com/modern-go/reflect2 v1.0.2 // indirect 42 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 43 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 44 | github.com/pkg/errors v0.9.1 // indirect 45 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 46 | github.com/spf13/pflag v1.0.5 // indirect 47 | github.com/valyala/bytebufferpool v1.0.0 // indirect 48 | github.com/valyala/fasttemplate v1.2.2 // indirect 49 | github.com/x448/float16 v0.8.4 // indirect 50 | go.uber.org/dig v1.19.0 // indirect 51 | go.uber.org/multierr v1.10.0 // indirect 52 | golang.org/x/crypto v0.36.0 // indirect 53 | golang.org/x/net v0.38.0 // indirect 54 | golang.org/x/oauth2 v0.27.0 // indirect 55 | golang.org/x/sys v0.31.0 // indirect 56 | golang.org/x/term v0.30.0 // indirect 57 | golang.org/x/text v0.23.0 // indirect 58 | golang.org/x/time v0.9.0 // indirect 59 | google.golang.org/protobuf v1.36.5 // indirect 60 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 61 | gopkg.in/inf.v0 v0.9.1 // indirect 62 | gopkg.in/yaml.v3 v3.0.1 // indirect 63 | k8s.io/klog/v2 v2.130.1 // indirect 64 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 65 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 66 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 67 | sigs.k8s.io/randfill v1.0.0 // indirect 68 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 69 | sigs.k8s.io/yaml v1.4.0 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 2 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 9 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 10 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 11 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 12 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 13 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 14 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 15 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 16 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 17 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 18 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 19 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 20 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 21 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 22 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 23 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 24 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 25 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 26 | github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= 27 | github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 28 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 29 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 30 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 31 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 32 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 33 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 34 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 35 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 36 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 37 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 38 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 39 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 40 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 41 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 42 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 43 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 44 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 45 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 46 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 47 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 48 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 49 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 50 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 51 | github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= 52 | github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= 53 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 54 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 55 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 56 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 57 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 58 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 59 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 60 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 61 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 62 | github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= 63 | github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= 64 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 65 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 66 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 67 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 68 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 69 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 70 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 71 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= 72 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= 73 | github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= 74 | github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 75 | github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= 76 | github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 77 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 78 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 79 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 80 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 81 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 82 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 83 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 84 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 85 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 86 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 87 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 88 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 89 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 90 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 91 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 92 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 93 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 94 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 95 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 96 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 97 | github.com/strowk/foxy-contexts v0.1.0-beta.5 h1:Jizc8LfhRws0JpvDuWbHcKQhodTgQdvTjTnUnEUnuiU= 98 | github.com/strowk/foxy-contexts v0.1.0-beta.5/go.mod h1:Xcg+JP0aJ18RhSl3oGMyptbiSVNC0cxlAY452t8uWG4= 99 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 100 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 101 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 102 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 103 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 104 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 105 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 106 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 107 | go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= 108 | go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= 109 | go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= 110 | go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= 111 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 112 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 113 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= 114 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= 115 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 116 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 117 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 118 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 119 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 120 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 121 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 122 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 123 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 124 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 125 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 126 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 127 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 128 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 129 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 130 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 131 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 132 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 133 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 134 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 135 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 136 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 137 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 138 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 139 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 140 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 141 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 142 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 143 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 144 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 145 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 146 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 147 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 148 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 149 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 150 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 151 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 152 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 153 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 154 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 155 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 156 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 157 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 158 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 159 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 160 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 161 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 162 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 163 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 164 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 165 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 166 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 167 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 168 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 169 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 170 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 171 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 172 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 173 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 174 | k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= 175 | k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= 176 | k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= 177 | k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= 178 | k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= 179 | k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= 180 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 181 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 182 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= 183 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= 184 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= 185 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 186 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= 187 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= 188 | sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 189 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 190 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 191 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= 192 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 193 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 194 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 195 | -------------------------------------------------------------------------------- /internal/config/options.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "strings" 6 | ) 7 | 8 | // Options represents the global configuration options 9 | type Options struct { 10 | // AllowedContexts is a list of k8s contexts that users are allowed to access 11 | // If empty, all contexts are allowed 12 | AllowedContexts []string 13 | } 14 | 15 | // GlobalOptions contains the parsed command line options 16 | var GlobalOptions = &Options{} 17 | 18 | // ParseFlags parses the command line flags 19 | func ParseFlags() bool { 20 | var allowedContextsStr string 21 | flag.StringVar(&allowedContextsStr, "allowed-contexts", "", "Comma-separated list of allowed k8s contexts. If empty, all contexts are allowed") 22 | 23 | // Add other flags here 24 | 25 | // Parse the flags 26 | flag.Parse() 27 | 28 | // Check if the flag is --version, version, help or --help 29 | // If so, we don't need to continue processing 30 | if len(flag.Args()) > 0 { 31 | arg := flag.Args()[0] 32 | if arg == "--version" || arg == "version" || arg == "help" || arg == "--help" { 33 | return false 34 | } 35 | } 36 | 37 | // Process allowed contexts 38 | if allowedContextsStr != "" { 39 | GlobalOptions.AllowedContexts = strings.Split(allowedContextsStr, ",") 40 | for i, ctx := range GlobalOptions.AllowedContexts { 41 | GlobalOptions.AllowedContexts[i] = strings.TrimSpace(ctx) 42 | } 43 | } 44 | 45 | return true 46 | } 47 | 48 | // IsContextAllowed checks if a context is allowed based on the configuration 49 | func IsContextAllowed(contextName string) bool { 50 | // If the allowed contexts list is empty, all contexts are allowed 51 | if len(GlobalOptions.AllowedContexts) == 0 { 52 | return true 53 | } 54 | 55 | for _, allowed := range GlobalOptions.AllowedContexts { 56 | if allowed == contextName { 57 | return true 58 | } 59 | } 60 | 61 | return false 62 | } 63 | -------------------------------------------------------------------------------- /internal/config/options_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsContextAllowed(t *testing.T) { 8 | // Reset the global options between tests 9 | defer func() { 10 | GlobalOptions = &Options{} 11 | }() 12 | 13 | tests := []struct { 14 | name string 15 | allowedContexts []string 16 | contextName string 17 | expected bool 18 | }{ 19 | { 20 | name: "all contexts allowed when no restrictions", 21 | allowedContexts: []string{}, 22 | contextName: "any-context", 23 | expected: true, 24 | }, 25 | { 26 | name: "context explicitly allowed", 27 | allowedContexts: []string{"context-1", "context-2"}, 28 | contextName: "context-1", 29 | expected: true, 30 | }, 31 | { 32 | name: "context not allowed", 33 | allowedContexts: []string{"context-1", "context-2"}, 34 | contextName: "context-3", 35 | expected: false, 36 | }, 37 | } 38 | 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | // Setup 42 | GlobalOptions.AllowedContexts = tt.allowedContexts 43 | 44 | // Test 45 | result := IsContextAllowed(tt.contextName) 46 | 47 | // Verify 48 | if result != tt.expected { 49 | t.Errorf("IsContextAllowed(%q) = %v, want %v", 50 | tt.contextName, result, tt.expected) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/content/json.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/strowk/foxy-contexts/pkg/mcp" 7 | ) 8 | 9 | func NewJsonContent(v any) (mcp.TextContent, error) { 10 | contents, err := json.Marshal(v) 11 | if err != nil { 12 | return mcp.TextContent{}, err 13 | } 14 | return mcp.TextContent{ 15 | Type: "text", 16 | Text: string(contents), 17 | }, nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/k8s/apps/v1/deployment/get_deployment_test.yaml: -------------------------------------------------------------------------------- 1 | case: Get k8s deployment 2 | in: 3 | { 4 | "jsonrpc": "2.0", 5 | "method": "tools/call", 6 | "id": 2, 7 | "params": 8 | { 9 | "name": "get-k8s-resource", 10 | "arguments": 11 | { 12 | "context": "k3d-mcp-k8s-integration-test", 13 | "namespace": "test-deployment", 14 | "kind": "deployment", 15 | "version": "v1", 16 | "group": "apps", 17 | "name": "nginx-deployment", 18 | }, 19 | }, 20 | } 21 | out: 22 | # we only check response parially, because there is too much various data there 23 | # and we will not benefit much from verifying all of it, so we only check the beginning 24 | # where metadata has name and namespace of the resource, if more detailed check is needed 25 | # it should be done in unit test where assertions could be made more dynamically in Go 26 | 27 | { 28 | "jsonrpc": "2.0", 29 | "id": 2, 30 | "result": 31 | { 32 | "content": 33 | [ 34 | { 35 | "type": "text", 36 | 37 | "text": !!ere '{"apiVersion":"apps\\/v1","kind":"Deployment","metadata":{/.*/"name":"nginx-deployment","namespace":"test-deployment"/.*/"replicas":0/.*/}', 38 | }, 39 | ], 40 | "isError": false, 41 | }, 42 | } 43 | 44 | --- 45 | case: Get k8s deployment name 46 | in: 47 | { 48 | "jsonrpc": "2.0", 49 | "method": "tools/call", 50 | "id": 2, 51 | "params": 52 | { 53 | "name": "get-k8s-resource", 54 | "arguments": 55 | { 56 | "context": "k3d-mcp-k8s-integration-test", 57 | "namespace": "test-deployment", 58 | "kind": "deployment", 59 | "name": "nginx-deployment", 60 | "go_template": "{{ .metadata.name }}", 61 | }, 62 | }, 63 | } 64 | out: 65 | { 66 | "jsonrpc": "2.0", 67 | "id": 2, 68 | "result": 69 | { 70 | "content": [{ "type": "text", "text": "nginx-deployment" }], 71 | "isError": false, 72 | }, 73 | } 74 | -------------------------------------------------------------------------------- /internal/k8s/apps/v1/deployment/list_deployments.go: -------------------------------------------------------------------------------- 1 | package deployment 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/strowk/mcp-k8s-go/internal/k8s/list_mapping" 7 | "github.com/strowk/mcp-k8s-go/internal/utils" 8 | 9 | appsv1 "k8s.io/api/apps/v1" 10 | "k8s.io/apimachinery/pkg/runtime" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | ) 13 | 14 | // DeploymentInList provides a structured representation of Deployment information 15 | type DeploymentInList struct { 16 | Name string `json:"name"` 17 | Namespace string `json:"namespace"` 18 | Age string `json:"age"` 19 | DesiredReplicas int `json:"desired_replicas"` 20 | ReadyReplicas int `json:"ready_replicas"` 21 | UpdatedReplicas int `json:"updated_replicas"` 22 | AvailableReplicas int `json:"available_replicas"` 23 | CreatedAt string `json:"created_at"` 24 | } 25 | 26 | func (d *DeploymentInList) GetName() string { 27 | return d.Name 28 | } 29 | 30 | func (d *DeploymentInList) GetNamespace() string { 31 | return d.Namespace 32 | } 33 | 34 | func NewDeploymentInList(deployment *appsv1.Deployment) *DeploymentInList { 35 | // Calculate age 36 | age := time.Since(deployment.CreationTimestamp.Time) 37 | 38 | // Extract deployment status information 39 | desiredReplicas := int(*(deployment.Spec.Replicas)) 40 | readyReplicas := deployment.Status.ReadyReplicas 41 | updatedReplicas := deployment.Status.UpdatedReplicas 42 | availableReplicas := deployment.Status.AvailableReplicas 43 | 44 | return &DeploymentInList{ 45 | Name: deployment.Name, 46 | Namespace: deployment.Namespace, 47 | Age: utils.FormatAge(age), 48 | DesiredReplicas: desiredReplicas, 49 | ReadyReplicas: int(readyReplicas), 50 | UpdatedReplicas: int(updatedReplicas), 51 | AvailableReplicas: int(availableReplicas), 52 | CreatedAt: deployment.CreationTimestamp.Format(time.RFC3339), 53 | } 54 | } 55 | 56 | func getDeploymentListMapping() list_mapping.ListMapping { 57 | return func(u runtime.Unstructured) (list_mapping.ListContentItem, error) { 58 | dep := appsv1.Deployment{} 59 | err := runtime.DefaultUnstructuredConverter.FromUnstructuredWithValidation(u.UnstructuredContent(), &dep, false) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return NewDeploymentInList(&dep), nil 64 | } 65 | } 66 | 67 | type listMappingResolver struct{} 68 | 69 | func (l *listMappingResolver) GetListMapping(gvk *schema.GroupVersionKind) list_mapping.ListMapping { 70 | if gvk.Group == "apps" && gvk.Version == "v1" && gvk.Kind == "Deployment" { 71 | return getDeploymentListMapping() 72 | } 73 | return nil 74 | } 75 | 76 | func NewListMappingResolver() list_mapping.ListMappingResolver { 77 | return &listMappingResolver{} 78 | } 79 | -------------------------------------------------------------------------------- /internal/k8s/apps/v1/deployment/list_deployments_test.yaml: -------------------------------------------------------------------------------- 1 | case: List k8s deployments using tool 2 | in: 3 | { 4 | "jsonrpc": "2.0", 5 | "method": "tools/call", 6 | "id": 2, 7 | "params": 8 | { 9 | "name": "list-k8s-resources", 10 | "arguments": 11 | { 12 | "context": "k3d-mcp-k8s-integration-test", 13 | "namespace": "test-deployment", 14 | "version": "v1", 15 | "group": "apps", 16 | "kind": "deployment", 17 | }, 18 | }, 19 | } 20 | out: 21 | { 22 | "jsonrpc": "2.0", 23 | "id": 2, 24 | "result": 25 | { 26 | "content": 27 | [ 28 | { 29 | "type": "text", 30 | "text": !!ere '{"name":"nginx-deployment","namespace":"test-deployment","age":"/[0-9sm]+/","desired_replicas":0,"ready_replicas":0,"updated_replicas":0,"available_replicas":0,"created_at":"/.+/"}', 31 | }, 32 | ], 33 | "isError": false, 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /internal/k8s/apps/v1/deployment/test_manifests/test_deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: nginx-deployment 5 | namespace: test-deployment 6 | labels: 7 | app: nginx 8 | spec: 9 | # for the purpose of testing how we work with the deployment 10 | # we only need to have the deployment itself, not the pods, 11 | # so we set replicas to 0 12 | replicas: 0 13 | selector: 14 | matchLabels: 15 | app: nginx 16 | template: 17 | metadata: 18 | labels: 19 | app: nginx 20 | spec: 21 | securityContext: 22 | runAsNonRoot: true 23 | containers: 24 | - name: nginx 25 | image: nginx:1.14.2 26 | ports: 27 | - containerPort: 80 28 | securityContext: 29 | allowPrivilegeEscalation: false 30 | -------------------------------------------------------------------------------- /internal/k8s/client.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "github.com/strowk/mcp-k8s-go/internal/config" 5 | "k8s.io/client-go/kubernetes" 6 | "k8s.io/client-go/tools/clientcmd" 7 | ) 8 | 9 | func GetKubeConfig() clientcmd.ClientConfig { 10 | loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 11 | kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, nil) 12 | return kubeConfig 13 | } 14 | 15 | func GetKubeConfigForContext(k8sContext string) clientcmd.ClientConfig { 16 | loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 17 | configOverrides := &clientcmd.ConfigOverrides{} 18 | if k8sContext == "" { 19 | configOverrides = nil 20 | } else { 21 | configOverrides.CurrentContext = k8sContext 22 | } 23 | 24 | return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 25 | loadingRules, 26 | configOverrides, 27 | ) 28 | } 29 | 30 | func GetCurrentContext() (string, error) { 31 | kubeConfig := GetKubeConfig() 32 | config, err := kubeConfig.RawConfig() 33 | if err != nil { 34 | return "", err 35 | } 36 | return config.CurrentContext, nil 37 | } 38 | 39 | func GetKubeClientset() (*kubernetes.Clientset, error) { 40 | kubeConfig := GetKubeConfig() 41 | 42 | config, err := kubeConfig.ClientConfig() 43 | if err != nil { 44 | return nil, err 45 | } 46 | clientset, err := kubernetes.NewForConfig(config) 47 | 48 | if err != nil { 49 | return nil, err 50 | } 51 | return clientset, nil 52 | } 53 | 54 | // IsContextAllowed checks if a context is allowed based on the configuration 55 | func IsContextAllowed(contextName string) bool { 56 | return config.IsContextAllowed(contextName) 57 | } 58 | -------------------------------------------------------------------------------- /internal/k8s/core/v1/node/get_node_test.yaml: -------------------------------------------------------------------------------- 1 | case: Get k8s node using get-k8s-resources tool 2 | in: 3 | { 4 | "jsonrpc": "2.0", 5 | "method": "tools/call", 6 | "id": 2, 7 | "params": 8 | { 9 | "name": "get-k8s-resource", 10 | "arguments": 11 | { 12 | "context": "k3d-mcp-k8s-integration-test", 13 | "kind": "node", 14 | "name": "k3d-mcp-k8s-integration-test-server-0", 15 | }, 16 | }, 17 | } 18 | out: 19 | { 20 | "jsonrpc": "2.0", 21 | "id": 2, 22 | "result": 23 | { 24 | "content": 25 | [ 26 | { 27 | "type": "text", 28 | "text": !!ere '{"apiVersion":"v1","kind":"Node","metadata":{/.*/"name":"k3d-mcp-k8s-integration-test-server-0"/.*/', 29 | }, 30 | ], 31 | "isError": false, 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /internal/k8s/core/v1/node/list_nodes_test.yaml: -------------------------------------------------------------------------------- 1 | case: List k8s nodes using list-k8s-resources tool 2 | in: 3 | { 4 | "jsonrpc": "2.0", 5 | "method": "tools/call", 6 | "id": 2, 7 | "params": 8 | { 9 | "name": "list-k8s-resources", 10 | "arguments": 11 | { "context": "k3d-mcp-k8s-integration-test", "kind": "node" }, 12 | }, 13 | } 14 | out: 15 | { 16 | "jsonrpc": "2.0", 17 | "id": 2, 18 | "result": 19 | { 20 | "content": ["text": '{"name":"k3d-mcp-k8s-integration-test-server-0"}'], 21 | "isError": false, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /internal/k8s/core/v1/pod/get_pod_test.yaml: -------------------------------------------------------------------------------- 1 | case: Get k8s pod using tool 2 | in: 3 | { 4 | "jsonrpc": "2.0", 5 | "method": "tools/call", 6 | "id": 2, 7 | "params": 8 | { 9 | "name": "get-k8s-resource", 10 | "arguments": 11 | { 12 | "context": "k3d-mcp-k8s-integration-test", 13 | "namespace": "test", 14 | "kind": "pod", 15 | "name": "nginx", 16 | }, 17 | }, 18 | } 19 | out: 20 | { 21 | "jsonrpc": "2.0", 22 | "id": 2, 23 | "result": 24 | { 25 | "content": 26 | [ 27 | { 28 | "type": "text", 29 | "text": !!ere '{"apiVersion":"v1","kind":"Pod","metadata":{/.*/"name":"nginx","namespace":"test"/.*/', 30 | }, 31 | ], 32 | "isError": false, 33 | }, 34 | } 35 | 36 | --- 37 | case: Get k8s pod dnsPolicy 38 | in: 39 | { 40 | "jsonrpc": "2.0", 41 | "method": "tools/call", 42 | "id": 2, 43 | "params": 44 | { 45 | "name": "get-k8s-resource", 46 | "arguments": 47 | { 48 | "context": "k3d-mcp-k8s-integration-test", 49 | "namespace": "test", 50 | "kind": "pod", 51 | "name": "nginx", 52 | "go_template": "{{ .spec.dnsPolicy }}", 53 | }, 54 | }, 55 | } 56 | out: 57 | { 58 | "jsonrpc": "2.0", 59 | "id": 2, 60 | "result": 61 | { "content": [{ "type": "text", "text": "ClusterFirst" }], "isError": false }, 62 | } 63 | -------------------------------------------------------------------------------- /internal/k8s/core/v1/pod/list_pods_test.yaml: -------------------------------------------------------------------------------- 1 | case: List k8s pods using tool 2 | in: 3 | { 4 | "jsonrpc": "2.0", 5 | "method": "tools/call", 6 | "id": 2, 7 | "params": 8 | { 9 | "name": "list-k8s-resources", 10 | "arguments": 11 | { "context": "k3d-mcp-k8s-integration-test", "namespace": "test", "kind": "pod" }, 12 | }, 13 | } 14 | out: 15 | { 16 | "jsonrpc": "2.0", 17 | "id": 2, 18 | "result": 19 | { 20 | "content": 21 | [ 22 | { "type": "text", "text": '{"name":"busybox","namespace":"test"}' }, 23 | { "type": "text", "text": '{"name":"nginx","namespace":"test"}' }, 24 | ], 25 | "isError": false, 26 | }, 27 | } 28 | 29 | --- 30 | case: List k8s pods using prompt 31 | 32 | in: 33 | { 34 | "jsonrpc": "2.0", 35 | "method": "prompts/get", 36 | "id": 3, 37 | "params": 38 | { 39 | "name": "list-k8s-pods", 40 | "arguments": { "namespace": "test" }, 41 | }, 42 | } 43 | 44 | out: 45 | { 46 | "jsonrpc": "2.0", 47 | "id": 3, 48 | "result": 49 | { 50 | "description": "Pods in namespace 'test', context 'k3d-mcp-k8s-integration-test'", 51 | "messages": 52 | [ 53 | { 54 | "content": 55 | { 56 | "type": "text", 57 | "text": "There are 2 pods in namespace 'test':", 58 | }, 59 | "role": "user", 60 | }, 61 | { 62 | "content": 63 | { 64 | "type": "text", 65 | "text": '{"name":"busybox","namespace":"test"}', 66 | }, 67 | "role": "user", 68 | }, 69 | { 70 | "content": 71 | { 72 | "type": "text", 73 | "text": '{"name":"nginx","namespace":"test"}', 74 | }, 75 | "role": "user", 76 | }, 77 | ], 78 | }, 79 | } 80 | -------------------------------------------------------------------------------- /internal/k8s/core/v1/pod/pod_exec_test.yaml: -------------------------------------------------------------------------------- 1 | case: Execute command in busybox pod 2 | 3 | in: 4 | { 5 | "jsonrpc": "2.0", 6 | "method": "tools/call", 7 | "id": 2, 8 | "params": 9 | { 10 | "name": "k8s-pod-exec", 11 | "arguments": 12 | { 13 | "context": "k3d-mcp-k8s-integration-test", 14 | "namespace": "test", 15 | "pod": "busybox", 16 | "command": "echo HELLO FROM BUSYBOX", 17 | }, 18 | }, 19 | } 20 | out: 21 | { 22 | "jsonrpc": "2.0", 23 | "id": 2, 24 | "result": 25 | { "content": [{ "type": "text", "text": '{"stdout":"HELLO FROM BUSYBOX\n","stderr":""}' }], "isError": false }, 26 | } 27 | -------------------------------------------------------------------------------- /internal/k8s/core/v1/service/list_services.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/strowk/mcp-k8s-go/internal/k8s/list_mapping" 7 | corev1 "k8s.io/api/core/v1" 8 | "k8s.io/apimachinery/pkg/runtime" 9 | "k8s.io/apimachinery/pkg/runtime/schema" 10 | ) 11 | 12 | type ServiceContent struct { 13 | Name string `json:"name"` 14 | Namespace string `json:"namespace"` 15 | Type string `json:"type"` 16 | ClusterIP string `json:"clusterIP"` 17 | ExternalIPs []string `json:"externalIPs"` 18 | Ports []string `json:"ports"` 19 | } 20 | 21 | func NewServiceContent(service *corev1.Service) *ServiceContent { 22 | serviceContent := &ServiceContent{ 23 | Name: service.Name, 24 | Namespace: service.Namespace, 25 | Type: string(service.Spec.Type), 26 | ClusterIP: service.Spec.ClusterIP, 27 | ExternalIPs: service.Spec.ExternalIPs, 28 | Ports: []string{}, 29 | } 30 | 31 | for _, port := range service.Spec.Ports { 32 | // this is done similarly to kubectl get services 33 | // see https://kubernetes.io/docs/reference/generated/kubectl/kubectl-commands#-em-service-em- 34 | serviceContent.Ports = append(serviceContent.Ports, fmt.Sprintf("%d/%s", port.Port, port.Protocol)) 35 | } 36 | 37 | return serviceContent 38 | } 39 | 40 | func (s *ServiceContent) GetName() string { 41 | return s.Name 42 | } 43 | 44 | func (s *ServiceContent) GetNamespace() string { 45 | return s.Namespace 46 | } 47 | 48 | func getServiceListMapping() list_mapping.ListMapping { 49 | return func(u runtime.Unstructured) (list_mapping.ListContentItem, error) { 50 | svc := corev1.Service{} 51 | err := runtime.DefaultUnstructuredConverter.FromUnstructuredWithValidation(u.UnstructuredContent(), &svc, false) 52 | if err != nil { 53 | return nil, err 54 | } 55 | return NewServiceContent(&svc), nil 56 | } 57 | } 58 | 59 | type listMappingResolver struct { 60 | list_mapping.ListMappingResolver 61 | } 62 | 63 | func (r *listMappingResolver) GetListMapping(gvk *schema.GroupVersionKind) list_mapping.ListMapping { 64 | if (gvk.Group == "core" || gvk.Group == "") && gvk.Version == "v1" && gvk.Kind == "Service" { 65 | return getServiceListMapping() 66 | } 67 | return nil 68 | } 69 | 70 | func NewListMappingResolver() list_mapping.ListMappingResolver { 71 | return &listMappingResolver{} 72 | } 73 | -------------------------------------------------------------------------------- /internal/k8s/core/v1/service/list_services_test.yaml: -------------------------------------------------------------------------------- 1 | in: 2 | { 3 | "jsonrpc": "2.0", 4 | "method": "tools/call", 5 | "id": 2, 6 | "params": 7 | { 8 | "name": "list-k8s-resources", 9 | "arguments": 10 | { 11 | "context": "k3d-mcp-k8s-integration-test", 12 | "namespace": "test", 13 | "kind": "service", 14 | }, 15 | }, 16 | } 17 | out: 18 | { 19 | "jsonrpc": "2.0", 20 | "id": 2, 21 | "result": 22 | { 23 | "content": 24 | [ 25 | { 26 | "type": "text", 27 | "text": '{"name":"nginx-headless","namespace":"test","type":"ClusterIP","clusterIP":"None","externalIPs":null,"ports":["80/TCP"]}', 28 | }, 29 | ], 30 | "isError": false, 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /internal/k8s/list_mapping/interface.go: -------------------------------------------------------------------------------- 1 | package list_mapping 2 | 3 | import ( 4 | "go.uber.org/fx" 5 | "k8s.io/apimachinery/pkg/runtime" 6 | "k8s.io/apimachinery/pkg/runtime/schema" 7 | ) 8 | 9 | type ListMapping func(u runtime.Unstructured) (ListContentItem, error) 10 | 11 | type ListContentItem interface { 12 | GetName() string 13 | GetNamespace() string 14 | } 15 | 16 | type ListMappingResolver interface { 17 | GetListMapping(gvk *schema.GroupVersionKind) ListMapping 18 | } 19 | 20 | const ( 21 | MappingResolversTag = `group:"list_mapping_resolvers"` 22 | ) 23 | 24 | func AsMappingResolver(f any) any { 25 | return fx.Annotate( 26 | f, 27 | fx.As(new(ListMappingResolver)), fx.ResultTags(MappingResolversTag), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /internal/k8s/list_mappings.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/strowk/mcp-k8s-go/internal/k8s/list_mapping" 7 | ) 8 | 9 | func (p *pool) GetListMapping(k8sCtx, kind, group, version string) list_mapping.ListMapping { 10 | p.getInformerMutex.Lock() 11 | defer p.getInformerMutex.Unlock() 12 | key := fmt.Sprintf("%s/%s/%s/%s", k8sCtx, kind, group, version) 13 | res, ok := p.keyToResource[key] 14 | if ok { 15 | if res.listMapping == nil { 16 | mapping := findListMapping(p, res) 17 | if mapping != nil { 18 | // mapping for the same resource is not expected to change 19 | // , so we can cache it here to avoid finding it again later 20 | res.listMapping = mapping 21 | return mapping 22 | } else { 23 | return nil 24 | } 25 | } 26 | 27 | } 28 | return nil 29 | } 30 | 31 | func findListMapping(p *pool, res *resolvedResource) list_mapping.ListMapping { 32 | for _, resolver := range p.listMappingResolvers { 33 | mapping := resolver.GetListMapping(res.gvk) 34 | if mapping != nil { 35 | return mapping 36 | } 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/k8s/mock/pool_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/k8s/pool.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=internal/k8s/pool.go -destination=internal/k8s/mock/pool_mock.go 7 | // 8 | 9 | // Package mock_k8s is a generated GoMock package. 10 | package mock_k8s 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | list_mapping "github.com/strowk/mcp-k8s-go/internal/k8s/list_mapping" 16 | gomock "go.uber.org/mock/gomock" 17 | informers "k8s.io/client-go/informers" 18 | kubernetes "k8s.io/client-go/kubernetes" 19 | ) 20 | 21 | // MockClientPool is a mock of ClientPool interface. 22 | type MockClientPool struct { 23 | ctrl *gomock.Controller 24 | recorder *MockClientPoolMockRecorder 25 | isgomock struct{} 26 | } 27 | 28 | // MockClientPoolMockRecorder is the mock recorder for MockClientPool. 29 | type MockClientPoolMockRecorder struct { 30 | mock *MockClientPool 31 | } 32 | 33 | // NewMockClientPool creates a new mock instance. 34 | func NewMockClientPool(ctrl *gomock.Controller) *MockClientPool { 35 | mock := &MockClientPool{ctrl: ctrl} 36 | mock.recorder = &MockClientPoolMockRecorder{mock} 37 | return mock 38 | } 39 | 40 | // EXPECT returns an object that allows the caller to indicate expected use. 41 | func (m *MockClientPool) EXPECT() *MockClientPoolMockRecorder { 42 | return m.recorder 43 | } 44 | 45 | // GetClientset mocks base method. 46 | func (m *MockClientPool) GetClientset(k8sContext string) (kubernetes.Interface, error) { 47 | m.ctrl.T.Helper() 48 | ret := m.ctrl.Call(m, "GetClientset", k8sContext) 49 | ret0, _ := ret[0].(kubernetes.Interface) 50 | ret1, _ := ret[1].(error) 51 | return ret0, ret1 52 | } 53 | 54 | // GetClientset indicates an expected call of GetClientset. 55 | func (mr *MockClientPoolMockRecorder) GetClientset(k8sContext any) *gomock.Call { 56 | mr.mock.ctrl.T.Helper() 57 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClientset", reflect.TypeOf((*MockClientPool)(nil).GetClientset), k8sContext) 58 | } 59 | 60 | // GetInformer mocks base method. 61 | func (m *MockClientPool) GetInformer(k8sCtx, kind, group, version string) (informers.GenericInformer, error) { 62 | m.ctrl.T.Helper() 63 | ret := m.ctrl.Call(m, "GetInformer", k8sCtx, kind, group, version) 64 | ret0, _ := ret[0].(informers.GenericInformer) 65 | ret1, _ := ret[1].(error) 66 | return ret0, ret1 67 | } 68 | 69 | // GetInformer indicates an expected call of GetInformer. 70 | func (mr *MockClientPoolMockRecorder) GetInformer(k8sCtx, kind, group, version any) *gomock.Call { 71 | mr.mock.ctrl.T.Helper() 72 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInformer", reflect.TypeOf((*MockClientPool)(nil).GetInformer), k8sCtx, kind, group, version) 73 | } 74 | 75 | // GetListMapping mocks base method. 76 | func (m *MockClientPool) GetListMapping(k8sCtx, kind, group, version string) list_mapping.ListMapping { 77 | m.ctrl.T.Helper() 78 | ret := m.ctrl.Call(m, "GetListMapping", k8sCtx, kind, group, version) 79 | ret0, _ := ret[0].(list_mapping.ListMapping) 80 | return ret0 81 | } 82 | 83 | // GetListMapping indicates an expected call of GetListMapping. 84 | func (mr *MockClientPoolMockRecorder) GetListMapping(k8sCtx, kind, group, version any) *gomock.Call { 85 | mr.mock.ctrl.T.Helper() 86 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetListMapping", reflect.TypeOf((*MockClientPool)(nil).GetListMapping), k8sCtx, kind, group, version) 87 | } 88 | -------------------------------------------------------------------------------- /internal/k8s/pool.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/strowk/mcp-k8s-go/internal/k8s/list_mapping" 11 | "k8s.io/apimachinery/pkg/api/meta" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/runtime/schema" 14 | "k8s.io/client-go/discovery/cached/memory" 15 | "k8s.io/client-go/dynamic" 16 | "k8s.io/client-go/dynamic/dynamicinformer" 17 | "k8s.io/client-go/informers" 18 | "k8s.io/client-go/kubernetes" 19 | "k8s.io/client-go/restmapper" 20 | "k8s.io/client-go/tools/cache" 21 | ) 22 | 23 | // ClientPool is a pool of Kubernetes clientsets and informers 24 | // that can be used to interact with Kubernetes resources. 25 | // 26 | // It is thread-safe and can be used from multiple goroutines. 27 | // It caches the clientsets and informers for each context 28 | // to avoid creating them multiple times. 29 | type ClientPool interface { 30 | GetClientset(k8sContext string) (kubernetes.Interface, error) 31 | GetInformer( 32 | k8sCtx string, 33 | kind string, 34 | group string, 35 | version string, 36 | ) (informers.GenericInformer, error) 37 | GetListMapping(k8sCtx, kind, group, version string) list_mapping.ListMapping 38 | } 39 | 40 | type resolvedResource struct { 41 | gvk *schema.GroupVersionKind 42 | mapping *meta.RESTMapping 43 | 44 | informer informers.GenericInformer 45 | listMapping list_mapping.ListMapping 46 | } 47 | 48 | type pool struct { 49 | clients map[string]kubernetes.Interface 50 | 51 | getClientsetMutex *sync.Mutex 52 | 53 | keyToResource map[string]*resolvedResource 54 | gvkToResource map[schema.GroupVersionKind]*resolvedResource 55 | 56 | getInformerMutex *sync.Mutex 57 | 58 | listMappingResolvers []list_mapping.ListMappingResolver 59 | } 60 | 61 | func NewClientPool(listMappingResolvers []list_mapping.ListMappingResolver) ClientPool { 62 | return &pool{ 63 | clients: make(map[string]kubernetes.Interface), 64 | getClientsetMutex: &sync.Mutex{}, 65 | 66 | keyToResource: make(map[string]*resolvedResource), 67 | gvkToResource: make(map[schema.GroupVersionKind]*resolvedResource), 68 | getInformerMutex: &sync.Mutex{}, 69 | 70 | listMappingResolvers: listMappingResolvers, 71 | } 72 | } 73 | 74 | func (p *pool) GetInformer( 75 | k8sCtx string, 76 | kind string, 77 | group string, 78 | version string, 79 | ) (informers.GenericInformer, error) { 80 | // creating informer needs to be thread-safe to avoid creating 81 | // multiple informers for the same resource 82 | p.getInformerMutex.Lock() 83 | defer p.getInformerMutex.Unlock() 84 | 85 | // this looks up if we have a resource with informer already 86 | // for exactly the same requested context and "lookup" gvk 87 | key := fmt.Sprintf("%s/%s/%s/%s", k8sCtx, kind, group, version) 88 | if res, ok := p.keyToResource[key]; ok { 89 | return res.informer, nil 90 | } 91 | 92 | // if not, then we resolve gvk and mapping from what server has 93 | res, err := p.resolve(k8sCtx, kind, group, version) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | // it is still possible for this resource to be known already 99 | // just with different key, so we check if we have it already, 100 | // now by canonical resolved gvk 101 | alreadySetupResource, ok := p.gvkToResource[*res.gvk] 102 | if ok { 103 | // rememeber that this key is resolved to already known resource 104 | p.keyToResource[key] = alreadySetupResource 105 | // and we can return the informer for it 106 | return alreadySetupResource.informer, nil 107 | } 108 | 109 | // if not, then we setup informer and cache it 110 | err = res.setupInformer(k8sCtx) 111 | if err != nil { 112 | return nil, err 113 | } 114 | p.keyToResource[key] = res 115 | p.gvkToResource[*res.gvk] = res 116 | return res.informer, nil 117 | } 118 | 119 | func (p *pool) resolve( 120 | k8sCtx string, 121 | kind string, 122 | group string, 123 | version string, 124 | ) (*resolvedResource, error) { 125 | clientset, err := p.GetClientset(k8sCtx) 126 | if err != nil { 127 | return nil, err 128 | } 129 | mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(clientset.Discovery())) 130 | 131 | serverPreferredResources, err := clientset.Discovery().ServerPreferredResources() 132 | if serverPreferredResources == nil && err != nil { 133 | return nil, err 134 | } 135 | 136 | lookupGvk := schema.GroupVersionKind{ 137 | Group: strings.ToLower(group), 138 | Version: strings.ToLower(version), 139 | Kind: strings.ToLower(kind), 140 | } 141 | 142 | var resolvedGvk *schema.GroupVersionKind 143 | var resolvedMapping *meta.RESTMapping 144 | 145 | lookingForResource: 146 | for _, r := range serverPreferredResources { 147 | for _, apiResource := range r.APIResources { 148 | resourceKind := apiResource.Kind 149 | resourceGroup := apiResource.Group 150 | resourceVersion := apiResource.Version 151 | if resourceGroup == "" || resourceVersion == "" { 152 | // some resources have empty group or version, which is then present in the containing resource list 153 | // for example: apps/v1 154 | // we need to set the group and version to the one from the containing resource list 155 | split := strings.SplitN(r.GroupVersion, "/", 2) 156 | if len(split) == 2 { 157 | resourceGroup = split[0] 158 | resourceVersion = split[1] 159 | } else { 160 | resourceVersion = r.GroupVersion 161 | } 162 | } 163 | 164 | if strings.EqualFold(apiResource.Kind, lookupGvk.Kind) { 165 | // some resources cannot have correct RESTMapping, but we need to create it 166 | // to check if the requested resource matches what we have found in the server 167 | // , but we can at first at least look if the kind matches what we are looking for 168 | gvk := schema.GroupVersionKind{ 169 | Group: resourceGroup, 170 | Version: resourceVersion, 171 | Kind: resourceKind, 172 | } 173 | 174 | mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) 175 | if err != nil { 176 | return nil, fmt.Errorf("failed to get rest mapping for %s: %w", gvk.String(), err) 177 | } 178 | 179 | if strings.EqualFold(mapping.GroupVersionKind.Kind, lookupGvk.Kind) && 180 | // if group or version were not specified, we would ignore them when matching 181 | // and this would simply match the first resource with matching kind 182 | (lookupGvk.Group == "" || strings.EqualFold(mapping.GroupVersionKind.Group, lookupGvk.Group)) && 183 | (lookupGvk.Version == "" || strings.EqualFold(mapping.GroupVersionKind.Version, lookupGvk.Version)) { 184 | 185 | resolvedGvk = &gvk 186 | resolvedMapping = mapping 187 | break lookingForResource 188 | } 189 | } 190 | } 191 | } 192 | 193 | if resolvedGvk == nil { 194 | return nil, fmt.Errorf("resource %s/%s/%s not found", group, version, kind) 195 | } 196 | 197 | return &resolvedResource{ 198 | gvk: resolvedGvk, 199 | mapping: resolvedMapping, 200 | }, nil 201 | } 202 | 203 | func (res *resolvedResource) setupInformer( 204 | k8sCtx string, 205 | ) error { 206 | cfg := GetKubeConfigForContext(k8sCtx) 207 | restConfig, err := cfg.ClientConfig() 208 | if err != nil { 209 | return err 210 | } 211 | 212 | dynClient, err := dynamic.NewForConfig(restConfig) 213 | if err != nil { 214 | return err 215 | } 216 | 217 | factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynClient, 10*time.Minute, metav1.NamespaceAll, nil) 218 | informer := factory.ForResource(res.mapping.Resource) 219 | go informer.Informer().Run(context.Background().Done()) 220 | isSynced := cache.WaitForCacheSync(context.Background().Done(), informer.Informer().HasSynced) 221 | if !isSynced { 222 | return fmt.Errorf("informer for resource %s/%s/%s is not synced", res.mapping.GroupVersionKind.Group, res.mapping.GroupVersionKind.Version, res.mapping.GroupVersionKind.Kind) 223 | } 224 | res.informer = informer 225 | return nil 226 | } 227 | 228 | func (p *pool) GetClientset(k8sContext string) (kubernetes.Interface, error) { 229 | p.getClientsetMutex.Lock() 230 | defer p.getClientsetMutex.Unlock() 231 | 232 | var effectiveContext string 233 | if k8sContext == "" { 234 | var err error 235 | effectiveContext, err = GetCurrentContext() 236 | if err != nil { 237 | return nil, err 238 | } 239 | } else { 240 | effectiveContext = k8sContext 241 | } 242 | 243 | if !IsContextAllowed(effectiveContext) { 244 | return nil, fmt.Errorf("context %s is not allowed", effectiveContext) 245 | } 246 | 247 | key := effectiveContext 248 | if client, ok := p.clients[key]; ok { 249 | return client, nil 250 | } 251 | 252 | client, err := getClientset(k8sContext) 253 | if err != nil { 254 | return nil, err 255 | } 256 | 257 | p.clients[key] = client 258 | return client, nil 259 | } 260 | 261 | func getClientset(k8sContext string) (kubernetes.Interface, error) { 262 | kubeConfig := GetKubeConfigForContext(k8sContext) 263 | 264 | config, err := kubeConfig.ClientConfig() 265 | if err != nil { 266 | return nil, err 267 | } 268 | 269 | clientset, err := kubernetes.NewForConfig(config) 270 | if err != nil { 271 | return nil, err 272 | } 273 | 274 | return clientset, nil 275 | } 276 | -------------------------------------------------------------------------------- /internal/prompts/namespaces.go: -------------------------------------------------------------------------------- 1 | package prompts 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/strowk/foxy-contexts/pkg/fxctx" 9 | "github.com/strowk/foxy-contexts/pkg/mcp" 10 | "github.com/strowk/mcp-k8s-go/internal/content" 11 | "github.com/strowk/mcp-k8s-go/internal/k8s" 12 | "github.com/strowk/mcp-k8s-go/internal/utils" 13 | 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | ) 16 | 17 | func NewListNamespacesPrompt(pool k8s.ClientPool) fxctx.Prompt { 18 | return fxctx.NewPrompt( 19 | mcp.Prompt{ 20 | Name: "list-k8s-namespaces", 21 | Description: utils.Ptr( 22 | "List Kubernetes Namespaces in the specified context", 23 | ), 24 | Arguments: []mcp.PromptArgument{ 25 | { 26 | Name: "context", 27 | Description: utils.Ptr( 28 | "Context to list namespaces in, defaults to current context", 29 | ), 30 | Required: utils.Ptr(false), 31 | }, 32 | }, 33 | }, 34 | func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { 35 | k8sContext := req.Params.Arguments["context"] 36 | clientset, err := pool.GetClientset(k8sContext) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to get k8s client: %w", err) 39 | } 40 | 41 | namespaces, err := clientset. 42 | CoreV1(). 43 | Namespaces(). 44 | List(ctx, metav1.ListOptions{}) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to list namespaces: %w", err) 47 | } 48 | 49 | sort.Slice(namespaces.Items, func(i, j int) bool { 50 | return namespaces.Items[i].Name < namespaces.Items[j].Name 51 | }) 52 | 53 | ofContextMsg := "" 54 | currentContext, err := k8s.GetCurrentContext() 55 | if err == nil && currentContext != "" { 56 | ofContextMsg = fmt.Sprintf(", context '%s'", currentContext) 57 | } 58 | 59 | var messages = make( 60 | []mcp.PromptMessage, 61 | len(namespaces.Items)+1, 62 | ) 63 | messages[0] = mcp.PromptMessage{ 64 | Content: mcp.TextContent{ 65 | Type: "text", 66 | Text: fmt.Sprintf( 67 | "There are %d namespaces%s:", 68 | len(namespaces.Items), 69 | ofContextMsg, 70 | ), 71 | }, 72 | Role: mcp.RoleUser, 73 | } 74 | 75 | type NamespaceInList struct { 76 | Name string `json:"name"` 77 | } 78 | 79 | for i, namespace := range namespaces.Items { 80 | content, err := content.NewJsonContent(NamespaceInList{ 81 | Name: namespace.Name, 82 | }) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to create content: %w", err) 85 | } 86 | messages[i+1] = mcp.PromptMessage{ 87 | Content: content, 88 | Role: mcp.RoleUser, 89 | } 90 | } 91 | 92 | return &mcp.GetPromptResult{ 93 | Description: utils.Ptr( 94 | fmt.Sprintf("Namespaces%s", ofContextMsg), 95 | ), 96 | Messages: messages, 97 | }, nil 98 | }, 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /internal/prompts/pods.go: -------------------------------------------------------------------------------- 1 | package prompts 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/strowk/foxy-contexts/pkg/fxctx" 10 | "github.com/strowk/foxy-contexts/pkg/mcp" 11 | "github.com/strowk/mcp-k8s-go/internal/content" 12 | "github.com/strowk/mcp-k8s-go/internal/k8s" 13 | "github.com/strowk/mcp-k8s-go/internal/utils" 14 | 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | ) 17 | 18 | func NewListPodsPrompt(pool k8s.ClientPool) fxctx.Prompt { 19 | return fxctx.NewPrompt( 20 | mcp.Prompt{ 21 | Name: "list-k8s-pods", 22 | Description: utils.Ptr( 23 | "List Kubernetes Pods with name and namespace in the current context", 24 | ), 25 | Arguments: []mcp.PromptArgument{ 26 | { 27 | Name: "namespace", 28 | Description: utils.Ptr( 29 | "Namespace to list Pods from, defaults to all namespaces", 30 | ), 31 | Required: utils.Ptr(false), 32 | }, 33 | }, 34 | }, 35 | func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { 36 | k8sNamespace := req.Params.Arguments["namespace"] 37 | if k8sNamespace == "" { 38 | k8sNamespace = metav1.NamespaceAll 39 | } 40 | 41 | clientset, err := pool.GetClientset("") 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to get k8s client: %w", err) 44 | } 45 | 46 | pods, err := clientset. 47 | CoreV1(). 48 | Pods(k8sNamespace). 49 | List(ctx, metav1.ListOptions{}) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to list pods: %w", err) 52 | } 53 | 54 | sort.Slice(pods.Items, func(i, j int) bool { 55 | return pods.Items[i].Name < pods.Items[j].Name 56 | }) 57 | 58 | namespaceInMessage := "all namespaces" 59 | if k8sNamespace != metav1.NamespaceAll { 60 | namespaceInMessage = fmt.Sprintf("namespace '%s'", k8sNamespace) 61 | } 62 | 63 | var messages = make( 64 | []mcp.PromptMessage, 65 | len(pods.Items)+1, 66 | ) 67 | messages[0] = mcp.PromptMessage{ 68 | Content: mcp.TextContent{ 69 | Type: "text", 70 | Text: fmt.Sprintf( 71 | "There are %d pods in %s:", 72 | len(pods.Items), 73 | namespaceInMessage, 74 | ), 75 | }, 76 | Role: mcp.RoleUser, 77 | } 78 | 79 | type PodInList struct { 80 | Name string `json:"name"` 81 | Namespace string `json:"namespace"` 82 | } 83 | 84 | for i, pod := range pods.Items { 85 | content, err := content.NewJsonContent(PodInList{ 86 | Name: pod.Name, 87 | Namespace: pod.Namespace, 88 | }) 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to create content: %w", err) 91 | } 92 | messages[i+1] = mcp.PromptMessage{ 93 | Content: content, 94 | Role: mcp.RoleUser, 95 | } 96 | } 97 | 98 | ofContextMsg := "" 99 | currentContext, err := k8s.GetCurrentContext() 100 | if err == nil && currentContext != "" { 101 | ofContextMsg = fmt.Sprintf(", context '%s'", currentContext) 102 | } 103 | 104 | return &mcp.GetPromptResult{ 105 | Description: utils.Ptr( 106 | fmt.Sprintf("Pods in %s%s", namespaceInMessage, ofContextMsg), 107 | ), 108 | Messages: messages, 109 | }, nil 110 | }, 111 | ).WithCompleter(func(arg *mcp.PromptArgument, value string) (*mcp.CompleteResult, error) { 112 | if arg.Name == "namespace" { 113 | 114 | client, err := pool.GetClientset("") 115 | 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to get k8s client: %w", err) 118 | } 119 | 120 | namespaces, err := client.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{}) 121 | if err != nil { 122 | return nil, fmt.Errorf("failed to get namespaces: %w", err) 123 | } 124 | 125 | var completions []string 126 | for _, ns := range namespaces.Items { 127 | if strings.HasPrefix(ns.Name, value) { 128 | completions = append(completions, ns.Name) 129 | } 130 | } 131 | 132 | return &mcp.CompleteResult{ 133 | Completion: mcp.CompleteResultCompletion{ 134 | HasMore: utils.Ptr(false), 135 | Total: utils.Ptr(len(completions)), 136 | Values: completions, 137 | }, 138 | }, nil 139 | } 140 | 141 | return nil, fmt.Errorf("no such argument to complete for prompt: '%s'", arg.Name) 142 | }) 143 | } 144 | -------------------------------------------------------------------------------- /internal/resources/contexts.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/strowk/mcp-k8s-go/internal/k8s" 9 | "github.com/strowk/mcp-k8s-go/internal/utils" 10 | 11 | "github.com/strowk/foxy-contexts/pkg/fxctx" 12 | "github.com/strowk/foxy-contexts/pkg/mcp" 13 | "k8s.io/client-go/tools/clientcmd/api" 14 | ) 15 | 16 | func NewContextsResourceProvider() fxctx.ResourceProvider { 17 | return fxctx.NewResourceProvider( 18 | func(_ context.Context) ([]mcp.Resource, error) { 19 | cfg, err := k8s.GetKubeConfig().RawConfig() 20 | if err != nil { 21 | return nil, fmt.Errorf("failed to get kubeconfig: %w", err) 22 | } 23 | 24 | resources := []mcp.Resource{} 25 | for name := range cfg.Contexts { 26 | if k8s.IsContextAllowed(name) { 27 | resources = append(resources, toMcpResourcse(name)) 28 | } 29 | } 30 | return resources, nil 31 | }, 32 | 33 | func(_ context.Context, uri string) (*mcp.ReadResourceResult, error) { 34 | cfg, err := k8s.GetKubeConfig().RawConfig() 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to get kubeconfig: %w", err) 37 | } 38 | 39 | if uri == "contexts" { 40 | contents := getContextsContent(uri, cfg) 41 | return &mcp.ReadResourceResult{ 42 | Contents: contents, 43 | }, nil 44 | } 45 | 46 | if strings.HasPrefix(uri, "contexts/") { 47 | name := strings.TrimPrefix(uri, "contexts/") 48 | c, ok := cfg.Contexts[name] 49 | if !ok { 50 | return nil, fmt.Errorf("context not found: %s", name) 51 | } 52 | 53 | var contents = make([]interface{}, 1) 54 | contents[0] = &struct { 55 | Uri string `json:"uri"` 56 | Text string `json:"text"` 57 | Context *api.Context `json:"context"` 58 | Name string `json:"name"` 59 | }{Context: c, Name: name, Text: name, Uri: uri} 60 | 61 | return &mcp.ReadResourceResult{ 62 | Contents: contents, 63 | }, nil 64 | } 65 | 66 | return nil, nil 67 | }) 68 | } 69 | 70 | func toMcpResourcse(contextName string) mcp.Resource { 71 | return mcp.Resource{Annotations: &mcp.ResourceAnnotations{ 72 | Audience: []mcp.Role{mcp.RoleAssistant, mcp.RoleUser}, 73 | }, 74 | Name: contextName, 75 | Description: utils.Ptr("Specific k8s context as read from kubeconfig configuration files"), 76 | Uri: "contexts/" + contextName, 77 | } 78 | } 79 | 80 | func getContextsContent(uri string, cfg api.Config) []interface{} { 81 | // First count allowed contexts to allocate the right size 82 | allowedContextsCount := 0 83 | for name := range cfg.Contexts { 84 | if k8s.IsContextAllowed(name) { 85 | allowedContextsCount++ 86 | } 87 | } 88 | 89 | var contents = make([]interface{}, allowedContextsCount) 90 | i := 0 91 | 92 | for name, c := range cfg.Contexts { 93 | if k8s.IsContextAllowed(name) { 94 | contents[i] = ContextContent{ 95 | Uri: uri + "/" + name, 96 | Text: name, 97 | 98 | Context: c, 99 | Name: name, 100 | } 101 | i++ 102 | } 103 | } 104 | return contents 105 | } 106 | 107 | type ContextContent struct { 108 | Uri string `json:"uri"` 109 | Text string `json:"text"` 110 | Context *api.Context `json:"context"` 111 | Name string `json:"name"` 112 | } 113 | -------------------------------------------------------------------------------- /internal/tests/assert_text_content.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/strowk/foxy-contexts/pkg/mcp" 8 | ) 9 | 10 | // AssertTextContentContainsInFirstString asserts that the first element of the content is a TextContent and that it contains the expected string 11 | func AssertTextContentContainsInFirstString(t *testing.T, expected string, content []any) { 12 | if assert.Len(t, content, 1) && assert.IsType(t, mcp.TextContent{}, content[0]) { 13 | assert.Contains(t, content[0].(mcp.TextContent).Text, "fake logs") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/tools/contexts.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | 8 | "github.com/strowk/mcp-k8s-go/internal/k8s" 9 | "github.com/strowk/mcp-k8s-go/internal/utils" 10 | 11 | "github.com/strowk/foxy-contexts/pkg/fxctx" 12 | "github.com/strowk/foxy-contexts/pkg/mcp" 13 | 14 | "k8s.io/client-go/tools/clientcmd/api" 15 | ) 16 | 17 | func NewListContextsTool() fxctx.Tool { 18 | return fxctx.NewTool( 19 | &mcp.Tool{ 20 | Name: "list-k8s-contexts", 21 | Description: utils.Ptr("List Kubernetes contexts from configuration files such as kubeconfig"), 22 | InputSchema: mcp.ToolInputSchema{ 23 | Type: "object", 24 | Properties: map[string]map[string]interface{}{}, 25 | Required: []string{}, 26 | }, 27 | }, 28 | func(_ context.Context, args map[string]interface{}) *mcp.CallToolResult { 29 | ctx := k8s.GetKubeConfig() 30 | cfg, err := ctx.RawConfig() 31 | if err != nil { 32 | log.Printf("failed to get kubeconfig: %v", err) 33 | return &mcp.CallToolResult{ 34 | IsError: utils.Ptr(true), 35 | Meta: map[string]interface{}{ 36 | "error": err.Error(), 37 | }, 38 | Content: []interface{}{}, 39 | } 40 | } 41 | 42 | return &mcp.CallToolResult{ 43 | Meta: map[string]interface{}{}, 44 | Content: getListContextsToolContent(cfg, cfg.CurrentContext), 45 | IsError: utils.Ptr(false), 46 | } 47 | }, 48 | ) 49 | } 50 | 51 | func getListContextsToolContent(cfg api.Config, current string) []interface{} { 52 | // First count allowed contexts to allocate the right size 53 | allowedContextsCount := 0 54 | for name := range cfg.Contexts { 55 | if k8s.IsContextAllowed(name) { 56 | allowedContextsCount++ 57 | } 58 | } 59 | 60 | var contents = make([]interface{}, allowedContextsCount) 61 | i := 0 62 | 63 | for name, c := range cfg.Contexts { 64 | if k8s.IsContextAllowed(name) { 65 | marshalled, err := json.Marshal(ContextJsonEncoded{ 66 | Context: c, 67 | Name: c.Cluster, 68 | Current: name == current, 69 | }) 70 | if err != nil { 71 | log.Printf("failed to marshal context: %v", err) 72 | continue 73 | } 74 | contents[i] = mcp.TextContent{ 75 | Type: "text", 76 | Text: string(marshalled), 77 | } 78 | 79 | i++ 80 | } 81 | } 82 | return contents 83 | } 84 | 85 | type ContextJsonEncoded struct { 86 | Context *api.Context `json:"context"` 87 | Name string `json:"name"` 88 | Current bool `json:"current"` 89 | } 90 | -------------------------------------------------------------------------------- /internal/tools/error.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "github.com/strowk/foxy-contexts/pkg/mcp" 5 | "github.com/strowk/mcp-k8s-go/internal/utils" 6 | ) 7 | 8 | func errResponse(err error) *mcp.CallToolResult { 9 | return &mcp.CallToolResult{ 10 | IsError: utils.Ptr(true), 11 | Meta: map[string]interface{}{}, 12 | Content: []interface{}{ 13 | mcp.TextContent{ 14 | Type: "text", 15 | Text: err.Error(), 16 | }, 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /internal/tools/events.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/strowk/mcp-k8s-go/internal/k8s" 7 | "github.com/strowk/mcp-k8s-go/internal/utils" 8 | 9 | "github.com/strowk/foxy-contexts/pkg/fxctx" 10 | "github.com/strowk/foxy-contexts/pkg/mcp" 11 | "github.com/strowk/foxy-contexts/pkg/toolinput" 12 | 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | ) 15 | 16 | func NewListEventsTool(pool k8s.ClientPool) fxctx.Tool { 17 | schema := toolinput.NewToolInputSchema( 18 | toolinput.WithRequiredString("context", "Name of the Kubernetes context to use"), 19 | toolinput.WithRequiredString("namespace", "Name of the namespace to list events from"), 20 | toolinput.WithNumber("limit", "Maximum number of events to list"), 21 | ) 22 | return fxctx.NewTool( 23 | &mcp.Tool{ 24 | Name: "list-k8s-events", 25 | Description: utils.Ptr("List Kubernetes events using specific context in a specified namespace"), 26 | InputSchema: schema.GetMcpToolInputSchema(), 27 | }, 28 | func(ctx context.Context, args map[string]interface{}) *mcp.CallToolResult { 29 | input, err := schema.Validate(args) 30 | if err != nil { 31 | return errResponse(err) 32 | } 33 | 34 | k8sCtx, err := input.String("context") 35 | if err != nil { 36 | return errResponse(err) 37 | } 38 | 39 | k8sNamespace, err := input.String("namespace") 40 | if err != nil { 41 | return errResponse(err) 42 | } 43 | 44 | clientset, err := pool.GetClientset(k8sCtx) 45 | if err != nil { 46 | return errResponse(err) 47 | } 48 | 49 | options := metav1.ListOptions{} 50 | if limit, err := input.Number("limit"); err == nil { 51 | options.Limit = int64(limit) 52 | } 53 | 54 | events, err := clientset. 55 | CoreV1(). 56 | Events(k8sNamespace). 57 | List(ctx, options) 58 | if err != nil { 59 | return errResponse(err) 60 | } 61 | 62 | var contents = make([]interface{}, len(events.Items)) 63 | for i, event := range events.Items { 64 | eventInList := EventInList{ 65 | Action: event.Action, 66 | Message: event.Message, 67 | Type: event.Type, 68 | Reason: event.Reason, 69 | InvolvedObject: InvolvedObject{ 70 | Kind: event.InvolvedObject.Kind, 71 | Name: event.InvolvedObject.Name, 72 | }, 73 | } 74 | content, err := NewJsonContent(eventInList) 75 | if err != nil { 76 | return errResponse(err) 77 | } 78 | contents[i] = content 79 | } 80 | 81 | return &mcp.CallToolResult{ 82 | Meta: map[string]interface{}{}, 83 | Content: contents, 84 | IsError: utils.Ptr(false), 85 | } 86 | }, 87 | ) 88 | } 89 | 90 | type InvolvedObject struct { 91 | Kind string `json:"kind"` 92 | Name string `json:"name"` 93 | } 94 | 95 | type EventInList struct { 96 | Action string `json:"action"` 97 | Message string `json:"message"` 98 | Type string `json:"type"` 99 | Reason string `json:"reason"` 100 | InvolvedObject InvolvedObject `json:"involvedObject"` 101 | } 102 | -------------------------------------------------------------------------------- /internal/tools/get_resource_tool.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "strings" 8 | 9 | "github.com/strowk/foxy-contexts/pkg/fxctx" 10 | "github.com/strowk/foxy-contexts/pkg/mcp" 11 | "github.com/strowk/foxy-contexts/pkg/toolinput" 12 | "github.com/strowk/mcp-k8s-go/internal/content" 13 | "github.com/strowk/mcp-k8s-go/internal/k8s" 14 | "github.com/strowk/mcp-k8s-go/internal/utils" 15 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 16 | ) 17 | 18 | func NewGetResourceTool(pool k8s.ClientPool) fxctx.Tool { 19 | contextProperty := "context" 20 | namespaceProperty := "namespace" 21 | kindProperty := "kind" 22 | groupProperty := "group" 23 | versionProperty := "version" 24 | nameProperty := "name" 25 | templateProperty := "go_template" 26 | 27 | inputSchema := toolinput.NewToolInputSchema( 28 | toolinput.WithString(contextProperty, "Name of the Kubernetes context to use, defaults to current context"), 29 | toolinput.WithString(namespaceProperty, "Namespace to get resource from, skip for cluster resources"), 30 | toolinput.WithString(groupProperty, "API Group of the resource to get"), 31 | toolinput.WithString(versionProperty, "API Version of the resource to get"), 32 | toolinput.WithRequiredString(kindProperty, "Kind of resource to get"), 33 | toolinput.WithRequiredString(nameProperty, "Name of the resource to get"), 34 | toolinput.WithString(templateProperty, "Go template to render the output, if not specified, the complete JSON object will be returned"), 35 | ) 36 | 37 | return fxctx.NewTool( 38 | &mcp.Tool{ 39 | Name: "get-k8s-resource", 40 | Description: utils.Ptr("Get details of any Kubernetes resource like pod, node or service - completely as JSON or rendered using template"), 41 | InputSchema: inputSchema.GetMcpToolInputSchema(), 42 | }, 43 | func(_ context.Context, args map[string]any) *mcp.CallToolResult { 44 | input, err := inputSchema.Validate(args) 45 | if err != nil { 46 | return utils.ErrResponse(err) 47 | } 48 | 49 | k8sCtx := input.StringOr(contextProperty, "") 50 | namespace := input.StringOr(namespaceProperty, "") 51 | 52 | kind, err := input.String(kindProperty) 53 | if err != nil { 54 | return utils.ErrResponse(err) 55 | } 56 | 57 | name, err := input.String(nameProperty) 58 | if err != nil { 59 | return utils.ErrResponse(err) 60 | } 61 | 62 | group := input.StringOr(groupProperty, "") 63 | version := input.StringOr(versionProperty, "") 64 | 65 | templateStr := input.StringOr(templateProperty, "") 66 | 67 | informer, err := pool.GetInformer(k8sCtx, kind, group, version) 68 | if err != nil { 69 | return utils.ErrResponse(err) 70 | } 71 | 72 | var key string 73 | 74 | if namespace == "" { 75 | key = name 76 | } else { 77 | key = fmt.Sprintf("%s/%s", namespace, name) 78 | } 79 | accumulator, exist, err := informer.Informer().GetIndexer().GetByKey(key) 80 | if err != nil { 81 | return utils.ErrResponse(err) 82 | } 83 | if !exist { 84 | return utils.ErrResponse(fmt.Errorf("resource %s/%s/%s/%s/%s not found", group, version, kind, namespace, name)) 85 | } 86 | unstructuredAcc, ok := accumulator.(*unstructured.Unstructured) 87 | if !ok { 88 | return utils.ErrResponse(fmt.Errorf("resource %s/%s/%s/%s/%s is not unstructured", group, version, kind, namespace, name)) 89 | } 90 | 91 | object := unstructuredAcc.Object 92 | 93 | if metadata, ok := object["metadata"]; ok { 94 | if metadataMap, ok := metadata.(map[string]any); ok { 95 | // this is too big and somewhat useless 96 | delete(metadataMap, "managedFields") 97 | } 98 | } 99 | 100 | var cnt any 101 | if templateStr != "" { 102 | tmpl, err := template.New("template").Parse(templateStr) 103 | if err != nil { 104 | return utils.ErrResponse(err) 105 | } 106 | buf := new(strings.Builder) 107 | err = tmpl.Execute(buf, object) 108 | if err != nil { 109 | return utils.ErrResponse(err) 110 | } 111 | cnt = mcp.TextContent{ 112 | Type: "text", 113 | Text: buf.String(), 114 | } 115 | } else { 116 | c, err := content.NewJsonContent(object) 117 | if err != nil { 118 | return utils.ErrResponse(err) 119 | } 120 | cnt = c 121 | } 122 | var contents = []any{cnt} 123 | 124 | return &mcp.CallToolResult{ 125 | Meta: map[string]any{}, 126 | Content: contents, 127 | IsError: utils.Ptr(false), 128 | } 129 | }, 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /internal/tools/json_content.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/strowk/foxy-contexts/pkg/mcp" 7 | ) 8 | 9 | func NewJsonContent(v interface{}) (mcp.TextContent, error) { 10 | contents, err := json.Marshal(v) 11 | if err != nil { 12 | return mcp.TextContent{}, err 13 | } 14 | return mcp.TextContent{ 15 | Type: "text", 16 | Text: string(contents), 17 | }, nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/tools/list_resources_tool.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | 7 | "github.com/strowk/foxy-contexts/pkg/fxctx" 8 | "github.com/strowk/foxy-contexts/pkg/mcp" 9 | "github.com/strowk/foxy-contexts/pkg/toolinput" 10 | "github.com/strowk/mcp-k8s-go/internal/content" 11 | "github.com/strowk/mcp-k8s-go/internal/k8s" 12 | "github.com/strowk/mcp-k8s-go/internal/k8s/list_mapping" 13 | "github.com/strowk/mcp-k8s-go/internal/utils" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/labels" 16 | "k8s.io/apimachinery/pkg/runtime" 17 | ) 18 | 19 | func NewListResourcesTool(pool k8s.ClientPool) fxctx.Tool { 20 | contextProperty := "context" 21 | namespaceProperty := "namespace" 22 | kindProperty := "kind" 23 | groupProperty := "group" 24 | versionProperty := "version" 25 | 26 | inputSchema := toolinput.NewToolInputSchema( 27 | toolinput.WithString(contextProperty, "Name of the Kubernetes context to use, defaults to current context"), 28 | toolinput.WithString(namespaceProperty, "Namespace to list resources from, defaults to all namespaces"), 29 | toolinput.WithString(groupProperty, "API Group of resources to list"), 30 | toolinput.WithString(versionProperty, "API Version of resources to list"), 31 | toolinput.WithRequiredString(kindProperty, "Kind of resources to list"), 32 | ) 33 | 34 | return fxctx.NewTool( 35 | &mcp.Tool{ 36 | Name: "list-k8s-resources", 37 | Description: utils.Ptr("List arbitrary Kubernetes resources"), 38 | InputSchema: inputSchema.GetMcpToolInputSchema(), 39 | }, 40 | func(ctx context.Context, args map[string]interface{}) *mcp.CallToolResult { 41 | input, err := inputSchema.Validate(args) 42 | if err != nil { 43 | return utils.ErrResponse(err) 44 | } 45 | 46 | k8sCtx := input.StringOr(contextProperty, "") 47 | namespace := input.StringOr(namespaceProperty, metav1.NamespaceAll) 48 | 49 | kind, err := input.String(kindProperty) 50 | if err != nil { 51 | return utils.ErrResponse(err) 52 | } 53 | 54 | group := input.StringOr(groupProperty, "") 55 | version := input.StringOr(versionProperty, "") 56 | 57 | informer, err := pool.GetInformer(k8sCtx, kind, group, version) 58 | if err != nil { 59 | return utils.ErrResponse(err) 60 | } 61 | 62 | listMapping := pool.GetListMapping(k8sCtx, kind, group, version) 63 | var unstructuredList []runtime.Object 64 | 65 | if namespace != metav1.NamespaceAll { 66 | unstructured, err := informer.Lister().ByNamespace(namespace).List(labels.Everything()) 67 | if err != nil { 68 | return utils.ErrResponse(err) 69 | } 70 | unstructuredList = unstructured 71 | } else { 72 | unstructured, err := informer.Lister().List(labels.Everything()) 73 | if err != nil { 74 | return utils.ErrResponse(err) 75 | } 76 | unstructuredList = unstructured 77 | } 78 | 79 | var contents = make([]any, 0) 80 | var listContents []list_mapping.ListContentItem 81 | for _, unstructuredItem := range unstructuredList { 82 | item := unstructuredItem.(runtime.Unstructured) 83 | var listContent list_mapping.ListContentItem 84 | 85 | if listMapping == nil { 86 | unscructuredContent := item.UnstructuredContent() 87 | 88 | // we try to list only metadata to avoid too big outputs 89 | metadata, ok := unscructuredContent["metadata"].(map[string]any) 90 | if !ok { 91 | cnt, err := content.NewJsonContent(unscructuredContent) 92 | if err != nil { 93 | return utils.ErrResponse(err) 94 | } 95 | contents = append(contents, cnt) 96 | continue 97 | } 98 | gen := GenericListContent{} 99 | if name, ok := metadata["name"].(string); ok { 100 | gen.Name = name 101 | } 102 | 103 | if namespace, ok := metadata["namespace"].(string); ok { 104 | gen.Namespace = namespace 105 | } 106 | 107 | listContent = gen 108 | } else { 109 | listContent, err = listMapping(item) 110 | if err != nil { 111 | return utils.ErrResponse(err) 112 | } 113 | } 114 | listContents = append(listContents, listContent) 115 | } 116 | 117 | // sort the list contents by name and namespace 118 | sort.Slice(listContents, func(i, j int) bool { 119 | if listContents[i].GetNamespace() == listContents[j].GetNamespace() { 120 | return listContents[i].GetName() < listContents[j].GetName() 121 | } 122 | return listContents[i].GetNamespace() < listContents[j].GetNamespace() 123 | }) 124 | 125 | // convert sorted list contents to JSON content 126 | for _, listContent := range listContents { 127 | cnt, err := content.NewJsonContent(listContent) 128 | if err != nil { 129 | return utils.ErrResponse(err) 130 | } 131 | contents = append(contents, cnt) 132 | } 133 | 134 | return &mcp.CallToolResult{ 135 | Meta: map[string]any{}, 136 | Content: contents, 137 | IsError: utils.Ptr(false), 138 | } 139 | }, 140 | ) 141 | } 142 | 143 | type GenericListContent struct { 144 | Name string `json:"name"` 145 | Namespace string `json:"namespace,omitempty"` 146 | } 147 | 148 | func (l GenericListContent) GetName() string { 149 | return l.Name 150 | } 151 | 152 | func (l GenericListContent) GetNamespace() string { 153 | return l.Namespace 154 | } 155 | -------------------------------------------------------------------------------- /internal/tools/namespaces.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | 7 | "github.com/strowk/mcp-k8s-go/internal/k8s" 8 | "github.com/strowk/mcp-k8s-go/internal/utils" 9 | 10 | "github.com/strowk/foxy-contexts/pkg/fxctx" 11 | "github.com/strowk/foxy-contexts/pkg/mcp" 12 | "github.com/strowk/foxy-contexts/pkg/toolinput" 13 | 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | ) 16 | 17 | func NewListNamespacesTool(pool k8s.ClientPool) fxctx.Tool { 18 | contextProperty := "context" 19 | schema := toolinput.NewToolInputSchema( 20 | toolinput.WithString(contextProperty, "Name of the Kubernetes context to use, defaults to current context"), 21 | ) 22 | return fxctx.NewTool( 23 | &mcp.Tool{ 24 | Name: "list-k8s-namespaces", 25 | Description: utils.Ptr("List Kubernetes namespaces using specific context"), 26 | InputSchema: schema.GetMcpToolInputSchema(), 27 | }, 28 | func(ctx context.Context, args map[string]interface{}) *mcp.CallToolResult { 29 | input, err := schema.Validate(args) 30 | if err != nil { 31 | return errResponse(err) 32 | } 33 | k8sCtx := input.StringOr(contextProperty, "") 34 | 35 | clientset, err := pool.GetClientset(k8sCtx) 36 | if err != nil { 37 | return errResponse(err) 38 | } 39 | 40 | namespace, err := clientset. 41 | CoreV1(). 42 | Namespaces(). 43 | List(ctx, metav1.ListOptions{}) 44 | if err != nil { 45 | return errResponse(err) 46 | } 47 | 48 | sort.Slice(namespace.Items, func(i, j int) bool { 49 | return namespace.Items[i].Name < namespace.Items[j].Name 50 | }) 51 | 52 | var contents = make([]interface{}, len(namespace.Items)) 53 | for i, namespace := range namespace.Items { 54 | content, err := NewJsonContent(NamespacesInList{ 55 | Name: namespace.Name, 56 | }) 57 | if err != nil { 58 | return errResponse(err) 59 | } 60 | contents[i] = content 61 | } 62 | 63 | return &mcp.CallToolResult{ 64 | Meta: map[string]interface{}{}, 65 | Content: contents, 66 | IsError: utils.Ptr(false), 67 | } 68 | }, 69 | ) 70 | } 71 | 72 | type NamespacesInList struct { 73 | Name string `json:"name"` 74 | } 75 | -------------------------------------------------------------------------------- /internal/tools/nodes.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | "time" 8 | 9 | "github.com/strowk/mcp-k8s-go/internal/k8s" 10 | "github.com/strowk/mcp-k8s-go/internal/utils" 11 | 12 | "github.com/strowk/foxy-contexts/pkg/fxctx" 13 | "github.com/strowk/foxy-contexts/pkg/mcp" 14 | "github.com/strowk/foxy-contexts/pkg/toolinput" 15 | 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | ) 18 | 19 | func NewListNodesTool(pool k8s.ClientPool) fxctx.Tool { 20 | contextProperty := "context" 21 | schema := toolinput.NewToolInputSchema( 22 | toolinput.WithString(contextProperty, "Name of the Kubernetes context to use, defaults to current context"), 23 | ) 24 | return fxctx.NewTool( 25 | &mcp.Tool{ 26 | Name: "list-k8s-nodes", 27 | Description: utils.Ptr("List Kubernetes nodes using specific context"), 28 | InputSchema: schema.GetMcpToolInputSchema(), 29 | }, 30 | func(ctx context.Context, args map[string]interface{}) *mcp.CallToolResult { 31 | input, err := schema.Validate(args) 32 | if err != nil { 33 | return errResponse(err) 34 | } 35 | k8sCtx := input.StringOr(contextProperty, "") 36 | 37 | clientset, err := pool.GetClientset(k8sCtx) 38 | if err != nil { 39 | return errResponse(err) 40 | } 41 | 42 | nodes, err := clientset. 43 | CoreV1(). 44 | Nodes(). 45 | List(ctx, metav1.ListOptions{}) 46 | if err != nil { 47 | return errResponse(err) 48 | } 49 | 50 | sort.Slice(nodes.Items, func(i, j int) bool { 51 | return nodes.Items[i].Name < nodes.Items[j].Name 52 | }) 53 | 54 | var contents = make([]interface{}, len(nodes.Items)) 55 | for i, ns := range nodes.Items { 56 | // Calculate age 57 | age := time.Since(ns.CreationTimestamp.Time) 58 | 59 | // Determine status 60 | status := "NotReady" 61 | for _, condition := range ns.Status.Conditions { 62 | if condition.Type == "Ready" { 63 | if condition.Status == "True" { 64 | status = "Ready" 65 | } else { 66 | status = "NotReady" 67 | } 68 | break 69 | } 70 | } 71 | 72 | content, err := NewJsonContent(NodeInList{ 73 | Name: ns.Name, 74 | Status: status, 75 | Age: formatAge(age), 76 | CreatedAt: ns.CreationTimestamp.Time, 77 | }) 78 | if err != nil { 79 | return errResponse(err) 80 | } 81 | contents[i] = content 82 | } 83 | 84 | return &mcp.CallToolResult{ 85 | Meta: map[string]interface{}{}, 86 | Content: contents, 87 | IsError: utils.Ptr(false), 88 | } 89 | }, 90 | ) 91 | } 92 | 93 | // NodeInList provides a structured representation of node information 94 | type NodeInList struct { 95 | Name string `json:"name"` 96 | Status string `json:"status"` 97 | Age string `json:"age"` 98 | CreatedAt time.Time `json:"created_at"` 99 | } 100 | 101 | // formatAge converts a duration to a human-readable age string 102 | func formatAge(duration time.Duration) string { 103 | if duration.Hours() < 1 { 104 | return duration.Round(time.Minute).String() 105 | } 106 | if duration.Hours() < 24 { 107 | return duration.Round(time.Hour).String() 108 | } 109 | days := int(duration.Hours() / 24) 110 | return formatDays(days) 111 | } 112 | 113 | // formatDays provides a concise representation of days 114 | func formatDays(days int) string { 115 | if days < 7 { 116 | return fmt.Sprintf("%dd", days) 117 | } 118 | if days < 30 { 119 | weeks := days / 7 120 | return fmt.Sprintf("%dw", weeks) 121 | } 122 | months := days / 30 123 | return fmt.Sprintf("%dmo", months) 124 | } 125 | -------------------------------------------------------------------------------- /internal/tools/pod_exec_cmd.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/strowk/mcp-k8s-go/internal/k8s" 11 | "github.com/strowk/mcp-k8s-go/internal/utils" 12 | 13 | "github.com/strowk/foxy-contexts/pkg/fxctx" 14 | "github.com/strowk/foxy-contexts/pkg/mcp" 15 | "github.com/strowk/foxy-contexts/pkg/toolinput" 16 | 17 | corev1 "k8s.io/api/core/v1" 18 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | "k8s.io/client-go/kubernetes/scheme" 20 | "k8s.io/client-go/rest" 21 | "k8s.io/client-go/tools/remotecommand" 22 | ) 23 | 24 | const timeout = 5 * time.Second 25 | 26 | func NewPodExecCommandTool(pool k8s.ClientPool) fxctx.Tool { 27 | k8sNamespace := "namespace" 28 | k8sPodName := "pod" 29 | execCommand := "command" 30 | k8sContext := "context" 31 | stdin := "stdin" 32 | schema := toolinput.NewToolInputSchema( 33 | toolinput.WithString(k8sContext, "Kubernetes context name, defaults to current context"), 34 | toolinput.WithRequiredString(k8sNamespace, "Namespace where pod is located"), 35 | toolinput.WithRequiredString(k8sPodName, "Name of the pod to execute command in"), 36 | toolinput.WithRequiredString(execCommand, "Command to be executed"), 37 | toolinput.WithString(stdin, "Standard input to the command, defaults to empty string"), 38 | ) 39 | return fxctx.NewTool( 40 | &mcp.Tool{ 41 | Name: "k8s-pod-exec", 42 | Description: utils.Ptr("Execute command in Kubernetes pod"), 43 | InputSchema: schema.GetMcpToolInputSchema(), 44 | }, 45 | func(ctx context.Context, args map[string]interface{}) *mcp.CallToolResult { 46 | input, err := schema.Validate(args) 47 | if err != nil { 48 | return errResponse(err) 49 | } 50 | k8sNamespace, err := input.String(k8sNamespace) 51 | if err != nil { 52 | return errResponse(fmt.Errorf("invalid input namespace: %w", err)) 53 | } 54 | k8sPodName, err := input.String(k8sPodName) 55 | if err != nil { 56 | return errResponse(fmt.Errorf("invalid input pod: %w", err)) 57 | } 58 | execCommand, err := input.String(execCommand) 59 | if err != nil { 60 | return errResponse(fmt.Errorf("invalid input command: %w", err)) 61 | } 62 | k8sContext := input.StringOr(k8sContext, "") 63 | stdin := input.StringOr(stdin, "") 64 | 65 | kubeconfig := k8s.GetKubeConfigForContext(k8sContext) 66 | config, err := kubeconfig.ClientConfig() 67 | if err != nil { 68 | return errResponse(fmt.Errorf("invalid config: %w", err)) 69 | } 70 | execResult, err := cmdExecuter(pool, config, k8sPodName, k8sNamespace, execCommand, k8sContext, stdin, ctx) 71 | if err != nil { 72 | return errResponse(fmt.Errorf("command execute failed: %w", err)) 73 | } 74 | 75 | var content mcp.TextContent 76 | contents := []interface{}{} 77 | content, err = NewJsonContent(execResult) 78 | if err != nil { 79 | return errResponse(err) 80 | } 81 | contents = append(contents, content) 82 | 83 | return &mcp.CallToolResult{ 84 | Meta: map[string]interface{}{}, 85 | Content: contents, 86 | IsError: utils.Ptr(false), 87 | } 88 | }, 89 | ) 90 | } 91 | 92 | type ExecResult struct { 93 | Stdout interface{} `json:"stdout"` 94 | Stderr interface{} `json:"stderr"` 95 | } 96 | 97 | func cmdExecuter( 98 | pool k8s.ClientPool, 99 | config *rest.Config, 100 | podName, 101 | namespace, 102 | cmd, 103 | k8sContext, 104 | stdin string, 105 | ctx context.Context, 106 | ) (ExecResult, error) { 107 | execResult := ExecResult{} 108 | clientset, err := pool.GetClientset(k8sContext) 109 | if err != nil { 110 | return execResult, err 111 | } 112 | 113 | pod, err := clientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) 114 | if err != nil { 115 | return execResult, err 116 | } 117 | 118 | if len(pod.Spec.Containers) == 0 { 119 | return execResult, fmt.Errorf("pod %s has no containers", podName) 120 | } 121 | 122 | containerName := pod.Spec.Containers[0].Name 123 | req := clientset.CoreV1().RESTClient().Post(). 124 | Resource("pods"). 125 | Name(podName). 126 | Namespace(namespace). 127 | SubResource("exec"). 128 | VersionedParams(&corev1.PodExecOptions{ 129 | Container: containerName, 130 | Command: []string{"sh", "-c", cmd}, 131 | Stdin: true, 132 | Stdout: true, 133 | Stderr: true, 134 | TTY: false, 135 | }, scheme.ParameterCodec) 136 | executor, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) 137 | if err != nil { 138 | return execResult, err 139 | } 140 | 141 | withTimeout, cancel := context.WithTimeout(ctx, timeout) 142 | defer cancel() // release resources if operation finishes before timeout 143 | 144 | var stdout, stderr bytes.Buffer 145 | if err = executor.StreamWithContext(withTimeout, remotecommand.StreamOptions{ 146 | Stdin: strings.NewReader(stdin), 147 | Stdout: &stdout, 148 | Stderr: &stderr, 149 | }); err != nil { 150 | if err == context.DeadlineExceeded { 151 | return execResult, fmt.Errorf("command timed out after %s", timeout) 152 | } 153 | return execResult, err 154 | } 155 | execResult.Stdout = stdout.String() 156 | execResult.Stderr = stderr.String() 157 | return execResult, nil 158 | } 159 | -------------------------------------------------------------------------------- /internal/tools/pod_logs.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/strowk/mcp-k8s-go/internal/k8s" 9 | "github.com/strowk/mcp-k8s-go/internal/utils" 10 | 11 | "github.com/strowk/foxy-contexts/pkg/fxctx" 12 | "github.com/strowk/foxy-contexts/pkg/mcp" 13 | "github.com/strowk/foxy-contexts/pkg/toolinput" 14 | 15 | v1 "k8s.io/api/core/v1" 16 | 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | ) 19 | 20 | func NewPodLogsTool(pool k8s.ClientPool) fxctx.Tool { 21 | schema := toolinput.NewToolInputSchema( 22 | toolinput.WithRequiredString("context", "Name of the Kubernetes context to use"), 23 | toolinput.WithRequiredString("namespace", "Name of the namespace where the pod is located"), 24 | toolinput.WithRequiredString("pod", "Name of the pod to get logs from"), 25 | toolinput.WithString("sinceDuration", "Only return logs newer than a relative duration like 5s, 2m, or 3h. Only one of sinceTime or sinceDuration may be set."), 26 | toolinput.WithString("sinceTime", "Only return logs after a specific date (RFC3339). Only one of sinceTime or sinceDuration may be set."), 27 | toolinput.WithBoolean("previousContainer", "Return previous terminated container logs, defaults to false."), 28 | ) 29 | return fxctx.NewTool( 30 | &mcp.Tool{ 31 | Name: "get-k8s-pod-logs", 32 | Description: utils.Ptr("Get logs for a Kubernetes pod using specific context in a specified namespace"), 33 | InputSchema: schema.GetMcpToolInputSchema(), 34 | }, 35 | func(ctx context.Context, args map[string]interface{}) *mcp.CallToolResult { 36 | input, err := schema.Validate(args) 37 | if err != nil { 38 | return errResponse(fmt.Errorf("invalid input: %w", err)) 39 | } 40 | 41 | k8sCtx, err := input.String("context") 42 | if err != nil { 43 | return errResponse(fmt.Errorf("invalid input: %w", err)) 44 | } 45 | 46 | k8sNamespace, err := input.String("namespace") 47 | if err != nil { 48 | return errResponse(fmt.Errorf("invalid input: %w", err)) 49 | } 50 | 51 | k8sPod, err := input.String("pod") 52 | if err != nil { 53 | return errResponse(fmt.Errorf("invalid input: %w", err)) 54 | } 55 | 56 | sinceDurationStr := input.StringOr("sinceDuration", "") 57 | 58 | sinceTimeStr := "" 59 | 60 | sinceTimeStr = input.StringOr("sinceTime", "") 61 | 62 | if sinceDurationStr != "" && sinceTimeStr != "" { 63 | return errResponse(fmt.Errorf("only one of sinceDuration or sinceTime may be set")) 64 | } 65 | 66 | previousContainer := input.BooleanOr("previousContainer", false) 67 | 68 | options := &v1.PodLogOptions{ 69 | Previous: previousContainer, 70 | } 71 | if sinceDurationStr != "" { 72 | sinceDuration, err := time.ParseDuration(sinceDurationStr) 73 | if err != nil { 74 | return errResponse(fmt.Errorf("invalid duration: %s, expected to be in Golang duration format as defined in standard time package", sinceDurationStr)) 75 | } 76 | 77 | options.SinceSeconds = utils.Ptr(int64(sinceDuration.Seconds())) 78 | } else if sinceTimeStr != "" { 79 | sinceTime, err := time.Parse(time.RFC3339, sinceTimeStr) 80 | if err != nil { 81 | return errResponse(fmt.Errorf("invalid time: '%s', expected to be in RFC3339 format, for example 2024-12-01T19:00:08Z", sinceTimeStr)) 82 | } 83 | 84 | options.SinceTime = &metav1.Time{Time: sinceTime} 85 | } 86 | 87 | clientset, err := pool.GetClientset(k8sCtx) 88 | if err != nil { 89 | return errResponse(err) 90 | } 91 | 92 | podLogs := clientset. 93 | CoreV1(). 94 | Pods(k8sNamespace). 95 | GetLogs(k8sPod, options). 96 | Do(ctx) 97 | 98 | err = podLogs.Error() 99 | 100 | if err != nil { 101 | return errResponse(err) 102 | } 103 | 104 | data, err := podLogs.Raw() 105 | if err != nil { 106 | return errResponse(err) 107 | } 108 | 109 | content := mcp.TextContent{ 110 | Type: "text", 111 | Text: string(data), 112 | } 113 | 114 | return &mcp.CallToolResult{ 115 | Content: []interface{}{content}, 116 | IsError: utils.Ptr(false), 117 | } 118 | }, 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /internal/tools/pod_logs_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | mock_k8s "github.com/strowk/mcp-k8s-go/internal/k8s/mock" 9 | "github.com/strowk/mcp-k8s-go/internal/tests" 10 | "go.uber.org/mock/gomock" 11 | v1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/client-go/kubernetes/fake" 14 | ) 15 | 16 | func TestPodLogs(t *testing.T) { 17 | cntr := gomock.NewController(t) 18 | poolMock := mock_k8s.NewMockClientPool(cntr) 19 | 20 | tool := NewPodLogsTool(poolMock) 21 | t.Run("Call with invalid type of previousContainer", func(t *testing.T) { 22 | args := map[string]any{ 23 | "context": "context", 24 | "namespace": "namespace", 25 | "pod": "pod", 26 | "previousContainer": "invalid", 27 | } 28 | resp := tool.Callback(context.Background(), args) 29 | if assert.NotNil(t, resp.IsError) { 30 | assert.True(t, *resp.IsError) 31 | } 32 | }) 33 | 34 | t.Run("Call with boolean within string for previousContainer", func(t *testing.T) { 35 | args := map[string]any{ 36 | "context": "context", 37 | "namespace": "namespace", 38 | "pod": "pod", 39 | "previousContainer": "true", // this is what Inspector gives us, this might be a bug 40 | } 41 | poolMock.EXPECT().GetClientset("context").Return(fake.NewClientset( 42 | &v1.Pod{ 43 | ObjectMeta: metav1.ObjectMeta{ 44 | Name: "pod", 45 | Namespace: "namespace", 46 | }, 47 | }, 48 | ), nil) 49 | resp := tool.Callback(context.Background(), args) 50 | tests.AssertTextContentContainsInFirstString(t, "fake logs", resp.Content) 51 | }) 52 | 53 | t.Run("Call with empty string for previousContainer", func(t *testing.T) { 54 | args := map[string]any{ 55 | "context": "context", 56 | "namespace": "namespace", 57 | "pod": "pod", 58 | "previousContainer": "", 59 | } 60 | poolMock.EXPECT().GetClientset("context").Return(fake.NewClientset( 61 | &v1.Pod{ 62 | ObjectMeta: metav1.ObjectMeta{ 63 | Name: "pod", 64 | Namespace: "namespace", 65 | }, 66 | }, 67 | ), nil) 68 | resp := tool.Callback(context.Background(), args) 69 | tests.AssertTextContentContainsInFirstString(t, "fake logs", resp.Content) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /internal/utils/age.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // FormatAge converts a duration to a human-readable age string 9 | func FormatAge(duration time.Duration) string { 10 | if duration.Hours() < 1 { 11 | return duration.Round(time.Minute).String() 12 | } 13 | if duration.Hours() < 24 { 14 | return duration.Round(time.Hour).String() 15 | } 16 | days := int(duration.Hours() / 24) 17 | return FormatDays(days) 18 | } 19 | 20 | // FormatDays provides a concise representation of days 21 | func FormatDays(days int) string { 22 | if days < 7 { 23 | return fmt.Sprintf("%dd", days) 24 | } 25 | if days < 30 { 26 | weeks := days / 7 27 | return fmt.Sprintf("%dw", weeks) 28 | } 29 | months := days / 30 30 | return fmt.Sprintf("%dmo", months) 31 | } 32 | -------------------------------------------------------------------------------- /internal/utils/error_response.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/strowk/foxy-contexts/pkg/mcp" 4 | 5 | func ErrResponse(err error) *mcp.CallToolResult { 6 | return &mcp.CallToolResult{ 7 | IsError: Ptr(true), 8 | Meta: map[string]interface{}{}, 9 | Content: []interface{}{ 10 | mcp.TextContent{ 11 | Type: "text", 12 | Text: err.Error(), 13 | }, 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/utils/ptr.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func Ptr[T any](v T) *T { 4 | return &v 5 | } 6 | -------------------------------------------------------------------------------- /internal/utils/sanitize.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | func SanitizeObjectMeta(object *metav1.ObjectMeta) { 8 | // exclude managed fields, since they are not relevant for users and would only 9 | // clutter the context window with irrelevant information 10 | object.ManagedFields = nil 11 | } 12 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/strowk/mcp-k8s-go/internal/config" 8 | "github.com/strowk/mcp-k8s-go/internal/k8s" 9 | "github.com/strowk/mcp-k8s-go/internal/k8s/apps/v1/deployment" 10 | "github.com/strowk/mcp-k8s-go/internal/k8s/core/v1/service" 11 | "github.com/strowk/mcp-k8s-go/internal/k8s/list_mapping" 12 | "github.com/strowk/mcp-k8s-go/internal/prompts" 13 | "github.com/strowk/mcp-k8s-go/internal/resources" 14 | "github.com/strowk/mcp-k8s-go/internal/tools" 15 | "github.com/strowk/mcp-k8s-go/internal/utils" 16 | 17 | "github.com/strowk/foxy-contexts/pkg/app" 18 | "github.com/strowk/foxy-contexts/pkg/mcp" 19 | "github.com/strowk/foxy-contexts/pkg/stdio" 20 | 21 | "go.uber.org/fx" 22 | "go.uber.org/fx/fxevent" 23 | "go.uber.org/zap" 24 | "k8s.io/client-go/kubernetes" 25 | _ "k8s.io/client-go/plugin/pkg/client/auth/exec" 26 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 27 | "k8s.io/client-go/tools/clientcmd" 28 | ) 29 | 30 | func getCapabilities() *mcp.ServerCapabilities { 31 | return &mcp.ServerCapabilities{ 32 | Resources: &mcp.ServerCapabilitiesResources{ 33 | ListChanged: utils.Ptr(false), 34 | Subscribe: utils.Ptr(false), 35 | }, 36 | Prompts: &mcp.ServerCapabilitiesPrompts{ 37 | ListChanged: utils.Ptr(false), 38 | }, 39 | Tools: &mcp.ServerCapabilitiesTools{ 40 | ListChanged: utils.Ptr(false), 41 | }, 42 | } 43 | } 44 | 45 | var ( 46 | version = "dev" 47 | commit = "none" 48 | date = "unknown" 49 | ) 50 | 51 | func main() { 52 | if len(os.Args) > 1 { 53 | arg := os.Args[1] 54 | if arg == "--version" { 55 | println(version) 56 | return 57 | } 58 | if arg == "version" { 59 | println("Version: ", version) 60 | println("Commit: ", commit) 61 | println("Date: ", date) 62 | return 63 | } 64 | if arg == "help" || arg == "--help" { 65 | printHelp() 66 | return 67 | } 68 | } 69 | 70 | // Parse configuration flags 71 | shouldContinue := config.ParseFlags() 72 | if !shouldContinue { 73 | return 74 | } 75 | 76 | foxyApp := getApp() 77 | err := foxyApp.Run() 78 | if err != nil { 79 | log.Fatalf("Error: %v", err) 80 | } 81 | } 82 | 83 | func printHelp() { 84 | println("mcp-k8s is an MCP server for Kubernetes") 85 | println("Read more about it in: https://github.com/strowk/mcp-k8s-go\n") 86 | println("Usage: [flags]") 87 | println(" Run with no flags to start the server\n") 88 | println("Flags:") 89 | println(" help, --help: Print this help message") 90 | println(" --version: Print the version of the server") 91 | println(" version: Print the version, commit and date of the server") 92 | println(" --allowed-contexts=: Comma-separated list of allowed k8s contexts") 93 | println(" If not specified, all contexts are allowed") 94 | } 95 | 96 | func getApp() *app.Builder { 97 | return app. 98 | NewBuilder(). 99 | WithFxOptions( 100 | fx.Provide(func() clientcmd.ClientConfig { 101 | return k8s.GetKubeConfig() 102 | }), 103 | fx.Provide(func() (*kubernetes.Clientset, error) { 104 | return k8s.GetKubeClientset() 105 | }), 106 | fx.Provide(fx.Annotate( 107 | func(listMappingResolvers []list_mapping.ListMappingResolver) k8s.ClientPool { 108 | return k8s.NewClientPool(listMappingResolvers) 109 | }, 110 | fx.ParamTags(list_mapping.MappingResolversTag), 111 | )), 112 | fx.Provide( 113 | list_mapping.AsMappingResolver(func() list_mapping.ListMappingResolver { 114 | return deployment.NewListMappingResolver() 115 | }), 116 | ), 117 | fx.Provide( 118 | list_mapping.AsMappingResolver(func() list_mapping.ListMappingResolver { 119 | return service.NewListMappingResolver() 120 | }), 121 | ), 122 | ). 123 | WithTool(tools.NewPodLogsTool). 124 | WithTool(tools.NewListContextsTool). 125 | WithTool(tools.NewListNamespacesTool). 126 | WithTool(tools.NewListResourcesTool). 127 | WithTool(tools.NewGetResourceTool). 128 | WithTool(tools.NewListNodesTool). 129 | WithTool(tools.NewListEventsTool). 130 | WithTool(tools.NewPodExecCommandTool). 131 | WithPrompt(prompts.NewListPodsPrompt). 132 | WithPrompt(prompts.NewListNamespacesPrompt). 133 | WithResourceProvider(resources.NewContextsResourceProvider). 134 | WithServerCapabilities(getCapabilities()). 135 | // setting up server 136 | WithName("mcp-k8s-go"). 137 | WithVersion(version). 138 | WithTransport(stdio.NewTransport()). 139 | // Configuring fx logging to only show errors 140 | WithFxOptions( 141 | fx.Provide(func() *zap.Logger { 142 | cfg := zap.NewDevelopmentConfig() 143 | cfg.Level.SetLevel(zap.ErrorLevel) 144 | logger, _ := cfg.Build() 145 | return logger 146 | }), 147 | fx.Option(fx.WithLogger( 148 | func(logger *zap.Logger) fxevent.Logger { 149 | return &fxevent.ZapLogger{Logger: logger} 150 | }, 151 | )), 152 | ) 153 | } 154 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/require" 13 | "github.com/strowk/foxy-contexts/pkg/foxytest" 14 | "github.com/strowk/mcp-k8s-go/internal/k8s" 15 | 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | ) 18 | 19 | func TestListContexts(t *testing.T) { 20 | ts, err := foxytest.Read("testdata/k8s_contexts") 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | require.NoError(t, os.Setenv("KUBECONFIG", "./testdata/k8s_contexts/kubeconfig")) 25 | defer func() { require.NoError(t, os.Unsetenv("KUBECONFIG")) }() 26 | ts.WithExecutable("go", []string{"run", "main.go"}) 27 | cntrl := foxytest.NewTestRunner(t) 28 | ts.Run(cntrl) 29 | ts.AssertNoErrors(cntrl) 30 | } 31 | 32 | func TestWithAllowedContexts(t *testing.T) { 33 | ts, err := foxytest.Read("testdata/allowed_contexts") 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | require.NoError(t, os.Setenv("KUBECONFIG", "./testdata/k8s_contexts/kubeconfig")) 38 | defer func() { require.NoError(t, os.Unsetenv("KUBECONFIG")) }() 39 | ts.WithExecutable("go", []string{"run", "main.go", "--allowed-contexts=allowed-ctx"}) 40 | cntrl := foxytest.NewTestRunner(t) 41 | ts.Run(cntrl) 42 | ts.AssertNoErrors(cntrl) 43 | } 44 | 45 | func TestInitialize(t *testing.T) { 46 | ts, err := foxytest.Read("testdata/initialize") 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | ts.WithLogging() 51 | ts.WithExecutable("go", []string{"run", "main.go"}) 52 | cntrl := foxytest.NewTestRunner(t) 53 | ts.Run(cntrl) 54 | ts.AssertNoErrors(cntrl) 55 | } 56 | 57 | func TestLists(t *testing.T) { 58 | ts, err := foxytest.Read("testdata") 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | ts.WithLogging() 63 | ts.WithExecutable("go", []string{"run", "main.go"}) 64 | cntrl := foxytest.NewTestRunner(t) 65 | ts.Run(cntrl) 66 | ts.AssertNoErrors(cntrl) 67 | } 68 | 69 | const k3dClusterName = "mcp-k8s-integration-test" 70 | 71 | func TestInK3dCluster(t *testing.T) { 72 | testSuites := []string{ 73 | "testdata/with_k3d", 74 | "internal/k8s/apps/v1/deployment", 75 | "internal/k8s/core/v1/pod", 76 | "internal/k8s/core/v1/node", 77 | "internal/k8s/core/v1/service", 78 | } 79 | 80 | withK3dCluster(t, k3dClusterName, func() { 81 | nginxImage := "nginx:1.27.3" 82 | busyboxImage := "busybox:1.37.0" 83 | 84 | preloadImage(t, nginxImage, k3dClusterName) 85 | preloadImage(t, busyboxImage, k3dClusterName) 86 | createTestNamespace(t, "test") 87 | createPod(t, "nginx", nginxImage) 88 | createPod(t, "busybox", busyboxImage, "--", "sh", "-c", "echo HELLO ; tail -f /dev/null") 89 | createPodService(t, "nginx", "nginx-headless", "None") 90 | 91 | // wait to make sure that more than a second passes for log test 92 | // (see more in get_k8s_pod_logs_test.yaml) 93 | cmd := exec.Command("kubectl", "wait", "--for=condition=Ready", "--timeout=5m", "pod", "busybox", "-n", "test") 94 | cmd.Stderr = os.Stderr 95 | err := cmd.Run() 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | time.Sleep(2 * time.Second) 100 | 101 | for _, suite := range testSuites { 102 | ts, err := foxytest.Read(suite) 103 | if err != nil { 104 | t.Fatal(err) 105 | } 106 | 107 | manifestsFolder := fmt.Sprintf("%s/test_manifests", suite) 108 | 109 | // if exists, apply manifests specific to particular testsuite 110 | if _, err := os.Stat(manifestsFolder); err == nil { 111 | namespaceName := fmt.Sprintf("test-%s", path.Base(suite)) 112 | createTestNamespace(t, namespaceName) 113 | cmd := exec.Command("kubectl", "apply", "-f", manifestsFolder) 114 | cmd.Stderr = os.Stderr 115 | err := cmd.Run() 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | } 120 | 121 | ts.WithLogging() 122 | ts.WithExecutable("go", []string{"run", "main.go"}) 123 | cntrl := foxytest.NewTestRunner(t) 124 | ts.Run(cntrl) 125 | ts.AssertNoErrors(cntrl) 126 | } 127 | }) 128 | } 129 | 130 | const kubeconfigPath = "testdata/with_k3d/kubeconfig" 131 | 132 | func withK3dCluster(t *testing.T, name string, fn func()) { 133 | t.Helper() 134 | cmd := exec.Command("k3d", "cluster", "delete", name) 135 | cmd.Stderr = os.Stderr 136 | err := cmd.Run() // precleanup if cluster has leaked from previous test 137 | if err != nil { 138 | t.Logf("error in preclean: %v", err) 139 | } 140 | 141 | defer deleteK3dCluster(t, name) 142 | 143 | t.Log("creating k3d cluster", name) 144 | createK3dCluster(t, name) 145 | saveKubeconfig(t, name) 146 | t.Log("waiting till k3d cluster is ready") 147 | waitForClusterReady(t) 148 | fn() 149 | } 150 | 151 | func createTestNamespace(t *testing.T, name string) { 152 | t.Helper() 153 | cmd := exec.Command("kubectl", "create", "namespace", name) 154 | cmd.Stderr = os.Stderr 155 | err := cmd.Run() 156 | if err != nil { 157 | t.Fatal(err) 158 | } 159 | } 160 | 161 | func createPod(t *testing.T, name string, image string, args ...string) { 162 | t.Helper() 163 | allargs := []string{"-n", "test", "run", name, "--image=" + image, "--restart=Never"} 164 | allargs = append(allargs, args...) 165 | cmd := exec.Command("kubectl", allargs...) 166 | cmd.Stderr = os.Stderr 167 | err := cmd.Run() 168 | if err != nil { 169 | t.Fatal(err) 170 | } 171 | 172 | t.Logf("waiting for pod %s to be running", name) 173 | 174 | // wait for pod to be running 175 | cmd = exec.Command("kubectl", "-n", "test", "wait", "--for=condition=Ready", "--timeout=5m", "pod", name) 176 | cmd.Stderr = os.Stderr 177 | err = cmd.Run() 178 | if err != nil { 179 | t.Fatal(err) 180 | } 181 | } 182 | 183 | func createPodService(t *testing.T, podName string, serviceName string, clusterIp string) { 184 | t.Helper() 185 | cmd := exec.Command("kubectl", "expose", "-n", "test", "pod", podName, "--port", "80", "--target-port", "80", "--name", serviceName, "--cluster-ip", clusterIp) 186 | cmd.Stderr = os.Stderr 187 | err := cmd.Run() 188 | if err != nil { 189 | t.Fatal(err) 190 | } 191 | } 192 | 193 | func createK3dCluster(t *testing.T, name string) { 194 | t.Helper() 195 | cmd := exec.Command("k3d", "cluster", "create", name, "--wait", "--no-lb", "--timeout", "5m") 196 | cmd.Stderr = os.Stderr 197 | err := cmd.Run() 198 | if err != nil { 199 | t.Fatal(err) 200 | } 201 | 202 | saveKubeconfig(t, name) 203 | } 204 | 205 | func saveKubeconfig(t *testing.T, name string) { 206 | require.NoError(t, os.Setenv("KUBECONFIG", kubeconfigPath)) 207 | 208 | // write kubeconfig to file 209 | data, err := exec.Command("k3d", "kubeconfig", "get", name).Output() 210 | if err != nil { 211 | t.Fatal(err) 212 | } 213 | 214 | err = os.WriteFile(kubeconfigPath, data, 0644) 215 | if err != nil { 216 | t.Fatal(err) 217 | } 218 | } 219 | 220 | func waitForClusterReady(t *testing.T) { 221 | // wait till all kube-system pods are running 222 | clients, err := k8s.GetKubeClientset() 223 | if err != nil { 224 | t.Fatal(err) 225 | } 226 | 227 | timeout := time.After(5 * time.Minute) 228 | ticker := time.NewTicker(5 * time.Second) 229 | t.Log("waiting for kube-system pods to be created") 230 | waiting: 231 | for { 232 | select { 233 | case <-timeout: 234 | t.Fatal("timed out waiting for kube-system pods to be created") 235 | case <-ticker.C: 236 | pods, err := clients.CoreV1().Pods("kube-system").List(context.Background(), metav1.ListOptions{}) 237 | if err == nil { 238 | if len(pods.Items) > 0 { 239 | break waiting 240 | } 241 | } else { 242 | t.Logf("error listing pods: %v", err) 243 | } 244 | } 245 | } 246 | 247 | // This is temporarily disabled, as it makes tests slower, while we actually don't need it at the moment 248 | // t.Log("waiting for kube-system pods to start") 249 | // cmd := exec.Command("kubectl", "wait", "--for=condition=Ready", "--timeout=5m", "pod", "--all", "-n", "kube-system") 250 | // cmd.Stderr = os.Stderr 251 | // err = cmd.Run() 252 | // if err != nil { 253 | // t.Fatal(err) 254 | // } 255 | } 256 | 257 | func deleteK3dCluster(t *testing.T, name string) { 258 | t.Helper() 259 | t.Log("deleting k3d cluster", name) 260 | cmd := exec.Command("k3d", "cluster", "delete", name) 261 | cmd.Stderr = os.Stderr 262 | err := cmd.Run() 263 | if err != nil { 264 | t.Error(err) 265 | } 266 | 267 | t.Log("removing kubeconfig file") 268 | // remove kubeconfig file 269 | err = os.Remove(kubeconfigPath) 270 | if err != nil { 271 | t.Error(err) 272 | } 273 | } 274 | 275 | // preloadImage pulls the image and imports it into the k3d cluster 276 | // this is needed to speed up the tests, as repeated runs would reuse 277 | // the image from the local docker cache 278 | func preloadImage(t *testing.T, image string, clusterName string) { 279 | t.Helper() 280 | t.Log("preloading image", image) 281 | cmd := exec.Command("docker", "pull", image) 282 | cmd.Stderr = os.Stderr 283 | err := cmd.Run() 284 | if err != nil { 285 | t.Fatal(err) 286 | } 287 | 288 | cmd = exec.Command("k3d", "image", "import", image, "-c", clusterName) 289 | cmd.Stderr = os.Stderr 290 | err = cmd.Run() 291 | if err != nil { 292 | t.Fatal(err) 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /packages/.gitignore: -------------------------------------------------------------------------------- 1 | **/bin/mcp*** 2 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-darwin-arm64/.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-darwin-arm64/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@strowk/mcp-k8s-darwin-arm64", 3 | "version": "0.3.5", 4 | "os": [ 5 | "darwin" 6 | ], 7 | "cpu": [ 8 | "arm64" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/strowk/mcp-k8s-go.git" 13 | }, 14 | "keywords": [ 15 | "MCP" 16 | ], 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/strowk/mcp-k8s-go/issues" 20 | }, 21 | "homepage": "https://github.com/strowk/mcp-k8s-go#readme", 22 | "description": "MCP Kubernetes Server - Darwin ARM64 build" 23 | } 24 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-darwin-x64/.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-darwin-x64/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@strowk/mcp-k8s-darwin-x64", 3 | "version": "0.3.5", 4 | "os": [ 5 | "darwin" 6 | ], 7 | "cpu": [ 8 | "x64" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/strowk/mcp-k8s-go.git" 13 | }, 14 | "keywords": [ 15 | "MCP" 16 | ], 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/strowk/mcp-k8s-go/issues" 20 | }, 21 | "homepage": "https://github.com/strowk/mcp-k8s-go#readme", 22 | "description": "MCP Kubernetes Server - Darwin x64 build" 23 | } 24 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-linux-arm64/.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-linux-arm64/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@strowk/mcp-k8s-linux-arm64", 3 | "version": "0.3.5", 4 | "os": [ 5 | "linux", 6 | "freebsd" 7 | ], 8 | "cpu": [ 9 | "arm64" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/strowk/mcp-k8s-go.git" 14 | }, 15 | "keywords": [ 16 | "MCP" 17 | ], 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/strowk/mcp-k8s-go/issues" 21 | }, 22 | "homepage": "https://github.com/strowk/mcp-k8s-go#readme", 23 | "description": "MCP Kubernetes Server - Linux ARM64 build" 24 | } 25 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-linux-x64/.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-linux-x64/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@strowk/mcp-k8s-linux-x64", 3 | "version": "0.3.5", 4 | "os": [ 5 | "linux", 6 | "freebsd" 7 | ], 8 | "cpu": [ 9 | "x64" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/strowk/mcp-k8s-go.git" 14 | }, 15 | "keywords": [ 16 | "MCP" 17 | ], 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/strowk/mcp-k8s-go/issues" 21 | }, 22 | "homepage": "https://github.com/strowk/mcp-k8s-go#readme", 23 | "description": "MCP Kubernetes Server - Linux x64 build" 24 | } 25 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-win32-arm64/.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-win32-arm64/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@strowk/mcp-k8s-win32-arm64", 3 | "version": "0.3.5", 4 | "os": [ 5 | "win32" 6 | ], 7 | "cpu": [ 8 | "arm64" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/strowk/mcp-k8s-go.git" 13 | }, 14 | "keywords": [ 15 | "MCP" 16 | ], 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/strowk/mcp-k8s-go/issues" 20 | }, 21 | "homepage": "https://github.com/strowk/mcp-k8s-go#readme", 22 | "description": "MCP Kubernetes Server - Windows ARM64 build" 23 | } 24 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-win32-x64/.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} -------------------------------------------------------------------------------- /packages/npm-mcp-k8s-win32-x64/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@strowk/mcp-k8s-win32-x64", 3 | "version": "0.3.5", 4 | "os": [ 5 | "win32" 6 | ], 7 | "cpu": [ 8 | "x64" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/strowk/mcp-k8s-go.git" 13 | }, 14 | "keywords": [ 15 | "MCP" 16 | ], 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/strowk/mcp-k8s-go/issues" 20 | }, 21 | "homepage": "https://github.com/strowk/mcp-k8s-go#readme", 22 | "description": "MCP Kubernetes Server - Windows x64 build" 23 | } 24 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s/.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} -------------------------------------------------------------------------------- /packages/npm-mcp-k8s/README.md: -------------------------------------------------------------------------------- 1 | # MCP K8S 2 | 3 | This is a distribution of MCP server connecting to Kubernetes written in Golang and published to npm. 4 | 5 | Currently available: 6 | 7 | | 💬 prompt | 🗂️ resource | 🤖 tool | 8 | 9 | - 🗂️🤖 List Kubernetes contexts 10 | - 💬🤖 List Kubernetes pods 11 | - 💬🤖 List Kubernetes namespaces 12 | - 🤖 List Kubernetes nodes 13 | - 🤖 List and get Kubernetes resources 14 | - includes custom mappings for resources like pods, services, deployments, but any resource can be listed and retrieved 15 | - 🤖 Get Kubernetes events 16 | - 🤖 Get Kubernetes pod logs 17 | - 🤖 Run command in Kubernetes pod 18 | 19 | ## Example usage with Claude Desktop 20 | 21 | To use this MCP server with Claude Desktop you would firstly need to install it. 22 | 23 | You have two options at the moment - use pre-built binaries or build it from source. Refer to [sources](https://github.com/strowk/mcp-k8s-go/) to know how to build from sources. 24 | 25 | ### Installing from npm 26 | 27 | ```bash 28 | npm install -g @strowk/mcp-k8s 29 | ``` 30 | 31 | Then check version by running `mcp-k8s --version` and if this printed installed version, you can proceed to add configuration to `claude_desktop_config.json` file: 32 | 33 | ```json 34 | { 35 | "mcpServers": { 36 | "mcp_k8s": { 37 | "command": "mcp-k8s", 38 | "args": [] 39 | } 40 | } 41 | } 42 | ``` 43 | 44 | ### Using from Claude Desktop 45 | 46 | Now you should be able to run Claude Desktop and: 47 | - see K8S contexts available to attach to conversation as a resource 48 | - ask Claude to list contexts 49 | - ask Claude to list pods in a given context and namespace 50 | - ask Claude to list events in a given context and namespace 51 | - ask Claude to read logs of a given pod in a given context and namespace 52 | 53 | ### Demo usage with Claude Desktop 54 | 55 | Following chat with Claude Desktop demonstrates how it looks when selected particular context as a resource and then asked to check pod logs for errors in kube-system namespace: 56 | 57 | ![Claude Desktop](docs/images/claude-desktop-logs.png) 58 | 59 | 60 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s/bin/cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require("path"); 4 | const childProcess = require("child_process"); 5 | 6 | // Lookup table for all platforms and binary distribution packages 7 | const BINARY_DISTRIBUTION_PACKAGES = { 8 | darwin_x64: "mcp-k8s-darwin-x64", 9 | darwin_arm64: "mcp-k8s-darwin-arm64", 10 | linux_x64: "mcp-k8s-linux-x64", 11 | linux_arm64: "mcp-k8s-linux-arm64", 12 | freebsd_x64: "mcp-k8s-linux-x64", 13 | freebsd_arm64: "mcp-k8s-linux-arm64", 14 | win32_x64: "mcp-k8s-win32-x64", 15 | win32_arm64: "mcp-k8s-win32-arm64", 16 | }; 17 | 18 | // Windows binaries end with .exe so we need to special case them. 19 | const binaryName = process.platform === "win32" ? "mcp-k8s-go.exe" : "mcp-k8s-go"; 20 | 21 | // Determine package name for this platform 22 | const platformSpecificPackageName = 23 | BINARY_DISTRIBUTION_PACKAGES[process.platform+"_"+process.arch]; 24 | 25 | function getBinaryPath() { 26 | try { 27 | // Resolving will fail if the optionalDependency was not installed 28 | return require.resolve(`@strowk/${platformSpecificPackageName}/bin/${binaryName}`); 29 | } catch (e) { 30 | return path.join(__dirname, "..", binaryName); 31 | } 32 | } 33 | 34 | childProcess.execFileSync(getBinaryPath(), process.argv.slice(2), { 35 | stdio: "inherit", 36 | }); 37 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s/index.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const childProcess = require("child_process"); 3 | 4 | // Lookup table for all platforms and binary distribution packages 5 | const BINARY_DISTRIBUTION_PACKAGES = { 6 | darwin_x64: "mcp-k8s-darwin-x64", 7 | darwin_arm64: "mcp-k8s-darwin-arm64", 8 | linux_x64: "mcp-k8s-linux-x64", 9 | linux_arm64: "mcp-k8s-linux-arm64", 10 | freebsd_x64: "mcp-k8s-linux-x64", 11 | freebsd_arm64: "mcp-k8s-linux-arm64", 12 | win32_x64: "mcp-k8s-win32-x64", 13 | win32_arm64: "mcp-k8s-win32-arm64", 14 | }; 15 | 16 | // Windows binaries end with .exe so we need to special case them. 17 | const binaryName = process.platform === "win32" ? "mcp-k8s-go.exe" : "mcp-k8s-go"; 18 | 19 | // Determine package name for this platform 20 | const platformSpecificPackageName = 21 | BINARY_DISTRIBUTION_PACKAGES[process.platform+"_"+process.arch]; 22 | 23 | function getBinaryPath() { 24 | try { 25 | // Resolving will fail if the optionalDependency was not installed 26 | return require.resolve(`@strowk/${platformSpecificPackageName}/bin/${binaryName}`); 27 | } catch (e) { 28 | return path.join(__dirname, "..", binaryName); 29 | } 30 | } 31 | 32 | module.exports.runBinary = function (...args) { 33 | childProcess.execFileSync(getBinaryPath(), args, { 34 | stdio: "inherit", 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /packages/npm-mcp-k8s/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@strowk/mcp-k8s", 3 | "version": "0.3.5", 4 | "main": "./index.js", 5 | "bin": { 6 | "mcp-k8s": "bin/cli" 7 | }, 8 | "optionalDependencies": { 9 | "@strowk/mcp-k8s-darwin-x64": "0.3.5", 10 | "@strowk/mcp-k8s-darwin-arm64": "0.3.5", 11 | "@strowk/mcp-k8s-linux-x64": "0.3.5", 12 | "@strowk/mcp-k8s-linux-arm64": "0.3.5", 13 | "@strowk/mcp-k8s-win32-x64": "0.3.5", 14 | "@strowk/mcp-k8s-win32-arm64": "0.3.5" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/strowk/mcp-k8s-go.git" 19 | }, 20 | "keywords": [ 21 | "MCP" 22 | ], 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/strowk/mcp-k8s-go/issues" 26 | }, 27 | "homepage": "https://github.com/strowk/mcp-k8s-go#readme", 28 | "description": "MCP Kubernetes Server - Windows ARM64 build" 29 | } 30 | -------------------------------------------------------------------------------- /packages/publish_npm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Mac 6 | mkdir -p ./packages/npm-mcp-k8s-darwin-x64/bin 7 | cp dist/mcp-k8s-go_darwin_amd64_v1/mcp-k8s-go ./packages/npm-mcp-k8s-darwin-x64/bin/mcp-k8s-go 8 | chmod +x ./packages/npm-mcp-k8s-darwin-x64/bin/mcp-k8s-go 9 | mkdir -p ./packages/npm-mcp-k8s-darwin-arm64/bin 10 | cp dist/mcp-k8s-go_darwin_arm64_v8.0/mcp-k8s-go ./packages/npm-mcp-k8s-darwin-arm64/bin/mcp-k8s-go 11 | chmod +x ./packages/npm-mcp-k8s-darwin-arm64/bin/mcp-k8s-go 12 | 13 | # Linux 14 | mkdir -p ./packages/npm-mcp-k8s-linux-x64/bin 15 | cp dist/mcp-k8s-go_linux_amd64_v1/mcp-k8s-go ./packages/npm-mcp-k8s-linux-x64/bin/mcp-k8s-go 16 | chmod +x ./packages/npm-mcp-k8s-linux-x64/bin/mcp-k8s-go 17 | mkdir -p ./packages/npm-mcp-k8s-linux-arm64/bin 18 | cp dist/mcp-k8s-go_linux_arm64_v8.0/mcp-k8s-go ./packages/npm-mcp-k8s-linux-arm64/bin/mcp-k8s-go 19 | chmod +x ./packages/npm-mcp-k8s-linux-arm64/bin/mcp-k8s-go 20 | 21 | # Windows 22 | mkdir -p ./packages/npm-mcp-k8s-win32-x64/bin 23 | cp dist/mcp-k8s-go_windows_amd64_v1/mcp-k8s-go.exe ./packages/npm-mcp-k8s-win32-x64/bin/mcp-k8s-go.exe 24 | mkdir -p ./packages/npm-mcp-k8s-win32-arm64/bin 25 | cp dist/mcp-k8s-go_windows_arm64_v8.0/mcp-k8s-go.exe ./packages/npm-mcp-k8s-win32-arm64/bin/mcp-k8s-go.exe 26 | 27 | cd packages/npm-mcp-k8s-darwin-x64 28 | npm publish --access public 29 | 30 | cd ../npm-mcp-k8s-darwin-arm64 31 | npm publish --access public 32 | 33 | cd ../npm-mcp-k8s-linux-x64 34 | npm publish --access public 35 | 36 | cd ../npm-mcp-k8s-linux-arm64 37 | npm publish --access public 38 | 39 | cd ../npm-mcp-k8s-win32-x64 40 | npm publish --access public 41 | 42 | cd ../npm-mcp-k8s-win32-arm64 43 | npm publish --access public 44 | 45 | cd ../npm-mcp-k8s 46 | npm publish --access public 47 | 48 | cd - -------------------------------------------------------------------------------- /packages/update_versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | previous_version="$(npm view ./packages/npm-mcp-k8s version)" 4 | new_version="${1}" 5 | 6 | if [ -z "$new_version" ]; then 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | # replace previous version with new version in all .json files in ./packages folder 12 | find ./packages -type f -name '*.json' -exec sed -i '' -e "s/${previous_version}/${new_version}/g" {} \; 13 | 14 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: [] 9 | properties: 10 | kubeconfigPath: 11 | type: string 12 | default: ~/.kube/config 13 | description: Path to your Kubernetes configuration file. 14 | commandFunction: 15 | # A function that produces the CLI command to start the MCP on stdio. 16 | |- 17 | (config) => ({ command: '/app/mcp-k8s-go', env: { KUBECONFIG: config.kubeconfigPath || '~/.kube/config' } }) 18 | -------------------------------------------------------------------------------- /synf.toml: -------------------------------------------------------------------------------- 1 | # language is used to determine the default paths to watch for changes 2 | # and the default command to run the server. 3 | # Possible values are "typescript", "python", "kotlin" and "golang" 4 | language = "golang" 5 | 6 | # Resending resource subscriptions can be enabled to make synf 7 | # parse all incoming requests and cache any resource subscriptions 8 | # and later resend them after server restart, defaults to false 9 | # resend_resource_subscriptions = false 10 | 11 | [build] 12 | # command and args are used to specify the command to build the server after changes. 13 | # These are the default values for golang: 14 | 15 | # command = "" 16 | # args = [""] 17 | 18 | [run] 19 | # command and args are used to specify the command to run the server 20 | # during development after it has been rebuilt 21 | # These are the default values for golang: 22 | 23 | # command = "go" 24 | # args = ["run", "main.go"] 25 | 26 | [watch] 27 | # Watch configurations are used to specify the files and directories to watch for changes 28 | # when hot reloading the server during development 29 | 30 | # default_paths are the paths that are watched by default 31 | # and are defined by the language that is being used. 32 | # You can override the default paths by specifying them here. 33 | # These are the paths that are watched by default for golang: 34 | 35 | # default_paths = ["go.mod"] 36 | 37 | # extra_paths are the paths that are watched in addition to the default paths. 38 | # You can use it to add more paths to watch for changes besides the default paths. 39 | extra_paths = ["internal", "main.go"] 40 | -------------------------------------------------------------------------------- /testdata/allowed_contexts/allowed_contexts_test.yaml: -------------------------------------------------------------------------------- 1 | case: List namespaces with disallowed context 2 | in: 3 | { "jsonrpc": "2.0", "method": "tools/call", "id": 2, "params": { "name": "list-k8s-namespaces", "arguments": { "context": "disallowed-ctx" } } } 4 | out: 5 | { 6 | "jsonrpc": "2.0", 7 | "id": 2, 8 | "result": 9 | { 10 | "content": [{"type": "text", "text": "context disallowed-ctx is not allowed"}], 11 | "isError": true 12 | } 13 | } 14 | 15 | --- 16 | case: List k8s resources with disallowed context 17 | in: 18 | { "jsonrpc": "2.0", "method": "tools/call", "id": 3, "params": { "name": "list-k8s-resources", "arguments": { "context": "disallowed-ctx", "kind": "pod", "namespace": "default" } } } 19 | out: 20 | { 21 | "jsonrpc": "2.0", 22 | "id": 3, 23 | "result": 24 | { 25 | "content": [{"type": "text", "text": "context disallowed-ctx is not allowed"}], 26 | "isError": true 27 | } 28 | } 29 | 30 | --- 31 | case: Get pod logs with disallowed context 32 | in: 33 | { 34 | "jsonrpc": "2.0", 35 | "method": "tools/call", 36 | "id": 4, 37 | "params": 38 | { 39 | "name": "get-k8s-pod-logs", 40 | "arguments": { 41 | "context": "disallowed-ctx", 42 | "namespace": "default", 43 | "pod": "example-pod" 44 | } 45 | } 46 | } 47 | out: 48 | { 49 | "jsonrpc": "2.0", 50 | "id": 4, 51 | "result": 52 | { 53 | "content": [{"type": "text", "text": "context disallowed-ctx is not allowed"}], 54 | "isError": true 55 | } 56 | } -------------------------------------------------------------------------------- /testdata/initialize/init_test.yaml: -------------------------------------------------------------------------------- 1 | case: "Initialize" 2 | 3 | in: 4 | { 5 | "jsonrpc": "2.0", 6 | "id": 1, 7 | "method": "initialize", 8 | "params": 9 | { 10 | "protocolVersion": "2024-11-05", 11 | "capabilities": { "roots": { "listChanged": true } }, 12 | "clientInfo": { "name": "Test client", "version": "0.0.42" }, 13 | }, 14 | } 15 | out: 16 | { 17 | "jsonrpc": "2.0", 18 | "result": 19 | { 20 | "capabilities": 21 | { 22 | "prompts": { "listChanged": false }, 23 | "resources": { "listChanged": false, "subscribe": false }, 24 | "tools": { "listChanged": false }, 25 | }, 26 | "protocolVersion": "2024-11-05", 27 | "serverInfo": { "name": "mcp-k8s-go", "version": !!re ".*" }, 28 | }, 29 | "id": 1, 30 | } 31 | --- 32 | in: { "method": "notifications/initialized", "jsonrpc": "2.0" } 33 | -------------------------------------------------------------------------------- /testdata/k8s_contexts/kubeconfig: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | contexts: 3 | - context: 4 | cluster: test-cluster 5 | user: test-user 6 | name: test-context 7 | current-context: test-context 8 | kind: Config 9 | -------------------------------------------------------------------------------- /testdata/k8s_contexts/list_k8s_contexts_test.yaml: -------------------------------------------------------------------------------- 1 | in: 2 | { 3 | "jsonrpc": "2.0", 4 | "method": "tools/call", 5 | "id": 2, 6 | "params": { "name": "list-k8s-contexts" }, 7 | } 8 | out: 9 | { 10 | "jsonrpc": "2.0", 11 | "id": 2, 12 | "result": 13 | { 14 | "content": 15 | [ 16 | { 17 | "type": "text", 18 | "text": '{"context":{"cluster":"test-cluster","user":"test-user"},"name":"test-cluster","current":true}', 19 | }, 20 | ], 21 | "isError": false, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /testdata/list_prompts_test.yaml: -------------------------------------------------------------------------------- 1 | case: List Prompts 2 | in: { "jsonrpc": "2.0", "method": "prompts/list", "id": 1, "params": {} } 3 | out: 4 | { 5 | "id": 1, 6 | "jsonrpc": "2.0", 7 | "result": 8 | { 9 | "prompts": [ 10 | { 11 | "name": "list-k8s-namespaces", 12 | "description": "List Kubernetes Namespaces in the specified context", 13 | "arguments": [ 14 | { 15 | "description": "Context to list namespaces in, defaults to current context", 16 | "name": "context", 17 | "required": false, 18 | }, 19 | ] 20 | }, 21 | { 22 | "name": "list-k8s-pods", 23 | "description": "List Kubernetes Pods with name and namespace in the current context", 24 | "arguments": [ 25 | { 26 | "description": "Namespace to list Pods from, defaults to all namespaces", 27 | "name": "namespace", 28 | "required": false, 29 | }, 30 | ] 31 | } 32 | ] 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /testdata/list_tools_test.yaml: -------------------------------------------------------------------------------- 1 | in: { "jsonrpc": "2.0", "method": "tools/list", "id": 1, "params": {} } 2 | out: 3 | { 4 | "id": 1, 5 | "jsonrpc": "2.0", 6 | "result": 7 | { 8 | "tools": 9 | [ 10 | { 11 | "name": "get-k8s-pod-logs", 12 | "description": "Get logs for a Kubernetes pod using specific context in a specified namespace", 13 | "inputSchema": 14 | { 15 | "type": "object", 16 | "properties": 17 | { 18 | "context": 19 | { 20 | "description": "Name of the Kubernetes context to use", 21 | "type": "string", 22 | }, 23 | "namespace": 24 | { 25 | "description": "Name of the namespace where the pod is located", 26 | "type": "string", 27 | }, 28 | "pod": 29 | { 30 | "description": "Name of the pod to get logs from", 31 | "type": "string", 32 | }, 33 | "previousContainer": 34 | { 35 | "description": "Return previous terminated container logs, defaults to false.", 36 | "type": "boolean", 37 | }, 38 | "sinceDuration": 39 | { 40 | "description": "Only return logs newer than a relative duration like 5s, 2m, or 3h. Only one of sinceTime or sinceDuration may be set.", 41 | "type": "string", 42 | }, 43 | "sinceTime": 44 | { 45 | "description": "Only return logs after a specific date (RFC3339). Only one of sinceTime or sinceDuration may be set.", 46 | "type": "string", 47 | }, 48 | }, 49 | "required": ["context", "namespace", "pod"], 50 | }, 51 | }, 52 | { 53 | "name": "get-k8s-resource", 54 | "description": "Get details of any Kubernetes resource like pod, node or service - completely as JSON or rendered using template", 55 | "inputSchema": 56 | { 57 | "type": "object", 58 | "properties": 59 | { 60 | "context": 61 | { 62 | "type": "string", 63 | "description": "Name of the Kubernetes context to use, defaults to current context", 64 | }, 65 | "namespace": 66 | { 67 | "type": "string", 68 | "description": "Namespace to get resource from, skip for cluster resources", 69 | }, 70 | "name": 71 | { 72 | "type": "string", 73 | "description": "Name of the resource to get", 74 | }, 75 | "group": 76 | { 77 | "type": "string", 78 | "description": "API Group of the resource to get", 79 | }, 80 | "version": 81 | { 82 | "type": "string", 83 | "description": "API Version of the resource to get", 84 | }, 85 | "kind": 86 | { 87 | "type": "string", 88 | "description": "Kind of resource to get", 89 | }, 90 | }, 91 | "required": ["kind", "name"], 92 | }, 93 | }, 94 | { 95 | "name": "k8s-pod-exec", 96 | "description": "Execute command in Kubernetes pod", 97 | "inputSchema": 98 | { 99 | "type": "object", 100 | "properties": 101 | { 102 | "context": 103 | { 104 | "type": "string", 105 | "description": "Kubernetes context name, defaults to current context", 106 | }, 107 | "namespace": 108 | { 109 | "type": "string", 110 | "description": "Namespace where pod is located", 111 | }, 112 | "pod": 113 | { 114 | "type": "string", 115 | "description": "Name of the pod to execute command in", 116 | }, 117 | "command": 118 | { 119 | "type": "string", 120 | "description": "Command to be executed", 121 | }, 122 | "stdin": 123 | { 124 | "type": "string", 125 | "description": "Standard input to the command, defaults to empty string", 126 | }, 127 | }, 128 | }, 129 | }, 130 | { 131 | "name": "list-k8s-contexts", 132 | "description": "List Kubernetes contexts from configuration files such as kubeconfig", 133 | "inputSchema": { "type": "object" }, 134 | }, 135 | { 136 | "name": "list-k8s-events", 137 | "description": "List Kubernetes events using specific context in a specified namespace", 138 | "inputSchema": 139 | { 140 | "type": "object", 141 | "properties": 142 | { 143 | "context": 144 | { 145 | "type": "string", 146 | "description": "Name of the Kubernetes context to use", 147 | }, 148 | "namespace": 149 | { 150 | "type": "string", 151 | "description": "Name of the namespace to list events from", 152 | }, 153 | "limit": 154 | { 155 | "type": "number", 156 | "description": "Maximum number of events to list", 157 | }, 158 | }, 159 | "required": ["context", "namespace"], 160 | }, 161 | }, 162 | { 163 | "name": "list-k8s-namespaces", 164 | "description": "List Kubernetes namespaces using specific context", 165 | "inputSchema": 166 | { 167 | "type": "object", 168 | "properties": 169 | { 170 | "context": 171 | { 172 | "type": "string", 173 | "description": "Name of the Kubernetes context to use, defaults to current context", 174 | }, 175 | }, 176 | }, 177 | }, 178 | { 179 | "name": "list-k8s-nodes", 180 | "description": "List Kubernetes nodes using specific context", 181 | "inputSchema": 182 | { 183 | "type": "object", 184 | "properties": 185 | { 186 | "context": 187 | { 188 | "type": "string", 189 | "description": "Name of the Kubernetes context to use, defaults to current context", 190 | }, 191 | }, 192 | }, 193 | }, 194 | 195 | { 196 | "name": "list-k8s-resources", 197 | "description": "List arbitrary Kubernetes resources", 198 | "inputSchema": 199 | { 200 | "type": "object", 201 | "properties": 202 | { 203 | "context": 204 | { 205 | "type": "string", 206 | "description": "Name of the Kubernetes context to use, defaults to current context", 207 | }, 208 | "namespace": 209 | { 210 | "type": "string", 211 | "description": "Namespace to list resources from, defaults to all namespaces", 212 | }, 213 | "group": 214 | { 215 | "type": "string", 216 | "description": "API Group of resources to list", 217 | }, 218 | "version": 219 | { 220 | "type": "string", 221 | "description": "API Version of resources to list", 222 | }, 223 | "kind": 224 | { 225 | "type": "string", 226 | "description": "Kind of resources to list", 227 | }, 228 | }, 229 | }, 230 | }, 231 | ], 232 | }, 233 | } 234 | -------------------------------------------------------------------------------- /testdata/with_k3d/.gitignore: -------------------------------------------------------------------------------- 1 | kubeconfig -------------------------------------------------------------------------------- /testdata/with_k3d/get_k8s_pod_logs_test.yaml: -------------------------------------------------------------------------------- 1 | case: Read logs from a single busybox pod 2 | 3 | in: 4 | { 5 | "jsonrpc": "2.0", 6 | "method": "tools/call", 7 | "id": 2, 8 | "params": 9 | { 10 | "name": "get-k8s-pod-logs", 11 | "arguments": 12 | { 13 | "context": "k3d-mcp-k8s-integration-test", 14 | "namespace": "test", 15 | "pod": "busybox", 16 | }, 17 | }, 18 | } 19 | out: 20 | { 21 | "jsonrpc": "2.0", 22 | "id": 2, 23 | "result": 24 | { "content": [{ "type": "text", "text": "HELLO\n" }], "isError": false }, 25 | } 26 | 27 | --- 28 | case: Fail reading logs from a non-existing pod 29 | 30 | in: 31 | { 32 | "jsonrpc": "2.0", 33 | "method": "tools/call", 34 | "id": 2, 35 | "params": 36 | { 37 | "name": "get-k8s-pod-logs", 38 | "arguments": 39 | { 40 | "context": "k3d-mcp-k8s-integration-test", 41 | "namespace": "test", 42 | "pod": "nonexistingpod", 43 | }, 44 | }, 45 | } 46 | out: 47 | { 48 | "jsonrpc": "2.0", 49 | "id": 2, 50 | "result": 51 | { 52 | "content": 53 | [{ "type": "text", "text": 'pods "nonexistingpod" not found' }], 54 | "isError": true, 55 | }, 56 | } 57 | 58 | --- 59 | case: Read logs with sinceDuration filter from a single busybox pod 60 | 61 | in: 62 | { 63 | "jsonrpc": "2.0", 64 | "method": "tools/call", 65 | "id": 2, 66 | "params": 67 | { 68 | "name": "get-k8s-pod-logs", 69 | "arguments": 70 | { 71 | "context": "k3d-mcp-k8s-integration-test", 72 | "namespace": "test", 73 | "pod": "busybox", 74 | "sinceDuration": "1s", 75 | }, 76 | }, 77 | } 78 | out: 79 | # expectation is that the logs are empty, since the pod was created before the sinceDuration 80 | # , however, k8s does not allow for filter to be 0s, so there is still chance 81 | # that this would fail... but it is very unlikely 82 | { 83 | "jsonrpc": "2.0", 84 | "id": 2, 85 | "result": { "content": [{ "type": "text", "text": "" }], "isError": false }, 86 | } 87 | 88 | --- 89 | case: Read logs with sinceTime filter from a single busybox pod 90 | 91 | in: 92 | { 93 | "jsonrpc": "2.0", 94 | "method": "tools/call", 95 | "id": 2, 96 | "params": 97 | { 98 | "name": "get-k8s-pod-logs", 99 | "arguments": 100 | { 101 | "context": "k3d-mcp-k8s-integration-test", 102 | "namespace": "test", 103 | "pod": "busybox", 104 | "sinceTime": "2021-01-01T00:00:00Z", 105 | }, 106 | }, 107 | } 108 | out: 109 | { 110 | "jsonrpc": "2.0", 111 | "id": 2, 112 | "result": 113 | { "content": [{ "type": "text", "text": "HELLO\n" }], "isError": false }, 114 | } 115 | -------------------------------------------------------------------------------- /testdata/with_k3d/list_k8s_contexts_test.yaml: -------------------------------------------------------------------------------- 1 | in: 2 | { 3 | "jsonrpc": "2.0", 4 | "method": "tools/call", 5 | "id": 2, 6 | "params": { "name": "list-k8s-contexts" }, 7 | } 8 | out: 9 | { 10 | "jsonrpc": "2.0", 11 | "id": 2, 12 | "result": 13 | { 14 | "content": 15 | [ 16 | { 17 | "type": "text", 18 | "text": '{"context":{"cluster":"k3d-mcp-k8s-integration-test","user":"admin@k3d-mcp-k8s-integration-test"},"name":"k3d-mcp-k8s-integration-test","current":true}', 19 | }, 20 | ], 21 | "isError": false, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /testdata/with_k3d/list_k8s_events_test.yaml: -------------------------------------------------------------------------------- 1 | case: List k8s events with missing required property 2 | 3 | in: 4 | { 5 | "jsonrpc": "2.0", 6 | "method": "tools/call", 7 | "id": 2, 8 | "params": 9 | { 10 | "name": "list-k8s-events", 11 | "arguments": { "context": "k3d-mcp-k8s-integration-test" }, 12 | }, 13 | } 14 | out: 15 | { 16 | "jsonrpc": "2.0", 17 | "id": 2, 18 | "result": 19 | { 20 | "content": [{ "type": "text", "text": "missing required property" }], 21 | "isError": true, 22 | }, 23 | } 24 | 25 | --- 26 | 27 | case: List k8s events 28 | 29 | in: 30 | { 31 | "jsonrpc": "2.0", 32 | "method": "tools/call", 33 | "id": 2, 34 | "params": 35 | { 36 | "name": "list-k8s-events", 37 | "arguments": 38 | { 39 | "context": "k3d-mcp-k8s-integration-test", 40 | "namespace": "test", 41 | "limit": 1, 42 | }, 43 | }, 44 | } 45 | 46 | out: 47 | { 48 | "jsonrpc": "2.0", 49 | "id": 2, 50 | "result": 51 | { 52 | "content": 53 | [ 54 | { 55 | "type": "text", 56 | "text": '{"action":"Binding","message":"Successfully assigned test/busybox to k3d-mcp-k8s-integration-test-server-0","type":"Normal","reason":"Scheduled","involvedObject":{"kind":"Pod","name":"busybox"}}', 57 | }, 58 | ], 59 | "isError": false, 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /testdata/with_k3d/list_k8s_namespaces_test.yaml: -------------------------------------------------------------------------------- 1 | case: List k8s namespaces using tool 2 | 3 | in: 4 | { 5 | "jsonrpc": "2.0", 6 | "method": "tools/call", 7 | "id": 2, 8 | "params": 9 | { 10 | "name": "list-k8s-namespaces", 11 | "arguments": 12 | { "context": "k3d-mcp-k8s-integration-test" }, 13 | }, 14 | } 15 | out: 16 | { 17 | "jsonrpc": "2.0", 18 | "id": 2, 19 | "result": 20 | { 21 | "content": 22 | [ 23 | { 24 | "type": "text", 25 | "text": '{"name":"default"}', 26 | }, 27 | { 28 | "type": "text", 29 | "text": '{"name":"kube-node-lease"}', 30 | }, 31 | { 32 | "type": "text", 33 | "text": '{"name":"kube-public"}', 34 | }, 35 | { 36 | "type": "text", 37 | "text": '{"name":"kube-system"}', 38 | }, 39 | { 40 | "type": "text", 41 | "text": '{"name":"test"}', 42 | }, 43 | ], 44 | "isError": false, 45 | }, 46 | } 47 | 48 | --- 49 | 50 | case: List k8s namespaces using prompt 51 | 52 | in: { 53 | "jsonrpc": "2.0", 54 | "method": "prompts/get", 55 | "id": 3, 56 | "params": { 57 | "name": "list-k8s-namespaces" 58 | } 59 | } 60 | 61 | out: { 62 | "jsonrpc": "2.0", 63 | "id": 3, 64 | "result": { 65 | "description": "Namespaces, context 'k3d-mcp-k8s-integration-test'", 66 | "messages": [ 67 | { 68 | "content": { 69 | "type": "text", 70 | "text": "There are 5 namespaces, context 'k3d-mcp-k8s-integration-test':" 71 | }, 72 | "role": "user" 73 | }, 74 | { 75 | "content": { 76 | "type": "text", 77 | "text": "{\"name\":\"default\"}" 78 | }, 79 | "role": "user" 80 | }, 81 | { 82 | "content": { 83 | "type": "text", 84 | "text": "{\"name\":\"kube-node-lease\"}" 85 | }, 86 | "role": "user" 87 | }, 88 | { 89 | "content": { 90 | "type": "text", 91 | "text": "{\"name\":\"kube-public\"}" 92 | }, 93 | "role": "user" 94 | }, 95 | { 96 | "content": { 97 | "type": "text", 98 | "text": "{\"name\":\"kube-system\"}" 99 | }, 100 | "role": "user" 101 | }, 102 | { 103 | "content": { 104 | "type": "text", 105 | "text": "{\"name\":\"test\"}" 106 | }, 107 | "role": "user" 108 | } 109 | ] 110 | } 111 | } -------------------------------------------------------------------------------- /testdata/with_k3d/list_k8s_nodes_test.yaml: -------------------------------------------------------------------------------- 1 | case: List nodes using tool 2 | 3 | in: 4 | { 5 | "jsonrpc": "2.0", 6 | "method": "tools/call", 7 | "id": 2, 8 | "params": 9 | { 10 | "name": "list-k8s-nodes", 11 | "arguments": 12 | { "context": "k3d-mcp-k8s-integration-test" }, 13 | }, 14 | } 15 | out: 16 | { 17 | "jsonrpc": "2.0", 18 | "id": 2, 19 | "result": 20 | { 21 | "content": 22 | [ 23 | { 24 | "type": "text", 25 | "text": !!ere '{"name":"k3d-mcp-k8s-integration-test-server-0","status":"Ready","age":"/[0-9sm]{2,4}/","created_at":"/.+/"}', 26 | # ^ this is a pattern, this ^ too 27 | # this just to match a duration // and this is for timestamp 28 | } 29 | ], 30 | "isError": false, 31 | }, 32 | } 33 | 34 | --- 35 | 36 | case: List nodes using tool with current context 37 | 38 | in: 39 | { 40 | "jsonrpc": "2.0", 41 | "method": "tools/call", 42 | "id": 2, 43 | "params": 44 | { 45 | "name": "list-k8s-nodes", 46 | }, 47 | } 48 | out: 49 | { 50 | "jsonrpc": "2.0", 51 | "id": 2, 52 | "result": 53 | { 54 | "content": 55 | [ 56 | { 57 | "type": "text", 58 | "text": !!ere '{"name":"k3d-mcp-k8s-integration-test-server-0","status":"Ready","age":"/[0-9sm]{2,4}/","created_at":"/.+/"}', 59 | # ^ this is a pattern, this ^ too 60 | # this just to match a duration // and this is for timestamp 61 | } 62 | ], 63 | "isError": false, 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /tools/generate-mocks.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | 3 | go install go.uber.org/mock/mockgen@latest 4 | mockgen -source=internal/k8s/pool.go -destination=internal/k8s/mock/pool_mock.go -------------------------------------------------------------------------------- /tools/inspector/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /tools/inspector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@modelcontextprotocol/inspector": "^0.7.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tools/inspector/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd tools/inspector 4 | npm install 5 | # npx @modelcontextprotocol/inspector "$@" 6 | 7 | npx @modelcontextprotocol/inspector go run -e KUBECONFIG=$KUBECONFIG ../../main.go "$@" 8 | -------------------------------------------------------------------------------- /tools/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | new_version="${1}" 6 | 7 | if [ -z "$new_version" ]; then 8 | echo "Usage: $0 " 9 | echo "hint: last tag is $(git describe --tags --abbrev=0)" 10 | exit 1 11 | fi 12 | 13 | # drop the v prefix 14 | new_version="${new_version#v}" 15 | 16 | # check that new version is X.Y.Z or X.Y.Z-beta.N 17 | if ! echo "$new_version" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$' > /dev/null; then 18 | echo "Version should be in format X.Y.Z where X, Y, Z are numbers, or X.Y.Z-beta.N" 19 | exit 1 20 | fi 21 | 22 | packages/update_versions.sh $new_version 23 | git add ./packages 24 | git commit -m "chore: update npm packages versions to $new_version" --no-verify && git push --no-verify || true 25 | 26 | git tag -a "v$new_version" -m "release v$new_version" 27 | git push --no-verify origin "v$new_version" 28 | -------------------------------------------------------------------------------- /tools/use-context.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | kubectl config use-context k3d-mcp-k8s-test --------------------------------------------------------------------------------