From e959f3ccbfb5613d19b38852117884fa7916f092 Mon Sep 17 00:00:00 2001 From: Ioanna Armouti Date: Fri, 8 Aug 2025 09:30:55 +0300 Subject: [PATCH 1/7] enable dashboard updates with patch operations --- go.mod | 1 + go.sum | 2 + tools/dashboard.go | 467 +++++++++++++++++++++++++++++++++++++++- tools/dashboard_test.go | 214 ++++++++++++++++++ 4 files changed, 674 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 207f2662..0a7012c8 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/grafana/pyroscope/api v1.2.0 github.com/invopop/jsonschema v0.13.0 github.com/mark3labs/mcp-go v0.36.0 + github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 github.com/prometheus/client_golang v1.23.0 github.com/prometheus/common v0.65.0 github.com/prometheus/prometheus v0.305.0 diff --git a/go.sum b/go.sum index 60908eb4..6e2fc034 100644 --- a/go.sum +++ b/go.sum @@ -219,6 +219,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 h1:Yl0tPBa8QPjGmesFh1D0rDy+q1Twx6FyU7VWHi8wZbI= +github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852/go.mod h1:eqOVx5Vwu4gd2mmMZvVZsgIqNSaW3xxRThUJ0k/TPk4= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= diff --git a/tools/dashboard.go b/tools/dashboard.go index 541397a1..81968567 100644 --- a/tools/dashboard.go +++ b/tools/dashboard.go @@ -2,10 +2,12 @@ package tools import ( "context" + "encoding/json" "fmt" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "github.com/oliveagle/jsonpath" "github.com/grafana/grafana-openapi-client-go/models" mcpgrafana "github.com/grafana/mcp-grafana" @@ -24,18 +26,91 @@ func getDashboardByUID(ctx context.Context, args GetDashboardByUIDParams) (*mode return dashboard.Payload, nil } +// PatchOperation represents a single patch operation +type PatchOperation struct { + Op string `json:"op" jsonschema:"required,description=Operation type: 'replace'\\, 'add'\\, 'remove'"` + Path string `json:"path" jsonschema:"required,description=JSONPath to the property to modify. Supports: '$.title'\\, '$.panels[0].title'\\, '$.panels[0].targets[0].expr'\\, '$.panels[1].targets[0].datasource'\\, etc."` + Value interface{} `json:"value,omitempty" jsonschema:"description=New value for replace/add operations"` +} + type UpdateDashboardParams struct { - Dashboard map[string]interface{} `json:"dashboard" jsonschema:"required,description=The full dashboard JSON"` - FolderUID string `json:"folderUid" jsonschema:"optional,description=The UID of the dashboard's folder"` - Message string `json:"message" jsonschema:"optional,description=Set a commit message for the version history"` - Overwrite bool `json:"overwrite" jsonschema:"optional,description=Overwrite the dashboard if it exists. Otherwise create one"` - UserID int64 `json:"userId" jsonschema:"optional,ID of the user making the change"` + // For full dashboard updates (creates new dashboards or complete rewrites) + Dashboard map[string]interface{} `json:"dashboard,omitempty" jsonschema:"description=The full dashboard JSON. Use for creating new dashboards or complete updates. Large dashboards consume significant context - consider using patches for small changes."` + + // For targeted updates using patch operations (preferred for existing dashboards) + UID string `json:"uid,omitempty" jsonschema:"description=UID of existing dashboard to update. Required when using patch operations."` + Operations []PatchOperation `json:"operations,omitempty" jsonschema:"description=Array of patch operations for targeted updates. More efficient than full dashboard JSON for small changes."` + + // Common parameters + FolderUID string `json:"folderUid,omitempty" jsonschema:"description=The UID of the dashboard's folder"` + Message string `json:"message,omitempty" jsonschema:"description=Set a commit message for the version history"` + Overwrite bool `json:"overwrite,omitempty" jsonschema:"description=Overwrite the dashboard if it exists. Otherwise create one"` + UserID int64 `json:"userId,omitempty" jsonschema:"description=ID of the user making the change"` } -// updateDashboard can be used to save an existing dashboard, or create a new one. -// DISCLAIMER: Large-sized dashboard JSON can exhaust context windows. We will -// implement features that address this in https://github.com/grafana/mcp-grafana/issues/101. +// updateDashboard intelligently handles dashboard updates using either full JSON or patch operations. +// It automatically uses the most efficient approach based on the provided parameters. func updateDashboard(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) { + // Determine the update strategy based on provided parameters + if len(args.Operations) > 0 && args.UID != "" { + // Patch-based update: fetch current dashboard and apply operations + return updateDashboardWithPatches(ctx, args) + } else if args.Dashboard != nil { + // Full dashboard update: use the provided JSON + return updateDashboardWithFullJSON(ctx, args) + } else { + return nil, fmt.Errorf("either dashboard JSON or (uid + operations) must be provided") + } +} + +// updateDashboardWithPatches applies patch operations to an existing dashboard +func updateDashboardWithPatches(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) { + // Get the current dashboard + dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{UID: args.UID}) + if err != nil { + return nil, fmt.Errorf("get dashboard by uid: %w", err) + } + + // Convert to modifiable map + dashboardMap, ok := dashboard.Dashboard.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("dashboard is not a JSON object") + } + + // Apply each patch operation + for i, op := range args.Operations { + switch op.Op { + case "replace", "add": + if err := setValueAtPath(dashboardMap, op.Path, op.Value); err != nil { + return nil, fmt.Errorf("operation %d (%s at %s): %w", i, op.Op, op.Path, err) + } + case "remove": + if err := removeValueAtPath(dashboardMap, op.Path); err != nil { + return nil, fmt.Errorf("operation %d (%s at %s): %w", i, op.Op, op.Path, err) + } + default: + return nil, fmt.Errorf("operation %d: unsupported operation '%s'", i, op.Op) + } + } + + // Use the folder UID from the existing dashboard if not provided + folderUID := args.FolderUID + if folderUID == "" && dashboard.Meta != nil { + folderUID = dashboard.Meta.FolderUID + } + + // Update with the patched dashboard + return updateDashboardWithFullJSON(ctx, UpdateDashboardParams{ + Dashboard: dashboardMap, + FolderUID: folderUID, + Message: args.Message, + Overwrite: true, // Always overwrite when patching + UserID: args.UserID, + }) +} + +// updateDashboardWithFullJSON performs a traditional full dashboard update +func updateDashboardWithFullJSON(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) { c := mcpgrafana.GrafanaClientFromContext(ctx) cmd := &models.SaveDashboardCommand{ Dashboard: args.Dashboard, @@ -53,7 +128,7 @@ func updateDashboard(ctx context.Context, args UpdateDashboardParams) (*models.P var GetDashboardByUID = mcpgrafana.MustTool( "get_dashboard_by_uid", - "Retrieves the complete dashboard, including panels, variables, and settings, for a specific dashboard identified by its UID.", + "Retrieves the complete dashboard, including panels, variables, and settings, for a specific dashboard identified by its UID. WARNING: Large dashboards can consume significant context window space. Consider using get_dashboard_summary for overview or get_dashboard_property for specific data instead.", getDashboardByUID, mcp.WithTitleAnnotation("Get dashboard details"), mcp.WithIdempotentHintAnnotation(true), @@ -62,7 +137,7 @@ var GetDashboardByUID = mcpgrafana.MustTool( var UpdateDashboard = mcpgrafana.MustTool( "update_dashboard", - "Create or update a dashboard", + "Create or update a dashboard using either full JSON or efficient patch operations. For new dashboards\\, provide the 'dashboard' field. For updating existing dashboards\\, use 'uid' + 'operations' for better context window efficiency. Patch operations support complex JSONPaths like '$.panels[0].targets[0].expr'\\, '$.panels[1].title'\\, '$.panels[2].targets[0].datasource'\\, etc.", updateDashboard, mcp.WithTitleAnnotation("Create or update dashboard"), mcp.WithDestructiveHintAnnotation(true), @@ -151,8 +226,380 @@ var GetDashboardPanelQueries = mcpgrafana.MustTool( mcp.WithReadOnlyHintAnnotation(true), ) +// GetDashboardPropertyParams defines parameters for getting specific dashboard properties +type GetDashboardPropertyParams struct { + UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"` + JSONPath string `json:"jsonPath" jsonschema:"required,description=JSONPath expression to extract specific data (e.g.\\, '$.panels[0].title' for first panel title\\, '$.panels[*].title' for all panel titles\\, '$.templating.list' for variables)"` +} + +// getDashboardProperty retrieves specific parts of a dashboard using JSONPath expressions. +// This helps reduce context window usage by fetching only the needed data. +func getDashboardProperty(ctx context.Context, args GetDashboardPropertyParams) (interface{}, error) { + dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{UID: args.UID}) + if err != nil { + return nil, fmt.Errorf("get dashboard by uid: %w", err) + } + + // Convert dashboard to JSON for JSONPath processing + dashboardJSON, err := json.Marshal(dashboard.Dashboard) + if err != nil { + return nil, fmt.Errorf("marshal dashboard to JSON: %w", err) + } + + var dashboardData interface{} + if err := json.Unmarshal(dashboardJSON, &dashboardData); err != nil { + return nil, fmt.Errorf("unmarshal dashboard JSON: %w", err) + } + + // Apply JSONPath expression + result, err := jsonpath.JsonPathLookup(dashboardData, args.JSONPath) + if err != nil { + return nil, fmt.Errorf("apply JSONPath '%s': %w", args.JSONPath, err) + } + + return result, nil +} + +var GetDashboardProperty = mcpgrafana.MustTool( + "get_dashboard_property", + "Get specific parts of a dashboard using JSONPath expressions to minimize context window usage. Common paths: '$.title' (title)\\, '$.panels[*].title' (all panel titles)\\, '$.panels[0]' (first panel)\\, '$.templating.list' (variables)\\, '$.tags' (tags)\\, '$.panels[*].targets[*].expr' (all queries). Use this instead of get_dashboard_by_uid when you only need specific dashboard properties.", + getDashboardProperty, + mcp.WithTitleAnnotation("Get dashboard property"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + +// GetDashboardSummaryParams defines parameters for getting a dashboard summary +type GetDashboardSummaryParams struct { + UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"` +} + +// DashboardSummary provides a compact overview of a dashboard without the full JSON +type DashboardSummary struct { + UID string `json:"uid"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + PanelCount int `json:"panelCount"` + Panels []PanelSummary `json:"panels"` + Variables []VariableSummary `json:"variables,omitempty"` + TimeRange TimeRangeSummary `json:"timeRange"` + Refresh string `json:"refresh,omitempty"` + Meta *models.DashboardMeta `json:"meta,omitempty"` +} + +type PanelSummary struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + QueryCount int `json:"queryCount"` +} + +type VariableSummary struct { + Name string `json:"name"` + Type string `json:"type"` + Label string `json:"label,omitempty"` +} + +type TimeRangeSummary struct { + From string `json:"from"` + To string `json:"to"` +} + +// getDashboardSummary provides a compact overview of a dashboard to help with context management +func getDashboardSummary(ctx context.Context, args GetDashboardSummaryParams) (*DashboardSummary, error) { + dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{UID: args.UID}) + if err != nil { + return nil, fmt.Errorf("get dashboard by uid: %w", err) + } + + db, ok := dashboard.Dashboard.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("dashboard is not a JSON object") + } + + summary := &DashboardSummary{ + UID: args.UID, + Meta: dashboard.Meta, + } + + // Extract basic info + if title, ok := db["title"].(string); ok { + summary.Title = title + } + if desc, ok := db["description"].(string); ok { + summary.Description = desc + } + if tags, ok := db["tags"].([]interface{}); ok { + for _, tag := range tags { + if tagStr, ok := tag.(string); ok { + summary.Tags = append(summary.Tags, tagStr) + } + } + } + if refresh, ok := db["refresh"].(string); ok { + summary.Refresh = refresh + } + + // Extract time range + if timeObj, ok := db["time"].(map[string]interface{}); ok { + if from, ok := timeObj["from"].(string); ok { + summary.TimeRange.From = from + } + if to, ok := timeObj["to"].(string); ok { + summary.TimeRange.To = to + } + } + + // Extract panel summaries + if panels, ok := db["panels"].([]interface{}); ok { + summary.PanelCount = len(panels) + for _, p := range panels { + if panel, ok := p.(map[string]interface{}); ok { + panelSummary := PanelSummary{} + + if id, ok := panel["id"].(float64); ok { + panelSummary.ID = int(id) + } + if title, ok := panel["title"].(string); ok { + panelSummary.Title = title + } + if panelType, ok := panel["type"].(string); ok { + panelSummary.Type = panelType + } + if desc, ok := panel["description"].(string); ok { + panelSummary.Description = desc + } + + // Count queries + if targets, ok := panel["targets"].([]interface{}); ok { + panelSummary.QueryCount = len(targets) + } + + summary.Panels = append(summary.Panels, panelSummary) + } + } + } + + // Extract variable summaries + if templating, ok := db["templating"].(map[string]interface{}); ok { + if list, ok := templating["list"].([]interface{}); ok { + for _, v := range list { + if variable, ok := v.(map[string]interface{}); ok { + varSummary := VariableSummary{} + + if name, ok := variable["name"].(string); ok { + varSummary.Name = name + } + if varType, ok := variable["type"].(string); ok { + varSummary.Type = varType + } + if label, ok := variable["label"].(string); ok { + varSummary.Label = label + } + + summary.Variables = append(summary.Variables, varSummary) + } + } + } + } + + return summary, nil +} + +var GetDashboardSummary = mcpgrafana.MustTool( + "get_dashboard_summary", + "Get a compact summary of a dashboard including title\\, panel count\\, panel types\\, variables\\, and other metadata without the full JSON. Use this for dashboard overview and planning modifications without consuming large context windows.", + getDashboardSummary, + mcp.WithTitleAnnotation("Get dashboard summary"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + +// Helper function to set a value at a JSONPath +func setValueAtPath(data map[string]interface{}, path string, value interface{}) error { + return applyJSONPath(data, path, value, false) +} + +// Helper function to remove a value at a JSONPath +func removeValueAtPath(data map[string]interface{}, path string) error { + return applyJSONPath(data, path, nil, true) +} + +// applyJSONPath applies a value to a JSONPath or removes it if remove=true +func applyJSONPath(data map[string]interface{}, path string, value interface{}, remove bool) error { + // Remove the leading "$." if present + if len(path) > 2 && path[:2] == "$." { + path = path[2:] + } + + // Split the path into segments + segments := parseJSONPath(path) + if len(segments) == 0 { + return fmt.Errorf("empty JSONPath") + } + + // Navigate to the parent of the target + current := data + for i, segment := range segments[:len(segments)-1] { + next, err := navigateSegment(current, segment) + if err != nil { + return fmt.Errorf("at segment %d (%s): %w", i, segment.String(), err) + } + current = next + } + + // Apply the final operation + finalSegment := segments[len(segments)-1] + if remove { + return removeAtSegment(current, finalSegment) + } + return setAtSegment(current, finalSegment, value) +} + +// JSONPathSegment represents a segment of a JSONPath +type JSONPathSegment struct { + Key string + Index int + IsArray bool +} + +func (s JSONPathSegment) String() string { + if s.IsArray { + return fmt.Sprintf("%s[%d]", s.Key, s.Index) + } + return s.Key +} + +// parseJSONPath parses a JSONPath string into segments +func parseJSONPath(path string) []JSONPathSegment { + var segments []JSONPathSegment + + // Simple parser for paths like "panels[0].targets[1].expr" + i := 0 + for i < len(path) { + // Find the key part + keyStart := i + for i < len(path) && path[i] != '[' && path[i] != '.' { + i++ + } + + if keyStart == i { + // Skip dots + if i < len(path) && path[i] == '.' { + i++ + } + continue + } + + key := path[keyStart:i] + + // Check if this is an array access + if i < len(path) && path[i] == '[' { + i++ // skip '[' + indexStart := i + for i < len(path) && path[i] != ']' { + i++ + } + if i >= len(path) { + break // malformed + } + + indexStr := path[indexStart:i] + index := 0 + if n, err := fmt.Sscanf(indexStr, "%d", &index); n == 1 && err == nil { + segments = append(segments, JSONPathSegment{ + Key: key, + Index: index, + IsArray: true, + }) + } + i++ // skip ']' + } else { + segments = append(segments, JSONPathSegment{ + Key: key, + IsArray: false, + }) + } + + // Skip dots + if i < len(path) && path[i] == '.' { + i++ + } + } + + return segments +} + +// navigateSegment navigates to the next level in the JSON structure +func navigateSegment(current map[string]interface{}, segment JSONPathSegment) (map[string]interface{}, error) { + if segment.IsArray { + // Get the array + arr, ok := current[segment.Key].([]interface{}) + if !ok { + return nil, fmt.Errorf("field '%s' is not an array", segment.Key) + } + + if segment.Index < 0 || segment.Index >= len(arr) { + return nil, fmt.Errorf("index %d out of bounds for array '%s' (length %d)", segment.Index, segment.Key, len(arr)) + } + + // Get the object at the index + obj, ok := arr[segment.Index].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("element at %s[%d] is not an object", segment.Key, segment.Index) + } + + return obj, nil + } else { + // Get the object + obj, ok := current[segment.Key].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("field '%s' is not an object", segment.Key) + } + + return obj, nil + } +} + +// setAtSegment sets a value at the final segment +func setAtSegment(current map[string]interface{}, segment JSONPathSegment, value interface{}) error { + if segment.IsArray { + // Get the array + arr, ok := current[segment.Key].([]interface{}) + if !ok { + return fmt.Errorf("field '%s' is not an array", segment.Key) + } + + if segment.Index < 0 || segment.Index >= len(arr) { + return fmt.Errorf("index %d out of bounds for array '%s' (length %d)", segment.Index, segment.Key, len(arr)) + } + + // Set the value in the array + arr[segment.Index] = value + return nil + } else { + // Set the value directly + current[segment.Key] = value + return nil + } +} + +// removeAtSegment removes a value at the final segment +func removeAtSegment(current map[string]interface{}, segment JSONPathSegment) error { + if segment.IsArray { + return fmt.Errorf("cannot remove array element %s[%d] (not supported)", segment.Key, segment.Index) + } else { + delete(current, segment.Key) + return nil + } +} + func AddDashboardTools(mcp *server.MCPServer) { GetDashboardByUID.Register(mcp) UpdateDashboard.Register(mcp) GetDashboardPanelQueries.Register(mcp) + GetDashboardProperty.Register(mcp) + GetDashboardSummary.Register(mcp) } diff --git a/tools/dashboard_test.go b/tools/dashboard_test.go index a58d96c8..ec9acd40 100644 --- a/tools/dashboard_test.go +++ b/tools/dashboard_test.go @@ -153,4 +153,218 @@ func TestDashboardTools(t *testing.T) { assert.Equal(t, panelQuery.Datasource.Type, "prometheus") } }) + + // Tests for new Issue #101 context window management tools + t.Run("get dashboard summary", func(t *testing.T) { + ctx := newTestContext() + + // Get the test dashboard + dashboard := getExistingTestDashboard(t, ctx, "") + + result, err := getDashboardSummary(ctx, GetDashboardSummaryParams{ + UID: dashboard.UID, + }) + require.NoError(t, err) + + assert.Equal(t, dashboard.UID, result.UID) + assert.NotEmpty(t, result.Title) + assert.Greater(t, result.PanelCount, 0, "Should have at least one panel") + assert.Len(t, result.Panels, result.PanelCount, "Panel count should match panels array length") + assert.NotNil(t, result.Meta) + + // Check that panels have expected structure + for _, panel := range result.Panels { + assert.NotEmpty(t, panel.Title) + assert.NotEmpty(t, panel.Type) + assert.GreaterOrEqual(t, panel.QueryCount, 0) + } + }) + + t.Run("get dashboard property - title", func(t *testing.T) { + ctx := newTestContext() + + dashboard := getExistingTestDashboard(t, ctx, "") + + result, err := getDashboardProperty(ctx, GetDashboardPropertyParams{ + UID: dashboard.UID, + JSONPath: "$.title", + }) + require.NoError(t, err) + + title, ok := result.(string) + require.True(t, ok, "Title should be a string") + assert.NotEmpty(t, title) + }) + + t.Run("get dashboard property - panel titles", func(t *testing.T) { + ctx := newTestContext() + + dashboard := getExistingTestDashboard(t, ctx, "") + + result, err := getDashboardProperty(ctx, GetDashboardPropertyParams{ + UID: dashboard.UID, + JSONPath: "$.panels[*].title", + }) + require.NoError(t, err) + + titles, ok := result.([]interface{}) + require.True(t, ok, "Panel titles should be an array") + assert.Greater(t, len(titles), 0, "Should have at least one panel title") + + for _, title := range titles { + titleStr, ok := title.(string) + require.True(t, ok, "Each title should be a string") + assert.NotEmpty(t, titleStr) + } + }) + + t.Run("get dashboard property - invalid path", func(t *testing.T) { + ctx := newTestContext() + + dashboard := getExistingTestDashboard(t, ctx, "") + + _, err := getDashboardProperty(ctx, GetDashboardPropertyParams{ + UID: dashboard.UID, + JSONPath: "$.nonexistent.path", + }) + require.Error(t, err, "Should fail for non-existent path") + }) + + t.Run("update dashboard - patch title", func(t *testing.T) { + ctx := newTestContext() + + // Get our test dashboard (not the provisioned one) + dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName) + + newTitle := "Updated Integration Test Dashboard" + + result, err := updateDashboard(ctx, UpdateDashboardParams{ + UID: dashboard.UID, + Operations: []PatchOperation{ + { + Op: "replace", + Path: "$.title", + Value: newTitle, + }, + }, + Message: "Updated title via patch", + }) + require.NoError(t, err) + assert.NotNil(t, result) + + // Verify the change was applied + updatedDashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{ + UID: dashboard.UID, + }) + require.NoError(t, err) + + dashboardMap, ok := updatedDashboard.Dashboard.(map[string]interface{}) + require.True(t, ok, "Dashboard should be a map") + assert.Equal(t, newTitle, dashboardMap["title"]) + }) + + t.Run("update dashboard - patch add description", func(t *testing.T) { + ctx := newTestContext() + + dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName) + + description := "This is a test description added via patch" + + _, err := updateDashboard(ctx, UpdateDashboardParams{ + UID: dashboard.UID, + Operations: []PatchOperation{ + { + Op: "add", + Path: "$.description", + Value: description, + }, + }, + Message: "Added description via patch", + }) + require.NoError(t, err) + + // Verify the description was added + updatedDashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{ + UID: dashboard.UID, + }) + require.NoError(t, err) + + dashboardMap, ok := updatedDashboard.Dashboard.(map[string]interface{}) + require.True(t, ok, "Dashboard should be a map") + assert.Equal(t, description, dashboardMap["description"]) + }) + + t.Run("update dashboard - patch remove description", func(t *testing.T) { + ctx := newTestContext() + + dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName) + + _, err := updateDashboard(ctx, UpdateDashboardParams{ + UID: dashboard.UID, + Operations: []PatchOperation{ + { + Op: "remove", + Path: "$.description", + }, + }, + Message: "Removed description via patch", + }) + require.NoError(t, err) + + // Verify the description was removed + updatedDashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{ + UID: dashboard.UID, + }) + require.NoError(t, err) + + dashboardMap, ok := updatedDashboard.Dashboard.(map[string]interface{}) + require.True(t, ok, "Dashboard should be a map") + _, hasDescription := dashboardMap["description"] + assert.False(t, hasDescription, "Description should be removed") + }) + + t.Run("update dashboard - unsupported operation", func(t *testing.T) { + ctx := newTestContext() + + dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName) + + _, err := updateDashboard(ctx, UpdateDashboardParams{ + UID: dashboard.UID, + Operations: []PatchOperation{ + { + Op: "copy", // Unsupported operation + Path: "$.title", + Value: "New Title", + }, + }, + }) + require.Error(t, err, "Should fail for unsupported operation") + }) + + t.Run("update dashboard - unsupported path", func(t *testing.T) { + ctx := newTestContext() + + dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName) + + _, err := updateDashboard(ctx, UpdateDashboardParams{ + UID: dashboard.UID, + Operations: []PatchOperation{ + { + Op: "replace", + Path: "$.panels[0].title", // Not yet supported + Value: "New Panel Title", + }, + }, + }) + require.Error(t, err, "Should fail for unsupported JSONPath") + }) + + t.Run("update dashboard - invalid parameters", func(t *testing.T) { + ctx := newTestContext() + + _, err := updateDashboard(ctx, UpdateDashboardParams{ + // Neither dashboard nor (uid + operations) provided + }) + require.Error(t, err, "Should fail when no valid parameters provided") + }) } From af9d6eddd83714997374a98c87267496fc1dc0b7 Mon Sep 17 00:00:00 2001 From: Ioanna Armouti Date: Fri, 8 Aug 2025 14:15:02 +0300 Subject: [PATCH 2/7] add integration test --- tests/dashboards_test.py | 72 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/dashboards_test.py b/tests/dashboards_test.py index 2966401c..95d4e91e 100644 --- a/tests/dashboards_test.py +++ b/tests/dashboards_test.py @@ -1,3 +1,4 @@ +import json import pytest from langevals import expect from langevals_langevals.llm_boolean import ( @@ -42,3 +43,74 @@ async def test_dashboard_panel_queries_tool(model: str, mcp_client: ClientSessio ) print("content", content) expect(input=prompt, output=content).to_pass(panel_queries_checker) + + +@pytest.mark.parametrize("model", models) +@pytest.mark.flaky(max_runs=3) +async def test_dashboard_update_with_patch_operations(model: str, mcp_client: ClientSession): + """Test that LLMs naturally use patch operations for dashboard updates""" + tools = await get_converted_tools(mcp_client) + + # First, create a non-provisioned test dashboard by copying the demo dashboard + # 1. Get the demo dashboard JSON + demo_result = await mcp_client.call_tool("get_dashboard_by_uid", {"uid": "fe9gm6guyzi0wd"}) + demo_data = json.loads(demo_result.content[0].text) + dashboard_json = demo_data["dashboard"] + + # 2. Remove uid and id to create a new dashboard + if "uid" in dashboard_json: + del dashboard_json["uid"] + if "id" in dashboard_json: + del dashboard_json["id"] + + # 3. Set a new title + title = f"Test Dashboard" + dashboard_json["title"] = title + dashboard_json["tags"] = ["python-integration-test"] + + # 4. Create the dashboard in Grafana + create_result = await mcp_client.call_tool("update_dashboard", { + "dashboard": dashboard_json, + "folderUid": "", + "overwrite": False + }) + create_data = json.loads(create_result.content[0].text) + created_dashboard_uid = create_data["uid"] + + # 5. Update the dashboard title + updated_title = f"Updated {title}" + title_prompt = f"Update the title of the Test Dashboard to {updated_title}. Search for the dashboard by title first." + + messages = [ + Message(role="system", content="You are a helpful assistant"), + Message(role="user", content=title_prompt), + ] + + # 6. Search for the test dashboard + messages = await llm_tool_call_sequence( + model, messages, tools, mcp_client, "search_dashboards", + {"query": title} + ) + + # 7. Update the dashboard using patch operations + messages = await llm_tool_call_sequence( + model, messages, tools, mcp_client, "update_dashboard", + { + "uid": created_dashboard_uid, + "operations": [ + { + "op": "replace", + "path": "$.title", + "value": updated_title + } + ] + } + ) + + # 8. Final LLM response - just verify it completes successfully + response = await acompletion(model=model, messages=messages, tools=tools) + content = response.choices[0].message.content + + # Test passes if we get here - the tool call sequence worked correctly + assert len(content) > 0, "LLM should provide a response after updating the dashboard" + From 39a36da85446be7676e87edb5f265038b8c8cb88 Mon Sep 17 00:00:00 2001 From: Ioanna Armouti Date: Fri, 8 Aug 2025 15:07:33 +0300 Subject: [PATCH 3/7] update README --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d3604e6..eeaf8a23 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,22 @@ _The following features are currently available in MCP server. This list is for ### Dashboards - **Search for dashboards:** Find dashboards by title or other metadata -- **Get dashboard by UID:** Retrieve full dashboard details using its unique identifier -- **Update or create a dashboard:** Modify existing dashboards or create new ones. _Note: Use with caution due to context window limitations; see [issue #101](https://github.com/grafana/mcp-grafana/issues/101)_ +- **Get dashboard by UID:** Retrieve full dashboard details using its unique identifier. _Warning: Large dashboards can consume significant context window space._ +- **Get dashboard summary:** Get a compact overview of a dashboard including title, panel count, panel types, variables, and metadata without the full JSON to minimize context window usage +- **Get dashboard property:** Extract specific parts of a dashboard using JSONPath expressions (e.g., `$.title`, `$.panels[*].title`) to fetch only needed data and reduce context window consumption +- **Update or create a dashboard:** Modify existing dashboards or create new ones. _Warning: Requires full dashboard JSON which can consume large amounts of context window space._ +- **Patch dashboard:** Apply specific changes to a dashboard without requiring the full JSON, significantly reducing context window usage for targeted modifications - **Get panel queries and datasource info:** Get the title, query string, and datasource information (including UID and type, if available) from every panel in a dashboard +#### Context Window Management + +The dashboard tools now include several strategies to manage context window usage effectively ([issue #101](https://github.com/grafana/mcp-grafana/issues/101)): + +- **Use `get_dashboard_summary`** for dashboard overview and planning modifications +- **Use `get_dashboard_property`** with JSONPath when you only need specific dashboard parts +- **Use `patch_dashboard`** for making small targeted changes instead of `update_dashboard` +- **Avoid `get_dashboard_by_uid`** unless you specifically need the complete dashboard JSON + ### Datasources - **List and fetch datasource information:** View all configured datasources and retrieve detailed information about each. @@ -148,6 +160,8 @@ Scopes define the specific resources that permissions apply to. Each action requ | `get_dashboard_by_uid` | Dashboard | Get a dashboard by uid | `dashboards:read` | `dashboards:uid:abc123` | | `update_dashboard` | Dashboard | Update or create a new dashboard | `dashboards:create`, `dashboards:write` | `dashboards:*`, `folders:*` or `folders:uid:xyz789` | | `get_dashboard_panel_queries` | Dashboard | Get panel title, queries, datasource UID and type from a dashboard | `dashboards:read` | `dashboards:uid:abc123` | +| `get_dashboard_property` | Dashboard | Extract specific parts of a dashboard using JSONPath expressions | `dashboards:read` | `dashboards:uid:abc123` | +| `get_dashboard_summary` | Dashboard | Get a compact summary of a dashboard without full JSON | `dashboards:read` | `dashboards:uid:abc123` | | `list_datasources` | Datasources | List datasources | `datasources:read` | `datasources:*` | | `get_datasource_by_uid` | Datasources | Get a datasource by uid | `datasources:read` | `datasources:uid:prometheus-uid` | | `get_datasource_by_name` | Datasources | Get a datasource by name | `datasources:read` | `datasources:*` or `datasources:uid:loki-uid` | From 61933e96cb2787e1eeb8fc10d37804c867ac4e21 Mon Sep 17 00:00:00 2001 From: Ioanna Armouti Date: Fri, 8 Aug 2025 15:38:29 +0300 Subject: [PATCH 4/7] create helper functions for jsonpath dashboard updates --- README.md | 1 - tools/dashboard.go | 293 +++++++++++++++++++++------------------- tools/dashboard_test.go | 18 --- 3 files changed, 155 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index eeaf8a23..97853645 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,6 @@ The dashboard tools now include several strategies to manage context window usag - **Use `get_dashboard_summary`** for dashboard overview and planning modifications - **Use `get_dashboard_property`** with JSONPath when you only need specific dashboard parts -- **Use `patch_dashboard`** for making small targeted changes instead of `update_dashboard` - **Avoid `get_dashboard_by_uid`** unless you specifically need the complete dashboard JSON ### Datasources diff --git a/tools/dashboard.go b/tools/dashboard.go index 81968567..56a7774d 100644 --- a/tools/dashboard.go +++ b/tools/dashboard.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "regexp" + "strconv" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -104,7 +106,7 @@ func updateDashboardWithPatches(ctx context.Context, args UpdateDashboardParams) Dashboard: dashboardMap, FolderUID: folderUID, Message: args.Message, - Overwrite: true, // Always overwrite when patching + Overwrite: true, UserID: args.UserID, }) } @@ -324,82 +326,28 @@ func getDashboardSummary(ctx context.Context, args GetDashboardSummaryParams) (* Meta: dashboard.Meta, } - // Extract basic info - if title, ok := db["title"].(string); ok { - summary.Title = title - } - if desc, ok := db["description"].(string); ok { - summary.Description = desc - } - if tags, ok := db["tags"].([]interface{}); ok { - for _, tag := range tags { - if tagStr, ok := tag.(string); ok { - summary.Tags = append(summary.Tags, tagStr) - } - } - } - if refresh, ok := db["refresh"].(string); ok { - summary.Refresh = refresh - } + // Extract basic info using helper functions + extractBasicDashboardInfo(db, summary) // Extract time range - if timeObj, ok := db["time"].(map[string]interface{}); ok { - if from, ok := timeObj["from"].(string); ok { - summary.TimeRange.From = from - } - if to, ok := timeObj["to"].(string); ok { - summary.TimeRange.To = to - } - } + summary.TimeRange = extractTimeRange(db) // Extract panel summaries - if panels, ok := db["panels"].([]interface{}); ok { + if panels := safeArray(db, "panels"); panels != nil { summary.PanelCount = len(panels) for _, p := range panels { - if panel, ok := p.(map[string]interface{}); ok { - panelSummary := PanelSummary{} - - if id, ok := panel["id"].(float64); ok { - panelSummary.ID = int(id) - } - if title, ok := panel["title"].(string); ok { - panelSummary.Title = title - } - if panelType, ok := panel["type"].(string); ok { - panelSummary.Type = panelType - } - if desc, ok := panel["description"].(string); ok { - panelSummary.Description = desc - } - - // Count queries - if targets, ok := panel["targets"].([]interface{}); ok { - panelSummary.QueryCount = len(targets) - } - - summary.Panels = append(summary.Panels, panelSummary) + if panelObj, ok := p.(map[string]interface{}); ok { + summary.Panels = append(summary.Panels, extractPanelSummary(panelObj)) } } } // Extract variable summaries - if templating, ok := db["templating"].(map[string]interface{}); ok { - if list, ok := templating["list"].([]interface{}); ok { + if templating := safeObject(db, "templating"); templating != nil { + if list := safeArray(templating, "list"); list != nil { for _, v := range list { if variable, ok := v.(map[string]interface{}); ok { - varSummary := VariableSummary{} - - if name, ok := variable["name"].(string); ok { - varSummary.Name = name - } - if varType, ok := variable["type"].(string); ok { - varSummary.Type = varType - } - if label, ok := variable["label"].(string); ok { - varSummary.Label = label - } - - summary.Variables = append(summary.Variables, varSummary) + summary.Variables = append(summary.Variables, extractVariableSummary(variable)) } } } @@ -473,76 +421,59 @@ func (s JSONPathSegment) String() string { } // parseJSONPath parses a JSONPath string into segments +// Supports paths like "panels[0].targets[1].expr", "title", "templating.list[0].name" func parseJSONPath(path string) []JSONPathSegment { var segments []JSONPathSegment - // Simple parser for paths like "panels[0].targets[1].expr" - i := 0 - for i < len(path) { - // Find the key part - keyStart := i - for i < len(path) && path[i] != '[' && path[i] != '.' { - i++ - } - - if keyStart == i { - // Skip dots - if i < len(path) && path[i] == '.' { - i++ - } - continue - } + // Handle empty path + if path == "" { + return segments + } - key := path[keyStart:i] + // Use regex for more robust parsing + re := regexp.MustCompile(`([^.\[\]]+)(?:\[(\d+)\])?`) + matches := re.FindAllStringSubmatch(path, -1) - // Check if this is an array access - if i < len(path) && path[i] == '[' { - i++ // skip '[' - indexStart := i - for i < len(path) && path[i] != ']' { - i++ - } - if i >= len(path) { - break // malformed + for _, match := range matches { + if len(match) >= 2 && match[1] != "" { + segment := JSONPathSegment{ + Key: match[1], + IsArray: len(match) >= 3 && match[2] != "", } - indexStr := path[indexStart:i] - index := 0 - if n, err := fmt.Sscanf(indexStr, "%d", &index); n == 1 && err == nil { - segments = append(segments, JSONPathSegment{ - Key: key, - Index: index, - IsArray: true, - }) + if segment.IsArray { + if index, err := strconv.Atoi(match[2]); err == nil { + segment.Index = index + } } - i++ // skip ']' - } else { - segments = append(segments, JSONPathSegment{ - Key: key, - IsArray: false, - }) - } - // Skip dots - if i < len(path) && path[i] == '.' { - i++ + segments = append(segments, segment) } } return segments } +// validateArrayAccess validates array access for a segment +func validateArrayAccess(current map[string]interface{}, segment JSONPathSegment) ([]interface{}, error) { + arr, ok := current[segment.Key].([]interface{}) + if !ok { + return nil, fmt.Errorf("field '%s' is not an array", segment.Key) + } + + if segment.Index < 0 || segment.Index >= len(arr) { + return nil, fmt.Errorf("index %d out of bounds for array '%s' (length %d)", segment.Index, segment.Key, len(arr)) + } + + return arr, nil +} + // navigateSegment navigates to the next level in the JSON structure func navigateSegment(current map[string]interface{}, segment JSONPathSegment) (map[string]interface{}, error) { if segment.IsArray { - // Get the array - arr, ok := current[segment.Key].([]interface{}) - if !ok { - return nil, fmt.Errorf("field '%s' is not an array", segment.Key) - } - - if segment.Index < 0 || segment.Index >= len(arr) { - return nil, fmt.Errorf("index %d out of bounds for array '%s' (length %d)", segment.Index, segment.Key, len(arr)) + arr, err := validateArrayAccess(current, segment) + if err != nil { + return nil, err } // Get the object at the index @@ -552,47 +483,133 @@ func navigateSegment(current map[string]interface{}, segment JSONPathSegment) (m } return obj, nil - } else { - // Get the object - obj, ok := current[segment.Key].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("field '%s' is not an object", segment.Key) - } + } - return obj, nil + // Get the object + obj, ok := current[segment.Key].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("field '%s' is not an object", segment.Key) } + + return obj, nil } // setAtSegment sets a value at the final segment func setAtSegment(current map[string]interface{}, segment JSONPathSegment, value interface{}) error { if segment.IsArray { - // Get the array - arr, ok := current[segment.Key].([]interface{}) - if !ok { - return fmt.Errorf("field '%s' is not an array", segment.Key) - } - - if segment.Index < 0 || segment.Index >= len(arr) { - return fmt.Errorf("index %d out of bounds for array '%s' (length %d)", segment.Index, segment.Key, len(arr)) + arr, err := validateArrayAccess(current, segment) + if err != nil { + return err } // Set the value in the array arr[segment.Index] = value return nil - } else { - // Set the value directly - current[segment.Key] = value - return nil } + + // Set the value directly + current[segment.Key] = value + return nil } // removeAtSegment removes a value at the final segment func removeAtSegment(current map[string]interface{}, segment JSONPathSegment) error { if segment.IsArray { return fmt.Errorf("cannot remove array element %s[%d] (not supported)", segment.Key, segment.Index) - } else { - delete(current, segment.Key) - return nil + } + + delete(current, segment.Key) + return nil +} + +// Helper functions for safe type conversions and field extraction + +// safeGet safely extracts a value from a map with type conversion +func safeGet[T any](data map[string]interface{}, key string, defaultVal T) T { + if val, ok := data[key]; ok { + if typedVal, ok := val.(T); ok { + return typedVal + } + } + return defaultVal +} + +func safeString(data map[string]interface{}, key string) string { + return safeGet(data, key, "") +} + +func safeStringSlice(data map[string]interface{}, key string) []string { + var result []string + if arr := safeArray(data, key); arr != nil { + for _, item := range arr { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + } + return result +} + +func safeFloat64(data map[string]interface{}, key string) float64 { + return safeGet(data, key, 0.0) +} + +func safeInt(data map[string]interface{}, key string) int { + return int(safeFloat64(data, key)) +} + +func safeObject(data map[string]interface{}, key string) map[string]interface{} { + return safeGet(data, key, map[string]interface{}(nil)) +} + +func safeArray(data map[string]interface{}, key string) []interface{} { + return safeGet(data, key, []interface{}(nil)) +} + +// extractBasicDashboardInfo extracts common dashboard fields +func extractBasicDashboardInfo(db map[string]interface{}, summary *DashboardSummary) { + summary.Title = safeString(db, "title") + summary.Description = safeString(db, "description") + summary.Tags = safeStringSlice(db, "tags") + summary.Refresh = safeString(db, "refresh") +} + +// extractTimeRange extracts time range information +func extractTimeRange(db map[string]interface{}) TimeRangeSummary { + timeObj := safeObject(db, "time") + if timeObj == nil { + return TimeRangeSummary{} + } + + return TimeRangeSummary{ + From: safeString(timeObj, "from"), + To: safeString(timeObj, "to"), + } +} + +// extractPanelSummary creates a panel summary from panel data +func extractPanelSummary(panel map[string]interface{}) PanelSummary { + summary := PanelSummary{ + ID: safeInt(panel, "id"), + Title: safeString(panel, "title"), + Type: safeString(panel, "type"), + Description: safeString(panel, "description"), + } + + // Count queries + if targets := safeArray(panel, "targets"); targets != nil { + summary.QueryCount = len(targets) + } + + return summary +} + +// extractVariableSummary creates a variable summary from variable data +func extractVariableSummary(variable map[string]interface{}) VariableSummary { + return VariableSummary{ + Name: safeString(variable, "name"), + Type: safeString(variable, "type"), + Label: safeString(variable, "label"), } } diff --git a/tools/dashboard_test.go b/tools/dashboard_test.go index ec9acd40..32b4c791 100644 --- a/tools/dashboard_test.go +++ b/tools/dashboard_test.go @@ -341,24 +341,6 @@ func TestDashboardTools(t *testing.T) { require.Error(t, err, "Should fail for unsupported operation") }) - t.Run("update dashboard - unsupported path", func(t *testing.T) { - ctx := newTestContext() - - dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName) - - _, err := updateDashboard(ctx, UpdateDashboardParams{ - UID: dashboard.UID, - Operations: []PatchOperation{ - { - Op: "replace", - Path: "$.panels[0].title", // Not yet supported - Value: "New Panel Title", - }, - }, - }) - require.Error(t, err, "Should fail for unsupported JSONPath") - }) - t.Run("update dashboard - invalid parameters", func(t *testing.T) { ctx := newTestContext() From d323074e40a512063d421736324c602e0f8e6970 Mon Sep 17 00:00:00 2001 From: Ioanna Armouti Date: Fri, 8 Aug 2025 16:21:01 +0300 Subject: [PATCH 5/7] lint --- tools/dashboard.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/dashboard.go b/tools/dashboard.go index 56a7774d..dee8a05b 100644 --- a/tools/dashboard.go +++ b/tools/dashboard.go @@ -311,7 +311,7 @@ type TimeRangeSummary struct { // getDashboardSummary provides a compact overview of a dashboard to help with context management func getDashboardSummary(ctx context.Context, args GetDashboardSummaryParams) (*DashboardSummary, error) { - dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{UID: args.UID}) + dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams(args)) if err != nil { return nil, fmt.Errorf("get dashboard by uid: %w", err) } From cc8374b7be907010e0e4a04223054dbb91f060d1 Mon Sep 17 00:00:00 2001 From: Ioanna Armouti Date: Fri, 8 Aug 2025 17:19:57 +0300 Subject: [PATCH 6/7] improve tool description --- tools/dashboard.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/dashboard.go b/tools/dashboard.go index dee8a05b..3a6f741f 100644 --- a/tools/dashboard.go +++ b/tools/dashboard.go @@ -221,7 +221,7 @@ func GetDashboardPanelQueriesTool(ctx context.Context, args DashboardPanelQuerie var GetDashboardPanelQueries = mcpgrafana.MustTool( "get_dashboard_panel_queries", - "Get the title, query string, and datasource information for each panel in a dashboard. The datasource is an object with fields `uid` (which may be a concrete UID or a template variable like \"$datasource\") and `type`. If the datasource UID is a template variable, it won't be usable directly for queries. Returns an array of objects, each representing a panel, with fields: title, query, and datasource (an object with uid and type).", + "Use this tool to retrieve panel queries and information from a Grafana dashboard. When asked about panel queries, queries in a dashboard, or what queries a dashboard contains, call this tool with the dashboard UID. The datasource is an object with fields `uid` (which may be a concrete UID or a template variable like \"$datasource\") and `type`. If the datasource UID is a template variable, it won't be usable directly for queries. Returns an array of objects, each representing a panel, with fields: title, query, and datasource (an object with uid and type).", GetDashboardPanelQueriesTool, mcp.WithTitleAnnotation("Get dashboard panel queries"), mcp.WithIdempotentHintAnnotation(true), From abdd76d8fcbe26ab6dd43feef6a46433c5636ac3 Mon Sep 17 00:00:00 2001 From: Ioanna Armouti Date: Fri, 8 Aug 2025 17:19:57 +0300 Subject: [PATCH 7/7] improve tool description --- tools/dashboard.go | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/tools/dashboard.go b/tools/dashboard.go index 3a6f741f..a4c75b64 100644 --- a/tools/dashboard.go +++ b/tools/dashboard.go @@ -83,11 +83,11 @@ func updateDashboardWithPatches(ctx context.Context, args UpdateDashboardParams) for i, op := range args.Operations { switch op.Op { case "replace", "add": - if err := setValueAtPath(dashboardMap, op.Path, op.Value); err != nil { + if err := applyJSONPath(dashboardMap, op.Path, op.Value, false); err != nil { return nil, fmt.Errorf("operation %d (%s at %s): %w", i, op.Op, op.Path, err) } case "remove": - if err := removeValueAtPath(dashboardMap, op.Path); err != nil { + if err := applyJSONPath(dashboardMap, op.Path, nil, true); err != nil { return nil, fmt.Errorf("operation %d (%s at %s): %w", i, op.Op, op.Path, err) } default: @@ -365,16 +365,6 @@ var GetDashboardSummary = mcpgrafana.MustTool( mcp.WithReadOnlyHintAnnotation(true), ) -// Helper function to set a value at a JSONPath -func setValueAtPath(data map[string]interface{}, path string, value interface{}) error { - return applyJSONPath(data, path, value, false) -} - -// Helper function to remove a value at a JSONPath -func removeValueAtPath(data map[string]interface{}, path string) error { - return applyJSONPath(data, path, nil, true) -} - // applyJSONPath applies a value to a JSONPath or removes it if remove=true func applyJSONPath(data map[string]interface{}, path string, value interface{}, remove bool) error { // Remove the leading "$." if present