diff --git a/README.md b/README.md index 2d3604e..9785364 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,21 @@ _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 +- **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 +159,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` | diff --git a/go.mod b/go.mod index 1ea0102..4b9ce64 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ toolchain go1.24.2 require ( connectrpc.com/connect v1.18.1 + github.com/PaesslerAG/gval v1.2.2 + github.com/PaesslerAG/jsonpath v0.1.1 github.com/go-openapi/runtime v0.28.0 github.com/go-openapi/strfmt v0.23.0 github.com/google/uuid v1.6.0 @@ -105,6 +107,7 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.3.1 // indirect github.com/smartystreets/assertions v1.0.1 // indirect github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 // indirect github.com/spf13/cast v1.7.1 // indirect diff --git a/go.sum b/go.sum index a51b4fd..7fc65fd 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,12 @@ connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2pr github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/gval v1.2.2 h1:Y7iBzhgE09IGTt5QgGQ2IdaYYYOU134YGHBThD+wm9E= +github.com/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= +github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/apache/arrow-go/v18 v18.3.0 h1:Xq4A6dZj9Nu33sqZibzn012LNnewkTUlfKVUFD/RX/I= @@ -248,6 +254,8 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= diff --git a/tests/dashboards_test.py b/tests/dashboards_test.py index 2966401..95d4e91 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" + diff --git a/tools/dashboard.go b/tools/dashboard.go index 541397a..d6b03cd 100644 --- a/tools/dashboard.go +++ b/tools/dashboard.go @@ -2,8 +2,13 @@ package tools import ( "context" + "encoding/json" "fmt" + "regexp" + "strconv" + "github.com/PaesslerAG/gval" + "github.com/PaesslerAG/jsonpath" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -24,18 +29,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 := 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 := 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: + 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, + 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 +131,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 +140,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), @@ -144,15 +222,398 @@ 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), 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 + builder := gval.Full(jsonpath.Language()) + path, err := builder.NewEvaluable(args.JSONPath) + if err != nil { + return nil, fmt.Errorf("create JSONPath evaluable '%s': %w", args.JSONPath, err) + } + + result, err := path(ctx, dashboardData) + 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(args)) + 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 using helper functions + extractBasicDashboardInfo(db, summary) + + // Extract time range + summary.TimeRange = extractTimeRange(db) + + // Extract panel summaries + if panels := safeArray(db, "panels"); panels != nil { + summary.PanelCount = len(panels) + for _, p := range panels { + if panelObj, ok := p.(map[string]interface{}); ok { + summary.Panels = append(summary.Panels, extractPanelSummary(panelObj)) + } + } + } + + // Extract variable summaries + 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 { + summary.Variables = append(summary.Variables, extractVariableSummary(variable)) + } + } + } + } + + 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), +) + +// 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 +// Supports paths like "panels[0].targets[1].expr", "title", "templating.list[0].name" +func parseJSONPath(path string) []JSONPathSegment { + var segments []JSONPathSegment + + // Handle empty path + if path == "" { + return segments + } + + // Use regex for more robust parsing + re := regexp.MustCompile(`([^.\[\]]+)(?:\[(\d+)\])?`) + matches := re.FindAllStringSubmatch(path, -1) + + for _, match := range matches { + if len(match) >= 2 && match[1] != "" { + segment := JSONPathSegment{ + Key: match[1], + IsArray: len(match) >= 3 && match[2] != "", + } + + if segment.IsArray { + if index, err := strconv.Atoi(match[2]); err == nil { + segment.Index = index + } + } + + 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 { + arr, err := validateArrayAccess(current, segment) + if err != nil { + return nil, err + } + + // 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 + } + + // 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 { + arr, err := validateArrayAccess(current, segment) + if err != nil { + return err + } + + // Set the value in the array + arr[segment.Index] = 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) + } + + 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"), + } +} + 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 a58d96c..32b4c79 100644 --- a/tools/dashboard_test.go +++ b/tools/dashboard_test.go @@ -153,4 +153,200 @@ 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 - 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") + }) }