Skip to content

Commit 370ae3e

Browse files
Merge pull request #5 from ShahryarShabani/feature/prometheus-integration
feat: Add Prometheus toolset for running PromQL queries
2 parents e0c8dbc + f46ede8 commit 370ae3e

File tree

11 files changed

+247
-10
lines changed

11 files changed

+247
-10
lines changed

cmd/kubernetes-mcp-server/main_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
1010
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
1111
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
12+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/prometheus"
1213
)
1314

1415
func TestMain(m *testing.M) {
@@ -17,6 +18,10 @@ func TestMain(m *testing.M) {
1718
// For this test, we can register a disabled version.
1819
confluenceToolset, _ := confluence.NewToolset(nil)
1920
toolsets.Register(confluenceToolset)
21+
22+
prometheusToolset, _ := prometheus.NewToolset(nil)
23+
toolsets.Register(prometheusToolset)
24+
2025
os.Exit(m.Run())
2126
}
2227

go.mod

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@ require (
107107
github.com/opencontainers/image-spec v1.1.1 // indirect
108108
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
109109
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
110-
github.com/prometheus/client_golang v1.22.0 // indirect
111-
github.com/prometheus/client_model v0.6.1 // indirect
112-
github.com/prometheus/common v0.62.0 // indirect
113-
github.com/prometheus/procfs v0.15.1 // indirect
110+
github.com/prometheus/client_golang v1.23.2 // indirect
111+
github.com/prometheus/client_model v0.6.2 // indirect
112+
github.com/prometheus/common v0.66.1 // indirect
113+
github.com/prometheus/procfs v0.16.1 // indirect
114114
github.com/rivo/uniseg v0.2.0 // indirect
115115
github.com/rubenv/sql-migrate v1.8.0 // indirect
116116
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -131,7 +131,7 @@ require (
131131
golang.org/x/time v0.12.0 // indirect
132132
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
133133
google.golang.org/grpc v1.72.1 // indirect
134-
google.golang.org/protobuf v1.36.6 // indirect
134+
google.golang.org/protobuf v1.36.8 // indirect
135135
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
136136
gopkg.in/inf.v0 v0.9.1 // indirect
137137
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,12 +244,20 @@ github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
244244
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
245245
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
246246
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
247+
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
248+
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
247249
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
248250
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
251+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
252+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
249253
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
250254
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
255+
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
256+
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
251257
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
252258
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
259+
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
260+
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
253261
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho=
254262
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U=
255263
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc=
@@ -417,6 +425,8 @@ google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
417425
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
418426
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
419427
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
428+
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
429+
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
420430
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
421431
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
422432
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

pkg/config/config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
// It allows to configure server specific settings and tools to be enabled or disabled.
1111
type StaticConfig struct {
1212
Confluence *ConfluenceConfig `toml:"confluence,omitempty"`
13+
Prometheus *PrometheusConfig `toml:"prometheus,omitempty"`
1314

1415
DeniedResources []GroupVersionKind `toml:"denied_resources"`
1516

@@ -60,10 +61,15 @@ type ConfluenceConfig struct {
6061
Token string `toml:"token"`
6162
}
6263

64+
// PrometheusConfig is the configuration for the Prometheus toolset.
65+
type PrometheusConfig struct {
66+
URL string `toml:"url"`
67+
}
68+
6369
func Default() *StaticConfig {
6470
return &StaticConfig{
6571
ListOutput: "table",
66-
Toolsets: []string{"core", "config", "helm", "confluence"},
72+
Toolsets: []string{"core", "config", "helm", "confluence", "prometheus"},
6773
}
6874
}
6975

pkg/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
152152
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
153153
})
154154
s.Run("toolsets defaulted correctly", func() {
155-
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
156-
for _, toolset := range []string{"core", "config", "helm", "confluence"} {
155+
s.Require().Lenf(config.Toolsets, 5, "Expected 5 toolsets, got %d", len(config.Toolsets))
156+
for _, toolset := range []string{"core", "config", "helm", "confluence", "prometheus"} {
157157
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
158158
}
159159
})

pkg/kubernetes-mcp-server/cmd/root_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
1414
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/confluence"
15+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/prometheus"
1516
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
1617
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
1718
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
@@ -26,6 +27,10 @@ func TestMain(m *testing.M) {
2627
// For this test, we can register a disabled version.
2728
confluenceToolset, _ := confluence.NewToolset(nil)
2829
toolsets.Register(confluenceToolset)
30+
31+
prometheusToolset, _ := prometheus.NewToolset(nil)
32+
toolsets.Register(prometheusToolset)
33+
2934
os.Exit(m.Run())
3035
}
3136

pkg/mcp/events_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ type EventsSuite struct {
1919

2020
func (s *EventsSuite) TestEventsList() {
2121
s.InitMcpClient()
22+
client := kubernetes.NewForConfigOrDie(envTestRestConfig)
23+
// Delete all events in the test namespaces
24+
for _, ns := range []string{"default", "ns-1"} {
25+
err := client.CoreV1().Events(ns).DeleteCollection(s.T().Context(), metav1.DeleteOptions{}, metav1.ListOptions{})
26+
s.Require().NoError(err)
27+
}
2228
s.Run("events_list (no events)", func() {
2329
toolResult, err := s.CallTool("events_list", map[string]interface{}{})
2430
s.Run("no error", func() {
@@ -30,7 +36,6 @@ func (s *EventsSuite) TestEventsList() {
3036
})
3137
})
3238
s.Run("events_list (with events)", func() {
33-
client := kubernetes.NewForConfigOrDie(envTestRestConfig)
3439
for _, ns := range []string{"default", "ns-1"} {
3540
_, _ = client.CoreV1().Events(ns).Create(s.T().Context(), &v1.Event{
3641
ObjectMeta: metav1.ObjectMeta{

pkg/mcp/mcp.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/containers/kubernetes-mcp-server/pkg/output"
2020
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
2121
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/confluence"
22+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/prometheus"
2223
"github.com/containers/kubernetes-mcp-server/pkg/version"
2324
)
2425

@@ -36,13 +37,19 @@ func (c *Configuration) Toolsets() []api.Toolset {
3637
if c.toolsets == nil {
3738
for _, toolsetName := range c.StaticConfig.Toolsets {
3839
var toolset api.Toolset
40+
var err error
3941
if toolsetName == "confluence" {
40-
var err error
4142
toolset, err = confluence.NewToolset(c.Confluence)
4243
if err != nil {
4344
klog.Warningf("failed to initialize confluence toolset: %v", err)
4445
continue
4546
}
47+
} else if toolsetName == "prometheus" {
48+
toolset, err = prometheus.NewToolset(c.Prometheus)
49+
if err != nil {
50+
klog.Warningf("failed to initialize prometheus toolset: %v", err)
51+
continue
52+
}
4653
} else {
4754
toolset = toolsets.ToolsetFromString(toolsetName)
4855
}

pkg/mcp/mcp_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"runtime"
9+
"sync"
910
"testing"
1011
"time"
1112

@@ -56,8 +57,11 @@ func TestSseHeaders(t *testing.T) {
5657
c.clientOptions = append(c.clientOptions, client.WithHeaders(map[string]string{"kubernetes-authorization": "Bearer a-token-from-mcp-client"}))
5758
}
5859
pathHeaders := make(map[string]http.Header, 0)
60+
var pathHeadersMutex sync.Mutex
5961
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
62+
pathHeadersMutex.Lock()
6063
pathHeaders[req.URL.Path] = req.Header.Clone()
64+
pathHeadersMutex.Unlock()
6165
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
6266
if req.URL.Path == "/api" {
6367
w.Header().Set("Content-Type", "application/json")
@@ -93,6 +97,8 @@ func TestSseHeaders(t *testing.T) {
9397
testCaseWithContext(t, &mcpContext{before: before}, func(c *mcpContext) {
9498
_, _ = c.callTool("pods_list", map[string]interface{}{})
9599
t.Run("DiscoveryClient propagates headers to Kube API", func(t *testing.T) {
100+
pathHeadersMutex.Lock()
101+
defer pathHeadersMutex.Unlock()
96102
if len(pathHeaders) == 0 {
97103
t.Fatalf("No requests were made to Kube API")
98104
}
@@ -107,6 +113,8 @@ func TestSseHeaders(t *testing.T) {
107113
}
108114
})
109115
t.Run("DynamicClient propagates headers to Kube API", func(t *testing.T) {
116+
pathHeadersMutex.Lock()
117+
defer pathHeadersMutex.Unlock()
110118
if len(pathHeaders) == 0 {
111119
t.Fatalf("No requests were made to Kube API")
112120
}
@@ -116,6 +124,8 @@ func TestSseHeaders(t *testing.T) {
116124
})
117125
_, _ = c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-to-delete"})
118126
t.Run("kubernetes.Interface propagates headers to Kube API", func(t *testing.T) {
127+
pathHeadersMutex.Lock()
128+
defer pathHeadersMutex.Unlock()
119129
if len(pathHeaders) == 0 {
120130
t.Fatalf("No requests were made to Kube API")
121131
}

pkg/toolsets/prometheus/toolset.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package prometheus
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/google/jsonschema-go/jsonschema"
9+
p8s_api "github.com/prometheus/client_golang/api"
10+
"github.com/prometheus/client_golang/api/prometheus/v1"
11+
"k8s.io/utils/ptr"
12+
13+
"github.com/containers/kubernetes-mcp-server/pkg/api"
14+
"github.com/containers/kubernetes-mcp-server/pkg/config"
15+
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
16+
)
17+
18+
// NewToolset returns a new toolset for Prometheus.
19+
func NewToolset(cfg *config.PrometheusConfig) (api.Toolset, error) {
20+
if cfg == nil || cfg.URL == "" {
21+
return &disabledToolset{}, nil
22+
}
23+
24+
client, err := p8s_api.NewClient(p8s_api.Config{
25+
Address: cfg.URL,
26+
})
27+
if err != nil {
28+
return nil, fmt.Errorf("failed to create prometheus client: %w", err)
29+
}
30+
31+
return &prometheusToolset{
32+
api: v1.NewAPI(client),
33+
}, nil
34+
}
35+
36+
type prometheusToolset struct {
37+
api v1.API
38+
}
39+
40+
func (t *prometheusToolset) GetName() string {
41+
return "prometheus"
42+
}
43+
44+
func (t *prometheusToolset) GetDescription() string {
45+
return "Tools for interacting with Prometheus"
46+
}
47+
48+
func (t *prometheusToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool {
49+
return []api.ServerTool{
50+
{
51+
Tool: api.Tool{
52+
Name: "prometheus.runQuery",
53+
Description: "Run a PromQL query.",
54+
InputSchema: &jsonschema.Schema{
55+
Type: "object",
56+
Properties: map[string]*jsonschema.Schema{
57+
"query": {
58+
Type: "string",
59+
Description: "The PromQL query to run.",
60+
},
61+
},
62+
Required: []string{"query"},
63+
},
64+
Annotations: api.ToolAnnotations{
65+
ReadOnlyHint: ptr.To(true),
66+
},
67+
},
68+
Handler: runQueryHandler(t.api),
69+
},
70+
}
71+
}
72+
73+
func runQueryHandler(p8sAPI v1.API) api.ToolHandlerFunc {
74+
return func(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
75+
query, _ := params.GetArguments()["query"].(string)
76+
77+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
78+
defer cancel()
79+
80+
result, warnings, err := p8sAPI.Query(ctx, query, time.Now())
81+
if err != nil {
82+
return api.NewToolCallResult("", fmt.Errorf("failed to run query: %w", err)), nil
83+
}
84+
if len(warnings) > 0 {
85+
// Not treating warnings as errors for now
86+
}
87+
88+
return api.NewToolCallResult(result.String(), nil), nil
89+
}
90+
}
91+
92+
type disabledToolset struct{}
93+
94+
func (t *disabledToolset) GetName() string {
95+
return "prometheus"
96+
}
97+
98+
func (t *disabledToolset) GetDescription() string {
99+
return "Prometheus toolset is disabled. Please configure it in the server settings."
100+
}
101+
102+
func (t *disabledToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool {
103+
return nil
104+
}

0 commit comments

Comments
 (0)