Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
changes:
- section: "Features Added"
description: "Added `--recommendation-type-id`, `--sub-category`, multi-value `--tracking-ids`, and `--retirement-date` filters to `azmcp advisor recommendation list`. Recommendation type IDs require canonical GUID format and intersect correctly with metadata-backed filters. Pass multiple tracking IDs as space-separated values after one option. Tracking IDs and retirement date can be used independently or together. With either filter, `--sub-category` is optional; when specified, it must be `ServiceUpgradeAndRetirement`. Metadata-backed filters now page through all Azure Resource Graph metadata results to avoid failures or incomplete matches when more than 1,000 records qualify."
- section: "Breaking Changes"
description: "Changed the `azmcp advisor recommendation list` response from the flat `resourceId`, `recommendationText`, `category`, `impact`, and `impactedResourceType` fields to an ARM-style `id`, `name`, `type`, and `properties` payload. Callers must read the impacted resource ID from `properties.resourceMetadata.resourceId`, recommendation text from `properties.shortDescription.problem`, and category and impact from `properties`."
12 changes: 9 additions & 3 deletions servers/Azure.Mcp.Server/docs/azmcp-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,16 +327,22 @@ azmcp server info
### Azure Advisor Operations

```bash
# List Advisor recommendations in a subscription, with optional server-side filters
# Only active recommendations (status 'New') are returned; dismissed and postponed ones are excluded
# List Advisor recommendations in a subscription, with optional filters
# Only active recommendations (status 'New') are returned; completed, dismissed, and postponed ones are excluded
# --tracking-ids and --retirement-date can be used independently or together
# --sub-category is optional with these filters; when specified, it must be ServiceUpgradeAndRetirement
# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp advisor recommendation list --subscription <subscription> \
[--top <top>] \
[--category <category>] \
[--impact <impact>] \
[--recommendation-type-id <guid>] \
[--resource-type <resource-type>] \
[--resource <resource>] \
[--search <search>]
[--search <search>] \
[--sub-category <sub-category>] \
[--tracking-ids <tracking-id1> <tracking-id2> ...] \
[--retirement-date <eq|lt|le|gt|ge>:<yyyy-MM-dd>]

# Summarize Advisor recommendations grouped by a chosen field (recommendation-type, category, impact, or resource-type)
# --group-by is optional and defaults to 'category' when omitted
Expand Down
26 changes: 24 additions & 2 deletions servers/Azure.Mcp.Server/docs/e2eTestPrompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,32 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat
| advisor_recommendation_list | List all recommendations in my subscription | none |
| advisor_recommendation_list | Show me Advisor recommendations in the subscription <subscription> | none |
| advisor_recommendation_list | List all Advisor recommendations in the subscription <subscription> | none |
| advisor_recommendation_list | List individual active Azure Advisor recommendation records in resource group <resource-group> in subscription <subscription> | none |
| advisor_recommendation_list | Show me high-impact Security recommendations in subscription <subscription> | none |
| advisor_recommendation_list | List Cost recommendations for storage accounts in subscription <subscription> | none |
| advisor_recommendation_list | Find Advisor recommendations mentioning "right-size" in subscription <subscription> | none |
| advisor_recommendation_list | Show me the top 10 medium-impact Advisor recommendations in subscription <subscription> | none |
| advisor_recommendation_list | List individual active Azure Advisor Cost recommendation records affecting storage accounts in subscription <subscription> | none |
| advisor_recommendation_list | Find individual active Azure Advisor recommendation records whose problem text mentions "right-size" in subscription <subscription> | none |
| advisor_recommendation_list | List individual active Azure Advisor Security recommendation records in subscription <subscription> | none |
| advisor_recommendation_list | Show me the top 10 Advisor recommendations in subscription <subscription> | none |
| advisor_recommendation_list | List active Advisor recommendations with recommendation type ID 1d70919c-1a4a-4f79-8300-bb576c291e9d in subscription <subscription> | none |
| advisor_recommendation_list | List Advisor recommendations in the ServiceUpgradeAndRetirement subcategory in subscription <subscription> | none |
| advisor_recommendation_list | Show Advisor ZoneResiliency recommendations in subscription <subscription> | none |
| advisor_recommendation_list | Show Advisor recommendations in the Reservations subcategory in subscription <subscription> | none |
| advisor_recommendation_list | List individual active Azure Advisor recommendation records and affected resources for Service Health tracking ID QNY1-HB8 in subscription <subscription> | none |
| advisor_recommendation_list | Show Advisor recommendations in subscription <subscription> for Service Health tracking IDs QNY1-HB8 and 9G0V-_G8 | none |
| advisor_recommendation_list | Show active Azure Advisor service-retirement recommendations in subscription <subscription> for Service Health tracking IDs QNY1-HB8, VN1S-1V8, and XV1P-9X8 whose retirement date is on or after September 19, 2026 | none |
| advisor_recommendation_list | List Advisor recommendations in subscription <subscription> for Service Health tracking ID QNY1-HB8 without setting a subcategory | none |
| advisor_recommendation_list | List active Azure Advisor service-retirement recommendations in subscription <subscription> whose retirement date is on or before March 31, 2027 | none |
| advisor_recommendation_list | Show active Azure Advisor service-retirement recommendations in subscription <subscription> whose retirement date is after March 31, 2027 | none |
| advisor_recommendation_list | List active Azure Advisor service-retirement recommendations in subscription <subscription> whose retirement date is on or after March 31, 2027 | none |
| advisor_recommendation_list | Find Advisor recommendations for resource <resource-id> without metadata filters | none |
| advisor_recommendation_list | Search individual active Azure Advisor recommendation records whose problem text mentions "encryption" in subscription <subscription> | none |
| advisor_recommendation_list | Show individual active Azure Advisor high-impact Security recommendation records affecting storage accounts in subscription <subscription> | none |
| advisor_recommendation_list | List the top 5 individual active Azure Advisor Cost recommendation records affecting storage accounts whose problem text mentions "encryption" in subscription <subscription> | none |
| advisor_recommendation_list | Find individual active Azure Advisor high-impact Security recommendation records whose problem text mentions "encryption" in subscription <subscription> | none |
| advisor_recommendation_list | Find individual active Azure Advisor recommendation records for resource <resource-id> matching Service Health tracking IDs QNY1-HB8 and 9G0V-_G8 | none |
| advisor_recommendation_list | Find active Advisor recommendations with recommendation type ID 1d70919c-1a4a-4f79-8300-bb576c291e9d matching Service Health tracking IDs QNY1-HB8 and 9G0V-_G8 in subscription <subscription> | none |
| advisor_recommendation_list | List Cost recommendations with resource type Microsoft.Storage/storageAccounts, resource <resource-id>, search encryption, subcategory ZoneResiliency, and top 5 in subscription <subscription> | none |
| advisor_recommendation_summary | Summarize the key themes from my Advisor recommendations in subscription <subscription> | none |
| advisor_recommendation_summary | Summarize Advisor recommendations in subscription <subscription> by category | none |
| advisor_recommendation_summary | Show the top 10 most common Advisor recommendations in subscription <subscription> | none |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.Mcp.Tools.Advisor.Commands.Metadata;
using Azure.Mcp.Tools.Advisor.Commands.Recommendation;
Expand All @@ -10,6 +11,7 @@ namespace Azure.Mcp.Tools.Advisor.Commands;
[JsonSerializable(typeof(RecommendationListCommand.RecommendationListResult))]
[JsonSerializable(typeof(RecommendationSummaryCommand.RecommendationSummaryResult))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
[JsonSerializable(typeof(RecommendationData))]
[JsonSerializable(typeof(RecommendationMetadataData))]
[JsonSerializable(typeof(RecommendationMetadataDataProperties))]
Expand All @@ -19,6 +21,9 @@ namespace Azure.Mcp.Tools.Advisor.Commands;
[JsonSerializable(typeof(RecommendationMetadataServiceRetirementData))]
[JsonSerializable(typeof(RecommendationMetadataSourceProperties))]
[JsonSerializable(typeof(Models.Recommendation))]
[JsonSerializable(typeof(global::Azure.Mcp.Tools.Advisor.Models.RecommendationProperties), TypeInfoPropertyName = "RecommendationResponseProperties")]
[JsonSerializable(typeof(global::Azure.Mcp.Tools.Advisor.Models.RecommendationResourceMetadata), TypeInfoPropertyName = "RecommendationResponseResourceMetadata")]
[JsonSerializable(typeof(Models.RecommendationShortDescription))]
[JsonSerializable(typeof(Models.RecommendationMetadata))]
[JsonSerializable(typeof(Models.RecommendationGroup))]
[JsonSerializable(typeof(Models.RecommendationSummary))]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Globalization;
using System.Net;
using Azure.Mcp.Tools.Advisor.Models;
using Azure.Mcp.Tools.Advisor.Options.Metadata;
using Azure.Mcp.Tools.Advisor.Services;
using Azure.Mcp.Tools.Advisor.Validation;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Models.Command;
Expand Down Expand Up @@ -45,7 +45,6 @@ public sealed class RecommendationMetadataListCommand(
"Performance",
"OperationalExcellence",
];
private static readonly string[] AllowedRetirementDateOperators = ["eq", "lt", "le", "gt", "ge"];
private static readonly HashSet<string> SupportedLanguages = new(StringComparer.OrdinalIgnoreCase)
{
"en", "cs", "de", "es", "fr", "hu", "id", "it", "ja", "ko",
Expand Down Expand Up @@ -84,25 +83,11 @@ public override void ValidateOptions(
$"Invalid --category value '{options.Category}'. Allowed values: {string.Join(", ", AllowedCategories)}.");
}

var hasServiceRetirementFilter =
!string.IsNullOrWhiteSpace(options.TrackingId) ||
!string.IsNullOrWhiteSpace(options.RetirementDate);
var normalizedSubCategory = options.SubCategory?.Trim();
if (hasServiceRetirementFilter &&
!string.IsNullOrWhiteSpace(normalizedSubCategory) &&
!normalizedSubCategory.Equals(
RecommendationMetadataFilters.ServiceRetirementSubCategory,
StringComparison.OrdinalIgnoreCase))
{
validationResult.Errors.Add(
"Service-retirement filters are only valid with --sub-category " +
$"{RecommendationMetadataFilters.ServiceRetirementSubCategory}.");
}

if (!TryParseRetirementDateFilter(options.RetirementDate, out _, out _, out var retirementDateError))
{
validationResult.Errors.Add(retirementDateError!);
}
ServiceRetirementFilterValidator.Validate(
validationResult,
options.SubCategory,
NormalizeTrackingIds(options.TrackingId),
options.RetirementDate);
}

public override async Task<CommandResponse> ExecuteAsync(
Expand All @@ -114,7 +99,7 @@ public override async Task<CommandResponse> ExecuteAsync(
{
_ = TryNormalizeLanguage(options.Language, out var language);
var impact = NormalizeImpact(options.Impact);
_ = TryParseRetirementDateFilter(
_ = ServiceRetirementFilterValidator.TryParseRetirementDate(
options.RetirementDate,
out var retirementDateOperator,
out var retirementDate,
Expand All @@ -125,7 +110,7 @@ public override async Task<CommandResponse> ExecuteAsync(
Impact: impact,
Category: NormalizeAllowedValue(options.Category, AllowedCategories),
SubCategory: NormalizeOptionalFilter(options.SubCategory),
TrackingId: NormalizeOptionalFilter(options.TrackingId),
TrackingIds: NormalizeTrackingIds(options.TrackingId),
RetirementDateOperator: retirementDateOperator,
RetirementDate: retirementDate);

Expand Down Expand Up @@ -202,44 +187,8 @@ private static bool TryNormalizeLanguage(string? language, out string normalized
private static string? NormalizeOptionalFilter(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();

private static bool TryParseRetirementDateFilter(
string? expression,
out string? comparisonOperator,
out DateOnly? retirementDate,
out string? error)
{
comparisonOperator = null;
retirementDate = null;
error = null;

if (string.IsNullOrWhiteSpace(expression))
{
return true;
}

var parts = expression.Split(':', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 ||
!AllowedRetirementDateOperators.Contains(parts[0], StringComparer.OrdinalIgnoreCase))
{
error = "Invalid --retirement-date value. Use '<operator>:<yyyy-MM-dd>' with operator eq, lt, le, gt, or ge; for example, --retirement-date ge:2026-03-01.";
return false;
}

if (!DateOnly.TryParseExact(
parts[1],
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsedDate))
{
error = "Invalid --retirement-date date. Use ISO date format yyyy-MM-dd, for example ge:2026-03-31.";
return false;
}

comparisonOperator = parts[0].ToLowerInvariant();
retirementDate = parsedDate;
return true;
}
private static string[]? NormalizeTrackingIds(string? trackingId) =>
NormalizeOptionalFilter(trackingId) is { } normalized ? [normalized] : null;

public sealed record RecommendationMetadataListResult(
List<Models.RecommendationMetadata> Metadata,
Expand Down
Loading
Loading