├── src ├── tools │ ├── interface.go │ ├── tools.go │ ├── client.go │ ├── list_contexts.go │ ├── mock_test.go │ ├── logs_test.go │ ├── describe.go │ ├── list_contexts_test.go │ ├── logs.go │ ├── list_events.go │ ├── describe_test.go │ ├── list_test.go │ ├── list_events_test.go │ ├── list.go │ └── testdata │ │ └── apiresources.yaml ├── client │ ├── kubernetes.go │ └── multi_cluster.go └── validation │ └── validation.go ├── .gitignore ├── main.go ├── LICENSE ├── .github └── workflows │ └── test.yml ├── go.mod ├── README.md └── go.sum /src/tools/interface.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/mark3labs/mcp-go/mcp" 7 | ) 8 | 9 | type Tools interface { 10 | Tool() mcp.Tool 11 | Handler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) 12 | } 13 | -------------------------------------------------------------------------------- /src/tools/tools.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-go/server" 5 | ) 6 | 7 | // RegisterTools は MCPServer に対してツールをまとめて登録します 8 | func RegisterTools(s *server.MCPServer, multiClient MultiClusterClientInterface) { 9 | tools := []Tools{ 10 | NewListTool(multiClient), 11 | NewLogTool(multiClient), 12 | NewDescribeTool(multiClient), 13 | NewListEventsTool(multiClient), 14 | NewListContextsTool(multiClient), 15 | } 16 | for _, t := range tools { 17 | s.AddTool(t.Tool(), t.Handler) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # IDE files 21 | .vscode/ 22 | .idea/ 23 | *.swp 24 | *.swo 25 | *~ 26 | 27 | # OS generated files 28 | .DS_Store 29 | .DS_Store? 30 | ._* 31 | .Spotlight-V100 32 | .Trashes 33 | ehthumbs.db 34 | Thumbs.db 35 | 36 | # Log files 37 | *.log 38 | 39 | # Environment variables 40 | .env 41 | .env.local 42 | .env.*.local 43 | 44 | # Build artifacts 45 | /bin/ 46 | /dist/ 47 | 48 | kubernetes-mcp 49 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mark3labs/mcp-go/server" 8 | "github.com/kkb0318/kubernetes-mcp/src/client" 9 | "github.com/kkb0318/kubernetes-mcp/src/tools" 10 | ) 11 | 12 | const Version = "0.1.0" 13 | 14 | func main() { 15 | s := server.NewMCPServer( 16 | "MCP k8s Server", 17 | Version, 18 | server.WithToolCapabilities(false), 19 | ) 20 | 21 | multiClient, err := client.NewMultiClusterClient() 22 | if err != nil { 23 | fmt.Fprintf(os.Stderr, "Error creating MultiCluster client: %v\n", err) 24 | os.Exit(1) 25 | } 26 | 27 | tools.RegisterTools(s, multiClient) 28 | 29 | if err := server.ServeStdio(s); err != nil { 30 | fmt.Fprintf(os.Stderr, "Error starting MCP server: %v\n", err) 31 | os.Exit(1) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/tools/client.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/api/meta" 5 | "k8s.io/apimachinery/pkg/runtime/schema" 6 | "k8s.io/client-go/discovery" 7 | "k8s.io/client-go/dynamic" 8 | "k8s.io/client-go/kubernetes" 9 | ) 10 | 11 | type Client interface { 12 | DynamicClient() (dynamic.Interface, error) 13 | DiscoClient() (discovery.DiscoveryInterface, error) 14 | RESTMapper() (meta.RESTMapper, error) 15 | Clientset() (*kubernetes.Clientset, error) 16 | ResourceInterface(gvr schema.GroupVersionResource, namespaced bool, ns string) (dynamic.ResourceInterface, error) 17 | } 18 | 19 | // MultiClusterClientInterface for managing multiple cluster connections. 20 | type MultiClusterClientInterface interface { 21 | GetClient(context string) (Client, error) 22 | GetDefaultContext() string 23 | ListContexts() ([]string, error) 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 kkb 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. 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.24' 20 | 21 | - name: Cache Go modules 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/go/pkg/mod 25 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 26 | restore-keys: | 27 | ${{ runner.os }}-go- 28 | 29 | - name: Download dependencies 30 | run: go mod download 31 | 32 | - name: Run tests 33 | run: go test -v ./... 34 | 35 | - name: Run tests with coverage 36 | run: go test -v -coverprofile=coverage.out ./... 37 | 38 | - name: Upload coverage to Codecov (optional) 39 | uses: codecov/codecov-action@v4 40 | with: 41 | file: ./coverage.out 42 | flags: unittests 43 | name: codecov-umbrella 44 | fail_ci_if_error: false -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kkb0318/kubernetes-mcp 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/google/gnostic-models v0.6.9 9 | github.com/mark3labs/mcp-go v0.24.1 10 | github.com/stretchr/testify v1.10.0 11 | k8s.io/api v0.33.0 12 | sigs.k8s.io/yaml v1.4.0 13 | ) 14 | 15 | require ( 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 18 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 19 | github.com/go-logr/logr v1.4.2 // indirect 20 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 21 | github.com/go-openapi/jsonreference v0.20.2 // indirect 22 | github.com/go-openapi/swag v0.23.0 // indirect 23 | github.com/gogo/protobuf v1.3.2 // indirect 24 | github.com/google/go-cmp v0.7.0 // indirect 25 | github.com/josharian/intern v1.0.0 // indirect 26 | github.com/json-iterator/go v1.1.12 // indirect 27 | github.com/mailru/easyjson v0.7.7 // indirect 28 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 29 | github.com/modern-go/reflect2 v1.0.2 // indirect 30 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 31 | github.com/pkg/errors v0.9.1 // indirect 32 | github.com/pmezard/go-difflib v1.0.0 // indirect 33 | github.com/spf13/pflag v1.0.5 // indirect 34 | github.com/x448/float16 v0.8.4 // indirect 35 | golang.org/x/net v0.38.0 // indirect 36 | golang.org/x/oauth2 v0.27.0 // indirect 37 | golang.org/x/sys v0.31.0 // indirect 38 | golang.org/x/term v0.30.0 // indirect 39 | golang.org/x/text v0.23.0 // indirect 40 | golang.org/x/time v0.9.0 // indirect 41 | google.golang.org/protobuf v1.36.5 // indirect 42 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 43 | gopkg.in/inf.v0 v0.9.1 // indirect 44 | gopkg.in/yaml.v3 v3.0.1 // indirect 45 | k8s.io/klog/v2 v2.130.1 // indirect 46 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 47 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 48 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 49 | sigs.k8s.io/randfill v1.0.0 // indirect 50 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 51 | ) 52 | 53 | require ( 54 | github.com/google/uuid v1.6.0 // indirect 55 | github.com/spf13/cast v1.7.1 // indirect 56 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 57 | k8s.io/apimachinery v0.33.0 58 | k8s.io/client-go v0.33.0 59 | ) 60 | -------------------------------------------------------------------------------- /src/client/kubernetes.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "k8s.io/apimachinery/pkg/api/meta" 9 | "k8s.io/apimachinery/pkg/runtime/schema" 10 | "k8s.io/client-go/discovery" 11 | "k8s.io/client-go/discovery/cached/memory" 12 | "k8s.io/client-go/dynamic" 13 | "k8s.io/client-go/kubernetes" 14 | "k8s.io/client-go/rest" 15 | "k8s.io/client-go/restmapper" 16 | "k8s.io/client-go/tools/clientcmd" 17 | ) 18 | 19 | type KubernetesClient struct { 20 | config *rest.Config 21 | } 22 | 23 | func NewKubernetesClient() (*KubernetesClient, error) { 24 | config, err := rest.InClusterConfig() 25 | if err != nil { 26 | // fallback to kubeconfig 27 | var kubeconfig string 28 | if kubeconfigEnv := os.Getenv("KUBECONFIG"); kubeconfigEnv != "" { 29 | kubeconfig = kubeconfigEnv 30 | } else { 31 | // Try multiple ways to find home directory 32 | home := os.Getenv("HOME") 33 | if home == "" { 34 | if userHome, err := os.UserHomeDir(); err == nil { 35 | home = userHome 36 | } 37 | } 38 | kubeconfig = filepath.Join(home, ".kube", "config") 39 | } 40 | config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to load kubeconfig: %w", err) 43 | } 44 | } 45 | return &KubernetesClient{config: config}, nil 46 | } 47 | 48 | func (k *KubernetesClient) DynamicClient() (dynamic.Interface, error) { 49 | return dynamic.NewForConfig(k.config) 50 | } 51 | func (k *KubernetesClient) DiscoClient() (discovery.DiscoveryInterface, error) { 52 | return discovery.NewDiscoveryClientForConfig(k.config) 53 | } 54 | func (k *KubernetesClient) Clientset() (*kubernetes.Clientset, error) { 55 | return kubernetes.NewForConfig(k.config) 56 | } 57 | func (k *KubernetesClient) RESTMapper() (meta.RESTMapper, error) { 58 | disco, err := k.DiscoClient() 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to create discovery client: %w", err) 61 | } 62 | return restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(disco)), nil 63 | } 64 | 65 | func (k *KubernetesClient) ResourceInterface( 66 | gvr schema.GroupVersionResource, 67 | namespaced bool, 68 | ns string, 69 | ) (dynamic.ResourceInterface, error) { 70 | dynClient, err := k.DynamicClient() 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to create dynamic client: %w", err) 73 | } 74 | 75 | if !namespaced { 76 | return dynClient.Resource(gvr), nil 77 | } 78 | return dynClient.Resource(gvr).Namespace(ns), nil 79 | } 80 | -------------------------------------------------------------------------------- /src/tools/list_contexts.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | // ListContextsTool は Kubernetes の context 一覧を返すツールです 12 | type ListContextsTool struct { 13 | multiClient MultiClusterClientInterface 14 | } 15 | 16 | // NewListContextsTool は新しい ListContextsTool インスタンスを作成します 17 | func NewListContextsTool(multiClient MultiClusterClientInterface) *ListContextsTool { 18 | return &ListContextsTool{multiClient: multiClient} 19 | } 20 | 21 | // Tool は MCP ツールの定義を返します 22 | func (t *ListContextsTool) Tool() mcp.Tool { 23 | return mcp.Tool{ 24 | Name: "list_contexts", 25 | Description: "List all available Kubernetes contexts from kubeconfig", 26 | InputSchema: mcp.ToolInputSchema{ 27 | Type: "object", 28 | Properties: map[string]interface{}{}, 29 | }, 30 | } 31 | } 32 | 33 | // ContextInfo は context の情報を表す構造体です 34 | type ContextInfo struct { 35 | Name string `json:"name"` 36 | IsCurrent bool `json:"is_current"` 37 | } 38 | 39 | // ListContextsResponse は list_contexts の応答を表す構造体です 40 | type ListContextsResponse struct { 41 | Contexts []ContextInfo `json:"contexts"` 42 | CurrentContext string `json:"current_context"` 43 | Total int `json:"total"` 44 | } 45 | 46 | // Handler は list_contexts リクエストを処理します 47 | func (t *ListContextsTool) Handler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { 48 | // Get all contexts 49 | contexts, err := t.multiClient.ListContexts() 50 | if err != nil { 51 | return nil, fmt.Errorf("error listing contexts: %w", err) 52 | } 53 | 54 | // Get current context 55 | currentContext := t.multiClient.GetDefaultContext() 56 | 57 | // Build response 58 | contextInfos := make([]ContextInfo, len(contexts)) 59 | for i, contextName := range contexts { 60 | contextInfos[i] = ContextInfo{ 61 | Name: contextName, 62 | IsCurrent: contextName == currentContext, 63 | } 64 | } 65 | 66 | response := ListContextsResponse{ 67 | Contexts: contextInfos, 68 | CurrentContext: currentContext, 69 | Total: len(contexts), 70 | } 71 | 72 | // Convert to JSON 73 | jsonBytes, err := json.MarshalIndent(response, "", " ") 74 | if err != nil { 75 | return nil, fmt.Errorf("error marshaling response: %w", err) 76 | } 77 | 78 | return mcp.NewToolResultText(string(jsonBytes)), nil 79 | } 80 | 81 | // Compile-time verification that ListContextsTool implements Tools interface 82 | var _ Tools = (*ListContextsTool)(nil) -------------------------------------------------------------------------------- /src/validation/validation.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // ValidateResourceName validates Kubernetes resource names 10 | func ValidateResourceName(name string) error { 11 | if name == "" { 12 | return fmt.Errorf("resource name cannot be empty") 13 | } 14 | 15 | // Kubernetes resource names must follow DNS subdomain naming conventions 16 | if len(name) > 253 { 17 | return fmt.Errorf("resource name too long (max 253 characters)") 18 | } 19 | 20 | // Must contain only lowercase alphanumeric characters, '-', or '.' 21 | validName := regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) 22 | if !validName.MatchString(name) { 23 | return fmt.Errorf("invalid resource name: must contain only lowercase alphanumeric characters, '-', or '.'") 24 | } 25 | 26 | return nil 27 | } 28 | 29 | // ValidateNamespace validates Kubernetes namespace names 30 | func ValidateNamespace(namespace string) error { 31 | if namespace == "" { 32 | return nil // Empty namespace is valid (uses default) 33 | } 34 | 35 | if len(namespace) > 63 { 36 | return fmt.Errorf("namespace name too long (max 63 characters)") 37 | } 38 | 39 | // Must contain only lowercase alphanumeric characters or '-' 40 | validNamespace := regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) 41 | if !validNamespace.MatchString(namespace) { 42 | return fmt.Errorf("invalid namespace name: must contain only lowercase alphanumeric characters or '-'") 43 | } 44 | 45 | // Reserved namespaces 46 | reserved := []string{"kube-system", "kube-public", "kube-node-lease"} 47 | for _, r := range reserved { 48 | if namespace == r { 49 | return nil // Reserved namespaces are valid 50 | } 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // ValidateLabelSelector validates Kubernetes label selectors 57 | func ValidateLabelSelector(selector string) error { 58 | if selector == "" { 59 | return nil // Empty selector is valid 60 | } 61 | 62 | // Basic validation for label selector format 63 | // This is a simplified check; Kubernetes has more complex rules 64 | parts := strings.Split(selector, ",") 65 | for _, part := range parts { 66 | part = strings.TrimSpace(part) 67 | if part == "" { 68 | continue 69 | } 70 | 71 | // Check for basic key=value or key!=value format 72 | if !strings.Contains(part, "=") && !strings.Contains(part, "!=") && !strings.Contains(part, " in ") && !strings.Contains(part, " notin ") { 73 | return fmt.Errorf("invalid label selector format: %s", part) 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | 80 | // ValidateKind validates Kubernetes resource kinds 81 | func ValidateKind(kind string) error { 82 | if kind == "" { 83 | return fmt.Errorf("resource kind cannot be empty") 84 | } 85 | 86 | if kind == "all" { 87 | return nil // Special case for discovery 88 | } 89 | 90 | // Allow both uppercase (Kind) and lowercase (resource names) formats 91 | // Must contain only alphanumeric characters and start with letter 92 | validKind := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9]*$`) 93 | if !validKind.MatchString(kind) { 94 | return fmt.Errorf("invalid resource kind: must start with letter and contain only alphanumeric characters") 95 | } 96 | 97 | return nil 98 | } -------------------------------------------------------------------------------- /src/tools/mock_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | 6 | openapi_v2 "github.com/google/gnostic-models/openapiv2" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/version" 9 | "k8s.io/client-go/discovery" 10 | "k8s.io/client-go/openapi" 11 | restclient "k8s.io/client-go/rest" 12 | ) 13 | 14 | type fakeDiscoveryClient struct { 15 | apiResourceLists []*metav1.APIResourceList 16 | } 17 | 18 | var _ discovery.DiscoveryInterface = (*fakeDiscoveryClient)(nil) 19 | 20 | func (f *fakeDiscoveryClient) ServerPreferredResources() ([]*metav1.APIResourceList, error) { 21 | return f.apiResourceLists, nil 22 | } 23 | 24 | func (f *fakeDiscoveryClient) ServerResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) { 25 | for _, list := range f.apiResourceLists { 26 | if list == nil { 27 | continue 28 | } 29 | if list.GroupVersion == groupVersion { 30 | return list, nil 31 | } 32 | } 33 | return nil, fmt.Errorf("no APIResourceList for groupVersion %q", groupVersion) 34 | } 35 | func (f *fakeDiscoveryClient) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { 36 | return nil, nil, nil 37 | } 38 | 39 | func (f *fakeDiscoveryClient) ServerGroups() (*metav1.APIGroupList, error) { 40 | return nil, fmt.Errorf("not implemented") 41 | } 42 | func (f *fakeDiscoveryClient) ServerResources() ([]*metav1.APIResourceList, error) { 43 | return nil, fmt.Errorf("not implemented") 44 | } 45 | func (f *fakeDiscoveryClient) ServerGroupResources() ([]*metav1.APIGroupList, error) { 46 | return nil, fmt.Errorf("not implemented") 47 | } 48 | func (f *fakeDiscoveryClient) ServerVersion() (*version.Info, error) { 49 | return nil, fmt.Errorf("not implemented") 50 | } 51 | func (f *fakeDiscoveryClient) OpenAPISchema() (*openapi_v2.Document, error) { 52 | return nil, fmt.Errorf("not implemented") 53 | } 54 | func (f *fakeDiscoveryClient) OpenAPIV3() openapi.Client { 55 | return nil 56 | } 57 | func (f *fakeDiscoveryClient) RESTClient() restclient.Interface { 58 | return nil 59 | } 60 | func (f *fakeDiscoveryClient) SwaggerSchema(version string) (*metav1.APIResourceList, error) { 61 | return nil, fmt.Errorf("not implemented") 62 | } 63 | func (f *fakeDiscoveryClient) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { 64 | return nil, fmt.Errorf("not implemented") 65 | } 66 | func (f *fakeDiscoveryClient) ServerPreferredResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) { 67 | return f.ServerResourcesForGroupVersion(groupVersion) 68 | } 69 | func (f *fakeDiscoveryClient) Fresh() bool { 70 | return true 71 | } 72 | func (f *fakeDiscoveryClient) Invalidate() { 73 | } 74 | 75 | func (f *fakeDiscoveryClient) WithLegacy() discovery.DiscoveryInterface { 76 | return nil 77 | } 78 | 79 | // FakeMultiClusterClient implements MultiClusterClientInterface for testing 80 | type FakeMultiClusterClient struct { 81 | client Client 82 | } 83 | 84 | func NewFakeMultiClusterClient(client Client) *FakeMultiClusterClient { 85 | return &FakeMultiClusterClient{client: client} 86 | } 87 | 88 | func (f *FakeMultiClusterClient) GetClient(context string) (Client, error) { 89 | return f.client, nil 90 | } 91 | 92 | func (f *FakeMultiClusterClient) GetDefaultContext() string { 93 | return "test-context" 94 | } 95 | 96 | func (f *FakeMultiClusterClient) ListContexts() ([]string, error) { 97 | return []string{"test-context", "other-context"}, nil 98 | } 99 | 100 | // Compile-time verification that FakeMultiClusterClient implements MultiClusterClientInterface 101 | var _ MultiClusterClientInterface = (*FakeMultiClusterClient)(nil) 102 | -------------------------------------------------------------------------------- /src/tools/logs_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/stretchr/testify/assert" 10 | "k8s.io/apimachinery/pkg/api/meta" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "k8s.io/client-go/discovery" 13 | "k8s.io/client-go/dynamic" 14 | "k8s.io/client-go/kubernetes" 15 | ) 16 | 17 | type FakeLogClient struct { 18 | clientset *kubernetes.Clientset 19 | err error 20 | } 21 | 22 | func (f *FakeLogClient) Clientset() (*kubernetes.Clientset, error) { 23 | return f.clientset, f.err 24 | } 25 | 26 | func (f *FakeLogClient) DynamicClient() (dynamic.Interface, error) { 27 | return nil, nil 28 | } 29 | 30 | func (f *FakeLogClient) DiscoClient() (discovery.DiscoveryInterface, error) { 31 | return nil, nil 32 | } 33 | 34 | func (f *FakeLogClient) RESTMapper() (meta.RESTMapper, error) { 35 | return nil, nil 36 | } 37 | 38 | func (f *FakeLogClient) ResourceInterface(gvr schema.GroupVersionResource, namespaced bool, ns string) (dynamic.ResourceInterface, error) { 39 | return nil, nil 40 | } 41 | 42 | func TestLogTool_Handler_ClientsetError(t *testing.T) { 43 | client := &FakeLogClient{ 44 | clientset: nil, 45 | err: errors.New("clientset error"), 46 | } 47 | 48 | multiClient := NewFakeMultiClusterClient(client) 49 | tool := NewLogTool(multiClient) 50 | 51 | req := mcp.CallToolRequest{ 52 | Params: struct { 53 | Name string `json:"name"` 54 | Arguments map[string]any `json:"arguments,omitempty"` 55 | Meta *struct { 56 | ProgressToken mcp.ProgressToken `json:"progressToken,omitempty"` 57 | } `json:"_meta,omitempty"` 58 | }{ 59 | Arguments: map[string]any{ 60 | "name": "test-pod", 61 | "namespace": "default", 62 | }, 63 | }, 64 | } 65 | 66 | actualResult, actualErr := tool.Handler(context.Background(), req) 67 | 68 | assert.Error(t, actualErr) 69 | assert.Contains(t, actualErr.Error(), "failed to get clientset: clientset error") 70 | assert.Nil(t, actualResult) 71 | } 72 | 73 | func TestParseAndValidateLogsParams(t *testing.T) { 74 | testCases := []struct { 75 | name string 76 | args map[string]any 77 | expectedErr bool 78 | }{ 79 | { 80 | name: "ValidParams", 81 | args: map[string]any{ 82 | "name": "test-pod", 83 | "namespace": "default", 84 | "container": "test-container", 85 | "tail": float64(100), 86 | "since": "1h", 87 | "sinceTime": "2025-06-20T10:00:00Z", 88 | "timestamps": true, 89 | "previous": false, 90 | "follow": false, 91 | }, 92 | expectedErr: false, 93 | }, 94 | { 95 | name: "MissingName", 96 | args: map[string]any{ 97 | "namespace": "default", 98 | }, 99 | expectedErr: true, 100 | }, 101 | } 102 | 103 | for _, tc := range testCases { 104 | t.Run(tc.name, func(t *testing.T) { 105 | client := &FakeLogClient{} 106 | multiClient := NewFakeMultiClusterClient(client) 107 | tool := NewLogTool(multiClient) 108 | result, err := tool.parseAndValidateLogsParams(tc.args) 109 | 110 | if tc.expectedErr { 111 | assert.Error(t, err) 112 | assert.Nil(t, result) 113 | } else { 114 | assert.NoError(t, err) 115 | assert.NotNil(t, result) 116 | } 117 | }) 118 | } 119 | } 120 | 121 | func TestSinceSeconds(t *testing.T) { 122 | testCases := []struct { 123 | name string 124 | since string 125 | expected *int64 126 | }{ 127 | { 128 | name: "EmptyString", 129 | since: "", 130 | expected: nil, 131 | }, 132 | { 133 | name: "ValidDuration", 134 | since: "1h", 135 | expected: func() *int64 { v := int64(3600); return &v }(), 136 | }, 137 | { 138 | name: "InvalidDuration", 139 | since: "invalid", 140 | expected: nil, 141 | }, 142 | } 143 | 144 | for _, tc := range testCases { 145 | t.Run(tc.name, func(t *testing.T) { 146 | result := sinceSeconds(tc.since) 147 | if tc.expected == nil { 148 | assert.Nil(t, result) 149 | } else { 150 | assert.NotNil(t, result) 151 | assert.Equal(t, *tc.expected, *result) 152 | } 153 | }) 154 | } 155 | } 156 | 157 | func TestSinceTime(t *testing.T) { 158 | testCases := []struct { 159 | name string 160 | sinceTime string 161 | expected bool 162 | }{ 163 | { 164 | name: "EmptyString", 165 | sinceTime: "", 166 | expected: false, 167 | }, 168 | { 169 | name: "ValidTime", 170 | sinceTime: "2025-06-20T10:00:00Z", 171 | expected: true, 172 | }, 173 | { 174 | name: "InvalidTime", 175 | sinceTime: "invalid-time", 176 | expected: false, 177 | }, 178 | } 179 | 180 | for _, tc := range testCases { 181 | t.Run(tc.name, func(t *testing.T) { 182 | result := sinceTime(tc.sinceTime) 183 | if tc.expected { 184 | assert.NotNil(t, result) 185 | } else { 186 | assert.Nil(t, result) 187 | } 188 | }) 189 | } 190 | } 191 | 192 | func TestLogTool_Tool(t *testing.T) { 193 | client := &FakeLogClient{} 194 | multiClient := NewFakeMultiClusterClient(client) 195 | tool := NewLogTool(multiClient) 196 | 197 | mcpTool := tool.Tool() 198 | 199 | assert.Equal(t, "get_pod_logs", mcpTool.Name) 200 | assert.Contains(t, mcpTool.Description, "Get logs from a Kubernetes pod") 201 | } 202 | -------------------------------------------------------------------------------- /src/tools/describe.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | ) 13 | 14 | type DescribeResourceInput struct { 15 | Context string `json:"context,omitempty"` 16 | Kind string `json:"kind"` 17 | Name string `json:"name"` 18 | Namespace string `json:"namespace,omitempty"` 19 | } 20 | 21 | type DescribeTool struct { 22 | multiClient MultiClusterClientInterface 23 | } 24 | 25 | func NewDescribeTool(multiClient MultiClusterClientInterface) *DescribeTool { 26 | return &DescribeTool{multiClient: multiClient} 27 | } 28 | 29 | func (d *DescribeTool) Tool() mcp.Tool { 30 | return mcp.NewTool("describe_resource", 31 | mcp.WithDescription("Describe a specific Kubernetes resource by kind and name, similar to 'kubectl describe'"), 32 | mcp.WithString("context", 33 | mcp.Description("Kubernetes context name from kubeconfig to use for this request (leave empty for current context)"), 34 | ), 35 | mcp.WithString("kind", 36 | mcp.Required(), 37 | mcp.Description("Kind of the Kubernetes resource, e.g., Pod, Deployment, Service, ConfigMap, or any CRD"), 38 | ), 39 | mcp.WithString("name", 40 | mcp.Required(), 41 | mcp.Description("Name of the resource to describe"), 42 | ), 43 | mcp.WithString("namespace", 44 | mcp.Description("Kubernetes namespace of the resource (leave empty to search all namespaces, use 'default' for default namespace)"), 45 | ), 46 | ) 47 | } 48 | 49 | func (d *DescribeTool) Handler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { 50 | input, err := parseAndValidateDescribeParams(req.Params.Arguments) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | // Get the appropriate client for the context 56 | client, err := d.multiClient.GetClient(input.Context) 57 | if err != nil { 58 | return nil, fmt.Errorf("failed to get client for context '%s': %w", input.Context, err) 59 | } 60 | 61 | gvrMatch, err := d.discoverResourceByKind(client, input.Kind) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | resource, err := d.getResource(ctx, client, gvrMatch, input) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | describeOutput := d.formatResourceDescription(resource) 72 | 73 | out, err := json.Marshal(describeOutput) 74 | if err != nil { 75 | return nil, fmt.Errorf("failed to marshal describe output: %w", err) 76 | } 77 | 78 | return mcp.NewToolResultText(string(out)), nil 79 | } 80 | 81 | func (d *DescribeTool) discoverResourceByKind(client Client, kind string) (*gvrMatch, error) { 82 | discoClient, err := client.DiscoClient() 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to create discovery client: %w", err) 85 | } 86 | 87 | apiResourceLists, err := discoClient.ServerPreferredResources() 88 | if err != nil { 89 | return nil, fmt.Errorf("failed to discover resources: %w", err) 90 | } 91 | 92 | return findGVRByKind(apiResourceLists, kind) 93 | } 94 | 95 | func (d *DescribeTool) getResource(ctx context.Context, client Client, gvrMatch *gvrMatch, input *DescribeResourceInput) (*unstructured.Unstructured, error) { 96 | ri, err := client.ResourceInterface(*gvrMatch.ToGroupVersionResource(), gvrMatch.namespaced, input.Namespace) 97 | if err != nil { 98 | return nil, fmt.Errorf("failed to create resource interface: %w", err) 99 | } 100 | 101 | resource, err := ri.Get(ctx, input.Name, metav1.GetOptions{}) 102 | if err != nil { 103 | return nil, fmt.Errorf("failed to get resource %s/%s: %w", input.Kind, input.Name, err) 104 | } 105 | 106 | return resource, nil 107 | } 108 | 109 | func (d *DescribeTool) formatResourceDescription(resource *unstructured.Unstructured) map[string]interface{} { 110 | description := map[string]interface{}{ 111 | "name": resource.GetName(), 112 | "namespace": resource.GetNamespace(), 113 | "kind": resource.GetKind(), 114 | "labels": resource.GetLabels(), 115 | "annotations": resource.GetAnnotations(), 116 | "creationTimestamp": resource.GetCreationTimestamp(), 117 | "resourceVersion": resource.GetResourceVersion(), 118 | "uid": resource.GetUID(), 119 | } 120 | 121 | if spec, found, err := unstructured.NestedMap(resource.Object, "spec"); found && err == nil { 122 | description["spec"] = spec 123 | } 124 | 125 | if status, found, err := unstructured.NestedMap(resource.Object, "status"); found && err == nil { 126 | description["status"] = status 127 | } 128 | 129 | if ownerRefs := resource.GetOwnerReferences(); len(ownerRefs) > 0 { 130 | description["ownerReferences"] = ownerRefs 131 | } 132 | 133 | if finalizers := resource.GetFinalizers(); len(finalizers) > 0 { 134 | description["finalizers"] = finalizers 135 | } 136 | 137 | return description 138 | } 139 | 140 | func parseAndValidateDescribeParams(args map[string]any) (*DescribeResourceInput, error) { 141 | input := &DescribeResourceInput{} 142 | 143 | // Optional: context 144 | if context, ok := args["context"].(string); ok { 145 | input.Context = context 146 | } 147 | 148 | if kindVal, ok := args["kind"].(string); ok && kindVal != "" { 149 | input.Kind = kindVal 150 | } else { 151 | return nil, errors.New("kind must be provided and be a string") 152 | } 153 | 154 | if nameVal, ok := args["name"].(string); ok && nameVal != "" { 155 | input.Name = nameVal 156 | } else { 157 | return nil, errors.New("name must be provided and be a string") 158 | } 159 | 160 | if ns, ok := args["namespace"].(string); ok { 161 | input.Namespace = ns 162 | } 163 | if input.Namespace == "" { 164 | input.Namespace = metav1.NamespaceAll 165 | } 166 | 167 | return input, nil 168 | } -------------------------------------------------------------------------------- /src/client/multi_cluster.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sync" 8 | 9 | "github.com/kkb0318/kubernetes-mcp/src/tools" 10 | "k8s.io/apimachinery/pkg/api/meta" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "k8s.io/client-go/discovery" 13 | "k8s.io/client-go/dynamic" 14 | "k8s.io/client-go/kubernetes" 15 | "k8s.io/client-go/rest" 16 | "k8s.io/client-go/tools/clientcmd" 17 | ) 18 | 19 | // MultiClusterClient manages connections to multiple Kubernetes clusters using contexts. 20 | type MultiClusterClient struct { 21 | clients map[string]*KubernetesClient 22 | defaultContext string 23 | kubeconfig string 24 | mu sync.RWMutex 25 | } 26 | 27 | // NewMultiClusterClient creates a new MultiClusterClient that can manage multiple cluster connections. 28 | func NewMultiClusterClient() (*MultiClusterClient, error) { 29 | var kubeconfig string 30 | if kubeconfigEnv := os.Getenv("KUBECONFIG"); kubeconfigEnv != "" { 31 | kubeconfig = kubeconfigEnv 32 | } else { 33 | // Try multiple ways to find home directory 34 | home := os.Getenv("HOME") 35 | if home == "" { 36 | if userHome, err := os.UserHomeDir(); err == nil { 37 | home = userHome 38 | } 39 | } 40 | kubeconfig = filepath.Join(home, ".kube", "config") 41 | } 42 | 43 | // Get the default context from kubeconfig 44 | defaultContext, err := getDefaultContext(kubeconfig) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to get default context: %w", err) 47 | } 48 | 49 | return &MultiClusterClient{ 50 | clients: make(map[string]*KubernetesClient), 51 | defaultContext: defaultContext, 52 | kubeconfig: kubeconfig, 53 | }, nil 54 | } 55 | 56 | // GetClient returns a Kubernetes client for the specified context. 57 | // If context is empty, it uses the default context. 58 | // Clients are cached to avoid recreating connections. 59 | func (m *MultiClusterClient) GetClient(context string) (tools.Client, error) { 60 | if context == "" { 61 | context = m.defaultContext 62 | } 63 | 64 | m.mu.RLock() 65 | if client, exists := m.clients[context]; exists { 66 | m.mu.RUnlock() 67 | return &ClientWrapper{client: client, context: context}, nil 68 | } 69 | m.mu.RUnlock() 70 | 71 | // Create new client for this context 72 | m.mu.Lock() 73 | defer m.mu.Unlock() 74 | 75 | // Double-check in case another goroutine created it while we were waiting for the lock 76 | if client, exists := m.clients[context]; exists { 77 | return &ClientWrapper{client: client, context: context}, nil 78 | } 79 | 80 | client, err := m.createClientForContext(context) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to create client for context '%s': %w", context, err) 83 | } 84 | 85 | m.clients[context] = client 86 | return &ClientWrapper{client: client, context: context}, nil 87 | } 88 | 89 | // createClientForContext creates a new KubernetesClient for the specified context. 90 | func (m *MultiClusterClient) createClientForContext(context string) (*KubernetesClient, error) { 91 | // First try in-cluster config (if running inside a pod) 92 | if config, err := rest.InClusterConfig(); err == nil { 93 | return &KubernetesClient{config: config}, nil 94 | } 95 | 96 | // Fall back to kubeconfig with specific context 97 | config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 98 | &clientcmd.ClientConfigLoadingRules{ExplicitPath: m.kubeconfig}, 99 | &clientcmd.ConfigOverrides{CurrentContext: context}, 100 | ).ClientConfig() 101 | 102 | if err != nil { 103 | return nil, fmt.Errorf("failed to load kubeconfig for context '%s': %w", context, err) 104 | } 105 | 106 | return &KubernetesClient{config: config}, nil 107 | } 108 | 109 | // GetDefaultContext returns the default context name. 110 | func (m *MultiClusterClient) GetDefaultContext() string { 111 | return m.defaultContext 112 | } 113 | 114 | // ListContexts returns all available contexts from the kubeconfig. 115 | func (m *MultiClusterClient) ListContexts() ([]string, error) { 116 | config, err := clientcmd.LoadFromFile(m.kubeconfig) 117 | if err != nil { 118 | return nil, fmt.Errorf("failed to load kubeconfig: %w", err) 119 | } 120 | 121 | contexts := make([]string, 0, len(config.Contexts)) 122 | for contextName := range config.Contexts { 123 | contexts = append(contexts, contextName) 124 | } 125 | 126 | return contexts, nil 127 | } 128 | 129 | // getDefaultContext extracts the default context from kubeconfig. 130 | func getDefaultContext(kubeconfig string) (string, error) { 131 | config, err := clientcmd.LoadFromFile(kubeconfig) 132 | if err != nil { 133 | return "", fmt.Errorf("failed to load kubeconfig: %w", err) 134 | } 135 | 136 | if config.CurrentContext == "" { 137 | // If no current context is set, try to find any available context 138 | for contextName := range config.Contexts { 139 | return contextName, nil 140 | } 141 | return "", fmt.Errorf("no contexts found in kubeconfig") 142 | } 143 | 144 | return config.CurrentContext, nil 145 | } 146 | 147 | // ClientWrapper wraps a KubernetesClient and implements the tools.Client interface. 148 | type ClientWrapper struct { 149 | client *KubernetesClient 150 | context string 151 | } 152 | 153 | // DynamicClient returns the dynamic client for this context. 154 | func (c *ClientWrapper) DynamicClient() (dynamic.Interface, error) { 155 | return c.client.DynamicClient() 156 | } 157 | 158 | // DiscoClient returns the discovery client for this context. 159 | func (c *ClientWrapper) DiscoClient() (discovery.DiscoveryInterface, error) { 160 | return c.client.DiscoClient() 161 | } 162 | 163 | // Clientset returns the typed clientset for this context. 164 | func (c *ClientWrapper) Clientset() (*kubernetes.Clientset, error) { 165 | return c.client.Clientset() 166 | } 167 | 168 | // RESTMapper returns the REST mapper for this context. 169 | func (c *ClientWrapper) RESTMapper() (meta.RESTMapper, error) { 170 | return c.client.RESTMapper() 171 | } 172 | 173 | // ResourceInterface returns the resource interface for this context. 174 | func (c *ClientWrapper) ResourceInterface(gvr schema.GroupVersionResource, namespaced bool, ns string) (dynamic.ResourceInterface, error) { 175 | return c.client.ResourceInterface(gvr, namespaced, ns) 176 | } 177 | 178 | // GetContext returns the context name for this client. 179 | func (c *ClientWrapper) GetContext() string { 180 | return c.context 181 | } 182 | 183 | // Compile-time verification that ClientWrapper implements tools.Client interface 184 | var _ tools.Client = (*ClientWrapper)(nil) 185 | 186 | // Compile-time verification that MultiClusterClient implements tools.MultiClusterClientInterface 187 | var _ tools.MultiClusterClientInterface = (*MultiClusterClient)(nil) 188 | 189 | -------------------------------------------------------------------------------- /src/tools/list_contexts_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // FakeListContextsMultiClusterClient implements MultiClusterClientInterface for testing list_contexts functionality 13 | type FakeListContextsMultiClusterClient struct { 14 | contexts []string 15 | currentContext string 16 | listError error 17 | } 18 | 19 | func (f *FakeListContextsMultiClusterClient) ListContexts() ([]string, error) { 20 | if f.listError != nil { 21 | return nil, f.listError 22 | } 23 | return f.contexts, nil 24 | } 25 | 26 | func (f *FakeListContextsMultiClusterClient) GetDefaultContext() string { 27 | return f.currentContext 28 | } 29 | 30 | func (f *FakeListContextsMultiClusterClient) GetClient(context string) (Client, error) { 31 | return nil, nil 32 | } 33 | 34 | func TestListContextsTool_Tool(t *testing.T) { 35 | multiClient := &FakeListContextsMultiClusterClient{} 36 | tool := NewListContextsTool(multiClient) 37 | 38 | mcpTool := tool.Tool() 39 | 40 | assert.Equal(t, "list_contexts", mcpTool.Name) 41 | assert.Equal(t, "List all available Kubernetes contexts from kubeconfig", mcpTool.Description) 42 | assert.Equal(t, "object", mcpTool.InputSchema.Type) 43 | assert.NotNil(t, mcpTool.InputSchema.Properties) 44 | } 45 | 46 | func TestListContextsTool_Handler(t *testing.T) { 47 | testCases := []struct { 48 | name string 49 | contexts []string 50 | currentContext string 51 | listError error 52 | expectedErr bool 53 | validate func(*testing.T, *ListContextsResponse) 54 | }{ 55 | { 56 | name: "successful listing with multiple contexts", 57 | contexts: []string{"context1", "context2", "context3"}, 58 | currentContext: "context2", 59 | expectedErr: false, 60 | validate: func(t *testing.T, response *ListContextsResponse) { 61 | assert.Equal(t, 3, response.Total) 62 | assert.Equal(t, "context2", response.CurrentContext) 63 | assert.Len(t, response.Contexts, 3) 64 | 65 | // Check that contexts are properly mapped 66 | contextMap := make(map[string]bool) 67 | for _, ctx := range response.Contexts { 68 | contextMap[ctx.Name] = ctx.IsCurrent 69 | } 70 | 71 | assert.True(t, contextMap["context2"], "context2 should be marked as current") 72 | assert.False(t, contextMap["context1"], "context1 should not be marked as current") 73 | assert.False(t, contextMap["context3"], "context3 should not be marked as current") 74 | }, 75 | }, 76 | { 77 | name: "single context", 78 | contexts: []string{"single-context"}, 79 | currentContext: "single-context", 80 | expectedErr: false, 81 | validate: func(t *testing.T, response *ListContextsResponse) { 82 | assert.Equal(t, 1, response.Total) 83 | assert.Equal(t, "single-context", response.CurrentContext) 84 | assert.Len(t, response.Contexts, 1) 85 | assert.Equal(t, "single-context", response.Contexts[0].Name) 86 | assert.True(t, response.Contexts[0].IsCurrent) 87 | }, 88 | }, 89 | { 90 | name: "no contexts", 91 | contexts: []string{}, 92 | currentContext: "", 93 | expectedErr: false, 94 | validate: func(t *testing.T, response *ListContextsResponse) { 95 | assert.Equal(t, 0, response.Total) 96 | assert.Equal(t, "", response.CurrentContext) 97 | assert.Len(t, response.Contexts, 0) 98 | }, 99 | }, 100 | { 101 | name: "current context not in list", 102 | contexts: []string{"context1", "context2"}, 103 | currentContext: "different-context", 104 | expectedErr: false, 105 | validate: func(t *testing.T, response *ListContextsResponse) { 106 | assert.Equal(t, 2, response.Total) 107 | assert.Equal(t, "different-context", response.CurrentContext) 108 | assert.Len(t, response.Contexts, 2) 109 | 110 | // All contexts should be marked as not current 111 | for _, ctx := range response.Contexts { 112 | assert.False(t, ctx.IsCurrent) 113 | } 114 | }, 115 | }, 116 | { 117 | name: "error listing contexts", 118 | listError: assert.AnError, 119 | expectedErr: true, 120 | }, 121 | } 122 | 123 | for _, tc := range testCases { 124 | t.Run(tc.name, func(t *testing.T) { 125 | multiClient := &FakeListContextsMultiClusterClient{ 126 | contexts: tc.contexts, 127 | currentContext: tc.currentContext, 128 | listError: tc.listError, 129 | } 130 | 131 | tool := NewListContextsTool(multiClient) 132 | 133 | req := &mcp.CallToolRequest{} 134 | req.Params.Arguments = map[string]interface{}{} 135 | 136 | result, err := tool.Handler(context.Background(), *req) 137 | 138 | if tc.expectedErr { 139 | assert.Error(t, err) 140 | assert.Nil(t, result) 141 | return 142 | } 143 | 144 | assert.NoError(t, err) 145 | assert.NotNil(t, result) 146 | 147 | // Parse the JSON response 148 | textContent, ok := result.Content[0].(mcp.TextContent) 149 | assert.True(t, ok) 150 | assert.Equal(t, "text", textContent.Type) 151 | 152 | var response ListContextsResponse 153 | err = json.Unmarshal([]byte(textContent.Text), &response) 154 | assert.NoError(t, err) 155 | 156 | if tc.validate != nil { 157 | tc.validate(t, &response) 158 | } 159 | }) 160 | } 161 | } 162 | 163 | func TestListContextsTool_JSONMarshaling(t *testing.T) { 164 | testCases := []struct { 165 | name string 166 | response ListContextsResponse 167 | }{ 168 | { 169 | name: "complete response", 170 | response: ListContextsResponse{ 171 | Contexts: []ContextInfo{ 172 | {Name: "context1", IsCurrent: true}, 173 | {Name: "context2", IsCurrent: false}, 174 | }, 175 | CurrentContext: "context1", 176 | Total: 2, 177 | }, 178 | }, 179 | { 180 | name: "empty response", 181 | response: ListContextsResponse{ 182 | Contexts: []ContextInfo{}, 183 | CurrentContext: "", 184 | Total: 0, 185 | }, 186 | }, 187 | } 188 | 189 | for _, tc := range testCases { 190 | t.Run(tc.name, func(t *testing.T) { 191 | // Test marshaling 192 | jsonBytes, err := json.MarshalIndent(tc.response, "", " ") 193 | assert.NoError(t, err) 194 | 195 | // Test unmarshaling 196 | var unmarshaled ListContextsResponse 197 | err = json.Unmarshal(jsonBytes, &unmarshaled) 198 | assert.NoError(t, err) 199 | 200 | // Verify round-trip consistency 201 | assert.Equal(t, tc.response, unmarshaled) 202 | }) 203 | } 204 | } 205 | 206 | func TestContextInfo_JSONTags(t *testing.T) { 207 | ctx := ContextInfo{ 208 | Name: "test-context", 209 | IsCurrent: true, 210 | } 211 | 212 | jsonBytes, err := json.Marshal(ctx) 213 | assert.NoError(t, err) 214 | 215 | expected := `{"name":"test-context","is_current":true}` 216 | assert.JSONEq(t, expected, string(jsonBytes)) 217 | } 218 | 219 | func TestNewListContextsTool(t *testing.T) { 220 | multiClient := &FakeListContextsMultiClusterClient{} 221 | tool := NewListContextsTool(multiClient) 222 | 223 | assert.NotNil(t, tool) 224 | assert.Equal(t, multiClient, tool.multiClient) 225 | } 226 | 227 | func TestListContextsTool_ImplementsInterface(t *testing.T) { 228 | multiClient := &FakeListContextsMultiClusterClient{} 229 | tool := NewListContextsTool(multiClient) 230 | 231 | // Verify that ListContextsTool implements Tools interface 232 | var _ Tools = tool 233 | } -------------------------------------------------------------------------------- /src/tools/logs.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "time" 9 | 10 | "github.com/mark3labs/mcp-go/mcp" 11 | "github.com/kkb0318/kubernetes-mcp/src/validation" 12 | corev1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | ) 15 | 16 | type KubectlLogsInput struct { 17 | Context string `json:"context,omitempty"` 18 | Name string `json:"name"` 19 | Namespace string `json:"namespace"` 20 | Container string `json:"container,omitempty"` 21 | Tail int64 `json:"tail,omitempty"` 22 | Since string `json:"since,omitempty"` 23 | SinceTime string `json:"sinceTime,omitempty"` 24 | Timestamps bool `json:"timestamps,omitempty"` 25 | Previous bool `json:"previous,omitempty"` 26 | } 27 | 28 | // LogTool handles fetching logs based on the input parameters. 29 | type LogTool struct { 30 | multiClient MultiClusterClientInterface 31 | } 32 | 33 | // NewLogTool creates a new LogTool with the provided MultiClusterClient. 34 | func NewLogTool(multiClient MultiClusterClientInterface) *LogTool { 35 | return &LogTool{multiClient: multiClient} 36 | } 37 | 38 | // Tool returns the MCP tool definition for fetching pod logs. 39 | func (l *LogTool) Tool() mcp.Tool { 40 | return mcp.NewTool("get_pod_logs", 41 | mcp.WithDescription("Get logs from a Kubernetes pod with various filtering options"), 42 | mcp.WithString("context", 43 | mcp.Description("Kubernetes context name from kubeconfig to use for this request (leave empty for current context)"), 44 | ), 45 | mcp.WithString("name", 46 | mcp.Required(), 47 | mcp.Description("Name of the pod to get logs from"), 48 | ), 49 | mcp.WithString("namespace", 50 | mcp.Description("Kubernetes namespace of the pod (defaults to 'default' if not specified)"), 51 | ), 52 | mcp.WithString("container", 53 | mcp.Description("Container name within the pod (optional)"), 54 | ), 55 | mcp.WithNumber("tail", 56 | mcp.Description("Number of lines to show from the end of the logs (defaults to 100 if not specified, use 0 for all logs)"), 57 | ), 58 | mcp.WithString("since", 59 | mcp.Description("Return logs newer than a relative duration like 5s, 2m, or 3h (optional)"), 60 | ), 61 | mcp.WithString("sinceTime", 62 | mcp.Description("Return logs after a specific time (RFC3339 format, e.g., 2025-06-20T10:00:00Z) (optional)"), 63 | ), 64 | mcp.WithBoolean("timestamps", 65 | mcp.Description("Include timestamps in the log output (optional)"), 66 | ), 67 | mcp.WithBoolean("previous", 68 | mcp.Description("Get logs from the previous container instance if it crashed (optional)"), 69 | ), 70 | ) 71 | } 72 | 73 | // Handler fetches logs based on the provided request parameters. 74 | func (l *LogTool) Handler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { 75 | input, err := l.parseAndValidateLogsParams(req.Params.Arguments) 76 | if err != nil { 77 | return nil, fmt.Errorf("failed to parse and validate list params: %w", err) 78 | } 79 | 80 | // Get the appropriate client for the context 81 | client, err := l.multiClient.GetClient(input.Context) 82 | if err != nil { 83 | return nil, fmt.Errorf("failed to get client for context '%s': %w", input.Context, err) 84 | } 85 | 86 | clientset, err := client.Clientset() 87 | if err != nil { 88 | return nil, fmt.Errorf("failed to get clientset: %w", err) 89 | } 90 | 91 | // First, get the pod to check its status 92 | pod, err := clientset.CoreV1().Pods(input.Namespace).Get(ctx, input.Name, metav1.GetOptions{}) 93 | if err != nil { 94 | return nil, fmt.Errorf("failed to get pod %s/%s: %w", input.Namespace, input.Name, err) 95 | } 96 | 97 | logs := make(map[string]any) 98 | logs["podStatus"] = map[string]any{ 99 | "phase": pod.Status.Phase, 100 | "reason": pod.Status.Reason, 101 | "message": pod.Status.Message, 102 | } 103 | 104 | // Check container statuses 105 | containerStatuses := make([]map[string]any, 0) 106 | for _, containerStatus := range pod.Status.ContainerStatuses { 107 | status := map[string]any{ 108 | "name": containerStatus.Name, 109 | "ready": containerStatus.Ready, 110 | "restartCount": containerStatus.RestartCount, 111 | } 112 | 113 | if containerStatus.State.Waiting != nil { 114 | status["state"] = "waiting" 115 | status["reason"] = containerStatus.State.Waiting.Reason 116 | status["message"] = containerStatus.State.Waiting.Message 117 | } else if containerStatus.State.Running != nil { 118 | status["state"] = "running" 119 | status["startedAt"] = containerStatus.State.Running.StartedAt 120 | } else if containerStatus.State.Terminated != nil { 121 | status["state"] = "terminated" 122 | status["reason"] = containerStatus.State.Terminated.Reason 123 | status["message"] = containerStatus.State.Terminated.Message 124 | status["exitCode"] = containerStatus.State.Terminated.ExitCode 125 | } 126 | 127 | containerStatuses = append(containerStatuses, status) 128 | } 129 | logs["containerStatuses"] = containerStatuses 130 | 131 | // Try to get current logs 132 | logOptions := &corev1.PodLogOptions{ 133 | Container: input.Container, 134 | SinceSeconds: sinceSeconds(input.Since), 135 | SinceTime: sinceTime(input.SinceTime), 136 | Timestamps: input.Timestamps, 137 | Previous: input.Previous, 138 | } 139 | 140 | // Only set TailLines if it's greater than 0 141 | if input.Tail > 0 { 142 | logOptions.TailLines = &input.Tail 143 | } 144 | 145 | podLogs := clientset.CoreV1().Pods(input.Namespace).GetLogs(input.Name, logOptions) 146 | podLogString, err := podLogs.Stream(ctx) 147 | if err != nil { 148 | // If getting current logs fails and we haven't tried previous logs, try previous 149 | if !input.Previous { 150 | logOptions.Previous = true 151 | // Ensure TailLines is set for previous logs too 152 | if input.Tail > 0 { 153 | logOptions.TailLines = &input.Tail 154 | } 155 | podLogs = clientset.CoreV1().Pods(input.Namespace).GetLogs(input.Name, logOptions) 156 | podLogString, err = podLogs.Stream(ctx) 157 | if err != nil { 158 | logs["error"] = fmt.Sprintf("failed to get both current and previous logs: %v", err) 159 | logs["logs"] = "" 160 | } else { 161 | defer podLogString.Close() 162 | logBytes, readErr := io.ReadAll(podLogString) 163 | if readErr != nil { 164 | logs["error"] = fmt.Sprintf("failed to read previous logs: %v", readErr) 165 | logs["logs"] = "" 166 | } else { 167 | logs["logs"] = string(logBytes) 168 | logs["source"] = "previous" 169 | } 170 | } 171 | } else { 172 | logs["error"] = fmt.Sprintf("failed to stream pod logs: %v", err) 173 | logs["logs"] = "" 174 | } 175 | } else { 176 | defer podLogString.Close() 177 | logBytes, readErr := io.ReadAll(podLogString) 178 | if readErr != nil { 179 | logs["error"] = fmt.Sprintf("failed to read pod logs: %v", readErr) 180 | logs["logs"] = "" 181 | } else { 182 | logs["logs"] = string(logBytes) 183 | logs["source"] = "current" 184 | } 185 | } 186 | 187 | out, err := json.Marshal(logs) 188 | if err != nil { 189 | return nil, fmt.Errorf("failed to marshal logs: %w", err) 190 | } 191 | 192 | return mcp.NewToolResultText(string(out)), nil 193 | } 194 | 195 | // sinceSeconds parses the 'since' duration string into seconds. 196 | func sinceSeconds(since string) *int64 { 197 | if since == "" { 198 | return nil 199 | } 200 | duration, err := time.ParseDuration(since) 201 | if err != nil { 202 | return nil 203 | } 204 | seconds := int64(duration.Seconds()) 205 | return &seconds 206 | } 207 | 208 | // sinceTime parses the 'sinceTime' string into metav1.Time. 209 | func sinceTime(sinceTime string) *metav1.Time { 210 | if sinceTime == "" { 211 | return nil 212 | } 213 | t, err := time.Parse(time.RFC3339, sinceTime) 214 | if err != nil { 215 | return nil 216 | } 217 | return &metav1.Time{Time: t} 218 | } 219 | 220 | // parseAndValidateLogsParams validates and parses the input parameters. 221 | func (l *LogTool) parseAndValidateLogsParams(args map[string]any) (*KubectlLogsInput, error) { 222 | input := &KubectlLogsInput{} 223 | 224 | // Optional: context 225 | if context, ok := args["context"]; ok && context != nil { 226 | input.Context = context.(string) 227 | } 228 | 229 | if name, ok := args["name"]; ok && name != nil { 230 | input.Name = name.(string) 231 | if err := validation.ValidateResourceName(input.Name); err != nil { 232 | return nil, fmt.Errorf("invalid pod name: %w", err) 233 | } 234 | } 235 | 236 | if namespace, ok := args["namespace"]; ok && namespace != nil { 237 | input.Namespace = namespace.(string) 238 | if err := validation.ValidateNamespace(input.Namespace); err != nil { 239 | return nil, fmt.Errorf("invalid namespace: %w", err) 240 | } 241 | } 242 | 243 | if container, ok := args["container"]; ok && container != nil { 244 | input.Container = container.(string) 245 | } 246 | 247 | if tail, ok := args["tail"]; ok && tail != nil { 248 | input.Tail = int64(tail.(float64)) 249 | } else { 250 | // Default to 100 lines if not specified 251 | input.Tail = 100 252 | } 253 | 254 | if since, ok := args["since"]; ok && since != nil { 255 | input.Since = since.(string) 256 | } 257 | 258 | if sinceTime, ok := args["sinceTime"]; ok && sinceTime != nil { 259 | input.SinceTime = sinceTime.(string) 260 | } 261 | 262 | if timestamps, ok := args["timestamps"]; ok && timestamps != nil { 263 | input.Timestamps = timestamps.(bool) 264 | } 265 | 266 | if previous, ok := args["previous"]; ok && previous != nil { 267 | input.Previous = previous.(bool) 268 | } 269 | 270 | if input.Namespace == "" { 271 | input.Namespace = metav1.NamespaceDefault 272 | } 273 | 274 | if input.Name == "" { 275 | return nil, fmt.Errorf("name must be provided") 276 | } 277 | 278 | return input, nil 279 | } 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes MCP Server 2 | 3 | [![tests](https://github.com/kkb0318/kubernetes-mcp/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/kkb0318/kubernetes-mcp/actions/workflows/test.yml) 4 | [![codecov](https://codecov.io/gh/kkb0318/kubernetes-mcp/graph/badge.svg?token=RPOAC26LAH)](https://codecov.io/gh/kkb0318/kubernetes-mcp) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | 7 | https://github.com/user-attachments/assets/89df70b0-65d1-461c-b4ab-84b2087136fa 8 | 9 | A Model Context Protocol (MCP) server that provides safe, read-only access to Kubernetes resources for debugging and inspection. Built with security in mind, it offers comprehensive cluster visibility without modification capabilities. 10 | 11 | ## Features 12 | 13 | - **🔒 Read-only security**: Safely inspect Kubernetes resources without modification capabilities 14 | - **🎯 CRD support**: Works seamlessly with any Custom Resource Definitions in your cluster 15 | - **🌐 Multi-cluster support**: Switch between different Kubernetes contexts seamlessly 16 | - **🔍 Smart discovery**: Find resources by API group substring (e.g., "flux" for FluxCD, "argo" for ArgoCD) 17 | - **⚡ High performance**: Efficient resource querying with filtering and pagination 18 | - **🛠️ Comprehensive toolset**: 19 | - `list_resources`: List and filter Kubernetes resources with advanced options 20 | - `describe_resource`: Get detailed information about specific resources 21 | - `get_pod_logs`: Retrieve pod logs with sophisticated filtering capabilities 22 | - `list_events`: List and filter Kubernetes events for debugging and monitoring 23 | - `list_contexts`: List all available Kubernetes contexts from kubeconfig 24 | 25 | ## 🚀 Quick Start 26 | 27 | ### Prerequisites 28 | 29 | - Kubernetes cluster access with a valid kubeconfig file 30 | - Go 1.24+ (for building from source) 31 | 32 | ### Installation Options 33 | 34 | #### Option 1: Install with Go (Recommended) 35 | 36 | ```bash 37 | go install github.com/kkb0318/kubernetes-mcp@latest 38 | ``` 39 | 40 | The binary will be available at `$GOPATH/bin/kubernetes-mcp` (or `$HOME/go/bin/kubernetes-mcp` if `GOPATH` is not set). 41 | 42 | #### Option 2: Build from Source 43 | 44 | ```bash 45 | git clone https://github.com/kkb0318/kubernetes-mcp.git 46 | cd kubernetes-mcp 47 | go build -o kubernetes-mcp . 48 | ``` 49 | 50 | ## ⚙️ Configuration 51 | 52 | ### MCP Server Setup 53 | 54 | Add the server to your MCP configuration: 55 | 56 | #### Basic Configuration 57 | Uses `~/.kube/config` automatically: 58 | ```json 59 | { 60 | "mcpServers": { 61 | "kubernetes": { 62 | "command": "/path/to/kubernetes-mcp" 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | #### Custom Kubeconfig 69 | ```json 70 | { 71 | "mcpServers": { 72 | "kubernetes": { 73 | "command": "/path/to/kubernetes-mcp", 74 | "env": { 75 | "KUBECONFIG": "/path/to/your/kubeconfig" 76 | } 77 | } 78 | } 79 | } 80 | ``` 81 | 82 | > **Note**: Replace `/path/to/kubernetes-mcp` with your actual binary path. 83 | 84 | ### Standalone Usage 85 | 86 | ```bash 87 | # Default kubeconfig (~/.kube/config) 88 | ./kubernetes-mcp 89 | 90 | # Custom kubeconfig path 91 | KUBECONFIG=/path/to/your/kubeconfig ./kubernetes-mcp 92 | ``` 93 | 94 | **Important**: Ensure you have appropriate read permissions for the Kubernetes resources you want to inspect. 95 | 96 | ## 🛠️ Available Tools 97 | 98 | ### `list_resources` 99 | List and filter Kubernetes resources with advanced capabilities. 100 | 101 | | Parameter | Type | Description | 102 | |-----------|------|-------------| 103 | | `context` | optional | Kubernetes context name from kubeconfig (leave empty for current context) | 104 | | `kind` | **required** | Resource type (Pod, Deployment, Service, etc.) or "all" for discovery | 105 | | `groupFilter` | optional | Filter by API group substring for project-specific resources | 106 | | `namespace` | optional | Target namespace (defaults to all namespaces) | 107 | | `labelSelector` | optional | Filter by labels (e.g., "app=nginx") | 108 | | `fieldSelector` | optional | Filter by fields (e.g., "metadata.name=my-pod") | 109 | | `limit` | optional | Maximum number of resources to return | 110 | | `timeoutSeconds` | optional | Request timeout (default: 30s) | 111 | | `showDetails` | optional | Return full resource objects instead of summary | 112 | 113 | **Examples:** 114 | ```json 115 | // List pods with label selector 116 | { 117 | "kind": "Pod", 118 | "namespace": "default", 119 | "labelSelector": "app=nginx" 120 | } 121 | 122 | // List pods from a specific cluster context 123 | { 124 | "kind": "Pod", 125 | "context": "production-cluster", 126 | "namespace": "default" 127 | } 128 | 129 | // Discover FluxCD resources 130 | { 131 | "kind": "all", 132 | "groupFilter": "flux" 133 | } 134 | ``` 135 | 136 | ### `describe_resource` 137 | Get detailed information about a specific Kubernetes resource. 138 | 139 | | Parameter | Type | Description | 140 | |-----------|------|-------------| 141 | | `context` | optional | Kubernetes context name from kubeconfig (leave empty for current context) | 142 | | `kind` | **required** | Resource type (Pod, Deployment, etc.) | 143 | | `name` | **required** | Resource name | 144 | | `namespace` | optional | Target namespace | 145 | 146 | **Example:** 147 | ```json 148 | { 149 | "kind": "Pod", 150 | "name": "nginx-pod", 151 | "namespace": "default" 152 | } 153 | ``` 154 | 155 | ### `get_pod_logs` 156 | Retrieve pod logs with sophisticated filtering options. 157 | 158 | | Parameter | Type | Description | 159 | |-----------|------|-------------| 160 | | `context` | optional | Kubernetes context name from kubeconfig (leave empty for current context) | 161 | | `name` | **required** | Pod name | 162 | | `namespace` | optional | Pod namespace (defaults to "default") | 163 | | `container` | optional | Specific container name | 164 | | `tail` | optional | Number of lines from the end (default: 100) | 165 | | `since` | optional | Duration like "5s", "2m", "3h" | 166 | | `sinceTime` | optional | RFC3339 timestamp | 167 | | `timestamps` | optional | Include timestamps in output | 168 | | `previous` | optional | Get logs from previous container instance | 169 | 170 | **Example:** 171 | ```json 172 | { 173 | "name": "nginx-pod", 174 | "namespace": "default", 175 | "tail": 50, 176 | "since": "5m", 177 | "timestamps": true 178 | } 179 | ``` 180 | 181 | ### `list_events` 182 | List and filter Kubernetes events with advanced filtering options for debugging and monitoring. 183 | 184 | | Parameter | Type | Description | 185 | |-----------|------|-------------| 186 | | `context` | optional | Kubernetes context name from kubeconfig (leave empty for current context) | 187 | | `namespace` | optional | Target namespace (leave empty for all namespaces) | 188 | | `object` | optional | Filter by object name (e.g., pod name, deployment name) | 189 | | `eventType` | optional | Filter by event type: "Normal" or "Warning" (case-insensitive) | 190 | | `reason` | optional | Filter by event reason (e.g., "Pulled", "Failed", "FailedScheduling") | 191 | | `since` | optional | Duration like "5s", "2m", "1h" | 192 | | `sinceTime` | optional | RFC3339 timestamp (e.g., "2025-06-20T10:00:00Z") | 193 | | `limit` | optional | Maximum number of events to return (default: 100) | 194 | | `timeoutSeconds` | optional | Request timeout (default: 30s) | 195 | 196 | **Examples:** 197 | ```json 198 | // List recent warning events 199 | { 200 | "eventType": "Warning", 201 | "since": "30m" 202 | } 203 | 204 | // List events for a specific pod 205 | { 206 | "object": "nginx-pod", 207 | "namespace": "default" 208 | } 209 | 210 | // List failed scheduling events 211 | { 212 | "reason": "FailedScheduling", 213 | "limit": 50 214 | } 215 | ``` 216 | 217 | ### `list_contexts` 218 | List all available Kubernetes contexts from your kubeconfig file. 219 | 220 | **Parameters:** 221 | None - this tool takes no parameters. 222 | 223 | **Example Response:** 224 | ```json 225 | { 226 | "contexts": [ 227 | { 228 | "name": "production-cluster", 229 | "is_current": false 230 | }, 231 | { 232 | "name": "staging-cluster", 233 | "is_current": true 234 | }, 235 | { 236 | "name": "development-cluster", 237 | "is_current": false 238 | } 239 | ], 240 | "current_context": "staging-cluster", 241 | "total": 3 242 | } 243 | ``` 244 | 245 | **Use Case:** 246 | Perfect for multi-cluster workflows where you need to: 247 | - Discover available Kubernetes contexts 248 | - Identify the current active context 249 | - Plan operations across multiple clusters 250 | 251 | ## 🌟 Advanced Features 252 | 253 | ### 🌐 Multi-Cluster Support 254 | Seamlessly work with multiple Kubernetes clusters using context switching: 255 | 256 | - **Context Parameter**: All tools now support an optional `context` parameter to specify which cluster to query 257 | - **Automatic Discovery**: Uses your existing kubeconfig file and automatically discovers available contexts 258 | - **Default Context**: When no context is specified, uses the current context from your kubeconfig 259 | - **Cached Connections**: Efficiently manages connections to multiple clusters with connection caching 260 | 261 | **Multi-cluster Examples:** 262 | ```json 263 | // Query production cluster 264 | { 265 | "kind": "Pod", 266 | "context": "production-cluster", 267 | "namespace": "default" 268 | } 269 | 270 | // Get logs from staging environment 271 | { 272 | "name": "api-server", 273 | "context": "staging-cluster", 274 | "namespace": "api" 275 | } 276 | 277 | // Compare resources across environments (use multiple calls) 278 | { 279 | "kind": "Deployment", 280 | "context": "production-cluster", 281 | "namespace": "app" 282 | } 283 | ``` 284 | 285 | ### 🎯 Custom Resource Definition (CRD) Support 286 | Automatically discovers and works with any CRDs in your cluster. Simply use the CRD's Kind name with `list_resources` or `describe_resource` tools. 287 | 288 | ### 🔍 Smart Resource Discovery 289 | Use the `groupFilter` parameter to discover resources by API group substring: 290 | 291 | | Filter | Discovers | Examples | 292 | |--------|-----------|----------| 293 | | `"flux"` | FluxCD resources | HelmReleases, Kustomizations, GitRepositories | 294 | | `"argo"` | ArgoCD resources | Applications, AppProjects, ApplicationSets | 295 | | `"istio"` | Istio resources | VirtualServices, DestinationRules, Gateways | 296 | | `"cert-manager"` | cert-manager resources | Certificates, Issuers, ClusterIssuers | 297 | 298 | ### 🔒 Security & Safety 299 | Built with security as a primary concern: 300 | - ✅ **Read-only access** - No resource creation, modification, or deletion 301 | - ✅ **Production safe** - Secure for use in production environments 302 | - ✅ **Minimal permissions** - Only requires read access to cluster resources 303 | - ✅ **No destructive operations** - Cannot harm your cluster 304 | 305 | --- 306 | 307 | ## 🤝 Contributing 308 | 309 | We welcome contributions! Please ensure all changes maintain the read-only nature of the server and include appropriate tests. 310 | 311 | ## 📄 License 312 | 313 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 314 | -------------------------------------------------------------------------------- /src/tools/list_events.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/kkb0318/kubernetes-mcp/src/validation" 11 | "github.com/mark3labs/mcp-go/mcp" 12 | 13 | corev1 "k8s.io/api/core/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/fields" 16 | ) 17 | 18 | // ListEventsInput represents the input parameters for listing Kubernetes events. 19 | type ListEventsInput struct { 20 | Context string `json:"context,omitempty"` 21 | Namespace string `json:"namespace,omitempty"` 22 | Object string `json:"object,omitempty"` 23 | EventType string `json:"eventType,omitempty"` 24 | Reason string `json:"reason,omitempty"` 25 | Since string `json:"since,omitempty"` 26 | SinceTime string `json:"sinceTime,omitempty"` 27 | Limit int64 `json:"limit,omitempty"` 28 | TimeoutSeconds int64 `json:"timeoutSeconds,omitempty"` 29 | } 30 | 31 | // EventInfo represents formatted event information for better readability. 32 | type EventInfo struct { 33 | FirstTimestamp metav1.Time `json:"firstTimestamp"` 34 | LastTimestamp metav1.Time `json:"lastTimestamp"` 35 | Count int32 `json:"count"` 36 | Type string `json:"type"` 37 | Reason string `json:"reason"` 38 | Object string `json:"object"` 39 | Message string `json:"message"` 40 | Source string `json:"source,omitempty"` 41 | Namespace string `json:"namespace,omitempty"` 42 | } 43 | 44 | // ListEventsTool provides functionality to list Kubernetes events with advanced filtering. 45 | type ListEventsTool struct { 46 | multiClient MultiClusterClientInterface 47 | } 48 | 49 | // NewListEventsTool creates a new ListEventsTool instance with the provided MultiClusterClient. 50 | func NewListEventsTool(multiClient MultiClusterClientInterface) *ListEventsTool { 51 | return &ListEventsTool{multiClient: multiClient} 52 | } 53 | 54 | // Tool returns the MCP tool definition for listing Kubernetes events. 55 | func (l *ListEventsTool) Tool() mcp.Tool { 56 | return mcp.NewTool("list_events", 57 | mcp.WithDescription("List Kubernetes events with advanced filtering options for debugging and monitoring"), 58 | mcp.WithString("context", 59 | mcp.Description("Kubernetes context name from kubeconfig to use for this request (leave empty for current context)"), 60 | ), 61 | mcp.WithString("namespace", 62 | mcp.Description("Kubernetes namespace to list events from (leave empty for all namespaces, use 'default' for default namespace)"), 63 | ), 64 | mcp.WithString("object", 65 | mcp.Description("Filter events by the name of the Kubernetes object (e.g., pod name, deployment name)"), 66 | ), 67 | mcp.WithString("eventType", 68 | mcp.Description("Filter by event type: 'Normal' or 'Warning' (case-insensitive)"), 69 | ), 70 | mcp.WithString("reason", 71 | mcp.Description("Filter by event reason (e.g., 'Pulled', 'Failed', 'FailedScheduling', 'Killing')"), 72 | ), 73 | mcp.WithString("since", 74 | mcp.Description("Return events newer than a relative duration like '5s', '2m', '1h', '24h' (optional)"), 75 | ), 76 | mcp.WithString("sinceTime", 77 | mcp.Description("Return events after a specific time (RFC3339 format, e.g., 2025-06-20T10:00:00Z) (optional)"), 78 | ), 79 | mcp.WithNumber("limit", 80 | mcp.Description("Maximum number of events to return (default: 100, use 0 for no limit)"), 81 | ), 82 | mcp.WithNumber("timeoutSeconds", 83 | mcp.Description("Timeout for the list operation in seconds (default: 30)"), 84 | ), 85 | ) 86 | } 87 | 88 | // Handler processes requests to list Kubernetes events with filtering options. 89 | func (l *ListEventsTool) Handler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { 90 | input, err := l.parseAndValidateEventsParams(req.Params.Arguments) 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to parse and validate events params: %w", err) 93 | } 94 | 95 | // Get the appropriate client for the context 96 | client, err := l.multiClient.GetClient(input.Context) 97 | if err != nil { 98 | return nil, fmt.Errorf("failed to get client for context '%s': %w", input.Context, err) 99 | } 100 | 101 | clientset, err := client.Clientset() 102 | if err != nil { 103 | return nil, fmt.Errorf("failed to get clientset: %w", err) 104 | } 105 | 106 | listOptions := l.buildListOptions(input) 107 | 108 | var eventList *corev1.EventList 109 | if input.Namespace == "" { 110 | // List events from all namespaces 111 | eventList, err = clientset.CoreV1().Events("").List(ctx, listOptions) 112 | } else { 113 | // List events from specific namespace 114 | eventList, err = clientset.CoreV1().Events(input.Namespace).List(ctx, listOptions) 115 | } 116 | 117 | if err != nil { 118 | return nil, fmt.Errorf("failed to list events: %w", err) 119 | } 120 | 121 | // Filter events based on input parameters 122 | filteredEvents := l.filterEvents(eventList.Items, input) 123 | 124 | // Convert to EventInfo format for better readability 125 | eventInfos := l.convertToEventInfos(filteredEvents) 126 | 127 | result := map[string]any{ 128 | "events": eventInfos, 129 | "total": len(eventInfos), 130 | "namespace": input.Namespace, 131 | "filters": map[string]any{ 132 | "object": input.Object, 133 | "eventType": input.EventType, 134 | "reason": input.Reason, 135 | "since": input.Since, 136 | "sinceTime": input.SinceTime, 137 | }, 138 | } 139 | 140 | out, err := json.Marshal(result) 141 | if err != nil { 142 | return nil, fmt.Errorf("failed to marshal events: %w", err) 143 | } 144 | 145 | return mcp.NewToolResultText(string(out)), nil 146 | } 147 | 148 | // buildListOptions creates metav1.ListOptions from the input parameters. 149 | func (l *ListEventsTool) buildListOptions(input *ListEventsInput) metav1.ListOptions { 150 | listOptions := metav1.ListOptions{} 151 | 152 | // Build field selector for object if specified 153 | if input.Object != "" { 154 | listOptions.FieldSelector = fields.OneTermEqualSelector("involvedObject.name", input.Object).String() 155 | } 156 | 157 | // Set limit 158 | listOptions.Limit = input.Limit 159 | 160 | // Set timeout 161 | listOptions.TimeoutSeconds = &input.TimeoutSeconds 162 | 163 | return listOptions 164 | } 165 | 166 | // filterEvents applies additional filtering based on input parameters. 167 | func (l *ListEventsTool) filterEvents(events []corev1.Event, input *ListEventsInput) []corev1.Event { 168 | var filteredEvents []corev1.Event 169 | 170 | for _, event := range events { 171 | // Filter by event type if specified 172 | if input.EventType != "" { 173 | if !strings.EqualFold(event.Type, input.EventType) { 174 | continue 175 | } 176 | } 177 | 178 | // Filter by reason if specified 179 | if input.Reason != "" { 180 | if !strings.Contains(strings.ToLower(event.Reason), strings.ToLower(input.Reason)) { 181 | continue 182 | } 183 | } 184 | 185 | // Filter by time if specified 186 | if !l.isEventWithinTimeRange(&event, input) { 187 | continue 188 | } 189 | 190 | filteredEvents = append(filteredEvents, event) 191 | } 192 | 193 | return filteredEvents 194 | } 195 | 196 | // isEventWithinTimeRange checks if the event falls within the specified time range. 197 | func (l *ListEventsTool) isEventWithinTimeRange(event *corev1.Event, input *ListEventsInput) bool { 198 | var cutoffTime time.Time 199 | var err error 200 | 201 | // Parse since duration 202 | if input.Since != "" { 203 | duration, parseErr := time.ParseDuration(input.Since) 204 | if parseErr == nil { 205 | cutoffTime = time.Now().Add(-duration) 206 | } 207 | } 208 | 209 | // Parse sinceTime (overrides since duration if both specified) 210 | if input.SinceTime != "" { 211 | cutoffTime, err = time.Parse(time.RFC3339, input.SinceTime) 212 | if err != nil { 213 | // If parsing fails, ignore the time filter 214 | return true 215 | } 216 | } 217 | 218 | // If no time filter specified, include all events 219 | if cutoffTime.IsZero() { 220 | return true 221 | } 222 | 223 | // Check if event's last timestamp is after the cutoff time 224 | return event.LastTimestamp.Time.After(cutoffTime) 225 | } 226 | 227 | // convertToEventInfos converts raw events to formatted EventInfo structs. 228 | func (l *ListEventsTool) convertToEventInfos(events []corev1.Event) []EventInfo { 229 | var eventInfos []EventInfo 230 | 231 | for _, event := range events { 232 | eventInfo := EventInfo{ 233 | FirstTimestamp: event.FirstTimestamp, 234 | LastTimestamp: event.LastTimestamp, 235 | Count: event.Count, 236 | Type: event.Type, 237 | Reason: event.Reason, 238 | Message: event.Message, 239 | Namespace: event.Namespace, 240 | } 241 | 242 | // Format involved object information 243 | if event.InvolvedObject.Kind != "" && event.InvolvedObject.Name != "" { 244 | eventInfo.Object = fmt.Sprintf("%s/%s", event.InvolvedObject.Kind, event.InvolvedObject.Name) 245 | if event.InvolvedObject.Namespace != "" && event.InvolvedObject.Namespace != event.Namespace { 246 | eventInfo.Object = fmt.Sprintf("%s/%s/%s", event.InvolvedObject.Namespace, event.InvolvedObject.Kind, event.InvolvedObject.Name) 247 | } 248 | } 249 | 250 | // Format source information 251 | if event.Source.Component != "" { 252 | eventInfo.Source = event.Source.Component 253 | if event.Source.Host != "" { 254 | eventInfo.Source = fmt.Sprintf("%s (%s)", event.Source.Component, event.Source.Host) 255 | } 256 | } 257 | 258 | eventInfos = append(eventInfos, eventInfo) 259 | } 260 | 261 | return eventInfos 262 | } 263 | 264 | // parseAndValidateEventsParams validates and extracts parameters from request arguments. 265 | func (l *ListEventsTool) parseAndValidateEventsParams(args map[string]any) (*ListEventsInput, error) { 266 | input := &ListEventsInput{} 267 | 268 | // Optional: context 269 | if context, ok := args["context"].(string); ok && context != "" { 270 | input.Context = context 271 | } 272 | 273 | if ns, ok := args["namespace"].(string); ok && ns != "" { 274 | input.Namespace = ns 275 | if err := validation.ValidateNamespace(input.Namespace); err != nil { 276 | return nil, fmt.Errorf("invalid namespace: %w", err) 277 | } 278 | } 279 | 280 | if obj, ok := args["object"].(string); ok && obj != "" { 281 | input.Object = obj 282 | if err := validation.ValidateResourceName(input.Object); err != nil { 283 | return nil, fmt.Errorf("invalid object: %w", err) 284 | } 285 | } 286 | 287 | if eventType, ok := args["eventType"].(string); ok && eventType != "" { 288 | input.EventType = eventType 289 | if !strings.EqualFold(eventType, "Normal") && !strings.EqualFold(eventType, "Warning") { 290 | return nil, fmt.Errorf("invalid eventType: must be 'Normal' or 'Warning' (case-insensitive)") 291 | } 292 | } 293 | 294 | if reason, ok := args["reason"].(string); ok && reason != "" { 295 | input.Reason = reason 296 | } 297 | 298 | if since, ok := args["since"].(string); ok && since != "" { 299 | input.Since = since 300 | if _, err := time.ParseDuration(since); err != nil { 301 | return nil, fmt.Errorf("invalid since duration format: %w", err) 302 | } 303 | } 304 | 305 | if sinceTime, ok := args["sinceTime"].(string); ok && sinceTime != "" { 306 | input.SinceTime = sinceTime 307 | if _, err := time.Parse(time.RFC3339, sinceTime); err != nil { 308 | return nil, fmt.Errorf("invalid sinceTime format (expected RFC3339): %w", err) 309 | } 310 | } 311 | 312 | if limit, ok := args["limit"].(float64); ok && limit >= 0 { 313 | input.Limit = int64(limit) 314 | } else { 315 | input.Limit = 100 316 | } 317 | 318 | if timeoutSeconds, ok := args["timeoutSeconds"].(float64); ok && timeoutSeconds > 0 { 319 | input.TimeoutSeconds = int64(timeoutSeconds) 320 | } else { 321 | input.TimeoutSeconds = 30 322 | } 323 | 324 | return input, nil 325 | } 326 | -------------------------------------------------------------------------------- /src/tools/describe_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/mark3labs/mcp-go/mcp" 8 | "github.com/stretchr/testify/assert" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "k8s.io/apimachinery/pkg/types" 13 | "k8s.io/apimachinery/pkg/api/meta" 14 | "k8s.io/client-go/discovery" 15 | "k8s.io/client-go/dynamic" 16 | "k8s.io/client-go/kubernetes" 17 | "k8s.io/apimachinery/pkg/watch" 18 | ) 19 | 20 | type FakeDescribeResourceInterface struct { 21 | resource *unstructured.Unstructured 22 | } 23 | 24 | func (f *FakeDescribeResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { 25 | return nil, nil 26 | } 27 | 28 | func (f *FakeDescribeResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { 29 | return nil, nil 30 | } 31 | 32 | func (f *FakeDescribeResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions) (*unstructured.Unstructured, error) { 33 | return nil, nil 34 | } 35 | 36 | func (f *FakeDescribeResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error { 37 | return nil 38 | } 39 | 40 | func (f *FakeDescribeResourceInterface) DeleteCollection(ctx context.Context, options metav1.DeleteOptions, listOptions metav1.ListOptions) error { 41 | return nil 42 | } 43 | 44 | func (f *FakeDescribeResourceInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { 45 | return f.resource, nil 46 | } 47 | 48 | func (f *FakeDescribeResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { 49 | return nil, nil 50 | } 51 | 52 | func (f *FakeDescribeResourceInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { 53 | return nil, nil 54 | } 55 | 56 | func (f *FakeDescribeResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) { 57 | return nil, nil 58 | } 59 | 60 | func (f *FakeDescribeResourceInterface) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) { 61 | return nil, nil 62 | } 63 | 64 | func (f *FakeDescribeResourceInterface) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions) (*unstructured.Unstructured, error) { 65 | return nil, nil 66 | } 67 | 68 | type FakeDescribeKubernetesClient struct { 69 | resource *unstructured.Unstructured 70 | } 71 | 72 | func (f FakeDescribeKubernetesClient) DynamicClient() (dynamic.Interface, error) { 73 | return nil, nil 74 | } 75 | 76 | func (f FakeDescribeKubernetesClient) DiscoClient() (discovery.DiscoveryInterface, error) { 77 | fakeDisco := &fakeDiscoveryClient{ 78 | apiResourceLists: []*metav1.APIResourceList{ 79 | { 80 | GroupVersion: "apps/v1", 81 | APIResources: []metav1.APIResource{ 82 | {Kind: "Deployment", Name: "deployments", Namespaced: true}, 83 | }, 84 | }, 85 | { 86 | GroupVersion: "v1", 87 | APIResources: []metav1.APIResource{ 88 | {Kind: "Pod", Name: "pods", Namespaced: true}, 89 | }, 90 | }, 91 | }, 92 | } 93 | return fakeDisco, nil 94 | } 95 | 96 | func (f FakeDescribeKubernetesClient) Clientset() (*kubernetes.Clientset, error) { 97 | return nil, nil 98 | } 99 | 100 | func (f FakeDescribeKubernetesClient) RESTMapper() (meta.RESTMapper, error) { 101 | return nil, nil 102 | } 103 | 104 | func (f FakeDescribeKubernetesClient) ResourceInterface(gvr schema.GroupVersionResource, namespaced bool, ns string) (dynamic.ResourceInterface, error) { 105 | return &FakeDescribeResourceInterface{resource: f.resource}, nil 106 | } 107 | 108 | func TestDescribeTool_Tool(t *testing.T) { 109 | client := FakeDescribeKubernetesClient{} 110 | multiClient := NewFakeMultiClusterClient(client) 111 | tool := NewDescribeTool(multiClient) 112 | 113 | mcpTool := tool.Tool() 114 | 115 | assert.Equal(t, "describe_resource", mcpTool.Name) 116 | assert.Contains(t, mcpTool.Description, "Describe a specific Kubernetes resource") 117 | } 118 | 119 | func TestDescribeTool_Handler(t *testing.T) { 120 | testPod := &unstructured.Unstructured{ 121 | Object: map[string]interface{}{ 122 | "apiVersion": "v1", 123 | "kind": "Pod", 124 | "metadata": map[string]interface{}{ 125 | "name": "test-pod", 126 | "namespace": "default", 127 | "labels": map[string]interface{}{ 128 | "app": "test", 129 | }, 130 | "annotations": map[string]interface{}{ 131 | "kubernetes.io/change-cause": "test deployment", 132 | }, 133 | "creationTimestamp": "2023-01-01T00:00:00Z", 134 | "resourceVersion": "12345", 135 | "uid": "test-uid-123", 136 | }, 137 | "spec": map[string]interface{}{ 138 | "containers": []interface{}{ 139 | map[string]interface{}{ 140 | "name": "test-container", 141 | "image": "nginx:latest", 142 | }, 143 | }, 144 | }, 145 | "status": map[string]interface{}{ 146 | "phase": "Running", 147 | "conditions": []interface{}{ 148 | map[string]interface{}{ 149 | "type": "Ready", 150 | "status": "True", 151 | }, 152 | }, 153 | }, 154 | }, 155 | } 156 | 157 | testCases := []struct { 158 | name string 159 | client Client 160 | request map[string]any 161 | expectError bool 162 | }{ 163 | { 164 | name: "SuccessfulDescribe", 165 | client: FakeDescribeKubernetesClient{resource: testPod}, 166 | request: map[string]any{ 167 | "kind": "Pod", 168 | "name": "test-pod", 169 | "namespace": "default", 170 | }, 171 | expectError: false, 172 | }, 173 | { 174 | name: "MissingKind", 175 | client: FakeDescribeKubernetesClient{resource: testPod}, 176 | request: map[string]any{ 177 | "name": "test-pod", 178 | "namespace": "default", 179 | }, 180 | expectError: true, 181 | }, 182 | { 183 | name: "MissingName", 184 | client: FakeDescribeKubernetesClient{resource: testPod}, 185 | request: map[string]any{ 186 | "kind": "Pod", 187 | "namespace": "default", 188 | }, 189 | expectError: true, 190 | }, 191 | { 192 | name: "EmptyKind", 193 | client: FakeDescribeKubernetesClient{resource: testPod}, 194 | request: map[string]any{ 195 | "kind": "", 196 | "name": "test-pod", 197 | "namespace": "default", 198 | }, 199 | expectError: true, 200 | }, 201 | { 202 | name: "EmptyName", 203 | client: FakeDescribeKubernetesClient{resource: testPod}, 204 | request: map[string]any{ 205 | "kind": "Pod", 206 | "name": "", 207 | "namespace": "default", 208 | }, 209 | expectError: true, 210 | }, 211 | } 212 | 213 | for _, tt := range testCases { 214 | t.Run(tt.name, func(t *testing.T) { 215 | multiClient := NewFakeMultiClusterClient(tt.client) 216 | tool := NewDescribeTool(multiClient) 217 | req := &mcp.CallToolRequest{} 218 | req.Params.Arguments = tt.request 219 | 220 | result, err := tool.Handler(context.TODO(), *req) 221 | 222 | if tt.expectError { 223 | assert.Error(t, err) 224 | assert.Nil(t, result) 225 | } else { 226 | assert.NoError(t, err) 227 | assert.NotNil(t, result) 228 | assert.NotEmpty(t, result.Content) 229 | textContent, ok := result.Content[0].(mcp.TextContent) 230 | assert.True(t, ok) 231 | assert.Equal(t, "text", textContent.Type) 232 | } 233 | }) 234 | } 235 | } 236 | 237 | func TestParseAndValidateDescribeParams(t *testing.T) { 238 | testCases := []struct { 239 | name string 240 | args map[string]any 241 | expected *DescribeResourceInput 242 | expectedErr bool 243 | }{ 244 | { 245 | name: "ValidMinimal", 246 | args: map[string]any{ 247 | "kind": "Pod", 248 | "name": "test-pod", 249 | }, 250 | expected: &DescribeResourceInput{ 251 | Kind: "Pod", 252 | Name: "test-pod", 253 | Namespace: metav1.NamespaceAll, 254 | }, 255 | expectedErr: false, 256 | }, 257 | { 258 | name: "ValidWithNamespace", 259 | args: map[string]any{ 260 | "kind": "Deployment", 261 | "name": "test-deployment", 262 | "namespace": "default", 263 | }, 264 | expected: &DescribeResourceInput{ 265 | Kind: "Deployment", 266 | Name: "test-deployment", 267 | Namespace: "default", 268 | }, 269 | expectedErr: false, 270 | }, 271 | { 272 | name: "MissingKind", 273 | args: map[string]any{ 274 | "name": "test-pod", 275 | "namespace": "default", 276 | }, 277 | expected: nil, 278 | expectedErr: true, 279 | }, 280 | { 281 | name: "MissingName", 282 | args: map[string]any{ 283 | "kind": "Pod", 284 | "namespace": "default", 285 | }, 286 | expected: nil, 287 | expectedErr: true, 288 | }, 289 | { 290 | name: "EmptyKind", 291 | args: map[string]any{ 292 | "kind": "", 293 | "name": "test-pod", 294 | "namespace": "default", 295 | }, 296 | expected: nil, 297 | expectedErr: true, 298 | }, 299 | { 300 | name: "EmptyName", 301 | args: map[string]any{ 302 | "kind": "Pod", 303 | "name": "", 304 | "namespace": "default", 305 | }, 306 | expected: nil, 307 | expectedErr: true, 308 | }, 309 | { 310 | name: "EmptyNamespace", 311 | args: map[string]any{ 312 | "kind": "Pod", 313 | "name": "test-pod", 314 | "namespace": "", 315 | }, 316 | expected: &DescribeResourceInput{ 317 | Kind: "Pod", 318 | Name: "test-pod", 319 | Namespace: metav1.NamespaceAll, 320 | }, 321 | expectedErr: false, 322 | }, 323 | } 324 | 325 | for _, tc := range testCases { 326 | t.Run(tc.name, func(t *testing.T) { 327 | result, err := parseAndValidateDescribeParams(tc.args) 328 | 329 | if tc.expectedErr { 330 | assert.Error(t, err) 331 | assert.Nil(t, result) 332 | } else { 333 | assert.NoError(t, err) 334 | assert.Equal(t, tc.expected, result) 335 | } 336 | }) 337 | } 338 | } 339 | 340 | func TestDescribeTool_FormatResourceDescription(t *testing.T) { 341 | client := FakeDescribeKubernetesClient{} 342 | multiClient := NewFakeMultiClusterClient(client) 343 | tool := NewDescribeTool(multiClient) 344 | 345 | testPod := &unstructured.Unstructured{} 346 | testPod.SetName("test-pod") 347 | testPod.SetNamespace("default") 348 | testPod.SetKind("Pod") 349 | testPod.SetLabels(map[string]string{ 350 | "app": "test", 351 | }) 352 | testPod.SetAnnotations(map[string]string{ 353 | "kubernetes.io/change-cause": "test deployment", 354 | }) 355 | testPod.SetUID("test-uid-123") 356 | testPod.SetResourceVersion("12345") 357 | 358 | // Set spec and status using the Object field 359 | testPod.Object = map[string]interface{}{ 360 | "metadata": map[string]interface{}{ 361 | "name": "test-pod", 362 | "namespace": "default", 363 | "labels": map[string]interface{}{ 364 | "app": "test", 365 | }, 366 | "annotations": map[string]interface{}{ 367 | "kubernetes.io/change-cause": "test deployment", 368 | }, 369 | "uid": "test-uid-123", 370 | "resourceVersion": "12345", 371 | }, 372 | "kind": "Pod", 373 | "spec": map[string]interface{}{ 374 | "containers": []interface{}{ 375 | map[string]interface{}{ 376 | "name": "test-container", 377 | "image": "nginx:latest", 378 | }, 379 | }, 380 | }, 381 | "status": map[string]interface{}{ 382 | "phase": "Running", 383 | "conditions": []interface{}{ 384 | map[string]interface{}{ 385 | "type": "Ready", 386 | "status": "True", 387 | }, 388 | }, 389 | }, 390 | } 391 | 392 | result := tool.formatResourceDescription(testPod) 393 | 394 | assert.Equal(t, "test-pod", result["name"]) 395 | assert.Equal(t, "default", result["namespace"]) 396 | assert.Equal(t, "Pod", result["kind"]) 397 | assert.NotNil(t, result["labels"]) 398 | assert.NotNil(t, result["annotations"]) 399 | assert.Equal(t, "test-uid-123", string(result["uid"].(types.UID))) 400 | assert.Equal(t, "12345", result["resourceVersion"]) 401 | assert.NotNil(t, result["spec"]) 402 | assert.NotNil(t, result["status"]) 403 | 404 | // Check spec content 405 | spec, ok := result["spec"].(map[string]interface{}) 406 | assert.True(t, ok) 407 | containers, ok := spec["containers"].([]interface{}) 408 | assert.True(t, ok) 409 | assert.Len(t, containers, 1) 410 | 411 | // Check status content 412 | status, ok := result["status"].(map[string]interface{}) 413 | assert.True(t, ok) 414 | assert.Equal(t, "Running", status["phase"]) 415 | assert.NotNil(t, status["conditions"]) 416 | } 417 | 418 | func TestDescribeTool_FormatResourceDescriptionWithoutSpecStatus(t *testing.T) { 419 | client := FakeDescribeKubernetesClient{} 420 | multiClient := NewFakeMultiClusterClient(client) 421 | tool := NewDescribeTool(multiClient) 422 | 423 | // Create a resource without spec and status 424 | testConfigMap := &unstructured.Unstructured{} 425 | testConfigMap.SetName("test-configmap") 426 | testConfigMap.SetNamespace("default") 427 | testConfigMap.SetKind("ConfigMap") 428 | testConfigMap.SetUID("configmap-uid-123") 429 | 430 | testConfigMap.Object = map[string]interface{}{ 431 | "metadata": map[string]interface{}{ 432 | "name": "test-configmap", 433 | "namespace": "default", 434 | "uid": "configmap-uid-123", 435 | }, 436 | "kind": "ConfigMap", 437 | "data": map[string]interface{}{ 438 | "key1": "value1", 439 | "key2": "value2", 440 | }, 441 | } 442 | 443 | result := tool.formatResourceDescription(testConfigMap) 444 | 445 | assert.Equal(t, "test-configmap", result["name"]) 446 | assert.Equal(t, "default", result["namespace"]) 447 | assert.Equal(t, "ConfigMap", result["kind"]) 448 | assert.Equal(t, "configmap-uid-123", string(result["uid"].(types.UID))) 449 | 450 | // spec and status should not be present since they don't exist in the resource 451 | _, specExists := result["spec"] 452 | _, statusExists := result["status"] 453 | assert.False(t, specExists) 454 | assert.False(t, statusExists) 455 | } 456 | 457 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 6 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 7 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 8 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 9 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 10 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 11 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 12 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 13 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 14 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 15 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 16 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 17 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 18 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 19 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 20 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 21 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 22 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 23 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 24 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 25 | github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= 26 | github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 27 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 28 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 29 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 30 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 31 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 32 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 33 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 34 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 35 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 36 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 37 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 38 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 39 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 40 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 41 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 42 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 43 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 44 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 45 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 46 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 47 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 48 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 49 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 50 | github.com/mark3labs/mcp-go v0.24.1 h1:YV+5X/+W4oBdERLWgiA1uR7AIvenlKJaa5V4hqufI7E= 51 | github.com/mark3labs/mcp-go v0.24.1/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= 52 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 53 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 54 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 55 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 56 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 57 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 58 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 59 | github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= 60 | github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 61 | github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= 62 | github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 63 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 64 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 65 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 66 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 67 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 68 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 69 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 70 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 71 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 72 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 73 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 74 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 75 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 76 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 77 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 78 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 79 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 80 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 81 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 82 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 83 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 84 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 85 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 86 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 87 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 88 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 89 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 90 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 91 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 92 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 93 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 94 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 95 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 96 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 97 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 98 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 99 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 100 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 101 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 102 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 103 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 104 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 105 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 106 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 107 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 109 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 110 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 111 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 112 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 113 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 114 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 115 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 116 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 117 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 118 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 119 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 120 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 121 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 122 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 123 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 124 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 125 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 126 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 127 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 128 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 129 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 130 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 131 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 132 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 133 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 134 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 135 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 136 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 137 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 138 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 139 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 140 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 141 | k8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU= 142 | k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM= 143 | k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ= 144 | k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= 145 | k8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98= 146 | k8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg= 147 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 148 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 149 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= 150 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= 151 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= 152 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 153 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= 154 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= 155 | sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 156 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 157 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 158 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= 159 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 160 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 161 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 162 | -------------------------------------------------------------------------------- /src/tools/list_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/stretchr/testify/assert" 10 | "k8s.io/apimachinery/pkg/api/meta" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/runtime/schema" 15 | "k8s.io/client-go/discovery" 16 | "k8s.io/client-go/dynamic" 17 | "k8s.io/client-go/dynamic/fake" 18 | "k8s.io/client-go/kubernetes" 19 | "sigs.k8s.io/yaml" 20 | 21 | appsv1 "k8s.io/api/apps/v1" 22 | corev1 "k8s.io/api/core/v1" 23 | ) 24 | 25 | func NewFakeKubernetesClient() { 26 | } 27 | 28 | type FakeKubernetesClient struct{} 29 | 30 | func (f FakeKubernetesClient) DynamicClient() (dynamic.Interface, error) { 31 | return nil, nil 32 | } 33 | 34 | func (f FakeKubernetesClient) DiscoClient() (discovery.DiscoveryInterface, error) { 35 | fakeDisco := &fakeDiscoveryClient{ 36 | apiResourceLists: []*metav1.APIResourceList{ 37 | { 38 | GroupVersion: "apps/v1", 39 | APIResources: []metav1.APIResource{ 40 | {Kind: "Deployment", Name: "deployments", Namespaced: true}, 41 | }, 42 | }, 43 | }, 44 | } 45 | return fakeDisco, nil 46 | } 47 | func (f FakeKubernetesClient) Clientset() (*kubernetes.Clientset, error) { 48 | return nil, nil 49 | } 50 | func (f FakeKubernetesClient) RESTMapper() (meta.RESTMapper, error) { 51 | return nil, nil 52 | } 53 | func (f FakeKubernetesClient) ResourceInterface(gvr schema.GroupVersionResource, namespaced bool, ns string) (dynamic.ResourceInterface, error) { 54 | scheme := runtime.NewScheme() 55 | _ = appsv1.AddToScheme(scheme) 56 | _ = corev1.AddToScheme(scheme) 57 | 58 | depUnstr := &unstructured.Unstructured{ 59 | Object: map[string]any{ 60 | "apiVersion": "apps/v1", 61 | "kind": "Deployment", 62 | "metadata": map[string]any{ 63 | "name": "foo-deployment", 64 | "namespace": "default", 65 | }, 66 | }, 67 | } 68 | 69 | fakeDynClient := fake.NewSimpleDynamicClient(scheme, depUnstr) 70 | ri := fakeDynClient.Resource(gvr).Namespace(ns) 71 | return ri, nil 72 | } 73 | 74 | func TestResource(t *testing.T) { 75 | testCases := []struct { 76 | name string 77 | input Client 78 | request map[string]any 79 | expected *mcp.CallToolResult 80 | }{ 81 | { 82 | name: "Success", 83 | input: FakeKubernetesClient{}, 84 | request: map[string]any{ 85 | "namespace": "default", 86 | "kind": "deployments", 87 | }, 88 | expected: &mcp.CallToolResult{ 89 | Content: []mcp.Content{ 90 | mcp.TextContent{ 91 | Annotated: mcp.Annotated{ 92 | Annotations: nil, 93 | }, 94 | Type: "text", 95 | Text: "[{\"name\":\"foo-deployment\",\"namespace\":\"default\",\"kind\":\"Deployment\"}]", 96 | }, 97 | }, 98 | }, 99 | }, 100 | } 101 | 102 | for _, tt := range testCases { 103 | t.Run(tt.name, func(t *testing.T) { 104 | multiClient := NewFakeMultiClusterClient(tt.input) 105 | l := NewListTool(multiClient) 106 | req := &mcp.CallToolRequest{} 107 | req.Params.Arguments = tt.request 108 | actual, err := l.Handler(context.TODO(), *req) 109 | assert.NoError(t, err) 110 | assert.Equal(t, tt.expected, actual) 111 | }) 112 | } 113 | } 114 | 115 | func TestFindGVR(t *testing.T) { 116 | 117 | tests := []struct { 118 | inputKind string 119 | datapath string 120 | expected *schema.GroupVersionResource 121 | expectingError bool 122 | }{ 123 | { 124 | inputKind: "hr", 125 | datapath: "testdata/apiresources.yaml", 126 | expected: &schema.GroupVersionResource{ 127 | Group: "helm.toolkit.fluxcd.io", 128 | Version: "v2", 129 | Resource: "helmreleases", 130 | }, 131 | expectingError: false, 132 | }, 133 | { 134 | inputKind: "HelmRelease", 135 | datapath: "testdata/apiresources.yaml", 136 | expected: &schema.GroupVersionResource{ 137 | Group: "helm.toolkit.fluxcd.io", 138 | Version: "v2", 139 | Resource: "helmreleases", 140 | }, 141 | expectingError: false, 142 | }, 143 | { 144 | inputKind: "helmreleases", 145 | datapath: "testdata/apiresources.yaml", 146 | expected: &schema.GroupVersionResource{ 147 | Group: "helm.toolkit.fluxcd.io", 148 | Version: "v2", 149 | Resource: "helmreleases", 150 | }, 151 | expectingError: false, 152 | }, 153 | { 154 | inputKind: "pod", 155 | datapath: "testdata/apiresources.yaml", 156 | expected: &schema.GroupVersionResource{ 157 | Group: "", 158 | Version: "v1", 159 | Resource: "pods", 160 | }, 161 | expectingError: false, 162 | }, 163 | { 164 | inputKind: "unknownkind", 165 | datapath: "testdata/apiresources.yaml", 166 | expected: &schema.GroupVersionResource{}, 167 | expectingError: true, 168 | }, 169 | { 170 | inputKind: "sa", 171 | datapath: "testdata/apiresources.yaml", 172 | expected: &schema.GroupVersionResource{ 173 | Group: "", 174 | Version: "v1", 175 | Resource: "serviceaccounts", 176 | }, 177 | expectingError: false, 178 | }, 179 | } 180 | 181 | for _, tt := range tests { 182 | t.Run(tt.inputKind, func(t *testing.T) { 183 | // arrange 184 | data, err := os.ReadFile(tt.datapath) 185 | if err != nil { 186 | t.Fatalf("Failed to read %s: %v", tt.datapath, err) 187 | } 188 | var apiResLists []*metav1.APIResourceList 189 | if err := yaml.Unmarshal(data, &apiResLists); err != nil { 190 | t.Fatalf("Failed to unmarshal Yaml: %v", err) 191 | } 192 | 193 | // act 194 | actual, err := findGVRByKind(apiResLists, tt.inputKind) 195 | 196 | // assert 197 | if tt.expectingError { 198 | assert.Error(t, err) 199 | } else { 200 | assert.Equal(t, tt.expected, actual.ToGroupVersionResource()) 201 | } 202 | }) 203 | } 204 | } 205 | 206 | func TestFindGVRsByGroupSubstring(t *testing.T) { 207 | tests := []struct { 208 | name string 209 | groupSubstring string 210 | datapath string 211 | expectedGVRs []*schema.GroupVersionResource 212 | expectingError bool 213 | }{ 214 | { 215 | name: "Helm group matches", 216 | groupSubstring: "helm.toolkit.fluxcd.io", 217 | datapath: "testdata/apiresources.yaml", 218 | expectedGVRs: []*schema.GroupVersionResource{ 219 | { 220 | Group: "helm.toolkit.fluxcd.io", 221 | Version: "v2", 222 | Resource: "helmreleases", 223 | }, 224 | }, 225 | expectingError: false, 226 | }, 227 | { 228 | name: "flux group matches", 229 | groupSubstring: "fluxcd", 230 | datapath: "testdata/apiresources.yaml", 231 | expectedGVRs: []*schema.GroupVersionResource{ 232 | { 233 | Group: "helm.toolkit.fluxcd.io", 234 | Version: "v2", 235 | Resource: "helmreleases", 236 | }, 237 | { 238 | Group: "kustomize.toolkit.fluxcd.io", 239 | Version: "v1", 240 | Resource: "kustomizations", 241 | }, 242 | { 243 | Group: "notification.toolkit.fluxcd.io", 244 | Version: "v1", 245 | Resource: "receivers", 246 | }, 247 | { 248 | Group: "notification.toolkit.fluxcd.io", 249 | Version: "v1beta3", 250 | Resource: "providers", 251 | }, 252 | { 253 | Group: "notification.toolkit.fluxcd.io", 254 | Version: "v1beta3", 255 | Resource: "alerts", 256 | }, 257 | { 258 | Group: "source.toolkit.fluxcd.io", 259 | Version: "v1", 260 | Resource: "gitrepositories", 261 | }, 262 | { 263 | Group: "source.toolkit.fluxcd.io", 264 | Version: "v1", 265 | Resource: "buckets", 266 | }, 267 | { 268 | Group: "source.toolkit.fluxcd.io", 269 | Version: "v1", 270 | Resource: "helmrepositories", 271 | }, 272 | { 273 | Group: "source.toolkit.fluxcd.io", 274 | Version: "v1", 275 | Resource: "helmcharts", 276 | }, 277 | { 278 | Group: "source.toolkit.fluxcd.io", 279 | Version: "v1beta2", 280 | Resource: "ocirepositories", 281 | }, 282 | }, 283 | expectingError: false, 284 | }, 285 | { 286 | name: "No group matches", 287 | groupSubstring: "nonexistent.group", 288 | datapath: "testdata/apiresources.yaml", 289 | expectedGVRs: nil, 290 | expectingError: false, 291 | }, 292 | } 293 | 294 | for _, tt := range tests { 295 | t.Run(tt.name, func(t *testing.T) { 296 | // arrange 297 | data, err := os.ReadFile(tt.datapath) 298 | if err != nil { 299 | t.Fatalf("failed to read %s: %v", tt.datapath, err) 300 | } 301 | var apiResLists []*metav1.APIResourceList 302 | if err := yaml.Unmarshal(data, &apiResLists); err != nil { 303 | t.Fatalf("failed to unmarshal YAML: %v", err) 304 | } 305 | 306 | // act 307 | actualGVRs, err := findGVRsByGroupSubstring(apiResLists, tt.groupSubstring) 308 | 309 | // assert 310 | if tt.expectingError { 311 | assert.Error(t, err) 312 | return 313 | } 314 | assert.NoError(t, err) 315 | assert.Equal(t, tt.expectedGVRs, actualGVRs.ToGroupVersionResources()) 316 | }) 317 | } 318 | } 319 | 320 | func TestParseAndValidateListParams(t *testing.T) { 321 | testCases := []struct { 322 | name string 323 | args map[string]any 324 | expected *ListResourcesInput 325 | expectedErr bool 326 | }{ 327 | { 328 | name: "MinimalValid", 329 | args: map[string]any{ 330 | "kind": "pods", 331 | }, 332 | expected: &ListResourcesInput{ 333 | Kind: "pods", 334 | Namespace: metav1.NamespaceAll, 335 | TimeoutSeconds: 30, 336 | }, 337 | expectedErr: false, 338 | }, 339 | { 340 | name: "FullValid", 341 | args: map[string]any{ 342 | "kind": "deployments", 343 | "namespace": "default", 344 | "labelSelector": "app=nginx", 345 | "fieldSelector": "metadata.name=my-deployment", 346 | "limit": float64(10), 347 | "timeoutSeconds": float64(60), 348 | "showDetails": true, 349 | }, 350 | expected: &ListResourcesInput{ 351 | Kind: "deployments", 352 | Namespace: "default", 353 | LabelSelector: "app=nginx", 354 | FieldSelector: "metadata.name=my-deployment", 355 | Limit: 10, 356 | TimeoutSeconds: 60, 357 | ShowDetails: true, 358 | }, 359 | expectedErr: false, 360 | }, 361 | { 362 | name: "MissingKind", 363 | args: map[string]any{ 364 | "namespace": "default", 365 | }, 366 | expected: nil, 367 | expectedErr: true, 368 | }, 369 | { 370 | name: "EmptyKind", 371 | args: map[string]any{ 372 | "kind": "", 373 | }, 374 | expected: nil, 375 | expectedErr: true, 376 | }, 377 | { 378 | name: "WithShowDetails", 379 | args: map[string]any{ 380 | "kind": "pods", 381 | "showDetails": true, 382 | }, 383 | expected: &ListResourcesInput{ 384 | Kind: "pods", 385 | Namespace: metav1.NamespaceAll, 386 | TimeoutSeconds: 30, 387 | ShowDetails: true, 388 | }, 389 | expectedErr: false, 390 | }, 391 | } 392 | 393 | for _, tc := range testCases { 394 | t.Run(tc.name, func(t *testing.T) { 395 | result, err := parseAndValidateListParams(tc.args) 396 | 397 | if tc.expectedErr { 398 | assert.Error(t, err) 399 | assert.Nil(t, result) 400 | } else { 401 | assert.NoError(t, err) 402 | assert.Equal(t, tc.expected, result) 403 | } 404 | }) 405 | } 406 | } 407 | 408 | func TestListTool_Tool(t *testing.T) { 409 | client := FakeKubernetesClient{} 410 | multiClient := NewFakeMultiClusterClient(client) 411 | tool := NewListTool(multiClient) 412 | 413 | mcpTool := tool.Tool() 414 | 415 | assert.Equal(t, "list_resources", mcpTool.Name) 416 | assert.Contains(t, mcpTool.Description, "List Kubernetes resources") 417 | } 418 | 419 | func TestExtractResourceStatus(t *testing.T) { 420 | client := FakeKubernetesClient{} 421 | multiClient := NewFakeMultiClusterClient(client) 422 | tool := NewListTool(multiClient) 423 | 424 | // Create a mock unstructured object with status 425 | obj := &unstructured.Unstructured{} 426 | obj.SetName("test-pod") 427 | obj.SetNamespace("default") 428 | obj.SetKind("Pod") 429 | 430 | // Set status using unstructured.SetNestedField to avoid deep copy issues 431 | obj.Object = map[string]interface{}{ 432 | "metadata": map[string]interface{}{ 433 | "name": "test-pod", 434 | "namespace": "default", 435 | }, 436 | "kind": "Pod", 437 | "status": map[string]interface{}{ 438 | "phase": "Running", 439 | "conditions": []interface{}{ 440 | map[string]interface{}{ 441 | "type": "Ready", 442 | "status": "True", 443 | }, 444 | }, 445 | "containerStatuses": []interface{}{ 446 | map[string]interface{}{ 447 | "name": "container1", 448 | "ready": true, 449 | "restartCount": int64(0), 450 | }, 451 | }, 452 | }, 453 | } 454 | 455 | result := tool.extractResourceStatus(obj) 456 | 457 | assert.Equal(t, "test-pod", result.Name) 458 | assert.Equal(t, "default", result.Namespace) 459 | assert.Equal(t, "Pod", result.Kind) 460 | assert.NotNil(t, result.Status) 461 | 462 | // Check that status contains the expected fields 463 | statusMap, ok := result.Status.(map[string]interface{}) 464 | assert.True(t, ok) 465 | assert.Equal(t, "Running", statusMap["phase"]) 466 | assert.NotNil(t, statusMap["conditions"]) 467 | assert.NotNil(t, statusMap["containerStatuses"]) 468 | } 469 | 470 | func TestBuildListOptions(t *testing.T) { 471 | testCases := []struct { 472 | name string 473 | input *ListResourcesInput 474 | expected metav1.ListOptions 475 | }{ 476 | { 477 | name: "FullOptions", 478 | input: &ListResourcesInput{ 479 | Kind: "deployments", 480 | Namespace: "default", 481 | LabelSelector: "app=nginx", 482 | FieldSelector: "metadata.name=test", 483 | Limit: 10, 484 | TimeoutSeconds: 60, 485 | }, 486 | expected: metav1.ListOptions{ 487 | LabelSelector: "app=nginx", 488 | FieldSelector: "metadata.name=test", 489 | Limit: 10, 490 | TimeoutSeconds: func() *int64 { v := int64(60); return &v }(), 491 | }, 492 | }, 493 | } 494 | 495 | for _, tc := range testCases { 496 | t.Run(tc.name, func(t *testing.T) { 497 | client := FakeKubernetesClient{} 498 | multiClient := NewFakeMultiClusterClient(client) 499 | tool := NewListTool(multiClient) 500 | result := tool.buildListOptions(tc.input) 501 | 502 | assert.Equal(t, tc.expected.LabelSelector, result.LabelSelector) 503 | assert.Equal(t, tc.expected.FieldSelector, result.FieldSelector) 504 | assert.Equal(t, tc.expected.Limit, result.Limit) 505 | if tc.expected.TimeoutSeconds != nil { 506 | assert.NotNil(t, result.TimeoutSeconds) 507 | assert.Equal(t, *tc.expected.TimeoutSeconds, *result.TimeoutSeconds) 508 | } 509 | }) 510 | } 511 | } 512 | -------------------------------------------------------------------------------- /src/tools/list_events_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mark3labs/mcp-go/mcp" 11 | "github.com/stretchr/testify/assert" 12 | corev1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/api/meta" 15 | "k8s.io/apimachinery/pkg/runtime/schema" 16 | "k8s.io/client-go/discovery" 17 | "k8s.io/client-go/dynamic" 18 | "k8s.io/client-go/kubernetes" 19 | ) 20 | 21 | type FakeEventsClient struct { 22 | clientset *kubernetes.Clientset 23 | err error 24 | } 25 | 26 | func (f *FakeEventsClient) Clientset() (*kubernetes.Clientset, error) { 27 | return f.clientset, f.err 28 | } 29 | 30 | func (f *FakeEventsClient) DynamicClient() (dynamic.Interface, error) { 31 | return nil, nil 32 | } 33 | 34 | func (f *FakeEventsClient) DiscoClient() (discovery.DiscoveryInterface, error) { 35 | return nil, nil 36 | } 37 | 38 | func (f *FakeEventsClient) RESTMapper() (meta.RESTMapper, error) { 39 | return nil, nil 40 | } 41 | 42 | func (f *FakeEventsClient) ResourceInterface(gvr schema.GroupVersionResource, namespaced bool, ns string) (dynamic.ResourceInterface, error) { 43 | return nil, nil 44 | } 45 | 46 | func TestListEventsTool_Handler_ClientsetError(t *testing.T) { 47 | client := &FakeEventsClient{ 48 | clientset: nil, 49 | err: errors.New("clientset error"), 50 | } 51 | 52 | multiClient := NewFakeMultiClusterClient(client) 53 | tool := NewListEventsTool(multiClient) 54 | 55 | req := mcp.CallToolRequest{ 56 | Params: struct { 57 | Name string `json:"name"` 58 | Arguments map[string]any `json:"arguments,omitempty"` 59 | Meta *struct { 60 | ProgressToken mcp.ProgressToken `json:"progressToken,omitempty"` 61 | } `json:"_meta,omitempty"` 62 | }{ 63 | Arguments: map[string]any{ 64 | "namespace": "default", 65 | }, 66 | }, 67 | } 68 | 69 | actualResult, actualErr := tool.Handler(context.Background(), req) 70 | 71 | assert.Error(t, actualErr) 72 | assert.Contains(t, actualErr.Error(), "failed to get clientset: clientset error") 73 | assert.Nil(t, actualResult) 74 | } 75 | 76 | func TestParseAndValidateEventsParams(t *testing.T) { 77 | testCases := []struct { 78 | name string 79 | args map[string]any 80 | expectedErr bool 81 | validate func(*testing.T, *ListEventsInput) 82 | }{ 83 | { 84 | name: "ValidParams", 85 | args: map[string]any{ 86 | "namespace": "default", 87 | "object": "test-pod", 88 | "eventType": "Warning", 89 | "reason": "Failed", 90 | "since": "1h", 91 | "sinceTime": "2025-06-20T10:00:00Z", 92 | "limit": float64(50), 93 | "timeoutSeconds": float64(60), 94 | }, 95 | expectedErr: false, 96 | validate: func(t *testing.T, input *ListEventsInput) { 97 | assert.Equal(t, "default", input.Namespace) 98 | assert.Equal(t, "test-pod", input.Object) 99 | assert.Equal(t, "Warning", input.EventType) 100 | assert.Equal(t, "Failed", input.Reason) 101 | assert.Equal(t, "1h", input.Since) 102 | assert.Equal(t, "2025-06-20T10:00:00Z", input.SinceTime) 103 | assert.Equal(t, int64(50), input.Limit) 104 | assert.Equal(t, int64(60), input.TimeoutSeconds) 105 | }, 106 | }, 107 | { 108 | name: "MinimalParams", 109 | args: map[string]any{}, 110 | expectedErr: false, 111 | validate: func(t *testing.T, input *ListEventsInput) { 112 | assert.Empty(t, input.Namespace) 113 | assert.Empty(t, input.Object) 114 | assert.Empty(t, input.EventType) 115 | assert.Empty(t, input.Reason) 116 | assert.Empty(t, input.Since) 117 | assert.Empty(t, input.SinceTime) 118 | assert.Equal(t, int64(100), input.Limit) // Default value 119 | assert.Equal(t, int64(30), input.TimeoutSeconds) // Default value 120 | }, 121 | }, 122 | { 123 | name: "InvalidEventType", 124 | args: map[string]any{ 125 | "eventType": "Invalid", 126 | }, 127 | expectedErr: true, 128 | }, 129 | { 130 | name: "InvalidNamespace", 131 | args: map[string]any{ 132 | "namespace": "invalid-namespace-with-invalid-chars!", 133 | }, 134 | expectedErr: true, 135 | }, 136 | { 137 | name: "InvalidSinceDuration", 138 | args: map[string]any{ 139 | "since": "invalid-duration", 140 | }, 141 | expectedErr: true, 142 | }, 143 | { 144 | name: "InvalidSinceTime", 145 | args: map[string]any{ 146 | "sinceTime": "invalid-time-format", 147 | }, 148 | expectedErr: true, 149 | }, 150 | { 151 | name: "ValidEventTypeNormal", 152 | args: map[string]any{ 153 | "eventType": "normal", 154 | }, 155 | expectedErr: false, 156 | validate: func(t *testing.T, input *ListEventsInput) { 157 | assert.Equal(t, "normal", input.EventType) 158 | }, 159 | }, 160 | { 161 | name: "ValidEventTypeWarning", 162 | args: map[string]any{ 163 | "eventType": "WARNING", 164 | }, 165 | expectedErr: false, 166 | validate: func(t *testing.T, input *ListEventsInput) { 167 | assert.Equal(t, "WARNING", input.EventType) 168 | }, 169 | }, 170 | } 171 | 172 | for _, tc := range testCases { 173 | t.Run(tc.name, func(t *testing.T) { 174 | client := &FakeEventsClient{} 175 | multiClient := NewFakeMultiClusterClient(client) 176 | tool := NewListEventsTool(multiClient) 177 | result, err := tool.parseAndValidateEventsParams(tc.args) 178 | 179 | if tc.expectedErr { 180 | assert.Error(t, err) 181 | assert.Nil(t, result) 182 | } else { 183 | assert.NoError(t, err) 184 | assert.NotNil(t, result) 185 | if tc.validate != nil { 186 | tc.validate(t, result) 187 | } 188 | } 189 | }) 190 | } 191 | } 192 | 193 | func TestListEventsTool_buildListOptions(t *testing.T) { 194 | client := &FakeEventsClient{} 195 | multiClient := NewFakeMultiClusterClient(client) 196 | tool := NewListEventsTool(multiClient) 197 | 198 | testCases := []struct { 199 | name string 200 | input *ListEventsInput 201 | validate func(*testing.T, metav1.ListOptions) 202 | }{ 203 | { 204 | name: "WithObject", 205 | input: &ListEventsInput{ 206 | Object: "test-pod", 207 | Limit: 50, 208 | TimeoutSeconds: 60, 209 | }, 210 | validate: func(t *testing.T, opts metav1.ListOptions) { 211 | assert.Equal(t, "involvedObject.name=test-pod", opts.FieldSelector) 212 | assert.Equal(t, int64(50), opts.Limit) 213 | assert.Equal(t, int64(60), *opts.TimeoutSeconds) 214 | }, 215 | }, 216 | { 217 | name: "DefaultValues", 218 | input: &ListEventsInput{}, 219 | validate: func(t *testing.T, opts metav1.ListOptions) { 220 | assert.Empty(t, opts.FieldSelector) 221 | assert.Equal(t, int64(0), opts.Limit) // No limit since input defaults weren't set 222 | assert.Equal(t, int64(0), *opts.TimeoutSeconds) // No timeout since input defaults weren't set 223 | }, 224 | }, 225 | { 226 | name: "ZeroLimit", 227 | input: &ListEventsInput{ 228 | Limit: 0, 229 | }, 230 | validate: func(t *testing.T, opts metav1.ListOptions) { 231 | assert.Equal(t, int64(0), opts.Limit) // No default applied in buildListOptions 232 | }, 233 | }, 234 | } 235 | 236 | for _, tc := range testCases { 237 | t.Run(tc.name, func(t *testing.T) { 238 | opts := tool.buildListOptions(tc.input) 239 | tc.validate(t, opts) 240 | }) 241 | } 242 | } 243 | 244 | func TestListEventsTool_filterEvents(t *testing.T) { 245 | client := &FakeEventsClient{} 246 | multiClient := NewFakeMultiClusterClient(client) 247 | tool := NewListEventsTool(multiClient) 248 | 249 | // Create test events 250 | now := time.Now() 251 | oneHourAgo := now.Add(-1 * time.Hour) 252 | twoHoursAgo := now.Add(-2 * time.Hour) 253 | 254 | events := []corev1.Event{ 255 | { 256 | Type: "Normal", 257 | Reason: "Pulled", 258 | LastTimestamp: metav1.Time{Time: oneHourAgo}, 259 | }, 260 | { 261 | Type: "Warning", 262 | Reason: "Failed", 263 | LastTimestamp: metav1.Time{Time: twoHoursAgo}, 264 | }, 265 | { 266 | Type: "Warning", 267 | Reason: "FailedScheduling", 268 | LastTimestamp: metav1.Time{Time: oneHourAgo}, 269 | }, 270 | } 271 | 272 | testCases := []struct { 273 | name string 274 | input *ListEventsInput 275 | expected int 276 | }{ 277 | { 278 | name: "NoFilter", 279 | input: &ListEventsInput{}, 280 | expected: 3, 281 | }, 282 | { 283 | name: "FilterByEventType", 284 | input: &ListEventsInput{ 285 | EventType: "Warning", 286 | }, 287 | expected: 2, 288 | }, 289 | { 290 | name: "FilterByReason", 291 | input: &ListEventsInput{ 292 | Reason: "Failed", 293 | }, 294 | expected: 2, // Both "Failed" and "FailedScheduling" should match 295 | }, 296 | { 297 | name: "FilterByExactReason", 298 | input: &ListEventsInput{ 299 | Reason: "Pulled", 300 | }, 301 | expected: 1, 302 | }, 303 | { 304 | name: "FilterBySince", 305 | input: &ListEventsInput{ 306 | Since: "90m", // 1.5 hours 307 | }, 308 | expected: 2, // Only events from 1 hour ago 309 | }, 310 | { 311 | name: "CombinedFilters", 312 | input: &ListEventsInput{ 313 | EventType: "Warning", 314 | Since: "90m", 315 | }, 316 | expected: 1, // Only Warning events from 1 hour ago 317 | }, 318 | } 319 | 320 | for _, tc := range testCases { 321 | t.Run(tc.name, func(t *testing.T) { 322 | filtered := tool.filterEvents(events, tc.input) 323 | assert.Len(t, filtered, tc.expected) 324 | }) 325 | } 326 | } 327 | 328 | func TestListEventsTool_isEventWithinTimeRange(t *testing.T) { 329 | client := &FakeEventsClient{} 330 | multiClient := NewFakeMultiClusterClient(client) 331 | tool := NewListEventsTool(multiClient) 332 | 333 | now := time.Now() 334 | event := &corev1.Event{ 335 | LastTimestamp: metav1.Time{Time: now.Add(-30 * time.Minute)}, 336 | } 337 | 338 | testCases := []struct { 339 | name string 340 | input *ListEventsInput 341 | expected bool 342 | }{ 343 | { 344 | name: "NoTimeFilter", 345 | input: &ListEventsInput{}, 346 | expected: true, 347 | }, 348 | { 349 | name: "WithinSinceRange", 350 | input: &ListEventsInput{ 351 | Since: "1h", 352 | }, 353 | expected: true, 354 | }, 355 | { 356 | name: "OutsideSinceRange", 357 | input: &ListEventsInput{ 358 | Since: "15m", 359 | }, 360 | expected: false, 361 | }, 362 | { 363 | name: "WithinSinceTimeRange", 364 | input: &ListEventsInput{ 365 | SinceTime: now.Add(-45 * time.Minute).Format(time.RFC3339), 366 | }, 367 | expected: true, 368 | }, 369 | { 370 | name: "OutsideSinceTimeRange", 371 | input: &ListEventsInput{ 372 | SinceTime: now.Add(-15 * time.Minute).Format(time.RFC3339), 373 | }, 374 | expected: false, 375 | }, 376 | { 377 | name: "InvalidSinceTime", 378 | input: &ListEventsInput{ 379 | SinceTime: "invalid-time", 380 | }, 381 | expected: true, // Should include all events if parsing fails 382 | }, 383 | } 384 | 385 | for _, tc := range testCases { 386 | t.Run(tc.name, func(t *testing.T) { 387 | result := tool.isEventWithinTimeRange(event, tc.input) 388 | assert.Equal(t, tc.expected, result) 389 | }) 390 | } 391 | } 392 | 393 | func TestListEventsTool_convertToEventInfos(t *testing.T) { 394 | client := &FakeEventsClient{} 395 | multiClient := NewFakeMultiClusterClient(client) 396 | tool := NewListEventsTool(multiClient) 397 | 398 | now := time.Now() 399 | events := []corev1.Event{ 400 | { 401 | ObjectMeta: metav1.ObjectMeta{ 402 | Namespace: "default", 403 | }, 404 | FirstTimestamp: metav1.Time{Time: now.Add(-1 * time.Hour)}, 405 | LastTimestamp: metav1.Time{Time: now.Add(-30 * time.Minute)}, 406 | Count: 3, 407 | Type: "Warning", 408 | Reason: "Failed", 409 | Message: "Container failed to start", 410 | InvolvedObject: corev1.ObjectReference{ 411 | Kind: "Pod", 412 | Name: "test-pod", 413 | Namespace: "default", 414 | }, 415 | Source: corev1.EventSource{ 416 | Component: "kubelet", 417 | Host: "node1", 418 | }, 419 | }, 420 | { 421 | ObjectMeta: metav1.ObjectMeta{ 422 | Namespace: "default", 423 | }, 424 | FirstTimestamp: metav1.Time{Time: now.Add(-2 * time.Hour)}, 425 | LastTimestamp: metav1.Time{Time: now.Add(-1 * time.Hour)}, 426 | Count: 1, 427 | Type: "Normal", 428 | Reason: "Pulled", 429 | Message: "Container image pulled", 430 | InvolvedObject: corev1.ObjectReference{ 431 | Kind: "Pod", 432 | Name: "test-pod", 433 | }, 434 | Source: corev1.EventSource{ 435 | Component: "kubelet", 436 | }, 437 | }, 438 | } 439 | 440 | eventInfos := tool.convertToEventInfos(events) 441 | 442 | assert.Len(t, eventInfos, 2) 443 | 444 | // Check first event 445 | assert.Equal(t, int32(3), eventInfos[0].Count) 446 | assert.Equal(t, "Warning", eventInfos[0].Type) 447 | assert.Equal(t, "Failed", eventInfos[0].Reason) 448 | assert.Equal(t, "Container failed to start", eventInfos[0].Message) 449 | assert.Equal(t, "default", eventInfos[0].Namespace) 450 | assert.Equal(t, "Pod/test-pod", eventInfos[0].Object) 451 | assert.Equal(t, "kubelet (node1)", eventInfos[0].Source) 452 | 453 | // Check second event 454 | assert.Equal(t, int32(1), eventInfos[1].Count) 455 | assert.Equal(t, "Normal", eventInfos[1].Type) 456 | assert.Equal(t, "Pulled", eventInfos[1].Reason) 457 | assert.Equal(t, "Container image pulled", eventInfos[1].Message) 458 | assert.Equal(t, "Pod/test-pod", eventInfos[1].Object) 459 | assert.Equal(t, "kubelet", eventInfos[1].Source) 460 | } 461 | 462 | func TestListEventsTool_Tool(t *testing.T) { 463 | client := &FakeEventsClient{} 464 | multiClient := NewFakeMultiClusterClient(client) 465 | tool := NewListEventsTool(multiClient) 466 | 467 | mcpTool := tool.Tool() 468 | 469 | assert.Equal(t, "list_events", mcpTool.Name) 470 | assert.Contains(t, mcpTool.Description, "List Kubernetes events") 471 | 472 | // Verify the tool has an input schema 473 | assert.NotNil(t, mcpTool.InputSchema) 474 | 475 | // Check that the tool schema has properties 476 | assert.NotNil(t, mcpTool.InputSchema.Properties) 477 | assert.True(t, len(mcpTool.InputSchema.Properties) > 0, "Tool should have input parameters") 478 | } 479 | 480 | func TestEventInfoJSONSerialization(t *testing.T) { 481 | now := time.Now() 482 | eventInfo := EventInfo{ 483 | FirstTimestamp: metav1.Time{Time: now.Add(-1 * time.Hour)}, 484 | LastTimestamp: metav1.Time{Time: now}, 485 | Count: 5, 486 | Type: "Warning", 487 | Reason: "Failed", 488 | Object: "Pod/test-pod", 489 | Message: "Container failed to start", 490 | Source: "kubelet (node1)", 491 | Namespace: "default", 492 | } 493 | 494 | // Test JSON serialization 495 | jsonData, err := json.Marshal(eventInfo) 496 | assert.NoError(t, err) 497 | assert.NotEmpty(t, jsonData) 498 | 499 | // Test JSON deserialization 500 | var deserializedEventInfo EventInfo 501 | err = json.Unmarshal(jsonData, &deserializedEventInfo) 502 | assert.NoError(t, err) 503 | assert.Equal(t, eventInfo.Count, deserializedEventInfo.Count) 504 | assert.Equal(t, eventInfo.Type, deserializedEventInfo.Type) 505 | assert.Equal(t, eventInfo.Reason, deserializedEventInfo.Reason) 506 | assert.Equal(t, eventInfo.Object, deserializedEventInfo.Object) 507 | assert.Equal(t, eventInfo.Message, deserializedEventInfo.Message) 508 | assert.Equal(t, eventInfo.Source, deserializedEventInfo.Source) 509 | assert.Equal(t, eventInfo.Namespace, deserializedEventInfo.Namespace) 510 | } -------------------------------------------------------------------------------- /src/tools/list.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/kkb0318/kubernetes-mcp/src/validation" 11 | "github.com/mark3labs/mcp-go/mcp" 12 | 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 15 | "k8s.io/apimachinery/pkg/runtime/schema" 16 | ) 17 | 18 | // ListResourcesInput represents the input parameters for listing Kubernetes resources. 19 | type ListResourcesInput struct { 20 | Context string `json:"context,omitempty"` 21 | Kind string `json:"kind"` 22 | GroupFilter string `json:"groupFilter,omitempty"` 23 | Namespace string `json:"namespace,omitempty"` 24 | LabelSelector string `json:"labelSelector,omitempty"` 25 | FieldSelector string `json:"fieldSelector,omitempty"` 26 | Limit int64 `json:"limit,omitempty"` 27 | TimeoutSeconds int64 `json:"timeoutSeconds,omitempty"` 28 | ShowDetails bool `json:"showDetails,omitempty"` 29 | } 30 | 31 | // ResourceWithStatus represents a resource with its status information extracted. 32 | type ResourceWithStatus struct { 33 | Name string `json:"name"` 34 | Namespace string `json:"namespace,omitempty"` 35 | Kind string `json:"kind"` 36 | Status any `json:"status,omitempty"` 37 | } 38 | 39 | // ListTool provides functionality to list Kubernetes resources by kind. 40 | type ListTool struct { 41 | multiClient MultiClusterClientInterface 42 | } 43 | 44 | // NewListTool creates a new ListTool instance with the provided MultiClusterClient. 45 | func NewListTool(multiClient MultiClusterClientInterface) ListTool { 46 | return ListTool{multiClient: multiClient} 47 | } 48 | 49 | // Tool returns the MCP tool definition for listing Kubernetes resources. 50 | func (l ListTool) Tool() mcp.Tool { 51 | return mcp.NewTool("list_resources", 52 | mcp.WithDescription("List Kubernetes resources with their status information by default, with advanced filtering options"), 53 | mcp.WithString("context", 54 | mcp.Description("Kubernetes context name from kubeconfig to use for this request (leave empty for current context)"), 55 | ), 56 | mcp.WithString("kind", 57 | mcp.Description("Kind of the Kubernetes resource, e.g., Pod, Deployment, Service, ConfigMap, or any CRD. Use 'all' with groupFilter to discover all resource types for a project."), 58 | ), 59 | mcp.WithString("groupFilter", 60 | mcp.Description("Filter by API group substring to discover all resources from a project (e.g., 'flux' for FluxCD, 'argo' for ArgoCD, 'istio' for Istio). When used with kind='all', returns all matching resource types."), 61 | ), 62 | mcp.WithString("namespace", 63 | mcp.Description("Kubernetes namespace to list resources from (leave empty for all namespaces, use 'default' for default namespace)"), 64 | ), 65 | mcp.WithString("labelSelector", 66 | mcp.Description("Filter resources by label selector (e.g., 'app=nginx', 'tier=frontend,environment!=prod')"), 67 | ), 68 | mcp.WithString("fieldSelector", 69 | mcp.Description("Filter resources by field selector (e.g., 'metadata.name=my-pod', 'spec.nodeName=node1')"), 70 | ), 71 | mcp.WithNumber("limit", 72 | mcp.Description("Maximum number of resources to return (useful for large clusters, default: no limit)"), 73 | ), 74 | mcp.WithNumber("timeoutSeconds", 75 | mcp.Description("Timeout for the list operation in seconds (default: 30)"), 76 | ), 77 | mcp.WithBoolean("showDetails", 78 | mcp.Description("Return complete resource objects instead of just name and status (default: false)"), 79 | ), 80 | ) 81 | } 82 | 83 | // Handler processes requests to list Kubernetes resources by kind and namespace. 84 | func (l ListTool) Handler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { 85 | input, err := parseAndValidateListParams(req.Params.Arguments) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | // Get the appropriate client for the context 91 | client, err := l.multiClient.GetClient(input.Context) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to get client for context '%s': %w", input.Context, err) 94 | } 95 | 96 | // Handle groupFilter functionality for discovering resources 97 | if input.GroupFilter != "" { 98 | if input.Kind == "all" || input.Kind == "" { 99 | // Discovery mode: return all resource types for the group 100 | return l.handleGroupDiscovery(client, input.GroupFilter) 101 | } else { 102 | // Filter mode: find specific kind within the group 103 | return l.handleGroupFilteredList(ctx, client, input) 104 | } 105 | } 106 | 107 | // Original functionality for specific kind 108 | gvrMatch, err := l.discoverResourceByKind(client, input.Kind) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | if input.ShowDetails { 114 | // Return full resource details (complete objects) 115 | resources, err := l.listResourceDetails(ctx, client, gvrMatch, input) 116 | if err != nil { 117 | return nil, err 118 | } 119 | out, err := json.Marshal(resources) 120 | if err != nil { 121 | return nil, fmt.Errorf("failed to marshal resource details: %w", err) 122 | } 123 | return mcp.NewToolResultText(string(out)), nil 124 | } else { 125 | // Default: Return resources with status information 126 | resourcesWithStatus, err := l.listResourcesWithStatus(ctx, client, gvrMatch, input) 127 | if err != nil { 128 | return nil, err 129 | } 130 | out, err := json.Marshal(resourcesWithStatus) 131 | if err != nil { 132 | return nil, fmt.Errorf("failed to marshal resources with status: %w", err) 133 | } 134 | return mcp.NewToolResultText(string(out)), nil 135 | } 136 | } 137 | 138 | // handleGroupDiscovery returns all available resource types for a given group filter 139 | func (l ListTool) handleGroupDiscovery(client Client, groupFilter string) (*mcp.CallToolResult, error) { 140 | discoClient, err := client.DiscoClient() 141 | if err != nil { 142 | return nil, fmt.Errorf("failed to create discovery client: %w", err) 143 | } 144 | 145 | apiResourceLists, err := discoClient.ServerPreferredResources() 146 | if err != nil { 147 | return nil, fmt.Errorf("failed to discover resources: %w", err) 148 | } 149 | 150 | matches, err := findGVRsByGroupSubstring(apiResourceLists, groupFilter) 151 | if err != nil { 152 | return nil, fmt.Errorf("failed to find resources by group substring: %w", err) 153 | } 154 | 155 | if len(matches) == 0 { 156 | return mcp.NewToolResultText(fmt.Sprintf(`{"message": "No resources found for group filter '%s'", "availableResources": []}`, groupFilter)), nil 157 | } 158 | 159 | // Format the discovered resource types 160 | discoveredTypes := make([]map[string]any, 0) 161 | for _, match := range matches { 162 | discoveredTypes = append(discoveredTypes, map[string]any{ 163 | "kind": match.apiRes.Kind, 164 | "group": match.groupVersion, 165 | "resource": match.apiRes.Name, 166 | "namespaced": match.namespaced, 167 | "shortNames": match.apiRes.ShortNames, 168 | }) 169 | } 170 | 171 | result := map[string]any{ 172 | "groupFilter": groupFilter, 173 | "discoveredTypes": discoveredTypes, 174 | "totalFound": len(matches), 175 | "message": fmt.Sprintf("Found %d resource types matching group filter '%s'", len(matches), groupFilter), 176 | } 177 | 178 | out, err := json.Marshal(result) 179 | if err != nil { 180 | return nil, fmt.Errorf("failed to marshal discovery result: %w", err) 181 | } 182 | return mcp.NewToolResultText(string(out)), nil 183 | } 184 | 185 | // handleGroupFilteredList lists resources of a specific kind within a filtered group 186 | func (l ListTool) handleGroupFilteredList(ctx context.Context, client Client, input *ListResourcesInput) (*mcp.CallToolResult, error) { 187 | discoClient, err := client.DiscoClient() 188 | if err != nil { 189 | return nil, fmt.Errorf("failed to create discovery client: %w", err) 190 | } 191 | 192 | apiResourceLists, err := discoClient.ServerPreferredResources() 193 | if err != nil { 194 | return nil, fmt.Errorf("failed to discover resources: %w", err) 195 | } 196 | 197 | // First find all resources in the group 198 | matches, err := findGVRsByGroupSubstring(apiResourceLists, input.GroupFilter) 199 | if err != nil { 200 | return nil, fmt.Errorf("failed to find resources by group substring: %w", err) 201 | } 202 | 203 | // Find the specific kind within the group 204 | var gvrMatch *gvrMatch 205 | kindLower := strings.ToLower(input.Kind) 206 | for _, match := range matches { 207 | if strings.ToLower(match.apiRes.Kind) == kindLower || strings.ToLower(match.apiRes.Name) == kindLower { 208 | gvrMatch = match 209 | break 210 | } 211 | // Check short names too 212 | for _, shortName := range match.apiRes.ShortNames { 213 | if strings.ToLower(shortName) == kindLower { 214 | gvrMatch = match 215 | break 216 | } 217 | } 218 | if gvrMatch != nil { 219 | break 220 | } 221 | } 222 | 223 | if gvrMatch == nil { 224 | return nil, fmt.Errorf("kind '%s' not found in group filter '%s'", input.Kind, input.GroupFilter) 225 | } 226 | 227 | // Now list the resources using the found GVR 228 | if input.ShowDetails { 229 | resources, err := l.listResourceDetails(ctx, client, gvrMatch, input) 230 | if err != nil { 231 | return nil, err 232 | } 233 | out, err := json.Marshal(resources) 234 | if err != nil { 235 | return nil, fmt.Errorf("failed to marshal resource details: %w", err) 236 | } 237 | return mcp.NewToolResultText(string(out)), nil 238 | } else { 239 | resourcesWithStatus, err := l.listResourcesWithStatus(ctx, client, gvrMatch, input) 240 | if err != nil { 241 | return nil, err 242 | } 243 | out, err := json.Marshal(resourcesWithStatus) 244 | if err != nil { 245 | return nil, fmt.Errorf("failed to marshal resources with status: %w", err) 246 | } 247 | return mcp.NewToolResultText(string(out)), nil 248 | } 249 | } 250 | 251 | // discoverResourceByKind discovers and returns the GroupVersionResource match for a given kind. 252 | func (l ListTool) discoverResourceByKind(client Client, kind string) (*gvrMatch, error) { 253 | discoClient, err := client.DiscoClient() 254 | if err != nil { 255 | return nil, fmt.Errorf("failed to create discovery client: %w", err) 256 | } 257 | 258 | apiResourceLists, err := discoClient.ServerPreferredResources() 259 | if err != nil { 260 | return nil, fmt.Errorf("failed to discover resources: %w", err) 261 | } 262 | 263 | return findGVRByKind(apiResourceLists, kind) 264 | } 265 | 266 | // listResourceDetails retrieves full details of all resources matching the given GVR and input parameters. 267 | func (l ListTool) listResourceDetails(ctx context.Context, client Client, gvrMatch *gvrMatch, input *ListResourcesInput) (any, error) { 268 | ri, err := client.ResourceInterface(*gvrMatch.ToGroupVersionResource(), gvrMatch.namespaced, input.Namespace) 269 | if err != nil { 270 | return nil, fmt.Errorf("failed to create resource interface: %w", err) 271 | } 272 | 273 | listOptions := l.buildListOptions(input) 274 | unstructList, err := ri.List(ctx, listOptions) 275 | if err != nil { 276 | return nil, fmt.Errorf("failed to list resources: %w", err) 277 | } 278 | 279 | return unstructList, nil 280 | } 281 | 282 | // buildListOptions creates metav1.ListOptions from the input parameters. 283 | func (l ListTool) buildListOptions(input *ListResourcesInput) metav1.ListOptions { 284 | listOptions := metav1.ListOptions{ 285 | LabelSelector: input.LabelSelector, 286 | FieldSelector: input.FieldSelector, 287 | } 288 | 289 | if input.Limit > 0 { 290 | listOptions.Limit = input.Limit 291 | } 292 | 293 | listOptions.TimeoutSeconds = &input.TimeoutSeconds 294 | 295 | return listOptions 296 | } 297 | 298 | // listResourcesWithStatus retrieves resources and extracts their status information. 299 | func (l ListTool) listResourcesWithStatus(ctx context.Context, client Client, gvrMatch *gvrMatch, input *ListResourcesInput) ([]ResourceWithStatus, error) { 300 | ri, err := client.ResourceInterface(*gvrMatch.ToGroupVersionResource(), gvrMatch.namespaced, input.Namespace) 301 | if err != nil { 302 | return nil, fmt.Errorf("failed to create resource interface: %w", err) 303 | } 304 | 305 | listOptions := l.buildListOptions(input) 306 | unstructList, err := ri.List(ctx, listOptions) 307 | if err != nil { 308 | return nil, fmt.Errorf("failed to list resources: %w", err) 309 | } 310 | 311 | var resourcesWithStatus []ResourceWithStatus 312 | for _, item := range unstructList.Items { 313 | resourceWithStatus := l.extractResourceStatus(&item) 314 | resourcesWithStatus = append(resourcesWithStatus, resourceWithStatus) 315 | } 316 | 317 | return resourcesWithStatus, nil 318 | } 319 | 320 | // extractResourceStatus extracts the status section from a resource. 321 | func (l ListTool) extractResourceStatus(obj *unstructured.Unstructured) ResourceWithStatus { 322 | resource := ResourceWithStatus{ 323 | Name: obj.GetName(), 324 | Namespace: obj.GetNamespace(), 325 | Kind: obj.GetKind(), 326 | } 327 | 328 | // Extract the entire status section 329 | if status, found, err := unstructured.NestedMap(obj.Object, "status"); found && err == nil { 330 | resource.Status = status 331 | } 332 | 333 | return resource 334 | } 335 | 336 | // parseAndValidateListParams validates and extracts parameters from request arguments. 337 | func parseAndValidateListParams(args map[string]any) (*ListResourcesInput, error) { 338 | input := &ListResourcesInput{} 339 | 340 | // Optional: context 341 | if context, ok := args["context"].(string); ok { 342 | input.Context = context 343 | } 344 | 345 | // Optional: groupFilter 346 | if groupFilter, ok := args["groupFilter"].(string); ok { 347 | input.GroupFilter = groupFilter 348 | } 349 | 350 | // Kind: Required unless groupFilter is used for discovery 351 | if kindVal, ok := args["kind"].(string); ok && kindVal != "" { 352 | input.Kind = kindVal 353 | if err := validation.ValidateKind(input.Kind); err != nil { 354 | return nil, fmt.Errorf("invalid kind: %w", err) 355 | } 356 | } else if input.GroupFilter == "" { 357 | return nil, errors.New("kind must be provided when groupFilter is not specified") 358 | } 359 | 360 | // Optional: namespace 361 | if ns, ok := args["namespace"].(string); ok { 362 | input.Namespace = ns 363 | if err := validation.ValidateNamespace(input.Namespace); err != nil { 364 | return nil, fmt.Errorf("invalid namespace: %w", err) 365 | } 366 | } 367 | if input.Namespace == "" { 368 | input.Namespace = metav1.NamespaceAll 369 | } 370 | 371 | // Optional: labelSelector 372 | if labelSelector, ok := args["labelSelector"].(string); ok { 373 | input.LabelSelector = labelSelector 374 | if err := validation.ValidateLabelSelector(input.LabelSelector); err != nil { 375 | return nil, fmt.Errorf("invalid labelSelector: %w", err) 376 | } 377 | } 378 | 379 | // Optional: fieldSelector 380 | if fieldSelector, ok := args["fieldSelector"].(string); ok { 381 | input.FieldSelector = fieldSelector 382 | } 383 | 384 | // Optional: limit 385 | if limit, ok := args["limit"].(float64); ok && limit > 0 { 386 | input.Limit = int64(limit) 387 | } 388 | 389 | // Optional: timeoutSeconds 390 | if timeoutSeconds, ok := args["timeoutSeconds"].(float64); ok && timeoutSeconds > 0 { 391 | input.TimeoutSeconds = int64(timeoutSeconds) 392 | } else { 393 | // Default timeout of 30 seconds 394 | input.TimeoutSeconds = 30 395 | } 396 | 397 | // Optional: showDetails 398 | if showDetails, ok := args["showDetails"].(bool); ok { 399 | input.ShowDetails = showDetails 400 | } 401 | 402 | return input, nil 403 | } 404 | 405 | // gvrMatchList is a collection of GroupVersionResource matches. 406 | type gvrMatchList []*gvrMatch 407 | 408 | // ToGroupVersionResources converts the match list to a slice of GroupVersionResource pointers. 409 | func (f *gvrMatchList) ToGroupVersionResources() []*schema.GroupVersionResource { 410 | var gvrList []*schema.GroupVersionResource 411 | for _, found := range *f { 412 | gvr := found.ToGroupVersionResource() 413 | if gvr == nil { 414 | continue 415 | } 416 | gvrList = append(gvrList, gvr) 417 | } 418 | return gvrList 419 | } 420 | 421 | // gvrMatch represents a matched Kubernetes API resource with its group/version and namespacing info. 422 | type gvrMatch struct { 423 | apiRes *metav1.APIResource 424 | groupVersion string 425 | namespaced bool 426 | } 427 | 428 | // newGvrMatch creates a new gvrMatch instance. 429 | func newGvrMatch(apiRes *metav1.APIResource, groupVersion string, namespaced bool) *gvrMatch { 430 | return &gvrMatch{ 431 | apiRes, 432 | groupVersion, 433 | namespaced, 434 | } 435 | } 436 | 437 | // ToGroupVersionResource converts the match to a GroupVersionResource. Returns nil if invalid. 438 | func (f *gvrMatch) ToGroupVersionResource() *schema.GroupVersionResource { 439 | if f.groupVersion == "" { 440 | return nil 441 | } 442 | if f.apiRes == nil { 443 | return nil 444 | } 445 | parts := strings.Split(f.groupVersion, "/") 446 | var group, version string 447 | if len(parts) == 1 { 448 | group = "" 449 | version = parts[0] 450 | } else { 451 | group = parts[0] 452 | version = parts[1] 453 | } 454 | gvr := &schema.GroupVersionResource{ 455 | Group: group, 456 | Version: version, 457 | Resource: f.apiRes.Name, 458 | } 459 | return gvr 460 | } 461 | 462 | // findGVRsByGroupSubstring finds all resources whose group contains the specified substring (case-insensitive). 463 | func findGVRsByGroupSubstring(apiResourceLists []*metav1.APIResourceList, groupSubstring string) (gvrMatchList, error) { 464 | target := strings.ToLower(groupSubstring) 465 | var matches gvrMatchList 466 | for _, apiResList := range apiResourceLists { 467 | if apiResList == nil { 468 | continue 469 | } 470 | gv := apiResList.GroupVersion 471 | if !strings.Contains(gv, target) { 472 | continue 473 | } 474 | for _, r := range apiResList.APIResources { 475 | matches = append(matches, newGvrMatch(&r, gv, r.Namespaced)) 476 | } 477 | } 478 | 479 | return matches, nil 480 | } 481 | 482 | // findGVRByKind finds a resource by matching against plural name, Kind, or short names (case-insensitive). 483 | func findGVRByKind(apiResourceLists []*metav1.APIResourceList, kind string) (*gvrMatch, error) { 484 | target := strings.ToLower(kind) 485 | var found *gvrMatch 486 | 487 | for _, apiResList := range apiResourceLists { 488 | if apiResList == nil { 489 | continue 490 | } 491 | gv := apiResList.GroupVersion 492 | 493 | for _, r := range apiResList.APIResources { 494 | nameLower := strings.ToLower(r.Name) 495 | kindLower := strings.ToLower(r.Kind) 496 | 497 | if nameLower == target || kindLower == target { 498 | found = newGvrMatch(&r, gv, r.Namespaced) 499 | break 500 | } 501 | 502 | for _, sn := range r.ShortNames { 503 | if strings.ToLower(sn) == target { 504 | found = newGvrMatch(&r, gv, r.Namespaced) 505 | break 506 | } 507 | } 508 | if found != nil { 509 | break 510 | } 511 | } 512 | 513 | if found != nil { 514 | break 515 | } 516 | } 517 | 518 | if found == nil { 519 | return nil, fmt.Errorf("cannot find resource '%s'", kind) 520 | } 521 | if found.ToGroupVersionResource() == nil { 522 | return nil, fmt.Errorf("cannot find resource '%s'", kind) 523 | } 524 | return found, nil 525 | } 526 | -------------------------------------------------------------------------------- /src/tools/testdata/apiresources.yaml: -------------------------------------------------------------------------------- 1 | - groupVersion: v1 2 | resources: 3 | - kind: ConfigMap 4 | name: configmaps 5 | namespaced: true 6 | shortNames: 7 | - cm 8 | singularName: configmap 9 | verbs: 10 | - create 11 | - delete 12 | - deletecollection 13 | - get 14 | - list 15 | - patch 16 | - update 17 | - watch 18 | - kind: Endpoints 19 | name: endpoints 20 | namespaced: true 21 | shortNames: 22 | - ep 23 | singularName: endpoints 24 | verbs: 25 | - create 26 | - delete 27 | - deletecollection 28 | - get 29 | - list 30 | - patch 31 | - update 32 | - watch 33 | - categories: 34 | - all 35 | kind: Service 36 | name: services 37 | namespaced: true 38 | shortNames: 39 | - svc 40 | singularName: service 41 | verbs: 42 | - create 43 | - delete 44 | - deletecollection 45 | - get 46 | - list 47 | - patch 48 | - update 49 | - watch 50 | - kind: PersistentVolumeClaim 51 | name: persistentvolumeclaims 52 | namespaced: true 53 | shortNames: 54 | - pvc 55 | singularName: persistentvolumeclaim 56 | verbs: 57 | - create 58 | - delete 59 | - deletecollection 60 | - get 61 | - list 62 | - patch 63 | - update 64 | - watch 65 | - categories: 66 | - all 67 | kind: Pod 68 | name: pods 69 | namespaced: true 70 | shortNames: 71 | - po 72 | singularName: pod 73 | verbs: 74 | - create 75 | - delete 76 | - deletecollection 77 | - get 78 | - list 79 | - patch 80 | - update 81 | - watch 82 | - kind: PersistentVolume 83 | name: persistentvolumes 84 | namespaced: false 85 | shortNames: 86 | - pv 87 | singularName: persistentvolume 88 | verbs: 89 | - create 90 | - delete 91 | - deletecollection 92 | - get 93 | - list 94 | - patch 95 | - update 96 | - watch 97 | - categories: 98 | - all 99 | kind: ReplicationController 100 | name: replicationcontrollers 101 | namespaced: true 102 | shortNames: 103 | - rc 104 | singularName: replicationcontroller 105 | verbs: 106 | - create 107 | - delete 108 | - deletecollection 109 | - get 110 | - list 111 | - patch 112 | - update 113 | - watch 114 | - kind: LimitRange 115 | name: limitranges 116 | namespaced: true 117 | shortNames: 118 | - limits 119 | singularName: limitrange 120 | verbs: 121 | - create 122 | - delete 123 | - deletecollection 124 | - get 125 | - list 126 | - patch 127 | - update 128 | - watch 129 | - kind: ResourceQuota 130 | name: resourcequotas 131 | namespaced: true 132 | shortNames: 133 | - quota 134 | singularName: resourcequota 135 | verbs: 136 | - create 137 | - delete 138 | - deletecollection 139 | - get 140 | - list 141 | - patch 142 | - update 143 | - watch 144 | - kind: Binding 145 | name: bindings 146 | namespaced: true 147 | singularName: binding 148 | verbs: 149 | - create 150 | - kind: Namespace 151 | name: namespaces 152 | namespaced: false 153 | shortNames: 154 | - ns 155 | singularName: namespace 156 | verbs: 157 | - create 158 | - delete 159 | - get 160 | - list 161 | - patch 162 | - update 163 | - watch 164 | - kind: Node 165 | name: nodes 166 | namespaced: false 167 | shortNames: 168 | - "no" 169 | singularName: node 170 | verbs: 171 | - create 172 | - delete 173 | - deletecollection 174 | - get 175 | - list 176 | - patch 177 | - update 178 | - watch 179 | - kind: Event 180 | name: events 181 | namespaced: true 182 | shortNames: 183 | - ev 184 | singularName: event 185 | verbs: 186 | - create 187 | - delete 188 | - deletecollection 189 | - get 190 | - list 191 | - patch 192 | - update 193 | - watch 194 | - kind: PodTemplate 195 | name: podtemplates 196 | namespaced: true 197 | singularName: podtemplate 198 | verbs: 199 | - create 200 | - delete 201 | - deletecollection 202 | - get 203 | - list 204 | - patch 205 | - update 206 | - watch 207 | - kind: ComponentStatus 208 | name: componentstatuses 209 | namespaced: false 210 | shortNames: 211 | - cs 212 | singularName: componentstatus 213 | verbs: 214 | - get 215 | - list 216 | - kind: Secret 217 | name: secrets 218 | namespaced: true 219 | singularName: secret 220 | verbs: 221 | - create 222 | - delete 223 | - deletecollection 224 | - get 225 | - list 226 | - patch 227 | - update 228 | - watch 229 | - kind: ServiceAccount 230 | name: serviceaccounts 231 | namespaced: true 232 | shortNames: 233 | - sa 234 | singularName: serviceaccount 235 | verbs: 236 | - create 237 | - delete 238 | - deletecollection 239 | - get 240 | - list 241 | - patch 242 | - update 243 | - watch 244 | - groupVersion: apiregistration.k8s.io/v1 245 | resources: 246 | - categories: 247 | - api-extensions 248 | kind: APIService 249 | name: apiservices 250 | namespaced: false 251 | singularName: apiservice 252 | verbs: 253 | - create 254 | - delete 255 | - deletecollection 256 | - get 257 | - list 258 | - patch 259 | - update 260 | - watch 261 | - groupVersion: apps/v1 262 | resources: 263 | - categories: 264 | - all 265 | kind: ReplicaSet 266 | name: replicasets 267 | namespaced: true 268 | shortNames: 269 | - rs 270 | singularName: replicaset 271 | verbs: 272 | - create 273 | - delete 274 | - deletecollection 275 | - get 276 | - list 277 | - patch 278 | - update 279 | - watch 280 | - kind: ControllerRevision 281 | name: controllerrevisions 282 | namespaced: true 283 | singularName: controllerrevision 284 | verbs: 285 | - create 286 | - delete 287 | - deletecollection 288 | - get 289 | - list 290 | - patch 291 | - update 292 | - watch 293 | - categories: 294 | - all 295 | kind: Deployment 296 | name: deployments 297 | namespaced: true 298 | shortNames: 299 | - deploy 300 | singularName: deployment 301 | verbs: 302 | - create 303 | - delete 304 | - deletecollection 305 | - get 306 | - list 307 | - patch 308 | - update 309 | - watch 310 | - categories: 311 | - all 312 | kind: StatefulSet 313 | name: statefulsets 314 | namespaced: true 315 | shortNames: 316 | - sts 317 | singularName: statefulset 318 | verbs: 319 | - create 320 | - delete 321 | - deletecollection 322 | - get 323 | - list 324 | - patch 325 | - update 326 | - watch 327 | - categories: 328 | - all 329 | kind: DaemonSet 330 | name: daemonsets 331 | namespaced: true 332 | shortNames: 333 | - ds 334 | singularName: daemonset 335 | verbs: 336 | - create 337 | - delete 338 | - deletecollection 339 | - get 340 | - list 341 | - patch 342 | - update 343 | - watch 344 | - groupVersion: events.k8s.io/v1 345 | resources: 346 | - kind: Event 347 | name: events 348 | namespaced: true 349 | shortNames: 350 | - ev 351 | singularName: event 352 | verbs: 353 | - create 354 | - delete 355 | - deletecollection 356 | - get 357 | - list 358 | - patch 359 | - update 360 | - watch 361 | - groupVersion: authentication.k8s.io/v1 362 | resources: 363 | - kind: SelfSubjectReview 364 | name: selfsubjectreviews 365 | namespaced: false 366 | singularName: selfsubjectreview 367 | verbs: 368 | - create 369 | - kind: TokenReview 370 | name: tokenreviews 371 | namespaced: false 372 | singularName: tokenreview 373 | verbs: 374 | - create 375 | - groupVersion: authorization.k8s.io/v1 376 | resources: 377 | - kind: SelfSubjectRulesReview 378 | name: selfsubjectrulesreviews 379 | namespaced: false 380 | singularName: selfsubjectrulesreview 381 | verbs: 382 | - create 383 | - kind: SubjectAccessReview 384 | name: subjectaccessreviews 385 | namespaced: false 386 | singularName: subjectaccessreview 387 | verbs: 388 | - create 389 | - kind: SelfSubjectAccessReview 390 | name: selfsubjectaccessreviews 391 | namespaced: false 392 | singularName: selfsubjectaccessreview 393 | verbs: 394 | - create 395 | - kind: LocalSubjectAccessReview 396 | name: localsubjectaccessreviews 397 | namespaced: true 398 | singularName: localsubjectaccessreview 399 | verbs: 400 | - create 401 | - groupVersion: autoscaling/v2 402 | resources: 403 | - categories: 404 | - all 405 | kind: HorizontalPodAutoscaler 406 | name: horizontalpodautoscalers 407 | namespaced: true 408 | shortNames: 409 | - hpa 410 | singularName: horizontalpodautoscaler 411 | verbs: 412 | - create 413 | - delete 414 | - deletecollection 415 | - get 416 | - list 417 | - patch 418 | - update 419 | - watch 420 | - groupVersion: autoscaling/v1 421 | resources: null 422 | - groupVersion: batch/v1 423 | resources: 424 | - categories: 425 | - all 426 | kind: Job 427 | name: jobs 428 | namespaced: true 429 | singularName: job 430 | verbs: 431 | - create 432 | - delete 433 | - deletecollection 434 | - get 435 | - list 436 | - patch 437 | - update 438 | - watch 439 | - categories: 440 | - all 441 | kind: CronJob 442 | name: cronjobs 443 | namespaced: true 444 | shortNames: 445 | - cj 446 | singularName: cronjob 447 | verbs: 448 | - create 449 | - delete 450 | - deletecollection 451 | - get 452 | - list 453 | - patch 454 | - update 455 | - watch 456 | - groupVersion: certificates.k8s.io/v1 457 | resources: 458 | - kind: CertificateSigningRequest 459 | name: certificatesigningrequests 460 | namespaced: false 461 | shortNames: 462 | - csr 463 | singularName: certificatesigningrequest 464 | verbs: 465 | - create 466 | - delete 467 | - deletecollection 468 | - get 469 | - list 470 | - patch 471 | - update 472 | - watch 473 | - groupVersion: networking.k8s.io/v1 474 | resources: 475 | - kind: Ingress 476 | name: ingresses 477 | namespaced: true 478 | shortNames: 479 | - ing 480 | singularName: ingress 481 | verbs: 482 | - create 483 | - delete 484 | - deletecollection 485 | - get 486 | - list 487 | - patch 488 | - update 489 | - watch 490 | - kind: ServiceCIDR 491 | name: servicecidrs 492 | namespaced: false 493 | singularName: servicecidr 494 | verbs: 495 | - create 496 | - delete 497 | - deletecollection 498 | - get 499 | - list 500 | - patch 501 | - update 502 | - watch 503 | - kind: IngressClass 504 | name: ingressclasses 505 | namespaced: false 506 | singularName: ingressclass 507 | verbs: 508 | - create 509 | - delete 510 | - deletecollection 511 | - get 512 | - list 513 | - patch 514 | - update 515 | - watch 516 | - kind: NetworkPolicy 517 | name: networkpolicies 518 | namespaced: true 519 | shortNames: 520 | - netpol 521 | singularName: networkpolicy 522 | verbs: 523 | - create 524 | - delete 525 | - deletecollection 526 | - get 527 | - list 528 | - patch 529 | - update 530 | - watch 531 | - kind: IPAddress 532 | name: ipaddresses 533 | namespaced: false 534 | shortNames: 535 | - ip 536 | singularName: ipaddress 537 | verbs: 538 | - create 539 | - delete 540 | - deletecollection 541 | - get 542 | - list 543 | - patch 544 | - update 545 | - watch 546 | - groupVersion: policy/v1 547 | resources: 548 | - kind: PodDisruptionBudget 549 | name: poddisruptionbudgets 550 | namespaced: true 551 | shortNames: 552 | - pdb 553 | singularName: poddisruptionbudget 554 | verbs: 555 | - create 556 | - delete 557 | - deletecollection 558 | - get 559 | - list 560 | - patch 561 | - update 562 | - watch 563 | - groupVersion: rbac.authorization.k8s.io/v1 564 | resources: 565 | - kind: RoleBinding 566 | name: rolebindings 567 | namespaced: true 568 | singularName: rolebinding 569 | verbs: 570 | - create 571 | - delete 572 | - deletecollection 573 | - get 574 | - list 575 | - patch 576 | - update 577 | - watch 578 | - kind: Role 579 | name: roles 580 | namespaced: true 581 | singularName: role 582 | verbs: 583 | - create 584 | - delete 585 | - deletecollection 586 | - get 587 | - list 588 | - patch 589 | - update 590 | - watch 591 | - kind: ClusterRole 592 | name: clusterroles 593 | namespaced: false 594 | singularName: clusterrole 595 | verbs: 596 | - create 597 | - delete 598 | - deletecollection 599 | - get 600 | - list 601 | - patch 602 | - update 603 | - watch 604 | - kind: ClusterRoleBinding 605 | name: clusterrolebindings 606 | namespaced: false 607 | singularName: clusterrolebinding 608 | verbs: 609 | - create 610 | - delete 611 | - deletecollection 612 | - get 613 | - list 614 | - patch 615 | - update 616 | - watch 617 | - groupVersion: storage.k8s.io/v1 618 | resources: 619 | - kind: StorageClass 620 | name: storageclasses 621 | namespaced: false 622 | shortNames: 623 | - sc 624 | singularName: storageclass 625 | verbs: 626 | - create 627 | - delete 628 | - deletecollection 629 | - get 630 | - list 631 | - patch 632 | - update 633 | - watch 634 | - kind: CSIDriver 635 | name: csidrivers 636 | namespaced: false 637 | singularName: csidriver 638 | verbs: 639 | - create 640 | - delete 641 | - deletecollection 642 | - get 643 | - list 644 | - patch 645 | - update 646 | - watch 647 | - kind: VolumeAttachment 648 | name: volumeattachments 649 | namespaced: false 650 | singularName: volumeattachment 651 | verbs: 652 | - create 653 | - delete 654 | - deletecollection 655 | - get 656 | - list 657 | - patch 658 | - update 659 | - watch 660 | - kind: CSINode 661 | name: csinodes 662 | namespaced: false 663 | singularName: csinode 664 | verbs: 665 | - create 666 | - delete 667 | - deletecollection 668 | - get 669 | - list 670 | - patch 671 | - update 672 | - watch 673 | - kind: CSIStorageCapacity 674 | name: csistoragecapacities 675 | namespaced: true 676 | singularName: csistoragecapacity 677 | verbs: 678 | - create 679 | - delete 680 | - deletecollection 681 | - get 682 | - list 683 | - patch 684 | - update 685 | - watch 686 | - groupVersion: admissionregistration.k8s.io/v1 687 | resources: 688 | - categories: 689 | - api-extensions 690 | kind: ValidatingAdmissionPolicy 691 | name: validatingadmissionpolicies 692 | namespaced: false 693 | singularName: validatingadmissionpolicy 694 | verbs: 695 | - create 696 | - delete 697 | - deletecollection 698 | - get 699 | - list 700 | - patch 701 | - update 702 | - watch 703 | - categories: 704 | - api-extensions 705 | kind: ValidatingAdmissionPolicyBinding 706 | name: validatingadmissionpolicybindings 707 | namespaced: false 708 | singularName: validatingadmissionpolicybinding 709 | verbs: 710 | - create 711 | - delete 712 | - deletecollection 713 | - get 714 | - list 715 | - patch 716 | - update 717 | - watch 718 | - categories: 719 | - api-extensions 720 | kind: MutatingWebhookConfiguration 721 | name: mutatingwebhookconfigurations 722 | namespaced: false 723 | singularName: mutatingwebhookconfiguration 724 | verbs: 725 | - create 726 | - delete 727 | - deletecollection 728 | - get 729 | - list 730 | - patch 731 | - update 732 | - watch 733 | - categories: 734 | - api-extensions 735 | kind: ValidatingWebhookConfiguration 736 | name: validatingwebhookconfigurations 737 | namespaced: false 738 | singularName: validatingwebhookconfiguration 739 | verbs: 740 | - create 741 | - delete 742 | - deletecollection 743 | - get 744 | - list 745 | - patch 746 | - update 747 | - watch 748 | - groupVersion: apiextensions.k8s.io/v1 749 | resources: 750 | - categories: 751 | - api-extensions 752 | kind: CustomResourceDefinition 753 | name: customresourcedefinitions 754 | namespaced: false 755 | shortNames: 756 | - crd 757 | - crds 758 | singularName: customresourcedefinition 759 | verbs: 760 | - create 761 | - delete 762 | - deletecollection 763 | - get 764 | - list 765 | - patch 766 | - update 767 | - watch 768 | - groupVersion: scheduling.k8s.io/v1 769 | resources: 770 | - kind: PriorityClass 771 | name: priorityclasses 772 | namespaced: false 773 | shortNames: 774 | - pc 775 | singularName: priorityclass 776 | verbs: 777 | - create 778 | - delete 779 | - deletecollection 780 | - get 781 | - list 782 | - patch 783 | - update 784 | - watch 785 | - groupVersion: coordination.k8s.io/v1 786 | resources: 787 | - kind: Lease 788 | name: leases 789 | namespaced: true 790 | singularName: lease 791 | verbs: 792 | - create 793 | - delete 794 | - deletecollection 795 | - get 796 | - list 797 | - patch 798 | - update 799 | - watch 800 | - groupVersion: node.k8s.io/v1 801 | resources: 802 | - kind: RuntimeClass 803 | name: runtimeclasses 804 | namespaced: false 805 | singularName: runtimeclass 806 | verbs: 807 | - create 808 | - delete 809 | - deletecollection 810 | - get 811 | - list 812 | - patch 813 | - update 814 | - watch 815 | - groupVersion: discovery.k8s.io/v1 816 | resources: 817 | - kind: EndpointSlice 818 | name: endpointslices 819 | namespaced: true 820 | singularName: endpointslice 821 | verbs: 822 | - create 823 | - delete 824 | - deletecollection 825 | - get 826 | - list 827 | - patch 828 | - update 829 | - watch 830 | - groupVersion: flowcontrol.apiserver.k8s.io/v1 831 | resources: 832 | - kind: FlowSchema 833 | name: flowschemas 834 | namespaced: false 835 | singularName: flowschema 836 | verbs: 837 | - create 838 | - delete 839 | - deletecollection 840 | - get 841 | - list 842 | - patch 843 | - update 844 | - watch 845 | - kind: PriorityLevelConfiguration 846 | name: prioritylevelconfigurations 847 | namespaced: false 848 | singularName: prioritylevelconfiguration 849 | verbs: 850 | - create 851 | - delete 852 | - deletecollection 853 | - get 854 | - list 855 | - patch 856 | - update 857 | - watch 858 | - groupVersion: projectcalico.org/v3 859 | resources: 860 | - categories: 861 | - "" 862 | kind: BGPPeer 863 | name: bgppeers 864 | namespaced: false 865 | singularName: "" 866 | verbs: 867 | - create 868 | - delete 869 | - deletecollection 870 | - get 871 | - list 872 | - patch 873 | - update 874 | - watch 875 | - categories: 876 | - "" 877 | kind: IPAMConfiguration 878 | name: ipamconfigurations 879 | namespaced: false 880 | shortNames: 881 | - ipamconfig 882 | singularName: "" 883 | verbs: 884 | - create 885 | - delete 886 | - deletecollection 887 | - get 888 | - list 889 | - patch 890 | - update 891 | - watch 892 | - categories: 893 | - "" 894 | kind: IPPool 895 | name: ippools 896 | namespaced: false 897 | singularName: "" 898 | verbs: 899 | - create 900 | - delete 901 | - deletecollection 902 | - get 903 | - list 904 | - patch 905 | - update 906 | - watch 907 | - categories: 908 | - "" 909 | kind: Profile 910 | name: profiles 911 | namespaced: false 912 | singularName: "" 913 | verbs: 914 | - create 915 | - delete 916 | - deletecollection 917 | - get 918 | - list 919 | - patch 920 | - update 921 | - watch 922 | - categories: 923 | - "" 924 | kind: CalicoNodeStatus 925 | name: caliconodestatuses 926 | namespaced: false 927 | shortNames: 928 | - caliconodestatus 929 | singularName: "" 930 | verbs: 931 | - create 932 | - delete 933 | - deletecollection 934 | - get 935 | - list 936 | - patch 937 | - update 938 | - watch 939 | - categories: 940 | - "" 941 | kind: GlobalNetworkPolicy 942 | name: globalnetworkpolicies 943 | namespaced: false 944 | shortNames: 945 | - gnp 946 | - cgnp 947 | - calicoglobalnetworkpolicies 948 | singularName: "" 949 | verbs: 950 | - create 951 | - delete 952 | - deletecollection 953 | - get 954 | - list 955 | - patch 956 | - update 957 | - watch 958 | - kind: KubeControllersConfiguration 959 | name: kubecontrollersconfigurations 960 | namespaced: false 961 | singularName: "" 962 | verbs: 963 | - create 964 | - delete 965 | - deletecollection 966 | - get 967 | - list 968 | - patch 969 | - update 970 | - watch 971 | - categories: 972 | - "" 973 | kind: HostEndpoint 974 | name: hostendpoints 975 | namespaced: false 976 | shortNames: 977 | - hep 978 | - heps 979 | singularName: "" 980 | verbs: 981 | - create 982 | - delete 983 | - deletecollection 984 | - get 985 | - list 986 | - patch 987 | - update 988 | - watch 989 | - categories: 990 | - "" 991 | kind: IPReservation 992 | name: ipreservations 993 | namespaced: false 994 | singularName: "" 995 | verbs: 996 | - create 997 | - delete 998 | - deletecollection 999 | - get 1000 | - list 1001 | - patch 1002 | - update 1003 | - watch 1004 | - categories: 1005 | - "" 1006 | kind: FelixConfiguration 1007 | name: felixconfigurations 1008 | namespaced: false 1009 | shortNames: 1010 | - felixconfig 1011 | - felixconfigs 1012 | singularName: "" 1013 | verbs: 1014 | - create 1015 | - delete 1016 | - deletecollection 1017 | - get 1018 | - list 1019 | - patch 1020 | - update 1021 | - watch 1022 | - categories: 1023 | - "" 1024 | kind: GlobalNetworkSet 1025 | name: globalnetworksets 1026 | namespaced: false 1027 | singularName: "" 1028 | verbs: 1029 | - create 1030 | - delete 1031 | - deletecollection 1032 | - get 1033 | - list 1034 | - patch 1035 | - update 1036 | - watch 1037 | - categories: 1038 | - "" 1039 | kind: BGPConfiguration 1040 | name: bgpconfigurations 1041 | namespaced: false 1042 | shortNames: 1043 | - bgpconfig 1044 | - bgpconfigs 1045 | singularName: "" 1046 | verbs: 1047 | - create 1048 | - delete 1049 | - deletecollection 1050 | - get 1051 | - list 1052 | - patch 1053 | - update 1054 | - watch 1055 | - categories: 1056 | - "" 1057 | kind: BlockAffinity 1058 | name: blockaffinities 1059 | namespaced: false 1060 | shortNames: 1061 | - blockaffinity 1062 | - affinity 1063 | - affinities 1064 | singularName: "" 1065 | verbs: 1066 | - create 1067 | - delete 1068 | - deletecollection 1069 | - get 1070 | - list 1071 | - patch 1072 | - update 1073 | - watch 1074 | - categories: 1075 | - "" 1076 | kind: NetworkPolicy 1077 | name: networkpolicies 1078 | namespaced: true 1079 | shortNames: 1080 | - cnp 1081 | - caliconetworkpolicy 1082 | - caliconetworkpolicies 1083 | singularName: "" 1084 | verbs: 1085 | - create 1086 | - delete 1087 | - deletecollection 1088 | - get 1089 | - list 1090 | - patch 1091 | - update 1092 | - watch 1093 | - categories: 1094 | - "" 1095 | kind: ClusterInformation 1096 | name: clusterinformations 1097 | namespaced: false 1098 | shortNames: 1099 | - clusterinfo 1100 | singularName: "" 1101 | verbs: 1102 | - create 1103 | - delete 1104 | - deletecollection 1105 | - get 1106 | - list 1107 | - patch 1108 | - update 1109 | - watch 1110 | - categories: 1111 | - "" 1112 | kind: BGPFilter 1113 | name: bgpfilters 1114 | namespaced: false 1115 | singularName: "" 1116 | verbs: 1117 | - create 1118 | - delete 1119 | - deletecollection 1120 | - get 1121 | - list 1122 | - patch 1123 | - update 1124 | - watch 1125 | - categories: 1126 | - "" 1127 | kind: NetworkSet 1128 | name: networksets 1129 | namespaced: true 1130 | shortNames: 1131 | - netsets 1132 | singularName: "" 1133 | verbs: 1134 | - create 1135 | - delete 1136 | - deletecollection 1137 | - get 1138 | - list 1139 | - patch 1140 | - update 1141 | - watch 1142 | - groupVersion: core.strimzi.io/v1beta2 1143 | resources: 1144 | - categories: 1145 | - strimzi 1146 | group: core.strimzi.io 1147 | kind: StrimziPodSet 1148 | name: strimzipodsets 1149 | namespaced: true 1150 | shortNames: 1151 | - sps 1152 | singularName: strimzipodset 1153 | verbs: 1154 | - delete 1155 | - deletecollection 1156 | - get 1157 | - list 1158 | - patch 1159 | - create 1160 | - update 1161 | - watch 1162 | version: v1beta2 1163 | - groupVersion: crd.projectcalico.org/v1 1164 | resources: 1165 | - group: crd.projectcalico.org 1166 | kind: FelixConfiguration 1167 | name: felixconfigurations 1168 | namespaced: false 1169 | singularName: felixconfiguration 1170 | verbs: 1171 | - delete 1172 | - deletecollection 1173 | - get 1174 | - list 1175 | - patch 1176 | - create 1177 | - update 1178 | - watch 1179 | version: v1 1180 | - group: crd.projectcalico.org 1181 | kind: GlobalNetworkSet 1182 | name: globalnetworksets 1183 | namespaced: false 1184 | singularName: globalnetworkset 1185 | verbs: 1186 | - delete 1187 | - deletecollection 1188 | - get 1189 | - list 1190 | - patch 1191 | - create 1192 | - update 1193 | - watch 1194 | version: v1 1195 | - group: crd.projectcalico.org 1196 | kind: IPAMBlock 1197 | name: ipamblocks 1198 | namespaced: false 1199 | singularName: ipamblock 1200 | verbs: 1201 | - delete 1202 | - deletecollection 1203 | - get 1204 | - list 1205 | - patch 1206 | - create 1207 | - update 1208 | - watch 1209 | version: v1 1210 | - group: crd.projectcalico.org 1211 | kind: HostEndpoint 1212 | name: hostendpoints 1213 | namespaced: false 1214 | singularName: hostendpoint 1215 | verbs: 1216 | - delete 1217 | - deletecollection 1218 | - get 1219 | - list 1220 | - patch 1221 | - create 1222 | - update 1223 | - watch 1224 | version: v1 1225 | - group: crd.projectcalico.org 1226 | kind: BGPConfiguration 1227 | name: bgpconfigurations 1228 | namespaced: false 1229 | singularName: bgpconfiguration 1230 | verbs: 1231 | - delete 1232 | - deletecollection 1233 | - get 1234 | - list 1235 | - patch 1236 | - create 1237 | - update 1238 | - watch 1239 | version: v1 1240 | - group: crd.projectcalico.org 1241 | kind: BlockAffinity 1242 | name: blockaffinities 1243 | namespaced: false 1244 | singularName: blockaffinity 1245 | verbs: 1246 | - delete 1247 | - deletecollection 1248 | - get 1249 | - list 1250 | - patch 1251 | - create 1252 | - update 1253 | - watch 1254 | version: v1 1255 | - group: crd.projectcalico.org 1256 | kind: IPAMConfig 1257 | name: ipamconfigs 1258 | namespaced: false 1259 | singularName: ipamconfig 1260 | verbs: 1261 | - delete 1262 | - deletecollection 1263 | - get 1264 | - list 1265 | - patch 1266 | - create 1267 | - update 1268 | - watch 1269 | version: v1 1270 | - group: crd.projectcalico.org 1271 | kind: IPReservation 1272 | name: ipreservations 1273 | namespaced: false 1274 | singularName: ipreservation 1275 | verbs: 1276 | - delete 1277 | - deletecollection 1278 | - get 1279 | - list 1280 | - patch 1281 | - create 1282 | - update 1283 | - watch 1284 | version: v1 1285 | - group: crd.projectcalico.org 1286 | kind: GlobalNetworkPolicy 1287 | name: globalnetworkpolicies 1288 | namespaced: false 1289 | singularName: globalnetworkpolicy 1290 | verbs: 1291 | - delete 1292 | - deletecollection 1293 | - get 1294 | - list 1295 | - patch 1296 | - create 1297 | - update 1298 | - watch 1299 | version: v1 1300 | - group: crd.projectcalico.org 1301 | kind: BGPPeer 1302 | name: bgppeers 1303 | namespaced: false 1304 | singularName: bgppeer 1305 | verbs: 1306 | - delete 1307 | - deletecollection 1308 | - get 1309 | - list 1310 | - patch 1311 | - create 1312 | - update 1313 | - watch 1314 | version: v1 1315 | - group: crd.projectcalico.org 1316 | kind: KubeControllersConfiguration 1317 | name: kubecontrollersconfigurations 1318 | namespaced: false 1319 | singularName: kubecontrollersconfiguration 1320 | verbs: 1321 | - delete 1322 | - deletecollection 1323 | - get 1324 | - list 1325 | - patch 1326 | - create 1327 | - update 1328 | - watch 1329 | version: v1 1330 | - group: crd.projectcalico.org 1331 | kind: BGPFilter 1332 | name: bgpfilters 1333 | namespaced: false 1334 | singularName: bgpfilter 1335 | verbs: 1336 | - delete 1337 | - deletecollection 1338 | - get 1339 | - list 1340 | - patch 1341 | - create 1342 | - update 1343 | - watch 1344 | version: v1 1345 | - group: crd.projectcalico.org 1346 | kind: CalicoNodeStatus 1347 | name: caliconodestatuses 1348 | namespaced: false 1349 | singularName: caliconodestatus 1350 | verbs: 1351 | - delete 1352 | - deletecollection 1353 | - get 1354 | - list 1355 | - patch 1356 | - create 1357 | - update 1358 | - watch 1359 | version: v1 1360 | - group: crd.projectcalico.org 1361 | kind: NetworkPolicy 1362 | name: networkpolicies 1363 | namespaced: true 1364 | singularName: networkpolicy 1365 | verbs: 1366 | - delete 1367 | - deletecollection 1368 | - get 1369 | - list 1370 | - patch 1371 | - create 1372 | - update 1373 | - watch 1374 | version: v1 1375 | - group: crd.projectcalico.org 1376 | kind: IPPool 1377 | name: ippools 1378 | namespaced: false 1379 | singularName: ippool 1380 | verbs: 1381 | - delete 1382 | - deletecollection 1383 | - get 1384 | - list 1385 | - patch 1386 | - create 1387 | - update 1388 | - watch 1389 | version: v1 1390 | - group: crd.projectcalico.org 1391 | kind: ClusterInformation 1392 | name: clusterinformations 1393 | namespaced: false 1394 | singularName: clusterinformation 1395 | verbs: 1396 | - delete 1397 | - deletecollection 1398 | - get 1399 | - list 1400 | - patch 1401 | - create 1402 | - update 1403 | - watch 1404 | version: v1 1405 | - group: crd.projectcalico.org 1406 | kind: NetworkSet 1407 | name: networksets 1408 | namespaced: true 1409 | singularName: networkset 1410 | verbs: 1411 | - delete 1412 | - deletecollection 1413 | - get 1414 | - list 1415 | - patch 1416 | - create 1417 | - update 1418 | - watch 1419 | version: v1 1420 | - group: crd.projectcalico.org 1421 | kind: IPAMHandle 1422 | name: ipamhandles 1423 | namespaced: false 1424 | singularName: ipamhandle 1425 | verbs: 1426 | - delete 1427 | - deletecollection 1428 | - get 1429 | - list 1430 | - patch 1431 | - create 1432 | - update 1433 | - watch 1434 | version: v1 1435 | - groupVersion: helm.toolkit.fluxcd.io/v2 1436 | resources: 1437 | - group: helm.toolkit.fluxcd.io 1438 | kind: HelmRelease 1439 | name: helmreleases 1440 | namespaced: true 1441 | shortNames: 1442 | - hr 1443 | singularName: helmrelease 1444 | verbs: 1445 | - delete 1446 | - deletecollection 1447 | - get 1448 | - list 1449 | - patch 1450 | - create 1451 | - update 1452 | - watch 1453 | version: v2 1454 | - groupVersion: helm.toolkit.fluxcd.io/v2beta2 1455 | resources: null 1456 | - groupVersion: helm.toolkit.fluxcd.io/v2beta1 1457 | resources: null 1458 | - groupVersion: kafka.strimzi.io/v1beta2 1459 | resources: 1460 | - categories: 1461 | - strimzi 1462 | group: kafka.strimzi.io 1463 | kind: KafkaMirrorMaker2 1464 | name: kafkamirrormaker2s 1465 | namespaced: true 1466 | shortNames: 1467 | - kmm2 1468 | singularName: kafkamirrormaker2 1469 | verbs: 1470 | - delete 1471 | - deletecollection 1472 | - get 1473 | - list 1474 | - patch 1475 | - create 1476 | - update 1477 | - watch 1478 | version: v1beta2 1479 | - categories: 1480 | - strimzi 1481 | group: kafka.strimzi.io 1482 | kind: KafkaConnector 1483 | name: kafkaconnectors 1484 | namespaced: true 1485 | shortNames: 1486 | - kctr 1487 | singularName: kafkaconnector 1488 | verbs: 1489 | - delete 1490 | - deletecollection 1491 | - get 1492 | - list 1493 | - patch 1494 | - create 1495 | - update 1496 | - watch 1497 | version: v1beta2 1498 | - categories: 1499 | - strimzi 1500 | group: kafka.strimzi.io 1501 | kind: KafkaRebalance 1502 | name: kafkarebalances 1503 | namespaced: true 1504 | shortNames: 1505 | - kr 1506 | singularName: kafkarebalance 1507 | verbs: 1508 | - delete 1509 | - deletecollection 1510 | - get 1511 | - list 1512 | - patch 1513 | - create 1514 | - update 1515 | - watch 1516 | version: v1beta2 1517 | - categories: 1518 | - strimzi 1519 | group: kafka.strimzi.io 1520 | kind: KafkaTopic 1521 | name: kafkatopics 1522 | namespaced: true 1523 | shortNames: 1524 | - kt 1525 | singularName: kafkatopic 1526 | verbs: 1527 | - delete 1528 | - deletecollection 1529 | - get 1530 | - list 1531 | - patch 1532 | - create 1533 | - update 1534 | - watch 1535 | version: v1beta2 1536 | - categories: 1537 | - strimzi 1538 | group: kafka.strimzi.io 1539 | kind: KafkaBridge 1540 | name: kafkabridges 1541 | namespaced: true 1542 | shortNames: 1543 | - kb 1544 | singularName: kafkabridge 1545 | verbs: 1546 | - delete 1547 | - deletecollection 1548 | - get 1549 | - list 1550 | - patch 1551 | - create 1552 | - update 1553 | - watch 1554 | version: v1beta2 1555 | - categories: 1556 | - strimzi 1557 | group: kafka.strimzi.io 1558 | kind: KafkaUser 1559 | name: kafkausers 1560 | namespaced: true 1561 | shortNames: 1562 | - ku 1563 | singularName: kafkauser 1564 | verbs: 1565 | - delete 1566 | - deletecollection 1567 | - get 1568 | - list 1569 | - patch 1570 | - create 1571 | - update 1572 | - watch 1573 | version: v1beta2 1574 | - categories: 1575 | - strimzi 1576 | group: kafka.strimzi.io 1577 | kind: KafkaConnect 1578 | name: kafkaconnects 1579 | namespaced: true 1580 | shortNames: 1581 | - kc 1582 | singularName: kafkaconnect 1583 | verbs: 1584 | - delete 1585 | - deletecollection 1586 | - get 1587 | - list 1588 | - patch 1589 | - create 1590 | - update 1591 | - watch 1592 | version: v1beta2 1593 | - categories: 1594 | - strimzi 1595 | group: kafka.strimzi.io 1596 | kind: KafkaMirrorMaker 1597 | name: kafkamirrormakers 1598 | namespaced: true 1599 | shortNames: 1600 | - kmm 1601 | singularName: kafkamirrormaker 1602 | verbs: 1603 | - delete 1604 | - deletecollection 1605 | - get 1606 | - list 1607 | - patch 1608 | - create 1609 | - update 1610 | - watch 1611 | version: v1beta2 1612 | - categories: 1613 | - strimzi 1614 | group: kafka.strimzi.io 1615 | kind: Kafka 1616 | name: kafkas 1617 | namespaced: true 1618 | shortNames: 1619 | - k 1620 | singularName: kafka 1621 | verbs: 1622 | - delete 1623 | - deletecollection 1624 | - get 1625 | - list 1626 | - patch 1627 | - create 1628 | - update 1629 | - watch 1630 | version: v1beta2 1631 | - categories: 1632 | - strimzi 1633 | group: kafka.strimzi.io 1634 | kind: KafkaNodePool 1635 | name: kafkanodepools 1636 | namespaced: true 1637 | shortNames: 1638 | - knp 1639 | singularName: kafkanodepool 1640 | verbs: 1641 | - delete 1642 | - deletecollection 1643 | - get 1644 | - list 1645 | - patch 1646 | - create 1647 | - update 1648 | - watch 1649 | version: v1beta2 1650 | - groupVersion: kafka.strimzi.io/v1beta1 1651 | resources: null 1652 | - groupVersion: kafka.strimzi.io/v1alpha1 1653 | resources: null 1654 | - groupVersion: kustomize.toolkit.fluxcd.io/v1 1655 | resources: 1656 | - group: kustomize.toolkit.fluxcd.io 1657 | kind: Kustomization 1658 | name: kustomizations 1659 | namespaced: true 1660 | shortNames: 1661 | - ks 1662 | singularName: kustomization 1663 | verbs: 1664 | - delete 1665 | - deletecollection 1666 | - get 1667 | - list 1668 | - patch 1669 | - create 1670 | - update 1671 | - watch 1672 | version: v1 1673 | - groupVersion: kustomize.toolkit.fluxcd.io/v1beta2 1674 | resources: null 1675 | - groupVersion: kustomize.toolkit.fluxcd.io/v1beta1 1676 | resources: null 1677 | - groupVersion: notification.toolkit.fluxcd.io/v1 1678 | resources: 1679 | - group: notification.toolkit.fluxcd.io 1680 | kind: Receiver 1681 | name: receivers 1682 | namespaced: true 1683 | singularName: receiver 1684 | verbs: 1685 | - delete 1686 | - deletecollection 1687 | - get 1688 | - list 1689 | - patch 1690 | - create 1691 | - update 1692 | - watch 1693 | version: v1 1694 | - groupVersion: notification.toolkit.fluxcd.io/v1beta3 1695 | resources: 1696 | - group: notification.toolkit.fluxcd.io 1697 | kind: Provider 1698 | name: providers 1699 | namespaced: true 1700 | singularName: provider 1701 | verbs: 1702 | - delete 1703 | - deletecollection 1704 | - get 1705 | - list 1706 | - patch 1707 | - create 1708 | - update 1709 | - watch 1710 | version: v1beta3 1711 | - group: notification.toolkit.fluxcd.io 1712 | kind: Alert 1713 | name: alerts 1714 | namespaced: true 1715 | singularName: alert 1716 | verbs: 1717 | - delete 1718 | - deletecollection 1719 | - get 1720 | - list 1721 | - patch 1722 | - create 1723 | - update 1724 | - watch 1725 | version: v1beta3 1726 | - groupVersion: notification.toolkit.fluxcd.io/v1beta2 1727 | resources: null 1728 | - groupVersion: notification.toolkit.fluxcd.io/v1beta1 1729 | resources: null 1730 | - groupVersion: operator.tigera.io/v1 1731 | resources: 1732 | - group: operator.tigera.io 1733 | kind: ImageSet 1734 | name: imagesets 1735 | namespaced: false 1736 | singularName: imageset 1737 | verbs: 1738 | - delete 1739 | - deletecollection 1740 | - get 1741 | - list 1742 | - patch 1743 | - create 1744 | - update 1745 | - watch 1746 | version: v1 1747 | - group: operator.tigera.io 1748 | kind: TigeraStatus 1749 | name: tigerastatuses 1750 | namespaced: false 1751 | singularName: tigerastatus 1752 | verbs: 1753 | - delete 1754 | - deletecollection 1755 | - get 1756 | - list 1757 | - patch 1758 | - create 1759 | - update 1760 | - watch 1761 | version: v1 1762 | - group: operator.tigera.io 1763 | kind: APIServer 1764 | name: apiservers 1765 | namespaced: false 1766 | singularName: apiserver 1767 | verbs: 1768 | - delete 1769 | - deletecollection 1770 | - get 1771 | - list 1772 | - patch 1773 | - create 1774 | - update 1775 | - watch 1776 | version: v1 1777 | - group: operator.tigera.io 1778 | kind: Installation 1779 | name: installations 1780 | namespaced: false 1781 | singularName: installation 1782 | verbs: 1783 | - delete 1784 | - deletecollection 1785 | - get 1786 | - list 1787 | - patch 1788 | - create 1789 | - update 1790 | - watch 1791 | version: v1 1792 | - groupVersion: source.toolkit.fluxcd.io/v1 1793 | resources: 1794 | - group: source.toolkit.fluxcd.io 1795 | kind: GitRepository 1796 | name: gitrepositories 1797 | namespaced: true 1798 | shortNames: 1799 | - gitrepo 1800 | singularName: gitrepository 1801 | verbs: 1802 | - delete 1803 | - deletecollection 1804 | - get 1805 | - list 1806 | - patch 1807 | - create 1808 | - update 1809 | - watch 1810 | version: v1 1811 | - group: source.toolkit.fluxcd.io 1812 | kind: Bucket 1813 | name: buckets 1814 | namespaced: true 1815 | singularName: bucket 1816 | verbs: 1817 | - delete 1818 | - deletecollection 1819 | - get 1820 | - list 1821 | - patch 1822 | - create 1823 | - update 1824 | - watch 1825 | version: v1 1826 | - group: source.toolkit.fluxcd.io 1827 | kind: HelmRepository 1828 | name: helmrepositories 1829 | namespaced: true 1830 | shortNames: 1831 | - helmrepo 1832 | singularName: helmrepository 1833 | verbs: 1834 | - delete 1835 | - deletecollection 1836 | - get 1837 | - list 1838 | - patch 1839 | - create 1840 | - update 1841 | - watch 1842 | version: v1 1843 | - group: source.toolkit.fluxcd.io 1844 | kind: HelmChart 1845 | name: helmcharts 1846 | namespaced: true 1847 | shortNames: 1848 | - hc 1849 | singularName: helmchart 1850 | verbs: 1851 | - delete 1852 | - deletecollection 1853 | - get 1854 | - list 1855 | - patch 1856 | - create 1857 | - update 1858 | - watch 1859 | version: v1 1860 | - groupVersion: source.toolkit.fluxcd.io/v1beta2 1861 | resources: 1862 | - group: source.toolkit.fluxcd.io 1863 | kind: OCIRepository 1864 | name: ocirepositories 1865 | namespaced: true 1866 | shortNames: 1867 | - ocirepo 1868 | singularName: ocirepository 1869 | verbs: 1870 | - delete 1871 | - deletecollection 1872 | - get 1873 | - list 1874 | - patch 1875 | - create 1876 | - update 1877 | - watch 1878 | version: v1beta2 1879 | - groupVersion: source.toolkit.fluxcd.io/v1beta1 1880 | resources: null 1881 | - groupVersion: metrics.k8s.io/v1beta1 1882 | resources: 1883 | - kind: PodMetrics 1884 | name: pods 1885 | namespaced: true 1886 | singularName: "" 1887 | verbs: 1888 | - get 1889 | - list 1890 | - kind: NodeMetrics 1891 | name: nodes 1892 | namespaced: false 1893 | singularName: "" 1894 | verbs: 1895 | - get 1896 | - list 1897 | 1898 | 1899 | --------------------------------------------------------------------------------