Skip to content

Commit 0c09c38

Browse files
feat: Add search.Resources tool
This commit introduces a new `search.Resources` tool to the core toolset. This tool allows users to search for a string within all accessible Kubernetes resources. The search is performed across all namespaces for namespaced resources and at the cluster level for cluster-scoped resources. The tool iterates through all discoverable API resources, lists their instances, and performs a case-insensitive search on the JSON representation of each resource. The results can be returned as a list of unstructured resources or as a formatted table, providing a flexible way to view the search results.
1 parent b55f28b commit 0c09c38

File tree

6 files changed

+273
-0
lines changed

6 files changed

+273
-0
lines changed

pkg/kubernetes/search.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package kubernetes
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/apimachinery/pkg/runtime/schema"
13+
)
14+
15+
// SearchResources searches for a query string in all resources across all namespaces.
16+
func (k *Kubernetes) SearchResources(ctx context.Context, query string, asTable bool) (runtime.Unstructured, error) {
17+
// Discovery client is used to discover different supported API groups, versions and resources.
18+
serverResources, err := k.manager.discoveryClient.ServerPreferredResources()
19+
if err != nil {
20+
return nil, fmt.Errorf("failed to get server resources: %w", err)
21+
}
22+
23+
var matchingResources []unstructured.Unstructured
24+
for _, apiResourceList := range serverResources {
25+
for _, apiResource := range apiResourceList.APIResources {
26+
// Skip resources that do not support the "list" verb
27+
if !contains(apiResource.Verbs, "list") {
28+
continue
29+
}
30+
31+
gvk := schema.GroupVersionKind{
32+
Group: apiResourceList.GroupVersion,
33+
Version: apiResource.Version,
34+
Kind: apiResource.Kind,
35+
}
36+
if gvk.Group == "" {
37+
gvk.Group = "core"
38+
}
39+
40+
if _, err := k.resourceFor(&gvk); err != nil {
41+
// Ignore errors for resources that cannot be mapped
42+
continue
43+
}
44+
var namespaces []string
45+
if apiResource.Namespaced {
46+
// Get all namespaces
47+
nsListObj, err := k.NamespacesList(ctx, ResourceListOptions{})
48+
if err != nil {
49+
return nil, fmt.Errorf("failed to list namespaces: %w", err)
50+
}
51+
if unstructuredList, ok := nsListObj.(*unstructured.UnstructuredList); ok {
52+
for _, ns := range unstructuredList.Items {
53+
namespaces = append(namespaces, ns.GetName())
54+
}
55+
}
56+
} else {
57+
// For cluster-scoped resources, use an empty namespace
58+
namespaces = append(namespaces, "")
59+
}
60+
61+
for _, ns := range namespaces {
62+
list, err := k.ResourcesList(ctx, &gvk, ns, ResourceListOptions{})
63+
if err != nil {
64+
// Ignore errors for resources that cannot be listed
65+
continue
66+
}
67+
68+
if unstructuredList, ok := list.(*unstructured.UnstructuredList); ok {
69+
for _, item := range unstructuredList.Items {
70+
match, err := matchResource(item, query)
71+
if err != nil {
72+
// Ignore errors during matching
73+
continue
74+
}
75+
if match {
76+
matchingResources = append(matchingResources, item)
77+
}
78+
}
79+
}
80+
}
81+
}
82+
}
83+
84+
if asTable {
85+
return k.createTable(matchingResources)
86+
}
87+
88+
return &unstructured.UnstructuredList{
89+
Object: map[string]interface{}{
90+
"apiVersion": "v1",
91+
"kind": "List",
92+
},
93+
Items: matchingResources,
94+
}, nil
95+
}
96+
97+
func (k *Kubernetes) createTable(resources []unstructured.Unstructured) (runtime.Unstructured, error) {
98+
table := &metav1.Table{
99+
TypeMeta: metav1.TypeMeta{
100+
APIVersion: "meta.k8s.io/v1",
101+
Kind: "Table",
102+
},
103+
ColumnDefinitions: []metav1.TableColumnDefinition{
104+
{Name: "Namespace", Type: "string"},
105+
{Name: "Kind", Type: "string"},
106+
{Name: "Name", Type: "string"},
107+
},
108+
}
109+
110+
for _, res := range resources {
111+
row := metav1.TableRow{
112+
Cells: []interface{}{
113+
res.GetNamespace(),
114+
res.GetKind(),
115+
res.GetName(),
116+
},
117+
Object: runtime.RawExtension{Object: &res},
118+
}
119+
table.Rows = append(table.Rows, row)
120+
}
121+
122+
unstructuredObject, err := runtime.DefaultUnstructuredConverter.ToUnstructured(table)
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to convert table to unstructured: %w", err)
125+
}
126+
return &unstructured.Unstructured{Object: unstructuredObject}, nil
127+
}
128+
129+
func matchResource(resource unstructured.Unstructured, query string) (bool, error) {
130+
data, err := json.Marshal(resource.Object)
131+
if err != nil {
132+
return false, fmt.Errorf("failed to marshal resource: %w", err)
133+
}
134+
return strings.Contains(strings.ToLower(string(data)), strings.ToLower(query)), nil
135+
}
136+
137+
func contains(slice []string, s string) bool {
138+
for _, item := range slice {
139+
if item == s {
140+
return true
141+
}
142+
}
143+
return false
144+
}

pkg/mcp/testdata/toolsets-core-tools.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,5 +418,28 @@
418418
]
419419
},
420420
"name": "resources_list"
421+
},
422+
{
423+
"annotations": {
424+
"readOnlyHint": true
425+
},
426+
"description": "Search for a string in all resources.",
427+
"inputSchema": {
428+
"type": "object",
429+
"properties": {
430+
"query": {
431+
"description": "The string to search for in the resources.",
432+
"type": "string"
433+
},
434+
"as_table": {
435+
"description": "Return the results as a table.",
436+
"type": "boolean"
437+
}
438+
},
439+
"required": [
440+
"query"
441+
]
442+
},
443+
"name": "search.Resources"
421444
}
422445
]

pkg/mcp/testdata/toolsets-full-tools-openshift.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,5 +538,28 @@
538538
]
539539
},
540540
"name": "resources_list"
541+
},
542+
{
543+
"annotations": {
544+
"readOnlyHint": true
545+
},
546+
"description": "Search for a string in all resources.",
547+
"inputSchema": {
548+
"type": "object",
549+
"properties": {
550+
"query": {
551+
"description": "The string to search for in the resources.",
552+
"type": "string"
553+
},
554+
"as_table": {
555+
"description": "Return the results as a table.",
556+
"type": "boolean"
557+
}
558+
},
559+
"required": [
560+
"query"
561+
]
562+
},
563+
"name": "search.Resources"
541564
}
542565
]

pkg/mcp/testdata/toolsets-full-tools.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,5 +524,28 @@
524524
]
525525
},
526526
"name": "resources_list"
527+
},
528+
{
529+
"annotations": {
530+
"readOnlyHint": true
531+
},
532+
"description": "Search for a string in all resources.",
533+
"inputSchema": {
534+
"type": "object",
535+
"properties": {
536+
"query": {
537+
"description": "The string to search for in the resources.",
538+
"type": "string"
539+
},
540+
"as_table": {
541+
"description": "Return the results as a table.",
542+
"type": "boolean"
543+
}
544+
},
545+
"required": [
546+
"query"
547+
]
548+
},
549+
"name": "search.Resources"
527550
}
528551
]

pkg/toolsets/core/search.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package core
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/google/jsonschema-go/jsonschema"
7+
"k8s.io/utils/ptr"
8+
9+
"github.com/containers/kubernetes-mcp-server/pkg/api"
10+
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
11+
)
12+
13+
func initSearch(_ kubernetes.Openshift) []api.ServerTool {
14+
return []api.ServerTool{
15+
{
16+
Tool: api.Tool{
17+
Name: "search.Resources",
18+
Description: "Search for a string in all resources.",
19+
InputSchema: &jsonschema.Schema{
20+
Type: "object",
21+
Properties: map[string]*jsonschema.Schema{
22+
"query": {
23+
Type: "string",
24+
Description: "The string to search for in the resources.",
25+
},
26+
"as_table": {
27+
Type: "boolean",
28+
Description: "Return the results as a table.",
29+
},
30+
},
31+
Required: []string{"query"},
32+
},
33+
Annotations: api.ToolAnnotations{
34+
ReadOnlyHint: ptr.To(true),
35+
},
36+
},
37+
Handler: searchResources,
38+
},
39+
}
40+
}
41+
42+
func searchResources(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
43+
query, ok := params.GetArguments()["query"].(string)
44+
if !ok {
45+
return api.NewToolCallResult("", fmt.Errorf("query is not a string")), nil
46+
}
47+
48+
asTable := false
49+
if val, ok := params.GetArguments()["as_table"].(bool); ok {
50+
asTable = val
51+
}
52+
53+
result, err := params.SearchResources(params, query, asTable)
54+
if err != nil {
55+
return api.NewToolCallResult("", fmt.Errorf("failed to search resources: %v", err)), nil
56+
}
57+
58+
return api.NewToolCallResult(params.ListOutput.PrintObj(result)), nil
59+
}

pkg/toolsets/core/toolset.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool {
2626
initNamespaces(o),
2727
initPods(),
2828
initResources(o),
29+
initSearch(o),
2930
)
3031
}
3132

0 commit comments

Comments
 (0)