From 0e0f0e8b51ae4d3e3aa3244257c073a3ae47b560 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Thu, 11 Dec 2025 10:44:33 +0000 Subject: [PATCH 1/8] Add tools for Perses dashboard This commit enables listing Perses dashboards from a cluster, getting a specific one, as well as returning out of the box dashboards that we ship with OpenShift platform. The idea is to for the LLM to first look at Out of the box dashboards, to see if it can answer a user's question using panels from those. And if not search wider for any custom dashboards a user might have. We also allow users (and ourselves!) to specify an LLM-friendly description of PersesDashboard objects using an annotation operator.perses.dev/mcp-help! This would help the LLM accurately filter and select dashboards that match a user's query. Signed-off-by: Saswata Mukherjee --- cmd/obs-mcp/main.go | 21 ++++-- go.mod | 34 +++++++++- go.sum | 90 ++++++++++++++++++++++-- pkg/k8s/perses.go | 124 ++++++++++++++++++++++++++++++++++ pkg/mcp/handlers.go | 101 ++++++++++++++++++++++++++- pkg/mcp/server.go | 20 ++++-- pkg/mcp/tools.go | 86 +++++++++++++++++++++++ pkg/mcp/tools_test.go | 3 + pkg/perses/dashboard.go | 9 +++ pkg/perses/ootb_dashboards.go | 32 +++++++++ 10 files changed, 502 insertions(+), 18 deletions(-) create mode 100644 pkg/k8s/perses.go create mode 100644 pkg/perses/dashboard.go create mode 100644 pkg/perses/ootb_dashboards.go diff --git a/cmd/obs-mcp/main.go b/cmd/obs-mcp/main.go index 334ecc3..7d040c6 100644 --- a/cmd/obs-mcp/main.go +++ b/cmd/obs-mcp/main.go @@ -13,6 +13,7 @@ import ( "github.com/rhobs/obs-mcp/pkg/k8s" "github.com/rhobs/obs-mcp/pkg/mcp" + "github.com/rhobs/obs-mcp/pkg/perses" "github.com/rhobs/obs-mcp/pkg/prometheus" ) @@ -30,6 +31,8 @@ func main() { var guardrails = flag.String("guardrails", "all", "Guardrails configuration: 'all' (default), 'none', or comma-separated list of guardrails to enable (disallow-explicit-name-label, require-label-matcher, disallow-blanket-regex)") var maxMetricCardinality = flag.Uint64("guardrails.max-metric-cardinality", 20000, "Maximum allowed series count per metric (0 = disabled)") var maxLabelCardinality = flag.Uint64("guardrails.max-label-cardinality", 500, "Maximum allowed label value count for blanket regex (0 = always disallow blanket regex). Only takes effect if disallow-blanket-regex is enabled.") + var ootbDashboards = flag.String("ootb-dashboards", "", "Path to YAML file containing out-of-the-box PersesDashboard definitions") + flag.Parse() // Configure slog with specified log level @@ -56,12 +59,22 @@ func main() { parsedGuardrails.MaxLabelCardinality = *maxLabelCardinality } + // Load out-of-the-box dashboards if specified + ootbDashboardsList, err := perses.LoadOOTBDashboards(*ootbDashboards) + if err != nil { + log.Fatalf("Failed to load OOTB dashboards: %v", err) + } + if len(ootbDashboardsList) > 0 { + slog.Info("Loaded out-of-the-box dashboards", "count", len(ootbDashboardsList)) + } + // Create MCP options opts := mcp.ObsMCPOptions{ - AuthMode: parsedAuthMode, - PromURL: promURL, - Insecure: *insecure, - Guardrails: parsedGuardrails, + AuthMode: parsedAuthMode, + PromURL: promURL, + Insecure: *insecure, + Guardrails: parsedGuardrails, + OOTBDashboards: ootbDashboardsList, } // Create MCP server diff --git a/go.mod b/go.mod index 33fca80..ad7c053 100644 --- a/go.mod +++ b/go.mod @@ -4,26 +4,39 @@ go 1.24.6 require ( github.com/mark3labs/mcp-go v0.39.1 + github.com/perses/perses-operator v0.2.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.67.1 github.com/prometheus/prometheus v0.307.3 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 + sigs.k8s.io/controller-runtime v0.22.4 ) require ( + cel.dev/expr v0.24.0 // indirect + github.com/PaesslerAG/gval v1.2.4 // indirect + github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/brunoga/deep v1.2.5 // 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/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect @@ -31,34 +44,51 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/labstack/echo/v4 v4.13.4 // indirect + github.com/labstack/gommon v0.4.2 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // 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/muhlemmer/gu v0.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/nexucis/lamenv v0.5.2 // indirect + github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e // indirect + github.com/perses/perses v0.51.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/zitadel/oidc/v3 v3.38.1 // indirect + github.com/zitadel/schema v1.3.1 // 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/crypto v0.42.0 // indirect + golang.org/x/exp v0.0.0-20250808145144-a408d31f581a // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/oauth2 v0.31.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/term v0.35.0 // indirect golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.13.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.1 // indirect - k8s.io/apimachinery v0.34.1 // 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 diff --git a/go.sum b/go.sum index 246fcf8..dce4297 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= 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= @@ -12,8 +14,16 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= 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/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU= +github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f h1:TxDCeKRCgHea2hUiMOjWwqzWmrIGqSOZYkEPuClXzDo= +github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f/go.mod h1:zTyVtYhYjcHpfCtqnCMxejgp0pEEwb/xJzhn05NrkJk= 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/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8= @@ -46,6 +56,8 @@ 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/brunoga/deep v1.2.5 h1:bigq4eooqbeJXfvTfZBn3AH3B1iW+rtetxVeh0GiLrg= +github.com/brunoga/deep v1.2.5/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI= 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= @@ -58,16 +70,24 @@ github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= +github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -82,8 +102,11 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v 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/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= 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.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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= @@ -117,27 +140,46 @@ 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/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 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/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= 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= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M= +github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= 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 v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e h1:AormqtWdtHdoQyGO90U1fRoElR0XQHmP0W9oJUsCOZY= +github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e/go.mod h1:CMTbKu0uWCFKgo4oDVoT8GcMC0bKyDH4cNG3GVfi+rA= +github.com/perses/perses v0.51.0 h1:lLssvsMjxFg2oP+vKX6pz2SFTfrUyso/A2/A/6oFens= +github.com/perses/perses v0.51.0/go.mod h1:DrGiL+itTLl2mwEvNa0wGokELfZTsqOc3TEg+2B0uwY= +github.com/perses/perses-operator v0.2.0 h1:gIhKUWca8ncaxyvOk2USaGfQ32eNcXzjDN97UlQAP0M= +github.com/perses/perses-operator v0.2.0/go.mod h1:91gFy0XicXrWSYSr4ChkMp16GSOkeXjKdkXlfEECw5g= 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= @@ -159,18 +201,35 @@ github.com/prometheus/prometheus v0.307.3 h1:zGIN3EpiKacbMatcUL2i6wC26eRWXdoXfNP github.com/prometheus/prometheus v0.307.3/go.mod h1:sPbNW+KTS7WmzFIafC3Inzb6oZVaGLnSvwqTdz2jxRQ= github.com/prometheus/sigv4 v0.2.1 h1:hl8D3+QEzU9rRmbKIRwMKRwaFGyLkbPdH5ZerglRHY0= github.com/prometheus/sigv4 v0.2.1/go.mod h1:ySk6TahIlsR2sxADuHy4IBFhwEjRGGsfbbLGhFYFj6Q= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 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= @@ -179,6 +238,10 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI 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= +github.com/zitadel/oidc/v3 v3.38.1 h1:VTf1Bv/33UbSwJnIWbfEIdpUGYKfoHetuBNIqVTcjvA= +github.com/zitadel/oidc/v3 v3.38.1/go.mod h1:muukzAasaWmn3vBwEVMglJfuTE0PKCvLJGombPwXIRw= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= @@ -191,8 +254,14 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= 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/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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= @@ -222,6 +291,8 @@ golang.org/x/sync v0.17.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.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= @@ -244,6 +315,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= 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= @@ -257,10 +330,13 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.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/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= 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= @@ -271,6 +347,8 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP 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/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= 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= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/pkg/k8s/perses.go b/pkg/k8s/perses.go new file mode 100644 index 0000000..1f74ab0 --- /dev/null +++ b/pkg/k8s/perses.go @@ -0,0 +1,124 @@ +package k8s + +import ( + "context" + "encoding/json" + "fmt" + + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + "github.com/rhobs/obs-mcp/pkg/perses" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // PersesMCPHelpAnnotation is the annotation key for MCP help description + PersesMCPHelpAnnotation = "operator.perses.dev/mcp-help" +) + +// GetPersesKubeClient returns a controller-runtime client with Perses types registered +func GetPersesKubeClient() (client.Client, error) { + config, err := GetClientConfig() + if err != nil { + return nil, err + } + + scheme := runtime.NewScheme() + if err := persesv1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add perses scheme: %w", err) + } + + c, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return nil, fmt.Errorf("failed to create controller-runtime client: %w", err) + } + + return c, nil +} + +// ListPersesDashboards lists all PersesDashboard objects across all namespaces or in a specific namespace. +// Uses types from github.com/perses/perses-operator/api/v1alpha1 +// The labelSelector parameter accepts Kubernetes label selector syntax (e.g., "app=myapp,env=prod"). +func ListPersesDashboards(ctx context.Context, namespace, labelSelector string) ([]perses.PersesDashboardInfo, error) { + c, err := GetPersesKubeClient() + if err != nil { + return nil, fmt.Errorf("failed to get perses client: %w", err) + } + + // Build list options + listOpts := &client.ListOptions{} + if namespace != "" { + listOpts.Namespace = namespace + } + if labelSelector != "" { + selector, err := labels.Parse(labelSelector) + if err != nil { + return nil, fmt.Errorf("invalid label selector: %w", err) + } + listOpts.LabelSelector = selector + } + + var dashboardList persesv1alpha1.PersesDashboardList + if err := c.List(ctx, &dashboardList, listOpts); err != nil { + return nil, fmt.Errorf("failed to list PersesDashboards: %w", err) + } + + dbInfos := make([]perses.PersesDashboardInfo, len(dashboardList.Items)) + for i, db := range dashboardList.Items { + dbInfo := perses.PersesDashboardInfo{ + Name: db.Name, + Namespace: db.Namespace, + Labels: db.GetLabels(), + } + + // Extract MCP help description from annotation if present + if annotations := db.GetAnnotations(); annotations != nil { + if description, ok := annotations[PersesMCPHelpAnnotation]; ok { + dbInfo.Description = description + } + } + + dbInfos[i] = dbInfo + } + + return dbInfos, nil +} + +// GetPersesDashboard retrieves a specific PersesDashboard by name and namespace. +// Returns the dashboard name, namespace, and full spec as a map for JSON serialization. +func GetPersesDashboard(ctx context.Context, namespace, name string) (string, string, map[string]interface{}, error) { + c, err := GetPersesKubeClient() + if err != nil { + return "", "", nil, fmt.Errorf("failed to get perses client: %w", err) + } + + var dashboard persesv1alpha1.PersesDashboard + key := client.ObjectKey{Namespace: namespace, Name: name} + if err := c.Get(ctx, key, &dashboard); err != nil { + return "", "", nil, fmt.Errorf("failed to get PersesDashboard %s/%s: %w", namespace, name, err) + } + + // Convert spec to map[string]interface{} for JSON serialization + specMap, err := specToMap(dashboard.Spec) + if err != nil { + return "", "", nil, fmt.Errorf("failed to convert spec to map: %w", err) + } + + return dashboard.Name, dashboard.Namespace, specMap, nil +} + +// specToMap converts a PersesDashboardSpec to a map[string]interface{} for JSON serialization +func specToMap(spec persesv1alpha1.Dashboard) (map[string]interface{}, error) { + data, err := json.Marshal(spec) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index e012506..1d2ea85 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -9,7 +9,8 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/prometheus/common/model" - + "github.com/rhobs/obs-mcp/pkg/k8s" + "github.com/rhobs/obs-mcp/pkg/perses" "github.com/rhobs/obs-mcp/pkg/prometheus" ) @@ -170,3 +171,101 @@ func ExecuteRangeQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.Call return mcp.NewToolResultStructured(output, string(jsonResult)), nil } } + +// ListPersesDashboardsHandler handles listing PersesDashboard CRD objects from the cluster. +func ListPersesDashboardsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("ListPersesDashboardsHandler called") + slog.Debug("ListPersesDashboardsHandler params", "params", req.Params) + + // Get optional parameters + namespace := req.GetString("namespace", "") + labelSelector := req.GetString("label_selector", "") + + dashboards, err := k8s.ListPersesDashboards(ctx, namespace, labelSelector) + if err != nil { + return errorResult(fmt.Sprintf("failed to list PersesDashboards: %s", err.Error())) + } + + slog.Info("ListPersesDashboardsHandler executed successfully", "resultLength", len(dashboards)) + slog.Debug("ListPersesDashboardsHandler results", "results", dashboards) + + // Convert to output format + dashboardInfos := make([]perses.PersesDashboardInfo, len(dashboards)) + for i, db := range dashboards { + dashboardInfos[i] = perses.PersesDashboardInfo{ + Name: db.Name, + Namespace: db.Namespace, + Labels: db.Labels, + Description: db.Description, + } + } + + output := ListPersesDashboardsOutput{Dashboards: dashboardInfos} + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal dashboards: %s", err.Error())) + } + + return mcp.NewToolResultStructured(output, string(result)), nil + } +} + +// OOTBPersesDashboardsHandler handles returning pre-configured out-of-the-box dashboards. +func OOTBPersesDashboardsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("OOTBPersesDashboardsHandler called") + + output := OOTBPersesDashboardsOutput{Dashboards: opts.OOTBDashboards} + + slog.Info("OOTBPersesDashboardsHandler executed successfully", "resultLength", len(opts.OOTBDashboards)) + slog.Debug("OOTBPersesDashboardsHandler results", "results", opts.OOTBDashboards) + + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal OOTB dashboards: %s", err.Error())) + } + + return mcp.NewToolResultStructured(output, string(result)), nil + } +} + +// GetPersesDashboardHandler handles getting a specific PersesDashboard by name and namespace. +func GetPersesDashboardHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("GetPersesDashboardHandler called") + slog.Debug("GetPersesDashboardHandler params", "params", req.Params) + + // Get required parameters + name, err := req.RequireString("name") + if err != nil { + return errorResult("name parameter is required and must be a string") + } + + namespace, err := req.RequireString("namespace") + if err != nil { + return errorResult("namespace parameter is required and must be a string") + } + + dashboardName, dashboardNamespace, spec, err := k8s.GetPersesDashboard(ctx, namespace, name) + if err != nil { + return errorResult(fmt.Sprintf("failed to get PersesDashboard: %s", err.Error())) + } + + slog.Info("GetPersesDashboardHandler executed successfully", "name", dashboardName, "namespace", dashboardNamespace) + slog.Debug("GetPersesDashboardHandler spec", "spec", spec) + + output := GetPersesDashboardOutput{ + Name: dashboardName, + Namespace: dashboardNamespace, + Spec: spec, + } + + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal dashboard: %s", err.Error())) + } + + return mcp.NewToolResultStructured(output, string(result)), nil + } +} diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 49af9fe..0569d18 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -12,16 +12,17 @@ import ( "time" "github.com/mark3labs/mcp-go/server" - + "github.com/rhobs/obs-mcp/pkg/perses" "github.com/rhobs/obs-mcp/pkg/prometheus" ) // ObsMCPOptions contains configuration options for the MCP server type ObsMCPOptions struct { - AuthMode AuthMode - PromURL string - Insecure bool - Guardrails *prometheus.Guardrails + AuthMode AuthMode + PromURL string + Insecure bool + Guardrails *prometheus.Guardrails + OOTBDashboards []perses.PersesDashboardInfo // Out-of-the-box dashboards loaded from YAML } const ( @@ -51,14 +52,23 @@ func SetupTools(mcpServer *server.MCPServer, opts ObsMCPOptions) error { // Create tool definitions listMetricsTool := CreateListMetricsTool() executeRangeQueryTool := CreateExecuteRangeQueryTool() + listPersesDashboardsTool := CreateListPersesDashboardsTool() + ootbPersesDashboardsTool := CreateOOTBPersesDashboardsTool() + getPersesDashboardTool := CreateGetPersesDashboardTool() // Create handlers listMetricsHandler := ListMetricsHandler(opts) executeRangeQueryHandler := ExecuteRangeQueryHandler(opts) + listPersesDashboardsHandler := ListPersesDashboardsHandler(opts) + ootbPersesDashboardsHandler := OOTBPersesDashboardsHandler(opts) + getPersesDashboardHandler := GetPersesDashboardHandler(opts) // Add tools to server mcpServer.AddTool(listMetricsTool, listMetricsHandler) mcpServer.AddTool(executeRangeQueryTool, executeRangeQueryHandler) + mcpServer.AddTool(ootbPersesDashboardsTool, ootbPersesDashboardsHandler) + mcpServer.AddTool(listPersesDashboardsTool, listPersesDashboardsHandler) + mcpServer.AddTool(getPersesDashboardTool, getPersesDashboardHandler) return nil } diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 7900334..6d3e291 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -2,6 +2,7 @@ package mcp import ( "github.com/mark3labs/mcp-go/mcp" + "github.com/rhobs/obs-mcp/pkg/perses" ) // ListMetricsOutput defines the output schema for the list_metrics tool. @@ -66,3 +67,88 @@ For historical data queries, use explicit 'start' and 'end' times. mcp.WithOutputSchema[RangeQueryOutput](), ) } + +// ListPersesDashboardsOutput defines the output schema for the list_perses_dashboards tool. +type ListPersesDashboardsOutput struct { + Dashboards []perses.PersesDashboardInfo `json:"dashboards" jsonschema:"description=List of PersesDashboard objects from the cluster"` +} + +func CreateListPersesDashboardsTool() mcp.Tool { + return mcp.NewTool("list_perses_dashboards", + mcp.WithDescription(`List all PersesDashboard custom resources from the Kubernetes cluster. + +PersesDashboard is a Custom Resource from the Perses operator (https://github.com/perses/perses-operator) that defines +dashboard configurations. This tool returns summary information about all available dashboards in the form of a list of names, namespaces, labels, and descriptions. + +IMPORTANT: Before using this tool, first check out_of_the_box_perses_dashboards for curated platform dashboards that are more likely to answer common user questions. +Only use this tool if the out-of-the-box dashboards don't have what the user is looking for. + +You can optionally filter by namespace and/or labels. + +Once you have found the dashboard you need, you can use the get_perses_dashboard tool to get the dashboard's panels and configuration. + +IMPORTANT: If you are looking for a specific dashboard, use this tool first to see if it exists. If it does, use the get_perses_dashboard tool to get the dashboard's panels and configuration. +`), + mcp.WithString("namespace", + mcp.Description("Optional namespace to filter dashboards. Leave empty to list from all namespaces."), + ), + mcp.WithString("label_selector", + mcp.Description("Optional Kubernetes label selector to filter dashboards (e.g., 'app=myapp', 'env=prod,team=platform', 'app in (foo,bar)'). Leave empty to list all dashboards."), + ), + mcp.WithOutputSchema[ListPersesDashboardsOutput](), + ) +} + +// OOTBPersesDashboardsOutput defines the output schema for the out_of_the_box_perses_dashboards tool. +type OOTBPersesDashboardsOutput struct { + Dashboards []perses.PersesDashboardInfo `json:"dashboards" jsonschema:"description=List of curated out-of-the-box PersesDashboard definitions"` +} + +// GetPersesDashboardOutput defines the output schema for the get_perses_dashboard tool. +type GetPersesDashboardOutput struct { + Name string `json:"name" jsonschema:"description=Name of the PersesDashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace where the PersesDashboard is located"` + Spec map[string]interface{} `json:"spec" jsonschema:"description=The full dashboard specification including panels, layouts, variables, and datasources"` +} + +func CreateOOTBPersesDashboardsTool() mcp.Tool { + tool := mcp.NewTool("out_of_the_box_perses_dashboards", + mcp.WithDescription(`List curated out-of-the-box PersesDashboard definitions for the platform. + +IMPORTANT: Use this tool FIRST when looking for dashboards. These are pre-configured, curated dashboards that cover common platform observability needs and are +most likely to answer user questions about the platform. + +Only fall back to list_perses_dashboards if the dashboards returned here don't have +what the user is looking for. + +Returns a list of dashboard summaries with name, namespace, labels, and description explaining what each dashboard contains. +`), + mcp.WithOutputSchema[OOTBPersesDashboardsOutput](), + ) + // workaround for tool with no parameter + tool.InputSchema = mcp.ToolInputSchema{} + tool.RawInputSchema = []byte(`{"type":"object","properties":{}}`) + return tool +} + +func CreateGetPersesDashboardTool() mcp.Tool { + return mcp.NewTool("get_perses_dashboard", + mcp.WithDescription(`Get a specific PersesDashboard by name and namespace. This tool is used to get the dashboard's panels and configuration. + +Use the list_perses_dashboards or out_of_the_box_perses_dashboards tool first to find available dashboards, then use this tool to get the full specification of a specific dashboard. + +Returns the dashboard's full specification including panels, layouts, variables, and datasources in JSON format. + +You can glean PromQL queries from the dashboard's panels and variables, as well as production context to allow you to answer a user's questions better. +`), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Name of the PersesDashboard"), + ), + mcp.WithString("namespace", + mcp.Required(), + mcp.Description("Namespace of the PersesDashboard"), + ), + mcp.WithOutputSchema[GetPersesDashboardOutput](), + ) +} diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index 9d8b9cc..f9e8bcd 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -314,6 +314,9 @@ func TestToolsHaveOutputSchema(t *testing.T) { tools := []mcp.Tool{ CreateListMetricsTool(), CreateExecuteRangeQueryTool(), + CreateListPersesDashboardsTool(), + CreateOOTBPersesDashboardsTool(), + CreateGetPersesDashboardTool(), } if len(tools) == 0 { diff --git a/pkg/perses/dashboard.go b/pkg/perses/dashboard.go new file mode 100644 index 0000000..8760737 --- /dev/null +++ b/pkg/perses/dashboard.go @@ -0,0 +1,9 @@ +package perses + +// PersesDashboardInfo contains summary information about a PersesDashboard. +type PersesDashboardInfo struct { + Name string `json:"name" jsonschema:"description=Name of the PersesDashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace where the PersesDashboard is located"` + Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Labels attached to the PersesDashboard"` + Description string `json:"description,omitempty" jsonschema:"description=Human-readable description of the dashboard and what information it contains (from operator.perses.dev/mcp-help annotation)"` +} diff --git a/pkg/perses/ootb_dashboards.go b/pkg/perses/ootb_dashboards.go new file mode 100644 index 0000000..432f071 --- /dev/null +++ b/pkg/perses/ootb_dashboards.go @@ -0,0 +1,32 @@ +package perses + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// OOTBDashboardsConfig represents the YAML structure for out-of-the-box dashboards +type OOTBDashboardsConfig struct { + Dashboards []PersesDashboardInfo `yaml:"dashboards"` +} + +// LoadOOTBDashboards loads out-of-the-box dashboard definitions from a YAML file +func LoadOOTBDashboards(filePath string) ([]PersesDashboardInfo, error) { + if filePath == "" { + return nil, nil + } + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read OOTB dashboards file: %w", err) + } + + var config OOTBDashboardsConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse OOTB dashboards YAML: %w", err) + } + + return config.Dashboards, nil +} From 9c81ad00a644460d1fb09fa66a391cae08144e35 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Thu, 11 Dec 2025 13:22:24 +0000 Subject: [PATCH 2/8] Translate list call in handler Signed-off-by: Saswata Mukherjee --- pkg/k8s/perses.go | 23 ++--------------------- pkg/mcp/handlers.go | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/pkg/k8s/perses.go b/pkg/k8s/perses.go index 1f74ab0..169f59f 100644 --- a/pkg/k8s/perses.go +++ b/pkg/k8s/perses.go @@ -6,7 +6,6 @@ import ( "fmt" persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" - "github.com/rhobs/obs-mcp/pkg/perses" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -40,7 +39,7 @@ func GetPersesKubeClient() (client.Client, error) { // ListPersesDashboards lists all PersesDashboard objects across all namespaces or in a specific namespace. // Uses types from github.com/perses/perses-operator/api/v1alpha1 // The labelSelector parameter accepts Kubernetes label selector syntax (e.g., "app=myapp,env=prod"). -func ListPersesDashboards(ctx context.Context, namespace, labelSelector string) ([]perses.PersesDashboardInfo, error) { +func ListPersesDashboards(ctx context.Context, namespace, labelSelector string) ([]persesv1alpha1.PersesDashboard, error) { c, err := GetPersesKubeClient() if err != nil { return nil, fmt.Errorf("failed to get perses client: %w", err) @@ -64,25 +63,7 @@ func ListPersesDashboards(ctx context.Context, namespace, labelSelector string) return nil, fmt.Errorf("failed to list PersesDashboards: %w", err) } - dbInfos := make([]perses.PersesDashboardInfo, len(dashboardList.Items)) - for i, db := range dashboardList.Items { - dbInfo := perses.PersesDashboardInfo{ - Name: db.Name, - Namespace: db.Namespace, - Labels: db.GetLabels(), - } - - // Extract MCP help description from annotation if present - if annotations := db.GetAnnotations(); annotations != nil { - if description, ok := annotations[PersesMCPHelpAnnotation]; ok { - dbInfo.Description = description - } - } - - dbInfos[i] = dbInfo - } - - return dbInfos, nil + return dashboardList.Items, nil } // GetPersesDashboard retrieves a specific PersesDashboard by name and namespace. diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index 1d2ea85..87af94c 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -193,12 +193,20 @@ func ListPersesDashboardsHandler(opts ObsMCPOptions) func(context.Context, mcp.C // Convert to output format dashboardInfos := make([]perses.PersesDashboardInfo, len(dashboards)) for i, db := range dashboards { - dashboardInfos[i] = perses.PersesDashboardInfo{ - Name: db.Name, - Namespace: db.Namespace, - Labels: db.Labels, - Description: db.Description, + dashboardInfo := perses.PersesDashboardInfo{ + Name: db.Name, + Namespace: db.Namespace, + Labels: db.GetLabels(), } + + // Extract MCP help description from annotation if present + if annotations := db.GetAnnotations(); annotations != nil { + if description, ok := annotations[k8s.PersesMCPHelpAnnotation]; ok { + dashboardInfo.Description = description + } + } + + dashboardInfos[i] = dashboardInfo } output := ListPersesDashboardsOutput{Dashboards: dashboardInfos} From 5784e021f29b99b9401d7d97baf4724f2622a404 Mon Sep 17 00:00:00 2001 From: Pranshu Srivastava Date: Sun, 25 Jan 2026 09:52:23 +0530 Subject: [PATCH 3/8] pkg/*: leverage existing dashboards and support UI rendering The MCP server can now find existing Perses dashboards in the cluster and narrow them down to panels that, upon visualization, help answer the user's concerns better. It is assumed that since the queries in these panels have been through human reviews and used in production, they do not need to be subject to the same validation and guardrails as we for the queries that the LLM comes up with on-the-fly. Upon the LLM sharing it's intent to render one or more panels after going through the existing dashboards, the MCP server will expose payloads representing panels in the same structure as found in jhadvig/genie-plugin, so the UI side has no problems graphing these out. --- cmd/obs-mcp/main.go | 20 +-- pkg/k8s/perses.go | 26 +-- pkg/mcp/auth.go | 8 +- pkg/mcp/handlers.go | 310 ++++++++++++++++++++++++++++------ pkg/mcp/handlers_test.go | 12 +- pkg/mcp/server.go | 37 ++-- pkg/mcp/tools.go | 162 ++++++++++++------ pkg/mcp/tools_test.go | 8 +- pkg/perses/dashboard.go | 56 +++++- pkg/perses/ootb_dashboards.go | 32 ---- pkg/perses/panels.go | 299 ++++++++++++++++++++++++++++++++ 11 files changed, 777 insertions(+), 193 deletions(-) delete mode 100644 pkg/perses/ootb_dashboards.go create mode 100644 pkg/perses/panels.go diff --git a/cmd/obs-mcp/main.go b/cmd/obs-mcp/main.go index 7d040c6..a5a1fcb 100644 --- a/cmd/obs-mcp/main.go +++ b/cmd/obs-mcp/main.go @@ -13,7 +13,6 @@ import ( "github.com/rhobs/obs-mcp/pkg/k8s" "github.com/rhobs/obs-mcp/pkg/mcp" - "github.com/rhobs/obs-mcp/pkg/perses" "github.com/rhobs/obs-mcp/pkg/prometheus" ) @@ -31,7 +30,6 @@ func main() { var guardrails = flag.String("guardrails", "all", "Guardrails configuration: 'all' (default), 'none', or comma-separated list of guardrails to enable (disallow-explicit-name-label, require-label-matcher, disallow-blanket-regex)") var maxMetricCardinality = flag.Uint64("guardrails.max-metric-cardinality", 20000, "Maximum allowed series count per metric (0 = disabled)") var maxLabelCardinality = flag.Uint64("guardrails.max-label-cardinality", 500, "Maximum allowed label value count for blanket regex (0 = always disallow blanket regex). Only takes effect if disallow-blanket-regex is enabled.") - var ootbDashboards = flag.String("ootb-dashboards", "", "Path to YAML file containing out-of-the-box PersesDashboard definitions") flag.Parse() @@ -59,22 +57,12 @@ func main() { parsedGuardrails.MaxLabelCardinality = *maxLabelCardinality } - // Load out-of-the-box dashboards if specified - ootbDashboardsList, err := perses.LoadOOTBDashboards(*ootbDashboards) - if err != nil { - log.Fatalf("Failed to load OOTB dashboards: %v", err) - } - if len(ootbDashboardsList) > 0 { - slog.Info("Loaded out-of-the-box dashboards", "count", len(ootbDashboardsList)) - } - // Create MCP options opts := mcp.ObsMCPOptions{ - AuthMode: parsedAuthMode, - PromURL: promURL, - Insecure: *insecure, - Guardrails: parsedGuardrails, - OOTBDashboards: ootbDashboardsList, + AuthMode: parsedAuthMode, + PromURL: promURL, + Insecure: *insecure, + Guardrails: parsedGuardrails, } // Create MCP server diff --git a/pkg/k8s/perses.go b/pkg/k8s/perses.go index 169f59f..a0a9425 100644 --- a/pkg/k8s/perses.go +++ b/pkg/k8s/perses.go @@ -12,12 +12,12 @@ import ( ) const ( - // PersesMCPHelpAnnotation is the annotation key for MCP help description - PersesMCPHelpAnnotation = "operator.perses.dev/mcp-help" + // MCPHelpAnnotation is the annotation key for MCP help description + MCPHelpAnnotation = "operator.perses.dev/mcp-help" ) -// GetPersesKubeClient returns a controller-runtime client with Perses types registered -func GetPersesKubeClient() (client.Client, error) { +// GetPersesClient returns a controller-runtime client with types registered +func GetPersesClient() (client.Client, error) { config, err := GetClientConfig() if err != nil { return nil, err @@ -36,11 +36,11 @@ func GetPersesKubeClient() (client.Client, error) { return c, nil } -// ListPersesDashboards lists all PersesDashboard objects across all namespaces or in a specific namespace. +// ListDashboards lists all Dashboard objects across all namespaces or in a specific namespace. // Uses types from github.com/perses/perses-operator/api/v1alpha1 // The labelSelector parameter accepts Kubernetes label selector syntax (e.g., "app=myapp,env=prod"). -func ListPersesDashboards(ctx context.Context, namespace, labelSelector string) ([]persesv1alpha1.PersesDashboard, error) { - c, err := GetPersesKubeClient() +func ListDashboards(ctx context.Context, namespace, labelSelector string) ([]persesv1alpha1.PersesDashboard, error) { + c, err := GetPersesClient() if err != nil { return nil, fmt.Errorf("failed to get perses client: %w", err) } @@ -60,16 +60,16 @@ func ListPersesDashboards(ctx context.Context, namespace, labelSelector string) var dashboardList persesv1alpha1.PersesDashboardList if err := c.List(ctx, &dashboardList, listOpts); err != nil { - return nil, fmt.Errorf("failed to list PersesDashboards: %w", err) + return nil, fmt.Errorf("failed to list Dashboards: %w", err) } return dashboardList.Items, nil } -// GetPersesDashboard retrieves a specific PersesDashboard by name and namespace. +// GetDashboard retrieves a specific Dashboard by name and namespace. // Returns the dashboard name, namespace, and full spec as a map for JSON serialization. -func GetPersesDashboard(ctx context.Context, namespace, name string) (string, string, map[string]interface{}, error) { - c, err := GetPersesKubeClient() +func GetDashboard(ctx context.Context, namespace, name string) (string, string, map[string]interface{}, error) { + c, err := GetPersesClient() if err != nil { return "", "", nil, fmt.Errorf("failed to get perses client: %w", err) } @@ -77,7 +77,7 @@ func GetPersesDashboard(ctx context.Context, namespace, name string) (string, st var dashboard persesv1alpha1.PersesDashboard key := client.ObjectKey{Namespace: namespace, Name: name} if err := c.Get(ctx, key, &dashboard); err != nil { - return "", "", nil, fmt.Errorf("failed to get PersesDashboard %s/%s: %w", namespace, name, err) + return "", "", nil, fmt.Errorf("failed to get Dashboard %s/%s: %w", namespace, name, err) } // Convert spec to map[string]interface{} for JSON serialization @@ -89,7 +89,7 @@ func GetPersesDashboard(ctx context.Context, namespace, name string) (string, st return dashboard.Name, dashboard.Namespace, specMap, nil } -// specToMap converts a PersesDashboardSpec to a map[string]interface{} for JSON serialization +// specToMap converts a DashboardSpec to a map[string]interface{} for JSON serialization func specToMap(spec persesv1alpha1.Dashboard) (map[string]interface{}, error) { data, err := json.Marshal(spec) if err != nil { diff --git a/pkg/mcp/auth.go b/pkg/mcp/auth.go index fdcefe6..d37a546 100644 --- a/pkg/mcp/auth.go +++ b/pkg/mcp/auth.go @@ -22,9 +22,12 @@ import ( type AuthMode string const ( - AuthModeKubeConfig AuthMode = "kubeconfig" + // AuthModeKubeConfig uses kubeconfig for authentication + AuthModeKubeConfig AuthMode = "kubeconfig" + // AuthModeServiceAccount uses in-cluster service account token for authentication AuthModeServiceAccount AuthMode = "serviceaccount" - AuthModeHeader AuthMode = "header" + // AuthModeHeader uses token from context header for authentication + AuthModeHeader AuthMode = "header" ) const ( @@ -32,6 +35,7 @@ const ( defaultServiceAccountCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt" ) +// ContextKey defines keys for context values type ContextKey string const ( diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index 87af94c..4078b34 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -172,44 +172,42 @@ func ExecuteRangeQueryHandler(opts ObsMCPOptions) func(context.Context, mcp.Call } } -// ListPersesDashboardsHandler handles listing PersesDashboard CRD objects from the cluster. -func ListPersesDashboardsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("ListPersesDashboardsHandler called") - slog.Debug("ListPersesDashboardsHandler params", "params", req.Params) - - // Get optional parameters - namespace := req.GetString("namespace", "") - labelSelector := req.GetString("label_selector", "") - - dashboards, err := k8s.ListPersesDashboards(ctx, namespace, labelSelector) +// DashboardsHandler handles returning all dashboards from the cluster. +// Returns all Dashboard resources to provide maximum context for LLM selection. +func DashboardsHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("DashboardsHandler called") + + // TODO: add a label selectors flag when more dashboards start annotating themselves? + dashboards, err := k8s.ListDashboards(ctx, "", "") if err != nil { - return errorResult(fmt.Sprintf("failed to list PersesDashboards: %s", err.Error())) + return errorResult(fmt.Sprintf("failed to list dashboards: %s", err.Error())) } - slog.Info("ListPersesDashboardsHandler executed successfully", "resultLength", len(dashboards)) - slog.Debug("ListPersesDashboardsHandler results", "results", dashboards) - - // Convert to output format - dashboardInfos := make([]perses.PersesDashboardInfo, len(dashboards)) - for i, db := range dashboards { - dashboardInfo := perses.PersesDashboardInfo{ - Name: db.Name, - Namespace: db.Namespace, - Labels: db.GetLabels(), + // Convert to DashboardInfo + dashboardInfos := make([]perses.DashboardInfo, 0, len(dashboards)) + for _, dashboard := range dashboards { + info := perses.DashboardInfo{ + Name: dashboard.Name, + Namespace: dashboard.Namespace, + Labels: dashboard.Labels, } - // Extract MCP help description from annotation if present - if annotations := db.GetAnnotations(); annotations != nil { - if description, ok := annotations[k8s.PersesMCPHelpAnnotation]; ok { - dashboardInfo.Description = description + // Extract description from annotation + if dashboard.Annotations != nil { + if desc, ok := dashboard.Annotations[k8s.MCPHelpAnnotation /* TODO: currently no such annotation is curated across openshift */]; ok { + info.Description = desc } } - dashboardInfos[i] = dashboardInfo + dashboardInfos = append(dashboardInfos, info) } - output := ListPersesDashboardsOutput{Dashboards: dashboardInfos} + slog.Info("DashboardsHandler executed successfully", "dashboardCount", len(dashboardInfos)) + slog.Debug("DashboardsHandler results", "results", dashboardInfos) + + output := DashboardsOutput{Dashboards: dashboardInfos} + result, err := json.Marshal(output) if err != nil { return errorResult(fmt.Sprintf("failed to marshal dashboards: %s", err.Error())) @@ -219,32 +217,51 @@ func ListPersesDashboardsHandler(opts ObsMCPOptions) func(context.Context, mcp.C } } -// OOTBPersesDashboardsHandler handles returning pre-configured out-of-the-box dashboards. -func OOTBPersesDashboardsHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// GetDashboardHandler handles getting a specific dashboard by name and namespace. +func GetDashboardHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("OOTBPersesDashboardsHandler called") + slog.Info("GetDashboardHandler called") + slog.Debug("GetDashboardHandler params", "params", req.Params) + + name, err := req.RequireString("name") + if err != nil { + return errorResult("name parameter is required and must be a string") + } + + namespace, err := req.RequireString("namespace") + if err != nil { + return errorResult("namespace parameter is required and must be a string") + } - output := OOTBPersesDashboardsOutput{Dashboards: opts.OOTBDashboards} + dashboardName, dashboardNamespace, spec, err := k8s.GetDashboard(ctx, namespace, name) + if err != nil { + return errorResult(fmt.Sprintf("failed to get Dashboard: %s", err.Error())) + } - slog.Info("OOTBPersesDashboardsHandler executed successfully", "resultLength", len(opts.OOTBDashboards)) - slog.Debug("OOTBPersesDashboardsHandler results", "results", opts.OOTBDashboards) + slog.Info("GetDashboardHandler executed successfully", "name", dashboardName, "namespace", dashboardNamespace) + slog.Debug("GetDashboardHandler spec", "spec", spec) + + output := GetDashboardOutput{ + Name: dashboardName, + Namespace: dashboardNamespace, + Spec: spec, + } result, err := json.Marshal(output) if err != nil { - return errorResult(fmt.Sprintf("failed to marshal OOTB dashboards: %s", err.Error())) + return errorResult(fmt.Sprintf("failed to marshal dashboard: %s", err.Error())) } return mcp.NewToolResultStructured(output, string(result)), nil } } -// GetPersesDashboardHandler handles getting a specific PersesDashboard by name and namespace. -func GetPersesDashboardHandler(opts ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// GetDashboardPanelsHandler handles getting panel metadata from a dashboard for LLM selection. +func GetDashboardPanelsHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - slog.Info("GetPersesDashboardHandler called") - slog.Debug("GetPersesDashboardHandler params", "params", req.Params) + slog.Info("GetDashboardPanelsHandler called") + slog.Debug("GetDashboardPanelsHandler params", "params", req.Params) - // Get required parameters name, err := req.RequireString("name") if err != nil { return errorResult("name parameter is required and must be a string") @@ -255,25 +272,224 @@ func GetPersesDashboardHandler(opts ObsMCPOptions) func(context.Context, mcp.Cal return errorResult("namespace parameter is required and must be a string") } - dashboardName, dashboardNamespace, spec, err := k8s.GetPersesDashboard(ctx, namespace, name) + // Optional panel IDs filter + panelIDsStr := req.GetString("panel_ids", "") + var panelIDs []string + if panelIDsStr != "" { + for _, part := range splitByComma(panelIDsStr) { + if part != "" { + panelIDs = append(panelIDs, part) + } + } + } + + dashboardName, dashboardNamespace, spec, err := k8s.GetDashboard(ctx, namespace, name) if err != nil { - return errorResult(fmt.Sprintf("failed to get PersesDashboard: %s", err.Error())) + return errorResult(fmt.Sprintf("failed to get dashboard: %s", err.Error())) } - slog.Info("GetPersesDashboardHandler executed successfully", "name", dashboardName, "namespace", dashboardNamespace) - slog.Debug("GetPersesDashboardHandler spec", "spec", spec) + // Extract panel metadata (with optional filtering) + panels, err := perses.ExtractPanels(dashboardName, dashboardNamespace, spec, false, panelIDs) + if err != nil { + return errorResult(fmt.Sprintf("failed to extract panels: %s", err.Error())) + } + + duration := "1h" + if d, ok := spec["duration"].(string); ok { + duration = d + } - output := GetPersesDashboardOutput{ + slog.Info("GetDashboardPanelsHandler executed successfully", + "name", dashboardName, + "namespace", dashboardNamespace, + "requested", len(panelIDs), + "returned", len(panels)) + + output := GetDashboardPanelsOutput{ Name: dashboardName, Namespace: dashboardNamespace, - Spec: spec, + Duration: duration, + Panels: panels, } result, err := json.Marshal(output) if err != nil { - return errorResult(fmt.Sprintf("failed to marshal dashboard: %s", err.Error())) + return errorResult(fmt.Sprintf("failed to marshal panels: %s", err.Error())) } return mcp.NewToolResultStructured(output, string(result)), nil } } + +// FormatPanelsForUIHandler handles formatting selected panels for UI rendering. +func FormatPanelsForUIHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Info("FormatPanelsForUIHandler called") + slog.Debug("FormatPanelsForUIHandler params", "params", req.Params) + + dashboardName, err := req.RequireString("dashboard_name") + if err != nil { + return errorResult("dashboard_name parameter is required and must be a string") + } + + dashboardNamespace, err := req.RequireString("dashboard_namespace") + if err != nil { + return errorResult("dashboard_namespace parameter is required and must be a string") + } + + panelIDsStr := req.GetString("panel_ids", "") + + // Parse comma-separated panel IDs + var panelIDs []string + if panelIDsStr != "" { + for _, part := range splitByComma(panelIDsStr) { + if part != "" { + panelIDs = append(panelIDs, part) + } + } + } + + _, _, spec, err := k8s.GetDashboard(ctx, dashboardNamespace, dashboardName) + if err != nil { + return errorResult(fmt.Sprintf("failed to get Dashboard: %s", err.Error())) + } + + // Extract full panel details for UI + panels, err := perses.ExtractPanels(dashboardName, dashboardNamespace, spec, true, panelIDs) + if err != nil { + return errorResult(fmt.Sprintf("failed to extract panels: %s", err.Error())) + } + + // Convert panels to DashboardWidget format + widgets := convertPanelsToDashboardWidgets(panels) + + slog.Info("FormatPanelsForUIHandler executed successfully", + "dashboard", dashboardName, + "namespace", dashboardNamespace, + "requestedPanels", len(panelIDs), + "formattedWidgets", len(widgets)) + + output := FormatPanelsForUIOutput{ + Widgets: widgets, + } + + result, err := json.Marshal(output) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal panels: %s", err.Error())) + } + + return mcp.NewToolResultStructured(output, string(result)), nil + } +} + +func splitByComma(s string) []string { + var parts []string + current := "" + for i := 0; i < len(s); i++ { + if s[i] == ',' { + trimmed := trimWhitespace(current) + if trimmed != "" { + parts = append(parts, trimmed) + } + current = "" + } else { + current += string(s[i]) + } + } + trimmed := trimWhitespace(current) + if trimmed != "" { + parts = append(parts, trimmed) + } + return parts +} + +func trimWhitespace(s string) string { + start := 0 + end := len(s) + + for start < end && isWhitespace(s[start]) { + start++ + } + + for end > start && isWhitespace(s[end-1]) { + end-- + } + + return s[start:end] +} + +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// convertPanelsToDashboardWidgets converts DashboardPanel objects to DashboardWidget format expected by UI. +func convertPanelsToDashboardWidgets(panels []perses.DashboardPanel) []perses.DashboardWidget { + widgets := make([]perses.DashboardWidget, 0, len(panels)) + + for _, panel := range panels { + // Set defaults for required fields + step := panel.Step + if step == "" { + step = "15s" // default step for Prometheus queries + } + duration := panel.Duration + if duration == "" { + duration = "1h" // default duration + } + + // Infer breakpoint from panel width if available + breakpoint := "lg" // default + if panel.Position != nil { + breakpoint = inferBreakpointFromWidth(panel.Position.W) + } + + widget := perses.DashboardWidget{ + ID: panel.ID, + ComponentType: mapChartTypeToComponent(panel.ChartType), + Breakpoint: breakpoint, + Props: perses.DashboardWidgetProps{ + Query: panel.Query, + Duration: duration, + Start: panel.Start, + End: panel.End, + Step: step, + }, + } + + // Add position if available + if panel.Position != nil { + widget.Position = *panel.Position + } + + widgets = append(widgets, widget) + } + + return widgets +} + +// mapChartTypeToComponent maps Perses chart types to component names +func mapChartTypeToComponent(chartType string) string { + switch chartType { + case "TimeSeriesChart": + return "PersesTimeSeries" + case "PieChart", "StatChart": + return "PersesPieChart" + case "Table": + return "PersesTable" + default: + return "PersesTimeSeries" // default fallback + } +} + +// inferBreakpointFromWidth maps panel width to responsive breakpoint. +// Perses uses a 24-column grid, so we infer breakpoints based on width. +func inferBreakpointFromWidth(width int) string { + if width >= 18 { + return "xl" + } else if width >= 12 { + return "lg" + } else if width >= 6 { + return "md" + } + return "sm" +} diff --git a/pkg/mcp/handlers_test.go b/pkg/mcp/handlers_test.go index 67765ff..05c4a9d 100644 --- a/pkg/mcp/handlers_test.go +++ b/pkg/mcp/handlers_test.go @@ -68,7 +68,7 @@ func TestExecuteRangeQueryHandler_ExplicitTimeRange_RFC3339(t *testing.T) { expectedEnd, _ := prometheus.ParseTimestamp("2024-01-01T01:00:00Z") mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { if query != "up{job=\"api\"}" { t.Errorf("expected query 'up{job=\"api\"}', got %q", query) } @@ -105,8 +105,8 @@ func TestExecuteRangeQueryHandler_ExplicitTimeRange_RFC3339(t *testing.T) { func TestExecuteRangeQueryHandler_StepParsing_ValidSteps(t *testing.T) { mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { - return map[string]any{"resultType": "matrix", "result": []any{}}, nil + ExecuteRangeQueryFunc: func(_ context.Context, _ string, _, _ time.Time, _ time.Duration) (map[string]any, error) { + return map[string]interface{}{"resultType": "matrix", "result": []interface{}{}}, nil }, } @@ -246,7 +246,7 @@ func TestExecuteRangeQueryHandler_RequiredParameters(t *testing.T) { func TestExecuteRangeQueryHandler_DurationMode_DefaultOneHour(t *testing.T) { mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { if query != "up{job=\"api\"}" { t.Errorf("expected query 'up{job=\"api\"}', got %q", query) } @@ -282,7 +282,7 @@ func TestExecuteRangeQueryHandler_DurationMode_DefaultOneHour(t *testing.T) { func TestExecuteRangeQueryHandler_DurationMode_CustomDuration(t *testing.T) { mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { if query != "rate(http_requests_total{job=\"api\"}[5m])" { t.Errorf("expected query 'rate(http_requests_total{job=\"api\"}[5m])', got %q", query) } @@ -316,7 +316,7 @@ func TestExecuteRangeQueryHandler_DurationMode_CustomDuration(t *testing.T) { func TestExecuteRangeQueryHandler_DurationMode_NOWKeyword(t *testing.T) { mockClient := &MockedLoader{ - ExecuteRangeQueryFunc: func(ctx context.Context, query string, start, end time.Time, step time.Duration) (map[string]any, error) { + ExecuteRangeQueryFunc: func(_ context.Context, _ string, start, end time.Time, _ time.Duration) (map[string]any, error) { duration := end.Sub(start) if duration < 59*time.Minute || duration > 61*time.Minute { t.Errorf("expected duration ~1h when NOW is used, got %v", duration) diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 0569d18..f54d5ab 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -12,17 +12,15 @@ import ( "time" "github.com/mark3labs/mcp-go/server" - "github.com/rhobs/obs-mcp/pkg/perses" "github.com/rhobs/obs-mcp/pkg/prometheus" ) // ObsMCPOptions contains configuration options for the MCP server type ObsMCPOptions struct { - AuthMode AuthMode - PromURL string - Insecure bool - Guardrails *prometheus.Guardrails - OOTBDashboards []perses.PersesDashboardInfo // Out-of-the-box dashboards loaded from YAML + AuthMode AuthMode + PromURL string + Insecure bool + Guardrails *prometheus.Guardrails } const ( @@ -33,6 +31,7 @@ const ( defaultShutdownTimeout = 10 * time.Second ) +// NewMCPServer creates and configures a new MCP server instance func NewMCPServer(opts ObsMCPOptions) (*server.MCPServer, error) { mcpServer := server.NewMCPServer( serverName, @@ -48,27 +47,32 @@ func NewMCPServer(opts ObsMCPOptions) (*server.MCPServer, error) { return mcpServer, nil } +// SetupTools registers all MCP tools and their handlers with the server func SetupTools(mcpServer *server.MCPServer, opts ObsMCPOptions) error { // Create tool definitions listMetricsTool := CreateListMetricsTool() executeRangeQueryTool := CreateExecuteRangeQueryTool() - listPersesDashboardsTool := CreateListPersesDashboardsTool() - ootbPersesDashboardsTool := CreateOOTBPersesDashboardsTool() - getPersesDashboardTool := CreateGetPersesDashboardTool() + listDashboardsTool := CreateListDashboardsTool() + getDashboardTool := CreateGetDashboardTool() + getDashboardPanelsTool := CreateGetDashboardPanelsTool() + formatPanelsForUITool := CreateFormatPanelsForUITool() // Create handlers listMetricsHandler := ListMetricsHandler(opts) executeRangeQueryHandler := ExecuteRangeQueryHandler(opts) - listPersesDashboardsHandler := ListPersesDashboardsHandler(opts) - ootbPersesDashboardsHandler := OOTBPersesDashboardsHandler(opts) - getPersesDashboardHandler := GetPersesDashboardHandler(opts) + dashboardsHandler := DashboardsHandler(opts) + getDashboardHandler := GetDashboardHandler(opts) + getDashboardPanelsHandler := GetDashboardPanelsHandler(opts) + formatPanelsForUIHandler := FormatPanelsForUIHandler(opts) // Add tools to server mcpServer.AddTool(listMetricsTool, listMetricsHandler) mcpServer.AddTool(executeRangeQueryTool, executeRangeQueryHandler) - mcpServer.AddTool(ootbPersesDashboardsTool, ootbPersesDashboardsHandler) - mcpServer.AddTool(listPersesDashboardsTool, listPersesDashboardsHandler) - mcpServer.AddTool(getPersesDashboardTool, getPersesDashboardHandler) + mcpServer.AddTool(listDashboardsTool, dashboardsHandler) + mcpServer.AddTool(listDashboardsTool, dashboardsHandler) + mcpServer.AddTool(getDashboardTool, getDashboardHandler) + mcpServer.AddTool(getDashboardPanelsTool, getDashboardPanelsHandler) + mcpServer.AddTool(formatPanelsForUITool, formatPanelsForUIHandler) return nil } @@ -93,6 +97,7 @@ func loggingMiddleware(next http.Handler) http.Handler { }) } +// Serve starts the MCP server and listens for incoming HTTP requests func Serve(ctx context.Context, mcpServer *server.MCPServer, listenAddr string) error { mux := http.NewServeMux() @@ -110,7 +115,7 @@ func Serve(ctx context.Context, mcpServer *server.MCPServer, listenAddr string) mux.Handle("/", streamableHTTPServer) - mux.HandleFunc(healthEndpoint, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(healthEndpoint, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) }) diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 6d3e291..ad654f3 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -19,10 +19,11 @@ type RangeQueryOutput struct { // 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"` + Metric map[string]string `json:"metric" jsonschema:"description=The metric labels as key-value pairs"` + Values [][]any `json:"values" jsonschema:"description=Array of [timestamp, value] pairs where timestamp is Unix epoch in seconds and value is the metric value"` } +// CreateListMetricsTool creates the list_metrics tool definition. func CreateListMetricsTool() mcp.Tool { tool := mcp.NewTool("list_metrics", mcp.WithDescription("List all available metrics in Prometheus"), @@ -35,6 +36,7 @@ func CreateListMetricsTool() mcp.Tool { return tool } +// CreateExecuteRangeQueryTool creates the execute_range_query tool definition. func CreateExecuteRangeQueryTool() mcp.Tool { return mcp.NewTool("execute_range_query", mcp.WithDescription(`Execute a PromQL range query with flexible time specification. @@ -68,87 +70,141 @@ For historical data queries, use explicit 'start' and 'end' times. ) } -// ListPersesDashboardsOutput defines the output schema for the list_perses_dashboards tool. -type ListPersesDashboardsOutput struct { - Dashboards []perses.PersesDashboardInfo `json:"dashboards" jsonschema:"description=List of PersesDashboard objects from the cluster"` +// DashboardsOutput defines the output schema for the list_dashboards tool. +type DashboardsOutput struct { + Dashboards []perses.DashboardInfo `json:"dashboards" jsonschema:"description=List of all PersesDashboard resources from the cluster with their metadata"` } -func CreateListPersesDashboardsTool() mcp.Tool { - return mcp.NewTool("list_perses_dashboards", - mcp.WithDescription(`List all PersesDashboard custom resources from the Kubernetes cluster. +// GetDashboardOutput defines the output schema for the get_dashboard tool. +type GetDashboardOutput struct { + Name string `json:"name" jsonschema:"description=Name of the Dashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace where the Dashboard is located"` + Spec map[string]interface{} `json:"spec" jsonschema:"description=The full dashboard specification including panels, layouts, variables, and datasources"` +} + +// CreateListDashboardsTool creates the list_dashboards tool definition. +func CreateListDashboardsTool() mcp.Tool { + tool := mcp.NewTool("list_dashboards", + mcp.WithDescription(`List all PersesDashboard resources from the cluster. -PersesDashboard is a Custom Resource from the Perses operator (https://github.com/perses/perses-operator) that defines -dashboard configurations. This tool returns summary information about all available dashboards in the form of a list of names, namespaces, labels, and descriptions. +Start here when there is a need to visualize metrics. -IMPORTANT: Before using this tool, first check out_of_the_box_perses_dashboards for curated platform dashboards that are more likely to answer common user questions. -Only use this tool if the out-of-the-box dashboards don't have what the user is looking for. +Returns dashboard summaries with name, namespace, labels, and descriptions. -You can optionally filter by namespace and/or labels. +Use the descriptions to identify dashboards relevant to the user's question. -Once you have found the dashboard you need, you can use the get_perses_dashboard tool to get the dashboard's panels and configuration. +In the case that there is insufficient information in the description, use get_dashboard to fetch the full dashboard spec for more context. Doing so is an expensive operation, so only do this when necessary. -IMPORTANT: If you are looking for a specific dashboard, use this tool first to see if it exists. If it does, use the get_perses_dashboard tool to get the dashboard's panels and configuration. +Follow up with get_dashboard_panels to see what panels are available in the relevant dashboard(s). `), - mcp.WithString("namespace", - mcp.Description("Optional namespace to filter dashboards. Leave empty to list from all namespaces."), + mcp.WithOutputSchema[DashboardsOutput](), + ) + // workaround for tool with no parameter + tool.InputSchema = mcp.ToolInputSchema{} + tool.RawInputSchema = []byte(`{"type":"object","properties":{}}`) + return tool +} + +// CreateGetDashboardTool creates the get_dashboard tool definition. +func CreateGetDashboardTool() mcp.Tool { + return mcp.NewTool("get_dashboard", + mcp.WithDescription(`Get a specific Dashboard by name and namespace. This tool is used to get the dashboard's panels and configuration. + +Use the list_dashboards tool first to find available dashboards, then use this tool to get the full specification of a specific dashboard, if needed (to gather more context). + +The intended use of this tool is only to gather more context on one or more dashboards when the description from list_dashboards is insufficient. + +Information about panels themselves should be gathered using get_dashboard_panels instead (e.g., looking at a "kind: Markdown" panel to gather more context). + +Returns the dashboard's full specification including panels, layouts, variables, and datasources in JSON format. + +For most use cases, you will want to follow up with get_dashboard_panels to extract panel metadata for selection. +`), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Name of the Dashboard"), ), - mcp.WithString("label_selector", - mcp.Description("Optional Kubernetes label selector to filter dashboards (e.g., 'app=myapp', 'env=prod,team=platform', 'app in (foo,bar)'). Leave empty to list all dashboards."), + mcp.WithString("namespace", + mcp.Required(), + mcp.Description("Namespace of the Dashboard"), ), - mcp.WithOutputSchema[ListPersesDashboardsOutput](), + mcp.WithOutputSchema[GetDashboardOutput](), ) } -// OOTBPersesDashboardsOutput defines the output schema for the out_of_the_box_perses_dashboards tool. -type OOTBPersesDashboardsOutput struct { - Dashboards []perses.PersesDashboardInfo `json:"dashboards" jsonschema:"description=List of curated out-of-the-box PersesDashboard definitions"` +// GetDashboardPanelsOutput defines the output schema for the get_dashboard_panels tool. +type GetDashboardPanelsOutput struct { + Name string `json:"name" jsonschema:"description=Name of the dashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace of the dashboard"` + Duration string `json:"duration,omitempty" jsonschema:"description=Default time duration for queries extracted from dashboard spec (e.g. 1h, 24h)"` + Panels []perses.DashboardPanel `json:"panels" jsonschema:"description=List of panel metadata including IDs, titles, queries, and chart types for LLM selection"` } -// GetPersesDashboardOutput defines the output schema for the get_perses_dashboard tool. -type GetPersesDashboardOutput struct { - Name string `json:"name" jsonschema:"description=Name of the PersesDashboard"` - Namespace string `json:"namespace" jsonschema:"description=Namespace where the PersesDashboard is located"` - Spec map[string]interface{} `json:"spec" jsonschema:"description=The full dashboard specification including panels, layouts, variables, and datasources"` -} +// CreateGetDashboardPanelsTool creates the get_dashboard_panels tool definition. +func CreateGetDashboardPanelsTool() mcp.Tool { + return mcp.NewTool("get_dashboard_panels", + mcp.WithDescription(`Get panel(s) information from a specific Dashboard. -func CreateOOTBPersesDashboardsTool() mcp.Tool { - tool := mcp.NewTool("out_of_the_box_perses_dashboards", - mcp.WithDescription(`List curated out-of-the-box PersesDashboard definitions for the platform. +After finding a relevant dashboard (using list_dashboards and conditionally, get_dashboard), use this to see what panels it contains. -IMPORTANT: Use this tool FIRST when looking for dashboards. These are pre-configured, curated dashboards that cover common platform observability needs and are -most likely to answer user questions about the platform. +Returns panel metadata including: +- Panel IDs (format: 'panelName' or 'panelName-N' for multi-query panels) +- Titles and descriptions +- PromQL queries (may contain variables like $namespace) +- Chart types (TimeSeriesChart, PieChart, Table) -Only fall back to list_perses_dashboards if the dashboards returned here don't have -what the user is looking for. +You can optionally provide specific panel IDs to fetch only those panels. This is useful when you remember panel IDs from earlier calls and want to re-fetch just their metadata without retrieving the entire dashboard's panels. -Returns a list of dashboard summaries with name, namespace, labels, and description explaining what each dashboard contains. +Use this information to identify which panels answer the user's question, then use format_panels_for_ui with the selected panel IDs to prepare them for display. `), - mcp.WithOutputSchema[OOTBPersesDashboardsOutput](), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Name of the Dashboard"), + ), + mcp.WithString("namespace", + mcp.Required(), + mcp.Description("Namespace of the Dashboard"), + ), + mcp.WithString("panel_ids", + mcp.Description("Optional comma-separated list of panel IDs to filter. Panel IDs follow the format 'panelName' or 'panelName-N' where N is the query index (e.g. 'cpuUsage,memoryUsage-0,networkTraffic-1'). Use this to fetch metadata for specific panels you've seen in earlier calls. Leave empty to get all panels."), + ), + mcp.WithOutputSchema[GetDashboardPanelsOutput](), ) - // workaround for tool with no parameter - tool.InputSchema = mcp.ToolInputSchema{} - tool.RawInputSchema = []byte(`{"type":"object","properties":{}}`) - return tool } -func CreateGetPersesDashboardTool() mcp.Tool { - return mcp.NewTool("get_perses_dashboard", - mcp.WithDescription(`Get a specific PersesDashboard by name and namespace. This tool is used to get the dashboard's panels and configuration. +// FormatPanelsForUIOutput defines the output schema for the format_panels_for_ui tool. +type FormatPanelsForUIOutput struct { + Widgets []perses.DashboardWidget `json:"widgets" jsonschema:"description=Dashboard widgets in DashboardWidget format ready for direct rendering by genie-plugin UI"` +} + +// CreateFormatPanelsForUITool creates the format_panels_for_ui tool definition. +func CreateFormatPanelsForUITool() mcp.Tool { + return mcp.NewTool("format_panels_for_ui", + mcp.WithDescription(`Format selected dashboard panels for UI rendering in DashboardWidget format. -Use the list_perses_dashboards or out_of_the_box_perses_dashboards tool first to find available dashboards, then use this tool to get the full specification of a specific dashboard. +After choosing relevant panels, use this to prepare them for display. -Returns the dashboard's full specification including panels, layouts, variables, and datasources in JSON format. +Returns an array of DashboardWidget objects ready for direct rendering, with: +- id: Unique panel identifier +- componentType: Perses component name (PersesTimeSeries, PersesPieChart, PersesTable) +- position: Grid layout coordinates (x, y, w, h) in 24-column grid +- breakpoint: Responsive grid breakpoint (xl/lg/md/sm) inferred from panel width +- props: Component properties (query, duration, step, start, end) -You can glean PromQL queries from the dashboard's panels and variables, as well as production context to allow you to answer a user's questions better. +Panel IDs (fetched using get_dashboard_panels) must be provided to specify which panels to format. `), - mcp.WithString("name", + mcp.WithString("dashboard_name", mcp.Required(), - mcp.Description("Name of the PersesDashboard"), + mcp.Description("Name of the dashboard containing the panels"), ), - mcp.WithString("namespace", + mcp.WithString("dashboard_namespace", mcp.Required(), - mcp.Description("Namespace of the PersesDashboard"), + mcp.Description("Namespace of the dashboard"), + ), + mcp.WithString("panel_ids", + mcp.Required(), // Panel IDs are not required in get_dashboard_panels, but are required here to specify which panels to format + mcp.Description("Comma-separated list of panel IDs to format (e.g. 'myPanelID-1,0_1-2')"), ), - mcp.WithOutputSchema[GetPersesDashboardOutput](), + mcp.WithOutputSchema[FormatPanelsForUIOutput](), ) } diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index f9e8bcd..9bcc10d 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -314,9 +314,11 @@ func TestToolsHaveOutputSchema(t *testing.T) { tools := []mcp.Tool{ CreateListMetricsTool(), CreateExecuteRangeQueryTool(), - CreateListPersesDashboardsTool(), - CreateOOTBPersesDashboardsTool(), - CreateGetPersesDashboardTool(), + CreateListDashboardsTool(), + CreateListDashboardsTool(), + CreateGetDashboardTool(), + CreateGetDashboardPanelsTool(), + CreateFormatPanelsForUITool(), } if len(tools) == 0 { diff --git a/pkg/perses/dashboard.go b/pkg/perses/dashboard.go index 8760737..d63a1ea 100644 --- a/pkg/perses/dashboard.go +++ b/pkg/perses/dashboard.go @@ -1,9 +1,55 @@ package perses -// PersesDashboardInfo contains summary information about a PersesDashboard. -type PersesDashboardInfo struct { - Name string `json:"name" jsonschema:"description=Name of the PersesDashboard"` - Namespace string `json:"namespace" jsonschema:"description=Namespace where the PersesDashboard is located"` - Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Labels attached to the PersesDashboard"` +// DashboardInfo contains metadata about a Perses Dashboard. +type DashboardInfo struct { + Name string `json:"name" jsonschema:"description=Name of the Dashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace where the Dashboard is located"` + Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Labels attached to the Dashboard"` Description string `json:"description,omitempty" jsonschema:"description=Human-readable description of the dashboard and what information it contains (from operator.perses.dev/mcp-help annotation)"` } + +// DashboardPanel is an intermediary representation of a dashboard panel's metadata and query information. +// We maintain this alongside DashboardWidget to separate concerns between data extraction and UI rendering. +// More importantly, this helps curb the blast radius of changes if the DashboardWidget interface evolves, which we expect it will (during the genie-plugin to genie-web-client migration). +type DashboardPanel struct { + // Needed to build context for identification and selection + ID string `json:"id" jsonschema:"description=Unique identifier for the panel in format 'panelName' or 'panelName-N' where N is the query index for multi-query panels"` + Title string `json:"title,omitempty" jsonschema:"description=Human-readable title of the panel extracted from panel.display.name"` + Description string `json:"description,omitempty" jsonschema:"description=Description of what the panel displays extracted from panel.display.description"` + Query string `json:"query" jsonschema:"description=PromQL query string for fetching data"` + ChartType string `json:"chartType,omitempty" jsonschema:"description=Type of chart to render (TimeSeriesChart, PieChart, StatChart, Table, etc)"` + + // Needed for UI rendering (only populated when fullDetails=true in ExtractPanels) + Duration string `json:"duration,omitempty" jsonschema:"description=Time duration for the query (e.g. 1h, 24h, 7d), extracted from dashboard spec or defaults to 1h"` + Start string `json:"start,omitempty" jsonschema:"description=Optional explicit start time as RFC3339 or Unix timestamp"` + End string `json:"end,omitempty" jsonschema:"description=Optional explicit end time as RFC3339 or Unix timestamp"` + Step string `json:"step,omitempty" jsonschema:"description=Query resolution step width (e.g. 15s, 1m, 5m), extracted from query spec if available"` + Position *PanelPosition `json:"position,omitempty" jsonschema:"description=Layout position information extracted from dashboard layout spec (only when fullDetails=true)"` +} + +// PanelPosition defines the layout position of a panel in a 24-column grid system. +type PanelPosition struct { + X int `json:"x" jsonschema:"description=X coordinate in 24-column grid"` + Y int `json:"y" jsonschema:"description=Y coordinate in grid"` + W int `json:"w" jsonschema:"description=Width in grid units (out of 24 columns)"` + H int `json:"h" jsonschema:"description=Height in grid units"` +} + +// DashboardWidget represents a dashboard widget in the format expected by genie-plugin UI. +// This matches the DashboardWidget interface from jhadvig/genie-plugin. +type DashboardWidget struct { + ID string `json:"id" jsonschema:"description=Unique identifier for the widget"` + ComponentType string `json:"componentType" jsonschema:"description=Type of Perses component to render (PersesTimeSeries, PersesPieChart, PersesTable)"` + Position PanelPosition `json:"position,omitempty" jsonschema:"description=Layout position in 24-column grid (optional, included when available from dashboard layout)"` + Props DashboardWidgetProps `json:"props" jsonschema:"description=Properties passed to the Perses component"` + Breakpoint string `json:"breakpoint" jsonschema:"description=Responsive grid breakpoint (xl/lg/md/sm) inferred from panel width, defaults to lg if position unavailable"` +} + +// DashboardWidgetProps contains the properties passed to Perses components. +type DashboardWidgetProps struct { + Query string `json:"query" jsonschema:"description=PromQL query string"` + Duration string `json:"duration" jsonschema:"description=Time duration for the query (e.g. 1h, 24h), defaults to 1h if not specified in dashboard"` + Start string `json:"start,omitempty" jsonschema:"description=Optional explicit start time as RFC3339 or Unix timestamp"` + End string `json:"end,omitempty" jsonschema:"description=Optional explicit end time as RFC3339 or Unix timestamp"` + Step string `json:"step" jsonschema:"description=Query resolution step width (e.g. 15s, 1m, 5m), defaults to 15s if not specified in dashboard"` +} diff --git a/pkg/perses/ootb_dashboards.go b/pkg/perses/ootb_dashboards.go deleted file mode 100644 index 432f071..0000000 --- a/pkg/perses/ootb_dashboards.go +++ /dev/null @@ -1,32 +0,0 @@ -package perses - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -// OOTBDashboardsConfig represents the YAML structure for out-of-the-box dashboards -type OOTBDashboardsConfig struct { - Dashboards []PersesDashboardInfo `yaml:"dashboards"` -} - -// LoadOOTBDashboards loads out-of-the-box dashboard definitions from a YAML file -func LoadOOTBDashboards(filePath string) ([]PersesDashboardInfo, error) { - if filePath == "" { - return nil, nil - } - - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read OOTB dashboards file: %w", err) - } - - var config OOTBDashboardsConfig - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse OOTB dashboards YAML: %w", err) - } - - return config.Dashboards, nil -} diff --git a/pkg/perses/panels.go b/pkg/perses/panels.go new file mode 100644 index 0000000..816763d --- /dev/null +++ b/pkg/perses/panels.go @@ -0,0 +1,299 @@ +package perses + +import ( + "fmt" + "log/slog" +) + +// ExtractPanels extracts panel information from a dashboard spec. +// If fullDetails is true, includes position, step, and duration for UI rendering. +// If panelIDs is provided, only extracts those specific panels. +// TODO: Sometimes, the dashboard description may be present in a dedicated panel rather than the dashboard metadata. Consider extracting that as well. +func ExtractPanels(dashboardName, dashboardNamespace string, spec map[string]any, fullDetails bool, panelIDs []string) ([]DashboardPanel, error) { + var panels []DashboardPanel + + panelsMap, ok := spec["panels"].(map[string]any) + if !ok { + slog.Debug("No panels found in dashboard spec", "dashboard", dashboardName) + return panels, nil + } + + // Build a lookup set for requested panel IDs + requestedIDs := make(map[string]bool) + for _, id := range panelIDs { + requestedIDs[id] = true + } + + // Extract additional details only if needed + var defaultDuration string + var layoutMap map[string]PanelPosition + if fullDetails { + defaultDuration = extractDefaultDuration(spec) + layoutMap = extractLayoutPositions(spec) + } + + // Process each panel + for panelName, panelData := range panelsMap { + panelMap, ok := panelData.(map[string]any) + if !ok { + continue + } + + // Extract the spec from the Panel wrapper + // Structure: { kind: "Panel", spec: { display, plugin, queries } } + spec, ok := panelMap["spec"].(map[string]any) + if !ok { + // Try without wrapper for backwards compatibility + spec = panelMap + } + + // Get basic panel info + title, description := extractDisplayInfo(spec) + chartType := extractChartType(spec) + queries := extractQueries(spec) + + // Create a panel for each query + for i, query := range queries { + panelID := panelName + if len(queries) > 1 { + panelID = fmt.Sprintf("%s-%d", panelName, i) + } + + // Skip if filtering and this panel wasn't requested + if len(requestedIDs) > 0 && !requestedIDs[panelID] { + continue + } + + panel := DashboardPanel{ + ID: panelID, + Title: title, + Description: description, + Query: query.Query, + ChartType: chartType, + } + + // Add full details only if requested + if fullDetails { + panel.Duration = defaultDuration + panel.Step = query.Step + if pos, ok := layoutMap[panelName]; ok { + panel.Position = &pos + } + } + + panels = append(panels, panel) + } + } + + slog.Debug("Extracted panels from dashboard", + "dashboard", dashboardName, + "namespace", dashboardNamespace, + "fullDetails", fullDetails, + "panelCount", len(panels)) + + return panels, nil +} + +// panelQuery represents a query extracted from a panel +type panelQuery struct { + Query string + Step string +} + +// extractDisplayInfo extracts title and description from a panel's display section +func extractDisplayInfo(panelMap map[string]any) (title, description string) { + display, ok := panelMap["display"].(map[string]any) + if !ok { + return "", "" + } + + if name, ok := display["name"].(string); ok { + title = name + } + if desc, ok := display["description"].(string); ok { + description = desc + } + return title, description +} + +// extractChartType extracts and maps the chart type from a panel's plugin section +func extractChartType(panelMap map[string]any) string { + plugin, ok := panelMap["plugin"].(map[string]any) + if !ok { + return "" + } + + kind, ok := plugin["kind"].(string) + if !ok { + return "" + } + + return mapKindToChartType(kind) +} + +// extractQueries extracts all queries from a panel +func extractQueries(panelMap map[string]any) []panelQuery { + var queries []panelQuery + + queriesArray, ok := panelMap["queries"].([]any) + if !ok { + return queries + } + + for _, queryData := range queriesArray { + queryMap, ok := queryData.(map[string]any) + if !ok { + continue + } + + if pq := extractSingleQuery(queryMap); pq.Query != "" { + queries = append(queries, pq) + } + } + + return queries +} + +// extractSingleQuery extracts query and step from a query spec. +// +// structure: { spec: { plugin: { spec: { query: "...", step: "..." } } } } +func extractSingleQuery(queryMap map[string]any) panelQuery { + spec, ok := queryMap["spec"].(map[string]any) + if !ok { + return panelQuery{} + } + + plugin, ok := spec["plugin"].(map[string]any) + if !ok { + return panelQuery{} + } + + pluginSpec, ok := plugin["spec"].(map[string]any) + if !ok { + return panelQuery{} + } + + pq := panelQuery{} + if query, ok := pluginSpec["query"].(string); ok { + pq.Query = query + } + if step, ok := pluginSpec["step"].(string); ok { + pq.Step = step + } + + return pq +} + +// extractDefaultDuration extracts the default duration from dashboard spec +func extractDefaultDuration(spec map[string]any) string { + if duration, ok := spec["duration"].(string); ok { + return duration + } + return "1h" +} + +// extractLayoutPositions extracts panel positions from the dashboard layout +func extractLayoutPositions(spec map[string]any) map[string]PanelPosition { + positions := make(map[string]PanelPosition) + + layouts, ok := spec["layouts"].([]any) + if !ok { + return positions + } + + for _, layoutData := range layouts { + layoutMap, ok := layoutData.(map[string]any) + if !ok { + continue + } + + layoutSpec, ok := layoutMap["spec"].(map[string]any) + if !ok { + continue + } + + items, ok := layoutSpec["items"].([]any) + if !ok { + continue + } + + for _, itemData := range items { + itemMap, ok := itemData.(map[string]any) + if !ok { + continue + } + + x, xOk := getInt(itemMap["x"]) + y, yOk := getInt(itemMap["y"]) + w, wOk := getInt(itemMap["width"]) + h, hOk := getInt(itemMap["height"]) + + if !xOk || !yOk || !wOk || !hOk { + continue + } + + // Extract panel reference from content.$ref + content, ok := itemMap["content"].(map[string]any) + if !ok { + continue + } + + ref, ok := content["$ref"].(string) + if !ok { + continue + } + + // - x: 4 + // "y": 1 + // width: 4 + // height: 3 + // content: + // $ref: "#/spec/panels/0_1" + panelName := extractPanelNameFromRef(ref) + if panelName != "" { + positions[panelName] = PanelPosition{X: x, Y: y, W: w, H: h} + } + } + } + + return positions +} + +// extractPanelNameFromRef extracts panel name from a JSON reference +// Example: "#/spec/panels/panelName" -> "panelName" +func extractPanelNameFromRef(ref string) string { + const prefix = "#/spec/panels/" + if len(ref) > len(prefix) && ref[:len(prefix)] == prefix { + return ref[len(prefix):] + } + return "" +} + +// mapKindToChartType maps plugin kinds to UI chart types +func mapKindToChartType(persesKind string) string { + switch persesKind { + case "TimeSeriesChart", "BarChart": + return "TimeSeriesChart" + case "StatChart", "GaugeChart": + return "PieChart" + case "Table": + return "Table" + default: + return persesKind + } +} + +func getInt(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int64: + return int(v), true + case float64: + return int(v), true + case float32: + return int(v), true + default: + return 0, false + } +} From 4d1f4056022a8c336361fef47cc24f741f9a89b0 Mon Sep 17 00:00:00 2001 From: Pranshu Srivastava Date: Sun, 25 Jan 2026 13:33:09 +0530 Subject: [PATCH 4/8] pkg/{mcp,perses}: add tests for added handler parameters --- pkg/mcp/handlers.go | 21 +++--- pkg/mcp/handlers_test.go | 151 +++++++++++++++++++++++++++++++++++++++ pkg/mcp/tools.go | 4 +- pkg/perses/panels.go | 1 - 4 files changed, 165 insertions(+), 12 deletions(-) diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index 4078b34..79805ed 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -327,17 +327,20 @@ func FormatPanelsForUIHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToo slog.Info("FormatPanelsForUIHandler called") slog.Debug("FormatPanelsForUIHandler params", "params", req.Params) - dashboardName, err := req.RequireString("dashboard_name") + name, err := req.RequireString("name") if err != nil { - return errorResult("dashboard_name parameter is required and must be a string") + return errorResult("name parameter is required and must be a string") } - dashboardNamespace, err := req.RequireString("dashboard_namespace") + namespace, err := req.RequireString("namespace") if err != nil { - return errorResult("dashboard_namespace parameter is required and must be a string") + return errorResult("namespace parameter is required and must be a string") } - panelIDsStr := req.GetString("panel_ids", "") + panelIDsStr, err := req.RequireString("panel_ids") + if err != nil { + return errorResult("panel_ids parameter is required and must be a string") + } // Parse comma-separated panel IDs var panelIDs []string @@ -349,13 +352,13 @@ func FormatPanelsForUIHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToo } } - _, _, spec, err := k8s.GetDashboard(ctx, dashboardNamespace, dashboardName) + _, _, spec, err := k8s.GetDashboard(ctx, namespace, name) if err != nil { return errorResult(fmt.Sprintf("failed to get Dashboard: %s", err.Error())) } // Extract full panel details for UI - panels, err := perses.ExtractPanels(dashboardName, dashboardNamespace, spec, true, panelIDs) + panels, err := perses.ExtractPanels(name, namespace, spec, true, panelIDs) if err != nil { return errorResult(fmt.Sprintf("failed to extract panels: %s", err.Error())) } @@ -364,8 +367,8 @@ func FormatPanelsForUIHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToo widgets := convertPanelsToDashboardWidgets(panels) slog.Info("FormatPanelsForUIHandler executed successfully", - "dashboard", dashboardName, - "namespace", dashboardNamespace, + "dashboard", name, + "namespace", namespace, "requestedPanels", len(panelIDs), "formattedWidgets", len(widgets)) diff --git a/pkg/mcp/handlers_test.go b/pkg/mcp/handlers_test.go index 05c4a9d..266aca7 100644 --- a/pkg/mcp/handlers_test.go +++ b/pkg/mcp/handlers_test.go @@ -357,3 +357,154 @@ func getErrorMessage(t *testing.T, result *mcp.CallToolResult) string { return fmt.Sprintf("%v", content) } } + +func TestGetDashboardPanelsHandler_RequiredParameters(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expectedError string + }{ + { + name: "missing name parameter", + params: map[string]interface{}{"namespace": "default"}, + expectedError: "name parameter is required and must be a string", + }, + { + name: "missing namespace parameter", + params: map[string]interface{}{"name": "test-dashboard"}, + expectedError: "namespace parameter is required and must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := GetDashboardPanelsHandler(ObsMCPOptions{}) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "get_dashboard_panels", + Arguments: tt.params, + }, + } + + result, _ := handler(context.Background(), req) + errorMsg := getErrorMessage(t, result) + if errorMsg != tt.expectedError { + t.Errorf("expected error %q, got %q", tt.expectedError, errorMsg) + } + }) + } +} + +func TestFormatPanelsForUIHandler_RequiredParameters(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expectedError string + }{ + { + name: "missing name", + params: map[string]interface{}{ + "namespace": "default", + "panel_ids": "0_0", + }, + expectedError: "name parameter is required and must be a string", + }, + { + name: "missing namespace", + params: map[string]interface{}{ + "name": "test-dashboard", + "panel_ids": "0_0", + }, + expectedError: "namespace parameter is required and must be a string", + }, + { + name: "missing panel_ids", + params: map[string]interface{}{ + "name": "test-dashboard", + "namespace": "default", + }, + expectedError: "panel_ids parameter is required and must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := FormatPanelsForUIHandler(ObsMCPOptions{}) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "format_panels_for_ui", + Arguments: tt.params, + }, + } + + result, _ := handler(context.Background(), req) + errorMsg := getErrorMessage(t, result) + if errorMsg != tt.expectedError { + t.Errorf("expected error %q, got %q", tt.expectedError, errorMsg) + } + }) + } +} + +func TestGetDashboardPanelsHandler_OptionalPanelIDs(t *testing.T) { + // This test verifies that panel_ids is optional + handler := GetDashboardPanelsHandler(ObsMCPOptions{}) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "get_dashboard_panels", + Arguments: map[string]interface{}{ + "name": "test-dashboard", + "namespace": "default", + // panel_ids is optional - not provided + }, + }, + } + + // Should fail with dashboard not found, not parameter error + result, _ := handler(context.Background(), req) + errorMsg := getErrorMessage(t, result) + if errorMsg == "panel_ids parameter is required and must be a string" { + t.Errorf("panel_ids should be optional, but got required parameter error") + } + // Expected to fail with "failed to get dashboard" since we don't have a real cluster + if len(errorMsg) > 0 && len(errorMsg) >= 23 && errorMsg[:23] != "failed to get dashboard" { + t.Logf("Got expected error: %s", errorMsg) + } +} + +func TestGetDashboardHandler_RequiredParameters(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expectedError string + }{ + { + name: "missing name parameter", + params: map[string]interface{}{"namespace": "default"}, + expectedError: "name parameter is required and must be a string", + }, + { + name: "missing namespace parameter", + params: map[string]interface{}{"name": "test-dashboard"}, + expectedError: "namespace parameter is required and must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := GetDashboardHandler(ObsMCPOptions{}) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "get_dashboard", + Arguments: tt.params, + }, + } + + result, _ := handler(context.Background(), req) + errorMsg := getErrorMessage(t, result) + if errorMsg != tt.expectedError { + t.Errorf("expected error %q, got %q", tt.expectedError, errorMsg) + } + }) + } +} diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index ad654f3..ec0ce7f 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -193,11 +193,11 @@ Returns an array of DashboardWidget objects ready for direct rendering, with: Panel IDs (fetched using get_dashboard_panels) must be provided to specify which panels to format. `), - mcp.WithString("dashboard_name", + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the dashboard containing the panels"), ), - mcp.WithString("dashboard_namespace", + mcp.WithString("namespace", mcp.Required(), mcp.Description("Namespace of the dashboard"), ), diff --git a/pkg/perses/panels.go b/pkg/perses/panels.go index 816763d..a15f5e2 100644 --- a/pkg/perses/panels.go +++ b/pkg/perses/panels.go @@ -8,7 +8,6 @@ import ( // ExtractPanels extracts panel information from a dashboard spec. // If fullDetails is true, includes position, step, and duration for UI rendering. // If panelIDs is provided, only extracts those specific panels. -// TODO: Sometimes, the dashboard description may be present in a dedicated panel rather than the dashboard metadata. Consider extracting that as well. func ExtractPanels(dashboardName, dashboardNamespace string, spec map[string]any, fullDetails bool, panelIDs []string) ([]DashboardPanel, error) { var panels []DashboardPanel From f387c5048231ef59071d1fd343f6b6a4831eeb74 Mon Sep 17 00:00:00 2001 From: Pranshu Srivastava Date: Sun, 25 Jan 2026 15:27:36 +0530 Subject: [PATCH 5/8] tests,hack: add e2e tests for added tools --- go.mod | 2 +- hack/e2e/manifests/perses-crd.yaml | 265 +++++ .../manifests/perses-sample-dashboard.yaml | 1041 +++++++++++++++++ hack/e2e/setup-cluster.sh | 7 + tests/e2e/e2e_test.go | 289 +++++ 5 files changed, 1603 insertions(+), 1 deletion(-) create mode 100644 hack/e2e/manifests/perses-crd.yaml create mode 100644 hack/e2e/manifests/perses-sample-dashboard.yaml diff --git a/go.mod b/go.mod index ad7c053..c23bb9c 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.67.1 github.com/prometheus/prometheus v0.307.3 - gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 sigs.k8s.io/controller-runtime v0.22.4 @@ -88,6 +87,7 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect diff --git a/hack/e2e/manifests/perses-crd.yaml b/hack/e2e/manifests/perses-crd.yaml new file mode 100644 index 0000000..24a234d --- /dev/null +++ b/hack/e2e/manifests/perses-crd.yaml @@ -0,0 +1,265 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.0 + name: persesdashboards.perses.dev +spec: + group: perses.dev + names: + kind: PersesDashboard + listKind: PersesDashboardList + plural: persesdashboards + shortNames: + - perdb + singular: persesdashboard + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: PersesDashboard is the Schema for the persesdashboards API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + datasources: + additionalProperties: + properties: + default: + type: boolean + display: + properties: + description: + type: string + name: + type: string + type: object + plugin: + description: |- + Plugin will contain the datasource configuration. + The data typed is available in Cue. + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + required: + - default + - plugin + type: object + description: Datasources is an optional list of datasource definition. + type: object + display: + properties: + description: + type: string + name: + type: string + type: object + duration: + description: Duration is the default time range to use when getting + data to fill the dashboard + format: duration + pattern: ^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$ + type: string + layouts: + items: + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + type: array + panels: + additionalProperties: + properties: + kind: + type: string + spec: + properties: + display: + properties: + description: + type: string + name: + type: string + required: + - name + type: object + links: + items: + properties: + name: + type: string + renderVariables: + type: boolean + targetBlank: + type: boolean + tooltip: + type: string + url: + type: string + required: + - url + type: object + type: array + plugin: + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + queries: + items: + properties: + kind: + type: string + spec: + properties: + plugin: + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + required: + - plugin + type: object + required: + - kind + - spec + type: object + type: array + required: + - display + - plugin + type: object + required: + - kind + - spec + type: object + type: object + refreshInterval: + description: RefreshInterval is the default refresh interval to use + when landing on the dashboard + format: duration + pattern: ^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$ + type: string + variables: + items: + properties: + kind: + description: Kind is the type of the variable. Depending on + the value of Kind, it will change the content of Spec. + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + type: array + required: + - duration + - layouts + - panels + type: object + status: + description: PersesDashboardStatus defines the observed state of PersesDashboard + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/hack/e2e/manifests/perses-sample-dashboard.yaml b/hack/e2e/manifests/perses-sample-dashboard.yaml new file mode 100644 index 0000000..bf67744 --- /dev/null +++ b/hack/e2e/manifests/perses-sample-dashboard.yaml @@ -0,0 +1,1041 @@ +# Taken from https://github.com/openshift/monitoring-plugin/blob/ea66fe4cfeb0f41b62278bca55f6f856480752c3/web/cypress/fixtures/coo/coo121_perses_dashboards/openshift-cluster-sample-dashboard.yaml +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: sample-dashboard + namespace: monitoring +spec: + display: + name: Kubernetes / Compute Resources / Cluster + variables: + - kind: ListVariable + spec: + display: + hidden: false + allowAllValue: false + allowMultiple: false + sort: alphabetical-asc + plugin: + kind: PrometheusLabelValuesVariable + spec: + labelName: cluster + matchers: + - up{job="kubelet", metrics_path="/metrics/cadvisor"} + name: cluster + panels: + "0_0": + kind: Panel + spec: + display: + name: CPU Utilisation + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: cluster:node_cpu:ratio_rate5m{cluster="$cluster"} + "0_1": + kind: Panel + spec: + display: + name: CPU Requests Commitment + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_cpu:kube_pod_container_resource_requests:sum{cluster="$cluster"}) / sum(kube_node_status_allocatable{job="kube-state-metrics",resource="cpu",cluster="$cluster"}) + "0_2": + kind: Panel + spec: + display: + name: CPU Limits Commitment + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_cpu:kube_pod_container_resource_limits:sum{cluster="$cluster"}) / sum(kube_node_status_allocatable{job="kube-state-metrics",resource="cpu",cluster="$cluster"}) + "0_3": + kind: Panel + spec: + display: + name: Memory Utilisation + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: 1 - sum(:node_memory_MemAvailable_bytes:sum{cluster="$cluster"}) / sum(node_memory_MemTotal_bytes{job="node-exporter",cluster="$cluster"}) + "0_4": + kind: Panel + spec: + display: + name: Memory Requests Commitment + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_memory:kube_pod_container_resource_requests:sum{cluster="$cluster"}) / sum(kube_node_status_allocatable{job="kube-state-metrics",resource="memory",cluster="$cluster"}) + "0_5": + kind: Panel + spec: + display: + name: Memory Limits Commitment + plugin: + kind: StatChart + spec: + calculation: mean + format: + unit: percent-decimal + thresholds: + steps: + - color: green + value: 0 + - color: red + value: 80 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_memory:kube_pod_container_resource_limits:sum{cluster="$cluster"}) / sum(kube_node_status_allocatable{job="kube-state-metrics",resource="memory",cluster="$cluster"}) + "1_0": + kind: Panel + spec: + display: + name: CPU Usage + plugin: + kind: TimeSeriesChart + spec: + legend: + mode: list + position: bottom + values: [] + visual: + areaOpacity: 1 + connectNulls: false + display: line + lineWidth: 0.25 + stack: all + yAxis: + format: + unit: decimal + min: 0 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{cluster="$cluster"}) by (namespace) + seriesNameFormat: "{{namespace}}" + "2_0": + kind: Panel + spec: + display: + name: CPU Quota + plugin: + kind: Table + spec: + columnSettings: + - header: Time + hide: true + name: Time + - header: Pods + name: "Value #A" + - header: Workloads + name: "Value #B" + - header: CPU Usage + name: "Value #C" + - header: CPU Requests + name: "Value #D" + - header: CPU Requests % + name: "Value #E" + - header: CPU Limits + name: "Value #F" + - header: CPU Limits % + name: "Value #G" + - header: Namespace + name: namespace + - header: "" + name: /.*/ + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(kube_pod_owner{job="kube-state-metrics", cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: count(avg(namespace_workload_pod:kube_pod_owner:relabel{cluster="$cluster"}) by (workload, namespace)) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_cpu:kube_pod_container_resource_requests:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{cluster="$cluster"}) by (namespace) / sum(namespace_cpu:kube_pod_container_resource_requests:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_cpu:kube_pod_container_resource_limits:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{cluster="$cluster"}) by (namespace) / sum(namespace_cpu:kube_pod_container_resource_limits:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + "3_0": + kind: Panel + spec: + display: + name: Memory Usage (w/o cache) + plugin: + kind: TimeSeriesChart + spec: + legend: + mode: list + position: bottom + values: [] + visual: + areaOpacity: 1 + connectNulls: false + display: line + lineWidth: 0.25 + stack: all + yAxis: + format: + unit: bytes + min: 0 + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", container!=""}) by (namespace) + seriesNameFormat: "{{namespace}}" + "4_0": + kind: Panel + spec: + display: + name: Requests by Namespace + plugin: + kind: Table + spec: + columnSettings: + - header: Time + hide: true + name: Time + - header: Pods + name: "Value #A" + - header: Workloads + name: "Value #B" + - header: Memory Usage + name: "Value #C" + - header: Memory Requests + name: "Value #D" + - header: Memory Requests % + name: "Value #E" + - header: Memory Limits + name: "Value #F" + - header: Memory Limits % + name: "Value #G" + - header: Namespace + name: namespace + - header: "" + name: /.*/ + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(kube_pod_owner{job="kube-state-metrics", cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: count(avg(namespace_workload_pod:kube_pod_owner:relabel{cluster="$cluster"}) by (workload, namespace)) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", container!=""}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_memory:kube_pod_container_resource_requests:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", container!=""}) by (namespace) / sum(namespace_memory:kube_pod_container_resource_requests:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(namespace_memory:kube_pod_container_resource_limits:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", container!=""}) by (namespace) / sum(namespace_memory:kube_pod_container_resource_limits:sum{cluster="$cluster"}) by (namespace) + seriesNameFormat: "" + "5_0": + kind: Panel + spec: + display: + name: Current Network Usage + plugin: + kind: Table + spec: + columnSettings: + - header: Time + hide: true + name: Time + - header: Current Receive Bandwidth + name: "Value #A" + - header: Current Transmit Bandwidth + name: "Value #B" + - header: Rate of Received Packets + name: "Value #C" + - header: Rate of Transmitted Packets + name: "Value #D" + - header: Rate of Received Packets Dropped + name: "Value #E" + - header: Rate of Transmitted Packets Dropped + name: "Value #F" + - header: Namespace + name: namespace + - header: "" + name: /.*/ + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_packets_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_packets_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_packets_dropped_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_packets_dropped_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "" + "6_0": + kind: Panel + spec: + display: + name: Receive Bandwidth + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "6_1": + kind: Panel + spec: + display: + name: Transmit Bandwidth + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "7_0": + kind: Panel + spec: + display: + name: "Average Container Bandwidth by Namespace: Received" + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: avg(irate(container_network_receive_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "7_1": + kind: Panel + spec: + display: + name: "Average Container Bandwidth by Namespace: Transmitted" + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: avg(irate(container_network_transmit_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "8_0": + kind: Panel + spec: + display: + name: Rate of Received Packets + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_packets_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "8_1": + kind: Panel + spec: + display: + name: Rate of Transmitted Packets + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_packets_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "9_0": + kind: Panel + spec: + display: + name: Rate of Received Packets Dropped + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_receive_packets_dropped_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "9_1": + kind: Panel + spec: + display: + name: Rate of Transmitted Packets Dropped + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum(irate(container_network_transmit_packets_dropped_total{job="kubelet", metrics_path="/metrics/cadvisor", cluster="$cluster", namespace=~".+"}[$__rate_interval])) by (namespace) + seriesNameFormat: "{{namespace}}" + "10_0": + kind: Panel + spec: + display: + name: IOPS(Reads+Writes) + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: ceil(sum by(namespace) (rate(container_fs_reads_total{job="kubelet", metrics_path="/metrics/cadvisor", id!="", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", cluster="$cluster", namespace!=""}[$__rate_interval]) + rate(container_fs_writes_total{job="kubelet", metrics_path="/metrics/cadvisor", id!="", cluster="$cluster", namespace!=""}[$__rate_interval]))) + seriesNameFormat: "{{namespace}}" + "10_1": + kind: Panel + spec: + display: + name: ThroughPut(Read+Write) + plugin: + kind: TimeSeriesChart + spec: {} + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", id!="", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", cluster="$cluster", namespace!=""}[$__rate_interval]) + rate(container_fs_writes_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "{{namespace}}" + "11_0": + kind: Panel + spec: + display: + name: Current Storage IO + plugin: + kind: Table + spec: + columnSettings: + - header: Time + hide: true + name: Time + - header: IOPS(Reads) + name: "Value #A" + - header: IOPS(Writes) + name: "Value #B" + - header: IOPS(Reads + Writes) + name: "Value #C" + - header: Throughput(Read) + name: "Value #D" + - header: Throughput(Write) + name: "Value #E" + - header: Throughput(Read + Write) + name: "Value #F" + - header: Namespace + name: namespace + - header: "" + name: /.*/ + queries: + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_writes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval]) + rate(container_fs_writes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_writes_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + - kind: TimeSeriesQuery + spec: + plugin: + kind: PrometheusTimeSeriesQuery + spec: + datasource: + kind: PrometheusDatasource + + query: sum by(namespace) (rate(container_fs_reads_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval]) + rate(container_fs_writes_bytes_total{job="kubelet", metrics_path="/metrics/cadvisor", device=~"(/dev.+)|mmcblk.p.+|nvme.+|rbd.+|sd.+|vd.+|xvd.+|dm-.+|dasd.+", id!="", cluster="$cluster", namespace!=""}[$__rate_interval])) + seriesNameFormat: "" + layouts: + - kind: Grid + spec: + display: + title: Headlines + collapse: + open: true + items: + - x: 0 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_0" + - x: 4 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_1" + - x: 8 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_2" + - x: 12 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_3" + - x: 16 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_4" + - x: 20 + "y": 1 + width: 4 + height: 3 + content: + $ref: "#/spec/panels/0_5" + - kind: Grid + spec: + display: + title: CPU + collapse: + open: true + items: + - x: 0 + "y": 5 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/1_0" + - kind: Grid + spec: + display: + title: CPU Quota + collapse: + open: true + items: + - x: 0 + "y": 13 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/2_0" + - kind: Grid + spec: + display: + title: Memory + collapse: + open: true + items: + - x: 0 + "y": 21 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/3_0" + - kind: Grid + spec: + display: + title: Memory Requests + collapse: + open: true + items: + - x: 0 + "y": 29 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/4_0" + - kind: Grid + spec: + display: + title: Current Network Usage + collapse: + open: true + items: + - x: 0 + "y": 37 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/5_0" + - kind: Grid + spec: + display: + title: Bandwidth + collapse: + open: true + items: + - x: 0 + "y": 45 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/6_0" + - x: 12 + "y": 45 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/6_1" + - kind: Grid + spec: + display: + title: Average Container Bandwidth by Namespace + collapse: + open: true + items: + - x: 0 + "y": 53 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/7_0" + - x: 12 + "y": 53 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/7_1" + - kind: Grid + spec: + display: + title: Rate of Packets + collapse: + open: true + items: + - x: 0 + "y": 61 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/8_0" + - x: 12 + "y": 61 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/8_1" + - kind: Grid + spec: + display: + title: Rate of Packets Dropped + collapse: + open: true + items: + - x: 0 + "y": 69 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/9_0" + - x: 12 + "y": 69 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/9_1" + - kind: Grid + spec: + display: + title: Storage IO + collapse: + open: true + items: + - x: 0 + "y": 77 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/10_0" + - x: 12 + "y": 77 + width: 12 + height: 7 + content: + $ref: "#/spec/panels/10_1" + - kind: Grid + spec: + display: + title: Storage IO - Distribution + collapse: + open: true + items: + - x: 0 + "y": 85 + width: 24 + height: 7 + content: + $ref: "#/spec/panels/11_0" + duration: 1h diff --git a/hack/e2e/setup-cluster.sh b/hack/e2e/setup-cluster.sh index 28413e2..7b02f8e 100755 --- a/hack/e2e/setup-cluster.sh +++ b/hack/e2e/setup-cluster.sh @@ -53,6 +53,13 @@ kubectl -n monitoring rollout status statefulset/prometheus-k8s --timeout=5m echo "==> Waiting for Alertmanager to be ready..." kubectl -n monitoring rollout status statefulset/alertmanager-main --timeout=5m +echo "==> Installing Perses CRD..." +kubectl apply -f "${ROOT_DIR}/hack/e2e/manifests/perses-crd.yaml" +kubectl wait --for condition=Established crd/persesdashboards.perses.dev --timeout=2m + +echo "==> Installing Perses sample dashboard..." +kubectl apply -f "${ROOT_DIR}/hack/e2e/manifests/perses-sample-dashboard.yaml" + echo "==> Cluster setup complete!" echo " Run 'make test-e2e-deploy' to build and deploy obs-mcp" echo " Run 'make test-e2e' to run E2E tests" diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index dd7af5b..c1fd7c4 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -148,6 +148,118 @@ func sendMCPRequest(t *testing.T, req MCPRequest) (*MCPResponse, error) { return &mcpResp, nil } +func getFirstDashboard(t *testing.T) (name, namespace string) { + t.Helper() + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "tools/call", + Params: map[string]any{ + "name": "list_dashboards", + "arguments": map[string]any{}, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call list_dashboards: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error getting dashboards: %s", resp.Error.Message) + } + + content, ok := resp.Result["content"].([]any) + if !ok || len(content) == 0 { + t.Fatalf("No dashboards available") + } + + firstContent, ok := content[0].(map[string]any) + if !ok { + t.Fatalf("Unexpected content structure") + } + + text, ok := firstContent["text"].(string) + if !ok { + t.Fatalf("No text field in content") + } + + var dashboardData struct { + Dashboards []struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"dashboards"` + } + + if err := json.Unmarshal([]byte(text), &dashboardData); err != nil { + t.Fatalf("Failed to parse dashboard data: %v", err) + } + + if len(dashboardData.Dashboards) == 0 { + t.Fatalf("No dashboards found") + } + + return dashboardData.Dashboards[0].Name, dashboardData.Dashboards[0].Namespace +} + +func getDashboardPanelIDs(t *testing.T, dashboardName, dashboardNamespace string) []string { + t.Helper() + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 2, + Method: "tools/call", + Params: map[string]any{ + "name": "get_dashboard_panels", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call get_dashboard_panels: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + content, ok := resp.Result["content"].([]any) + if !ok || len(content) == 0 { + t.Fatalf("No panel content available") + } + + text, ok := content[0].(map[string]any)["text"].(string) + if !ok { + t.Fatalf("No text field in panel content") + } + + var panelsData struct { + Panels []struct { + ID string `json:"id"` + } `json:"panels"` + } + + if err := json.Unmarshal([]byte(text), &panelsData); err != nil { + t.Fatalf("Failed to parse panels data: %v", err) + } + + if len(panelsData.Panels) == 0 { + t.Fatalf("No panels found in dashboard") + } + + panelIDs := make([]string, len(panelsData.Panels)) + for i, panel := range panelsData.Panels { + panelIDs[i] = panel.ID + } + + return panelIDs +} + func TestHealthEndpoint(t *testing.T) { resp, err := http.Get(obsMCPURL + "/health") if err != nil { @@ -374,3 +486,180 @@ func TestGuardrailsBlockDangerousQuery(t *testing.T) { t.Error("Expected guardrails to block the dangerous query") } } + +func TestListDashboards(t *testing.T) { + req := MCPRequest{ + JSONRPC: "2.0", + ID: 8, + Method: "tools/call", + Params: map[string]any{ + "name": "list_dashboards", + "arguments": map[string]any{}, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call list_dashboards: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + if resp.Result == nil { + t.Fatal("Expected result, got nil") + } + + // Verify the result structure contains dashboards + resultJSON, _ := json.Marshal(resp.Result) + resultStr := string(resultJSON) + + if !strings.Contains(resultStr, "dashboards") { + t.Error("Expected 'dashboards' field in result") + } + + t.Logf("list_dashboards returned successfully with valid structure") +} + +func TestGetDashboardPanels(t *testing.T) { + t.Run("WithListDashboards", func(t *testing.T) { + dashboardName, dashboardNamespace := getFirstDashboard(t) + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 12, + Method: "tools/call", + Params: map[string]any{ + "name": "get_dashboard_panels", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call get_dashboard_panels: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + if resp.Result == nil { + t.Fatal("Expected result, got nil") + } + + t.Logf("get_dashboard_panels returned successfully for %s/%s", dashboardNamespace, dashboardName) + }) + + t.Run("WithPanelIDsFilter", func(t *testing.T) { + dashboardName, dashboardNamespace := getFirstDashboard(t) + // Get panel IDs without filter first + panelIDs := getDashboardPanelIDs(t, dashboardName, dashboardNamespace) + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 14, + Method: "tools/call", + Params: map[string]any{ + "name": "get_dashboard_panels", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + "panel_ids": fmt.Sprintf("%s,%s", panelIDs[0], panelIDs[1]), + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call get_dashboard_panels: %v", err) + } + + if resp.Error != nil { + t.Errorf("Unexpected error: %s", resp.Error.Message) + } + + t.Log("get_dashboard_panels with panel_ids filter handled correctly") + }) +} + +func TestFormatPanelsForUI(t *testing.T) { + t.Run("SinglePanel", func(t *testing.T) { + dashboardName, dashboardNamespace := getFirstDashboard(t) + panelIDs := getDashboardPanelIDs(t, dashboardName, dashboardNamespace) + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 18, + Method: "tools/call", + Params: map[string]any{ + "name": "format_panels_for_ui", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + "panel_ids": panelIDs[0], + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call format_panels_for_ui: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + if resp.Result == nil { + t.Fatal("Expected result, got nil") + } + + resultJSON, _ := json.Marshal(resp.Result) + resultStr := string(resultJSON) + + if !strings.Contains(resultStr, "widgets") { + t.Error("Expected 'widgets' field in result") + } + + t.Log("format_panels_for_ui returned successfully") + }) + + t.Run("MultiplePanels", func(t *testing.T) { + dashboardName, dashboardNamespace := getFirstDashboard(t) + panelIDs := getDashboardPanelIDs(t, dashboardName, dashboardNamespace) + + if len(panelIDs) < 2 { + t.Skip("Need at least 2 panels for this test") + } + + req := MCPRequest{ + JSONRPC: "2.0", + ID: 21, + Method: "tools/call", + Params: map[string]any{ + "name": "format_panels_for_ui", + "arguments": map[string]any{ + "name": dashboardName, + "namespace": dashboardNamespace, + "panel_ids": fmt.Sprintf("%s,%s", panelIDs[0], panelIDs[1]), + }, + }, + } + + resp, err := sendMCPRequest(t, req) + if err != nil { + t.Fatalf("Failed to call format_panels_for_ui: %v", err) + } + + if resp.Error != nil { + t.Fatalf("MCP error: %s", resp.Error.Message) + } + + t.Log("format_panels_for_ui with multiple panel IDs handled correctly") + }) +} From dd77b612bd01b54ba1741842363fbb3a5767247e Mon Sep 17 00:00:00 2001 From: Pranshu Srivastava Date: Sun, 25 Jan 2026 16:13:30 +0530 Subject: [PATCH 6/8] pkg: resolve lint issues --- pkg/k8s/perses.go | 24 ++++++++++++--------- pkg/mcp/handlers.go | 45 ++++++++++++++++++++-------------------- pkg/mcp/handlers_test.go | 27 ++++++++++++------------ pkg/mcp/server.go | 1 + pkg/mcp/tools.go | 15 +++++++------- pkg/perses/dashboard.go | 2 +- pkg/perses/panels.go | 10 ++++----- 7 files changed, 64 insertions(+), 60 deletions(-) diff --git a/pkg/k8s/perses.go b/pkg/k8s/perses.go index a0a9425..9e60bc8 100644 --- a/pkg/k8s/perses.go +++ b/pkg/k8s/perses.go @@ -39,7 +39,7 @@ func GetPersesClient() (client.Client, error) { // ListDashboards lists all Dashboard objects across all namespaces or in a specific namespace. // Uses types from github.com/perses/perses-operator/api/v1alpha1 // The labelSelector parameter accepts Kubernetes label selector syntax (e.g., "app=myapp,env=prod"). -func ListDashboards(ctx context.Context, namespace, labelSelector string) ([]persesv1alpha1.PersesDashboard, error) { +func ListDashboards(ctx context.Context, namespace, labelSelector string) (dashboardPointers []*persesv1alpha1.PersesDashboard, err error) { c, err := GetPersesClient() if err != nil { return nil, fmt.Errorf("failed to get perses client: %w", err) @@ -63,40 +63,44 @@ func ListDashboards(ctx context.Context, namespace, labelSelector string) ([]per return nil, fmt.Errorf("failed to list Dashboards: %w", err) } - return dashboardList.Items, nil + for i := range dashboardList.Items { + dashboardPointers = append(dashboardPointers, &dashboardList.Items[i]) + } + + return } // GetDashboard retrieves a specific Dashboard by name and namespace. // Returns the dashboard name, namespace, and full spec as a map for JSON serialization. -func GetDashboard(ctx context.Context, namespace, name string) (string, string, map[string]interface{}, error) { +func GetDashboard(ctx context.Context, namespace, name string) (specMap map[string]any, err error) { c, err := GetPersesClient() if err != nil { - return "", "", nil, fmt.Errorf("failed to get perses client: %w", err) + return nil, fmt.Errorf("failed to get perses client: %w", err) } var dashboard persesv1alpha1.PersesDashboard key := client.ObjectKey{Namespace: namespace, Name: name} if err := c.Get(ctx, key, &dashboard); err != nil { - return "", "", nil, fmt.Errorf("failed to get Dashboard %s/%s: %w", namespace, name, err) + return nil, fmt.Errorf("failed to get Dashboard %s/%s: %w", namespace, name, err) } // Convert spec to map[string]interface{} for JSON serialization - specMap, err := specToMap(dashboard.Spec) + specMap, err = specToMap(&dashboard.Spec) if err != nil { - return "", "", nil, fmt.Errorf("failed to convert spec to map: %w", err) + return nil, fmt.Errorf("failed to convert spec to map: %w", err) } - return dashboard.Name, dashboard.Namespace, specMap, nil + return } // specToMap converts a DashboardSpec to a map[string]interface{} for JSON serialization -func specToMap(spec persesv1alpha1.Dashboard) (map[string]interface{}, error) { +func specToMap(spec *persesv1alpha1.Dashboard) (map[string]any, error) { data, err := json.Marshal(spec) if err != nil { return nil, err } - var result map[string]interface{} + var result map[string]any if err := json.Unmarshal(data, &result); err != nil { return nil, err } diff --git a/pkg/mcp/handlers.go b/pkg/mcp/handlers.go index 79805ed..4697cd5 100644 --- a/pkg/mcp/handlers.go +++ b/pkg/mcp/handlers.go @@ -9,6 +9,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/prometheus/common/model" + "github.com/rhobs/obs-mcp/pkg/k8s" "github.com/rhobs/obs-mcp/pkg/perses" "github.com/rhobs/obs-mcp/pkg/prometheus" @@ -233,17 +234,17 @@ func GetDashboardHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToolRequ return errorResult("namespace parameter is required and must be a string") } - dashboardName, dashboardNamespace, spec, err := k8s.GetDashboard(ctx, namespace, name) + spec, err := k8s.GetDashboard(ctx, namespace, name) if err != nil { return errorResult(fmt.Sprintf("failed to get Dashboard: %s", err.Error())) } - slog.Info("GetDashboardHandler executed successfully", "name", dashboardName, "namespace", dashboardNamespace) + slog.Info("GetDashboardHandler executed successfully", "name", name, "namespace", namespace) slog.Debug("GetDashboardHandler spec", "spec", spec) output := GetDashboardOutput{ - Name: dashboardName, - Namespace: dashboardNamespace, + Name: name, + Namespace: namespace, Spec: spec, } @@ -283,16 +284,13 @@ func GetDashboardPanelsHandler(_ ObsMCPOptions) func(context.Context, mcp.CallTo } } - dashboardName, dashboardNamespace, spec, err := k8s.GetDashboard(ctx, namespace, name) + spec, err := k8s.GetDashboard(ctx, namespace, name) if err != nil { return errorResult(fmt.Sprintf("failed to get dashboard: %s", err.Error())) } // Extract panel metadata (with optional filtering) - panels, err := perses.ExtractPanels(dashboardName, dashboardNamespace, spec, false, panelIDs) - if err != nil { - return errorResult(fmt.Sprintf("failed to extract panels: %s", err.Error())) - } + panels := perses.ExtractPanels(name, namespace, spec, false, panelIDs) duration := "1h" if d, ok := spec["duration"].(string); ok { @@ -300,14 +298,14 @@ func GetDashboardPanelsHandler(_ ObsMCPOptions) func(context.Context, mcp.CallTo } slog.Info("GetDashboardPanelsHandler executed successfully", - "name", dashboardName, - "namespace", dashboardNamespace, + "name", name, + "namespace", namespace, "requested", len(panelIDs), "returned", len(panels)) output := GetDashboardPanelsOutput{ - Name: dashboardName, - Namespace: dashboardNamespace, + Name: name, + Namespace: namespace, Duration: duration, Panels: panels, } @@ -352,16 +350,13 @@ func FormatPanelsForUIHandler(_ ObsMCPOptions) func(context.Context, mcp.CallToo } } - _, _, spec, err := k8s.GetDashboard(ctx, namespace, name) + spec, err := k8s.GetDashboard(ctx, namespace, name) if err != nil { return errorResult(fmt.Sprintf("failed to get Dashboard: %s", err.Error())) } // Extract full panel details for UI - panels, err := perses.ExtractPanels(name, namespace, spec, true, panelIDs) - if err != nil { - return errorResult(fmt.Sprintf("failed to extract panels: %s", err.Error())) - } + panels := perses.ExtractPanels(name, namespace, spec, true, panelIDs) // Convert panels to DashboardWidget format widgets := convertPanelsToDashboardWidgets(panels) @@ -426,7 +421,7 @@ func isWhitespace(b byte) bool { } // convertPanelsToDashboardWidgets converts DashboardPanel objects to DashboardWidget format expected by UI. -func convertPanelsToDashboardWidgets(panels []perses.DashboardPanel) []perses.DashboardWidget { +func convertPanelsToDashboardWidgets(panels []*perses.DashboardPanel) []perses.DashboardWidget { widgets := make([]perses.DashboardWidget, 0, len(panels)) for _, panel := range panels { @@ -487,12 +482,16 @@ func mapChartTypeToComponent(chartType string) string { // inferBreakpointFromWidth maps panel width to responsive breakpoint. // Perses uses a 24-column grid, so we infer breakpoints based on width. func inferBreakpointFromWidth(width int) string { - if width >= 18 { + switch { + case width >= 18: return "xl" - } else if width >= 12 { + case width >= 12: return "lg" - } else if width >= 6 { + case width >= 6: return "md" + case width >= 1: + return "sm" + default: // 0 + return "lg" } - return "sm" } diff --git a/pkg/mcp/handlers_test.go b/pkg/mcp/handlers_test.go index 266aca7..247c547 100644 --- a/pkg/mcp/handlers_test.go +++ b/pkg/mcp/handlers_test.go @@ -3,6 +3,7 @@ package mcp import ( "context" "fmt" + "strings" "testing" "time" @@ -106,7 +107,7 @@ func TestExecuteRangeQueryHandler_ExplicitTimeRange_RFC3339(t *testing.T) { func TestExecuteRangeQueryHandler_StepParsing_ValidSteps(t *testing.T) { mockClient := &MockedLoader{ ExecuteRangeQueryFunc: func(_ context.Context, _ string, _, _ time.Time, _ time.Duration) (map[string]any, error) { - return map[string]interface{}{"resultType": "matrix", "result": []interface{}{}}, nil + return map[string]any{"resultType": "matrix", "result": []any{}}, nil }, } @@ -361,17 +362,17 @@ func getErrorMessage(t *testing.T, result *mcp.CallToolResult) string { func TestGetDashboardPanelsHandler_RequiredParameters(t *testing.T) { tests := []struct { name string - params map[string]interface{} + params map[string]any expectedError string }{ { name: "missing name parameter", - params: map[string]interface{}{"namespace": "default"}, + params: map[string]any{"namespace": "default"}, expectedError: "name parameter is required and must be a string", }, { name: "missing namespace parameter", - params: map[string]interface{}{"name": "test-dashboard"}, + params: map[string]any{"name": "test-dashboard"}, expectedError: "namespace parameter is required and must be a string", }, } @@ -398,12 +399,12 @@ func TestGetDashboardPanelsHandler_RequiredParameters(t *testing.T) { func TestFormatPanelsForUIHandler_RequiredParameters(t *testing.T) { tests := []struct { name string - params map[string]interface{} + params map[string]any expectedError string }{ { name: "missing name", - params: map[string]interface{}{ + params: map[string]any{ "namespace": "default", "panel_ids": "0_0", }, @@ -411,7 +412,7 @@ func TestFormatPanelsForUIHandler_RequiredParameters(t *testing.T) { }, { name: "missing namespace", - params: map[string]interface{}{ + params: map[string]any{ "name": "test-dashboard", "panel_ids": "0_0", }, @@ -419,7 +420,7 @@ func TestFormatPanelsForUIHandler_RequiredParameters(t *testing.T) { }, { name: "missing panel_ids", - params: map[string]interface{}{ + params: map[string]any{ "name": "test-dashboard", "namespace": "default", }, @@ -452,7 +453,7 @@ func TestGetDashboardPanelsHandler_OptionalPanelIDs(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "get_dashboard_panels", - Arguments: map[string]interface{}{ + Arguments: map[string]any{ "name": "test-dashboard", "namespace": "default", // panel_ids is optional - not provided @@ -467,7 +468,7 @@ func TestGetDashboardPanelsHandler_OptionalPanelIDs(t *testing.T) { t.Errorf("panel_ids should be optional, but got required parameter error") } // Expected to fail with "failed to get dashboard" since we don't have a real cluster - if len(errorMsg) > 0 && len(errorMsg) >= 23 && errorMsg[:23] != "failed to get dashboard" { + if errorMsg != "" && strings.HasPrefix(errorMsg, "failed to get Dashboard") { t.Logf("Got expected error: %s", errorMsg) } } @@ -475,17 +476,17 @@ func TestGetDashboardPanelsHandler_OptionalPanelIDs(t *testing.T) { func TestGetDashboardHandler_RequiredParameters(t *testing.T) { tests := []struct { name string - params map[string]interface{} + params map[string]any expectedError string }{ { name: "missing name parameter", - params: map[string]interface{}{"namespace": "default"}, + params: map[string]any{"namespace": "default"}, expectedError: "name parameter is required and must be a string", }, { name: "missing namespace parameter", - params: map[string]interface{}{"name": "test-dashboard"}, + params: map[string]any{"name": "test-dashboard"}, expectedError: "namespace parameter is required and must be a string", }, } diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index f54d5ab..a6fbeec 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -12,6 +12,7 @@ import ( "time" "github.com/mark3labs/mcp-go/server" + "github.com/rhobs/obs-mcp/pkg/prometheus" ) diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index ec0ce7f..98cda80 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -2,6 +2,7 @@ package mcp import ( "github.com/mark3labs/mcp-go/mcp" + "github.com/rhobs/obs-mcp/pkg/perses" ) @@ -77,9 +78,9 @@ type DashboardsOutput struct { // GetDashboardOutput defines the output schema for the get_dashboard tool. type GetDashboardOutput struct { - Name string `json:"name" jsonschema:"description=Name of the Dashboard"` - Namespace string `json:"namespace" jsonschema:"description=Namespace where the Dashboard is located"` - Spec map[string]interface{} `json:"spec" jsonschema:"description=The full dashboard specification including panels, layouts, variables, and datasources"` + Name string `json:"name" jsonschema:"description=Name of the Dashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace where the Dashboard is located"` + Spec map[string]any `json:"spec" jsonschema:"description=The full dashboard specification including panels, layouts, variables, and datasources"` } // CreateListDashboardsTool creates the list_dashboards tool definition. @@ -134,10 +135,10 @@ For most use cases, you will want to follow up with get_dashboard_panels to extr // GetDashboardPanelsOutput defines the output schema for the get_dashboard_panels tool. type GetDashboardPanelsOutput struct { - Name string `json:"name" jsonschema:"description=Name of the dashboard"` - Namespace string `json:"namespace" jsonschema:"description=Namespace of the dashboard"` - Duration string `json:"duration,omitempty" jsonschema:"description=Default time duration for queries extracted from dashboard spec (e.g. 1h, 24h)"` - Panels []perses.DashboardPanel `json:"panels" jsonschema:"description=List of panel metadata including IDs, titles, queries, and chart types for LLM selection"` + Name string `json:"name" jsonschema:"description=Name of the dashboard"` + Namespace string `json:"namespace" jsonschema:"description=Namespace of the dashboard"` + Duration string `json:"duration,omitempty" jsonschema:"description=Default time duration for queries extracted from dashboard spec (e.g. 1h, 24h)"` + Panels []*perses.DashboardPanel `json:"panels" jsonschema:"description=List of panel metadata including IDs, titles, queries, and chart types for LLM selection"` } // CreateGetDashboardPanelsTool creates the get_dashboard_panels tool definition. diff --git a/pkg/perses/dashboard.go b/pkg/perses/dashboard.go index d63a1ea..c07011d 100644 --- a/pkg/perses/dashboard.go +++ b/pkg/perses/dashboard.go @@ -40,7 +40,7 @@ type PanelPosition struct { type DashboardWidget struct { ID string `json:"id" jsonschema:"description=Unique identifier for the widget"` ComponentType string `json:"componentType" jsonschema:"description=Type of Perses component to render (PersesTimeSeries, PersesPieChart, PersesTable)"` - Position PanelPosition `json:"position,omitempty" jsonschema:"description=Layout position in 24-column grid (optional, included when available from dashboard layout)"` + Position PanelPosition `json:"position" jsonschema:"description=Layout position in 24-column grid (optional, included when available from dashboard layout)"` Props DashboardWidgetProps `json:"props" jsonschema:"description=Properties passed to the Perses component"` Breakpoint string `json:"breakpoint" jsonschema:"description=Responsive grid breakpoint (xl/lg/md/sm) inferred from panel width, defaults to lg if position unavailable"` } diff --git a/pkg/perses/panels.go b/pkg/perses/panels.go index a15f5e2..bf2a52a 100644 --- a/pkg/perses/panels.go +++ b/pkg/perses/panels.go @@ -8,13 +8,11 @@ import ( // ExtractPanels extracts panel information from a dashboard spec. // If fullDetails is true, includes position, step, and duration for UI rendering. // If panelIDs is provided, only extracts those specific panels. -func ExtractPanels(dashboardName, dashboardNamespace string, spec map[string]any, fullDetails bool, panelIDs []string) ([]DashboardPanel, error) { - var panels []DashboardPanel - +func ExtractPanels(dashboardName, dashboardNamespace string, spec map[string]any, fullDetails bool, panelIDs []string) (panels []*DashboardPanel) { panelsMap, ok := spec["panels"].(map[string]any) if !ok { slog.Debug("No panels found in dashboard spec", "dashboard", dashboardName) - return panels, nil + return panels } // Build a lookup set for requested panel IDs @@ -63,7 +61,7 @@ func ExtractPanels(dashboardName, dashboardNamespace string, spec map[string]any continue } - panel := DashboardPanel{ + panel := &DashboardPanel{ ID: panelID, Title: title, Description: description, @@ -90,7 +88,7 @@ func ExtractPanels(dashboardName, dashboardNamespace string, spec map[string]any "fullDetails", fullDetails, "panelCount", len(panels)) - return panels, nil + return panels } // panelQuery represents a query extracted from a panel From 762b4d917131dfc790ebf06b3b5bb9260b2333e1 Mon Sep 17 00:00:00 2001 From: Pranshu Srivastava Date: Sun, 25 Jan 2026 16:41:33 +0530 Subject: [PATCH 7/8] hack: resolve Perses CRD issues --- hack/e2e/manifests/perses-crd.yaml | 16 ++++++---------- hack/e2e/setup-cluster.sh | 14 +++++++------- manifests/kubernetes/01_service_account.yaml | 3 +++ tests/e2e/e2e_test.go | 8 -------- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/hack/e2e/manifests/perses-crd.yaml b/hack/e2e/manifests/perses-crd.yaml index 24a234d..4b136d5 100644 --- a/hack/e2e/manifests/perses-crd.yaml +++ b/hack/e2e/manifests/perses-crd.yaml @@ -1,9 +1,9 @@ ---- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.0 + cert-manager.io/inject-ca-from: perses-operator-system/perses-operator-serving-cert + controller-gen.kubebuilder.io/version: v0.19.0 name: persesdashboards.perses.dev spec: group: perses.dev @@ -79,8 +79,7 @@ spec: type: string type: object duration: - description: Duration is the default time range to use when getting - data to fill the dashboard + description: Duration is the default time range to use when getting data to fill the dashboard format: duration pattern: ^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$ type: string @@ -174,8 +173,7 @@ spec: type: object type: object refreshInterval: - description: RefreshInterval is the default refresh interval to use - when landing on the dashboard + description: RefreshInterval is the default refresh interval to use when landing on the dashboard format: duration pattern: ^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$ type: string @@ -183,8 +181,7 @@ spec: items: properties: kind: - description: Kind is the type of the variable. Depending on - the value of Kind, it will change the content of Spec. + description: Kind is the type of the variable. Depending on the value of Kind, it will change the content of Spec. type: string spec: x-kubernetes-preserve-unknown-fields: true @@ -203,8 +200,7 @@ spec: properties: conditions: items: - description: Condition contains details for one aspect of the current - state of this API Resource. + description: Condition contains details for one aspect of the current state of this API Resource. properties: lastTransitionTime: description: |- diff --git a/hack/e2e/setup-cluster.sh b/hack/e2e/setup-cluster.sh index 7b02f8e..41e6a1c 100755 --- a/hack/e2e/setup-cluster.sh +++ b/hack/e2e/setup-cluster.sh @@ -26,6 +26,10 @@ fi # Apply CRDs and namespace setup first kubectl apply --server-side -f "${KUBE_PROMETHEUS_DIR}/manifests/setup" + +echo "==> Installing Perses CRD..." +kubectl apply -f "${ROOT_DIR}/hack/e2e/manifests/perses-crd.yaml" + echo "==> Waiting for CRDs to be established..." kubectl wait --for condition=Established --all CustomResourceDefinition --namespace=monitoring --timeout=5m @@ -44,6 +48,9 @@ for f in "${KUBE_PROMETHEUS_DIR}"/manifests/alertmanager-*.yaml; do kubectl apply -f "$f" done +echo "==> Installing Perses sample dashboard..." +kubectl apply -f "${ROOT_DIR}/hack/e2e/manifests/perses-sample-dashboard.yaml" + echo "==> Waiting for Prometheus Operator to be ready..." kubectl -n monitoring rollout status deployment/prometheus-operator --timeout=5m @@ -53,13 +60,6 @@ kubectl -n monitoring rollout status statefulset/prometheus-k8s --timeout=5m echo "==> Waiting for Alertmanager to be ready..." kubectl -n monitoring rollout status statefulset/alertmanager-main --timeout=5m -echo "==> Installing Perses CRD..." -kubectl apply -f "${ROOT_DIR}/hack/e2e/manifests/perses-crd.yaml" -kubectl wait --for condition=Established crd/persesdashboards.perses.dev --timeout=2m - -echo "==> Installing Perses sample dashboard..." -kubectl apply -f "${ROOT_DIR}/hack/e2e/manifests/perses-sample-dashboard.yaml" - echo "==> Cluster setup complete!" echo " Run 'make test-e2e-deploy' to build and deploy obs-mcp" echo " Run 'make test-e2e' to run E2E tests" diff --git a/manifests/kubernetes/01_service_account.yaml b/manifests/kubernetes/01_service_account.yaml index 2eba402..4bcb500 100644 --- a/manifests/kubernetes/01_service_account.yaml +++ b/manifests/kubernetes/01_service_account.yaml @@ -19,6 +19,9 @@ rules: - apiGroups: [""] resources: ["namespaces"] verbs: ["get", "list"] + - apiGroups: ["perses.dev"] + resources: ["persesdashboards"] + verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index c1fd7c4..63ba07a 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -149,8 +149,6 @@ func sendMCPRequest(t *testing.T, req MCPRequest) (*MCPResponse, error) { } func getFirstDashboard(t *testing.T) (name, namespace string) { - t.Helper() - req := MCPRequest{ JSONRPC: "2.0", ID: 1, @@ -297,7 +295,6 @@ func TestListMetrics(t *testing.T) { t.Error("Expected result, got nil") } - t.Logf("list_metrics returned successfully") } func TestListMetricsReturnsKnownMetrics(t *testing.T) { @@ -357,7 +354,6 @@ func TestExecuteRangeQuery(t *testing.T) { t.Errorf("MCP error: %s", resp.Error.Message) } - t.Logf("execute_range_query returned successfully") } func TestRangeQueryWithInvalidPromQL(t *testing.T) { @@ -518,8 +514,6 @@ func TestListDashboards(t *testing.T) { if !strings.Contains(resultStr, "dashboards") { t.Error("Expected 'dashboards' field in result") } - - t.Logf("list_dashboards returned successfully with valid structure") } func TestGetDashboardPanels(t *testing.T) { @@ -551,8 +545,6 @@ func TestGetDashboardPanels(t *testing.T) { if resp.Result == nil { t.Fatal("Expected result, got nil") } - - t.Logf("get_dashboard_panels returned successfully for %s/%s", dashboardNamespace, dashboardName) }) t.Run("WithPanelIDsFilter", func(t *testing.T) { From 6de4fb160e632377673c36d06f53525e2d5c7317 Mon Sep 17 00:00:00 2001 From: Pranshu Srivastava Date: Mon, 26 Jan 2026 20:52:20 +0530 Subject: [PATCH 8/8] pkg: resolve naming conflicts with layout-manager --- hack/lightspeed-stack/lightspeed-stack.yaml | 5 +++-- pkg/mcp/handlers_test.go | 2 +- pkg/mcp/server.go | 1 - pkg/mcp/tools.go | 22 +++++++++++---------- tests/e2e/e2e_test.go | 8 ++++---- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/hack/lightspeed-stack/lightspeed-stack.yaml b/hack/lightspeed-stack/lightspeed-stack.yaml index 0dc68b5..53728a0 100644 --- a/hack/lightspeed-stack/lightspeed-stack.yaml +++ b/hack/lightspeed-stack/lightspeed-stack.yaml @@ -4,7 +4,6 @@ name: Lightspeed Core Service (LCS) service: host: localhost port: 8080 - auth_enabled: false workers: 1 color_log: true @@ -30,7 +29,9 @@ mcp_servers: - name: "obs" provider_id: "model-context-protocol" url: "http://localhost:9100/mcp" - + # - name: "layout-manager" + # provider_id: "model-context-protocol" + # url: "http://localhost:9081/mcp" customization: system_prompt: |- Always use available tools. diff --git a/pkg/mcp/handlers_test.go b/pkg/mcp/handlers_test.go index 247c547..179dfb4 100644 --- a/pkg/mcp/handlers_test.go +++ b/pkg/mcp/handlers_test.go @@ -496,7 +496,7 @@ func TestGetDashboardHandler_RequiredParameters(t *testing.T) { handler := GetDashboardHandler(ObsMCPOptions{}) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ - Name: "get_dashboard", + Name: "get_perses_dashboard", Arguments: tt.params, }, } diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index a6fbeec..ac6ee70 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -70,7 +70,6 @@ func SetupTools(mcpServer *server.MCPServer, opts ObsMCPOptions) error { mcpServer.AddTool(listMetricsTool, listMetricsHandler) mcpServer.AddTool(executeRangeQueryTool, executeRangeQueryHandler) mcpServer.AddTool(listDashboardsTool, dashboardsHandler) - mcpServer.AddTool(listDashboardsTool, dashboardsHandler) mcpServer.AddTool(getDashboardTool, getDashboardHandler) mcpServer.AddTool(getDashboardPanelsTool, getDashboardPanelsHandler) mcpServer.AddTool(formatPanelsForUITool, formatPanelsForUIHandler) diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 98cda80..ed93deb 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -71,21 +71,22 @@ For historical data queries, use explicit 'start' and 'end' times. ) } -// DashboardsOutput defines the output schema for the list_dashboards tool. +// DashboardsOutput defines the output schema for the list_perses_dashboards tool. type DashboardsOutput struct { Dashboards []perses.DashboardInfo `json:"dashboards" jsonschema:"description=List of all PersesDashboard resources from the cluster with their metadata"` } -// GetDashboardOutput defines the output schema for the get_dashboard tool. +// GetDashboardOutput defines the output schema for the get_perses_dashboard tool. type GetDashboardOutput struct { Name string `json:"name" jsonschema:"description=Name of the Dashboard"` Namespace string `json:"namespace" jsonschema:"description=Namespace where the Dashboard is located"` Spec map[string]any `json:"spec" jsonschema:"description=The full dashboard specification including panels, layouts, variables, and datasources"` } -// CreateListDashboardsTool creates the list_dashboards tool definition. +// CreateListDashboardsTool creates the list_perses_dashboards tool definition. func CreateListDashboardsTool() mcp.Tool { - tool := mcp.NewTool("list_dashboards", + // "list_dashboards" conflicts with the same tool in layout-manager, and makes LCS throw duplicate tool name errors + tool := mcp.NewTool("list_perses_dashboards", mcp.WithDescription(`List all PersesDashboard resources from the cluster. Start here when there is a need to visualize metrics. @@ -94,7 +95,7 @@ Returns dashboard summaries with name, namespace, labels, and descriptions. Use the descriptions to identify dashboards relevant to the user's question. -In the case that there is insufficient information in the description, use get_dashboard to fetch the full dashboard spec for more context. Doing so is an expensive operation, so only do this when necessary. +In the case that there is insufficient information in the description, use get_perses_dashboard to fetch the full dashboard spec for more context. Doing so is an expensive operation, so only do this when necessary. Follow up with get_dashboard_panels to see what panels are available in the relevant dashboard(s). `), @@ -106,14 +107,15 @@ Follow up with get_dashboard_panels to see what panels are available in the rele return tool } -// CreateGetDashboardTool creates the get_dashboard tool definition. +// CreateGetDashboardTool creates the get_perses_dashboard tool definition. func CreateGetDashboardTool() mcp.Tool { - return mcp.NewTool("get_dashboard", + // "get_dashboard" conflicts with the same tool in layout-manager, and makes LCS throw duplicate tool name errors + return mcp.NewTool("get_perses_dashboard", mcp.WithDescription(`Get a specific Dashboard by name and namespace. This tool is used to get the dashboard's panels and configuration. -Use the list_dashboards tool first to find available dashboards, then use this tool to get the full specification of a specific dashboard, if needed (to gather more context). +Use the list_perses_dashboards tool first to find available dashboards, then use this tool to get the full specification of a specific dashboard, if needed (to gather more context). -The intended use of this tool is only to gather more context on one or more dashboards when the description from list_dashboards is insufficient. +The intended use of this tool is only to gather more context on one or more dashboards when the description from list_perses_dashboards is insufficient. Information about panels themselves should be gathered using get_dashboard_panels instead (e.g., looking at a "kind: Markdown" panel to gather more context). @@ -146,7 +148,7 @@ func CreateGetDashboardPanelsTool() mcp.Tool { return mcp.NewTool("get_dashboard_panels", mcp.WithDescription(`Get panel(s) information from a specific Dashboard. -After finding a relevant dashboard (using list_dashboards and conditionally, get_dashboard), use this to see what panels it contains. +After finding a relevant dashboard (using list_perses_dashboards and conditionally, get_perses_dashboard), use this to see what panels it contains. Returns panel metadata including: - Panel IDs (format: 'panelName' or 'panelName-N' for multi-query panels) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 63ba07a..05b02b8 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -154,14 +154,14 @@ func getFirstDashboard(t *testing.T) (name, namespace string) { ID: 1, Method: "tools/call", Params: map[string]any{ - "name": "list_dashboards", + "name": "list_perses_dashboards", "arguments": map[string]any{}, }, } resp, err := sendMCPRequest(t, req) if err != nil { - t.Fatalf("Failed to call list_dashboards: %v", err) + t.Fatalf("Failed to call list_perses_dashboards: %v", err) } if resp.Error != nil { @@ -489,14 +489,14 @@ func TestListDashboards(t *testing.T) { ID: 8, Method: "tools/call", Params: map[string]any{ - "name": "list_dashboards", + "name": "list_perses_dashboards", "arguments": map[string]any{}, }, } resp, err := sendMCPRequest(t, req) if err != nil { - t.Fatalf("Failed to call list_dashboards: %v", err) + t.Fatalf("Failed to call list_perses_dashboards: %v", err) } if resp.Error != nil {