diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1e72e808dd..9f1c753617 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -22,6 +22,7 @@
+
diff --git a/servers/Azure.Mcp.Server/changelog-entries/monitor-metrics-batchquery.yml b/servers/Azure.Mcp.Server/changelog-entries/monitor-metrics-batchquery.yml
new file mode 100644
index 0000000000..5df91d7230
--- /dev/null
+++ b/servers/Azure.Mcp.Server/changelog-entries/monitor-metrics-batchquery.yml
@@ -0,0 +1,3 @@
+changes:
+ - section: Features Added
+ description: Added the `azmcp monitor metrics batchquery` command for querying Azure Monitor metrics across multiple resources in a single request.
diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md
index ca869c8ca8..4088918462 100644
--- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md
+++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md
@@ -2859,7 +2859,7 @@ azmcp insights get --scope tenant \
azmcp iothub hub get --subscription \
--resource-group \
--hub-name
-```
+```
### Azure Key Vault Operations
@@ -3292,6 +3292,36 @@ azmcp monitor metrics query --subscription \
--end-time "2024-01-01T23:59:59Z" \
--interval "PT1H" \
--aggregation "Average"
+
+# Query Azure Monitor metrics for multiple resources in a single batch request
+# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
+azmcp monitor metrics batchquery --subscription \
+ --resources \
+ --metric-namespace \
+ --metric-names \
+ [--resource-group ] \
+ [--resource-type ] \
+ [--start-time ] \
+ [--end-time ] \
+ [--interval ] \
+ [--aggregation ] \
+ [--filter ] \
+ [--order-by ] \
+ [--top ] \
+ [--max-buckets ]
+
+# Query CPU metrics across multiple storage accounts at once
+# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
+azmcp monitor metrics batchquery --subscription \
+ --resources "storageaccount1,storageaccount2,storageaccount3" \
+ --resource-group \
+ --resource-type "Microsoft.Storage/storageAccounts" \
+ --metric-namespace "Microsoft.Storage/storageAccounts" \
+ --metric-names "Transactions" \
+ --start-time "2024-01-01T00:00:00Z" \
+ --end-time "2024-01-01T23:59:59Z" \
+ --interval "PT1H" \
+ --aggregation "Total"
```
#### Web Tests (Availability Tests)
diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md
index bc0d0da564..6dc40a2d78 100644
--- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md
+++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md
@@ -866,6 +866,9 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat
| monitor_instrumentation_send-enhancement-select | Submit enhancement selection keys for Azure Monitor instrumentation session after enhancement options are presented | investigation-required |
| monitor_instrumentation_send-enhancement-select | Continue instrumentation enhancement flow by sending selected keys to session | none |
| monitor_instrumentation_send-enhancement-select | Send chosen enhancement option keys to Azure Monitor instrumentation onboarding session | none |
+| monitor_metrics_batchquery | Get the metric for storage accounts , , and over the last | none |
+| monitor_metrics_batchquery | Compare across resources and in resource group for the last | none |
+| monitor_metrics_batchquery | Query for multiple resources , in one request | none |
| monitor_metrics_definitions | Get metric definitions for from the namespace | none |
| monitor_metrics_definitions | Show me all available metrics and their definitions for storage account | none |
| monitor_metrics_definitions | What metric definitions are available for the Application Insights resource | none |
diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json
index 28fb049433..9f27a06069 100644
--- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json
+++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json
@@ -544,6 +544,7 @@
"monitor_activitylog_list",
"monitor_healthmodels_list",
"monitor_healthmodels_get",
+ "monitor_metrics_batchquery",
"monitor_metrics_definitions",
"monitor_metrics_query",
"monitor_resource_log_query",
diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj b/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj
index 3a03b666a8..39f5546962 100644
--- a/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj
+++ b/tools/Azure.Mcp.Tools.Monitor/src/Azure.Mcp.Tools.Monitor.csproj
@@ -25,6 +25,7 @@
+
diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/Metrics/MetricsBatchQueryCommand.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/Metrics/MetricsBatchQueryCommand.cs
new file mode 100644
index 0000000000..e33c709bff
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/Metrics/MetricsBatchQueryCommand.cs
@@ -0,0 +1,159 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using Azure.Mcp.Core.Commands.Subscription;
+using Azure.Mcp.Core.Services.Azure.Subscription;
+using Azure.Mcp.Tools.Monitor.Models;
+using Azure.Mcp.Tools.Monitor.Options.Metrics;
+using Azure.Mcp.Tools.Monitor.Services;
+using Microsoft.Extensions.Logging;
+using Microsoft.Mcp.Core.Commands;
+using Microsoft.Mcp.Core.Models.Command;
+
+namespace Azure.Mcp.Tools.Monitor.Commands.Metrics;
+
+///
+/// Command for querying Azure Monitor metrics across multiple resources in a single batch request
+///
+[CommandMetadata(
+ Id = "6c1b0f5f-04c1-4b2e-8f0b-0d6f4f7cba2e",
+ Name = "batchquery",
+ Title = "Query Azure Monitor Metrics for Multiple Resources",
+ Description = "Query Azure Monitor metrics for multiple resources in a single batch request. Returns time series data for the specified metrics, grouped by resource. All resources must belong to the same subscription, Azure region, and resource type.",
+ Destructive = false,
+ Idempotent = true,
+ OpenWorld = false,
+ ReadOnly = true,
+ Secret = false,
+ LocalRequired = false)]
+public sealed class MetricsBatchQueryCommand(ILogger logger, IMonitorMetricsService metricsService, ISubscriptionResolver subscriptionResolver)
+ : SubscriptionCommand(subscriptionResolver)
+{
+ private readonly ILogger _logger = logger;
+ private readonly IMonitorMetricsService _metricsService = metricsService;
+
+ public override void ValidateOptions(MetricsBatchQueryOptions options, ValidationResult validationResult)
+ {
+ base.ValidateOptions(options, validationResult);
+
+ if (string.IsNullOrWhiteSpace(options.Resources))
+ {
+ validationResult.Errors.Add($"Invalid format for '--resources'. Provide a comma-separated list of resource names or resource IDs to query (e.g. resource1,resource2).");
+ }
+ else
+ {
+ string[] resources = [.. options.Resources.Split(',').Select(t => t.Trim())];
+ if (resources.Length == 0 || resources.Any(s => string.IsNullOrWhiteSpace(s)))
+ {
+ validationResult.Errors.Add($"Invalid format for '--resources'. Provide a comma-separated list of resource names or resource IDs to query (e.g. resource1,resource2).");
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(options.MetricNames))
+ {
+ validationResult.Errors.Add($"Invalid format for '--metric-names'. Provide a comma-separated list of metric names to query (e.g. CPU,memory).");
+ }
+ else
+ {
+ string[] metricNames = [.. options.MetricNames.Split(',').Select(t => t.Trim())];
+
+ if (metricNames.Length == 0 || metricNames.Any(s => string.IsNullOrWhiteSpace(s)))
+ {
+ validationResult.Errors.Add($"Invalid format for '--metric-names'. Provide a comma-separated list of metric names to query (e.g. CPU,memory).");
+ }
+ }
+ }
+
+ public override void PostBindOptions(MetricsBatchQueryOptions options)
+ {
+ base.PostBindOptions(options);
+ options.StartTime ??= DateTime.UtcNow.AddHours(-24).ToString("o"); // Default to 24 hours ago if not specified
+ options.EndTime ??= DateTime.UtcNow.ToString("o"); // Default to now if not specified
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, MetricsBatchQueryOptions options, CancellationToken cancellationToken)
+ {
+ try
+ {
+ string[] resources = [.. options.Resources.Split(',').Select(t => t.Trim())];
+ string[] metricNames = [.. options.MetricNames.Split(',').Select(t => t.Trim())];
+
+ var results = await _metricsService.QueryMetricsBatchAsync(
+ options.Subscription!,
+ options.ResourceGroup,
+ options.ResourceType,
+ resources,
+ options.MetricNamespace,
+ metricNames,
+ options.StartTime,
+ options.EndTime,
+ options.Interval,
+ options.Aggregation,
+ options.Filter,
+ options.OrderBy,
+ options.Top,
+ options.Tenant,
+ cancellationToken);
+
+ // Validate bucket count limit
+ if (results?.Count > 0)
+ {
+ int maxBuckets = options.MaxBuckets ?? 50; // Use provided value or default to 50
+
+ foreach (var resourceResult in results)
+ {
+ foreach (var metric in resourceResult.Metrics)
+ {
+ foreach (var timeSeries in metric.TimeSeries)
+ {
+ // Check each bucket array for exceeding the limit
+ var bucketCounts = new[]
+ {
+ timeSeries.AvgBuckets?.Length ?? 0,
+ timeSeries.MinBuckets?.Length ?? 0,
+ timeSeries.MaxBuckets?.Length ?? 0,
+ timeSeries.TotalBuckets?.Length ?? 0,
+ timeSeries.CountBuckets?.Length ?? 0
+ };
+
+ int maxBucketCount = bucketCounts.Max();
+
+ if (maxBucketCount > maxBuckets)
+ {
+ string errorMessage = $"Time series for metric '{metric.Name}' on resource '{resourceResult.ResourceId}' contains {maxBucketCount} time buckets, " +
+ $"which exceeds the maximum allowed limit of {maxBuckets}. " +
+ $"To resolve this issue, either query a smaller time range, " +
+ $"increase the interval size (e.g., use PT1H instead of PT5M), " +
+ $"or increase the --max-buckets parameter.";
+
+ context.Response.Status = HttpStatusCode.BadRequest;
+ context.Response.Message = errorMessage;
+
+ _logger.LogWarning("Bucket limit exceeded. ResourceId: {ResourceId}, MetricName: {MetricName}, BucketCount: {BucketCount}, MaxBuckets: {MaxBuckets}",
+ resourceResult.ResourceId, metric.Name, maxBucketCount, maxBuckets);
+
+ return context.Response;
+ }
+ }
+ }
+ }
+ }
+
+ // Set results
+ context.Response.Results = ResponseResult.Create(new(results ?? []), MonitorJsonContext.Default.MetricsBatchQueryCommandResult);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "Error querying batch metrics. ResourceGroup: {ResourceGroup}, ResourceType: {ResourceType}, Resources: {Resources}, MetricNames: {MetricNames}.",
+ options.ResourceGroup, options.ResourceType, options.Resources, options.MetricNames);
+ HandleException(context, ex);
+ }
+
+ return context.Response;
+ }
+
+ // Strongly-typed result records
+ public sealed record MetricsBatchQueryCommandResult(List Results);
+}
diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs b/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs
index 3f0e59a315..84c2d04f35 100644
--- a/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs
+++ b/tools/Azure.Mcp.Tools.Monitor/src/Commands/MonitorJsonContext.cs
@@ -29,10 +29,13 @@ namespace Azure.Mcp.Tools.Monitor.Commands;
[JsonSerializable(typeof(HealthModelSummary))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(MetricsBatchQueryCommand.MetricsBatchQueryCommandResult))]
[JsonSerializable(typeof(MetricsDefinitionsCommand.MetricsDefinitionsCommandResult))]
[JsonSerializable(typeof(MetricsDefinitionsCommand.MetricsDefinitionsCommandResult))]
[JsonSerializable(typeof(MetricsQueryCommand.MetricsQueryCommandResult))]
[JsonSerializable(typeof(MetricsQueryCommand.MetricsQueryCommandResult))]
+[JsonSerializable(typeof(Azure.Mcp.Tools.Monitor.Models.ResourceMetricsResult))]
+[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(TableListCommand.TableListCommandResult))]
[JsonSerializable(typeof(TableTypeListCommand.TableTypeListCommandResult))]
[JsonSerializable(typeof(WebTestsCreateOrUpdateCommand.WebTestsCreateOrUpdateCommandResult))]
diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Models/ResourceMetricsResult.cs b/tools/Azure.Mcp.Tools.Monitor/src/Models/ResourceMetricsResult.cs
new file mode 100644
index 0000000000..16cc5184fa
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.Monitor/src/Models/ResourceMetricsResult.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace Azure.Mcp.Tools.Monitor.Models;
+
+///
+/// Represents the compact metric results for a single resource returned from a batch metrics query
+///
+public class ResourceMetricsResult
+{
+ ///
+ /// The resource ID the metrics were queried for
+ ///
+ [JsonPropertyName("resourceId")]
+ public string ResourceId { get; set; } = string.Empty;
+
+ ///
+ /// The compact metric results for this resource
+ ///
+ [JsonPropertyName("metrics")]
+ public List Metrics { get; set; } = [];
+}
diff --git a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs
index 732c4d6c1f..bd8f643269 100644
--- a/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs
+++ b/tools/Azure.Mcp.Tools.Monitor/src/MonitorSetup.cs
@@ -82,6 +82,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -151,6 +152,7 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider)
monitor.AddSubGroup(metrics);
metrics.AddCommand(serviceProvider);
+ metrics.AddCommand(serviceProvider);
metrics.AddCommand(serviceProvider);
var activityLog = new CommandGroup("activitylog", "Azure Monitor activity log operations - Commands for querying and analyzing activity logs for Azure resources.");
diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Options/Metrics/MetricsBatchQueryOptions.cs b/tools/Azure.Mcp.Tools.Monitor/src/Options/Metrics/MetricsBatchQueryOptions.cs
new file mode 100644
index 0000000000..3836da7e31
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.Monitor/src/Options/Metrics/MetricsBatchQueryOptions.cs
@@ -0,0 +1,92 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Mcp.Core.Options;
+using Microsoft.Mcp.Core.Options;
+
+namespace Azure.Mcp.Tools.Monitor.Options.Metrics;
+
+///
+/// Options for querying metrics for multiple resources in a single batch request
+///
+public sealed class MetricsBatchQueryOptions : ISubscriptionOption
+{
+ ///
+ /// The resources to query metrics for (required)
+ ///
+ [Option(Description = "Comma-separated list of resource names or full resource IDs to query metrics for (up to 50 resources). " +
+ "All resources must belong to the same subscription, Azure region, and resource type.")]
+ public required string Resources { get; set; }
+
+ ///
+ /// The resource type (optional, e.g., 'Microsoft.Storage/storageAccounts')
+ ///
+ [Option(Description = "The Azure resource type (e.g., 'Microsoft.Storage/storageAccounts', 'Microsoft.Compute/virtualMachines'), applied to all resources. If not specified, will attempt to infer from each resource name.")]
+ public string? ResourceType { get; set; }
+
+ [Option(Description = OptionDescriptions.ResourceGroup)]
+ public string? ResourceGroup { get; set; }
+
+ [Option(Description = OptionDescriptions.Tenant)]
+ public string? Tenant { get; set; }
+
+ [Option(Description = OptionDescriptions.Subscription)]
+ public string? Subscription { get; set; }
+
+ ///
+ /// The names of metrics to query
+ ///
+ [Option(Description = "The names of metrics to query (comma-separated).")]
+ public required string MetricNames { get; set; }
+
+ [Option(Description = MonitorOptionDescriptions.MetricNamespace)]
+ public required string MetricNamespace { get; set; }
+
+ ///
+ /// Start time for the query in ISO format
+ ///
+ [Option(Description = "The start time for the query in ISO format (e.g., 2023-01-01T00:00:00Z). Defaults to 24 hours ago.")]
+ public string? StartTime { get; set; }
+
+ ///
+ /// End time for the query in ISO format
+ ///
+ [Option(Description = "The end time for the query in ISO format (e.g., 2023-01-01T00:00:00Z). Defaults to now.")]
+ public string? EndTime { get; set; }
+
+ ///
+ /// Time interval for the query
+ ///
+ [Option(Description = "The time interval for data points (e.g., PT1H for 1 hour, PT5M for 5 minutes).")]
+ public string? Interval { get; set; }
+
+ ///
+ /// Aggregation type(s) for the metrics (Average, Maximum, Minimum, Total, Count)
+ ///
+ [Option(Description = "The aggregation type(s) to use (comma-separated, e.g., Average,Maximum).")]
+ public string? Aggregation { get; set; }
+
+ ///
+ /// OData filter for the query
+ ///
+ [Option(Description = "OData filter to apply to the metrics query.")]
+ public string? Filter { get; set; }
+
+ ///
+ /// The aggregation to use for sorting results and the direction of the sort. Only valid when '--filter' is specified.
+ ///
+ [Option(Description = "The aggregation to use for sorting results and the direction of the sort (e.g., 'total asc'). Only valid when '--filter' is specified.")]
+ public string? OrderBy { get; set; }
+
+ ///
+ /// The maximum number of records to retrieve per resource. Only valid when '--filter' is specified.
+ ///
+ [Option(Description = "The maximum number of time series to retrieve per resource per metric. Only valid when '--filter' is specified. Defaults to 10.")]
+ public int? Top { get; set; }
+
+ ///
+ /// The maximum number of time buckets to return per metric time series. Defaults to 50.
+ ///
+ [Option(Description = "The maximum number of time buckets to return per metric time series. Defaults to 50.", DefaultValue = 50)]
+ public int? MaxBuckets { get; set; }
+}
diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorMetricsService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorMetricsService.cs
index 2a7cb28827..3ecfd7a9cf 100644
--- a/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorMetricsService.cs
+++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/IMonitorMetricsService.cs
@@ -83,4 +83,41 @@ Task> ListMetricNamespacesAsync(
string? searchString = null,
string? tenant = null,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Queries metrics for multiple resources in a single batch request. All resources must belong to the same
+ /// subscription, Azure region, and resource type.
+ ///
+ /// The subscription ID
+ /// The resource group name (optional, applied to all resources)
+ /// The resource type (optional, e.g., 'Microsoft.Storage/storageAccounts', applied to all resources)
+ /// The names or resource IDs of the resources to query metrics for
+ /// Required metric namespace
+ /// List of metric names to query
+ /// Optional start time for the query in ISO format
+ /// Optional end time for the query in ISO format
+ /// Optional time interval for data points
+ /// Optional comma-separated aggregation types (Average, Maximum, Minimum, Total, Count)
+ /// Optional OData filter to apply
+ /// Optional sort order, only valid when is specified
+ /// Optional maximum number of time series to retrieve per resource per metric, only valid when is specified
+ /// Optional tenant ID for multi-tenant scenarios
+ /// Cancellation token
+ /// List of metric results per resource, with time series data
+ Task> QueryMetricsBatchAsync(
+ string subscription,
+ string? resourceGroup,
+ string? resourceType,
+ IEnumerable resources,
+ string metricNamespace,
+ IEnumerable metricNames,
+ string? startTime = null,
+ string? endTime = null,
+ string? interval = null,
+ string? aggregation = null,
+ string? filter = null,
+ string? orderBy = null,
+ int? top = null,
+ string? tenant = null,
+ CancellationToken cancellationToken = default);
}
diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs
index 9154b1002d..ad89929996 100644
--- a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs
+++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorMetricsService.cs
@@ -3,13 +3,18 @@
using System.Globalization;
using System.Xml;
+using Azure.Core;
+using Azure.Core.Pipeline;
using Azure.Mcp.Core.Services.Azure;
using Azure.Mcp.Tools.Monitor.Models;
+using Azure.Monitor.Query.Metrics;
using Azure.ResourceManager.Monitor;
using Azure.ResourceManager.Monitor.Models;
+using Microsoft.Mcp.Core.Services.Azure.Authentication;
using MetricDefinition = Azure.Mcp.Tools.Monitor.Models.MetricDefinition;
using MetricNamespace = Azure.Mcp.Tools.Monitor.Models.MetricNamespace;
using MetricResult = Azure.Mcp.Tools.Monitor.Models.MetricResult;
+using SdkMetricResult = Azure.Monitor.Query.Metrics.Models.MetricResult;
namespace Azure.Mcp.Tools.Monitor.Services;
@@ -318,6 +323,200 @@ public async Task> ListMetricNamespacesAsync(
return results;
}
+ private const int MaxBatchResources = 50;
+
+ public async Task> QueryMetricsBatchAsync(
+ string subscription,
+ string? resourceGroup,
+ string? resourceType,
+ IEnumerable resources,
+ string metricNamespace,
+ IEnumerable metricNames,
+ string? startTime = null,
+ string? endTime = null,
+ string? interval = null,
+ string? aggregation = null,
+ string? filter = null,
+ string? orderBy = null,
+ int? top = null,
+ string? tenant = null,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateRequiredParameters((nameof(subscription), subscription), (nameof(metricNamespace), metricNamespace));
+ ArgumentNullException.ThrowIfNull(resources);
+ ArgumentNullException.ThrowIfNull(metricNames);
+
+ var resourceNames = resources.Select(r => r.Trim()).Where(r => r.Length > 0).ToList();
+ if (resourceNames.Count == 0)
+ {
+ throw new ArgumentException("At least one resource must be specified.", nameof(resources));
+ }
+
+ if (resourceNames.Count > MaxBatchResources)
+ {
+ throw new ArgumentException($"A maximum of {MaxBatchResources} resources can be queried in a single batch request. Provided: {resourceNames.Count}.", nameof(resources));
+ }
+
+ // Resolve each resource name (or already-valid resource ID) to a full resource identifier.
+ var resourceIds = new List(resourceNames.Count);
+ foreach (var resourceName in resourceNames)
+ {
+ var resourceId = await _resourceResolverService.ResolveResourceIdAsync(subscription, resourceGroup, resourceType, resourceName, tenant, cancellationToken);
+ resourceIds.Add(resourceId);
+ }
+
+ var armClient = await CreateArmClientAsync(tenant, cancellationToken: cancellationToken);
+
+ // The batch metrics endpoint is regional and requires all resources to share the same region, so resolve
+ // the region from the first resource and let the service validate the rest.
+ var firstResource = await armClient.GetGenericResource(resourceIds[0]).GetAsync(cancellationToken);
+ var region = firstResource.Value.Data.Location.Name;
+
+ var credential = await GetCredential(tenant, cancellationToken);
+ var metricsClientOptions = AddDefaultPolicies(new MetricsClientOptions());
+ metricsClientOptions.Audience = GetMetricsClientAudience();
+ metricsClientOptions.Transport = new HttpClientTransport(AzureService.GetClient());
+
+ var metricsClient = new MetricsClient(new Uri($"https://{region}.{GetMetricsEndpointHostSuffix()}"), credential, metricsClientOptions);
+
+ var queryOptions = new MetricsQueryResourcesOptions
+ {
+ Filter = filter,
+ OrderBy = orderBy,
+ Size = top
+ };
+
+ if (!string.IsNullOrEmpty(startTime))
+ {
+ if (!DateTimeOffset.TryParse(startTime, out var start))
+ {
+ throw new ArgumentException($"Invalid start time format: {startTime}");
+ }
+ queryOptions.StartTime = start;
+ }
+
+ if (!string.IsNullOrEmpty(endTime))
+ {
+ if (!DateTimeOffset.TryParse(endTime, out var end))
+ {
+ throw new ArgumentException($"Invalid end time format: {endTime}");
+ }
+ queryOptions.EndTime = end;
+ }
+
+ if (!string.IsNullOrEmpty(interval))
+ {
+ try
+ {
+ queryOptions.Granularity = XmlConvert.ToTimeSpan(interval);
+ }
+ catch (Exception ex)
+ {
+ throw new ArgumentException($"Invalid interval format: {ex}.", ex);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(aggregation))
+ {
+ foreach (var agg in aggregation.Split(',').Select(a => a.Trim()).Where(a => a.Length > 0))
+ {
+ queryOptions.Aggregations.Add(agg);
+ }
+ }
+
+ var response = await metricsClient.QueryResourcesAsync(resourceIds, metricNames, metricNamespace, queryOptions, cancellationToken);
+
+ // The response values are returned in the same order as the requested resource IDs.
+ var values = response.Value.Values;
+ var results = new List(values.Count);
+ for (int i = 0; i < values.Count; i++)
+ {
+ var resourceResult = values[i];
+ var compactResource = new ResourceMetricsResult
+ {
+ ResourceId = i < resourceIds.Count ? resourceIds[i].ToString() : string.Empty,
+ Metrics = []
+ };
+
+ foreach (var metric in resourceResult.Metrics)
+ {
+ compactResource.Metrics.Add(ConvertToCompactMetricResult(metric, interval));
+ }
+
+ results.Add(compactResource);
+ }
+
+ return results;
+ }
+
+ private static MetricResult ConvertToCompactMetricResult(SdkMetricResult metric, string? interval)
+ {
+ var compactResult = new MetricResult
+ {
+ Name = metric.Name,
+ Unit = metric.Unit.ToString(),
+ TimeSeries = []
+ };
+
+ foreach (var timeSeries in metric.TimeSeries)
+ {
+ if (timeSeries.Values.Count == 0)
+ {
+ continue;
+ }
+
+ var compactTimeSeries = new MetricTimeSeries
+ {
+ Metadata = new Dictionary(timeSeries.Metadata),
+ Start = timeSeries.Values[0].TimeStamp.UtcDateTime,
+ End = timeSeries.Values[^1].TimeStamp.UtcDateTime,
+ Interval = interval ?? "PT1M"
+ };
+
+ var avgValues = timeSeries.Values.Where(v => v.Average.HasValue).Select(v => v.Average!.Value).ToArray();
+ if (avgValues.Length > 0)
+ compactTimeSeries.AvgBuckets = avgValues;
+
+ var minValues = timeSeries.Values.Where(v => v.Minimum.HasValue).Select(v => v.Minimum!.Value).ToArray();
+ if (minValues.Length > 0)
+ compactTimeSeries.MinBuckets = minValues;
+
+ var maxValues = timeSeries.Values.Where(v => v.Maximum.HasValue).Select(v => v.Maximum!.Value).ToArray();
+ if (maxValues.Length > 0)
+ compactTimeSeries.MaxBuckets = maxValues;
+
+ var totalValues = timeSeries.Values.Where(v => v.Total.HasValue).Select(v => v.Total!.Value).ToArray();
+ if (totalValues.Length > 0)
+ compactTimeSeries.TotalBuckets = totalValues;
+
+ var countValues = timeSeries.Values.Where(v => v.Count.HasValue).Select(v => v.Count!.Value).ToArray();
+ if (countValues.Length > 0)
+ compactTimeSeries.CountBuckets = countValues;
+
+ compactResult.TimeSeries.Add(compactTimeSeries);
+ }
+
+ return compactResult;
+ }
+
+ private MetricsClientAudience GetMetricsClientAudience() =>
+ AzureService.CloudConfiguration.CloudType switch
+ {
+ AzureCloudConfiguration.AzureCloud.AzurePublicCloud => MetricsClientAudience.AzurePublicCloud,
+ AzureCloudConfiguration.AzureCloud.AzureChinaCloud => MetricsClientAudience.AzureChina,
+ AzureCloudConfiguration.AzureCloud.AzureUSGovernmentCloud => MetricsClientAudience.AzureGovernment,
+ _ => MetricsClientAudience.AzurePublicCloud
+ };
+
+ private string GetMetricsEndpointHostSuffix() =>
+ AzureService.CloudConfiguration.CloudType switch
+ {
+ AzureCloudConfiguration.AzureCloud.AzurePublicCloud => "metrics.monitor.azure.com",
+ AzureCloudConfiguration.AzureCloud.AzureChinaCloud => "metrics.monitor.azure.cn",
+ AzureCloudConfiguration.AzureCloud.AzureUSGovernmentCloud => "metrics.monitor.azure.us",
+ _ => "metrics.monitor.azure.com"
+ };
+
private static string ToIsoString(DateTimeOffset dto)
{
if (dto.Offset == TimeSpan.Zero)
diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Metrics/MetricsBatchQueryCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Metrics/MetricsBatchQueryCommandTests.cs
new file mode 100644
index 0000000000..d092ed01ff
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/Metrics/MetricsBatchQueryCommandTests.cs
@@ -0,0 +1,460 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using Azure.Mcp.Tests.Commands;
+using Azure.Mcp.Tools.Monitor.Commands;
+using Azure.Mcp.Tools.Monitor.Commands.Metrics;
+using Azure.Mcp.Tools.Monitor.Models;
+using Azure.Mcp.Tools.Monitor.Services;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using Xunit;
+
+namespace Azure.Mcp.Tools.Monitor.Tests.Metrics;
+
+public class MetricsBatchQueryCommandTests : SubscriptionCommandUnitTestsBase
+{
+ #region Constructor and Properties Tests
+
+ [Fact]
+ public void Constructor_InitializesCommandCorrectly()
+ {
+ Assert.Equal("batchquery", CommandDefinition.Name);
+ Assert.Equal("Query Azure Monitor Metrics for Multiple Resources", Command.Title);
+ Assert.NotNull(Command.Description);
+ Assert.NotEmpty(Command.Description);
+ }
+
+ [Fact]
+ public void Name_ReturnsCorrectValue()
+ {
+ Assert.Equal("batchquery", Command.Name);
+ }
+
+ #endregion
+
+ #region Option Registration Tests
+
+ [Fact]
+ public void RegisterOptions_AddsAllExpectedOptions()
+ {
+ var options = CommandDefinition.Options.Select(o => o.Name).ToList();
+
+ Assert.Contains("--resource-group", options);
+ Assert.Contains("--resource-type", options);
+ Assert.Contains("--resources", options);
+ Assert.Contains("--metric-names", options);
+ Assert.Contains("--metric-namespace", options);
+ Assert.Contains("--start-time", options);
+ Assert.Contains("--end-time", options);
+ Assert.Contains("--interval", options);
+ Assert.Contains("--aggregation", options);
+ Assert.Contains("--filter", options);
+ Assert.Contains("--order-by", options);
+ Assert.Contains("--top", options);
+ Assert.Contains("--max-buckets", options);
+
+ var requiredOptions = CommandDefinition.Options.Where(o => o.Required).Select(o => o.Name).ToList();
+ Assert.Contains("--resources", requiredOptions);
+ Assert.Contains("--metric-names", requiredOptions);
+ Assert.Contains("--metric-namespace", requiredOptions);
+ }
+
+ #endregion
+
+ #region Option Binding Tests
+
+ [Fact]
+ public async Task ExecuteAsync_BindsAllOptionsCorrectly()
+ {
+ // Arrange
+ Service.QueryMetricsBatchAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns([]);
+
+ // Act
+ await ExecuteCommandAsync(
+ "--subscription", "sub1",
+ "--resource-group", "rg1",
+ "--resource-type", "Microsoft.Storage/storageAccounts",
+ "--resources", "sa1,sa2",
+ "--metric-names", "CPU,Memory",
+ "--metric-namespace", "Microsoft.Storage",
+ "--start-time", "2023-01-01T00:00:00Z",
+ "--end-time", "2023-01-02T00:00:00Z",
+ "--interval", "PT1M",
+ "--aggregation", "Average",
+ "--filter", "dimension eq 'value'",
+ "--order-by", "total asc",
+ "--top", "5",
+ "--max-buckets", "100");
+
+ // Assert
+ await Service.Received(1).QueryMetricsBatchAsync(
+ "sub1",
+ "rg1",
+ "Microsoft.Storage/storageAccounts",
+ Arg.Is>(m => m.SequenceEqual(new[] { "sa1", "sa2" })),
+ "Microsoft.Storage",
+ Arg.Is>(m => m.SequenceEqual(new[] { "CPU", "Memory" })),
+ "2023-01-01T00:00:00Z",
+ "2023-01-02T00:00:00Z",
+ "PT1M",
+ "Average",
+ "dimension eq 'value'",
+ "total asc",
+ 5,
+ null,
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_HandlesOptionalParameters()
+ {
+ // Arrange
+ Service.QueryMetricsBatchAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns([]);
+
+ // Act
+ await ExecuteCommandAsync(
+ "--subscription", "sub1",
+ "--resources", "sa1",
+ "--metric-names", "CPU",
+ "--metric-namespace", "microsoft.compute/virtualmachines");
+
+ // Assert
+ await Service.Received(1).QueryMetricsBatchAsync(
+ Arg.Is(t => t == "sub1"),
+ Arg.Is(t => t == null),
+ Arg.Is(t => t == null),
+ Arg.Is>(m => m.SequenceEqual(new[] { "sa1" })),
+ Arg.Is(t => t == "microsoft.compute/virtualmachines"),
+ Arg.Is>(m => m.SequenceEqual(new[] { "CPU" })),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Is(t => t == null),
+ Arg.Is(t => t == null),
+ Arg.Is(t => t == null),
+ Arg.Is(t => t == null),
+ Arg.Is(t => t == null),
+ Arg.Is(t => t == null),
+ Arg.Any());
+ }
+
+ #endregion
+
+ #region Validation Tests
+
+ [Theory]
+ [InlineData("sa1", true)]
+ [InlineData("sa1,sa2", true)]
+ [InlineData("sa1, sa2, sa3", true)]
+ [InlineData(",", false)]
+ [InlineData("sa1,", false)]
+ [InlineData(",sa1", false)]
+ public async Task Validate_Resources_ValidatesCorrectly(string resources, bool shouldBeValid)
+ {
+ // Arrange & Act
+ var result = await ExecuteCommandAsync(
+ "--subscription", "sub1",
+ "--resources", resources,
+ "--metric-namespace", "microsoft.compute/virtualmachines",
+ "--metric-names", "CPU");
+
+ // Assert
+ if (!shouldBeValid)
+ {
+ Assert.NotNull(result.Message);
+ Assert.Contains("Invalid format for '--resources'", result.Message);
+ Assert.Equal(HttpStatusCode.BadRequest, result.Status);
+ }
+ else
+ {
+ Assert.Equal("Success", result.Message);
+ Assert.Equal(HttpStatusCode.OK, result.Status);
+ }
+ }
+
+ [Theory]
+ [InlineData("CPU", true)]
+ [InlineData("CPU,Memory", true)]
+ [InlineData(",", false)]
+ [InlineData("CPU,", false)]
+ public async Task Validate_MetricNames_ValidatesCorrectly(string metricNames, bool shouldBeValid)
+ {
+ // Arrange & Act
+ var result = await ExecuteCommandAsync(
+ "--subscription", "sub1",
+ "--resources", "sa1",
+ "--metric-namespace", "microsoft.compute/virtualmachines",
+ "--metric-names", metricNames);
+
+ // Assert
+ if (!shouldBeValid)
+ {
+ Assert.NotNull(result.Message);
+ Assert.Contains("Invalid format for '--metric-names'", result.Message);
+ Assert.Equal(HttpStatusCode.BadRequest, result.Status);
+ }
+ else
+ {
+ Assert.Equal("Success", result.Message);
+ Assert.Equal(HttpStatusCode.OK, result.Status);
+ }
+ }
+
+ #endregion
+
+ #region ExecuteAsync Tests - Success Scenarios
+
+ [Fact]
+ public async Task ExecuteAsync_ValidInput_ReturnsSuccess()
+ {
+ // Arrange
+ var expectedResults = new List
+ {
+ new()
+ {
+ ResourceId = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/sa1",
+ Metrics =
+ [
+ new()
+ {
+ Name = "CPU",
+ Unit = "Percent",
+ TimeSeries =
+ [
+ new()
+ {
+ Metadata = [],
+ Start = DateTime.UtcNow.AddHours(-1),
+ End = DateTime.UtcNow,
+ Interval = "PT1M",
+ AvgBuckets = [45.5, 50.2, 48.1]
+ }
+ ]
+ }
+ ]
+ }
+ };
+
+ Service.QueryMetricsBatchAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(expectedResults);
+
+ // Act
+ var response = await ExecuteCommandAsync(
+ "--subscription", "sub1",
+ "--resources", "sa1",
+ "--metric-names", "CPU",
+ "--metric-namespace", "microsoft.compute/virtualmachines");
+
+ // Assert
+ var results = ValidateAndDeserializeResponse(response, MonitorJsonContext.Default.MetricsBatchQueryCommandResult);
+ Assert.Single(results.Results);
+ var resourceResult = results.Results[0];
+ Assert.Equal(expectedResults[0].ResourceId, resourceResult.ResourceId);
+ Assert.Single(resourceResult.Metrics);
+ Assert.Equal("CPU", resourceResult.Metrics[0].Name);
+ Assert.Equal([45.5, 50.2, 48.1], resourceResult.Metrics[0].TimeSeries[0].AvgBuckets!);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_EmptyResults_ReturnsSuccessWithEmptyResults()
+ {
+ // Arrange
+ Service.QueryMetricsBatchAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns([]);
+
+ // Act
+ var response = await ExecuteCommandAsync(
+ "--subscription", "sub1",
+ "--resources", "sa1",
+ "--metric-names", "CPU",
+ "--metric-namespace", "microsoft.compute/virtualmachines");
+
+ // Assert
+ var results = ValidateAndDeserializeResponse(response, MonitorJsonContext.Default.MetricsBatchQueryCommandResult);
+ Assert.Empty(results.Results);
+ }
+
+ #endregion
+
+ #region ExecuteAsync Tests - Validation Failures
+
+ [Theory]
+ [InlineData("--subscription sub1 --metric-names CPU --metric-namespace microsoft.compute/virtualmachines")] // Missing resources
+ [InlineData("--subscription sub1 --resources sa1 --metric-namespace microsoft.compute/virtualmachines")] // Missing metric-names
+ [InlineData("--subscription sub1 --resources sa1 --metric-names CPU")] // Missing metric-namespace
+ public async Task ExecuteAsync_InvalidInput_ReturnsBadRequest(string args)
+ {
+ // Arrange & Act
+ var response = await ExecuteCommandAsync(args);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.Status);
+ Assert.NotEmpty(response.Message);
+ Assert.Null(response.Results);
+ }
+
+ #endregion
+
+ #region ExecuteAsync Tests - Bucket Limit Validation
+
+ [Fact]
+ public async Task ExecuteAsync_ExceedsBucketLimit_ReturnsBadRequest()
+ {
+ // Arrange
+ var resultsWithTooManyBuckets = new List
+ {
+ new()
+ {
+ ResourceId = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/sa1",
+ Metrics =
+ [
+ new()
+ {
+ Name = "CPU",
+ Unit = "Percent",
+ TimeSeries =
+ [
+ new()
+ {
+ Metadata = [],
+ Start = DateTime.UtcNow.AddHours(-1),
+ End = DateTime.UtcNow,
+ Interval = "PT1M",
+ AvgBuckets = new double[51] // Exceeds default limit of 50
+ }
+ ]
+ }
+ ]
+ }
+ };
+
+ Service.QueryMetricsBatchAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(resultsWithTooManyBuckets);
+
+ // Act
+ var response = await ExecuteCommandAsync(
+ "--subscription", "sub1",
+ "--resources", "sa1",
+ "--metric-names", "CPU",
+ "--metric-namespace", "microsoft.compute/virtualmachines");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.Status);
+ Assert.Contains("exceeds the maximum allowed limit", response.Message);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ServiceThrowsException_HandlesError()
+ {
+ // Arrange
+ Service.QueryMetricsBatchAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .ThrowsAsync(new InvalidOperationException("Test error"));
+
+ // Act
+ var response = await ExecuteCommandAsync(
+ "--subscription", "sub1",
+ "--resources", "sa1",
+ "--metric-names", "CPU",
+ "--metric-namespace", "microsoft.compute/virtualmachines");
+
+ // Assert
+ Assert.NotEqual(HttpStatusCode.OK, response.Status);
+ Assert.NotNull(response.Message);
+ }
+
+ #endregion
+}
diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs
index 796f675d9b..d664b7b82c 100644
--- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs
+++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs
@@ -19,6 +19,7 @@ public sealed class MonitorCommandTests(ITestOutputHelper output, TestProxyFixtu
private string? _bingWebTestName;
private string? _healthModelParentName;
private string? _healthModelChildName;
+ private string? _storageAccountName;
private static readonly string[] s_validHealthStates =
[
@@ -85,6 +86,7 @@ public override async ValueTask InitializeAsync()
_bingWebTestName = $"{Settings.ResourceBaseName}-bing-test";
_healthModelParentName = $"{Settings.ResourceBaseName}-hm-a";
_healthModelChildName = $"{Settings.ResourceBaseName}-hm-b";
+ _storageAccountName = $"{Settings.ResourceBaseName}mon";
}
// [Fact]
@@ -491,6 +493,79 @@ public override async ValueTask InitializeAsync()
// }
// }
+ #region Metrics Integration Tests
+
+ [Fact]
+ public async Task Should_Batch_Query_Metrics()
+ {
+ // The command defaults start/end time to "now" when not supplied, which would produce a different
+ // query string URI on every run and break playback matching. Registering the computed values as
+ // variables pins the values used at record time so they're replayed exactly during playback.
+ var startTime = RegisterOrRetrieveVariable("startTime", DateTime.UtcNow.AddHours(-24).ToString("o"));
+ var endTime = RegisterOrRetrieveVariable("endTime", DateTime.UtcNow.ToString("o"));
+
+ var result = await CallToolAsync(
+ "monitor_metrics_batchquery",
+ new()
+ {
+ { "subscription", Settings.SubscriptionId },
+ { "resource-group", Settings.ResourceGroupName },
+ { "resource-type", "Microsoft.Storage/storageAccounts" },
+ { "resources", _storageAccountName },
+ { "metric-namespace", "Microsoft.Storage/storageAccounts" },
+ { "metric-names", "UsedCapacity" },
+ { "start-time", startTime },
+ { "end-time", endTime }
+ });
+
+ var resultsArray = result.AssertProperty("results");
+ Assert.Equal(JsonValueKind.Array, resultsArray.ValueKind);
+ var resources = resultsArray.EnumerateArray().ToList();
+ Assert.NotEmpty(resources);
+
+ var firstResource = resources[0];
+
+ var resourceId = firstResource.AssertProperty("resourceId");
+ Assert.Equal(JsonValueKind.String, resourceId.ValueKind);
+ Assert.Contains(_storageAccountName!, resourceId.GetString(), StringComparison.OrdinalIgnoreCase);
+
+ var metrics = firstResource.AssertProperty("metrics");
+ Assert.Equal(JsonValueKind.Array, metrics.ValueKind);
+ var metricsList = metrics.EnumerateArray().ToList();
+ Assert.NotEmpty(metricsList);
+
+ var firstMetric = metricsList[0];
+
+ var name = firstMetric.AssertProperty("name");
+ Assert.Equal(JsonValueKind.String, name.ValueKind);
+ Assert.Equal("UsedCapacity", name.GetString());
+
+ var unit = firstMetric.AssertProperty("unit");
+ Assert.Equal(JsonValueKind.String, unit.ValueKind);
+ Assert.False(string.IsNullOrEmpty(unit.GetString()));
+
+ var timeSeries = firstMetric.AssertProperty("timeSeries");
+ Assert.Equal(JsonValueKind.Array, timeSeries.ValueKind);
+ var timeSeriesList = timeSeries.EnumerateArray().ToList();
+ Assert.NotEmpty(timeSeriesList);
+
+ var firstTimeSeries = timeSeriesList[0];
+
+ var start = firstTimeSeries.AssertProperty("start");
+ Assert.Equal(JsonValueKind.String, start.ValueKind);
+ Assert.True(DateTime.TryParse(start.GetString(), out _));
+
+ var end = firstTimeSeries.AssertProperty("end");
+ Assert.Equal(JsonValueKind.String, end.ValueKind);
+ Assert.True(DateTime.TryParse(end.GetString(), out _));
+
+ var interval = firstTimeSeries.AssertProperty("interval");
+ Assert.Equal(JsonValueKind.String, interval.ValueKind);
+ Assert.StartsWith("PT", interval.GetString());
+ }
+
+ #endregion
+
#region WebTests Integration Tests
[Fact]
diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/assets.json b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/assets.json
index 7854447b75..3eb1d14ebe 100644
--- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/assets.json
+++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/assets.json
@@ -2,5 +2,5 @@
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "",
"TagPrefix": "Azure.Mcp.Tools.Monitor.Tests",
- "Tag": "Azure.Mcp.Tools.Monitor.Tests_5c8c2c1c62"
+ "Tag": "Azure.Mcp.Tools.Monitor.Tests_42583a5140"
}