| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | /** |
| 4 | * MCP Metrics Query Tool |
| 5 | * |
| 6 | * This tool allows querying metrics data via the Model Context Protocol. |
| 7 | * It provides an interface to the data query engine similar to the API v2 data endpoint. |
| 8 | * |
| 9 | * Query Process: |
| 10 | * 1. The query engine first determines all unique time-series to query by filtering based on context, nodes, |
| 11 | * time-frame, and other supplied filters. |
| 12 | * |
| 13 | * 2. It then queries each time-series, automatically applying over-time-aggregation. For example, if the |
| 14 | * database has 1000 points for a time series and you request 10 points, the query engine reduces the |
| 15 | * 1000 points to 10 using the time_group aggregation function (average, max, min, etc.). |
| 16 | * |
| 17 | * 3. After time aggregation, the query engine applies the group_by aggregation across metrics. |
| 18 | * For example, if querying disk I/O for 10 disks from 2 nodes with 2 dimensions each (read/write), |
| 19 | * you have 40 unique time-series. With group_by=dimension, the engine would: |
| 20 | * - Aggregate all 20 'read' dimensions (from all disks across all nodes) into a single 'read' dimension |
| 21 | * - Aggregate all 20 'write' dimensions (from all disks across all nodes) into a single 'write' dimension |
| 22 | * - Use the specified aggregation function (sum, min, max, average) for this cross-metric aggregation |
| 23 | * |
| 24 | * 4. The result will contain only the grouped dimensions, but with rich metadata: |
| 25 | * - Each data point contains: timestamp, aggregated value, anomaly rate, and quality flags |
| 26 | * - Quality flags indicate whether original data had gaps or counter overflows |
| 27 | * |
| 28 | * 5. When 'jsonwrap' is included in options, the response includes comprehensive statistics about all |
| 29 | * facets of the query, providing aggregated min, max, average, anomaly rate, and volume contribution |
| 30 | * percentages per node, instance, dimension, and label. |
| 31 | */ |
| 32 | |
| 33 | #include "mcp-tools-query-metrics.h" |
| 34 | #include "mcp-tools.h" |
| 35 | #include "mcp-params.h" |
| 36 | #include "web/api/formatters/rrd2json.h" |
| 37 | |
| 38 | |
| 39 | // JSON schema for the metrics query tool |
| 40 | void mcp_tool_query_metrics_schema(BUFFER *buffer) { |
| 41 | // Tool input schema |
| 42 | buffer_json_member_add_object(buffer, "inputSchema"); |
| 43 | buffer_json_member_add_string(buffer, "type", "object"); |
| 44 | buffer_json_member_add_string(buffer, "title", "Query Metrics Data"); |
| 45 | |
| 46 | // Properties |
| 47 | buffer_json_member_add_object(buffer, "properties"); |
| 48 | |
| 49 | // Selection parameters |
| 50 | mcp_schema_add_string_param( |
| 51 | buffer, "metric", "Metric Name", |
| 52 | "The exact metric (context) to query.\n" |
| 53 | "Use the '" MCP_TOOL_LIST_METRICS "' tool to discover available metrics.", |
| 54 | NULL, true); |
| 55 | |
| 56 | mcp_schema_add_array_param( |
| 57 | buffer, "dimensions", |
| 58 | "Dimensions Filter", |
| 59 | "Array of dimensions to include in the query.\n" |
| 60 | "Examples: [\"read\", \"write\"] or [\"in\", \"out\"] or [\"used\", \"free\", \"cached\"]\n" |
| 61 | "Use the '" MCP_TOOL_GET_METRICS_DETAILS "' tool to discover the available dimensions for a metric."); |
| 62 | |
| 63 | mcp_schema_add_labels_object( |
| 64 | buffer, "Labels Filter", |
| 65 | "Query only the instances with the given labels. " |
| 66 | "Example: {\"disk_type\": [\"ssd\", \"nvme\"], \"mount_point\": [\"/\"]}\n" |
| 67 | "Values in the same array are ORed, different keys are ANDed. " |
| 68 | "Use the '" MCP_TOOL_GET_METRICS_DETAILS "' tool to discover available labels and values for a metric."); |
| 69 | |
| 70 | mcp_schema_add_array_param( |
| 71 | buffer, "instances", |
| 72 | "Instances Filter", |
| 73 | "Query only the given instances.\n" |
| 74 | "Use the '" MCP_TOOL_GET_METRICS_DETAILS "' tool to discover available instances for a metric.\n" |
| 75 | "If no instances are specified, all instances of the metric are queried.\n" |
| 76 | "Example: [\"instance1\", \"instance2\", \"instance3\"]\n." |
| 77 | "IMPORTANT: when you have a choice, prefer to filter by labels instead of instances, because many monitored " |
| 78 | "components may change instance names over time."); |
| 79 | |
| 80 | mcp_schema_add_array_param( |
| 81 | buffer, "nodes", |
| 82 | "Nodes Filter", |
| 83 | "Array of nodes to include in the query.\n" |
| 84 | "If no nodes are specified, all nodes having data for the given metrics in the specified time-frame will be queried.\n" |
| 85 | "Examples: [\"node1\", \"node2\", \"node3\"]\n" |
| 86 | "Use the '" MCP_TOOL_LIST_NODES "' tool to discover the available nodes."); |
| 87 | |
| 88 | // Add cardinality limit |
| 89 | mcp_schema_add_cardinality_limit(buffer, |
| 90 | "Limit the response cardinality (number of dimensions, instances, labels, etc.). " |
| 91 | "When the limit is exceeded, the response will indicate how many items were omitted.", |
| 92 | MCP_DATA_CARDINALITY_LIMIT, |
| 93 | 1, // minimum |
| 94 | MAX(MCP_DATA_CARDINALITY_LIMIT, MCP_DATA_CARDINALITY_LIMIT_MAX)); |
| 95 | |
| 96 | // Time parameters |
| 97 | mcp_schema_add_time_params(buffer, "query window", true); |
| 98 | |
| 99 | buffer_json_member_add_object(buffer, "points"); |
| 100 | { |
| 101 | buffer_json_member_add_string(buffer, "type", "number"); |
| 102 | buffer_json_member_add_string(buffer, "title", "Data Points"); |
| 103 | buffer_json_member_add_string(buffer, "description", "Number of data points to return."); |
| 104 | buffer_json_member_add_uint64(buffer, "default", 60); |
| 105 | } |
| 106 | buffer_json_object_close(buffer); // points |
| 107 | |
| 108 | buffer_json_member_add_object(buffer, "timeout"); |
| 109 | { |
| 110 | buffer_json_member_add_string(buffer, "type", "number"); |
| 111 | buffer_json_member_add_string(buffer, "title", "Timeout"); |
| 112 | buffer_json_member_add_string(buffer, "description", "Query timeout in seconds."); |
| 113 | buffer_json_member_add_uint64(buffer, "default", 60); |
| 114 | } |
| 115 | buffer_json_object_close(buffer); // timeout |
| 116 | |
| 117 | buffer_json_member_add_object(buffer, "options"); |
| 118 | { |
| 119 | buffer_json_member_add_string(buffer, "type", "string"); |
| 120 | buffer_json_member_add_string(buffer, "title", "Query Options"); |
| 121 | buffer_json_member_add_string( |
| 122 | buffer, "description", |
| 123 | "Space-separated list of additional query options:\n" |
| 124 | "'percentage': Return values as percentages of total\n" |
| 125 | "'absolute' or 'absolute-sum': Return absolute values for stacked charts\n" |
| 126 | "'display-absolute': Convert percentage values to absolute before application of grouping functions\n" |
| 127 | "'all-dimensions': Include all dimensions, even those with just zero values\n" |
| 128 | "Example: 'absolute percentage'"); |
| 129 | } |
| 130 | buffer_json_object_close(buffer); // options |
| 131 | |
| 132 | // Time grouping |
| 133 | buffer_json_member_add_object(buffer, "time_group"); |
| 134 | { |
| 135 | buffer_json_member_add_string(buffer, "type", "string"); |
| 136 | buffer_json_member_add_string(buffer, "title", "Time Grouping Method"); |
| 137 | buffer_json_member_add_string(buffer, "description", "Method to group data points over time. The 'extremes' method returns the maximum value for positive numbers and the minimum value for negative numbers, which is particularly useful for showing the highest peaks in both directions on charts."); |
| 138 | buffer_json_member_add_string(buffer, "default", "average"); |
| 139 | |
| 140 | // Define enum of possible values |
| 141 | buffer_json_member_add_array(buffer, "enum"); |
| 142 | buffer_json_add_array_item_string(buffer, "average"); // "avg" and "mean" are aliases |
| 143 | buffer_json_add_array_item_string(buffer, "min"); |
| 144 | buffer_json_add_array_item_string(buffer, "max"); |
| 145 | buffer_json_add_array_item_string(buffer, "sum"); |
| 146 | buffer_json_add_array_item_string(buffer, "incremental-sum"); // "incremental_sum" is an alias |
| 147 | buffer_json_add_array_item_string(buffer, "median"); |
| 148 | buffer_json_add_array_item_string(buffer, "trimmed-mean"); |
| 149 | buffer_json_add_array_item_string(buffer, "trimmed-median"); |
| 150 | buffer_json_add_array_item_string(buffer, "percentile"); // requires time_group_options parameter |
| 151 | buffer_json_add_array_item_string(buffer, "stddev"); // standard deviation |
| 152 | buffer_json_add_array_item_string(buffer, "coefficient-of-variation"); // relative standard deviation (cv) |
| 153 | buffer_json_add_array_item_string(buffer, "ema"); // exponential moving average (alias "ses" or "ewma") |
| 154 | buffer_json_add_array_item_string(buffer, "des"); // double exponential smoothing |
| 155 | buffer_json_add_array_item_string(buffer, "countif"); // requires time_group_options parameter |
| 156 | buffer_json_add_array_item_string(buffer, "extremes"); // for each time frame, returns max for positive values and min for negative values |
| 157 | buffer_json_array_close(buffer); |
| 158 | } |
| 159 | buffer_json_object_close(buffer); // time_group |
| 160 | |
| 161 | buffer_json_member_add_object(buffer, "time_group_options"); |
| 162 | { |
| 163 | buffer_json_member_add_string(buffer, "type", "string"); |
| 164 | buffer_json_member_add_string(buffer, "title", "Time Group Options"); |
| 165 | buffer_json_member_add_string( |
| 166 | buffer, "description", |
| 167 | "Additional options for time grouping.\n" |
| 168 | "For 'percentile', specify a percentage (0-100).\n" |
| 169 | "For 'countif', specify a comparison operator and value (e.g., '>0', '=0', '!=0', '<=10')."); |
| 170 | } |
| 171 | buffer_json_object_close(buffer); // time_group_options |
| 172 | |
| 173 | // Tier selection |
| 174 | buffer_json_member_add_object(buffer, "tier"); |
| 175 | { |
| 176 | buffer_json_member_add_string(buffer, "type", "number"); |
| 177 | buffer_json_member_add_string(buffer, "title", "Storage Tier"); |
| 178 | buffer_json_member_add_string( |
| 179 | buffer, "description", |
| 180 | "Storage tier to query from.\n" |
| 181 | "If not specified, Netdata will automatically pick the best tier based on the time-frame and points requested.\n" |
| 182 | "CAUTION: specifying a high-resolution tier (like 0) over long time-frames (like days) may consume significant system resources."); |
| 183 | } |
| 184 | buffer_json_object_close(buffer); // tier |
| 185 | |
| 186 | // Group by parameters |
| 187 | buffer_json_member_add_object(buffer, "group_by"); |
| 188 | { |
| 189 | buffer_json_member_add_string(buffer, "type", "array"); |
| 190 | buffer_json_member_add_string(buffer, "title", "Group By"); |
| 191 | buffer_json_member_add_string( |
| 192 | buffer, "description", |
| 193 | "Specifies how to group metrics across different time-series.\n" |
| 194 | "- 'dimension': Groups by dimension name across all instances/nodes. Example: for disks it provides the aggregate of reads and writes across all disks of all nodes.\n" |
| 195 | "- 'instance': Groups by instance across all nodes. Example: for disks, it provides the aggregate per disk name (sda, sdb, etc), aggregating their reads and writes, across all nodes.\n" |
| 196 | "- 'node': Groups by node. Example: for disks, it provides one metric per node, aggregating reads and writes across all its disks.\n" |
| 197 | "- 'label': Groups by the given label key (use the parameter 'group_by_label' to set the key). Example: for disks, aggregate over key 'disk_type' to get an group all 'physical', 'virtual' and 'partition' separately.\n" |
| 198 | "Multiple groupings can be combined. Example: '[\"dimension\", \"label\"]'."); |
| 199 | buffer_json_member_add_array(buffer, "default"); |
| 200 | buffer_json_add_array_item_string(buffer, "dimension"); |
| 201 | buffer_json_array_close(buffer); |
| 202 | |
| 203 | // Define items schema with enum values |
| 204 | buffer_json_member_add_object(buffer, "items"); |
| 205 | { |
| 206 | buffer_json_member_add_string(buffer, "type", "string"); |
| 207 | buffer_json_member_add_array(buffer, "enum"); |
| 208 | buffer_json_add_array_item_string(buffer, "dimension"); |
| 209 | buffer_json_add_array_item_string(buffer, "instance"); |
| 210 | buffer_json_add_array_item_string(buffer, "node"); |
| 211 | buffer_json_add_array_item_string(buffer, "label"); |
| 212 | |
| 213 | // we don't offer these to MCP clients. |
| 214 | // buffer_json_add_array_item_string(buffer, "context"); |
| 215 | // buffer_json_add_array_item_string(buffer, "units"); |
| 216 | buffer_json_array_close(buffer); |
| 217 | } |
| 218 | buffer_json_object_close(buffer); // items |
| 219 | } |
| 220 | buffer_json_object_close(buffer); // group_by |
| 221 | |
| 222 | mcp_schema_add_string_param( |
| 223 | buffer, "group_by_label", |
| 224 | "Group By Label", |
| 225 | "When 'group_by' includes 'label', this parameter specifies the label key to group by.\n" |
| 226 | "Example: if metrics have an 'interface_type' label with values like 'real' or 'virtual', " |
| 227 | "setting 'group_by_label' to 'interface_type' would aggregate metrics separately for physical and virtual network interfaces.", |
| 228 | NULL, false); |
| 229 | |
| 230 | buffer_json_member_add_object(buffer, "aggregation"); |
| 231 | { |
| 232 | buffer_json_member_add_string(buffer, "type", "string"); |
| 233 | buffer_json_member_add_string(buffer, "title", "Aggregation Method"); |
| 234 | buffer_json_member_add_string( |
| 235 | buffer, "description", |
| 236 | "Method to use when aggregating grouped metrics.\n" |
| 237 | "- 'sum': Sum of all grouped metrics (useful for additive metrics like bytes transferred, operations, etc.)\n" |
| 238 | "- 'min': Minimum value among all grouped metrics (useful for finding best performance metrics)\n" |
| 239 | "- 'max': Maximum value among all grouped metrics (useful for finding worst performance metrics, peak resource usage)\n" |
| 240 | "- 'extremes': When values are both positive and negative, shows the maximum value for positive metrics and the minimum value for negative metrics\n" |
| 241 | "- 'average': Average of all grouped metrics (CAUTION: When 'group_by' doesn't include 'dimension', this averages different metric types together - e.g., CPU user + system + idle - which is rarely meaningful)\n" |
| 242 | "- 'percentage': Expresses each grouped metric as a percentage of its group's total (useful for seeing proportional contributions)\n"); |
| 243 | |
| 244 | // Define enum of possible values |
| 245 | buffer_json_member_add_array(buffer, "enum"); |
| 246 | buffer_json_add_array_item_string(buffer, "sum"); |
| 247 | buffer_json_add_array_item_string(buffer, "min"); |
| 248 | buffer_json_add_array_item_string(buffer, "max"); |
| 249 | buffer_json_add_array_item_string(buffer, "extremes"); |
| 250 | buffer_json_add_array_item_string(buffer, "average"); |
| 251 | buffer_json_add_array_item_string(buffer, "percentage"); |
| 252 | buffer_json_array_close(buffer); |
| 253 | } |
| 254 | buffer_json_object_close(buffer); // aggregation |
| 255 | |
| 256 | buffer_json_object_close(buffer); // properties |
| 257 | |
| 258 | // Required fields |
| 259 | buffer_json_member_add_array(buffer, "required"); |
| 260 | buffer_json_add_array_item_string(buffer, "metric"); |
| 261 | buffer_json_add_array_item_string(buffer, "dimensions"); |
| 262 | buffer_json_add_array_item_string(buffer, "after"); |
| 263 | buffer_json_add_array_item_string(buffer, "before"); |
| 264 | buffer_json_add_array_item_string(buffer, "points"); |
| 265 | buffer_json_add_array_item_string(buffer, "time_group"); |
| 266 | buffer_json_add_array_item_string(buffer, "group_by"); |
| 267 | buffer_json_add_array_item_string(buffer, "aggregation"); |
| 268 | buffer_json_add_array_item_string(buffer, "cardinality_limit"); |
| 269 | buffer_json_array_close(buffer); |
| 270 | |
| 271 | buffer_json_object_close(buffer); // inputSchema |
| 272 | } |
| 273 | |
| 274 | // Structure to hold interruption data |
| 275 | typedef struct { |
| 276 | MCP_CLIENT *mcpc; |
| 277 | MCP_REQUEST_ID id; |
| 278 | } mcp_query_interrupt_data; |
| 279 | |
| 280 | // Interrupt callback for query execution |
| 281 | static bool mcp_query_interrupt_callback(void *data) { |
| 282 | (void)data; |
| 283 | |
| 284 | // Real implementations might check for client disconnection or timeout |
| 285 | // Here we're just returning false to indicate "no interrupt" |
| 286 | return false; |
| 287 | } |
| 288 | |
| 289 | // Removed extract_string_param and extract_size_param - now using mcp-params functions |
| 290 | |
| 291 | // Execute the metrics query |
| 292 | MCP_RETURN_CODE mcp_tool_query_metrics_execute(MCP_CLIENT *mcpc, struct json_object *params, MCP_REQUEST_ID id __maybe_unused) { |
| 293 | if (!mcpc) |
| 294 | return MCP_RC_ERROR; |
| 295 | |
| 296 | usec_t received_ut = now_monotonic_usec(); |
| 297 | |
| 298 | // Extract and validate context parameter |
| 299 | const char *context = mcp_params_extract_string(params, "metric", NULL); |
| 300 | |
| 301 | // Validate required parameters with detailed error messages |
| 302 | if (!context || !*context) { |
| 303 | buffer_sprintf(mcpc->error, "Missing required parameter 'metric'. Use the '" MCP_TOOL_LIST_METRICS "' tool to discover available metrics/contexts."); |
| 304 | return MCP_RC_BAD_REQUEST; |
| 305 | } |
| 306 | |
| 307 | // Check if context contains patterns |
| 308 | if (simple_pattern_contains_wildcards(context, SIMPLE_PATTERN_DEFAULT_WEB_SEPARATORS)) { |
| 309 | buffer_sprintf(mcpc->error, "The 'context' parameter must be an exact context name, not a pattern. " |
| 310 | "Wildcards or pattern separators are not supported. " |
| 311 | "Use the " MCP_TOOL_LIST_METRICS " tool to discover exact context names."); |
| 312 | return MCP_RC_BAD_REQUEST; |
| 313 | } |
| 314 | |
| 315 | // Check if all required parameters are provided |
| 316 | struct json_object *after_obj = NULL, *before_obj = NULL, *points_obj = NULL, *time_group_obj = NULL; |
| 317 | struct json_object *group_by_obj = NULL, *aggregation_obj = NULL, *dimensions_obj = NULL; |
| 318 | |
| 319 | if (!json_object_object_get_ex(params, "dimensions", &dimensions_obj) || !dimensions_obj) { |
| 320 | buffer_sprintf(mcpc->error, "Missing required parameter 'dimensions'. Use the '" MCP_TOOL_LIST_METRICS "' to get the list of dimensions for this metric/context."); |
| 321 | return MCP_RC_BAD_REQUEST; |
| 322 | } |
| 323 | |
| 324 | if (!json_object_object_get_ex(params, "after", &after_obj) || !after_obj) { |
| 325 | buffer_sprintf(mcpc->error, "Missing required parameter 'after'. This parameter defines the start time for your query (Unix epoch timestamp in seconds, or negative value relative to 'before', or RFC3339 datetime string)."); |
| 326 | return MCP_RC_BAD_REQUEST; |
| 327 | } |
| 328 | |
| 329 | if (!json_object_object_get_ex(params, "before", &before_obj) || !before_obj) { |
| 330 | buffer_sprintf(mcpc->error, "Missing required parameter 'before'. This parameter defines the end time for your query (Unix epoch timestamp in seconds, or negative value relative to now, or RFC3339 datetime string)."); |
| 331 | return MCP_RC_BAD_REQUEST; |
| 332 | } |
| 333 | |
| 334 | if (!json_object_object_get_ex(params, "points", &points_obj) || !points_obj) { |
| 335 | buffer_sprintf(mcpc->error, "Missing required parameter 'points'. This parameter defines how many data points to return in your result set (e.g., 60 for minute-level granularity in an hour)."); |
| 336 | return MCP_RC_BAD_REQUEST; |
| 337 | } |
| 338 | |
| 339 | if (!json_object_object_get_ex(params, "time_group", &time_group_obj) || !time_group_obj) { |
| 340 | buffer_sprintf(mcpc->error, "Missing required parameter 'time_group'. This parameter defines how to aggregate data points over time (e.g., 'average', 'min', 'max', 'sum')."); |
| 341 | return MCP_RC_BAD_REQUEST; |
| 342 | } |
| 343 | |
| 344 | if (!json_object_object_get_ex(params, "group_by", &group_by_obj) || !group_by_obj) { |
| 345 | buffer_sprintf(mcpc->error, "Missing required parameter 'group_by'. This parameter defines how to group metrics (e.g., 'dimension', 'instance', 'node', or combinations like 'dimension,node')."); |
| 346 | return MCP_RC_BAD_REQUEST; |
| 347 | } |
| 348 | |
| 349 | if (!json_object_object_get_ex(params, "aggregation", &aggregation_obj) || !aggregation_obj) { |
| 350 | buffer_sprintf(mcpc->error, "Missing required parameter 'aggregation'. This parameter defines the function to use when aggregating metrics (e.g., 'sum', 'min', 'max', 'average')."); |
| 351 | return MCP_RC_BAD_REQUEST; |
| 352 | } |
| 353 | |
| 354 | struct json_object *cardinality_limit_obj = NULL; |
| 355 | if (!json_object_object_get_ex(params, "cardinality_limit", &cardinality_limit_obj) || !cardinality_limit_obj) { |
| 356 | buffer_sprintf(mcpc->error, "Missing required parameter 'cardinality_limit'. This parameter limits the number of items returned to keep response sizes manageable (default: %d).", MCP_DATA_CARDINALITY_LIMIT); |
| 357 | return MCP_RC_BAD_REQUEST; |
| 358 | } |
| 359 | |
| 360 | // Get time_group value to check if it's a percentile or countif |
| 361 | const char *time_group_str = NULL; |
| 362 | if (json_object_is_type(time_group_obj, json_type_string)) { |
| 363 | time_group_str = json_object_get_string(time_group_obj); |
| 364 | |
| 365 | // Check if time_group_options is required based on time_group |
| 366 | if (time_group_str && ( |
| 367 | strcmp(time_group_str, "percentile") == 0 || |
| 368 | strcmp(time_group_str, "countif") == 0)) { |
| 369 | |
| 370 | struct json_object *time_group_options_obj = NULL; |
| 371 | if (!json_object_object_get_ex(params, "time_group_options", &time_group_options_obj) || !time_group_options_obj) { |
| 372 | if (strcmp(time_group_str, "percentile") == 0) { |
| 373 | buffer_sprintf(mcpc->error, "Missing required parameter 'time_group_options' when using time_group='percentile'. You must specify a percentage value between 0-100 (e.g., '95' for 95th percentile)."); |
| 374 | } else { |
| 375 | buffer_sprintf(mcpc->error, "Missing required parameter 'time_group_options' when using time_group='countif'. You must specify a comparison operator and value (e.g., '>0', '=0', '!=0', '<=10')."); |
| 376 | } |
| 377 | return MCP_RC_BAD_REQUEST; |
| 378 | } |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | // Handle nodes array parameter |
| 383 | CLEAN_BUFFER *nodes_buffer = NULL; |
| 384 | |
| 385 | nodes_buffer = mcp_params_parse_array_to_pattern(params, "nodes", false, false, MCP_TOOL_LIST_NODES, mcpc->error); |
| 386 | if (buffer_strlen(mcpc->error) > 0) { |
| 387 | return MCP_RC_BAD_REQUEST; |
| 388 | } |
| 389 | |
| 390 | // Handle instances array parameter |
| 391 | CLEAN_BUFFER *instances_buffer = NULL; |
| 392 | |
| 393 | instances_buffer = mcp_params_parse_array_to_pattern(params, "instances", false, false, MCP_TOOL_GET_METRICS_DETAILS, mcpc->error); |
| 394 | if (buffer_strlen(mcpc->error) > 0) { |
| 395 | return MCP_RC_BAD_REQUEST; |
| 396 | } |
| 397 | |
| 398 | // Handle dimensions array parameter |
| 399 | CLEAN_BUFFER *dimensions_buffer = NULL; |
| 400 | |
| 401 | dimensions_buffer = mcp_params_parse_array_to_pattern(params, "dimensions", true, false, MCP_TOOL_GET_METRICS_DETAILS, mcpc->error); |
| 402 | if (buffer_strlen(mcpc->error) > 0) { |
| 403 | buffer_strcat(mcpc->error, ". You must explicitly list every dimension you want to query. " |
| 404 | "Use the '" MCP_TOOL_GET_METRICS_DETAILS "' tool to discover available dimensions for the context."); |
| 405 | return MCP_RC_BAD_REQUEST; |
| 406 | } |
| 407 | // Handle labels - expects a structured object only |
| 408 | CLEAN_BUFFER *labels_buffer = NULL; |
| 409 | |
| 410 | labels_buffer = mcp_params_parse_labels_object(params, MCP_TOOL_GET_METRICS_DETAILS, mcpc->error); |
| 411 | if (buffer_strlen(mcpc->error) > 0) { |
| 412 | return MCP_RC_BAD_REQUEST; |
| 413 | } |
| 414 | |
| 415 | // Removed alerts parameter - not used in query_metrics |
| 416 | |
| 417 | // Time parameters - parse and validate together |
| 418 | time_t after, before; |
| 419 | if (!mcp_params_parse_time_window(params, &after, &before, |
| 420 | MCP_DEFAULT_AFTER_TIME, MCP_DEFAULT_BEFORE_TIME, |
| 421 | false, mcpc->error)) { |
| 422 | return MCP_RC_BAD_REQUEST; |
| 423 | } |
| 424 | |
| 425 | // No need to check aggregation_obj here - we already have a default in group_by struct |
| 426 | |
| 427 | // Other parameters |
| 428 | size_t points = mcp_params_extract_size(params, "points", 0, 0, SIZE_MAX, mcpc->error); |
| 429 | if (buffer_strlen(mcpc->error) > 0) { |
| 430 | return MCP_RC_BAD_REQUEST; |
| 431 | } |
| 432 | |
| 433 | size_t cardinality_limit = mcp_params_extract_size(params, "cardinality_limit", MCP_DATA_CARDINALITY_LIMIT, 1, 1000, mcpc->error); |
| 434 | if (buffer_strlen(mcpc->error) > 0) { |
| 435 | return MCP_RC_BAD_REQUEST; |
| 436 | } |
| 437 | |
| 438 | // Check if points is more than 1000 |
| 439 | if (points < 1) { |
| 440 | buffer_sprintf(mcpc->error, |
| 441 | "Too few data points requested: %zu. The minimum allowed is 1 point.", |
| 442 | points); |
| 443 | return MCP_RC_BAD_REQUEST; |
| 444 | } |
| 445 | |
| 446 | // Check if points is more than 1000 |
| 447 | if (points > 1000) { |
| 448 | buffer_sprintf(mcpc->error, |
| 449 | "Too many data points requested: %zu. The maximum allowed is 1000 points. Please reduce the 'points' parameter value to 1000 or less.\n" |
| 450 | "This limit helps reduce response size and save context space when used with AI assistants.", |
| 451 | points); |
| 452 | return MCP_RC_BAD_REQUEST; |
| 453 | } |
| 454 | |
| 455 | long timeout = mcp_params_extract_timeout(params, "timeout", 0, 0, 3600, mcpc->error); |
| 456 | if (buffer_strlen(mcpc->error) > 0) { |
| 457 | return MCP_RC_BAD_REQUEST; |
| 458 | } |
| 459 | |
| 460 | const char *options_str = mcp_params_extract_string(params, "options", NULL); |
| 461 | RRDR_OPTIONS options = 0; |
| 462 | if (options_str && *options_str) |
| 463 | options |= rrdr_options_parse(options_str); |
| 464 | |
| 465 | // Time grouping |
| 466 | RRDR_TIME_GROUPING time_group = RRDR_GROUPING_AVERAGE; |
| 467 | if (time_group_str && *time_group_str) |
| 468 | time_group = time_grouping_parse(time_group_str, RRDR_GROUPING_AVERAGE); |
| 469 | |
| 470 | const char *time_group_options = mcp_params_extract_string(params, "time_group_options", NULL); |
| 471 | |
| 472 | // Tier selection (give an invalid default to now the caller added a tier to the query) |
| 473 | size_t tier = mcp_params_extract_size(params, "tier", nd_profile.storage_tiers + 1, 0, SIZE_MAX, mcpc->error); |
| 474 | if (buffer_strlen(mcpc->error) > 0) { |
| 475 | return MCP_RC_BAD_REQUEST; |
| 476 | } |
| 477 | if (tier < nd_profile.storage_tiers) |
| 478 | options |= RRDR_OPTION_SELECTED_TIER; |
| 479 | else |
| 480 | tier = 0; |
| 481 | |
| 482 | // Group by parameters (simplified - in real implementation handle multiple passes) |
| 483 | struct group_by_pass group_by[MAX_QUERY_GROUP_BY_PASSES] = { |
| 484 | { |
| 485 | .group_by = RRDR_GROUP_BY_NONE, |
| 486 | .group_by_label = NULL, |
| 487 | .aggregation = RRDR_GROUP_BY_FUNCTION_AVERAGE, |
| 488 | }, |
| 489 | }; |
| 490 | |
| 491 | // Handle group_by array parameter and convert to comma-separated string |
| 492 | CLEAN_BUFFER *group_by_buffer = NULL; |
| 493 | const char *group_by_str = NULL; |
| 494 | |
| 495 | group_by_buffer = mcp_params_parse_array_to_pattern(params, "group_by", true, false, MCP_TOOL_GET_METRICS_DETAILS, mcpc->error); |
| 496 | if (buffer_strlen(mcpc->error) > 0) { |
| 497 | return MCP_RC_BAD_REQUEST; |
| 498 | } |
| 499 | |
| 500 | if (group_by_buffer && buffer_strlen(group_by_buffer) > 0) { |
| 501 | group_by_str = buffer_tostring(group_by_buffer); |
| 502 | group_by[0].group_by = group_by_parse(group_by_str); |
| 503 | } |
| 504 | |
| 505 | const char *group_by_label = mcp_params_extract_string(params, "group_by_label", NULL); |
| 506 | if (group_by_label && *group_by_label) { |
| 507 | group_by[0].group_by_label = (char *)group_by_label; |
| 508 | group_by[0].group_by |= RRDR_GROUP_BY_LABEL; |
| 509 | } |
| 510 | |
| 511 | const char *aggregation_str = mcp_params_extract_string(params, "aggregation", NULL); |
| 512 | if (aggregation_str && *aggregation_str) |
| 513 | group_by[0].aggregation = group_by_aggregate_function_parse(aggregation_str); |
| 514 | |
| 515 | // Create interrupt callback data |
| 516 | mcp_query_interrupt_data interrupt_data = { |
| 517 | .mcpc = mcpc, |
| 518 | .id = id |
| 519 | }; |
| 520 | |
| 521 | // Prepare a query target request |
| 522 | QUERY_TARGET_REQUEST qtr = { |
| 523 | .version = 3, |
| 524 | .scope_nodes = buffer_tostring(nodes_buffer), // Use nodes as scope_nodes |
| 525 | .scope_contexts = context, // Use the single context as scope_contexts |
| 526 | .scope_instances = buffer_tostring(instances_buffer), // Use instances as scope_instances for MCP |
| 527 | .scope_labels = buffer_tostring(labels_buffer), // Use labels as scope_labels for MCP |
| 528 | .scope_dimensions = buffer_tostring(dimensions_buffer), // Use dimensions as scope_dimensions for MCP |
| 529 | .after = after, |
| 530 | .before = before, |
| 531 | .host = NULL, |
| 532 | .st = NULL, |
| 533 | .nodes = NULL, // Don't use the 'nodes' parameter here (we use scope_nodes) |
| 534 | .contexts = NULL, // Don't use the 'contexts' parameter here (we use scope_contexts) |
| 535 | .instances = NULL, // Don't use the 'instances' parameter here (we use scope_instances) |
| 536 | .dimensions = NULL, // Don't use the 'dimensions' parameter here (we use scope_dimensions) |
| 537 | .alerts = NULL, |
| 538 | .timeout_ms = (int)(timeout * MSEC_PER_SEC), |
| 539 | .points = points, |
| 540 | .format = DATASOURCE_JSON2, |
| 541 | .options = options | |
| 542 | RRDR_OPTION_ABSOLUTE | RRDR_OPTION_JSON_WRAP | RRDR_OPTION_RETURN_JWAR | |
| 543 | RRDR_OPTION_VIRTUAL_POINTS | RRDR_OPTION_NOT_ALIGNED | RRDR_OPTION_NONZERO | |
| 544 | RRDR_OPTION_MINIFY | RRDR_OPTION_MINIMAL_STATS | RRDR_OPTION_LONG_JSON_KEYS | |
| 545 | RRDR_OPTION_MCP_INFO | RRDR_OPTION_RFC3339, |
| 546 | .time_group_method = time_group, |
| 547 | .time_group_options = time_group_options, |
| 548 | .resampling_time = 0, |
| 549 | .tier = tier, |
| 550 | .chart_label_key = NULL, |
| 551 | .labels = NULL, // Don't use labels parameter here (we use scope_labels) |
| 552 | .query_source = QUERY_SOURCE_API_DATA, |
| 553 | .priority = STORAGE_PRIORITY_NORMAL, |
| 554 | .received_ut = received_ut, |
| 555 | .cardinality_limit = cardinality_limit, |
| 556 | |
| 557 | .interrupt_callback = mcp_query_interrupt_callback, |
| 558 | .interrupt_callback_data = &interrupt_data, |
| 559 | |
| 560 | .transaction = NULL, // No transaction for MCP |
| 561 | }; |
| 562 | |
| 563 | // Copy group_by structures |
| 564 | for (size_t g = 0; g < MAX_QUERY_GROUP_BY_PASSES; g++) |
| 565 | qtr.group_by[g] = group_by[g]; |
| 566 | |
| 567 | // Create a query target |
| 568 | QUERY_TARGET *qt = query_target_create(&qtr); |
| 569 | if (!qt) { |
| 570 | buffer_sprintf(mcpc->error, "Failed to prepare the query."); |
| 571 | return MCP_RC_INTERNAL_ERROR; |
| 572 | } |
| 573 | |
| 574 | // Create a temporary buffer for the query result |
| 575 | CLEAN_BUFFER *tmp_buffer = buffer_create(0, NULL); |
| 576 | |
| 577 | // Prepare onewayalloc for query execution |
| 578 | ONEWAYALLOC *owa = onewayalloc_create(0); |
| 579 | |
| 580 | // Execute the query and get the data |
| 581 | time_t latest_timestamp = 0; |
| 582 | int ret = data_query_execute(owa, tmp_buffer, qt, &latest_timestamp); |
| 583 | |
| 584 | // Clean up |
| 585 | query_target_release(qt); |
| 586 | onewayalloc_destroy(owa); |
| 587 | |
| 588 | if (ret != HTTP_RESP_OK) { |
| 589 | const char *error_desc = "unknown error"; |
| 590 | |
| 591 | // Map common HTTP error codes to more descriptive messages |
| 592 | switch (ret) { |
| 593 | case HTTP_RESP_BAD_REQUEST: |
| 594 | error_desc = "bad request parameters"; |
| 595 | break; |
| 596 | case HTTP_RESP_NOT_FOUND: |
| 597 | error_desc = "metric/context not found"; |
| 598 | break; |
| 599 | case HTTP_RESP_GATEWAY_TIMEOUT: |
| 600 | case HTTP_RESP_SERVICE_UNAVAILABLE: |
| 601 | error_desc = "timeout or service unavailable"; |
| 602 | break; |
| 603 | case HTTP_RESP_INTERNAL_SERVER_ERROR: |
| 604 | error_desc = "internal server error"; |
| 605 | break; |
| 606 | default: |
| 607 | break; |
| 608 | } |
| 609 | |
| 610 | buffer_sprintf(mcpc->error, "Failed to execute query: %s (http error code: %d). The context '%s' might not exist, or no data is available for the specified time range.", |
| 611 | error_desc, ret, context); |
| 612 | return MCP_RC_INTERNAL_ERROR; |
| 613 | } |
| 614 | |
| 615 | // Check if instance filtering or grouping is used |
| 616 | bool using_instances = (instances_buffer && buffer_strlen(instances_buffer) > 0) || |
| 617 | (group_by[0].group_by & RRDR_GROUP_BY_INSTANCE); |
| 618 | |
| 619 | // Return the raw query engine response as-is |
| 620 | mcp_init_success_result(mcpc, id); |
| 621 | { |
| 622 | buffer_json_member_add_array(mcpc->result, "content"); |
| 623 | { |
| 624 | // Main result content |
| 625 | buffer_json_add_array_item_object(mcpc->result); |
| 626 | { |
| 627 | buffer_json_member_add_string(mcpc->result, "type", "text"); |
| 628 | buffer_json_member_add_string(mcpc->result, "text", buffer_tostring(tmp_buffer)); |
| 629 | } |
| 630 | buffer_json_object_close(mcpc->result); |
| 631 | |
| 632 | // Add a warning about potentially misleading aggregation |
| 633 | bool warn_aggregation = false; |
| 634 | // Only warn if using average without dimension grouping AND multiple dimensions selected |
| 635 | int dimensions_count = (int)json_object_array_length(dimensions_obj); |
| 636 | if (dimensions_count > 1 && |
| 637 | group_by[0].aggregation == RRDR_GROUP_BY_FUNCTION_AVERAGE && |
| 638 | !(group_by[0].group_by & RRDR_GROUP_BY_DIMENSION)) { |
| 639 | warn_aggregation = true; |
| 640 | } |
| 641 | |
| 642 | if (warn_aggregation) { |
| 643 | buffer_json_add_array_item_object(mcpc->result); |
| 644 | { |
| 645 | buffer_json_member_add_string(mcpc->result, "type", "text"); |
| 646 | buffer_json_member_add_string(mcpc->result, "text", |
| 647 | "⚠️ WARNING: Potentially Misleading Aggregation\n\n" |
| 648 | "You are using 'average' aggregation without including 'dimension' in group_by. " |
| 649 | "This means different metric types are being averaged together, which rarely produces meaningful results.\n\n" |
| 650 | "For example:\n" |
| 651 | "- For CPU metrics: averaging user, system, idle, wait states together\n" |
| 652 | "- For network metrics: averaging in/out traffic together\n" |
| 653 | "- For disk I/O: averaging reads and writes together\n\n" |
| 654 | "Check the 'aggregated' field in view.dimensions to see how many time-series were combined. " |
| 655 | "Values greater than 1 indicate multiple different metrics were averaged together.\n\n" |
| 656 | "Consider using:\n" |
| 657 | "- 'sum' aggregation for additive metrics\n" |
| 658 | "- Include 'dimension' in group_by (e.g., 'instance,dimension')\n" |
| 659 | "- Review the summary section to understand what's being aggregated"); |
| 660 | } |
| 661 | buffer_json_object_close(mcpc->result); |
| 662 | } |
| 663 | |
| 664 | // Add an instance usage warning if applicable |
| 665 | if (using_instances) { |
| 666 | buffer_json_add_array_item_object(mcpc->result); |
| 667 | { |
| 668 | buffer_json_member_add_string(mcpc->result, "type", "text"); |
| 669 | buffer_json_member_add_string(mcpc->result, "text", |
| 670 | "⚠️ Instance Usage Notice: Instance filtering/grouping behavior varies by collector type:\n\n" |
| 671 | "- **Stable instances** (systemd services, cgroups): Instance names are typically stable and match their labels. " |
| 672 | "Filtering by instance works reliably.\n\n" |
| 673 | "- **Dynamic instances** (Kubernetes pods, containers, processes): Instance names often contain random IDs or session identifiers. " |
| 674 | "Each restart creates a new instance. For these, filtering/grouping by labels is recommended to see the complete picture across all instances.\n\n" |
| 675 | "- **Detecting restarts**: Grouping by labels and examining instance counts can reveal restart patterns - " |
| 676 | "multiple instances with the same labels but different names often indicate restarts or scaling events.\n\n" |
| 677 | "Best practice: Check if your target system uses stable or dynamic instances. When in doubt, group by labels for comprehensive data, " |
| 678 | "then examine instance patterns for additional insights."); |
| 679 | } |
| 680 | buffer_json_object_close(mcpc->result); |
| 681 | } |
| 682 | } |
| 683 | buffer_json_array_close(mcpc->result); // Close content array |
| 684 | } |
| 685 | buffer_json_object_close(mcpc->result); // Close result object |
| 686 | buffer_json_finalize(mcpc->result); // Finalize the JSON |
| 687 | |
| 688 | return MCP_RC_OK; |
| 689 | } |