From f5c35e1b8f0f9ff4114ea936f5810ef371a64c13 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Thu, 29 Jan 2026 15:58:20 +0000 Subject: [PATCH 1/8] experiment: Expose MCP as toolset Signed-off-by: Saswata Mukherjee --- go.mod | 68 ++-- go.sum | 204 ++++++----- pkg/toolset/config/config.go | 98 ++++++ pkg/toolset/tools/handlers.go | 466 +++++++++++++++++++++++++ pkg/toolset/tools/prometheus_client.go | 138 ++++++++ pkg/toolset/tools/tools.go | 290 +++++++++++++++ pkg/toolset/toolset.go | 78 +++++ 7 files changed, 1224 insertions(+), 118 deletions(-) create mode 100644 pkg/toolset/config/config.go create mode 100644 pkg/toolset/tools/handlers.go create mode 100644 pkg/toolset/tools/prometheus_client.go create mode 100644 pkg/toolset/tools/tools.go create mode 100644 pkg/toolset/toolset.go diff --git a/go.mod b/go.mod index 8465609..ecca58f 100644 --- a/go.mod +++ b/go.mod @@ -1,29 +1,35 @@ module github.com/rhobs/obs-mcp -go 1.24.6 +go 1.25.0 require ( + github.com/BurntSushi/toml v1.6.0 + github.com/containers/kubernetes-mcp-server v0.0.57 github.com/go-openapi/strfmt v0.25.0 - github.com/mark3labs/mcp-go v0.39.1 + github.com/google/jsonschema-go v0.4.2 + github.com/mark3labs/mcp-go v0.43.2 github.com/prometheus/alertmanager v0.30.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.67.4 github.com/prometheus/prometheus v0.307.3 - k8s.io/api v0.34.1 - k8s.io/apimachinery v0.34.1 - k8s.io/client-go v0.34.1 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 ) require ( - cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.24.1 // indirect @@ -47,55 +53,65 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect - github.com/oklog/ulid v1.3.1 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.13.0 // indirect google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/cli-runtime v0.35.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/metrics v0.35.0 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 88b0b3c..7c0b7ea 100644 --- a/go.sum +++ b/go.sum @@ -2,18 +2,26 @@ cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/compute/metadata v0.8.4 h1:oXMa1VMQBVCyewMIOm3WQsnVd9FbKBtm8reqWRaXnHQ= +cloud.google.com/go/compute/metadata v0.8.4/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0 h1:wL5IEG5zb7BVv1Kv0Xm92orq+5hB5Nipn3B5tn4Rqfk= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc= github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/config v1.32.3 h1:cpz7H2uMNTDa0h/5CYL5dLUEzPSLo2g0NkbxTRJtSSU= @@ -48,10 +56,17 @@ github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containers/kubernetes-mcp-server v0.0.57 h1:+30MpG+N/Zz/XYLCtnp39jZ5zYuYbor9C3/9U4YW1jw= +github.com/containers/kubernetes-mcp-server v0.0.57/go.mod h1:0hqFye3ZFRN3Y9Z6PdRe85VHrwdpKVhE7yEeovSU8gQ= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -66,6 +81,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -123,17 +140,19 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250923004556-9e5a51aed1e8 h1:ZI8gCoCjGzPsum4L21jHdQs8shFBIQih1TM9Rd/c+EQ= github.com/google/pprof v0.0.0-20250923004556-9e5a51aed1e8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -148,15 +167,16 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -165,18 +185,24 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.39.1 h1:2oPxk7aDbQhouakkYyKl2T4hKFU1c6FDaubWyGyVE1k= -github.com/mark3labs/mcp-go v0.39.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= @@ -187,14 +213,14 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= -github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= -github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -208,46 +234,53 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/prometheus v0.307.3 h1:zGIN3EpiKacbMatcUL2i6wC26eRWXdoXfNPjoBc2l34= github.com/prometheus/prometheus v0.307.3/go.mod h1:sPbNW+KTS7WmzFIafC3Inzb6oZVaGLnSvwqTdz2jxRQ= github.com/prometheus/sigv4 v0.3.0 h1:QIG7nTbu0JTnNidGI1Uwl5AGVIChWUACxn2B/BQ1kms= github.com/prometheus/sigv4 v0.3.0/go.mod h1:fKtFYDus2M43CWKMNtGvFNHGXnAJJEGZbiYCmVp/F8I= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -256,85 +289,72 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250808145144-a408d31f581a h1:Y+7uR/b1Mw2iSXZ3G//1haIiSElDQZ8KWh0h+sZPG90= golang.org/x/exp v0.0.0-20250808145144-a408d31f581a/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= google.golang.org/api v0.250.0 h1:qvkwrf/raASj82UegU2RSDGWi/89WkLckn4LuO4lVXM= google.golang.org/api v0.250.0/go.mod h1:Y9Uup8bDLJJtMzJyQnu+rLRJLA0wn+wTtc6vTlOvfXo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= -k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= -k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= -k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= -k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE= +k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/metrics v0.35.0 h1:xVFoqtAGm2dMNJAcB5TFZJPCen0uEqqNt52wW7ABbX8= +k8s.io/metrics v0.35.0/go.mod h1:g2Up4dcBygZi2kQSEQVDByFs+VUwepJMzzQLJJLpq4M= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/toolset/config/config.go b/pkg/toolset/config/config.go new file mode 100644 index 0000000..339836b --- /dev/null +++ b/pkg/toolset/config/config.go @@ -0,0 +1,98 @@ +package config + +import ( + "context" + "fmt" + + "github.com/BurntSushi/toml" + "github.com/containers/kubernetes-mcp-server/pkg/api" + serverconfig "github.com/containers/kubernetes-mcp-server/pkg/config" + + "github.com/rhobs/obs-mcp/pkg/prometheus" +) + +// Config holds obs-mcp toolset configuration +type Config struct { + // PrometheusURL is the URL of the Prometheus/Thanos Querier endpoint. + // This field is required. Example: "https://thanos-querier-openshift-monitoring.apps.example.com" + PrometheusURL string `toml:"prometheus_url,omitempty"` + + // Insecure controls whether to skip TLS certificate verification. + // Default: false (verify certificates) + Insecure bool `toml:"insecure,omitempty"` + + // Guardrails controls which query safety checks are enabled. + // Valid values: "all" (default), "none", or comma-separated list of: + // - "disallow-explicit-name-label" + // - "require-label-matcher" + // - "disallow-blanket-regex" + Guardrails string `toml:"guardrails,omitempty"` + + // MaxMetricCardinality is the maximum allowed series count per metric. + // Set to 0 to disable this check. + // Default: 20000 + MaxMetricCardinality uint64 `toml:"max_metric_cardinality,omitempty"` + + // MaxLabelCardinality is the maximum allowed label value count for blanket regex. + // Only takes effect if disallow-blanket-regex is enabled. + // Set to 0 to always disallow blanket regex. + // Default: 500 + MaxLabelCardinality uint64 `toml:"max_label_cardinality,omitempty"` +} + +var _ api.ExtendedConfig = (*Config)(nil) + +// Validate checks that the configuration values are valid. +func (c *Config) Validate() error { + // Validate guardrails configuration + if c.Guardrails != "" { + _, err := prometheus.ParseGuardrails(c.Guardrails) + if err != nil { + return fmt.Errorf("invalid guardrails configuration: %w", err) + } + } + + return nil +} + +// GetGuardrails returns the parsed guardrails configuration with cardinality limits applied. +func (c *Config) GetGuardrails() (*prometheus.Guardrails, error) { + guardrailsStr := c.Guardrails + if guardrailsStr == "" { + guardrailsStr = "all" // default + } + + guardrails, err := prometheus.ParseGuardrails(guardrailsStr) + if err != nil { + return nil, err + } + + if guardrails != nil { + // Apply cardinality limits + maxMetricCard := c.MaxMetricCardinality + if maxMetricCard == 0 { + maxMetricCard = 20000 // default + } + guardrails.MaxMetricCardinality = maxMetricCard + + maxLabelCard := c.MaxLabelCardinality + if maxLabelCard == 0 { + maxLabelCard = 500 // default + } + guardrails.MaxLabelCardinality = maxLabelCard + } + + return guardrails, nil +} + +func obsMCPToolsetParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (api.ExtendedConfig, error) { + var cfg Config + if err := md.PrimitiveDecode(primitive, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func init() { + serverconfig.RegisterToolsetConfig("obs-mcp", obsMCPToolsetParser) +} diff --git a/pkg/toolset/tools/handlers.go b/pkg/toolset/tools/handlers.go new file mode 100644 index 0000000..b3267fd --- /dev/null +++ b/pkg/toolset/tools/handlers.go @@ -0,0 +1,466 @@ +package tools + +import ( + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/prometheus/common/model" + + "github.com/rhobs/obs-mcp/pkg/prometheus" +) + +// Output types for tool results + +// ListMetricsOutput defines the output schema for the list_metrics tool. +type ListMetricsOutput struct { + Metrics []string `json:"metrics"` +} + +// InstantQueryOutput defines the output schema for the execute_instant_query tool. +type InstantQueryOutput struct { + ResultType string `json:"resultType"` + Result []InstantResult `json:"result"` + Warnings []string `json:"warnings,omitempty"` +} + +// InstantResult represents a single instant query result. +type InstantResult struct { + Metric map[string]string `json:"metric"` + Value []any `json:"value"` +} + +// RangeQueryOutput defines the output schema for the execute_range_query tool. +type RangeQueryOutput struct { + ResultType string `json:"resultType"` + Result []SeriesResult `json:"result"` + Warnings []string `json:"warnings,omitempty"` +} + +// SeriesResult represents a single time series result from a range query. +type SeriesResult struct { + Metric map[string]string `json:"metric"` + Values [][]any `json:"values"` +} + +// LabelNamesOutput defines the output schema for the get_label_names tool. +type LabelNamesOutput struct { + Labels []string `json:"labels"` +} + +// LabelValuesOutput defines the output schema for the get_label_values tool. +type LabelValuesOutput struct { + Values []string `json:"values"` +} + +// SeriesOutput defines the output schema for the get_series tool. +type SeriesOutput struct { + Series []map[string]string `json:"series"` + Cardinality int `json:"cardinality"` +} + +// Helper function to create error results +func errorResult(msg string) (*api.ToolCallResult, error) { + slog.Info("Query execution error: " + msg) + return api.NewToolCallResult("", fmt.Errorf("%s", msg)), nil +} + +// Helper function to get string argument with default +func getStringArg(params api.ToolHandlerParams, key string, defaultValue string) string { + if val, ok := params.GetArguments()[key].(string); ok && val != "" { + return val + } + return defaultValue +} + +// ListMetricsHandler handles the listing of available Prometheus metrics. +func ListMetricsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + slog.Info("ListMetricsHandler called") + + promClient, err := getPromClient(params) + if err != nil { + return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) + } + + metrics, err := promClient.ListMetrics(params.Context) + if err != nil { + return errorResult(fmt.Sprintf("failed to list metrics: %s", err.Error())) + } + + slog.Info("ListMetricsHandler executed successfully", "resultLength", len(metrics)) + slog.Debug("ListMetricsHandler results", "results", metrics) + + output := ListMetricsOutput{Metrics: metrics} + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal metrics: %s", err.Error())) + } + + return api.NewToolCallResult(string(result), nil), nil +} + +// ExecuteInstantQueryHandler handles the execution of Prometheus instant queries. +func ExecuteInstantQueryHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + slog.Info("ExecuteInstantQueryHandler called") + + promClient, err := getPromClient(params) + if err != nil { + return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) + } + + // Get required query parameter + query, ok := params.GetArguments()["query"].(string) + if !ok || query == "" { + return errorResult("query parameter is required and must be a string") + } + + // Get optional time parameter + timeStr := getStringArg(params, "time", "") + + var queryTime time.Time + if timeStr == "" { + queryTime = time.Now() + } else { + queryTime, err = prometheus.ParseTimestamp(timeStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid time format: %s", err.Error())) + } + } + + // Execute the instant query + result, err := promClient.ExecuteInstantQuery(params.Context, query, queryTime) + if err != nil { + return errorResult(fmt.Sprintf("failed to execute instant query: %s", err.Error())) + } + + // Convert to structured output + output := InstantQueryOutput{ + ResultType: fmt.Sprintf("%v", result["resultType"]), + } + + resVector, ok := result["result"].(model.Vector) + if ok { + slog.Info("ExecuteInstantQueryHandler executed successfully", "resultLength", len(resVector)) + slog.Debug("ExecuteInstantQueryHandler results", "results", resVector) + + output.Result = make([]InstantResult, len(resVector)) + for i, sample := range resVector { + labels := make(map[string]string) + for k, v := range sample.Metric { + labels[string(k)] = string(v) + } + output.Result[i] = InstantResult{ + Metric: labels, + Value: []any{float64(sample.Timestamp) / 1000, sample.Value.String()}, + } + } + } else { + slog.Info("ExecuteInstantQueryHandler executed successfully (unknown format)", "result", result) + } + + if warnings, ok := result["warnings"].([]string); ok { + output.Warnings = warnings + } + + jsonResult, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal result: %s", err.Error())) + } + + return api.NewToolCallResult(string(jsonResult), nil), nil +} + +// ExecuteRangeQueryHandler handles the execution of Prometheus range queries. +func ExecuteRangeQueryHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + slog.Info("ExecuteRangeQueryHandler called") + + promClient, err := getPromClient(params) + if err != nil { + return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) + } + + // Get required query parameter + query, ok := params.GetArguments()["query"].(string) + if !ok || query == "" { + return errorResult("query parameter is required and must be a string") + } + + // Get required step parameter + step, ok := params.GetArguments()["step"].(string) + if !ok || step == "" { + return errorResult("step parameter is required and must be a string") + } + + // Parse step duration + stepDuration, err := model.ParseDuration(step) + if err != nil { + return errorResult(fmt.Sprintf("invalid step format: %s", err.Error())) + } + + // Get optional parameters + startStr := getStringArg(params, "start", "") + endStr := getStringArg(params, "end", "") + durationStr := getStringArg(params, "duration", "") + + // Validate parameter combinations + if startStr != "" && endStr != "" && durationStr != "" { + return errorResult("cannot specify both start/end and duration parameters") + } + + if (startStr != "" && endStr == "") || (startStr == "" && endStr != "") { + return errorResult("both start and end must be provided together") + } + + var startTime, endTime time.Time + + // Handle duration-based query (default to 1h if nothing specified) + if durationStr != "" || (startStr == "" && endStr == "") { + if durationStr == "" { + durationStr = "1h" + } + + duration, err := model.ParseDuration(durationStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid duration format: %s", err.Error())) + } + + endTime = time.Now() + startTime = endTime.Add(-time.Duration(duration)) + } else { + // Handle explicit start/end times + startTime, err = prometheus.ParseTimestamp(startStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) + } + + endTime, err = prometheus.ParseTimestamp(endStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) + } + } + + // Execute the range query + result, err := promClient.ExecuteRangeQuery(params.Context, query, startTime, endTime, time.Duration(stepDuration)) + if err != nil { + return errorResult(fmt.Sprintf("failed to execute range query: %s", err.Error())) + } + + // Convert to structured output + output := RangeQueryOutput{ + ResultType: fmt.Sprintf("%v", result["resultType"]), + } + + resMatrix, ok := result["result"].(model.Matrix) + if ok { + slog.Info("ExecuteRangeQueryHandler executed successfully", "resultLength", resMatrix.Len()) + slog.Debug("ExecuteRangeQueryHandler results", "results", resMatrix) + + output.Result = make([]SeriesResult, len(resMatrix)) + for i, series := range resMatrix { + labels := make(map[string]string) + for k, v := range series.Metric { + labels[string(k)] = string(v) + } + values := make([][]any, len(series.Values)) + for j, sample := range series.Values { + values[j] = []any{float64(sample.Timestamp) / 1000, sample.Value.String()} + } + output.Result[i] = SeriesResult{ + Metric: labels, + Values: values, + } + } + } else { + slog.Info("ExecuteRangeQueryHandler executed successfully (unknown format)", "result", result) + } + + if warnings, ok := result["warnings"].([]string); ok { + output.Warnings = warnings + } + + // Convert to JSON for fallback text + jsonResult, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal result: %s", err.Error())) + } + + return api.NewToolCallResult(string(jsonResult), nil), nil +} + +// GetLabelNamesHandler handles the retrieval of label names. +func GetLabelNamesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + slog.Info("GetLabelNamesHandler called") + + promClient, err := getPromClient(params) + if err != nil { + return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) + } + + // Get optional parameters + metric := getStringArg(params, "metric", "") + startStr := getStringArg(params, "start", "") + endStr := getStringArg(params, "end", "") + + // Default to last hour if not specified + var startTime, endTime time.Time + if startStr == "" && endStr == "" { + endTime = time.Now() + startTime = endTime.Add(-prometheus.ListMetricsTimeRange) + } else { + if startStr != "" { + startTime, err = prometheus.ParseTimestamp(startStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) + } + } + if endStr != "" { + endTime, err = prometheus.ParseTimestamp(endStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) + } + } + } + + // Get label names + labels, err := promClient.GetLabelNames(params.Context, metric, startTime, endTime) + if err != nil { + return errorResult(fmt.Sprintf("failed to get label names: %s", err.Error())) + } + + output := LabelNamesOutput{Labels: labels} + + slog.Info("GetLabelNamesHandler executed successfully", "labelCount", len(labels)) + slog.Debug("GetLabelNamesHandler results", "results", labels) + + jsonResult, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal label names: %s", err.Error())) + } + + return api.NewToolCallResult(string(jsonResult), nil), nil +} + +// GetLabelValuesHandler handles the retrieval of label values. +func GetLabelValuesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + slog.Info("GetLabelValuesHandler called") + + promClient, err := getPromClient(params) + if err != nil { + return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) + } + + // Get required label parameter + label, ok := params.GetArguments()["label"].(string) + if !ok || label == "" { + return errorResult("label parameter is required and must be a string") + } + + // Get optional parameters + metric := getStringArg(params, "metric", "") + startStr := getStringArg(params, "start", "") + endStr := getStringArg(params, "end", "") + + // Default to last hour if not specified + var startTime, endTime time.Time + if startStr == "" && endStr == "" { + endTime = time.Now() + startTime = endTime.Add(-prometheus.ListMetricsTimeRange) + } else { + if startStr != "" { + startTime, err = prometheus.ParseTimestamp(startStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) + } + } + if endStr != "" { + endTime, err = prometheus.ParseTimestamp(endStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) + } + } + } + + // Get label values + values, err := promClient.GetLabelValues(params.Context, label, metric, startTime, endTime) + if err != nil { + return errorResult(fmt.Sprintf("failed to get label values: %s", err.Error())) + } + + output := LabelValuesOutput{Values: values} + + slog.Info("GetLabelValuesHandler executed successfully", "valueCount", len(values)) + slog.Debug("GetLabelValuesHandler results", "results", values) + + jsonResult, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal label values: %s", err.Error())) + } + + return api.NewToolCallResult(string(jsonResult), nil), nil +} + +// GetSeriesHandler handles the retrieval of time series. +func GetSeriesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + slog.Info("GetSeriesHandler called") + + promClient, err := getPromClient(params) + if err != nil { + return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) + } + + // Get required matches parameter + matchesStr, ok := params.GetArguments()["matches"].(string) + if !ok || matchesStr == "" { + return errorResult("matches parameter is required and must be a string") + } + + // Parse matches - could be comma-separated + matches := []string{matchesStr} + + // Get optional parameters + startStr := getStringArg(params, "start", "") + endStr := getStringArg(params, "end", "") + + // Default to last hour if not specified + var startTime, endTime time.Time + if startStr == "" && endStr == "" { + endTime = time.Now() + startTime = endTime.Add(-prometheus.ListMetricsTimeRange) + } else { + if startStr != "" { + startTime, err = prometheus.ParseTimestamp(startStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) + } + } + if endStr != "" { + endTime, err = prometheus.ParseTimestamp(endStr) + if err != nil { + return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) + } + } + } + + // Get series + series, err := promClient.GetSeries(params.Context, matches, startTime, endTime) + if err != nil { + return errorResult(fmt.Sprintf("failed to get series: %s", err.Error())) + } + + output := SeriesOutput{ + Series: series, + Cardinality: len(series), + } + + slog.Info("GetSeriesHandler executed successfully", "cardinality", len(series)) + slog.Debug("GetSeriesHandler results", "results", series) + + jsonResult, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal series: %s", err.Error())) + } + + return api.NewToolCallResult(string(jsonResult), nil), nil +} diff --git a/pkg/toolset/tools/prometheus_client.go b/pkg/toolset/tools/prometheus_client.go new file mode 100644 index 0000000..adc9f7a --- /dev/null +++ b/pkg/toolset/tools/prometheus_client.go @@ -0,0 +1,138 @@ +package tools + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + promapi "github.com/prometheus/client_golang/api" + promcfg "github.com/prometheus/common/config" + "k8s.io/client-go/rest" + + "github.com/rhobs/obs-mcp/pkg/prometheus" + toolsetconfig "github.com/rhobs/obs-mcp/pkg/toolset/config" +) + +const ( + defaultServiceAccountCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt" + defaultPrometheusURL = "http://localhost:9090" +) + +// getConfig retrieves the obs-mcp toolset configuration from params. +func getConfig(params api.ToolHandlerParams) *toolsetconfig.Config { + if cfg, ok := params.GetToolsetConfig("obs-mcp"); ok { + if obsCfg, ok := cfg.(*toolsetconfig.Config); ok { + return obsCfg + } + } + // Return default config if not found + return &toolsetconfig.Config{} +} + +// getPromClient creates a Prometheus client using the toolset configuration. +func getPromClient(params api.ToolHandlerParams) (prometheus.Loader, error) { + cfg := getConfig(params) + + // Get metrics backend URL from config, fallback to default + metricsBackendURL := cfg.PrometheusURL + if metricsBackendURL == "" { + metricsBackendURL = defaultPrometheusURL + slog.Info("No prometheus_url configured, using default", "url", defaultPrometheusURL) + } + + // Get guardrails configuration + guardrails, err := cfg.GetGuardrails() + if err != nil { + slog.Warn("Failed to parse guardrails configuration", "err", err) + } + + // Create API config using the REST config from params + apiConfig, err := createAPIConfigFromRESTConfig(params, metricsBackendURL, cfg.Insecure) + if err != nil { + return nil, fmt.Errorf("failed to create API config: %w", err) + } + + // Create Prometheus client + promClient, err := prometheus.NewPrometheusClient(apiConfig) + if err != nil { + return nil, fmt.Errorf("failed to create Prometheus client: %w", err) + } + + promClient.WithGuardrails(guardrails) + + return promClient, nil +} + +// createAPIConfigFromRESTConfig creates a Prometheus API config from Kubernetes REST config. +func createAPIConfigFromRESTConfig(params api.ToolHandlerParams, prometheusURL string, insecure bool) (promapi.Config, error) { + restConfig := params.RESTConfig() + if restConfig == nil { + return promapi.Config{}, fmt.Errorf("no REST config available") + } + + // For routes/ingresses, we need to configure TLS appropriately + tlsConfig := rest.TLSClientConfig{Insecure: insecure} + restConfig.TLSClientConfig = tlsConfig + + // Create HTTP client with Kubernetes authentication + rt, err := rest.TransportFor(restConfig) + if err != nil { + return promapi.Config{}, fmt.Errorf("failed to create transport from REST config: %w", err) + } + + return promapi.Config{ + Address: prometheusURL, + RoundTripper: rt, + }, nil +} + +// createAPIConfigWithToken creates a Prometheus API config with a bearer token. +func createAPIConfigWithToken(prometheusURL, token string, insecure bool) (promapi.Config, error) { + apiConfig := promapi.Config{ + Address: prometheusURL, + } + + useTLS := strings.HasPrefix(prometheusURL, "https://") + if useTLS { + defaultRt := promapi.DefaultRoundTripper.(*http.Transport) + + if insecure { + defaultRt.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } else { + certs, err := createCertPool() + if err != nil { + return promapi.Config{}, err + } + defaultRt.TLSClientConfig = &tls.Config{RootCAs: certs} + } + + if token != "" { + apiConfig.RoundTripper = promcfg.NewAuthorizationCredentialsRoundTripper( + "Bearer", promcfg.NewInlineSecret(token), defaultRt) + } else { + apiConfig.RoundTripper = defaultRt + } + } else { + slog.Warn("Connecting to Prometheus without TLS") + } + + return apiConfig, nil +} + +// createCertPool creates a certificate pool from the service account CA. +func createCertPool() (*x509.CertPool, error) { + certs := x509.NewCertPool() + + pemData, err := os.ReadFile(defaultServiceAccountCAPath) + if err != nil { + slog.Error("Failed to read the CA certificate", "err", err) + return nil, err + } + certs.AppendCertsFromPEM(pemData) + return certs, nil +} diff --git a/pkg/toolset/tools/tools.go b/pkg/toolset/tools/tools.go new file mode 100644 index 0000000..7cbef70 --- /dev/null +++ b/pkg/toolset/tools/tools.go @@ -0,0 +1,290 @@ +package tools + +import ( + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +// InitListMetrics creates the list_metrics tool. +func InitListMetrics() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "list_metrics", + Description: `MANDATORY FIRST STEP: List all available metric names in Prometheus. + +YOU MUST CALL THIS TOOL BEFORE ANY OTHER QUERY TOOL + +This tool MUST be called first for EVERY observability question to: +1. Discover what metrics actually exist in this environment +2. Find the EXACT metric name to use in queries +3. Avoid querying non-existent metrics + +NEVER skip this step. NEVER guess metric names. Metric names vary between environments. + +After calling this tool: +1. Search the returned list for relevant metrics +2. Use the EXACT metric name found in subsequent queries +3. If no relevant metric exists, inform the user`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + }, + Annotations: api.ToolAnnotations{ + Title: "List Available Metrics", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: ListMetricsHandler, + }, + } +} + +// InitExecuteInstantQuery creates the execute_instant_query tool. +func InitExecuteInstantQuery() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "execute_instant_query", + Description: `Execute a PromQL instant query to get current/point-in-time values. + +PREREQUISITE: You MUST call list_metrics first to verify the metric exists + +WHEN TO USE: +- Current state questions: "What is the current error rate?" +- Point-in-time snapshots: "How many pods are running?" +- Latest values: "Which pods are in Pending state?" + +The 'query' parameter MUST use metric names that were returned by list_metrics.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": { + Type: "string", + Description: "PromQL query string using metric names verified via list_metrics", + }, + "time": { + Type: "string", + Description: "Evaluation time as RFC3339 or Unix timestamp. Omit or use 'NOW' for current time.", + }, + }, + Required: []string{"query"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Execute Instant Query", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: ExecuteInstantQueryHandler, + }, + } +} + +// InitExecuteRangeQuery creates the execute_range_query tool. +func InitExecuteRangeQuery() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "execute_range_query", + Description: `Execute a PromQL range query to get time-series data over a period. + +PREREQUISITE: You MUST call list_metrics first to verify the metric exists + +WHEN TO USE: +- Trends over time: "What was CPU usage over the last hour?" +- Rate calculations: "How many requests per second?" +- Historical analysis: "Were there any restarts in the last 5 minutes?" + +TIME PARAMETERS: +- 'duration': Look back from now (e.g., "5m", "1h", "24h") +- 'step': Data point resolution (e.g., "1m" for 1-hour duration, "5m" for 24-hour duration) + +The 'query' parameter MUST use metric names that were returned by list_metrics.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": { + Type: "string", + Description: "PromQL query string using metric names verified via list_metrics", + }, + "step": { + Type: "string", + Description: "Query resolution step width (e.g., '15s', '1m', '1h'). Choose based on time range: shorter ranges use smaller steps.", + Pattern: `^\d+[smhdwy]$`, + }, + "start": { + Type: "string", + Description: "Start time as RFC3339 or Unix timestamp (optional)", + }, + "end": { + Type: "string", + Description: "End time as RFC3339 or Unix timestamp (optional). Use `NOW` for current time.", + }, + "duration": { + Type: "string", + Description: "Duration to look back from now (e.g., '1h', '30m', '1d', '2w') (optional)", + Pattern: `^\d+[smhdwy]$`, + }, + }, + Required: []string{"query", "step"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Execute Range Query", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: ExecuteRangeQueryHandler, + }, + } +} + +// InitGetLabelNames creates the get_label_names tool. +func InitGetLabelNames() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "get_label_names", + Description: `Get all label names (dimensions) available for filtering a metric. + +WHEN TO USE (after calling list_metrics): +- To discover how to filter metrics (by namespace, pod, service, etc.) +- Before constructing label matchers in PromQL queries + +The 'metric' parameter should use a metric name from list_metrics output.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "metric": { + Type: "string", + Description: "Metric name (from list_metrics) to get label names for. Leave empty for all metrics.", + }, + "start": { + Type: "string", + Description: "Start time for label discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", + }, + "end": { + Type: "string", + Description: "End time for label discovery as RFC3339 or Unix timestamp (optional, defaults to now)", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Get Label Names", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: GetLabelNamesHandler, + }, + } +} + +// InitGetLabelValues creates the get_label_values tool. +func InitGetLabelValues() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "get_label_values", + Description: `Get all unique values for a specific label. + +WHEN TO USE (after calling list_metrics and get_label_names): +- To find exact label values for filtering (namespace names, pod names, etc.) +- To see what values exist before constructing queries + +The 'metric' parameter should use a metric name from list_metrics output.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "label": { + Type: "string", + Description: "Label name (from get_label_names) to get values for", + }, + "metric": { + Type: "string", + Description: "Metric name (from list_metrics) to scope the label values to. Leave empty for all metrics.", + }, + "start": { + Type: "string", + Description: "Start time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", + }, + "end": { + Type: "string", + Description: "End time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to now)", + }, + }, + Required: []string{"label"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Get Label Values", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: GetLabelValuesHandler, + }, + } +} + +// InitGetSeries creates the get_series tool. +func InitGetSeries() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "get_series", + Description: `Get time series matching selectors and preview cardinality. + +WHEN TO USE (optional, after calling list_metrics): +- To verify label filters match expected series before querying +- To check cardinality and avoid slow queries + +CARDINALITY GUIDANCE: +- <100 series: Safe +- 100-1000: Usually fine +- >1000: Add more label filters + +The selector should use metric names from list_metrics output.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "matches": { + Type: "string", + Description: "PromQL series selector using metric names from list_metrics", + }, + "start": { + Type: "string", + Description: "Start time for series discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", + }, + "end": { + Type: "string", + Description: "End time for series discovery as RFC3339 or Unix timestamp (optional, defaults to now)", + }, + }, + Required: []string{"matches"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Get Series", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: GetSeriesHandler, + }, + } +} diff --git a/pkg/toolset/toolset.go b/pkg/toolset/toolset.go new file mode 100644 index 0000000..ebff73a --- /dev/null +++ b/pkg/toolset/toolset.go @@ -0,0 +1,78 @@ +package toolset + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" + "github.com/rhobs/obs-mcp/pkg/toolset/tools" +) + +// Toolset implements the observability toolset for advanced Prometheus monitoring. +type Toolset struct{} + +var _ api.Toolset = (*Toolset)(nil) + +// GetName returns the name of the toolset. +func (t *Toolset) GetName() string { + return "obs-mcp" +} + +// GetDescription returns a human-readable description of the toolset. +func (t *Toolset) GetDescription() string { + return `Advanced observability tools for comprehensive Prometheus metrics querying with guardrails and discovery features. + +## MANDATORY WORKFLOW - ALWAYS FOLLOW THIS ORDER + +**STEP 1: ALWAYS call list_metrics FIRST** +- This is NON-NEGOTIABLE for EVERY question +- NEVER skip this step, even if you think you know the metric name +- NEVER guess metric names - they vary between environments +- Search the returned list to find the exact metric name that exists + +**STEP 2: Call get_label_names for the metric you found** +- Discover available labels for filtering (namespace, pod, service, etc.) + +**STEP 3: Call get_label_values if you need specific filter values** +- Find exact label values (e.g., actual namespace names, pod names) + +**STEP 4: Execute your query using the EXACT metric name from Step 1** +- Use execute_instant_query for current state questions +- Use execute_range_query for trends/historical analysis + +## CRITICAL RULES + +1. **NEVER query a metric without first calling list_metrics** - You must verify the metric exists +2. **Use EXACT metric names from list_metrics output** - Do not modify or guess metric names +3. **If list_metrics doesn't return a relevant metric, tell the user** - Don't fabricate queries +4. **BE PROACTIVE** - Complete all steps automatically without asking for confirmation. When you find a relevant metric, proceed to query. +5. **UNDERSTAND TIME FRAMES** - Use the start and end parameters to specify the time frame for your queries. You can use NOW for current time liberally across parameters, and NOW±duration for relative time frames. + +## Query Type Selection + +- **execute_instant_query**: Current values, point-in-time snapshots, "right now" questions +- **execute_range_query**: Trends over time, rate calculations, historical analysis` +} + +// GetTools returns all tools provided by this toolset. +func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { + return slices.Concat( + tools.InitListMetrics(), + tools.InitExecuteInstantQuery(), + tools.InitExecuteRangeQuery(), + tools.InitGetLabelNames(), + tools.InitGetLabelValues(), + tools.InitGetSeries(), + ) +} + +// GetPrompts returns prompts provided by this toolset. +func (t *Toolset) GetPrompts() []api.ServerPrompt { + // Currently, prompts are not supported through this toolset + // The workflow instructions are embedded in the tool descriptions + return nil +} + +func init() { + toolsets.Register(&Toolset{}) +} From 86a97a5fac740eab684d62b05a3614ed5b668826 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Wed, 4 Feb 2026 08:12:00 +0000 Subject: [PATCH 2/8] Add AM tools to toolset Signed-off-by: Saswata Mukherjee --- pkg/toolset/config/config.go | 4 + pkg/toolset/tools/handlers.go | 255 +++++++++++++++++++++++++ pkg/toolset/tools/prometheus_client.go | 37 ++++ pkg/toolset/tools/tools.go | 106 ++++++++++ pkg/toolset/toolset.go | 2 + 5 files changed, 404 insertions(+) diff --git a/pkg/toolset/config/config.go b/pkg/toolset/config/config.go index 339836b..355f96d 100644 --- a/pkg/toolset/config/config.go +++ b/pkg/toolset/config/config.go @@ -17,6 +17,10 @@ type Config struct { // This field is required. Example: "https://thanos-querier-openshift-monitoring.apps.example.com" PrometheusURL string `toml:"prometheus_url,omitempty"` + // AlertmanagerURL is the URL of the Alertmanager endpoint. + // This field is optional. Example: "https://alertmanager-main-openshift-monitoring.apps.example.com" + AlertmanagerURL string `toml:"alertmanager_url,omitempty"` + // Insecure controls whether to skip TLS certificate verification. // Default: false (verify certificates) Insecure bool `toml:"insecure,omitempty"` diff --git a/pkg/toolset/tools/handlers.go b/pkg/toolset/tools/handlers.go index b3267fd..bc918a2 100644 --- a/pkg/toolset/tools/handlers.go +++ b/pkg/toolset/tools/handlers.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "log/slog" + "maps" + "strings" "time" "github.com/containers/kubernetes-mcp-server/pkg/api" @@ -61,6 +63,56 @@ type SeriesOutput struct { Cardinality int `json:"cardinality"` } +// AlertsOutput defines the output schema for the get_alerts tool. +type AlertsOutput struct { + Alerts []Alert `json:"alerts"` +} + +// Alert represents a single alert from Alertmanager. +type Alert struct { + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt,omitempty"` + Status AlertStatus `json:"status"` +} + +// AlertStatus represents the status of an alert. +type AlertStatus struct { + State string `json:"state"` + SilencedBy []string `json:"silencedBy,omitempty"` + InhibitedBy []string `json:"inhibitedBy,omitempty"` +} + +// SilencesOutput defines the output schema for the get_silences tool. +type SilencesOutput struct { + Silences []Silence `json:"silences"` +} + +// Silence represents a single silence from Alertmanager. +type Silence struct { + ID string `json:"id"` + Status SilenceStatus `json:"status"` + Matchers []Matcher `json:"matchers"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + CreatedBy string `json:"createdBy"` + Comment string `json:"comment"` +} + +// SilenceStatus represents the status of a silence. +type SilenceStatus struct { + State string `json:"state"` +} + +// Matcher represents a label matcher for a silence. +type Matcher struct { + Name string `json:"name"` + Value string `json:"value"` + IsRegex bool `json:"isRegex"` + IsEqual bool `json:"isEqual"` +} + // Helper function to create error results func errorResult(msg string) (*api.ToolCallResult, error) { slog.Info("Query execution error: " + msg) @@ -464,3 +516,206 @@ func GetSeriesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) return api.NewToolCallResult(string(jsonResult), nil), nil } + +// GetAlertsHandler handles the retrieval of alerts from Alertmanager. +func GetAlertsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + slog.Info("GetAlertsHandler called") + + amClient, err := getAlertmanagerClient(params) + if err != nil { + return errorResult(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())) + } + + // Get optional boolean parameters + var active, silenced, inhibited, unprocessed *bool + if activeVal, ok := params.GetArguments()["active"].(bool); ok { + active = &activeVal + } + if silencedVal, ok := params.GetArguments()["silenced"].(bool); ok { + silenced = &silencedVal + } + if inhibitedVal, ok := params.GetArguments()["inhibited"].(bool); ok { + inhibited = &inhibitedVal + } + if unprocessedVal, ok := params.GetArguments()["unprocessed"].(bool); ok { + unprocessed = &unprocessedVal + } + + // Get optional string parameters + filterStr := getStringArg(params, "filter", "") + receiver := getStringArg(params, "receiver", "") + var filter []string + if filterStr != "" { + // Split by comma if multiple filters are provided + filter = strings.Split(filterStr, ",") + for i := range filter { + filter[i] = strings.TrimSpace(filter[i]) + } + } + + alerts, err := amClient.GetAlerts(params.Context, active, silenced, inhibited, unprocessed, filter, receiver) + if err != nil { + return errorResult(fmt.Sprintf("failed to get alerts: %s", err.Error())) + } + + // Convert to output format + output := AlertsOutput{ + Alerts: make([]Alert, len(alerts)), + } + + for i, alert := range alerts { + labels := make(map[string]string) + maps.Copy(labels, alert.Labels) + + annotations := make(map[string]string) + maps.Copy(annotations, alert.Annotations) + + var silencedBy, inhibitedBy []string + var state string + if alert.Status != nil { + if alert.Status.SilencedBy != nil { + silencedBy = alert.Status.SilencedBy + } + if alert.Status.InhibitedBy != nil { + inhibitedBy = alert.Status.InhibitedBy + } + if alert.Status.State != nil { + state = *alert.Status.State + } + } + if silencedBy == nil { + silencedBy = []string{} + } + if inhibitedBy == nil { + inhibitedBy = []string{} + } + + var startsAt, endsAt string + if alert.StartsAt != nil { + startsAt = alert.StartsAt.String() + } + if alert.EndsAt != nil { + endsAt = alert.EndsAt.String() + } + + output.Alerts[i] = Alert{ + Labels: labels, + Annotations: annotations, + StartsAt: startsAt, + EndsAt: endsAt, + Status: AlertStatus{ + State: state, + SilencedBy: silencedBy, + InhibitedBy: inhibitedBy, + }, + } + } + + slog.Info("GetAlertsHandler executed successfully", "alertCount", len(alerts)) + slog.Debug("GetAlertsHandler results", "results", output.Alerts) + + jsonResult, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal alerts: %s", err.Error())) + } + + return api.NewToolCallResult(string(jsonResult), nil), nil +} + +// GetSilencesHandler handles the retrieval of silences from Alertmanager. +func GetSilencesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + slog.Info("GetSilencesHandler called") + + amClient, err := getAlertmanagerClient(params) + if err != nil { + return errorResult(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())) + } + + filterStr := getStringArg(params, "filter", "") + var filter []string + if filterStr != "" { + // Split by comma if multiple filters are provided + filter = strings.Split(filterStr, ",") + for i := range filter { + filter[i] = strings.TrimSpace(filter[i]) + } + } + + silences, err := amClient.GetSilences(params.Context, filter) + if err != nil { + return errorResult(fmt.Sprintf("failed to get silences: %s", err.Error())) + } + + output := SilencesOutput{ + Silences: make([]Silence, len(silences)), + } + + for i, silence := range silences { + matchers := make([]Matcher, len(silence.Matchers)) + for j, m := range silence.Matchers { + isEqual := true + if m.IsEqual != nil { + isEqual = *m.IsEqual + } + var name, value string + var isRegex bool + if m.Name != nil { + name = *m.Name + } + if m.Value != nil { + value = *m.Value + } + if m.IsRegex != nil { + isRegex = *m.IsRegex + } + matchers[j] = Matcher{ + Name: name, + Value: value, + IsRegex: isRegex, + IsEqual: isEqual, + } + } + + var id, state, createdBy, comment, startsAt, endsAt string + if silence.ID != nil { + id = *silence.ID + } + if silence.Status != nil && silence.Status.State != nil { + state = *silence.Status.State + } + if silence.StartsAt != nil { + startsAt = silence.StartsAt.String() + } + if silence.EndsAt != nil { + endsAt = silence.EndsAt.String() + } + if silence.CreatedBy != nil { + createdBy = *silence.CreatedBy + } + if silence.Comment != nil { + comment = *silence.Comment + } + + output.Silences[i] = Silence{ + ID: id, + Status: SilenceStatus{ + State: state, + }, + Matchers: matchers, + StartsAt: startsAt, + EndsAt: endsAt, + CreatedBy: createdBy, + Comment: comment, + } + } + + slog.Info("GetSilencesHandler executed successfully", "silenceCount", len(silences)) + slog.Debug("GetSilencesHandler results", "results", output.Silences) + + jsonResult, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal silences: %s", err.Error())) + } + + return api.NewToolCallResult(string(jsonResult), nil), nil +} diff --git a/pkg/toolset/tools/prometheus_client.go b/pkg/toolset/tools/prometheus_client.go index adc9f7a..1c9ecba 100644 --- a/pkg/toolset/tools/prometheus_client.go +++ b/pkg/toolset/tools/prometheus_client.go @@ -14,6 +14,7 @@ import ( promcfg "github.com/prometheus/common/config" "k8s.io/client-go/rest" + "github.com/rhobs/obs-mcp/pkg/alertmanager" "github.com/rhobs/obs-mcp/pkg/prometheus" toolsetconfig "github.com/rhobs/obs-mcp/pkg/toolset/config" ) @@ -136,3 +137,39 @@ func createCertPool() (*x509.CertPool, error) { certs.AppendCertsFromPEM(pemData) return certs, nil } + +// getAlertmanagerClient creates an Alertmanager client using the toolset configuration. +func getAlertmanagerClient(params api.ToolHandlerParams) (alertmanager.Loader, error) { + cfg := getConfig(params) + + alertmanagerURL := cfg.AlertmanagerURL + if alertmanagerURL == "" { + return nil, fmt.Errorf("alertmanager_url not configured") + } + + restConfig := params.RESTConfig() + if restConfig == nil { + return nil, fmt.Errorf("no REST config available") + } + + tlsConfig := rest.TLSClientConfig{Insecure: cfg.Insecure} + restConfig.TLSClientConfig = tlsConfig + + rt, err := rest.TransportFor(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create transport from REST config: %w", err) + } + + apiConfig := promapi.Config{ + Address: alertmanagerURL, + RoundTripper: rt, + } + + // Create Alertmanager client + amClient, err := alertmanager.NewAlertmanagerClient(apiConfig) + if err != nil { + return nil, fmt.Errorf("failed to create Alertmanager client: %w", err) + } + + return amClient, nil +} diff --git a/pkg/toolset/tools/tools.go b/pkg/toolset/tools/tools.go index 7cbef70..6b07570 100644 --- a/pkg/toolset/tools/tools.go +++ b/pkg/toolset/tools/tools.go @@ -288,3 +288,109 @@ The selector should use metric names from list_metrics output.`, }, } } + +// InitGetAlerts creates the get_alerts tool. +func InitGetAlerts() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "get_alerts", + Description: `Get alerts from Alertmanager. + +WHEN TO USE: +- START HERE when investigating issues: if the user asks about things breaking, errors, failures, outages, services being down, or anything going wrong in the cluster +- When the user mentions a specific alert name - use this tool to get the alert's full labels (namespace, pod, service, etc.) which are essential for further investigation with other tools +- To see currently firing alerts in the cluster +- To check which alerts are active, silenced, or inhibited +- To understand what's happening before diving into metrics or logs + +INVESTIGATION TIP: Alert labels often contain the exact identifiers (pod names, namespaces, job names) needed for targeted queries with prometheus tools. + +FILTERING: +- Use 'active' to filter for only active alerts (not resolved) +- Use 'silenced' to filter for silenced alerts +- Use 'inhibited' to filter for inhibited alerts +- Use 'filter' to apply label matchers (e.g., "alertname=HighCPU") +- Use 'receiver' to filter alerts by receiver name + +All filter parameters are optional. Without filters, all alerts are returned.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "active": { + Type: "boolean", + Description: "Filter for active alerts only (true/false, optional)", + }, + "silenced": { + Type: "boolean", + Description: "Filter for silenced alerts only (true/false, optional)", + }, + "inhibited": { + Type: "boolean", + Description: "Filter for inhibited alerts only (true/false, optional)", + }, + "unprocessed": { + Type: "boolean", + Description: "Filter for unprocessed alerts only (true/false, optional)", + }, + "filter": { + Type: "string", + Description: "Label matchers to filter alerts (e.g., 'alertname=HighCPU', optional)", + }, + "receiver": { + Type: "string", + Description: "Receiver name to filter alerts (optional)", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Get Alerts", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: GetAlertsHandler, + }, + } +} + +// InitGetSilences creates the get_silences tool. +func InitGetSilences() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "get_silences", + Description: `Get silences from Alertmanager. + +WHEN TO USE: +- To see which alerts are currently silenced +- To check active, pending, or expired silences +- To investigate why certain alerts are not firing notifications + +FILTERING: +- Use 'filter' to apply label matchers to find specific silences + +Silences are used to temporarily mute alerts based on label matchers. This tool helps you understand what is currently silenced in your environment.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "filter": { + Type: "string", + Description: "Label matchers to filter silences (e.g., 'alertname=HighCPU', optional)", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Get Silences", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: GetSilencesHandler, + }, + } +} diff --git a/pkg/toolset/toolset.go b/pkg/toolset/toolset.go index ebff73a..78e41f2 100644 --- a/pkg/toolset/toolset.go +++ b/pkg/toolset/toolset.go @@ -63,6 +63,8 @@ func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { tools.InitGetLabelNames(), tools.InitGetLabelValues(), tools.InitGetSeries(), + tools.InitGetAlerts(), + tools.InitGetSilences(), ) } From 9ab97fb233bd40c09c1ee848b0735009ebf09b71 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Fri, 6 Feb 2026 09:30:46 +0000 Subject: [PATCH 3/8] Refactor packages to share code Signed-off-by: Saswata Mukherjee --- pkg/handlers/handlers.go | 493 +++++++++++++++++++++++++ pkg/handlers/schema.go | 152 ++++++++ pkg/mcp/auth.go | 25 ++ pkg/mcp/handlers.go | 614 +++---------------------------- pkg/mcp/server.go | 42 +-- pkg/mcp/tools.go | 221 +---------- pkg/mcp/tools_test.go | 49 +-- pkg/prompts/prompt.go | 145 ++++++++ pkg/resultutil/result.go | 69 ++++ pkg/resultutil/result_test.go | 179 +++++++++ pkg/toolset/tools/handlers.go | 669 +++------------------------------- pkg/toolset/tools/tools.go | 120 +----- pkg/toolset/toolset.go | 34 +- 13 files changed, 1225 insertions(+), 1587 deletions(-) create mode 100644 pkg/handlers/handlers.go create mode 100644 pkg/handlers/schema.go create mode 100644 pkg/prompts/prompt.go create mode 100644 pkg/resultutil/result.go create mode 100644 pkg/resultutil/result_test.go diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go new file mode 100644 index 0000000..0399b87 --- /dev/null +++ b/pkg/handlers/handlers.go @@ -0,0 +1,493 @@ +package handlers + +import ( + "context" + "fmt" + "log/slog" + "maps" + "strings" + "time" + + "github.com/prometheus/common/model" + + "github.com/rhobs/obs-mcp/pkg/alertmanager" + "github.com/rhobs/obs-mcp/pkg/prometheus" + "github.com/rhobs/obs-mcp/pkg/resultutil" +) + +// ListMetricsHandler handles the listing of available Prometheus metrics. +func ListMetricsHandler(ctx context.Context, promClient prometheus.Loader) *resultutil.Result { + slog.Info("ListMetricsHandler called") + + metrics, err := promClient.ListMetrics(ctx) + if err != nil { + slog.Error("failed to list metrics", "error", err) + return resultutil.NewErrorResult(fmt.Errorf("failed to list metrics: %w", err)) + } + + slog.Info("ListMetricsHandler executed successfully", "resultLength", len(metrics)) + slog.Debug("ListMetricsHandler results", "results", metrics) + + output := ListMetricsOutput{Metrics: metrics} + return resultutil.NewSuccessResult(output) +} + +// ExecuteRangeQueryHandler handles the execution of Prometheus range queries. +func ExecuteRangeQueryHandler(ctx context.Context, promClient prometheus.Loader, input RangeQueryInput) *resultutil.Result { + slog.Info("ExecuteRangeQueryHandler called") + slog.Debug("ExecuteRangeQueryHandler params", "input", input) + + // Validate required parameters + if input.Query == "" { + return resultutil.NewErrorResult(fmt.Errorf("query parameter is required and must be a string")) + } + if input.Step == "" { + return resultutil.NewErrorResult(fmt.Errorf("step parameter is required and must be a string")) + } + + // Parse step duration + stepDuration, err := model.ParseDuration(input.Step) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid step format: %w", err)) + } + + // Validate parameter combinations + if input.Start != "" && input.End != "" && input.Duration != "" { + return resultutil.NewErrorResult(fmt.Errorf("cannot specify both start/end and duration parameters")) + } + + if (input.Start != "" && input.End == "") || (input.Start == "" && input.End != "") { + return resultutil.NewErrorResult(fmt.Errorf("both start and end must be provided together")) + } + + var startTime, endTime time.Time + + // Handle duration-based query (default to 1h if nothing specified) + if input.Duration != "" || (input.Start == "" && input.End == "") { + durationStr := input.Duration + if durationStr == "" { + durationStr = "1h" + } + + duration, err := model.ParseDuration(durationStr) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid duration format: %w", err)) + } + + endTime = time.Now() + startTime = endTime.Add(-time.Duration(duration)) + } else { + // Handle explicit start/end times + startTime, err = prometheus.ParseTimestamp(input.Start) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid start time format: %w", err)) + } + + endTime, err = prometheus.ParseTimestamp(input.End) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid end time format: %w", err)) + } + } + + // Execute the range query + result, err := promClient.ExecuteRangeQuery(ctx, input.Query, startTime, endTime, time.Duration(stepDuration)) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("failed to execute range query: %w", err)) + } + + // Convert to structured output + output := RangeQueryOutput{ + ResultType: fmt.Sprintf("%v", result["resultType"]), + } + + resMatrix, ok := result["result"].(model.Matrix) + if ok { + slog.Info("ExecuteRangeQueryHandler executed successfully", "resultLength", resMatrix.Len()) + slog.Debug("ExecuteRangeQueryHandler results", "results", resMatrix) + + output.Result = make([]SeriesResult, len(resMatrix)) + for i, series := range resMatrix { + labels := make(map[string]string) + for k, v := range series.Metric { + labels[string(k)] = string(v) + } + values := make([][]any, len(series.Values)) + for j, sample := range series.Values { + values[j] = []any{float64(sample.Timestamp) / 1000, sample.Value.String()} + } + output.Result[i] = SeriesResult{ + Metric: labels, + Values: values, + } + } + } else { + slog.Info("ExecuteRangeQueryHandler executed successfully (unknown format)", "result", result) + } + + if warnings, ok := result["warnings"].([]string); ok { + output.Warnings = warnings + } + + return resultutil.NewSuccessResult(output) +} + +// ExecuteInstantQueryHandler handles the execution of Prometheus instant queries. +func ExecuteInstantQueryHandler(ctx context.Context, promClient prometheus.Loader, input InstantQueryInput) *resultutil.Result { + slog.Info("ExecuteInstantQueryHandler called") + slog.Debug("ExecuteInstantQueryHandler params", "input", input) + + // Validate required parameters + if input.Query == "" { + return resultutil.NewErrorResult(fmt.Errorf("query parameter is required and must be a string")) + } + + var queryTime time.Time + var err error + if input.Time == "" { + queryTime = time.Now() + } else { + queryTime, err = prometheus.ParseTimestamp(input.Time) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid time format: %w", err)) + } + } + + // Execute the instant query + result, err := promClient.ExecuteInstantQuery(ctx, input.Query, queryTime) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("failed to execute instant query: %w", err)) + } + + // Convert to structured output + output := InstantQueryOutput{ + ResultType: fmt.Sprintf("%v", result["resultType"]), + } + + resVector, ok := result["result"].(model.Vector) + if ok { + slog.Info("ExecuteInstantQueryHandler executed successfully", "resultLength", len(resVector)) + slog.Debug("ExecuteInstantQueryHandler results", "results", resVector) + + output.Result = make([]InstantResult, len(resVector)) + for i, sample := range resVector { + labels := make(map[string]string) + for k, v := range sample.Metric { + labels[string(k)] = string(v) + } + output.Result[i] = InstantResult{ + Metric: labels, + Value: []any{float64(sample.Timestamp) / 1000, sample.Value.String()}, + } + } + } else { + slog.Info("ExecuteInstantQueryHandler executed successfully (unknown format)", "result", result) + } + + if warnings, ok := result["warnings"].([]string); ok { + output.Warnings = warnings + } + + return resultutil.NewSuccessResult(output) +} + +// GetLabelNamesHandler handles the retrieval of label names. +func GetLabelNamesHandler(ctx context.Context, promClient prometheus.Loader, input LabelNamesInput) *resultutil.Result { + slog.Info("GetLabelNamesHandler called") + slog.Debug("GetLabelNamesHandler params", "input", input) + + // Default to last hour if not specified + var startTime, endTime time.Time + var err error + if input.Start == "" && input.End == "" { + endTime = time.Now() + startTime = endTime.Add(-prometheus.ListMetricsTimeRange) + } else { + if input.Start != "" { + startTime, err = prometheus.ParseTimestamp(input.Start) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid start time format: %w", err)) + } + } + if input.End != "" { + endTime, err = prometheus.ParseTimestamp(input.End) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid end time format: %w", err)) + } + } + } + + // Get label names + labels, err := promClient.GetLabelNames(ctx, input.Metric, startTime, endTime) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("failed to get label names: %w", err)) + } + + slog.Info("GetLabelNamesHandler executed successfully", "labelCount", len(labels)) + slog.Debug("GetLabelNamesHandler results", "results", labels) + + output := LabelNamesOutput{Labels: labels} + return resultutil.NewSuccessResult(output) +} + +// GetLabelValuesHandler handles the retrieval of label values. +func GetLabelValuesHandler(ctx context.Context, promClient prometheus.Loader, input LabelValuesInput) *resultutil.Result { + slog.Info("GetLabelValuesHandler called") + slog.Debug("GetLabelValuesHandler params", "input", input) + + // Validate required parameters + if input.Label == "" { + return resultutil.NewErrorResult(fmt.Errorf("label parameter is required and must be a string")) + } + + // Default to last hour if not specified + var startTime, endTime time.Time + var err error + if input.Start == "" && input.End == "" { + endTime = time.Now() + startTime = endTime.Add(-prometheus.ListMetricsTimeRange) + } else { + if input.Start != "" { + startTime, err = prometheus.ParseTimestamp(input.Start) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid start time format: %w", err)) + } + } + if input.End != "" { + endTime, err = prometheus.ParseTimestamp(input.End) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid end time format: %w", err)) + } + } + } + + // Get label values + values, err := promClient.GetLabelValues(ctx, input.Label, input.Metric, startTime, endTime) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("failed to get label values: %w", err)) + } + + slog.Info("GetLabelValuesHandler executed successfully", "valueCount", len(values)) + slog.Debug("GetLabelValuesHandler results", "results", values) + + output := LabelValuesOutput{Values: values} + return resultutil.NewSuccessResult(output) +} + +// GetSeriesHandler handles the retrieval of time series. +func GetSeriesHandler(ctx context.Context, promClient prometheus.Loader, input SeriesInput) *resultutil.Result { + slog.Info("GetSeriesHandler called") + slog.Debug("GetSeriesHandler params", "input", input) + + // Validate required parameters + if input.Matches == "" { + return resultutil.NewErrorResult(fmt.Errorf("matches parameter is required and must be a string")) + } + + // Parse matches - could be comma-separated + matches := []string{input.Matches} + // If it contains comma outside of braces, split it + // For simplicity, treat the entire string as one match for now + // Users can make multiple calls if needed + + // Default to last hour if not specified + var startTime, endTime time.Time + var err error + if input.Start == "" && input.End == "" { + endTime = time.Now() + startTime = endTime.Add(-prometheus.ListMetricsTimeRange) + } else { + if input.Start != "" { + startTime, err = prometheus.ParseTimestamp(input.Start) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid start time format: %w", err)) + } + } + if input.End != "" { + endTime, err = prometheus.ParseTimestamp(input.End) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("invalid end time format: %w", err)) + } + } + } + + // Get series + series, err := promClient.GetSeries(ctx, matches, startTime, endTime) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("failed to get series: %w", err)) + } + + slog.Info("GetSeriesHandler executed successfully", "cardinality", len(series)) + slog.Debug("GetSeriesHandler results", "results", series) + + output := SeriesOutput{ + Series: series, + Cardinality: len(series), + } + return resultutil.NewSuccessResult(output) +} + +// GetAlertsHandler handles the retrieval of alerts from Alertmanager. +func GetAlertsHandler(ctx context.Context, amClient alertmanager.Loader, input AlertsInput) *resultutil.Result { + slog.Info("GetAlertsHandler called") + slog.Debug("GetAlertsHandler params", "input", input) + + var filter []string + if input.Filter != "" { + // Split by comma if multiple filters are provided + filter = strings.Split(input.Filter, ",") + for i := range filter { + filter[i] = strings.TrimSpace(filter[i]) + } + } + + alerts, err := amClient.GetAlerts(ctx, input.Active, input.Silenced, input.Inhibited, input.Unprocessed, filter, input.Receiver) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("failed to get alerts: %w", err)) + } + + // Convert to output format + output := AlertsOutput{ + Alerts: make([]Alert, len(alerts)), + } + + for i, alert := range alerts { + labels := make(map[string]string) + maps.Copy(labels, alert.Labels) + + annotations := make(map[string]string) + maps.Copy(annotations, alert.Annotations) + + var silencedBy, inhibitedBy []string + var state string + if alert.Status != nil { + if alert.Status.SilencedBy != nil { + silencedBy = alert.Status.SilencedBy + } + if alert.Status.InhibitedBy != nil { + inhibitedBy = alert.Status.InhibitedBy + } + if alert.Status.State != nil { + state = *alert.Status.State + } + } + if silencedBy == nil { + silencedBy = []string{} + } + if inhibitedBy == nil { + inhibitedBy = []string{} + } + + var startsAt, endsAt string + if alert.StartsAt != nil { + startsAt = alert.StartsAt.String() + } + if alert.EndsAt != nil { + endsAt = alert.EndsAt.String() + } + + output.Alerts[i] = Alert{ + Labels: labels, + Annotations: annotations, + StartsAt: startsAt, + EndsAt: endsAt, + Status: AlertStatus{ + State: state, + SilencedBy: silencedBy, + InhibitedBy: inhibitedBy, + }, + } + } + + slog.Info("GetAlertsHandler executed successfully", "alertCount", len(alerts)) + slog.Debug("GetAlertsHandler results", "results", output.Alerts) + + return resultutil.NewSuccessResult(output) +} + +// GetSilencesHandler handles the retrieval of silences from Alertmanager. +func GetSilencesHandler(ctx context.Context, amClient alertmanager.Loader, input SilencesInput) *resultutil.Result { + slog.Info("GetSilencesHandler called") + slog.Debug("GetSilencesHandler params", "input", input) + + var filter []string + if input.Filter != "" { + // Split by comma if multiple filters are provided + filter = strings.Split(input.Filter, ",") + for i := range filter { + filter[i] = strings.TrimSpace(filter[i]) + } + } + + silences, err := amClient.GetSilences(ctx, filter) + if err != nil { + return resultutil.NewErrorResult(fmt.Errorf("failed to get silences: %w", err)) + } + + output := SilencesOutput{ + Silences: make([]Silence, len(silences)), + } + + for i, silence := range silences { + matchers := make([]Matcher, len(silence.Matchers)) + for j, m := range silence.Matchers { + isEqual := true + if m.IsEqual != nil { + isEqual = *m.IsEqual + } + var name, value string + var isRegex bool + if m.Name != nil { + name = *m.Name + } + if m.Value != nil { + value = *m.Value + } + if m.IsRegex != nil { + isRegex = *m.IsRegex + } + matchers[j] = Matcher{ + Name: name, + Value: value, + IsRegex: isRegex, + IsEqual: isEqual, + } + } + + var id, state, createdBy, comment, startsAt, endsAt string + if silence.ID != nil { + id = *silence.ID + } + if silence.Status != nil && silence.Status.State != nil { + state = *silence.Status.State + } + if silence.StartsAt != nil { + startsAt = silence.StartsAt.String() + } + if silence.EndsAt != nil { + endsAt = silence.EndsAt.String() + } + if silence.CreatedBy != nil { + createdBy = *silence.CreatedBy + } + if silence.Comment != nil { + comment = *silence.Comment + } + + output.Silences[i] = Silence{ + ID: id, + Status: SilenceStatus{ + State: state, + }, + Matchers: matchers, + StartsAt: startsAt, + EndsAt: endsAt, + CreatedBy: createdBy, + Comment: comment, + } + } + + slog.Info("GetSilencesHandler executed successfully", "silenceCount", len(silences)) + slog.Debug("GetSilencesHandler results", "results", output.Silences) + + return resultutil.NewSuccessResult(output) +} diff --git a/pkg/handlers/schema.go b/pkg/handlers/schema.go new file mode 100644 index 0000000..4f125d1 --- /dev/null +++ b/pkg/handlers/schema.go @@ -0,0 +1,152 @@ +package handlers + +// ListMetricsOutput defines the output schema for the list_metrics tool. +type ListMetricsOutput struct { + Metrics []string `json:"metrics" jsonschema:"description=List of all available metric names"` +} + +// InstantQueryOutput defines the output schema for the execute_instant_query tool. +type InstantQueryOutput struct { + ResultType string `json:"resultType" jsonschema:"description=The type of result returned (e.g. vector, scalar, string)"` + Result []InstantResult `json:"result" jsonschema:"description=The query results as an array of instant values"` + Warnings []string `json:"warnings,omitempty" jsonschema:"description=Any warnings generated during query execution"` +} + +// InstantResult represents a single instant query result. +type InstantResult struct { + Metric map[string]string `json:"metric" jsonschema:"description=The metric labels"` + Value []any `json:"value" jsonschema:"description=[timestamp, value] pair for the instant query"` +} + +// LabelNamesOutput defines the output schema for the get_label_names tool. +type LabelNamesOutput struct { + Labels []string `json:"labels" jsonschema:"description=List of label names available for the specified metric or all metrics"` +} + +// LabelValuesOutput defines the output schema for the get_label_values tool. +type LabelValuesOutput struct { + Values []string `json:"values" jsonschema:"description=List of unique values for the specified label"` +} + +// SeriesOutput defines the output schema for the get_series tool. +type SeriesOutput struct { + Series []map[string]string `json:"series" jsonschema:"description=List of time series matching the selector, each series is a map of label names to values"` + Cardinality int `json:"cardinality" jsonschema:"description=Total number of series matching the selector"` +} + +// RangeQueryOutput defines the output schema for the execute_range_query tool. +type RangeQueryOutput struct { + ResultType string `json:"resultType" jsonschema:"description=The type of result returned: matrix or vector or scalar"` + Result []SeriesResult `json:"result" jsonschema:"description=The query results as an array of time series"` + Warnings []string `json:"warnings,omitempty" jsonschema:"description=Any warnings generated during query execution"` +} + +// SeriesResult represents a single time series result from a range query. +type SeriesResult struct { + Metric map[string]string `json:"metric" jsonschema:"description=The metric labels"` + Values [][]any `json:"values" jsonschema:"description=Array of [timestamp, value] pairs"` +} + +// AlertsOutput defines the output schema for the get_alerts tool. +type AlertsOutput struct { + Alerts []Alert `json:"alerts" jsonschema:"description=List of alerts from Alertmanager"` +} + +// Alert represents a single alert from Alertmanager. +type Alert struct { + Labels map[string]string `json:"labels" jsonschema:"description=Labels of the alert"` + Annotations map[string]string `json:"annotations" jsonschema:"description=Annotations of the alert"` + StartsAt string `json:"startsAt" jsonschema:"description=Start time of the alert"` + EndsAt string `json:"endsAt,omitempty" jsonschema:"description=End time of the alert (if resolved)"` + Status AlertStatus `json:"status" jsonschema:"description=Current status of the alert"` +} + +// AlertStatus represents the status of an alert. +type AlertStatus struct { + State string `json:"state" jsonschema:"description=State of the alert (active, suppressed, unprocessed)"` + SilencedBy []string `json:"silencedBy,omitempty" jsonschema:"description=List of silences that are silencing this alert"` + InhibitedBy []string `json:"inhibitedBy,omitempty" jsonschema:"description=List of alerts that are inhibiting this alert"` +} + +// SilencesOutput defines the output schema for the get_silences tool. +type SilencesOutput struct { + Silences []Silence `json:"silences" jsonschema:"description=List of silences from Alertmanager"` +} + +// Silence represents a single silence from Alertmanager. +type Silence struct { + ID string `json:"id" jsonschema:"description=Unique identifier of the silence"` + Status SilenceStatus `json:"status" jsonschema:"description=Current status of the silence"` + Matchers []Matcher `json:"matchers" jsonschema:"description=Label matchers for this silence"` + StartsAt string `json:"startsAt" jsonschema:"description=Start time of the silence"` + EndsAt string `json:"endsAt" jsonschema:"description=End time of the silence"` + CreatedBy string `json:"createdBy" jsonschema:"description=Creator of the silence"` + Comment string `json:"comment" jsonschema:"description=Comment describing the silence"` +} + +// SilenceStatus represents the status of a silence. +type SilenceStatus struct { + State string `json:"state" jsonschema:"description=State of the silence (active, pending, expired)"` +} + +// Matcher represents a label matcher for a silence. +type Matcher struct { + Name string `json:"name" jsonschema:"description=Label name to match"` + Value string `json:"value" jsonschema:"description=Label value to match"` + IsRegex bool `json:"isRegex" jsonschema:"description=Whether the match is a regex match"` + IsEqual bool `json:"isEqual" jsonschema:"description=Whether the match is an equality match (true) or inequality match (false)"` +} + +// Input structs for handler parameters + +// RangeQueryInput defines the input parameters for ExecuteRangeQueryHandler. +type RangeQueryInput struct { + Query string `json:"query"` + Step string `json:"step"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + Duration string `json:"duration,omitempty"` +} + +// InstantQueryInput defines the input parameters for ExecuteInstantQueryHandler. +type InstantQueryInput struct { + Query string `json:"query"` + Time string `json:"time,omitempty"` +} + +// LabelNamesInput defines the input parameters for GetLabelNamesHandler. +type LabelNamesInput struct { + Metric string `json:"metric,omitempty"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` +} + +// LabelValuesInput defines the input parameters for GetLabelValuesHandler. +type LabelValuesInput struct { + Label string `json:"label"` + Metric string `json:"metric,omitempty"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` +} + +// SeriesInput defines the input parameters for GetSeriesHandler. +type SeriesInput struct { + Matches string `json:"matches"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` +} + +// AlertsInput defines the input parameters for GetAlertsHandler. +type AlertsInput struct { + Active *bool `json:"active,omitempty"` + Silenced *bool `json:"silenced,omitempty"` + Inhibited *bool `json:"inhibited,omitempty"` + Unprocessed *bool `json:"unprocessed,omitempty"` + Filter string `json:"filter,omitempty"` + Receiver string `json:"receiver,omitempty"` +} + +// SilencesInput defines the input parameters for GetSilencesHandler. +type SilencesInput struct { + Filter string `json:"filter,omitempty"` +} diff --git a/pkg/mcp/auth.go b/pkg/mcp/auth.go index e9047b9..d47e2a2 100644 --- a/pkg/mcp/auth.go +++ b/pkg/mcp/auth.go @@ -14,6 +14,7 @@ import ( promcfg "github.com/prometheus/common/config" "k8s.io/client-go/rest" + "github.com/rhobs/obs-mcp/pkg/alertmanager" "github.com/rhobs/obs-mcp/pkg/k8s" "github.com/rhobs/obs-mcp/pkg/prometheus" ) @@ -84,6 +85,30 @@ func getPromClient(ctx context.Context, opts ObsMCPOptions) (prometheus.Loader, return promClient, nil } +func getAlertmanagerClient(ctx context.Context, opts ObsMCPOptions) (alertmanager.Loader, error) { + // Check if a test client was injected via context + if testClient := ctx.Value(TestAlertmanagerClientKey); testClient != nil { + if client, ok := testClient.(alertmanager.Loader); ok { + return client, nil + } + } + + apiConfig, err := createAPIConfig(ctx, opts) + if err != nil { + return nil, fmt.Errorf("failed to create API config: %v", err) + } + + // Update the address to use AlertmanagerURL instead of MetricsBackendURL + apiConfig.Address = opts.AlertmanagerURL + + amClient, err := alertmanager.NewAlertmanagerClient(apiConfig) + if err != nil { + return nil, fmt.Errorf("failed to create Alertmanager client: %v", err) + } + + return amClient, nil +} + func createAPIConfig(ctx context.Context, opts ObsMCPOptions) (promapi.Config, error) { switch opts.AuthMode { case AuthModeKubeConfig: diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index 183a692..c1140fb 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -2,668 +2,150 @@ package mcp import ( "context" - "encoding/json" "fmt" - "log/slog" - "maps" - "strings" - "time" "github.com/mark3labs/mcp-go/mcp" - "github.com/prometheus/common/model" - "github.com/rhobs/obs-mcp/pkg/alertmanager" - "github.com/rhobs/obs-mcp/pkg/prometheus" + "github.com/rhobs/obs-mcp/pkg/handlers" ) -// errorResult is a helper to log and return an error result. -func errorResult(msg string) (*mcp.CallToolResult, error) { - slog.Info("Query execution error: " + msg) - return mcp.NewToolResultError(msg), nil -} - // ListMetricsHandler handles the listing of available Prometheus metrics. func ListMetricsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("ListMetricsHandler called") - slog.Debug("ListMetricsHandler params", "params", req.Params) promClient, err := getPromClient(ctx, opts) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - metrics, err := promClient.ListMetrics(ctx) - if err != nil { - return errorResult(fmt.Sprintf("failed to list metrics: %s", err.Error())) - } - - slog.Info("ListMetricsHandler executed successfully", "resultLength", len(metrics)) - slog.Debug("ListMetricsHandler results", "results", metrics) - - output := ListMetricsOutput{Metrics: metrics} - result, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal metrics: %s", err.Error())) + return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return mcp.NewToolResultStructured(output, string(result)), nil + return handlers.ListMetricsHandler(ctx, promClient).ToMCPResult() } } // ExecuteRangeQueryHandler handles the execution of Prometheus range queries. func ExecuteRangeQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("ExecuteRangeQueryHandler called") - slog.Debug("ExecuteRangeQueryHandler params", "params", req.Params) - promClient, err := getPromClient(ctx, opts) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - // Get required query parameter - query, err := req.RequireString("query") - if err != nil { - return mcp.NewToolResultError("query parameter is required and must be a string"), nil //nolint:nilerr // MCP pattern: error in result, not return - } - - // Get required step parameter - step, err := req.RequireString("step") - if err != nil { - return mcp.NewToolResultError("step parameter is required and must be a string"), nil //nolint:nilerr // MCP pattern: error in result, not return - } - - // Parse step duration - stepDuration, err := model.ParseDuration(step) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid step format: %s", err.Error())), nil - } - - // Get optional parameters - startStr := req.GetString("start", "") - endStr := req.GetString("end", "") - durationStr := req.GetString("duration", "") - - // Validate parameter combinations - if startStr != "" && endStr != "" && durationStr != "" { - return errorResult("cannot specify both start/end and duration parameters") - } - - if (startStr != "" && endStr == "") || (startStr == "" && endStr != "") { - return errorResult("both start and end must be provided together") - } - - var startTime, endTime time.Time - - // Handle duration-based query (default to 1h if nothing specified) - if durationStr != "" || (startStr == "" && endStr == "") { - if durationStr == "" { - durationStr = "1h" - } - - duration, err := model.ParseDuration(durationStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid duration format: %s", err.Error())) - } - - endTime = time.Now() - startTime = endTime.Add(-time.Duration(duration)) - } else { - // Handle explicit start/end times - startTime, err = prometheus.ParseTimestamp(startStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) - } - - endTime, err = prometheus.ParseTimestamp(endStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) - } - } - - // Execute the range query - result, err := promClient.ExecuteRangeQuery(ctx, query, startTime, endTime, time.Duration(stepDuration)) - if err != nil { - return errorResult(fmt.Sprintf("failed to execute range query: %s", err.Error())) - } - - // Convert to structured output - output := RangeQueryOutput{ - ResultType: fmt.Sprintf("%v", result["resultType"]), - } - - resMatrix, ok := result["result"].(model.Matrix) - if ok { - slog.Info("ExecuteRangeQueryHandler executed successfully", "resultLength", resMatrix.Len()) - slog.Debug("ExecuteRangeQueryHandler results", "results", resMatrix) - - output.Result = make([]SeriesResult, len(resMatrix)) - for i, series := range resMatrix { - labels := make(map[string]string) - for k, v := range series.Metric { - labels[string(k)] = string(v) - } - values := make([][]any, len(series.Values)) - for j, sample := range series.Values { - values[j] = []any{float64(sample.Timestamp) / 1000, sample.Value.String()} - } - output.Result[i] = SeriesResult{ - Metric: labels, - Values: values, - } - } - } else { - slog.Info("ExecuteRangeQueryHandler executed successfully (unknown format)", "result", result) - } - - if warnings, ok := result["warnings"].([]string); ok { - output.Warnings = warnings - } - - // Convert to JSON for fallback text - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal result: %s", err.Error())) - } - - return mcp.NewToolResultStructured(output, string(jsonResult)), nil + return handlers.ExecuteRangeQueryHandler(ctx, promClient, handlers.RangeQueryInput{ + Query: req.GetString("query", ""), + Step: req.GetString("step", ""), + Start: req.GetString("start", ""), + End: req.GetString("end", ""), + Duration: req.GetString("duration", ""), + }).ToMCPResult() } } // ExecuteInstantQueryHandler handles the execution of Prometheus instant queries. func ExecuteInstantQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("ExecuteInstantQueryHandler called") - slog.Debug("ExecuteInstantQueryHandler params", "params", req.Params) - promClient, err := getPromClient(ctx, opts) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get required query parameter - query, err := req.RequireString("query") - if err != nil { - return errorResult("query parameter is required and must be a string") - } - - // Get optional time parameter - timeStr := req.GetString("time", "") - - var queryTime time.Time - if timeStr == "" { - queryTime = time.Now() - } else { - queryTime, err = prometheus.ParseTimestamp(timeStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid time format: %s", err.Error())) - } - } - - // Execute the instant query - result, err := promClient.ExecuteInstantQuery(ctx, query, queryTime) - if err != nil { - return errorResult(fmt.Sprintf("failed to execute instant query: %s", err.Error())) - } - - // Convert to structured output - output := InstantQueryOutput{ - ResultType: fmt.Sprintf("%v", result["resultType"]), - } - - resVector, ok := result["result"].(model.Vector) - if ok { - slog.Info("ExecuteInstantQueryHandler executed successfully", "resultLength", len(resVector)) - slog.Debug("ExecuteInstantQueryHandler results", "results", resVector) - - output.Result = make([]InstantResult, len(resVector)) - for i, sample := range resVector { - labels := make(map[string]string) - for k, v := range sample.Metric { - labels[string(k)] = string(v) - } - output.Result[i] = InstantResult{ - Metric: labels, - Value: []any{float64(sample.Timestamp) / 1000, sample.Value.String()}, - } - } - } else { - slog.Info("ExecuteInstantQueryHandler executed successfully (unknown format)", "result", result) - } - - if warnings, ok := result["warnings"].([]string); ok { - output.Warnings = warnings - } - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal result: %s", err.Error())) + return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return mcp.NewToolResultStructured(output, string(jsonResult)), nil + return handlers.ExecuteInstantQueryHandler(ctx, promClient, handlers.InstantQueryInput{ + Query: req.GetString("query", ""), + Time: req.GetString("time", ""), + }).ToMCPResult() } } // GetLabelNamesHandler handles the retrieval of label names. func GetLabelNamesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("GetLabelNamesHandler called") - slog.Debug("GetLabelNamesHandler params", "params", req.Params) - promClient, err := getPromClient(ctx, opts) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get optional parameters - metric := req.GetString("metric", "") - startStr := req.GetString("start", "") - endStr := req.GetString("end", "") - - // Default to last hour if not specified - var startTime, endTime time.Time - if startStr == "" && endStr == "" { - endTime = time.Now() - startTime = endTime.Add(-prometheus.ListMetricsTimeRange) - } else { - if startStr != "" { - startTime, err = prometheus.ParseTimestamp(startStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) - } - } - if endStr != "" { - endTime, err = prometheus.ParseTimestamp(endStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) - } - } - } - - // Get label names - labels, err := promClient.GetLabelNames(ctx, metric, startTime, endTime) - if err != nil { - return errorResult(fmt.Sprintf("failed to get label names: %s", err.Error())) - } - - output := LabelNamesOutput{Labels: labels} - - slog.Info("GetLabelNamesHandler executed successfully", "labelCount", len(labels)) - slog.Debug("GetLabelNamesHandler results", "results", labels) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal label names: %s", err.Error())) + return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return mcp.NewToolResultStructured(output, string(jsonResult)), nil + return handlers.GetLabelNamesHandler(ctx, promClient, handlers.LabelNamesInput{ + Metric: req.GetString("metric", ""), + Start: req.GetString("start", ""), + End: req.GetString("end", ""), + }).ToMCPResult() } } // GetLabelValuesHandler handles the retrieval of label values. func GetLabelValuesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("GetLabelValuesHandler called") - slog.Debug("GetLabelValuesHandler params", "params", req.Params) - promClient, err := getPromClient(ctx, opts) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get required label parameter - label, err := req.RequireString("label") - if err != nil { - return errorResult("label parameter is required and must be a string") - } - - // Get optional parameters - metric := req.GetString("metric", "") - startStr := req.GetString("start", "") - endStr := req.GetString("end", "") - - // Default to last hour if not specified - var startTime, endTime time.Time - if startStr == "" && endStr == "" { - endTime = time.Now() - startTime = endTime.Add(-prometheus.ListMetricsTimeRange) - } else { - if startStr != "" { - startTime, err = prometheus.ParseTimestamp(startStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) - } - } - if endStr != "" { - endTime, err = prometheus.ParseTimestamp(endStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) - } - } - } - - // Get label values - values, err := promClient.GetLabelValues(ctx, label, metric, startTime, endTime) - if err != nil { - return errorResult(fmt.Sprintf("failed to get label values: %s", err.Error())) - } - - output := LabelValuesOutput{Values: values} - - slog.Info("GetLabelValuesHandler executed successfully", "valueCount", len(values)) - slog.Debug("GetLabelValuesHandler results", "results", values) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal label values: %s", err.Error())) + return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return mcp.NewToolResultStructured(output, string(jsonResult)), nil + return handlers.GetLabelValuesHandler(ctx, promClient, handlers.LabelValuesInput{ + Label: req.GetString("label", ""), + Metric: req.GetString("metric", ""), + Start: req.GetString("start", ""), + End: req.GetString("end", ""), + }).ToMCPResult() } } // GetSeriesHandler handles the retrieval of time series. func GetSeriesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("GetSeriesHandler called") - slog.Debug("GetSeriesHandler params", "params", req.Params) - promClient, err := getPromClient(ctx, opts) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get required matches parameter - matchesStr, err := req.RequireString("matches") - if err != nil { - return errorResult("matches parameter is required and must be a string") - } - - // Parse matches - could be comma-separated - matches := []string{matchesStr} - // If it contains comma outside of braces, split it - // For simplicity, treat the entire string as one match for now - // Users can make multiple calls if needed - - // Get optional parameters - startStr := req.GetString("start", "") - endStr := req.GetString("end", "") - - // Default to last hour if not specified - var startTime, endTime time.Time - if startStr == "" && endStr == "" { - endTime = time.Now() - startTime = endTime.Add(-prometheus.ListMetricsTimeRange) - } else { - if startStr != "" { - startTime, err = prometheus.ParseTimestamp(startStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) - } - } - if endStr != "" { - endTime, err = prometheus.ParseTimestamp(endStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) - } - } - } - - // Get series - series, err := promClient.GetSeries(ctx, matches, startTime, endTime) - if err != nil { - return errorResult(fmt.Sprintf("failed to get series: %s", err.Error())) - } - - output := SeriesOutput{ - Series: series, - Cardinality: len(series), - } - - slog.Info("GetSeriesHandler executed successfully", "cardinality", len(series)) - slog.Debug("GetSeriesHandler results", "results", series) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal series: %s", err.Error())) + return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return mcp.NewToolResultStructured(output, string(jsonResult)), nil + return handlers.GetSeriesHandler(ctx, promClient, handlers.SeriesInput{ + Matches: req.GetString("matches", ""), + Start: req.GetString("start", ""), + End: req.GetString("end", ""), + }).ToMCPResult() } } // GetAlertsHandler handles the retrieval of alerts from Alertmanager. func GetAlertsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("GetAlertsHandler called") - slog.Debug("GetAlertsHandler params", "params", req.Params) - amClient, err := getAlertmanagerClient(ctx, opts) if err != nil { - return errorResult(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())) + return mcp.NewToolResultError(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())), nil } - var active, silenced, inhibited, unprocessed *bool + // Parse MCP parameters into input struct + var input handlers.AlertsInput if req.Params.Arguments != nil { if args, ok := req.Params.Arguments.(map[string]any); ok { if activeVal, ok := args["active"].(bool); ok { - active = &activeVal + input.Active = &activeVal } if silencedVal, ok := args["silenced"].(bool); ok { - silenced = &silencedVal + input.Silenced = &silencedVal } if inhibitedVal, ok := args["inhibited"].(bool); ok { - inhibited = &inhibitedVal + input.Inhibited = &inhibitedVal } if unprocessedVal, ok := args["unprocessed"].(bool); ok { - unprocessed = &unprocessedVal - } - } - } - - // Get optional string parameters - filterStr := req.GetString("filter", "") - receiver := req.GetString("receiver", "") - var filter []string - if filterStr != "" { - // Split by comma if multiple filters are provided - filter = strings.Split(filterStr, ",") - for i := range filter { - filter[i] = strings.TrimSpace(filter[i]) - } - } - - alerts, err := amClient.GetAlerts(ctx, active, silenced, inhibited, unprocessed, filter, receiver) - if err != nil { - return errorResult(fmt.Sprintf("failed to get alerts: %s", err.Error())) - } - - // Convert to output format - output := AlertsOutput{ - Alerts: make([]Alert, len(alerts)), - } - - for i, alert := range alerts { - labels := make(map[string]string) - maps.Copy(labels, alert.Labels) - - annotations := make(map[string]string) - maps.Copy(annotations, alert.Annotations) - - var silencedBy, inhibitedBy []string - var state string - if alert.Status != nil { - if alert.Status.SilencedBy != nil { - silencedBy = alert.Status.SilencedBy - } - if alert.Status.InhibitedBy != nil { - inhibitedBy = alert.Status.InhibitedBy - } - if alert.Status.State != nil { - state = *alert.Status.State + input.Unprocessed = &unprocessedVal } } - if silencedBy == nil { - silencedBy = []string{} - } - if inhibitedBy == nil { - inhibitedBy = []string{} - } - - var startsAt, endsAt string - if alert.StartsAt != nil { - startsAt = alert.StartsAt.String() - } - if alert.EndsAt != nil { - endsAt = alert.EndsAt.String() - } - - output.Alerts[i] = Alert{ - Labels: labels, - Annotations: annotations, - StartsAt: startsAt, - EndsAt: endsAt, - Status: AlertStatus{ - State: state, - SilencedBy: silencedBy, - InhibitedBy: inhibitedBy, - }, - } } + input.Filter = req.GetString("filter", "") + input.Receiver = req.GetString("receiver", "") - slog.Info("GetAlertsHandler executed successfully", "alertCount", len(alerts)) - slog.Debug("GetAlertsHandler results", "results", output.Alerts) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal alerts: %s", err.Error())) - } - - return mcp.NewToolResultStructured(output, string(jsonResult)), nil + return handlers.GetAlertsHandler(ctx, amClient, input).ToMCPResult() } } // GetSilencesHandler handles the retrieval of silences from Alertmanager. func GetSilencesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("GetSilencesHandler called") - slog.Debug("GetSilencesHandler params", "params", req.Params) - amClient, err := getAlertmanagerClient(ctx, opts) if err != nil { - return errorResult(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())) - } - - filterStr := req.GetString("filter", "") - var filter []string - if filterStr != "" { - // Split by comma if multiple filters are provided - filter = strings.Split(filterStr, ",") - for i := range filter { - filter[i] = strings.TrimSpace(filter[i]) - } - } - - silences, err := amClient.GetSilences(ctx, filter) - if err != nil { - return errorResult(fmt.Sprintf("failed to get silences: %s", err.Error())) - } - - output := SilencesOutput{ - Silences: make([]Silence, len(silences)), + return mcp.NewToolResultError(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())), nil } - for i, silence := range silences { - matchers := make([]Matcher, len(silence.Matchers)) - for j, m := range silence.Matchers { - isEqual := true - if m.IsEqual != nil { - isEqual = *m.IsEqual - } - var name, value string - var isRegex bool - if m.Name != nil { - name = *m.Name - } - if m.Value != nil { - value = *m.Value - } - if m.IsRegex != nil { - isRegex = *m.IsRegex - } - matchers[j] = Matcher{ - Name: name, - Value: value, - IsRegex: isRegex, - IsEqual: isEqual, - } - } - - var id, state, createdBy, comment, startsAt, endsAt string - if silence.ID != nil { - id = *silence.ID - } - if silence.Status != nil && silence.Status.State != nil { - state = *silence.Status.State - } - if silence.StartsAt != nil { - startsAt = silence.StartsAt.String() - } - if silence.EndsAt != nil { - endsAt = silence.EndsAt.String() - } - if silence.CreatedBy != nil { - createdBy = *silence.CreatedBy - } - if silence.Comment != nil { - comment = *silence.Comment - } - - output.Silences[i] = Silence{ - ID: id, - Status: SilenceStatus{ - State: state, - }, - Matchers: matchers, - StartsAt: startsAt, - EndsAt: endsAt, - CreatedBy: createdBy, - Comment: comment, - } - } - - slog.Info("GetSilencesHandler executed successfully", "silenceCount", len(silences)) - slog.Debug("GetSilencesHandler results", "results", output.Silences) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal silences: %s", err.Error())) - } - - return mcp.NewToolResultStructured(output, string(jsonResult)), nil - } -} - -func getAlertmanagerClient(ctx context.Context, opts ObsMCPOptions) (alertmanager.Loader, error) { - // Check if a test client was injected via context - if testClient := ctx.Value(TestAlertmanagerClientKey); testClient != nil { - if client, ok := testClient.(alertmanager.Loader); ok { - return client, nil - } + return handlers.GetSilencesHandler(ctx, amClient, handlers.SilencesInput{ + Filter: req.GetString("filter", ""), + }).ToMCPResult() } - - apiConfig, err := createAPIConfig(ctx, opts) - if err != nil { - return nil, fmt.Errorf("failed to create API config: %v", err) - } - - // Update the address to use AlertmanagerURL instead of MetricsBackendURL - apiConfig.Address = opts.AlertmanagerURL - - amClient, err := alertmanager.NewAlertmanagerClient(apiConfig) - if err != nil { - return nil, fmt.Errorf("failed to create Alertmanager client: %v", err) - } - - return amClient, nil } diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 3982470..496cf5b 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -14,6 +14,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/rhobs/obs-mcp/pkg/prometheus" + "github.com/rhobs/obs-mcp/pkg/prompts" ) // ObsMCPOptions contains configuration options for the MCP server @@ -31,45 +32,6 @@ const ( serverName = "obs-mcp" serverVersion = "1.0.0" defaultShutdownTimeout = 10 * time.Second - - serverInstructions = `You are an expert Kubernetes and OpenShift observability assistant with direct access to Prometheus metrics and Alertmanager alerts through this MCP server. - -## INVESTIGATION STARTING POINT - -When the user asks about issues, errors, failures, outages, or things going wrong - consider calling get_alerts first to see what's currently firing. Alert labels provide exact identifiers (namespaces, pods, services) useful for targeted metric queries. - -If the user mentions a specific alert by name, use get_alerts with a filter to retrieve its full labels before investigating further. - -## MANDATORY WORKFLOW FOR QUERYING - ALWAYS FOLLOW THIS ORDER - -**STEP 1: ALWAYS call list_metrics FIRST** -- This is NON-NEGOTIABLE for EVERY question -- NEVER skip this step, even if you think you know the metric name -- NEVER guess metric names - they vary between environments -- Search the returned list to find the exact metric name that exists - -**STEP 2: Call get_label_names for the metric you found** -- Discover available labels for filtering (namespace, pod, service, etc.) - -**STEP 3: Call get_label_values if you need specific filter values** -- Find exact label values (e.g., actual namespace names, pod names) - -**STEP 4: Execute your query using the EXACT metric name from Step 1** -- Use execute_instant_query for current state questions -- Use execute_range_query for trends/historical analysis - -## CRITICAL RULES - -1. **NEVER query a metric without first calling list_metrics** - You must verify the metric exists -2. **Use EXACT metric names from list_metrics output** - Do not modify or guess metric names -3. **If list_metrics doesn't return a relevant metric, tell the user** - Don't fabricate queries -4. **BE PROACTIVE** - Complete all steps automatically without asking for confirmation. When you find a relevant metric, proceed to query. -5. **UNDERSTAND TIME FRAMES** - Use the start and end parameters to specify the time frame for your queries. You can use NOW for current time liberally across parameters, and NOW±duration for relative time frames. - -## Query Type Selection - -- **execute_instant_query**: Current values, point-in-time snapshots, "right now" questions -- **execute_range_query**: Trends over time, rate calculations, historical analysis` ) func NewMCPServer(opts ObsMCPOptions) (*server.MCPServer, error) { @@ -78,7 +40,7 @@ func NewMCPServer(opts ObsMCPOptions) (*server.MCPServer, error) { serverVersion, server.WithLogging(), server.WithToolCapabilities(true), - server.WithInstructions(serverInstructions), + server.WithInstructions(prompts.ServerPrompt), ) if err := SetupTools(mcpServer, opts); err != nil { diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index ceae13e..0045390 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -2,104 +2,10 @@ package mcp import ( "github.com/mark3labs/mcp-go/mcp" -) - -// ListMetricsOutput defines the output schema for the list_metrics tool. -type ListMetricsOutput struct { - Metrics []string `json:"metrics" jsonschema:"description=List of all available metric names"` -} - -// InstantQueryOutput defines the output schema for the execute_instant_query tool. -type InstantQueryOutput struct { - ResultType string `json:"resultType" jsonschema:"description=The type of result returned (e.g. vector, scalar, string)"` - Result []InstantResult `json:"result" jsonschema:"description=The query results as an array of instant values"` - Warnings []string `json:"warnings,omitempty" jsonschema:"description=Any warnings generated during query execution"` -} - -// InstantResult represents a single instant query result. -type InstantResult struct { - Metric map[string]string `json:"metric" jsonschema:"description=The metric labels"` - Value []any `json:"value" jsonschema:"description=[timestamp, value] pair for the instant query"` -} - -// LabelNamesOutput defines the output schema for the get_label_names tool. -type LabelNamesOutput struct { - Labels []string `json:"labels" jsonschema:"description=List of label names available for the specified metric or all metrics"` -} - -// LabelValuesOutput defines the output schema for the get_label_values tool. -type LabelValuesOutput struct { - Values []string `json:"values" jsonschema:"description=List of unique values for the specified label"` -} - -// SeriesOutput defines the output schema for the get_series tool. -type SeriesOutput struct { - Series []map[string]string `json:"series" jsonschema:"description=List of time series matching the selector, each series is a map of label names to values"` - Cardinality int `json:"cardinality" jsonschema:"description=Total number of series matching the selector"` -} - -// RangeQueryOutput defines the output schema for the execute_range_query tool. -type RangeQueryOutput struct { - ResultType string `json:"resultType" jsonschema:"description=The type of result returned: matrix or vector or scalar"` - Result []SeriesResult `json:"result" jsonschema:"description=The query results as an array of time series"` - Warnings []string `json:"warnings,omitempty" jsonschema:"description=Any warnings generated during query execution"` -} - -// SeriesResult represents a single time series result from a range query. -type SeriesResult struct { - Metric map[string]string `json:"metric" jsonschema:"description=The metric labels"` - Values [][]any `json:"values" jsonschema:"description=Array of [timestamp, value] pairs"` -} - -// AlertsOutput defines the output schema for the get_alerts tool. -type AlertsOutput struct { - Alerts []Alert `json:"alerts" jsonschema:"description=List of alerts from Alertmanager"` -} - -// Alert represents a single alert from Alertmanager. -type Alert struct { - Labels map[string]string `json:"labels" jsonschema:"description=Labels of the alert"` - Annotations map[string]string `json:"annotations" jsonschema:"description=Annotations of the alert"` - StartsAt string `json:"startsAt" jsonschema:"description=Start time of the alert"` - EndsAt string `json:"endsAt,omitempty" jsonschema:"description=End time of the alert (if resolved)"` - Status AlertStatus `json:"status" jsonschema:"description=Current status of the alert"` -} - -// AlertStatus represents the status of an alert. -type AlertStatus struct { - State string `json:"state" jsonschema:"description=State of the alert (active, suppressed, unprocessed)"` - SilencedBy []string `json:"silencedBy,omitempty" jsonschema:"description=List of silences that are silencing this alert"` - InhibitedBy []string `json:"inhibitedBy,omitempty" jsonschema:"description=List of alerts that are inhibiting this alert"` -} - -// SilencesOutput defines the output schema for the get_silences tool. -type SilencesOutput struct { - Silences []Silence `json:"silences" jsonschema:"description=List of silences from Alertmanager"` -} - -// Silence represents a single silence from Alertmanager. -type Silence struct { - ID string `json:"id" jsonschema:"description=Unique identifier of the silence"` - Status SilenceStatus `json:"status" jsonschema:"description=Current status of the silence"` - Matchers []Matcher `json:"matchers" jsonschema:"description=Label matchers for this silence"` - StartsAt string `json:"startsAt" jsonschema:"description=Start time of the silence"` - EndsAt string `json:"endsAt" jsonschema:"description=End time of the silence"` - CreatedBy string `json:"createdBy" jsonschema:"description=Creator of the silence"` - Comment string `json:"comment" jsonschema:"description=Comment describing the silence"` -} - -// SilenceStatus represents the status of a silence. -type SilenceStatus struct { - State string `json:"state" jsonschema:"description=State of the silence (active, pending, expired)"` -} -// Matcher represents a label matcher for a silence. -type Matcher struct { - Name string `json:"name" jsonschema:"description=Label name to match"` - Value string `json:"value" jsonschema:"description=Label value to match"` - IsRegex bool `json:"isRegex" jsonschema:"description=Whether the match is a regex match"` - IsEqual bool `json:"isEqual" jsonschema:"description=Whether the match is an equality match (true) or inequality match (false)"` -} + "github.com/rhobs/obs-mcp/pkg/handlers" + "github.com/rhobs/obs-mcp/pkg/prompts" +) // AllTools returns all available MCP tools. // When adding a new tool, add it here to keep documentation in sync. @@ -118,23 +24,8 @@ func AllTools() []mcp.Tool { func CreateListMetricsTool() mcp.Tool { tool := mcp.NewTool("list_metrics", - mcp.WithDescription(`MANDATORY FIRST STEP: List all available metric names in Prometheus. - -YOU MUST CALL THIS TOOL BEFORE ANY OTHER QUERY TOOL - -This tool MUST be called first for EVERY observability question to: -1. Discover what metrics actually exist in this environment -2. Find the EXACT metric name to use in queries -3. Avoid querying non-existent metrics - -NEVER skip this step. NEVER guess metric names. Metric names vary between environments. - -After calling this tool: -1. Search the returned list for relevant metrics -2. Use the EXACT metric name found in subsequent queries -3. If no relevant metric exists, inform the user -`), - mcp.WithOutputSchema[ListMetricsOutput](), + mcp.WithDescription(prompts.ListMetricsPrompt), + mcp.WithOutputSchema[handlers.ListMetricsOutput](), ) // workaround for tool with no parameter // see https://github.com/containers/kubernetes-mcp-server/pull/341/files#diff-8f8a99cac7a7cbb9c14477d40539efa1494b62835603244ba9f10e6be1c7e44c @@ -145,17 +36,7 @@ After calling this tool: func CreateExecuteInstantQueryTool() mcp.Tool { return mcp.NewTool("execute_instant_query", - mcp.WithDescription(`Execute a PromQL instant query to get current/point-in-time values. - -PREREQUISITE: You MUST call list_metrics first to verify the metric exists - -WHEN TO USE: -- Current state questions: "What is the current error rate?" -- Point-in-time snapshots: "How many pods are running?" -- Latest values: "Which pods are in Pending state?" - -The 'query' parameter MUST use metric names that were returned by list_metrics. -`), + mcp.WithDescription(prompts.ExecuteInstantQueryPrompt), mcp.WithString("query", mcp.Required(), mcp.Description("PromQL query string using metric names verified via list_metrics"), @@ -163,26 +44,13 @@ The 'query' parameter MUST use metric names that were returned by list_metrics. mcp.WithString("time", mcp.Description("Evaluation time as RFC3339 or Unix timestamp. Omit or use 'NOW' for current time."), ), - mcp.WithOutputSchema[InstantQueryOutput](), + mcp.WithOutputSchema[handlers.InstantQueryOutput](), ) } func CreateExecuteRangeQueryTool() mcp.Tool { return mcp.NewTool("execute_range_query", - mcp.WithDescription(`Execute a PromQL range query to get time-series data over a period. - -PREREQUISITE: You MUST call list_metrics first to verify the metric exists - -WHEN TO USE: -- Trends over time: "What was CPU usage over the last hour?" -- Rate calculations: "How many requests per second?" -- Historical analysis: "Were there any restarts in the last 5 minutes?" - -TIME PARAMETERS: -- 'duration': Look back from now (e.g., "5m", "1h", "24h") -- 'step': Data point resolution (e.g., "1m" for 1-hour duration, "5m" for 24-hour duration) - -The 'query' parameter MUST use metric names that were returned by list_metrics.`), + mcp.WithDescription(prompts.ExecuteRangeQueryPrompt), mcp.WithString("query", mcp.Required(), mcp.Description("PromQL query string using metric names verified via list_metrics"), @@ -202,19 +70,13 @@ The 'query' parameter MUST use metric names that were returned by list_metrics.` mcp.Description("Duration to look back from now (e.g., '1h', '30m', '1d', '2w') (optional)"), mcp.Pattern(`^\d+[smhdwy]$`), ), - mcp.WithOutputSchema[RangeQueryOutput](), + mcp.WithOutputSchema[handlers.RangeQueryOutput](), ) } func CreateGetLabelNamesTool() mcp.Tool { return mcp.NewTool("get_label_names", - mcp.WithDescription(`Get all label names (dimensions) available for filtering a metric. - -WHEN TO USE (after calling list_metrics): -- To discover how to filter metrics (by namespace, pod, service, etc.) -- Before constructing label matchers in PromQL queries - -The 'metric' parameter should use a metric name from list_metrics output.`), + mcp.WithDescription(prompts.GetLabelNamesPrompt), mcp.WithString("metric", mcp.Description("Metric name (from list_metrics) to get label names for. Leave empty for all metrics."), ), @@ -224,19 +86,13 @@ The 'metric' parameter should use a metric name from list_metrics output.`), mcp.WithString("end", mcp.Description("End time for label discovery as RFC3339 or Unix timestamp (optional, defaults to now)"), ), - mcp.WithOutputSchema[LabelNamesOutput](), + mcp.WithOutputSchema[handlers.LabelNamesOutput](), ) } func CreateGetLabelValuesTool() mcp.Tool { return mcp.NewTool("get_label_values", - mcp.WithDescription(`Get all unique values for a specific label. - -WHEN TO USE (after calling list_metrics and get_label_names): -- To find exact label values for filtering (namespace names, pod names, etc.) -- To see what values exist before constructing queries - -The 'metric' parameter should use a metric name from list_metrics output.`), + mcp.WithDescription(prompts.GetLabelValuesPrompt), mcp.WithString("label", mcp.Required(), mcp.Description("Label name (from get_label_names) to get values for"), @@ -250,24 +106,13 @@ The 'metric' parameter should use a metric name from list_metrics output.`), mcp.WithString("end", mcp.Description("End time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to now)"), ), - mcp.WithOutputSchema[LabelValuesOutput](), + mcp.WithOutputSchema[handlers.LabelValuesOutput](), ) } func CreateGetSeriesTool() mcp.Tool { return mcp.NewTool("get_series", - mcp.WithDescription(`Get time series matching selectors and preview cardinality. - -WHEN TO USE (optional, after calling list_metrics): -- To verify label filters match expected series before querying -- To check cardinality and avoid slow queries - -CARDINALITY GUIDANCE: -- <100 series: Safe -- 100-1000: Usually fine -- >1000: Add more label filters - -The selector should use metric names from list_metrics output.`), + mcp.WithDescription(prompts.GetSeriesPrompt), mcp.WithString("matches", mcp.Required(), mcp.Description("PromQL series selector using metric names from list_metrics"), @@ -278,31 +123,13 @@ The selector should use metric names from list_metrics output.`), mcp.WithString("end", mcp.Description("End time for series discovery as RFC3339 or Unix timestamp (optional, defaults to now)"), ), - mcp.WithOutputSchema[SeriesOutput](), + mcp.WithOutputSchema[handlers.SeriesOutput](), ) } func CreateGetAlertsTool() mcp.Tool { return mcp.NewTool("get_alerts", - mcp.WithDescription(`Get alerts from Alertmanager. - -WHEN TO USE: -- START HERE when investigating issues: if the user asks about things breaking, errors, failures, outages, services being down, or anything going wrong in the cluster -- When the user mentions a specific alert name - use this tool to get the alert's full labels (namespace, pod, service, etc.) which are essential for further investigation with other tools -- To see currently firing alerts in the cluster -- To check which alerts are active, silenced, or inhibited -- To understand what's happening before diving into metrics or logs - -INVESTIGATION TIP: Alert labels often contain the exact identifiers (pod names, namespaces, job names) needed for targeted queries with prometheus tools. - -FILTERING: -- Use 'active' to filter for only active alerts (not resolved) -- Use 'silenced' to filter for silenced alerts -- Use 'inhibited' to filter for inhibited alerts -- Use 'filter' to apply label matchers (e.g., "alertname=HighCPU") -- Use 'receiver' to filter alerts by receiver name - -All filter parameters are optional. Without filters, all alerts are returned.`), + mcp.WithDescription(prompts.GetAlertsPrompt), mcp.WithBoolean("active", mcp.Description("Filter for active alerts only (true/false, optional)"), ), @@ -321,26 +148,16 @@ All filter parameters are optional. Without filters, all alerts are returned.`), mcp.WithString("receiver", mcp.Description("Receiver name to filter alerts (optional)"), ), - mcp.WithOutputSchema[AlertsOutput](), + mcp.WithOutputSchema[handlers.AlertsOutput](), ) } func CreateGetSilencesTool() mcp.Tool { return mcp.NewTool("get_silences", - mcp.WithDescription(`Get silences from Alertmanager. - -WHEN TO USE: -- To see which alerts are currently silenced -- To check active, pending, or expired silences -- To investigate why certain alerts are not firing notifications - -FILTERING: -- Use 'filter' to apply label matchers to find specific silences - -Silences are used to temporarily mute alerts based on label matchers. This tool helps you understand what is currently silenced in your environment.`), + mcp.WithDescription(prompts.GetSilencesPrompt), mcp.WithString("filter", mcp.Description("Label matchers to filter silences (e.g., 'alertname=HighCPU', optional)"), ), - mcp.WithOutputSchema[SilencesOutput](), + mcp.WithOutputSchema[handlers.SilencesOutput](), ) } diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index 9d8b9cc..cce1db6 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -6,24 +6,25 @@ import ( "testing" "github.com/mark3labs/mcp-go/mcp" + "github.com/rhobs/obs-mcp/pkg/handlers" ) func TestListMetricsOutputSerialization(t *testing.T) { tests := []struct { name string - input ListMetricsOutput + input handlers.ListMetricsOutput }{ { name: "empty", - input: ListMetricsOutput{Metrics: []string{}}, + input: handlers.ListMetricsOutput{Metrics: []string{}}, }, { name: "single metric", - input: ListMetricsOutput{Metrics: []string{"up"}}, + input: handlers.ListMetricsOutput{Metrics: []string{"up"}}, }, { name: "multiple metrics", - input: ListMetricsOutput{Metrics: []string{"up", "node_cpu_seconds_total", "go_goroutines"}}, + input: handlers.ListMetricsOutput{Metrics: []string{"up", "node_cpu_seconds_total", "go_goroutines"}}, }, } @@ -34,7 +35,7 @@ func TestListMetricsOutputSerialization(t *testing.T) { t.Fatalf("marshal failed: %v", err) } - var result ListMetricsOutput + var result handlers.ListMetricsOutput if err := json.Unmarshal(data, &result); err != nil { t.Fatalf("unmarshal failed: %v", err) } @@ -45,13 +46,13 @@ func TestListMetricsOutputSerialization(t *testing.T) { func TestRangeQueryOutputSerialization(t *testing.T) { tests := []struct { name string - input RangeQueryOutput + input handlers.RangeQueryOutput }{ { name: "matrix single series", - input: RangeQueryOutput{ + input: handlers.RangeQueryOutput{ ResultType: "matrix", - Result: []SeriesResult{{ + Result: []handlers.SeriesResult{{ Metric: map[string]string{"__name__": "up"}, Values: [][]any{{1700000000.0, "1"}}, }}, @@ -59,9 +60,9 @@ func TestRangeQueryOutputSerialization(t *testing.T) { }, { name: "matrix multiple series", - input: RangeQueryOutput{ + input: handlers.RangeQueryOutput{ ResultType: "matrix", - Result: []SeriesResult{ + Result: []handlers.SeriesResult{ {Metric: map[string]string{"job": "a"}, Values: [][]any{}}, {Metric: map[string]string{"job": "b"}, Values: [][]any{}}, {Metric: map[string]string{"job": "c"}, Values: [][]any{}}, @@ -70,16 +71,16 @@ func TestRangeQueryOutputSerialization(t *testing.T) { }, { name: "empty result", - input: RangeQueryOutput{ + input: handlers.RangeQueryOutput{ ResultType: "matrix", - Result: []SeriesResult{}, + Result: []handlers.SeriesResult{}, }, }, { name: "vector result", - input: RangeQueryOutput{ + input: handlers.RangeQueryOutput{ ResultType: "vector", - Result: []SeriesResult{{ + Result: []handlers.SeriesResult{{ Metric: map[string]string{"__name__": "up"}, Values: [][]any{{1700000000.0, "1"}}, }}, @@ -87,9 +88,9 @@ func TestRangeQueryOutputSerialization(t *testing.T) { }, { name: "scalar result", - input: RangeQueryOutput{ + input: handlers.RangeQueryOutput{ ResultType: "scalar", - Result: []SeriesResult{{ + Result: []handlers.SeriesResult{{ Metric: map[string]string{}, Values: [][]any{{1700000000.0, "42"}}, }}, @@ -97,9 +98,9 @@ func TestRangeQueryOutputSerialization(t *testing.T) { }, { name: "with warnings", - input: RangeQueryOutput{ + input: handlers.RangeQueryOutput{ ResultType: "matrix", - Result: []SeriesResult{}, + Result: []handlers.SeriesResult{}, Warnings: []string{"warning1", "warning2"}, }, }, @@ -112,7 +113,7 @@ func TestRangeQueryOutputSerialization(t *testing.T) { t.Fatalf("marshal failed: %v", err) } - var result RangeQueryOutput + var result handlers.RangeQueryOutput if err := json.Unmarshal(data, &result); err != nil { t.Fatalf("unmarshal failed: %v", err) } @@ -123,25 +124,25 @@ func TestRangeQueryOutputSerialization(t *testing.T) { func TestSeriesResultSerialization(t *testing.T) { tests := []struct { name string - input SeriesResult + input handlers.SeriesResult }{ { name: "with labels and values", - input: SeriesResult{ + input: handlers.SeriesResult{ Metric: map[string]string{"__name__": "up", "job": "prometheus"}, Values: [][]any{{1700000000.0, "1"}, {1700000060.0, "1"}}, }, }, { name: "empty", - input: SeriesResult{ + input: handlers.SeriesResult{ Metric: map[string]string{}, Values: [][]any{}, }, }, { name: "many labels", - input: SeriesResult{ + input: handlers.SeriesResult{ Metric: map[string]string{ "__name__": "http_requests", "method": "GET", "status": "200", "handler": "/api", "instance": "localhost:9090", @@ -158,7 +159,7 @@ func TestSeriesResultSerialization(t *testing.T) { t.Fatalf("marshal failed: %v", err) } - var result SeriesResult + var result handlers.SeriesResult if err := json.Unmarshal(data, &result); err != nil { t.Fatalf("unmarshal failed: %v", err) } diff --git a/pkg/prompts/prompt.go b/pkg/prompts/prompt.go new file mode 100644 index 0000000..7186515 --- /dev/null +++ b/pkg/prompts/prompt.go @@ -0,0 +1,145 @@ +package prompts + +const ( + ServerPrompt = `You are an expert Kubernetes and OpenShift observability assistant with direct access to Prometheus metrics and Alertmanager alerts through this MCP server. + +## INVESTIGATION STARTING POINT + +When the user asks about issues, errors, failures, outages, or things going wrong - consider calling get_alerts first to see what's currently firing. Alert labels provide exact identifiers (namespaces, pods, services) useful for targeted metric queries. + +If the user mentions a specific alert by name, use get_alerts with a filter to retrieve its full labels before investigating further. + +## MANDATORY WORKFLOW FOR QUERYING - ALWAYS FOLLOW THIS ORDER + +**STEP 1: ALWAYS call list_metrics FIRST** +- This is NON-NEGOTIABLE for EVERY question +- NEVER skip this step, even if you think you know the metric name +- NEVER guess metric names - they vary between environments +- Search the returned list to find the exact metric name that exists + +**STEP 2: Call get_label_names for the metric you found** +- Discover available labels for filtering (namespace, pod, service, etc.) + +**STEP 3: Call get_label_values if you need specific filter values** +- Find exact label values (e.g., actual namespace names, pod names) + +**STEP 4: Execute your query using the EXACT metric name from Step 1** +- Use execute_instant_query for current state questions +- Use execute_range_query for trends/historical analysis + +## CRITICAL RULES + +1. **NEVER query a metric without first calling list_metrics** - You must verify the metric exists +2. **Use EXACT metric names from list_metrics output** - Do not modify or guess metric names +3. **If list_metrics doesn't return a relevant metric, tell the user** - Don't fabricate queries +4. **BE PROACTIVE** - Complete all steps automatically without asking for confirmation. When you find a relevant metric, proceed to query. +5. **UNDERSTAND TIME FRAMES** - Use the start and end parameters to specify the time frame for your queries. You can use NOW for current time liberally across parameters, and NOW±duration for relative time frames. + +## Query Type Selection + +- **execute_instant_query**: Current values, point-in-time snapshots, "right now" questions +- **execute_range_query**: Trends over time, rate calculations, historical analysis` + + ListMetricsPrompt = `MANDATORY FIRST STEP: List all available metric names in Prometheus. + +YOU MUST CALL THIS TOOL BEFORE ANY OTHER QUERY TOOL + +This tool MUST be called first for EVERY observability question to: +1. Discover what metrics actually exist in this environment +2. Find the EXACT metric name to use in queries +3. Avoid querying non-existent metrics + +NEVER skip this step. NEVER guess metric names. Metric names vary between environments. + +After calling this tool: +1. Search the returned list for relevant metrics +2. Use the EXACT metric name found in subsequent queries +3. If no relevant metric exists, inform the user` + + ExecuteInstantQueryPrompt = `Execute a PromQL instant query to get current/point-in-time values. + +PREREQUISITE: You MUST call list_metrics first to verify the metric exists + +WHEN TO USE: +- Current state questions: "What is the current error rate?" +- Point-in-time snapshots: "How many pods are running?" +- Latest values: "Which pods are in Pending state?" + +The 'query' parameter MUST use metric names that were returned by list_metrics.` + + ExecuteRangeQueryPrompt = `Execute a PromQL range query to get time-series data over a period. + +PREREQUISITE: You MUST call list_metrics first to verify the metric exists + +WHEN TO USE: +- Trends over time: "What was CPU usage over the last hour?" +- Rate calculations: "How many requests per second?" +- Historical analysis: "Were there any restarts in the last 5 minutes?" + +TIME PARAMETERS: +- 'duration': Look back from now (e.g., "5m", "1h", "24h") +- 'step': Data point resolution (e.g., "1m" for 1-hour duration, "5m" for 24-hour duration) + +The 'query' parameter MUST use metric names that were returned by list_metrics.` + + GetLabelNamesPrompt = `Get all label names (dimensions) available for filtering a metric. + +WHEN TO USE (after calling list_metrics): +- To discover how to filter metrics (by namespace, pod, service, etc.) +- Before constructing label matchers in PromQL queries + +The 'metric' parameter should use a metric name from list_metrics output.` + + GetLabelValuesPrompt = `Get all unique values for a specific label. + +WHEN TO USE (after calling list_metrics and get_label_names): +- To find exact label values for filtering (namespace names, pod names, etc.) +- To see what values exist before constructing queries + +The 'metric' parameter should use a metric name from list_metrics output.` + + GetSeriesPrompt = `Get time series matching selectors and preview cardinality. + +WHEN TO USE (optional, after calling list_metrics): +- To verify label filters match expected series before querying +- To check cardinality and avoid slow queries + +CARDINALITY GUIDANCE: +- <100 series: Safe +- 100-1000: Usually fine +- >1000: Add more label filters + +The selector should use metric names from list_metrics output.` + + GetAlertsPrompt = `Get alerts from Alertmanager. + +WHEN TO USE: +- START HERE when investigating issues: if the user asks about things breaking, errors, failures, outages, services being down, or anything going wrong in the cluster +- When the user mentions a specific alert name - use this tool to get the alert's full labels (namespace, pod, service, etc.) which are essential for further investigation with other tools +- To see currently firing alerts in the cluster +- To check which alerts are active, silenced, or inhibited +- To understand what's happening before diving into metrics or logs + +INVESTIGATION TIP: Alert labels often contain the exact identifiers (pod names, namespaces, job names) needed for targeted queries with prometheus tools. + +FILTERING: +- Use 'active' to filter for only active alerts (not resolved) +- Use 'silenced' to filter for silenced alerts +- Use 'inhibited' to filter for inhibited alerts +- Use 'filter' to apply label matchers (e.g., "alertname=HighCPU") +- Use 'receiver' to filter alerts by receiver name + +All filter parameters are optional. Without filters, all alerts are returned.` + + GetSilencesPrompt = `Get silences from Alertmanager. + +WHEN TO USE: +- To see which alerts are currently silenced +- To check active, pending, or expired silences +- To investigate why certain alerts are not firing notifications + +FILTERING: +- Use 'filter' to apply label matchers to find specific silences + +Silences are used to temporarily mute alerts based on label matchers. This tool helps you understand what is currently silenced in your environment.` +) diff --git a/pkg/resultutil/result.go b/pkg/resultutil/result.go new file mode 100644 index 0000000..91b4606 --- /dev/null +++ b/pkg/resultutil/result.go @@ -0,0 +1,69 @@ +package resultutil + +import ( + "encoding/json" + "fmt" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/mark3labs/mcp-go/mcp" +) + +// Result represents a common tool execution result that can be converted +// to either MCP or Toolset result types. +type Result struct { + // Data holds the structured result data (only set for successful results) + Data any + // JSONText holds the JSON string representation of Data + JSONText string + // Error holds any error that occurred (nil for successful results) + Error error +} + +// NewSuccessResult creates a successful result with structured data. +// The data will be automatically marshaled to JSON. +// If marshaling fails, an error result is returned instead. +func NewSuccessResult(data any) *Result { + jsonBytes, err := json.Marshal(data) + if err != nil { + return &Result{ + Error: fmt.Errorf("failed to marshal result: %w", err), + } + } + + return &Result{ + Data: data, + JSONText: string(jsonBytes), + } +} + +// NewErrorResult creates an error result with the given error. +func NewErrorResult(err error) *Result { + return &Result{ + Error: err, + } +} + +// ToMCPResult converts the Result to an MCP CallToolResult. +// Returns (result, nil) following the MCP pattern where errors +// are encoded in the result, not the error return value. +func (r *Result) ToMCPResult() (*mcp.CallToolResult, error) { + if r.Error != nil { + return mcp.NewToolResultError(r.Error.Error()), nil + } + return mcp.NewToolResultStructured(r.Data, r.JSONText), nil +} + +// ToToolsetResult converts the Result to a Toolset ToolCallResult. +// Returns (result, nil) following the pattern where errors are encoded +// in the ToolCallResult, not the error return value. +func (r *Result) ToToolsetResult() (*api.ToolCallResult, error) { + if r.Error != nil { + return api.NewToolCallResult("", r.Error), nil + } + return api.NewToolCallResult(r.JSONText, nil), nil +} + +// IsError returns true if the result represents an error. +func (r *Result) IsError() bool { + return r.Error != nil +} diff --git a/pkg/resultutil/result_test.go b/pkg/resultutil/result_test.go new file mode 100644 index 0000000..0486c20 --- /dev/null +++ b/pkg/resultutil/result_test.go @@ -0,0 +1,179 @@ +package resultutil + +import ( + "encoding/json" + "errors" + "testing" +) + +// Example output types (similar to what's used in the handlers) +type ExampleOutput struct { + Message string `json:"message"` + Items []string `json:"items"` +} + +func TestNewSuccessResult(t *testing.T) { + output := ExampleOutput{ + Message: "test message", + Items: []string{"item1", "item2"}, + } + + result := NewSuccessResult(output) + + if result.IsError() { + t.Errorf("expected success result, got error: %v", result.Error) + } + + if result.Data == nil { + t.Error("expected Data to be set") + } + + if result.JSONText == "" { + t.Error("expected JSONText to be set") + } + + // Verify JSON is valid and matches the data + var decoded ExampleOutput + if err := json.Unmarshal([]byte(result.JSONText), &decoded); err != nil { + t.Errorf("failed to unmarshal JSONText: %v", err) + } + + if decoded.Message != output.Message { + t.Errorf("expected message %q, got %q", output.Message, decoded.Message) + } +} + +func TestNewErrorResult(t *testing.T) { + errorMsg := "test error message" + result := NewErrorResult(errors.New(errorMsg)) + + if !result.IsError() { + t.Error("expected error result") + } + + if result.Error == nil { + t.Error("expected Error to be set") + } + + if result.Error.Error() != errorMsg { + t.Errorf("expected error message %q, got %q", errorMsg, result.Error.Error()) + } + + if result.Data != nil { + t.Error("expected Data to be nil for error result") + } +} + +func TestToMCPResult_Success(t *testing.T) { + output := ExampleOutput{ + Message: "test", + Items: []string{"a", "b"}, + } + + result := NewSuccessResult(output) + mcpResult, err := result.ToMCPResult() + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if mcpResult == nil { + t.Error("expected non-nil MCP result") + } + + // The MCP result should contain the structured data + if mcpResult.Content == nil { + t.Error("expected MCP result content to be set") + } +} + +func TestToMCPResult_Error(t *testing.T) { + result := NewErrorResult(errors.New("test error")) + mcpResult, err := result.ToMCPResult() + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if mcpResult == nil { + t.Error("expected non-nil MCP result") + } + + // MCP error results should have isError set to true + if !mcpResult.IsError { + t.Error("expected MCP result to have IsError=true") + } +} + +func TestToToolsetResult_Success(t *testing.T) { + output := ExampleOutput{ + Message: "test", + Items: []string{"a", "b"}, + } + + result := NewSuccessResult(output) + toolsetResult, err := result.ToToolsetResult() + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if toolsetResult == nil { + t.Error("expected non-nil Toolset result") + } + + // The Toolset result should contain the JSON text + if toolsetResult.Error != nil { + t.Errorf("expected no error in result, got: %v", toolsetResult.Error) + } + + if toolsetResult.Content == "" { + t.Error("expected content to be set") + } + + // Verify the content is valid JSON + var decoded ExampleOutput + if err := json.Unmarshal([]byte(toolsetResult.Content), &decoded); err != nil { + t.Errorf("failed to unmarshal content: %v", err) + } +} + +func TestToToolsetResult_Error(t *testing.T) { + errorMsg := "test error" + result := NewErrorResult(errors.New(errorMsg)) + toolsetResult, err := result.ToToolsetResult() + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if toolsetResult == nil { + t.Error("expected non-nil Toolset result") + } + + // The Toolset result should contain the error + if toolsetResult.Error == nil { + t.Error("expected error in result") + } + + if toolsetResult.Error.Error() != errorMsg { + t.Errorf("expected error message %q, got %q", errorMsg, toolsetResult.Error.Error()) + } +} + +func TestMarshalError(t *testing.T) { + // Create a type that can't be marshaled to JSON + type UnmarshalableType struct { + Channel chan int // channels can't be marshaled to JSON + } + + result := NewSuccessResult(UnmarshalableType{Channel: make(chan int)}) + + if !result.IsError() { + t.Error("expected error result when marshaling fails") + } + + if result.Error == nil { + t.Error("expected Error to be set") + } +} diff --git a/pkg/toolset/tools/handlers.go b/pkg/toolset/tools/handlers.go index bc918a2..d39ec9d 100644 --- a/pkg/toolset/tools/handlers.go +++ b/pkg/toolset/tools/handlers.go @@ -1,124 +1,13 @@ package tools import ( - "encoding/json" "fmt" - "log/slog" - "maps" - "strings" - "time" "github.com/containers/kubernetes-mcp-server/pkg/api" - "github.com/prometheus/common/model" - "github.com/rhobs/obs-mcp/pkg/prometheus" + "github.com/rhobs/obs-mcp/pkg/handlers" ) -// Output types for tool results - -// ListMetricsOutput defines the output schema for the list_metrics tool. -type ListMetricsOutput struct { - Metrics []string `json:"metrics"` -} - -// InstantQueryOutput defines the output schema for the execute_instant_query tool. -type InstantQueryOutput struct { - ResultType string `json:"resultType"` - Result []InstantResult `json:"result"` - Warnings []string `json:"warnings,omitempty"` -} - -// InstantResult represents a single instant query result. -type InstantResult struct { - Metric map[string]string `json:"metric"` - Value []any `json:"value"` -} - -// RangeQueryOutput defines the output schema for the execute_range_query tool. -type RangeQueryOutput struct { - ResultType string `json:"resultType"` - Result []SeriesResult `json:"result"` - Warnings []string `json:"warnings,omitempty"` -} - -// SeriesResult represents a single time series result from a range query. -type SeriesResult struct { - Metric map[string]string `json:"metric"` - Values [][]any `json:"values"` -} - -// LabelNamesOutput defines the output schema for the get_label_names tool. -type LabelNamesOutput struct { - Labels []string `json:"labels"` -} - -// LabelValuesOutput defines the output schema for the get_label_values tool. -type LabelValuesOutput struct { - Values []string `json:"values"` -} - -// SeriesOutput defines the output schema for the get_series tool. -type SeriesOutput struct { - Series []map[string]string `json:"series"` - Cardinality int `json:"cardinality"` -} - -// AlertsOutput defines the output schema for the get_alerts tool. -type AlertsOutput struct { - Alerts []Alert `json:"alerts"` -} - -// Alert represents a single alert from Alertmanager. -type Alert struct { - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` - StartsAt string `json:"startsAt"` - EndsAt string `json:"endsAt,omitempty"` - Status AlertStatus `json:"status"` -} - -// AlertStatus represents the status of an alert. -type AlertStatus struct { - State string `json:"state"` - SilencedBy []string `json:"silencedBy,omitempty"` - InhibitedBy []string `json:"inhibitedBy,omitempty"` -} - -// SilencesOutput defines the output schema for the get_silences tool. -type SilencesOutput struct { - Silences []Silence `json:"silences"` -} - -// Silence represents a single silence from Alertmanager. -type Silence struct { - ID string `json:"id"` - Status SilenceStatus `json:"status"` - Matchers []Matcher `json:"matchers"` - StartsAt string `json:"startsAt"` - EndsAt string `json:"endsAt"` - CreatedBy string `json:"createdBy"` - Comment string `json:"comment"` -} - -// SilenceStatus represents the status of a silence. -type SilenceStatus struct { - State string `json:"state"` -} - -// Matcher represents a label matcher for a silence. -type Matcher struct { - Name string `json:"name"` - Value string `json:"value"` - IsRegex bool `json:"isRegex"` - IsEqual bool `json:"isEqual"` -} - -// Helper function to create error results -func errorResult(msg string) (*api.ToolCallResult, error) { - slog.Info("Query execution error: " + msg) - return api.NewToolCallResult("", fmt.Errorf("%s", msg)), nil -} - // Helper function to get string argument with default func getStringArg(params api.ToolHandlerParams, key string, defaultValue string) string { if val, ok := params.GetArguments()[key].(string); ok && val != "" { @@ -129,593 +18,133 @@ func getStringArg(params api.ToolHandlerParams, key string, defaultValue string) // ListMetricsHandler handles the listing of available Prometheus metrics. func ListMetricsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - slog.Info("ListMetricsHandler called") - promClient, err := getPromClient(params) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - metrics, err := promClient.ListMetrics(params.Context) - if err != nil { - return errorResult(fmt.Sprintf("failed to list metrics: %s", err.Error())) - } - - slog.Info("ListMetricsHandler executed successfully", "resultLength", len(metrics)) - slog.Debug("ListMetricsHandler results", "results", metrics) - - output := ListMetricsOutput{Metrics: metrics} - result, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal metrics: %s", err.Error())) + return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - return api.NewToolCallResult(string(result), nil), nil + return handlers.ListMetricsHandler(params.Context, promClient).ToToolsetResult() } // ExecuteInstantQueryHandler handles the execution of Prometheus instant queries. func ExecuteInstantQueryHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - slog.Info("ExecuteInstantQueryHandler called") - promClient, err := getPromClient(params) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get required query parameter - query, ok := params.GetArguments()["query"].(string) - if !ok || query == "" { - return errorResult("query parameter is required and must be a string") - } - - // Get optional time parameter - timeStr := getStringArg(params, "time", "") - - var queryTime time.Time - if timeStr == "" { - queryTime = time.Now() - } else { - queryTime, err = prometheus.ParseTimestamp(timeStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid time format: %s", err.Error())) - } - } - - // Execute the instant query - result, err := promClient.ExecuteInstantQuery(params.Context, query, queryTime) - if err != nil { - return errorResult(fmt.Sprintf("failed to execute instant query: %s", err.Error())) - } - - // Convert to structured output - output := InstantQueryOutput{ - ResultType: fmt.Sprintf("%v", result["resultType"]), - } - - resVector, ok := result["result"].(model.Vector) - if ok { - slog.Info("ExecuteInstantQueryHandler executed successfully", "resultLength", len(resVector)) - slog.Debug("ExecuteInstantQueryHandler results", "results", resVector) - - output.Result = make([]InstantResult, len(resVector)) - for i, sample := range resVector { - labels := make(map[string]string) - for k, v := range sample.Metric { - labels[string(k)] = string(v) - } - output.Result[i] = InstantResult{ - Metric: labels, - Value: []any{float64(sample.Timestamp) / 1000, sample.Value.String()}, - } - } - } else { - slog.Info("ExecuteInstantQueryHandler executed successfully (unknown format)", "result", result) + return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - if warnings, ok := result["warnings"].([]string); ok { - output.Warnings = warnings + input := handlers.InstantQueryInput{ + Query: getStringArg(params, "query", ""), + Time: getStringArg(params, "time", ""), } - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal result: %s", err.Error())) - } - - return api.NewToolCallResult(string(jsonResult), nil), nil + return handlers.ExecuteInstantQueryHandler(params.Context, promClient, input).ToToolsetResult() } // ExecuteRangeQueryHandler handles the execution of Prometheus range queries. func ExecuteRangeQueryHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - slog.Info("ExecuteRangeQueryHandler called") - promClient, err := getPromClient(params) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get required query parameter - query, ok := params.GetArguments()["query"].(string) - if !ok || query == "" { - return errorResult("query parameter is required and must be a string") - } - - // Get required step parameter - step, ok := params.GetArguments()["step"].(string) - if !ok || step == "" { - return errorResult("step parameter is required and must be a string") - } - - // Parse step duration - stepDuration, err := model.ParseDuration(step) - if err != nil { - return errorResult(fmt.Sprintf("invalid step format: %s", err.Error())) - } - - // Get optional parameters - startStr := getStringArg(params, "start", "") - endStr := getStringArg(params, "end", "") - durationStr := getStringArg(params, "duration", "") - - // Validate parameter combinations - if startStr != "" && endStr != "" && durationStr != "" { - return errorResult("cannot specify both start/end and duration parameters") - } - - if (startStr != "" && endStr == "") || (startStr == "" && endStr != "") { - return errorResult("both start and end must be provided together") - } - - var startTime, endTime time.Time - - // Handle duration-based query (default to 1h if nothing specified) - if durationStr != "" || (startStr == "" && endStr == "") { - if durationStr == "" { - durationStr = "1h" - } - - duration, err := model.ParseDuration(durationStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid duration format: %s", err.Error())) - } - - endTime = time.Now() - startTime = endTime.Add(-time.Duration(duration)) - } else { - // Handle explicit start/end times - startTime, err = prometheus.ParseTimestamp(startStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) - } - - endTime, err = prometheus.ParseTimestamp(endStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) - } - } - - // Execute the range query - result, err := promClient.ExecuteRangeQuery(params.Context, query, startTime, endTime, time.Duration(stepDuration)) - if err != nil { - return errorResult(fmt.Sprintf("failed to execute range query: %s", err.Error())) - } - - // Convert to structured output - output := RangeQueryOutput{ - ResultType: fmt.Sprintf("%v", result["resultType"]), - } - - resMatrix, ok := result["result"].(model.Matrix) - if ok { - slog.Info("ExecuteRangeQueryHandler executed successfully", "resultLength", resMatrix.Len()) - slog.Debug("ExecuteRangeQueryHandler results", "results", resMatrix) - - output.Result = make([]SeriesResult, len(resMatrix)) - for i, series := range resMatrix { - labels := make(map[string]string) - for k, v := range series.Metric { - labels[string(k)] = string(v) - } - values := make([][]any, len(series.Values)) - for j, sample := range series.Values { - values[j] = []any{float64(sample.Timestamp) / 1000, sample.Value.String()} - } - output.Result[i] = SeriesResult{ - Metric: labels, - Values: values, - } - } - } else { - slog.Info("ExecuteRangeQueryHandler executed successfully (unknown format)", "result", result) + return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - if warnings, ok := result["warnings"].([]string); ok { - output.Warnings = warnings - } - - // Convert to JSON for fallback text - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal result: %s", err.Error())) + input := handlers.RangeQueryInput{ + Query: getStringArg(params, "query", ""), + Step: getStringArg(params, "step", ""), + Start: getStringArg(params, "start", ""), + End: getStringArg(params, "end", ""), + Duration: getStringArg(params, "duration", ""), } - return api.NewToolCallResult(string(jsonResult), nil), nil + return handlers.ExecuteRangeQueryHandler(params.Context, promClient, input).ToToolsetResult() } // GetLabelNamesHandler handles the retrieval of label names. func GetLabelNamesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - slog.Info("GetLabelNamesHandler called") - promClient, err := getPromClient(params) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get optional parameters - metric := getStringArg(params, "metric", "") - startStr := getStringArg(params, "start", "") - endStr := getStringArg(params, "end", "") - - // Default to last hour if not specified - var startTime, endTime time.Time - if startStr == "" && endStr == "" { - endTime = time.Now() - startTime = endTime.Add(-prometheus.ListMetricsTimeRange) - } else { - if startStr != "" { - startTime, err = prometheus.ParseTimestamp(startStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) - } - } - if endStr != "" { - endTime, err = prometheus.ParseTimestamp(endStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) - } - } - } - - // Get label names - labels, err := promClient.GetLabelNames(params.Context, metric, startTime, endTime) - if err != nil { - return errorResult(fmt.Sprintf("failed to get label names: %s", err.Error())) + return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - output := LabelNamesOutput{Labels: labels} - - slog.Info("GetLabelNamesHandler executed successfully", "labelCount", len(labels)) - slog.Debug("GetLabelNamesHandler results", "results", labels) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal label names: %s", err.Error())) + input := handlers.LabelNamesInput{ + Metric: getStringArg(params, "metric", ""), + Start: getStringArg(params, "start", ""), + End: getStringArg(params, "end", ""), } - return api.NewToolCallResult(string(jsonResult), nil), nil + return handlers.GetLabelNamesHandler(params.Context, promClient, input).ToToolsetResult() } // GetLabelValuesHandler handles the retrieval of label values. func GetLabelValuesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - slog.Info("GetLabelValuesHandler called") - promClient, err := getPromClient(params) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get required label parameter - label, ok := params.GetArguments()["label"].(string) - if !ok || label == "" { - return errorResult("label parameter is required and must be a string") - } - - // Get optional parameters - metric := getStringArg(params, "metric", "") - startStr := getStringArg(params, "start", "") - endStr := getStringArg(params, "end", "") - - // Default to last hour if not specified - var startTime, endTime time.Time - if startStr == "" && endStr == "" { - endTime = time.Now() - startTime = endTime.Add(-prometheus.ListMetricsTimeRange) - } else { - if startStr != "" { - startTime, err = prometheus.ParseTimestamp(startStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) - } - } - if endStr != "" { - endTime, err = prometheus.ParseTimestamp(endStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) - } - } + return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - // Get label values - values, err := promClient.GetLabelValues(params.Context, label, metric, startTime, endTime) - if err != nil { - return errorResult(fmt.Sprintf("failed to get label values: %s", err.Error())) + input := handlers.LabelValuesInput{ + Label: getStringArg(params, "label", ""), + Metric: getStringArg(params, "metric", ""), + Start: getStringArg(params, "start", ""), + End: getStringArg(params, "end", ""), } - output := LabelValuesOutput{Values: values} - - slog.Info("GetLabelValuesHandler executed successfully", "valueCount", len(values)) - slog.Debug("GetLabelValuesHandler results", "results", values) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal label values: %s", err.Error())) - } - - return api.NewToolCallResult(string(jsonResult), nil), nil + return handlers.GetLabelValuesHandler(params.Context, promClient, input).ToToolsetResult() } // GetSeriesHandler handles the retrieval of time series. func GetSeriesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - slog.Info("GetSeriesHandler called") - promClient, err := getPromClient(params) if err != nil { - return errorResult(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())) - } - - // Get required matches parameter - matchesStr, ok := params.GetArguments()["matches"].(string) - if !ok || matchesStr == "" { - return errorResult("matches parameter is required and must be a string") - } - - // Parse matches - could be comma-separated - matches := []string{matchesStr} - - // Get optional parameters - startStr := getStringArg(params, "start", "") - endStr := getStringArg(params, "end", "") - - // Default to last hour if not specified - var startTime, endTime time.Time - if startStr == "" && endStr == "" { - endTime = time.Now() - startTime = endTime.Add(-prometheus.ListMetricsTimeRange) - } else { - if startStr != "" { - startTime, err = prometheus.ParseTimestamp(startStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid start time format: %s", err.Error())) - } - } - if endStr != "" { - endTime, err = prometheus.ParseTimestamp(endStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid end time format: %s", err.Error())) - } - } - } - - // Get series - series, err := promClient.GetSeries(params.Context, matches, startTime, endTime) - if err != nil { - return errorResult(fmt.Sprintf("failed to get series: %s", err.Error())) - } - - output := SeriesOutput{ - Series: series, - Cardinality: len(series), + return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - slog.Info("GetSeriesHandler executed successfully", "cardinality", len(series)) - slog.Debug("GetSeriesHandler results", "results", series) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal series: %s", err.Error())) + input := handlers.SeriesInput{ + Matches: getStringArg(params, "matches", ""), + Start: getStringArg(params, "start", ""), + End: getStringArg(params, "end", ""), } - return api.NewToolCallResult(string(jsonResult), nil), nil + return handlers.GetSeriesHandler(params.Context, promClient, input).ToToolsetResult() } // GetAlertsHandler handles the retrieval of alerts from Alertmanager. func GetAlertsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - slog.Info("GetAlertsHandler called") - amClient, err := getAlertmanagerClient(params) if err != nil { - return errorResult(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())) + return api.NewToolCallResult("", fmt.Errorf("failed to create Alertmanager client: %w", err)), nil } - // Get optional boolean parameters - var active, silenced, inhibited, unprocessed *bool + // Parse boolean parameters + var input handlers.AlertsInput if activeVal, ok := params.GetArguments()["active"].(bool); ok { - active = &activeVal + input.Active = &activeVal } if silencedVal, ok := params.GetArguments()["silenced"].(bool); ok { - silenced = &silencedVal + input.Silenced = &silencedVal } if inhibitedVal, ok := params.GetArguments()["inhibited"].(bool); ok { - inhibited = &inhibitedVal + input.Inhibited = &inhibitedVal } if unprocessedVal, ok := params.GetArguments()["unprocessed"].(bool); ok { - unprocessed = &unprocessedVal - } - - // Get optional string parameters - filterStr := getStringArg(params, "filter", "") - receiver := getStringArg(params, "receiver", "") - var filter []string - if filterStr != "" { - // Split by comma if multiple filters are provided - filter = strings.Split(filterStr, ",") - for i := range filter { - filter[i] = strings.TrimSpace(filter[i]) - } + input.Unprocessed = &unprocessedVal } + input.Filter = getStringArg(params, "filter", "") + input.Receiver = getStringArg(params, "receiver", "") - alerts, err := amClient.GetAlerts(params.Context, active, silenced, inhibited, unprocessed, filter, receiver) - if err != nil { - return errorResult(fmt.Sprintf("failed to get alerts: %s", err.Error())) - } - - // Convert to output format - output := AlertsOutput{ - Alerts: make([]Alert, len(alerts)), - } - - for i, alert := range alerts { - labels := make(map[string]string) - maps.Copy(labels, alert.Labels) - - annotations := make(map[string]string) - maps.Copy(annotations, alert.Annotations) - - var silencedBy, inhibitedBy []string - var state string - if alert.Status != nil { - if alert.Status.SilencedBy != nil { - silencedBy = alert.Status.SilencedBy - } - if alert.Status.InhibitedBy != nil { - inhibitedBy = alert.Status.InhibitedBy - } - if alert.Status.State != nil { - state = *alert.Status.State - } - } - if silencedBy == nil { - silencedBy = []string{} - } - if inhibitedBy == nil { - inhibitedBy = []string{} - } - - var startsAt, endsAt string - if alert.StartsAt != nil { - startsAt = alert.StartsAt.String() - } - if alert.EndsAt != nil { - endsAt = alert.EndsAt.String() - } - - output.Alerts[i] = Alert{ - Labels: labels, - Annotations: annotations, - StartsAt: startsAt, - EndsAt: endsAt, - Status: AlertStatus{ - State: state, - SilencedBy: silencedBy, - InhibitedBy: inhibitedBy, - }, - } - } - - slog.Info("GetAlertsHandler executed successfully", "alertCount", len(alerts)) - slog.Debug("GetAlertsHandler results", "results", output.Alerts) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal alerts: %s", err.Error())) - } - - return api.NewToolCallResult(string(jsonResult), nil), nil + return handlers.GetAlertsHandler(params.Context, amClient, input).ToToolsetResult() } // GetSilencesHandler handles the retrieval of silences from Alertmanager. func GetSilencesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - slog.Info("GetSilencesHandler called") - amClient, err := getAlertmanagerClient(params) if err != nil { - return errorResult(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())) - } - - filterStr := getStringArg(params, "filter", "") - var filter []string - if filterStr != "" { - // Split by comma if multiple filters are provided - filter = strings.Split(filterStr, ",") - for i := range filter { - filter[i] = strings.TrimSpace(filter[i]) - } + return api.NewToolCallResult("", fmt.Errorf("failed to create Alertmanager client: %w", err)), nil } - silences, err := amClient.GetSilences(params.Context, filter) - if err != nil { - return errorResult(fmt.Sprintf("failed to get silences: %s", err.Error())) - } - - output := SilencesOutput{ - Silences: make([]Silence, len(silences)), - } - - for i, silence := range silences { - matchers := make([]Matcher, len(silence.Matchers)) - for j, m := range silence.Matchers { - isEqual := true - if m.IsEqual != nil { - isEqual = *m.IsEqual - } - var name, value string - var isRegex bool - if m.Name != nil { - name = *m.Name - } - if m.Value != nil { - value = *m.Value - } - if m.IsRegex != nil { - isRegex = *m.IsRegex - } - matchers[j] = Matcher{ - Name: name, - Value: value, - IsRegex: isRegex, - IsEqual: isEqual, - } - } - - var id, state, createdBy, comment, startsAt, endsAt string - if silence.ID != nil { - id = *silence.ID - } - if silence.Status != nil && silence.Status.State != nil { - state = *silence.Status.State - } - if silence.StartsAt != nil { - startsAt = silence.StartsAt.String() - } - if silence.EndsAt != nil { - endsAt = silence.EndsAt.String() - } - if silence.CreatedBy != nil { - createdBy = *silence.CreatedBy - } - if silence.Comment != nil { - comment = *silence.Comment - } - - output.Silences[i] = Silence{ - ID: id, - Status: SilenceStatus{ - State: state, - }, - Matchers: matchers, - StartsAt: startsAt, - EndsAt: endsAt, - CreatedBy: createdBy, - Comment: comment, - } - } - - slog.Info("GetSilencesHandler executed successfully", "silenceCount", len(silences)) - slog.Debug("GetSilencesHandler results", "results", output.Silences) - - jsonResult, err := json.Marshal(output) - if err != nil { - return errorResult(fmt.Sprintf("failed to marshal silences: %s", err.Error())) + input := handlers.SilencesInput{ + Filter: getStringArg(params, "filter", ""), } - return api.NewToolCallResult(string(jsonResult), nil), nil + return handlers.GetSilencesHandler(params.Context, amClient, input).ToToolsetResult() } diff --git a/pkg/toolset/tools/tools.go b/pkg/toolset/tools/tools.go index 6b07570..7dd6bd5 100644 --- a/pkg/toolset/tools/tools.go +++ b/pkg/toolset/tools/tools.go @@ -5,6 +5,7 @@ import ( "k8s.io/utils/ptr" "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/rhobs/obs-mcp/pkg/prompts" ) // InitListMetrics creates the list_metrics tool. @@ -12,22 +13,8 @@ func InitListMetrics() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ - Name: "list_metrics", - Description: `MANDATORY FIRST STEP: List all available metric names in Prometheus. - -YOU MUST CALL THIS TOOL BEFORE ANY OTHER QUERY TOOL - -This tool MUST be called first for EVERY observability question to: -1. Discover what metrics actually exist in this environment -2. Find the EXACT metric name to use in queries -3. Avoid querying non-existent metrics - -NEVER skip this step. NEVER guess metric names. Metric names vary between environments. - -After calling this tool: -1. Search the returned list for relevant metrics -2. Use the EXACT metric name found in subsequent queries -3. If no relevant metric exists, inform the user`, + Name: "list_metrics", + Description: prompts.ListMetricsPrompt, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{}, @@ -50,17 +37,8 @@ func InitExecuteInstantQuery() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ - Name: "execute_instant_query", - Description: `Execute a PromQL instant query to get current/point-in-time values. - -PREREQUISITE: You MUST call list_metrics first to verify the metric exists - -WHEN TO USE: -- Current state questions: "What is the current error rate?" -- Point-in-time snapshots: "How many pods are running?" -- Latest values: "Which pods are in Pending state?" - -The 'query' parameter MUST use metric names that were returned by list_metrics.`, + Name: "execute_instant_query", + Description: prompts.ExecuteInstantQueryPrompt, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -93,21 +71,8 @@ func InitExecuteRangeQuery() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ - Name: "execute_range_query", - Description: `Execute a PromQL range query to get time-series data over a period. - -PREREQUISITE: You MUST call list_metrics first to verify the metric exists - -WHEN TO USE: -- Trends over time: "What was CPU usage over the last hour?" -- Rate calculations: "How many requests per second?" -- Historical analysis: "Were there any restarts in the last 5 minutes?" - -TIME PARAMETERS: -- 'duration': Look back from now (e.g., "5m", "1h", "24h") -- 'step': Data point resolution (e.g., "1m" for 1-hour duration, "5m" for 24-hour duration) - -The 'query' parameter MUST use metric names that were returned by list_metrics.`, + Name: "execute_range_query", + Description: prompts.ExecuteRangeQueryPrompt, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -154,14 +119,8 @@ func InitGetLabelNames() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ - Name: "get_label_names", - Description: `Get all label names (dimensions) available for filtering a metric. - -WHEN TO USE (after calling list_metrics): -- To discover how to filter metrics (by namespace, pod, service, etc.) -- Before constructing label matchers in PromQL queries - -The 'metric' parameter should use a metric name from list_metrics output.`, + Name: "get_label_names", + Description: prompts.GetLabelNamesPrompt, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -197,14 +156,8 @@ func InitGetLabelValues() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ - Name: "get_label_values", - Description: `Get all unique values for a specific label. - -WHEN TO USE (after calling list_metrics and get_label_names): -- To find exact label values for filtering (namespace names, pod names, etc.) -- To see what values exist before constructing queries - -The 'metric' parameter should use a metric name from list_metrics output.`, + Name: "get_label_values", + Description: prompts.GetLabelValuesPrompt, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -245,19 +198,8 @@ func InitGetSeries() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ - Name: "get_series", - Description: `Get time series matching selectors and preview cardinality. - -WHEN TO USE (optional, after calling list_metrics): -- To verify label filters match expected series before querying -- To check cardinality and avoid slow queries - -CARDINALITY GUIDANCE: -- <100 series: Safe -- 100-1000: Usually fine -- >1000: Add more label filters - -The selector should use metric names from list_metrics output.`, + Name: "get_series", + Description: prompts.GetSeriesPrompt, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -294,26 +236,8 @@ func InitGetAlerts() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ - Name: "get_alerts", - Description: `Get alerts from Alertmanager. - -WHEN TO USE: -- START HERE when investigating issues: if the user asks about things breaking, errors, failures, outages, services being down, or anything going wrong in the cluster -- When the user mentions a specific alert name - use this tool to get the alert's full labels (namespace, pod, service, etc.) which are essential for further investigation with other tools -- To see currently firing alerts in the cluster -- To check which alerts are active, silenced, or inhibited -- To understand what's happening before diving into metrics or logs - -INVESTIGATION TIP: Alert labels often contain the exact identifiers (pod names, namespaces, job names) needed for targeted queries with prometheus tools. - -FILTERING: -- Use 'active' to filter for only active alerts (not resolved) -- Use 'silenced' to filter for silenced alerts -- Use 'inhibited' to filter for inhibited alerts -- Use 'filter' to apply label matchers (e.g., "alertname=HighCPU") -- Use 'receiver' to filter alerts by receiver name - -All filter parameters are optional. Without filters, all alerts are returned.`, + Name: "get_alerts", + Description: prompts.GetAlertsPrompt, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -361,18 +285,8 @@ func InitGetSilences() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ - Name: "get_silences", - Description: `Get silences from Alertmanager. - -WHEN TO USE: -- To see which alerts are currently silenced -- To check active, pending, or expired silences -- To investigate why certain alerts are not firing notifications - -FILTERING: -- Use 'filter' to apply label matchers to find specific silences - -Silences are used to temporarily mute alerts based on label matchers. This tool helps you understand what is currently silenced in your environment.`, + Name: "get_silences", + Description: prompts.GetSilencesPrompt, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ diff --git a/pkg/toolset/toolset.go b/pkg/toolset/toolset.go index 78e41f2..ceefa8f 100644 --- a/pkg/toolset/toolset.go +++ b/pkg/toolset/toolset.go @@ -5,6 +5,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/toolsets" + "github.com/rhobs/obs-mcp/pkg/prompts" "github.com/rhobs/obs-mcp/pkg/toolset/tools" ) @@ -20,38 +21,7 @@ func (t *Toolset) GetName() string { // GetDescription returns a human-readable description of the toolset. func (t *Toolset) GetDescription() string { - return `Advanced observability tools for comprehensive Prometheus metrics querying with guardrails and discovery features. - -## MANDATORY WORKFLOW - ALWAYS FOLLOW THIS ORDER - -**STEP 1: ALWAYS call list_metrics FIRST** -- This is NON-NEGOTIABLE for EVERY question -- NEVER skip this step, even if you think you know the metric name -- NEVER guess metric names - they vary between environments -- Search the returned list to find the exact metric name that exists - -**STEP 2: Call get_label_names for the metric you found** -- Discover available labels for filtering (namespace, pod, service, etc.) - -**STEP 3: Call get_label_values if you need specific filter values** -- Find exact label values (e.g., actual namespace names, pod names) - -**STEP 4: Execute your query using the EXACT metric name from Step 1** -- Use execute_instant_query for current state questions -- Use execute_range_query for trends/historical analysis - -## CRITICAL RULES - -1. **NEVER query a metric without first calling list_metrics** - You must verify the metric exists -2. **Use EXACT metric names from list_metrics output** - Do not modify or guess metric names -3. **If list_metrics doesn't return a relevant metric, tell the user** - Don't fabricate queries -4. **BE PROACTIVE** - Complete all steps automatically without asking for confirmation. When you find a relevant metric, proceed to query. -5. **UNDERSTAND TIME FRAMES** - Use the start and end parameters to specify the time frame for your queries. You can use NOW for current time liberally across parameters, and NOW±duration for relative time frames. - -## Query Type Selection - -- **execute_instant_query**: Current values, point-in-time snapshots, "right now" questions -- **execute_range_query**: Trends over time, rate calculations, historical analysis` + return prompts.ServerPrompt } // GetTools returns all tools provided by this toolset. From 69eca43374d5e126797f15470d873584127c3765 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Fri, 6 Feb 2026 09:32:23 +0000 Subject: [PATCH 4/8] Update Go Signed-off-by: Saswata Mukherjee --- .github/env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/env b/.github/env index 92b4d18..456c27e 100644 --- a/.github/env +++ b/.github/env @@ -1,2 +1,2 @@ -golang-version=1.24 +golang-version=1.25 kind-version=v0.30.0 From 223c7974882093cfc70fca967b65f8f6b64fceb0 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Fri, 6 Feb 2026 10:00:28 +0000 Subject: [PATCH 5/8] Fix lint Signed-off-by: Saswata Mukherjee --- .golangci.yml | 2 + pkg/mcp/tools_test.go | 1 + pkg/resultutil/result.go | 2 + pkg/resultutil/result_test.go | 10 ++--- pkg/toolset/tools/handlers.go | 2 +- pkg/toolset/tools/prometheus_client.go | 55 +------------------------- pkg/toolset/tools/tools.go | 2 +- pkg/toolset/toolset.go | 1 + 8 files changed, 14 insertions(+), 61 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index c711a2f..c68fe75 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -31,6 +31,8 @@ linters: - diagnostic - style - performance + disabled-checks: + - hugeParam exclusions: presets: - comments diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index cce1db6..d386249 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mark3labs/mcp-go/mcp" + "github.com/rhobs/obs-mcp/pkg/handlers" ) diff --git a/pkg/resultutil/result.go b/pkg/resultutil/result.go index 91b4606..9212fef 100644 --- a/pkg/resultutil/result.go +++ b/pkg/resultutil/result.go @@ -48,6 +48,7 @@ func NewErrorResult(err error) *Result { // are encoded in the result, not the error return value. func (r *Result) ToMCPResult() (*mcp.CallToolResult, error) { if r.Error != nil { + //nolint:nilerr // MCP pattern encodes errors in result, not error return return mcp.NewToolResultError(r.Error.Error()), nil } return mcp.NewToolResultStructured(r.Data, r.JSONText), nil @@ -58,6 +59,7 @@ func (r *Result) ToMCPResult() (*mcp.CallToolResult, error) { // in the ToolCallResult, not the error return value. func (r *Result) ToToolsetResult() (*api.ToolCallResult, error) { if r.Error != nil { + //nolint:nilerr // Toolset pattern encodes errors in result, not error return return api.NewToolCallResult("", r.Error), nil } return api.NewToolCallResult(r.JSONText, nil), nil diff --git a/pkg/resultutil/result_test.go b/pkg/resultutil/result_test.go index 0486c20..bc97deb 100644 --- a/pkg/resultutil/result_test.go +++ b/pkg/resultutil/result_test.go @@ -78,7 +78,7 @@ func TestToMCPResult_Success(t *testing.T) { } if mcpResult == nil { - t.Error("expected non-nil MCP result") + t.Fatal("expected non-nil MCP result") } // The MCP result should contain the structured data @@ -96,7 +96,7 @@ func TestToMCPResult_Error(t *testing.T) { } if mcpResult == nil { - t.Error("expected non-nil MCP result") + t.Fatal("expected non-nil MCP result") } // MCP error results should have isError set to true @@ -119,7 +119,7 @@ func TestToToolsetResult_Success(t *testing.T) { } if toolsetResult == nil { - t.Error("expected non-nil Toolset result") + t.Fatal("expected non-nil Toolset result") } // The Toolset result should contain the JSON text @@ -148,12 +148,12 @@ func TestToToolsetResult_Error(t *testing.T) { } if toolsetResult == nil { - t.Error("expected non-nil Toolset result") + t.Fatal("expected non-nil Toolset result") } // The Toolset result should contain the error if toolsetResult.Error == nil { - t.Error("expected error in result") + t.Fatal("expected error in result") } if toolsetResult.Error.Error() != errorMsg { diff --git a/pkg/toolset/tools/handlers.go b/pkg/toolset/tools/handlers.go index d39ec9d..3516355 100644 --- a/pkg/toolset/tools/handlers.go +++ b/pkg/toolset/tools/handlers.go @@ -9,7 +9,7 @@ import ( ) // Helper function to get string argument with default -func getStringArg(params api.ToolHandlerParams, key string, defaultValue string) string { +func getStringArg(params api.ToolHandlerParams, key, defaultValue string) string { if val, ok := params.GetArguments()[key].(string); ok && val != "" { return val } diff --git a/pkg/toolset/tools/prometheus_client.go b/pkg/toolset/tools/prometheus_client.go index 1c9ecba..c3b9b69 100644 --- a/pkg/toolset/tools/prometheus_client.go +++ b/pkg/toolset/tools/prometheus_client.go @@ -1,17 +1,11 @@ package tools import ( - "crypto/tls" - "crypto/x509" "fmt" "log/slog" - "net/http" - "os" - "strings" "github.com/containers/kubernetes-mcp-server/pkg/api" promapi "github.com/prometheus/client_golang/api" - promcfg "github.com/prometheus/common/config" "k8s.io/client-go/rest" "github.com/rhobs/obs-mcp/pkg/alertmanager" @@ -20,8 +14,7 @@ import ( ) const ( - defaultServiceAccountCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt" - defaultPrometheusURL = "http://localhost:9090" + defaultPrometheusURL = "http://localhost:9090" ) // getConfig retrieves the obs-mcp toolset configuration from params. @@ -92,52 +85,6 @@ func createAPIConfigFromRESTConfig(params api.ToolHandlerParams, prometheusURL s }, nil } -// createAPIConfigWithToken creates a Prometheus API config with a bearer token. -func createAPIConfigWithToken(prometheusURL, token string, insecure bool) (promapi.Config, error) { - apiConfig := promapi.Config{ - Address: prometheusURL, - } - - useTLS := strings.HasPrefix(prometheusURL, "https://") - if useTLS { - defaultRt := promapi.DefaultRoundTripper.(*http.Transport) - - if insecure { - defaultRt.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } else { - certs, err := createCertPool() - if err != nil { - return promapi.Config{}, err - } - defaultRt.TLSClientConfig = &tls.Config{RootCAs: certs} - } - - if token != "" { - apiConfig.RoundTripper = promcfg.NewAuthorizationCredentialsRoundTripper( - "Bearer", promcfg.NewInlineSecret(token), defaultRt) - } else { - apiConfig.RoundTripper = defaultRt - } - } else { - slog.Warn("Connecting to Prometheus without TLS") - } - - return apiConfig, nil -} - -// createCertPool creates a certificate pool from the service account CA. -func createCertPool() (*x509.CertPool, error) { - certs := x509.NewCertPool() - - pemData, err := os.ReadFile(defaultServiceAccountCAPath) - if err != nil { - slog.Error("Failed to read the CA certificate", "err", err) - return nil, err - } - certs.AppendCertsFromPEM(pemData) - return certs, nil -} - // getAlertmanagerClient creates an Alertmanager client using the toolset configuration. func getAlertmanagerClient(params api.ToolHandlerParams) (alertmanager.Loader, error) { cfg := getConfig(params) diff --git a/pkg/toolset/tools/tools.go b/pkg/toolset/tools/tools.go index 7dd6bd5..4efe013 100644 --- a/pkg/toolset/tools/tools.go +++ b/pkg/toolset/tools/tools.go @@ -1,10 +1,10 @@ package tools import ( + "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/google/jsonschema-go/jsonschema" "k8s.io/utils/ptr" - "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/rhobs/obs-mcp/pkg/prompts" ) diff --git a/pkg/toolset/toolset.go b/pkg/toolset/toolset.go index ceefa8f..ad95549 100644 --- a/pkg/toolset/toolset.go +++ b/pkg/toolset/toolset.go @@ -5,6 +5,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/toolsets" + "github.com/rhobs/obs-mcp/pkg/prompts" "github.com/rhobs/obs-mcp/pkg/toolset/tools" ) From cc6e9f677d6a8689b151348d283a581d980b95e3 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Mon, 9 Feb 2026 10:21:03 +0000 Subject: [PATCH 6/8] Dedup tool definitions and input handling Signed-off-by: Saswata Mukherjee --- pkg/mcp/handlers.go | 60 +------- pkg/mcp/tools.go | 145 ++++--------------- pkg/tooldef/bind.go | 84 +++++++++++ pkg/tooldef/definitions.go | 261 +++++++++++++++++++++++++++++++++ pkg/tooldef/mcp.go | 40 +++++ pkg/tooldef/toolset.go | 60 ++++++++ pkg/tooldef/types.go | 30 ++++ pkg/toolset/tools/handlers.go | 76 +--------- pkg/toolset/tools/tools.go | 265 ++-------------------------------- 9 files changed, 528 insertions(+), 493 deletions(-) create mode 100644 pkg/tooldef/bind.go create mode 100644 pkg/tooldef/definitions.go create mode 100644 pkg/tooldef/mcp.go create mode 100644 pkg/tooldef/toolset.go create mode 100644 pkg/tooldef/types.go diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index c1140fb..8539c45 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -7,6 +7,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/rhobs/obs-mcp/pkg/handlers" + "github.com/rhobs/obs-mcp/pkg/tooldef" ) // ListMetricsHandler handles the listing of available Prometheus metrics. @@ -29,13 +30,7 @@ func ExecuteRangeQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.Call return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.ExecuteRangeQueryHandler(ctx, promClient, handlers.RangeQueryInput{ - Query: req.GetString("query", ""), - Step: req.GetString("step", ""), - Start: req.GetString("start", ""), - End: req.GetString("end", ""), - Duration: req.GetString("duration", ""), - }).ToMCPResult() + return handlers.ExecuteRangeQueryHandler(ctx, promClient, tooldef.BuildRangeQueryInput(req.GetArguments())).ToMCPResult() } } @@ -47,10 +42,7 @@ func ExecuteInstantQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.Ca return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.ExecuteInstantQueryHandler(ctx, promClient, handlers.InstantQueryInput{ - Query: req.GetString("query", ""), - Time: req.GetString("time", ""), - }).ToMCPResult() + return handlers.ExecuteInstantQueryHandler(ctx, promClient, tooldef.BuildInstantQueryInput(req.GetArguments())).ToMCPResult() } } @@ -62,11 +54,7 @@ func GetLabelNamesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallTool return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.GetLabelNamesHandler(ctx, promClient, handlers.LabelNamesInput{ - Metric: req.GetString("metric", ""), - Start: req.GetString("start", ""), - End: req.GetString("end", ""), - }).ToMCPResult() + return handlers.GetLabelNamesHandler(ctx, promClient, tooldef.BuildLabelNamesInput(req.GetArguments())).ToMCPResult() } } @@ -78,12 +66,7 @@ func GetLabelValuesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToo return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.GetLabelValuesHandler(ctx, promClient, handlers.LabelValuesInput{ - Label: req.GetString("label", ""), - Metric: req.GetString("metric", ""), - Start: req.GetString("start", ""), - End: req.GetString("end", ""), - }).ToMCPResult() + return handlers.GetLabelValuesHandler(ctx, promClient, tooldef.BuildLabelValuesInput(req.GetArguments())).ToMCPResult() } } @@ -95,11 +78,7 @@ func GetSeriesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequ return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.GetSeriesHandler(ctx, promClient, handlers.SeriesInput{ - Matches: req.GetString("matches", ""), - Start: req.GetString("start", ""), - End: req.GetString("end", ""), - }).ToMCPResult() + return handlers.GetSeriesHandler(ctx, promClient, tooldef.BuildSeriesInput(req.GetArguments())).ToMCPResult() } } @@ -111,28 +90,7 @@ func GetAlertsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequ return mcp.NewToolResultError(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())), nil } - // Parse MCP parameters into input struct - var input handlers.AlertsInput - if req.Params.Arguments != nil { - if args, ok := req.Params.Arguments.(map[string]any); ok { - if activeVal, ok := args["active"].(bool); ok { - input.Active = &activeVal - } - if silencedVal, ok := args["silenced"].(bool); ok { - input.Silenced = &silencedVal - } - if inhibitedVal, ok := args["inhibited"].(bool); ok { - input.Inhibited = &inhibitedVal - } - if unprocessedVal, ok := args["unprocessed"].(bool); ok { - input.Unprocessed = &unprocessedVal - } - } - } - input.Filter = req.GetString("filter", "") - input.Receiver = req.GetString("receiver", "") - - return handlers.GetAlertsHandler(ctx, amClient, input).ToMCPResult() + return handlers.GetAlertsHandler(ctx, amClient, tooldef.BuildAlertsInput(req.GetArguments())).ToMCPResult() } } @@ -144,8 +102,6 @@ func GetSilencesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRe return mcp.NewToolResultError(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())), nil } - return handlers.GetSilencesHandler(ctx, amClient, handlers.SilencesInput{ - Filter: req.GetString("filter", ""), - }).ToMCPResult() + return handlers.GetSilencesHandler(ctx, amClient, tooldef.BuildSilencesInput(req.GetArguments())).ToMCPResult() } } diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 0045390..17ebb7b 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -4,11 +4,11 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/rhobs/obs-mcp/pkg/handlers" - "github.com/rhobs/obs-mcp/pkg/prompts" + "github.com/rhobs/obs-mcp/pkg/tooldef" ) // AllTools returns all available MCP tools. -// When adding a new tool, add it here to keep documentation in sync. +// When adding a new tool, add it to pkg/tooldef/definitions.go to keep both MCP and Toolset in sync, as well as docs. func AllTools() []mcp.Tool { return []mcp.Tool{ CreateListMetricsTool(), @@ -23,141 +23,52 @@ func AllTools() []mcp.Tool { } func CreateListMetricsTool() mcp.Tool { - tool := mcp.NewTool("list_metrics", - mcp.WithDescription(prompts.ListMetricsPrompt), - mcp.WithOutputSchema[handlers.ListMetricsOutput](), - ) - // workaround for tool with no parameter - // see https://github.com/containers/kubernetes-mcp-server/pull/341/files#diff-8f8a99cac7a7cbb9c14477d40539efa1494b62835603244ba9f10e6be1c7e44c - tool.InputSchema = mcp.ToolInputSchema{} - tool.RawInputSchema = []byte(`{"type":"object","properties":{}}`) + tool := tooldef.ListMetrics.ToMCPTool() + // The syntax looks a bit odd, but essentially, WithOutputSchema is a generic function, + // which returns a ToolOption with signature func (*Tool). We are essentially calling the returned + // ToolOption on this tool. + mcp.WithOutputSchema[handlers.ListMetricsOutput]()(&tool) return tool } func CreateExecuteInstantQueryTool() mcp.Tool { - return mcp.NewTool("execute_instant_query", - mcp.WithDescription(prompts.ExecuteInstantQueryPrompt), - mcp.WithString("query", - mcp.Required(), - mcp.Description("PromQL query string using metric names verified via list_metrics"), - ), - mcp.WithString("time", - mcp.Description("Evaluation time as RFC3339 or Unix timestamp. Omit or use 'NOW' for current time."), - ), - mcp.WithOutputSchema[handlers.InstantQueryOutput](), - ) + tool := tooldef.ExecuteInstantQuery.ToMCPTool() + mcp.WithOutputSchema[handlers.InstantQueryOutput]()(&tool) + return tool } func CreateExecuteRangeQueryTool() mcp.Tool { - return mcp.NewTool("execute_range_query", - mcp.WithDescription(prompts.ExecuteRangeQueryPrompt), - mcp.WithString("query", - mcp.Required(), - mcp.Description("PromQL query string using metric names verified via list_metrics"), - ), - mcp.WithString("step", - mcp.Required(), - mcp.Description("Query resolution step width (e.g., '15s', '1m', '1h'). Choose based on time range: shorter ranges use smaller steps."), - mcp.Pattern(`^\d+[smhdwy]$`), - ), - mcp.WithString("start", - mcp.Description("Start time as RFC3339 or Unix timestamp (optional)"), - ), - mcp.WithString("end", - mcp.Description("End time as RFC3339 or Unix timestamp (optional). Use `NOW` for current time."), - ), - mcp.WithString("duration", - mcp.Description("Duration to look back from now (e.g., '1h', '30m', '1d', '2w') (optional)"), - mcp.Pattern(`^\d+[smhdwy]$`), - ), - mcp.WithOutputSchema[handlers.RangeQueryOutput](), - ) + tool := tooldef.ExecuteRangeQuery.ToMCPTool() + mcp.WithOutputSchema[handlers.RangeQueryOutput]()(&tool) + return tool } func CreateGetLabelNamesTool() mcp.Tool { - return mcp.NewTool("get_label_names", - mcp.WithDescription(prompts.GetLabelNamesPrompt), - mcp.WithString("metric", - mcp.Description("Metric name (from list_metrics) to get label names for. Leave empty for all metrics."), - ), - mcp.WithString("start", - mcp.Description("Start time for label discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)"), - ), - mcp.WithString("end", - mcp.Description("End time for label discovery as RFC3339 or Unix timestamp (optional, defaults to now)"), - ), - mcp.WithOutputSchema[handlers.LabelNamesOutput](), - ) + tool := tooldef.GetLabelNames.ToMCPTool() + mcp.WithOutputSchema[handlers.LabelNamesOutput]()(&tool) + return tool } func CreateGetLabelValuesTool() mcp.Tool { - return mcp.NewTool("get_label_values", - mcp.WithDescription(prompts.GetLabelValuesPrompt), - mcp.WithString("label", - mcp.Required(), - mcp.Description("Label name (from get_label_names) to get values for"), - ), - mcp.WithString("metric", - mcp.Description("Metric name (from list_metrics) to scope the label values to. Leave empty for all metrics."), - ), - mcp.WithString("start", - mcp.Description("Start time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)"), - ), - mcp.WithString("end", - mcp.Description("End time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to now)"), - ), - mcp.WithOutputSchema[handlers.LabelValuesOutput](), - ) + tool := tooldef.GetLabelValues.ToMCPTool() + mcp.WithOutputSchema[handlers.LabelValuesOutput]()(&tool) + return tool } func CreateGetSeriesTool() mcp.Tool { - return mcp.NewTool("get_series", - mcp.WithDescription(prompts.GetSeriesPrompt), - mcp.WithString("matches", - mcp.Required(), - mcp.Description("PromQL series selector using metric names from list_metrics"), - ), - mcp.WithString("start", - mcp.Description("Start time for series discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)"), - ), - mcp.WithString("end", - mcp.Description("End time for series discovery as RFC3339 or Unix timestamp (optional, defaults to now)"), - ), - mcp.WithOutputSchema[handlers.SeriesOutput](), - ) + tool := tooldef.GetSeries.ToMCPTool() + mcp.WithOutputSchema[handlers.SeriesOutput]()(&tool) + return tool } func CreateGetAlertsTool() mcp.Tool { - return mcp.NewTool("get_alerts", - mcp.WithDescription(prompts.GetAlertsPrompt), - mcp.WithBoolean("active", - mcp.Description("Filter for active alerts only (true/false, optional)"), - ), - mcp.WithBoolean("silenced", - mcp.Description("Filter for silenced alerts only (true/false, optional)"), - ), - mcp.WithBoolean("inhibited", - mcp.Description("Filter for inhibited alerts only (true/false, optional)"), - ), - mcp.WithBoolean("unprocessed", - mcp.Description("Filter for unprocessed alerts only (true/false, optional)"), - ), - mcp.WithString("filter", - mcp.Description("Label matchers to filter alerts (e.g., 'alertname=HighCPU', optional)"), - ), - mcp.WithString("receiver", - mcp.Description("Receiver name to filter alerts (optional)"), - ), - mcp.WithOutputSchema[handlers.AlertsOutput](), - ) + tool := tooldef.GetAlerts.ToMCPTool() + mcp.WithOutputSchema[handlers.AlertsOutput]()(&tool) + return tool } func CreateGetSilencesTool() mcp.Tool { - return mcp.NewTool("get_silences", - mcp.WithDescription(prompts.GetSilencesPrompt), - mcp.WithString("filter", - mcp.Description("Label matchers to filter silences (e.g., 'alertname=HighCPU', optional)"), - ), - mcp.WithOutputSchema[handlers.SilencesOutput](), - ) + tool := tooldef.GetSilences.ToMCPTool() + mcp.WithOutputSchema[handlers.SilencesOutput]()(&tool) + return tool } diff --git a/pkg/tooldef/bind.go b/pkg/tooldef/bind.go new file mode 100644 index 0000000..6ae3b14 --- /dev/null +++ b/pkg/tooldef/bind.go @@ -0,0 +1,84 @@ +package tooldef + +import "github.com/rhobs/obs-mcp/pkg/handlers" + +// GetString is a helper to extract a string parameter with a default value +func GetString(params map[string]any, key, defaultValue string) string { + if val, ok := params[key]; ok { + if str, ok := val.(string); ok && str != "" { + return str + } + } + return defaultValue +} + +// GetBoolPtr is a helper to extract an optional boolean parameter as a pointer +func GetBoolPtr(params map[string]any, key string) *bool { + if val, ok := params[key]; ok { + if b, ok := val.(bool); ok { + return &b + } + } + return nil +} + +// These functions eliminate duplication between MCP and Toolset handlers + +func BuildInstantQueryInput(args map[string]any) handlers.InstantQueryInput { + return handlers.InstantQueryInput{ + Query: GetString(args, "query", ""), + Time: GetString(args, "time", ""), + } +} + +func BuildRangeQueryInput(args map[string]any) handlers.RangeQueryInput { + return handlers.RangeQueryInput{ + Query: GetString(args, "query", ""), + Step: GetString(args, "step", ""), + Start: GetString(args, "start", ""), + End: GetString(args, "end", ""), + Duration: GetString(args, "duration", ""), + } +} + +func BuildLabelNamesInput(args map[string]any) handlers.LabelNamesInput { + return handlers.LabelNamesInput{ + Metric: GetString(args, "metric", ""), + Start: GetString(args, "start", ""), + End: GetString(args, "end", ""), + } +} + +func BuildLabelValuesInput(args map[string]any) handlers.LabelValuesInput { + return handlers.LabelValuesInput{ + Label: GetString(args, "label", ""), + Metric: GetString(args, "metric", ""), + Start: GetString(args, "start", ""), + End: GetString(args, "end", ""), + } +} + +func BuildSeriesInput(args map[string]any) handlers.SeriesInput { + return handlers.SeriesInput{ + Matches: GetString(args, "matches", ""), + Start: GetString(args, "start", ""), + End: GetString(args, "end", ""), + } +} + +func BuildAlertsInput(args map[string]any) handlers.AlertsInput { + return handlers.AlertsInput{ + Active: GetBoolPtr(args, "active"), + Silenced: GetBoolPtr(args, "silenced"), + Inhibited: GetBoolPtr(args, "inhibited"), + Unprocessed: GetBoolPtr(args, "unprocessed"), + Filter: GetString(args, "filter", ""), + Receiver: GetString(args, "receiver", ""), + } +} + +func BuildSilencesInput(args map[string]any) handlers.SilencesInput { + return handlers.SilencesInput{ + Filter: GetString(args, "filter", ""), + } +} diff --git a/pkg/tooldef/definitions.go b/pkg/tooldef/definitions.go new file mode 100644 index 0000000..d7fd9ed --- /dev/null +++ b/pkg/tooldef/definitions.go @@ -0,0 +1,261 @@ +package tooldef + +import "github.com/rhobs/obs-mcp/pkg/prompts" + +// All tool definitions as a single source of truth +var ( + ListMetrics = ToolDef{ + Name: "list_metrics", + Description: prompts.ListMetricsPrompt, + Title: "List Available Metrics", + Params: []ParamDef{}, // no parameters + ReadOnly: true, + Destructive: false, + Idempotent: true, + OpenWorld: true, + } + + ExecuteInstantQuery = ToolDef{ + Name: "execute_instant_query", + Description: prompts.ExecuteInstantQueryPrompt, + Title: "Execute Instant Query", + ReadOnly: true, + Destructive: false, + Idempotent: true, + OpenWorld: true, + Params: []ParamDef{ + { + Name: "query", + Type: ParamTypeString, + Description: "PromQL query string using metric names verified via list_metrics", + Required: true, + }, + { + Name: "time", + Type: ParamTypeString, + Description: "Evaluation time as RFC3339 or Unix timestamp. Omit or use 'NOW' for current time.", + Required: false, + }, + }, + } + + ExecuteRangeQuery = ToolDef{ + Name: "execute_range_query", + Description: prompts.ExecuteRangeQueryPrompt, + Title: "Execute Range Query", + ReadOnly: true, + Destructive: false, + Idempotent: true, + OpenWorld: true, + Params: []ParamDef{ + { + Name: "query", + Type: ParamTypeString, + Description: "PromQL query string using metric names verified via list_metrics", + Required: true, + }, + { + Name: "step", + Type: ParamTypeString, + Description: "Query resolution step width (e.g., '15s', '1m', '1h'). Choose based on time range: shorter ranges use smaller steps.", + Required: true, + Pattern: `^\d+[smhdwy]$`, + }, + { + Name: "start", + Type: ParamTypeString, + Description: "Start time as RFC3339 or Unix timestamp (optional)", + Required: false, + }, + { + Name: "end", + Type: ParamTypeString, + Description: "End time as RFC3339 or Unix timestamp (optional). Use `NOW` for current time.", + Required: false, + }, + { + Name: "duration", + Type: ParamTypeString, + Description: "Duration to look back from now (e.g., '1h', '30m', '1d', '2w') (optional)", + Required: false, + Pattern: `^\d+[smhdwy]$`, + }, + }, + } + + GetLabelNames = ToolDef{ + Name: "get_label_names", + Description: prompts.GetLabelNamesPrompt, + Title: "Get Label Names", + ReadOnly: true, + Destructive: false, + Idempotent: true, + OpenWorld: true, + Params: []ParamDef{ + { + Name: "metric", + Type: ParamTypeString, + Description: "Metric name (from list_metrics) to get label names for. Leave empty for all metrics.", + Required: false, + }, + { + Name: "start", + Type: ParamTypeString, + Description: "Start time for label discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", + Required: false, + }, + { + Name: "end", + Type: ParamTypeString, + Description: "End time for label discovery as RFC3339 or Unix timestamp (optional, defaults to now)", + Required: false, + }, + }, + } + + GetLabelValues = ToolDef{ + Name: "get_label_values", + Description: prompts.GetLabelValuesPrompt, + Title: "Get Label Values", + ReadOnly: true, + Destructive: false, + Idempotent: true, + OpenWorld: true, + Params: []ParamDef{ + { + Name: "label", + Type: ParamTypeString, + Description: "Label name (from get_label_names) to get values for", + Required: true, + }, + { + Name: "metric", + Type: ParamTypeString, + Description: "Metric name (from list_metrics) to scope the label values to. Leave empty for all metrics.", + Required: false, + }, + { + Name: "start", + Type: ParamTypeString, + Description: "Start time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", + Required: false, + }, + { + Name: "end", + Type: ParamTypeString, + Description: "End time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to now)", + Required: false, + }, + }, + } + + GetSeries = ToolDef{ + Name: "get_series", + Description: prompts.GetSeriesPrompt, + Title: "Get Series", + ReadOnly: true, + Destructive: false, + Idempotent: true, + OpenWorld: true, + Params: []ParamDef{ + { + Name: "matches", + Type: ParamTypeString, + Description: "PromQL series selector using metric names from list_metrics", + Required: true, + }, + { + Name: "start", + Type: ParamTypeString, + Description: "Start time for series discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", + Required: false, + }, + { + Name: "end", + Type: ParamTypeString, + Description: "End time for series discovery as RFC3339 or Unix timestamp (optional, defaults to now)", + Required: false, + }, + }, + } + + GetAlerts = ToolDef{ + Name: "get_alerts", + Description: prompts.GetAlertsPrompt, + Title: "Get Alerts", + ReadOnly: true, + Destructive: false, + Idempotent: true, + OpenWorld: true, + Params: []ParamDef{ + { + Name: "active", + Type: ParamTypeBoolean, + Description: "Filter for active alerts only (true/false, optional)", + Required: false, + }, + { + Name: "silenced", + Type: ParamTypeBoolean, + Description: "Filter for silenced alerts only (true/false, optional)", + Required: false, + }, + { + Name: "inhibited", + Type: ParamTypeBoolean, + Description: "Filter for inhibited alerts only (true/false, optional)", + Required: false, + }, + { + Name: "unprocessed", + Type: ParamTypeBoolean, + Description: "Filter for unprocessed alerts only (true/false, optional)", + Required: false, + }, + { + Name: "filter", + Type: ParamTypeString, + Description: "Label matchers to filter alerts (e.g., 'alertname=HighCPU', optional)", + Required: false, + }, + { + Name: "receiver", + Type: ParamTypeString, + Description: "Receiver name to filter alerts (optional)", + Required: false, + }, + }, + } + + GetSilences = ToolDef{ + Name: "get_silences", + Description: prompts.GetSilencesPrompt, + Title: "Get Silences", + ReadOnly: true, + Destructive: false, + Idempotent: true, + OpenWorld: true, + Params: []ParamDef{ + { + Name: "filter", + Type: ParamTypeString, + Description: "Label matchers to filter silences (e.g., 'alertname=HighCPU', optional)", + Required: false, + }, + }, + } +) + +// AllTools returns all tool definitions +func AllTools() []ToolDef { + return []ToolDef{ + ListMetrics, + ExecuteInstantQuery, + ExecuteRangeQuery, + GetLabelNames, + GetLabelValues, + GetSeries, + GetAlerts, + GetSilences, + } +} diff --git a/pkg/tooldef/mcp.go b/pkg/tooldef/mcp.go new file mode 100644 index 0000000..c613b8a --- /dev/null +++ b/pkg/tooldef/mcp.go @@ -0,0 +1,40 @@ +package tooldef + +import "github.com/mark3labs/mcp-go/mcp" + +// ToMCPTool converts a ToolDef to an mcp.Tool +func (d ToolDef) ToMCPTool() mcp.Tool { + opts := []mcp.ToolOption{mcp.WithDescription(d.Description)} + + for _, param := range d.Params { + switch param.Type { + case ParamTypeString: + stringOpts := []mcp.PropertyOption{mcp.Description(param.Description)} + if param.Required { + stringOpts = append(stringOpts, mcp.Required()) + } + if param.Pattern != "" { + stringOpts = append(stringOpts, mcp.Pattern(param.Pattern)) + } + opts = append(opts, mcp.WithString(param.Name, stringOpts...)) + + case ParamTypeBoolean: + boolOpts := []mcp.PropertyOption{mcp.Description(param.Description)} + if param.Required { + boolOpts = append(boolOpts, mcp.Required()) + } + opts = append(opts, mcp.WithBoolean(param.Name, boolOpts...)) + } + } + + tool := mcp.NewTool(d.Name, opts...) + + // Workaround for tools with no parameters + // See https://github.com/containers/kubernetes-mcp-server/pull/341/files + if len(d.Params) == 0 { + tool.InputSchema = mcp.ToolInputSchema{} + tool.RawInputSchema = []byte(`{"type":"object","properties":{}}`) + } + + return tool +} diff --git a/pkg/tooldef/toolset.go b/pkg/tooldef/toolset.go new file mode 100644 index 0000000..e8054eb --- /dev/null +++ b/pkg/tooldef/toolset.go @@ -0,0 +1,60 @@ +package tooldef + +import ( + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" +) + +// ToServerTool converts a ToolDef to an api.ServerTool +func (d ToolDef) ToServerTool(handler func(api.ToolHandlerParams) (*api.ToolCallResult, error)) api.ServerTool { + properties := make(map[string]*jsonschema.Schema) + var required []string + + for _, param := range d.Params { + schema := &jsonschema.Schema{ + Description: param.Description, + } + + switch param.Type { + case ParamTypeString: + schema.Type = "string" + if param.Pattern != "" { + schema.Pattern = param.Pattern + } + case ParamTypeBoolean: + schema.Type = "boolean" + } + + properties[param.Name] = schema + + if param.Required { + required = append(required, param.Name) + } + } + + inputSchema := &jsonschema.Schema{ + Type: "object", + Properties: properties, + } + + if len(required) > 0 { + inputSchema.Required = required + } + + return api.ServerTool{ + Tool: api.Tool{ + Name: d.Name, + Description: d.Description, + InputSchema: inputSchema, + Annotations: api.ToolAnnotations{ + Title: d.Title, + ReadOnlyHint: ptr.To(d.ReadOnly), + DestructiveHint: ptr.To(d.Destructive), + IdempotentHint: ptr.To(d.Idempotent), + OpenWorldHint: ptr.To(d.OpenWorld), + }, + }, + Handler: handler, + } +} diff --git a/pkg/tooldef/types.go b/pkg/tooldef/types.go new file mode 100644 index 0000000..a785efa --- /dev/null +++ b/pkg/tooldef/types.go @@ -0,0 +1,30 @@ +package tooldef + +// ToolDef defines a tool that can be converted to different formats (MCP, Toolset, etc.) +type ToolDef struct { + Name string + Description string + Title string + Params []ParamDef + ReadOnly bool + Destructive bool + Idempotent bool + OpenWorld bool +} + +// ParamDef defines a tool parameter +type ParamDef struct { + Name string + Type ParamType + Description string + Required bool + Pattern string +} + +// ParamType represents the type of a parameter +type ParamType string + +const ( + ParamTypeString ParamType = "string" + ParamTypeBoolean ParamType = "boolean" +) diff --git a/pkg/toolset/tools/handlers.go b/pkg/toolset/tools/handlers.go index 3516355..51d6bcb 100644 --- a/pkg/toolset/tools/handlers.go +++ b/pkg/toolset/tools/handlers.go @@ -6,16 +6,9 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/rhobs/obs-mcp/pkg/handlers" + "github.com/rhobs/obs-mcp/pkg/tooldef" ) -// Helper function to get string argument with default -func getStringArg(params api.ToolHandlerParams, key, defaultValue string) string { - if val, ok := params.GetArguments()[key].(string); ok && val != "" { - return val - } - return defaultValue -} - // ListMetricsHandler handles the listing of available Prometheus metrics. func ListMetricsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { promClient, err := getPromClient(params) @@ -33,12 +26,7 @@ func ExecuteInstantQueryHandler(params api.ToolHandlerParams) (*api.ToolCallResu return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - input := handlers.InstantQueryInput{ - Query: getStringArg(params, "query", ""), - Time: getStringArg(params, "time", ""), - } - - return handlers.ExecuteInstantQueryHandler(params.Context, promClient, input).ToToolsetResult() + return handlers.ExecuteInstantQueryHandler(params.Context, promClient, tooldef.BuildInstantQueryInput(params.GetArguments())).ToToolsetResult() } // ExecuteRangeQueryHandler handles the execution of Prometheus range queries. @@ -48,15 +36,7 @@ func ExecuteRangeQueryHandler(params api.ToolHandlerParams) (*api.ToolCallResult return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - input := handlers.RangeQueryInput{ - Query: getStringArg(params, "query", ""), - Step: getStringArg(params, "step", ""), - Start: getStringArg(params, "start", ""), - End: getStringArg(params, "end", ""), - Duration: getStringArg(params, "duration", ""), - } - - return handlers.ExecuteRangeQueryHandler(params.Context, promClient, input).ToToolsetResult() + return handlers.ExecuteRangeQueryHandler(params.Context, promClient, tooldef.BuildRangeQueryInput(params.GetArguments())).ToToolsetResult() } // GetLabelNamesHandler handles the retrieval of label names. @@ -66,13 +46,7 @@ func GetLabelNamesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, er return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - input := handlers.LabelNamesInput{ - Metric: getStringArg(params, "metric", ""), - Start: getStringArg(params, "start", ""), - End: getStringArg(params, "end", ""), - } - - return handlers.GetLabelNamesHandler(params.Context, promClient, input).ToToolsetResult() + return handlers.GetLabelNamesHandler(params.Context, promClient, tooldef.BuildLabelNamesInput(params.GetArguments())).ToToolsetResult() } // GetLabelValuesHandler handles the retrieval of label values. @@ -82,14 +56,7 @@ func GetLabelValuesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, e return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - input := handlers.LabelValuesInput{ - Label: getStringArg(params, "label", ""), - Metric: getStringArg(params, "metric", ""), - Start: getStringArg(params, "start", ""), - End: getStringArg(params, "end", ""), - } - - return handlers.GetLabelValuesHandler(params.Context, promClient, input).ToToolsetResult() + return handlers.GetLabelValuesHandler(params.Context, promClient, tooldef.BuildLabelValuesInput(params.GetArguments())).ToToolsetResult() } // GetSeriesHandler handles the retrieval of time series. @@ -99,13 +66,7 @@ func GetSeriesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - input := handlers.SeriesInput{ - Matches: getStringArg(params, "matches", ""), - Start: getStringArg(params, "start", ""), - End: getStringArg(params, "end", ""), - } - - return handlers.GetSeriesHandler(params.Context, promClient, input).ToToolsetResult() + return handlers.GetSeriesHandler(params.Context, promClient, tooldef.BuildSeriesInput(params.GetArguments())).ToToolsetResult() } // GetAlertsHandler handles the retrieval of alerts from Alertmanager. @@ -115,24 +76,7 @@ func GetAlertsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) return api.NewToolCallResult("", fmt.Errorf("failed to create Alertmanager client: %w", err)), nil } - // Parse boolean parameters - var input handlers.AlertsInput - if activeVal, ok := params.GetArguments()["active"].(bool); ok { - input.Active = &activeVal - } - if silencedVal, ok := params.GetArguments()["silenced"].(bool); ok { - input.Silenced = &silencedVal - } - if inhibitedVal, ok := params.GetArguments()["inhibited"].(bool); ok { - input.Inhibited = &inhibitedVal - } - if unprocessedVal, ok := params.GetArguments()["unprocessed"].(bool); ok { - input.Unprocessed = &unprocessedVal - } - input.Filter = getStringArg(params, "filter", "") - input.Receiver = getStringArg(params, "receiver", "") - - return handlers.GetAlertsHandler(params.Context, amClient, input).ToToolsetResult() + return handlers.GetAlertsHandler(params.Context, amClient, tooldef.BuildAlertsInput(params.GetArguments())).ToToolsetResult() } // GetSilencesHandler handles the retrieval of silences from Alertmanager. @@ -142,9 +86,5 @@ func GetSilencesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, erro return api.NewToolCallResult("", fmt.Errorf("failed to create Alertmanager client: %w", err)), nil } - input := handlers.SilencesInput{ - Filter: getStringArg(params, "filter", ""), - } - - return handlers.GetSilencesHandler(params.Context, amClient, input).ToToolsetResult() + return handlers.GetSilencesHandler(params.Context, amClient, tooldef.BuildSilencesInput(params.GetArguments())).ToToolsetResult() } diff --git a/pkg/toolset/tools/tools.go b/pkg/toolset/tools/tools.go index 4efe013..8192b18 100644 --- a/pkg/toolset/tools/tools.go +++ b/pkg/toolset/tools/tools.go @@ -2,309 +2,62 @@ package tools import ( "github.com/containers/kubernetes-mcp-server/pkg/api" - "github.com/google/jsonschema-go/jsonschema" - "k8s.io/utils/ptr" - "github.com/rhobs/obs-mcp/pkg/prompts" + "github.com/rhobs/obs-mcp/pkg/tooldef" ) // InitListMetrics creates the list_metrics tool. func InitListMetrics() []api.ServerTool { return []api.ServerTool{ - { - Tool: api.Tool{ - Name: "list_metrics", - Description: prompts.ListMetricsPrompt, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{}, - }, - Annotations: api.ToolAnnotations{ - Title: "List Available Metrics", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(true), - OpenWorldHint: ptr.To(true), - }, - }, - Handler: ListMetricsHandler, - }, + tooldef.ListMetrics.ToServerTool(ListMetricsHandler), } } // InitExecuteInstantQuery creates the execute_instant_query tool. func InitExecuteInstantQuery() []api.ServerTool { return []api.ServerTool{ - { - Tool: api.Tool{ - Name: "execute_instant_query", - Description: prompts.ExecuteInstantQueryPrompt, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "query": { - Type: "string", - Description: "PromQL query string using metric names verified via list_metrics", - }, - "time": { - Type: "string", - Description: "Evaluation time as RFC3339 or Unix timestamp. Omit or use 'NOW' for current time.", - }, - }, - Required: []string{"query"}, - }, - Annotations: api.ToolAnnotations{ - Title: "Execute Instant Query", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(true), - OpenWorldHint: ptr.To(true), - }, - }, - Handler: ExecuteInstantQueryHandler, - }, + tooldef.ExecuteInstantQuery.ToServerTool(ExecuteInstantQueryHandler), } } // InitExecuteRangeQuery creates the execute_range_query tool. func InitExecuteRangeQuery() []api.ServerTool { return []api.ServerTool{ - { - Tool: api.Tool{ - Name: "execute_range_query", - Description: prompts.ExecuteRangeQueryPrompt, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "query": { - Type: "string", - Description: "PromQL query string using metric names verified via list_metrics", - }, - "step": { - Type: "string", - Description: "Query resolution step width (e.g., '15s', '1m', '1h'). Choose based on time range: shorter ranges use smaller steps.", - Pattern: `^\d+[smhdwy]$`, - }, - "start": { - Type: "string", - Description: "Start time as RFC3339 or Unix timestamp (optional)", - }, - "end": { - Type: "string", - Description: "End time as RFC3339 or Unix timestamp (optional). Use `NOW` for current time.", - }, - "duration": { - Type: "string", - Description: "Duration to look back from now (e.g., '1h', '30m', '1d', '2w') (optional)", - Pattern: `^\d+[smhdwy]$`, - }, - }, - Required: []string{"query", "step"}, - }, - Annotations: api.ToolAnnotations{ - Title: "Execute Range Query", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(true), - OpenWorldHint: ptr.To(true), - }, - }, - Handler: ExecuteRangeQueryHandler, - }, + tooldef.ExecuteRangeQuery.ToServerTool(ExecuteRangeQueryHandler), } } // InitGetLabelNames creates the get_label_names tool. func InitGetLabelNames() []api.ServerTool { return []api.ServerTool{ - { - Tool: api.Tool{ - Name: "get_label_names", - Description: prompts.GetLabelNamesPrompt, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "metric": { - Type: "string", - Description: "Metric name (from list_metrics) to get label names for. Leave empty for all metrics.", - }, - "start": { - Type: "string", - Description: "Start time for label discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", - }, - "end": { - Type: "string", - Description: "End time for label discovery as RFC3339 or Unix timestamp (optional, defaults to now)", - }, - }, - }, - Annotations: api.ToolAnnotations{ - Title: "Get Label Names", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(true), - OpenWorldHint: ptr.To(true), - }, - }, - Handler: GetLabelNamesHandler, - }, + tooldef.GetLabelNames.ToServerTool(GetLabelNamesHandler), } } // InitGetLabelValues creates the get_label_values tool. func InitGetLabelValues() []api.ServerTool { return []api.ServerTool{ - { - Tool: api.Tool{ - Name: "get_label_values", - Description: prompts.GetLabelValuesPrompt, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "label": { - Type: "string", - Description: "Label name (from get_label_names) to get values for", - }, - "metric": { - Type: "string", - Description: "Metric name (from list_metrics) to scope the label values to. Leave empty for all metrics.", - }, - "start": { - Type: "string", - Description: "Start time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", - }, - "end": { - Type: "string", - Description: "End time for label value discovery as RFC3339 or Unix timestamp (optional, defaults to now)", - }, - }, - Required: []string{"label"}, - }, - Annotations: api.ToolAnnotations{ - Title: "Get Label Values", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(true), - OpenWorldHint: ptr.To(true), - }, - }, - Handler: GetLabelValuesHandler, - }, + tooldef.GetLabelValues.ToServerTool(GetLabelValuesHandler), } } // InitGetSeries creates the get_series tool. func InitGetSeries() []api.ServerTool { return []api.ServerTool{ - { - Tool: api.Tool{ - Name: "get_series", - Description: prompts.GetSeriesPrompt, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "matches": { - Type: "string", - Description: "PromQL series selector using metric names from list_metrics", - }, - "start": { - Type: "string", - Description: "Start time for series discovery as RFC3339 or Unix timestamp (optional, defaults to 1 hour ago)", - }, - "end": { - Type: "string", - Description: "End time for series discovery as RFC3339 or Unix timestamp (optional, defaults to now)", - }, - }, - Required: []string{"matches"}, - }, - Annotations: api.ToolAnnotations{ - Title: "Get Series", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(true), - OpenWorldHint: ptr.To(true), - }, - }, - Handler: GetSeriesHandler, - }, + tooldef.GetSeries.ToServerTool(GetSeriesHandler), } } // InitGetAlerts creates the get_alerts tool. func InitGetAlerts() []api.ServerTool { return []api.ServerTool{ - { - Tool: api.Tool{ - Name: "get_alerts", - Description: prompts.GetAlertsPrompt, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "active": { - Type: "boolean", - Description: "Filter for active alerts only (true/false, optional)", - }, - "silenced": { - Type: "boolean", - Description: "Filter for silenced alerts only (true/false, optional)", - }, - "inhibited": { - Type: "boolean", - Description: "Filter for inhibited alerts only (true/false, optional)", - }, - "unprocessed": { - Type: "boolean", - Description: "Filter for unprocessed alerts only (true/false, optional)", - }, - "filter": { - Type: "string", - Description: "Label matchers to filter alerts (e.g., 'alertname=HighCPU', optional)", - }, - "receiver": { - Type: "string", - Description: "Receiver name to filter alerts (optional)", - }, - }, - }, - Annotations: api.ToolAnnotations{ - Title: "Get Alerts", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(true), - OpenWorldHint: ptr.To(true), - }, - }, - Handler: GetAlertsHandler, - }, + tooldef.GetAlerts.ToServerTool(GetAlertsHandler), } } // InitGetSilences creates the get_silences tool. func InitGetSilences() []api.ServerTool { return []api.ServerTool{ - { - Tool: api.Tool{ - Name: "get_silences", - Description: prompts.GetSilencesPrompt, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "filter": { - Type: "string", - Description: "Label matchers to filter silences (e.g., 'alertname=HighCPU', optional)", - }, - }, - }, - Annotations: api.ToolAnnotations{ - Title: "Get Silences", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(true), - OpenWorldHint: ptr.To(true), - }, - }, - Handler: GetSilencesHandler, - }, + tooldef.GetSilences.ToServerTool(GetSilencesHandler), } } From 25b185e9ff4f294ed68bae62739d95f4925cec82 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Mon, 9 Feb 2026 11:37:55 +0000 Subject: [PATCH 7/8] Restructure packages Signed-off-by: Saswata Mukherjee --- pkg/mcp/handlers.go | 19 ++-- pkg/mcp/server.go | 4 +- pkg/mcp/tools.go | 35 ++++--- pkg/mcp/tools_test.go | 50 +++++----- pkg/tooldef/bind.go | 84 ----------------- pkg/tooldef/mcp.go | 40 -------- pkg/tooldef/toolset.go | 60 ------------ pkg/tooldef/types.go | 30 ------ pkg/{tooldef => tools}/definitions.go | 20 ++-- pkg/{handlers => tools}/handlers.go | 81 +++++++++++++++- pkg/{prompts => tools}/prompt.go | 2 +- pkg/{handlers => tools}/schema.go | 2 +- pkg/tools/tooldef.go | 128 ++++++++++++++++++++++++++ pkg/toolset/tools/handlers.go | 19 ++-- pkg/toolset/tools/tools.go | 18 ++-- pkg/toolset/toolset.go | 22 ++--- 16 files changed, 301 insertions(+), 313 deletions(-) delete mode 100644 pkg/tooldef/bind.go delete mode 100644 pkg/tooldef/mcp.go delete mode 100644 pkg/tooldef/toolset.go delete mode 100644 pkg/tooldef/types.go rename pkg/{tooldef => tools}/definitions.go (94%) rename pkg/{handlers => tools}/handlers.go (87%) rename pkg/{prompts => tools}/prompt.go (99%) rename pkg/{handlers => tools}/schema.go (99%) create mode 100644 pkg/tools/tooldef.go diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index 8539c45..3a9922c 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -6,8 +6,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" - "github.com/rhobs/obs-mcp/pkg/handlers" - "github.com/rhobs/obs-mcp/pkg/tooldef" + "github.com/rhobs/obs-mcp/pkg/tools" ) // ListMetricsHandler handles the listing of available Prometheus metrics. @@ -18,7 +17,7 @@ func ListMetricsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRe return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.ListMetricsHandler(ctx, promClient).ToMCPResult() + return tools.ListMetricsHandler(ctx, promClient).ToMCPResult() } } @@ -30,7 +29,7 @@ func ExecuteRangeQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.Call return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.ExecuteRangeQueryHandler(ctx, promClient, tooldef.BuildRangeQueryInput(req.GetArguments())).ToMCPResult() + return tools.ExecuteRangeQueryHandler(ctx, promClient, tools.BuildRangeQueryInput(req.GetArguments())).ToMCPResult() } } @@ -42,7 +41,7 @@ func ExecuteInstantQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.Ca return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.ExecuteInstantQueryHandler(ctx, promClient, tooldef.BuildInstantQueryInput(req.GetArguments())).ToMCPResult() + return tools.ExecuteInstantQueryHandler(ctx, promClient, tools.BuildInstantQueryInput(req.GetArguments())).ToMCPResult() } } @@ -54,7 +53,7 @@ func GetLabelNamesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallTool return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.GetLabelNamesHandler(ctx, promClient, tooldef.BuildLabelNamesInput(req.GetArguments())).ToMCPResult() + return tools.GetLabelNamesHandler(ctx, promClient, tools.BuildLabelNamesInput(req.GetArguments())).ToMCPResult() } } @@ -66,7 +65,7 @@ func GetLabelValuesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToo return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.GetLabelValuesHandler(ctx, promClient, tooldef.BuildLabelValuesInput(req.GetArguments())).ToMCPResult() + return tools.GetLabelValuesHandler(ctx, promClient, tools.BuildLabelValuesInput(req.GetArguments())).ToMCPResult() } } @@ -78,7 +77,7 @@ func GetSeriesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequ return mcp.NewToolResultError(fmt.Sprintf("failed to create Prometheus client: %s", err.Error())), nil } - return handlers.GetSeriesHandler(ctx, promClient, tooldef.BuildSeriesInput(req.GetArguments())).ToMCPResult() + return tools.GetSeriesHandler(ctx, promClient, tools.BuildSeriesInput(req.GetArguments())).ToMCPResult() } } @@ -90,7 +89,7 @@ func GetAlertsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequ return mcp.NewToolResultError(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())), nil } - return handlers.GetAlertsHandler(ctx, amClient, tooldef.BuildAlertsInput(req.GetArguments())).ToMCPResult() + return tools.GetAlertsHandler(ctx, amClient, tools.BuildAlertsInput(req.GetArguments())).ToMCPResult() } } @@ -102,6 +101,6 @@ func GetSilencesHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRe return mcp.NewToolResultError(fmt.Sprintf("failed to create Alertmanager client: %s", err.Error())), nil } - return handlers.GetSilencesHandler(ctx, amClient, tooldef.BuildSilencesInput(req.GetArguments())).ToMCPResult() + return tools.GetSilencesHandler(ctx, amClient, tools.BuildSilencesInput(req.GetArguments())).ToMCPResult() } } diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 496cf5b..90387be 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -14,7 +14,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/rhobs/obs-mcp/pkg/prometheus" - "github.com/rhobs/obs-mcp/pkg/prompts" + "github.com/rhobs/obs-mcp/pkg/tools" ) // ObsMCPOptions contains configuration options for the MCP server @@ -40,7 +40,7 @@ func NewMCPServer(opts ObsMCPOptions) (*server.MCPServer, error) { serverVersion, server.WithLogging(), server.WithToolCapabilities(true), - server.WithInstructions(prompts.ServerPrompt), + server.WithInstructions(tools.ServerPrompt), ) if err := SetupTools(mcpServer, opts); err != nil { diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 17ebb7b..046ff0a 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -3,8 +3,7 @@ package mcp import ( "github.com/mark3labs/mcp-go/mcp" - "github.com/rhobs/obs-mcp/pkg/handlers" - "github.com/rhobs/obs-mcp/pkg/tooldef" + "github.com/rhobs/obs-mcp/pkg/tools" ) // AllTools returns all available MCP tools. @@ -23,52 +22,52 @@ func AllTools() []mcp.Tool { } func CreateListMetricsTool() mcp.Tool { - tool := tooldef.ListMetrics.ToMCPTool() + tool := tools.ListMetrics.ToMCPTool() // The syntax looks a bit odd, but essentially, WithOutputSchema is a generic function, // which returns a ToolOption with signature func (*Tool). We are essentially calling the returned // ToolOption on this tool. - mcp.WithOutputSchema[handlers.ListMetricsOutput]()(&tool) + mcp.WithOutputSchema[tools.ListMetricsOutput]()(&tool) return tool } func CreateExecuteInstantQueryTool() mcp.Tool { - tool := tooldef.ExecuteInstantQuery.ToMCPTool() - mcp.WithOutputSchema[handlers.InstantQueryOutput]()(&tool) + tool := tools.ExecuteInstantQuery.ToMCPTool() + mcp.WithOutputSchema[tools.InstantQueryOutput]()(&tool) return tool } func CreateExecuteRangeQueryTool() mcp.Tool { - tool := tooldef.ExecuteRangeQuery.ToMCPTool() - mcp.WithOutputSchema[handlers.RangeQueryOutput]()(&tool) + tool := tools.ExecuteRangeQuery.ToMCPTool() + mcp.WithOutputSchema[tools.RangeQueryOutput]()(&tool) return tool } func CreateGetLabelNamesTool() mcp.Tool { - tool := tooldef.GetLabelNames.ToMCPTool() - mcp.WithOutputSchema[handlers.LabelNamesOutput]()(&tool) + tool := tools.GetLabelNames.ToMCPTool() + mcp.WithOutputSchema[tools.LabelNamesOutput]()(&tool) return tool } func CreateGetLabelValuesTool() mcp.Tool { - tool := tooldef.GetLabelValues.ToMCPTool() - mcp.WithOutputSchema[handlers.LabelValuesOutput]()(&tool) + tool := tools.GetLabelValues.ToMCPTool() + mcp.WithOutputSchema[tools.LabelValuesOutput]()(&tool) return tool } func CreateGetSeriesTool() mcp.Tool { - tool := tooldef.GetSeries.ToMCPTool() - mcp.WithOutputSchema[handlers.SeriesOutput]()(&tool) + tool := tools.GetSeries.ToMCPTool() + mcp.WithOutputSchema[tools.SeriesOutput]()(&tool) return tool } func CreateGetAlertsTool() mcp.Tool { - tool := tooldef.GetAlerts.ToMCPTool() - mcp.WithOutputSchema[handlers.AlertsOutput]()(&tool) + tool := tools.GetAlerts.ToMCPTool() + mcp.WithOutputSchema[tools.AlertsOutput]()(&tool) return tool } func CreateGetSilencesTool() mcp.Tool { - tool := tooldef.GetSilences.ToMCPTool() - mcp.WithOutputSchema[handlers.SilencesOutput]()(&tool) + tool := tools.GetSilences.ToMCPTool() + mcp.WithOutputSchema[tools.SilencesOutput]()(&tool) return tool } diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index d386249..4dfe941 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -7,25 +7,25 @@ import ( "github.com/mark3labs/mcp-go/mcp" - "github.com/rhobs/obs-mcp/pkg/handlers" + "github.com/rhobs/obs-mcp/pkg/tools" ) func TestListMetricsOutputSerialization(t *testing.T) { tests := []struct { name string - input handlers.ListMetricsOutput + input tools.ListMetricsOutput }{ { name: "empty", - input: handlers.ListMetricsOutput{Metrics: []string{}}, + input: tools.ListMetricsOutput{Metrics: []string{}}, }, { name: "single metric", - input: handlers.ListMetricsOutput{Metrics: []string{"up"}}, + input: tools.ListMetricsOutput{Metrics: []string{"up"}}, }, { name: "multiple metrics", - input: handlers.ListMetricsOutput{Metrics: []string{"up", "node_cpu_seconds_total", "go_goroutines"}}, + input: tools.ListMetricsOutput{Metrics: []string{"up", "node_cpu_seconds_total", "go_goroutines"}}, }, } @@ -36,7 +36,7 @@ func TestListMetricsOutputSerialization(t *testing.T) { t.Fatalf("marshal failed: %v", err) } - var result handlers.ListMetricsOutput + var result tools.ListMetricsOutput if err := json.Unmarshal(data, &result); err != nil { t.Fatalf("unmarshal failed: %v", err) } @@ -47,13 +47,13 @@ func TestListMetricsOutputSerialization(t *testing.T) { func TestRangeQueryOutputSerialization(t *testing.T) { tests := []struct { name string - input handlers.RangeQueryOutput + input tools.RangeQueryOutput }{ { name: "matrix single series", - input: handlers.RangeQueryOutput{ + input: tools.RangeQueryOutput{ ResultType: "matrix", - Result: []handlers.SeriesResult{{ + Result: []tools.SeriesResult{{ Metric: map[string]string{"__name__": "up"}, Values: [][]any{{1700000000.0, "1"}}, }}, @@ -61,9 +61,9 @@ func TestRangeQueryOutputSerialization(t *testing.T) { }, { name: "matrix multiple series", - input: handlers.RangeQueryOutput{ + input: tools.RangeQueryOutput{ ResultType: "matrix", - Result: []handlers.SeriesResult{ + Result: []tools.SeriesResult{ {Metric: map[string]string{"job": "a"}, Values: [][]any{}}, {Metric: map[string]string{"job": "b"}, Values: [][]any{}}, {Metric: map[string]string{"job": "c"}, Values: [][]any{}}, @@ -72,16 +72,16 @@ func TestRangeQueryOutputSerialization(t *testing.T) { }, { name: "empty result", - input: handlers.RangeQueryOutput{ + input: tools.RangeQueryOutput{ ResultType: "matrix", - Result: []handlers.SeriesResult{}, + Result: []tools.SeriesResult{}, }, }, { name: "vector result", - input: handlers.RangeQueryOutput{ + input: tools.RangeQueryOutput{ ResultType: "vector", - Result: []handlers.SeriesResult{{ + Result: []tools.SeriesResult{{ Metric: map[string]string{"__name__": "up"}, Values: [][]any{{1700000000.0, "1"}}, }}, @@ -89,9 +89,9 @@ func TestRangeQueryOutputSerialization(t *testing.T) { }, { name: "scalar result", - input: handlers.RangeQueryOutput{ + input: tools.RangeQueryOutput{ ResultType: "scalar", - Result: []handlers.SeriesResult{{ + Result: []tools.SeriesResult{{ Metric: map[string]string{}, Values: [][]any{{1700000000.0, "42"}}, }}, @@ -99,9 +99,9 @@ func TestRangeQueryOutputSerialization(t *testing.T) { }, { name: "with warnings", - input: handlers.RangeQueryOutput{ + input: tools.RangeQueryOutput{ ResultType: "matrix", - Result: []handlers.SeriesResult{}, + Result: []tools.SeriesResult{}, Warnings: []string{"warning1", "warning2"}, }, }, @@ -114,7 +114,7 @@ func TestRangeQueryOutputSerialization(t *testing.T) { t.Fatalf("marshal failed: %v", err) } - var result handlers.RangeQueryOutput + var result tools.RangeQueryOutput if err := json.Unmarshal(data, &result); err != nil { t.Fatalf("unmarshal failed: %v", err) } @@ -125,25 +125,25 @@ func TestRangeQueryOutputSerialization(t *testing.T) { func TestSeriesResultSerialization(t *testing.T) { tests := []struct { name string - input handlers.SeriesResult + input tools.SeriesResult }{ { name: "with labels and values", - input: handlers.SeriesResult{ + input: tools.SeriesResult{ Metric: map[string]string{"__name__": "up", "job": "prometheus"}, Values: [][]any{{1700000000.0, "1"}, {1700000060.0, "1"}}, }, }, { name: "empty", - input: handlers.SeriesResult{ + input: tools.SeriesResult{ Metric: map[string]string{}, Values: [][]any{}, }, }, { name: "many labels", - input: handlers.SeriesResult{ + input: tools.SeriesResult{ Metric: map[string]string{ "__name__": "http_requests", "method": "GET", "status": "200", "handler": "/api", "instance": "localhost:9090", @@ -160,7 +160,7 @@ func TestSeriesResultSerialization(t *testing.T) { t.Fatalf("marshal failed: %v", err) } - var result handlers.SeriesResult + var result tools.SeriesResult if err := json.Unmarshal(data, &result); err != nil { t.Fatalf("unmarshal failed: %v", err) } diff --git a/pkg/tooldef/bind.go b/pkg/tooldef/bind.go deleted file mode 100644 index 6ae3b14..0000000 --- a/pkg/tooldef/bind.go +++ /dev/null @@ -1,84 +0,0 @@ -package tooldef - -import "github.com/rhobs/obs-mcp/pkg/handlers" - -// GetString is a helper to extract a string parameter with a default value -func GetString(params map[string]any, key, defaultValue string) string { - if val, ok := params[key]; ok { - if str, ok := val.(string); ok && str != "" { - return str - } - } - return defaultValue -} - -// GetBoolPtr is a helper to extract an optional boolean parameter as a pointer -func GetBoolPtr(params map[string]any, key string) *bool { - if val, ok := params[key]; ok { - if b, ok := val.(bool); ok { - return &b - } - } - return nil -} - -// These functions eliminate duplication between MCP and Toolset handlers - -func BuildInstantQueryInput(args map[string]any) handlers.InstantQueryInput { - return handlers.InstantQueryInput{ - Query: GetString(args, "query", ""), - Time: GetString(args, "time", ""), - } -} - -func BuildRangeQueryInput(args map[string]any) handlers.RangeQueryInput { - return handlers.RangeQueryInput{ - Query: GetString(args, "query", ""), - Step: GetString(args, "step", ""), - Start: GetString(args, "start", ""), - End: GetString(args, "end", ""), - Duration: GetString(args, "duration", ""), - } -} - -func BuildLabelNamesInput(args map[string]any) handlers.LabelNamesInput { - return handlers.LabelNamesInput{ - Metric: GetString(args, "metric", ""), - Start: GetString(args, "start", ""), - End: GetString(args, "end", ""), - } -} - -func BuildLabelValuesInput(args map[string]any) handlers.LabelValuesInput { - return handlers.LabelValuesInput{ - Label: GetString(args, "label", ""), - Metric: GetString(args, "metric", ""), - Start: GetString(args, "start", ""), - End: GetString(args, "end", ""), - } -} - -func BuildSeriesInput(args map[string]any) handlers.SeriesInput { - return handlers.SeriesInput{ - Matches: GetString(args, "matches", ""), - Start: GetString(args, "start", ""), - End: GetString(args, "end", ""), - } -} - -func BuildAlertsInput(args map[string]any) handlers.AlertsInput { - return handlers.AlertsInput{ - Active: GetBoolPtr(args, "active"), - Silenced: GetBoolPtr(args, "silenced"), - Inhibited: GetBoolPtr(args, "inhibited"), - Unprocessed: GetBoolPtr(args, "unprocessed"), - Filter: GetString(args, "filter", ""), - Receiver: GetString(args, "receiver", ""), - } -} - -func BuildSilencesInput(args map[string]any) handlers.SilencesInput { - return handlers.SilencesInput{ - Filter: GetString(args, "filter", ""), - } -} diff --git a/pkg/tooldef/mcp.go b/pkg/tooldef/mcp.go deleted file mode 100644 index c613b8a..0000000 --- a/pkg/tooldef/mcp.go +++ /dev/null @@ -1,40 +0,0 @@ -package tooldef - -import "github.com/mark3labs/mcp-go/mcp" - -// ToMCPTool converts a ToolDef to an mcp.Tool -func (d ToolDef) ToMCPTool() mcp.Tool { - opts := []mcp.ToolOption{mcp.WithDescription(d.Description)} - - for _, param := range d.Params { - switch param.Type { - case ParamTypeString: - stringOpts := []mcp.PropertyOption{mcp.Description(param.Description)} - if param.Required { - stringOpts = append(stringOpts, mcp.Required()) - } - if param.Pattern != "" { - stringOpts = append(stringOpts, mcp.Pattern(param.Pattern)) - } - opts = append(opts, mcp.WithString(param.Name, stringOpts...)) - - case ParamTypeBoolean: - boolOpts := []mcp.PropertyOption{mcp.Description(param.Description)} - if param.Required { - boolOpts = append(boolOpts, mcp.Required()) - } - opts = append(opts, mcp.WithBoolean(param.Name, boolOpts...)) - } - } - - tool := mcp.NewTool(d.Name, opts...) - - // Workaround for tools with no parameters - // See https://github.com/containers/kubernetes-mcp-server/pull/341/files - if len(d.Params) == 0 { - tool.InputSchema = mcp.ToolInputSchema{} - tool.RawInputSchema = []byte(`{"type":"object","properties":{}}`) - } - - return tool -} diff --git a/pkg/tooldef/toolset.go b/pkg/tooldef/toolset.go deleted file mode 100644 index e8054eb..0000000 --- a/pkg/tooldef/toolset.go +++ /dev/null @@ -1,60 +0,0 @@ -package tooldef - -import ( - "github.com/containers/kubernetes-mcp-server/pkg/api" - "github.com/google/jsonschema-go/jsonschema" - "k8s.io/utils/ptr" -) - -// ToServerTool converts a ToolDef to an api.ServerTool -func (d ToolDef) ToServerTool(handler func(api.ToolHandlerParams) (*api.ToolCallResult, error)) api.ServerTool { - properties := make(map[string]*jsonschema.Schema) - var required []string - - for _, param := range d.Params { - schema := &jsonschema.Schema{ - Description: param.Description, - } - - switch param.Type { - case ParamTypeString: - schema.Type = "string" - if param.Pattern != "" { - schema.Pattern = param.Pattern - } - case ParamTypeBoolean: - schema.Type = "boolean" - } - - properties[param.Name] = schema - - if param.Required { - required = append(required, param.Name) - } - } - - inputSchema := &jsonschema.Schema{ - Type: "object", - Properties: properties, - } - - if len(required) > 0 { - inputSchema.Required = required - } - - return api.ServerTool{ - Tool: api.Tool{ - Name: d.Name, - Description: d.Description, - InputSchema: inputSchema, - Annotations: api.ToolAnnotations{ - Title: d.Title, - ReadOnlyHint: ptr.To(d.ReadOnly), - DestructiveHint: ptr.To(d.Destructive), - IdempotentHint: ptr.To(d.Idempotent), - OpenWorldHint: ptr.To(d.OpenWorld), - }, - }, - Handler: handler, - } -} diff --git a/pkg/tooldef/types.go b/pkg/tooldef/types.go deleted file mode 100644 index a785efa..0000000 --- a/pkg/tooldef/types.go +++ /dev/null @@ -1,30 +0,0 @@ -package tooldef - -// ToolDef defines a tool that can be converted to different formats (MCP, Toolset, etc.) -type ToolDef struct { - Name string - Description string - Title string - Params []ParamDef - ReadOnly bool - Destructive bool - Idempotent bool - OpenWorld bool -} - -// ParamDef defines a tool parameter -type ParamDef struct { - Name string - Type ParamType - Description string - Required bool - Pattern string -} - -// ParamType represents the type of a parameter -type ParamType string - -const ( - ParamTypeString ParamType = "string" - ParamTypeBoolean ParamType = "boolean" -) diff --git a/pkg/tooldef/definitions.go b/pkg/tools/definitions.go similarity index 94% rename from pkg/tooldef/definitions.go rename to pkg/tools/definitions.go index d7fd9ed..cf11492 100644 --- a/pkg/tooldef/definitions.go +++ b/pkg/tools/definitions.go @@ -1,12 +1,10 @@ -package tooldef - -import "github.com/rhobs/obs-mcp/pkg/prompts" +package tools // All tool definitions as a single source of truth var ( ListMetrics = ToolDef{ Name: "list_metrics", - Description: prompts.ListMetricsPrompt, + Description: ListMetricsPrompt, Title: "List Available Metrics", Params: []ParamDef{}, // no parameters ReadOnly: true, @@ -17,7 +15,7 @@ var ( ExecuteInstantQuery = ToolDef{ Name: "execute_instant_query", - Description: prompts.ExecuteInstantQueryPrompt, + Description: ExecuteInstantQueryPrompt, Title: "Execute Instant Query", ReadOnly: true, Destructive: false, @@ -41,7 +39,7 @@ var ( ExecuteRangeQuery = ToolDef{ Name: "execute_range_query", - Description: prompts.ExecuteRangeQueryPrompt, + Description: ExecuteRangeQueryPrompt, Title: "Execute Range Query", ReadOnly: true, Destructive: false, @@ -85,7 +83,7 @@ var ( GetLabelNames = ToolDef{ Name: "get_label_names", - Description: prompts.GetLabelNamesPrompt, + Description: GetLabelNamesPrompt, Title: "Get Label Names", ReadOnly: true, Destructive: false, @@ -115,7 +113,7 @@ var ( GetLabelValues = ToolDef{ Name: "get_label_values", - Description: prompts.GetLabelValuesPrompt, + Description: GetLabelValuesPrompt, Title: "Get Label Values", ReadOnly: true, Destructive: false, @@ -151,7 +149,7 @@ var ( GetSeries = ToolDef{ Name: "get_series", - Description: prompts.GetSeriesPrompt, + Description: GetSeriesPrompt, Title: "Get Series", ReadOnly: true, Destructive: false, @@ -181,7 +179,7 @@ var ( GetAlerts = ToolDef{ Name: "get_alerts", - Description: prompts.GetAlertsPrompt, + Description: GetAlertsPrompt, Title: "Get Alerts", ReadOnly: true, Destructive: false, @@ -229,7 +227,7 @@ var ( GetSilences = ToolDef{ Name: "get_silences", - Description: prompts.GetSilencesPrompt, + Description: GetSilencesPrompt, Title: "Get Silences", ReadOnly: true, Destructive: false, diff --git a/pkg/handlers/handlers.go b/pkg/tools/handlers.go similarity index 87% rename from pkg/handlers/handlers.go rename to pkg/tools/handlers.go index 0399b87..2f8541b 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/tools/handlers.go @@ -1,4 +1,4 @@ -package handlers +package tools import ( "context" @@ -15,6 +15,85 @@ import ( "github.com/rhobs/obs-mcp/pkg/resultutil" ) +// GetString is a helper to extract a string parameter with a default value +func GetString(params map[string]any, key, defaultValue string) string { + if val, ok := params[key]; ok { + if str, ok := val.(string); ok && str != "" { + return str + } + } + return defaultValue +} + +// GetBoolPtr is a helper to extract an optional boolean parameter as a pointer +func GetBoolPtr(params map[string]any, key string) *bool { + if val, ok := params[key]; ok { + if b, ok := val.(bool); ok { + return &b + } + } + return nil +} + +func BuildInstantQueryInput(args map[string]any) InstantQueryInput { + return InstantQueryInput{ + Query: GetString(args, "query", ""), + Time: GetString(args, "time", ""), + } +} + +func BuildRangeQueryInput(args map[string]any) RangeQueryInput { + return RangeQueryInput{ + Query: GetString(args, "query", ""), + Step: GetString(args, "step", ""), + Start: GetString(args, "start", ""), + End: GetString(args, "end", ""), + Duration: GetString(args, "duration", ""), + } +} + +func BuildLabelNamesInput(args map[string]any) LabelNamesInput { + return LabelNamesInput{ + Metric: GetString(args, "metric", ""), + Start: GetString(args, "start", ""), + End: GetString(args, "end", ""), + } +} + +func BuildLabelValuesInput(args map[string]any) LabelValuesInput { + return LabelValuesInput{ + Label: GetString(args, "label", ""), + Metric: GetString(args, "metric", ""), + Start: GetString(args, "start", ""), + End: GetString(args, "end", ""), + } +} + +func BuildSeriesInput(args map[string]any) SeriesInput { + return SeriesInput{ + Matches: GetString(args, "matches", ""), + Start: GetString(args, "start", ""), + End: GetString(args, "end", ""), + } +} + +func BuildAlertsInput(args map[string]any) AlertsInput { + return AlertsInput{ + Active: GetBoolPtr(args, "active"), + Silenced: GetBoolPtr(args, "silenced"), + Inhibited: GetBoolPtr(args, "inhibited"), + Unprocessed: GetBoolPtr(args, "unprocessed"), + Filter: GetString(args, "filter", ""), + Receiver: GetString(args, "receiver", ""), + } +} + +func BuildSilencesInput(args map[string]any) SilencesInput { + return SilencesInput{ + Filter: GetString(args, "filter", ""), + } +} + // ListMetricsHandler handles the listing of available Prometheus metrics. func ListMetricsHandler(ctx context.Context, promClient prometheus.Loader) *resultutil.Result { slog.Info("ListMetricsHandler called") diff --git a/pkg/prompts/prompt.go b/pkg/tools/prompt.go similarity index 99% rename from pkg/prompts/prompt.go rename to pkg/tools/prompt.go index 7186515..e530871 100644 --- a/pkg/prompts/prompt.go +++ b/pkg/tools/prompt.go @@ -1,4 +1,4 @@ -package prompts +package tools const ( ServerPrompt = `You are an expert Kubernetes and OpenShift observability assistant with direct access to Prometheus metrics and Alertmanager alerts through this MCP server. diff --git a/pkg/handlers/schema.go b/pkg/tools/schema.go similarity index 99% rename from pkg/handlers/schema.go rename to pkg/tools/schema.go index 4f125d1..f5a2ed4 100644 --- a/pkg/handlers/schema.go +++ b/pkg/tools/schema.go @@ -1,4 +1,4 @@ -package handlers +package tools // ListMetricsOutput defines the output schema for the list_metrics tool. type ListMetricsOutput struct { diff --git a/pkg/tools/tooldef.go b/pkg/tools/tooldef.go new file mode 100644 index 0000000..f478c04 --- /dev/null +++ b/pkg/tools/tooldef.go @@ -0,0 +1,128 @@ +package tools + +import ( + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/mark3labs/mcp-go/mcp" +) + +// ParamDef defines a tool parameter +type ParamDef struct { + Name string + Type ParamType + Description string + Required bool + Pattern string +} + +// ParamType represents the type of a parameter +type ParamType string + +const ( + ParamTypeString ParamType = "string" + ParamTypeBoolean ParamType = "boolean" +) + +// ToolDef defines a tool that can be converted to different formats (MCP, Toolset, etc.) +type ToolDef struct { + Name string + Description string + Title string + Params []ParamDef + ReadOnly bool + Destructive bool + Idempotent bool + OpenWorld bool +} + +// ToMCPTool converts a ToolDef to an mcp.Tool +func (d ToolDef) ToMCPTool() mcp.Tool { + opts := []mcp.ToolOption{mcp.WithDescription(d.Description)} + + for _, param := range d.Params { + switch param.Type { + case ParamTypeString: + stringOpts := []mcp.PropertyOption{mcp.Description(param.Description)} + if param.Required { + stringOpts = append(stringOpts, mcp.Required()) + } + if param.Pattern != "" { + stringOpts = append(stringOpts, mcp.Pattern(param.Pattern)) + } + opts = append(opts, mcp.WithString(param.Name, stringOpts...)) + + case ParamTypeBoolean: + boolOpts := []mcp.PropertyOption{mcp.Description(param.Description)} + if param.Required { + boolOpts = append(boolOpts, mcp.Required()) + } + opts = append(opts, mcp.WithBoolean(param.Name, boolOpts...)) + } + } + + tool := mcp.NewTool(d.Name, opts...) + + // Workaround for tools with no parameters + // See https://github.com/containers/kubernetes-mcp-server/pull/341/files + if len(d.Params) == 0 { + tool.InputSchema = mcp.ToolInputSchema{} + tool.RawInputSchema = []byte(`{"type":"object","properties":{}}`) + } + + return tool +} + +// ToServerTool converts a ToolDef to an api.ServerTool +func (d ToolDef) ToServerTool(handler func(api.ToolHandlerParams) (*api.ToolCallResult, error)) api.ServerTool { + properties := make(map[string]*jsonschema.Schema) + var required []string + + for _, param := range d.Params { + schema := &jsonschema.Schema{ + Description: param.Description, + } + + switch param.Type { + case ParamTypeString: + schema.Type = "string" + if param.Pattern != "" { + schema.Pattern = param.Pattern + } + case ParamTypeBoolean: + schema.Type = "boolean" + } + + properties[param.Name] = schema + + if param.Required { + required = append(required, param.Name) + } + } + + inputSchema := &jsonschema.Schema{ + Type: "object", + Properties: properties, + } + + if len(required) > 0 { + inputSchema.Required = required + } + + return api.ServerTool{ + Tool: api.Tool{ + Name: d.Name, + Description: d.Description, + InputSchema: inputSchema, + Annotations: api.ToolAnnotations{ + Title: d.Title, + ReadOnlyHint: ptr.To(d.ReadOnly), + DestructiveHint: ptr.To(d.Destructive), + IdempotentHint: ptr.To(d.Idempotent), + OpenWorldHint: ptr.To(d.OpenWorld), + }, + }, + Handler: handler, + } +} diff --git a/pkg/toolset/tools/handlers.go b/pkg/toolset/tools/handlers.go index 51d6bcb..2606491 100644 --- a/pkg/toolset/tools/handlers.go +++ b/pkg/toolset/tools/handlers.go @@ -5,8 +5,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" - "github.com/rhobs/obs-mcp/pkg/handlers" - "github.com/rhobs/obs-mcp/pkg/tooldef" + "github.com/rhobs/obs-mcp/pkg/tools" ) // ListMetricsHandler handles the listing of available Prometheus metrics. @@ -16,7 +15,7 @@ func ListMetricsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, erro return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - return handlers.ListMetricsHandler(params.Context, promClient).ToToolsetResult() + return tools.ListMetricsHandler(params.Context, promClient).ToToolsetResult() } // ExecuteInstantQueryHandler handles the execution of Prometheus instant queries. @@ -26,7 +25,7 @@ func ExecuteInstantQueryHandler(params api.ToolHandlerParams) (*api.ToolCallResu return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - return handlers.ExecuteInstantQueryHandler(params.Context, promClient, tooldef.BuildInstantQueryInput(params.GetArguments())).ToToolsetResult() + return tools.ExecuteInstantQueryHandler(params.Context, promClient, tools.BuildInstantQueryInput(params.GetArguments())).ToToolsetResult() } // ExecuteRangeQueryHandler handles the execution of Prometheus range queries. @@ -36,7 +35,7 @@ func ExecuteRangeQueryHandler(params api.ToolHandlerParams) (*api.ToolCallResult return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - return handlers.ExecuteRangeQueryHandler(params.Context, promClient, tooldef.BuildRangeQueryInput(params.GetArguments())).ToToolsetResult() + return tools.ExecuteRangeQueryHandler(params.Context, promClient, tools.BuildRangeQueryInput(params.GetArguments())).ToToolsetResult() } // GetLabelNamesHandler handles the retrieval of label names. @@ -46,7 +45,7 @@ func GetLabelNamesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, er return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - return handlers.GetLabelNamesHandler(params.Context, promClient, tooldef.BuildLabelNamesInput(params.GetArguments())).ToToolsetResult() + return tools.GetLabelNamesHandler(params.Context, promClient, tools.BuildLabelNamesInput(params.GetArguments())).ToToolsetResult() } // GetLabelValuesHandler handles the retrieval of label values. @@ -56,7 +55,7 @@ func GetLabelValuesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, e return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - return handlers.GetLabelValuesHandler(params.Context, promClient, tooldef.BuildLabelValuesInput(params.GetArguments())).ToToolsetResult() + return tools.GetLabelValuesHandler(params.Context, promClient, tools.BuildLabelValuesInput(params.GetArguments())).ToToolsetResult() } // GetSeriesHandler handles the retrieval of time series. @@ -66,7 +65,7 @@ func GetSeriesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) return api.NewToolCallResult("", fmt.Errorf("failed to create Prometheus client: %w", err)), nil } - return handlers.GetSeriesHandler(params.Context, promClient, tooldef.BuildSeriesInput(params.GetArguments())).ToToolsetResult() + return tools.GetSeriesHandler(params.Context, promClient, tools.BuildSeriesInput(params.GetArguments())).ToToolsetResult() } // GetAlertsHandler handles the retrieval of alerts from Alertmanager. @@ -76,7 +75,7 @@ func GetAlertsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) return api.NewToolCallResult("", fmt.Errorf("failed to create Alertmanager client: %w", err)), nil } - return handlers.GetAlertsHandler(params.Context, amClient, tooldef.BuildAlertsInput(params.GetArguments())).ToToolsetResult() + return tools.GetAlertsHandler(params.Context, amClient, tools.BuildAlertsInput(params.GetArguments())).ToToolsetResult() } // GetSilencesHandler handles the retrieval of silences from Alertmanager. @@ -86,5 +85,5 @@ func GetSilencesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, erro return api.NewToolCallResult("", fmt.Errorf("failed to create Alertmanager client: %w", err)), nil } - return handlers.GetSilencesHandler(params.Context, amClient, tooldef.BuildSilencesInput(params.GetArguments())).ToToolsetResult() + return tools.GetSilencesHandler(params.Context, amClient, tools.BuildSilencesInput(params.GetArguments())).ToToolsetResult() } diff --git a/pkg/toolset/tools/tools.go b/pkg/toolset/tools/tools.go index 8192b18..26f35ee 100644 --- a/pkg/toolset/tools/tools.go +++ b/pkg/toolset/tools/tools.go @@ -3,61 +3,61 @@ package tools import ( "github.com/containers/kubernetes-mcp-server/pkg/api" - "github.com/rhobs/obs-mcp/pkg/tooldef" + "github.com/rhobs/obs-mcp/pkg/tools" ) // InitListMetrics creates the list_metrics tool. func InitListMetrics() []api.ServerTool { return []api.ServerTool{ - tooldef.ListMetrics.ToServerTool(ListMetricsHandler), + tools.ListMetrics.ToServerTool(ListMetricsHandler), } } // InitExecuteInstantQuery creates the execute_instant_query tool. func InitExecuteInstantQuery() []api.ServerTool { return []api.ServerTool{ - tooldef.ExecuteInstantQuery.ToServerTool(ExecuteInstantQueryHandler), + tools.ExecuteInstantQuery.ToServerTool(ExecuteInstantQueryHandler), } } // InitExecuteRangeQuery creates the execute_range_query tool. func InitExecuteRangeQuery() []api.ServerTool { return []api.ServerTool{ - tooldef.ExecuteRangeQuery.ToServerTool(ExecuteRangeQueryHandler), + tools.ExecuteRangeQuery.ToServerTool(ExecuteRangeQueryHandler), } } // InitGetLabelNames creates the get_label_names tool. func InitGetLabelNames() []api.ServerTool { return []api.ServerTool{ - tooldef.GetLabelNames.ToServerTool(GetLabelNamesHandler), + tools.GetLabelNames.ToServerTool(GetLabelNamesHandler), } } // InitGetLabelValues creates the get_label_values tool. func InitGetLabelValues() []api.ServerTool { return []api.ServerTool{ - tooldef.GetLabelValues.ToServerTool(GetLabelValuesHandler), + tools.GetLabelValues.ToServerTool(GetLabelValuesHandler), } } // InitGetSeries creates the get_series tool. func InitGetSeries() []api.ServerTool { return []api.ServerTool{ - tooldef.GetSeries.ToServerTool(GetSeriesHandler), + tools.GetSeries.ToServerTool(GetSeriesHandler), } } // InitGetAlerts creates the get_alerts tool. func InitGetAlerts() []api.ServerTool { return []api.ServerTool{ - tooldef.GetAlerts.ToServerTool(GetAlertsHandler), + tools.GetAlerts.ToServerTool(GetAlertsHandler), } } // InitGetSilences creates the get_silences tool. func InitGetSilences() []api.ServerTool { return []api.ServerTool{ - tooldef.GetSilences.ToServerTool(GetSilencesHandler), + tools.GetSilences.ToServerTool(GetSilencesHandler), } } diff --git a/pkg/toolset/toolset.go b/pkg/toolset/toolset.go index ad95549..6100eaf 100644 --- a/pkg/toolset/toolset.go +++ b/pkg/toolset/toolset.go @@ -6,8 +6,8 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/toolsets" - "github.com/rhobs/obs-mcp/pkg/prompts" - "github.com/rhobs/obs-mcp/pkg/toolset/tools" + "github.com/rhobs/obs-mcp/pkg/tools" + toolset_tools "github.com/rhobs/obs-mcp/pkg/toolset/tools" ) // Toolset implements the observability toolset for advanced Prometheus monitoring. @@ -22,20 +22,20 @@ func (t *Toolset) GetName() string { // GetDescription returns a human-readable description of the toolset. func (t *Toolset) GetDescription() string { - return prompts.ServerPrompt + return tools.ServerPrompt } // GetTools returns all tools provided by this toolset. func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { return slices.Concat( - tools.InitListMetrics(), - tools.InitExecuteInstantQuery(), - tools.InitExecuteRangeQuery(), - tools.InitGetLabelNames(), - tools.InitGetLabelValues(), - tools.InitGetSeries(), - tools.InitGetAlerts(), - tools.InitGetSilences(), + toolset_tools.InitListMetrics(), + toolset_tools.InitExecuteInstantQuery(), + toolset_tools.InitExecuteRangeQuery(), + toolset_tools.InitGetLabelNames(), + toolset_tools.InitGetLabelValues(), + toolset_tools.InitGetSeries(), + toolset_tools.InitGetAlerts(), + toolset_tools.InitGetSilences(), ) } From 73739a5172749d8d15711a89daf44c5a0216f45f Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Mon, 9 Feb 2026 11:40:22 +0000 Subject: [PATCH 8/8] Lint Signed-off-by: Saswata Mukherjee --- pkg/mcp/tools_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index 4dfe941..2b05b2f 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -313,16 +313,16 @@ func TestToolPatternValidation(t *testing.T) { } func TestToolsHaveOutputSchema(t *testing.T) { - tools := []mcp.Tool{ + toolsToTest := []mcp.Tool{ CreateListMetricsTool(), CreateExecuteRangeQueryTool(), } - if len(tools) == 0 { + if len(toolsToTest) == 0 { t.Fatal("expected at least one tool") } - for _, tool := range tools { + for _, tool := range toolsToTest { t.Run(tool.Name, func(t *testing.T) { if tool.OutputSchema.Type == "" && len(tool.RawOutputSchema) == 0 { t.Errorf("tool %q missing output schema", tool.Name)