From 7dccb4add6ea3e3492124e58b187ed53d558439c Mon Sep 17 00:00:00 2001 From: francesco1501 Date: Thu, 7 May 2026 13:32:24 +0000 Subject: [PATCH 1/2] Add CostManagement query tool Adds a new Azure.Mcp.Tools.CostManagement toolset with the first command 'azmcp costmanagement query run' that retrieves actual Azure costs and usage from the Azure Cost Management Query/Usage API for a subscription or resource group. Features: - Predefined timeframes (MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate) and Custom range with --from/--to - Optional Daily/Monthly granularity - Grouping by a single Azure dimension (ServiceName, ResourceGroupName, ResourceLocation, ResourceId, MeterCategory, MeterSubCategory, ChargeType, BillingPeriod) - Backed by Azure.ResourceManager.CostManagement 1.0.3 (GA) Includes: - 30 unit tests (10 command + 20 service helpers) - LiveTests project scaffolding (assets.json with empty Tag for maintainer to populate at merge) - Full CONTRIBUTING.md compliance: AOT-compatible, internal record, GetStatusCode override, JsonSerializerContext, sealed setup, etc. Closes the gap tracked in #1420 (FinOps recommenders) and supersedes the abandoned PR #447 (CostManagement Tool File Structure). --- .github/CODEOWNERS | 6 + .vscode/cspell.json | 1 + Directory.Packages.props | 1 + Microsoft.Mcp.slnx | 8 + .../Azure.Mcp.Server/Azure.Mcp.Server.slnx | 8 + servers/Azure.Mcp.Server/README.md | 7 + ...francesco1501-add-costmanagement-query.yml | 3 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 15 + .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 15 + servers/Azure.Mcp.Server/src/Program.cs | 1 + .../src/Resources/consolidated-tools.json | 33 +++ .../src/AssemblyInfo.cs | 7 + .../src/Azure.Mcp.Tools.CostManagement.csproj | 20 ++ .../src/Commands/BaseCostManagementCommand.cs | 32 +++ .../src/Commands/CostManagementJsonContext.cs | 16 ++ .../src/Commands/Query/QueryRunCommand.cs | 142 ++++++++++ .../src/CostManagementSetup.cs | 38 +++ .../src/GlobalUsings.cs | 4 + .../src/Models/CostQueryResult.cs | 14 + .../src/Models/CostQueryRow.cs | 9 + .../src/Models/QueryGranularity.cs | 11 + .../src/Models/QueryTimeframe.cs | 20 ++ .../src/Options/BaseCostManagementOptions.cs | 8 + .../CostManagementOptionDefinitions.cs | 57 ++++ .../src/Options/Query/QueryRunOptions.cs | 15 + .../src/Services/CostManagementService.cs | 256 ++++++++++++++++++ .../src/Services/ICostManagementService.cs | 26 ++ ....Mcp.Tools.CostManagement.LiveTests.csproj | 17 ++ .../QueryRunCommandTests.cs | 79 ++++++ .../assets.json | 6 + ....Mcp.Tools.CostManagement.UnitTests.csproj | 17 ++ .../Query/QueryRunCommandTests.cs | 210 ++++++++++++++ .../Services/CostManagementServiceTests.cs | 170 ++++++++++++ .../tests/test-resources-post.ps1 | 15 + .../tests/test-resources.bicep | 15 + 35 files changed, 1302 insertions(+) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/francesco1501-add-costmanagement-query.yml create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/AssemblyInfo.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Azure.Mcp.Tools.CostManagement.csproj create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Commands/BaseCostManagementCommand.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Commands/CostManagementJsonContext.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Commands/Query/QueryRunCommand.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/CostManagementSetup.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/GlobalUsings.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Models/CostQueryResult.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Models/CostQueryRow.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Options/BaseCostManagementOptions.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Options/CostManagementOptionDefinitions.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Options/Query/QueryRunOptions.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/src/Services/ICostManagementService.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/Azure.Mcp.Tools.CostManagement.LiveTests.csproj create mode 100644 tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/QueryRunCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/assets.json create mode 100644 tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Azure.Mcp.Tools.CostManagement.UnitTests.csproj create mode 100644 tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Query/QueryRunCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Services/CostManagementServiceTests.cs create mode 100644 tools/Azure.Mcp.Tools.CostManagement/tests/test-resources-post.ps1 create mode 100644 tools/Azure.Mcp.Tools.CostManagement/tests/test-resources.bicep diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 531013a2bf..7f88eb8655 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -191,6 +191,12 @@ # ServiceLabel: %tools-Core # ServiceOwners: @alzimmermsft @anannya03 @anuchandy @g2vinay @xiangyan99 @microsoft/azure-mcp +# PRLabel: %tools-CostManagement +/tools/Azure.Mcp.Tools.CostManagement/ @francesco1501 @microsoft/azure-mcp + +# ServiceLabel: %tools-CostManagement +# ServiceOwners: @francesco1501 + # PRLabel: %tools-CosmosDB /tools/Azure.Mcp.Tools.Cosmos/ @sajeetharan @xiangyan99 @microsoft/azure-mcp diff --git a/.vscode/cspell.json b/.vscode/cspell.json index c320390471..6d756c51b1 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -381,6 +381,7 @@ "conv", "copilotmd", "cosell", + "costmanagement", "csdevkit", "cslschema", "cvzf", diff --git a/Directory.Packages.props b/Directory.Packages.props index 530f3816a8..e3c4259354 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,6 +30,7 @@ + diff --git a/Microsoft.Mcp.slnx b/Microsoft.Mcp.slnx index 0b94242f82..b80f4e484f 100644 --- a/Microsoft.Mcp.slnx +++ b/Microsoft.Mcp.slnx @@ -223,6 +223,14 @@ + + + + + + + + diff --git a/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx b/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx index 4c49c99a78..c24ac200b4 100644 --- a/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx +++ b/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx @@ -177,6 +177,14 @@ + + + + + + + + diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 70c79df339..b175d6deb7 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1041,6 +1041,12 @@ Example prompts that generate Azure CLI commands: * "Show me my container registries in the 'my-resource-group' resource group" * "List all my Azure Container Registry repositories" +### 💰 Azure Cost Management + +* "How much did we spend this month on subscription 'sub-name'?" +* "Show last month's Azure costs grouped by service" +* "Daily cost breakdown for resource group 'rg-prod' between 2026-04-01 and 2026-04-30" + ### 📊 Azure Cosmos DB * "Show me all my Cosmos DB databases" @@ -1195,6 +1201,7 @@ The Azure MCP Server provides tools for interacting with **43+ Azure service are - �🔐 **Azure Confidential Ledger** - Tamper-proof ledger operations - 📦 **Azure Container Apps** - Container hosting - 📦 **Azure Container Registry (ACR)** - Container registry management +- 💰 **Azure Cost Management** - Query actual Azure costs and usage by subscription, resource group, time range, and dimension - 📊 **Azure Cosmos DB** - NoSQL database operations - 🧮 **Azure Data Explorer** - Analytics queries and KQL - 🐬 **Azure Database for MySQL** - MySQL database management diff --git a/servers/Azure.Mcp.Server/changelog-entries/francesco1501-add-costmanagement-query.yml b/servers/Azure.Mcp.Server/changelog-entries/francesco1501-add-costmanagement-query.yml new file mode 100644 index 0000000000..58ad844759 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/francesco1501-add-costmanagement-query.yml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added `azmcp costmanagement query run` tool to retrieve actual Azure costs and usage from the Azure Cost Management Query/Usage API for a subscription or resource group, with support for predefined timeframes (MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate) and a custom range, optional Daily/Monthly granularity, and grouping by a single dimension (ServiceName, ResourceGroupName, ResourceLocation, ResourceId, MeterCategory, MeterSubCategory, ChargeType, BillingPeriod). Closes the gap tracked in #1420 and supersedes #447." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 2ecee91355..6d0482cf02 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -1929,6 +1929,21 @@ azmcp acr registry repository list --subscription \ --registry ``` +### Azure Cost Management Operations + +```bash +# Query actual Azure costs for a subscription or resource group over a time period. +# Defaults to MonthToDate timeframe and a single aggregated total. +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp costmanagement query run --subscription \ + [--resource-group ] \ + [--timeframe ] \ + [--from ] \ + [--to ] \ + [--granularity ] \ + [--group-by ] +``` + ### Azure Cosmos DB Operations ```bash diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 176706dcab..6277aac6f5 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -334,6 +334,21 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | confidentialledger_entries_get | Get entry from Confidential Ledger for transaction on ledger | | confidentialledger_entries_get | Get transaction from ledger | +## Azure Cost Management + +| Tool Name | Test Prompt | +|:----------|:----------| +| costmanagement_query_run | How much did we spend this month on subscription ? | +| costmanagement_query_run | What are my Azure costs month-to-date for subscription , broken down by service? | +| costmanagement_query_run | Show me last month's Azure spend on subscription grouped by resource group | +| costmanagement_query_run | What are the top resource groups by cost in subscription last billing month? | +| costmanagement_query_run | Give me a daily breakdown of costs for resource group in subscription for the last month | +| costmanagement_query_run | How much did we spend in from 2026-04-01 to 2026-04-30? | +| costmanagement_query_run | Show monthly Azure costs for subscription grouped by ServiceName | +| costmanagement_query_run | Compare this billing month vs the last billing month for subscription | +| costmanagement_query_run | Which services drove the most cost in week-to-date? | +| costmanagement_query_run | Get cost by ResourceLocation for subscription month-to-date | + ## Azure Cosmos DB | Tool Name | Test Prompt | diff --git a/servers/Azure.Mcp.Server/src/Program.cs b/servers/Azure.Mcp.Server/src/Program.cs index 69369d1099..9498ae770f 100644 --- a/servers/Azure.Mcp.Server/src/Program.cs +++ b/servers/Azure.Mcp.Server/src/Program.cs @@ -167,6 +167,7 @@ private static IAreaSetup[] RegisterAreas() new Azure.Mcp.Tools.Compute.ComputeSetup(), new Azure.Mcp.Tools.ConfidentialLedger.ConfidentialLedgerSetup(), new Azure.Mcp.Tools.ContainerApps.ContainerAppsSetup(), + new Azure.Mcp.Tools.CostManagement.CostManagementSetup(), new Azure.Mcp.Tools.EventHubs.EventHubsSetup(), new Azure.Mcp.Tools.FileShares.FileSharesSetup(), new Azure.Mcp.Tools.FoundryExtensions.FoundryExtensionsSetup(), diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 4b184f6077..850799552d 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -769,6 +769,39 @@ "pricing_get" ] }, + { + "name": "query_azure_costs", + "description": "Query actual Azure costs and usage data for a subscription or resource group over a time period. Returns aggregated costs in the billing currency. Supports common predefined timeframes (MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate) and a Custom range. Optional Daily/Monthly granularity and grouping by a single dimension (ServiceName, ResourceGroupName, ResourceLocation, ResourceId, MeterCategory, MeterSubCategory, ChargeType, BillingPeriod). Caller must have 'Cost Management Reader' or 'Reader' role on the scope.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "costmanagement_query_run" + ] + }, { "name": "generate_azure_cli_commands", "description": "Generate Azure CLI commands from natural language descriptions to help answer questions about Azure environments and operations", diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/AssemblyInfo.cs b/tools/Azure.Mcp.Tools.CostManagement/src/AssemblyInfo.cs new file mode 100644 index 0000000000..7ff556e280 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.CostManagement.UnitTests")] +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.CostManagement.LiveTests")] diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Azure.Mcp.Tools.CostManagement.csproj b/tools/Azure.Mcp.Tools.CostManagement/src/Azure.Mcp.Tools.CostManagement.csproj new file mode 100644 index 0000000000..7abebfb10f --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Azure.Mcp.Tools.CostManagement.csproj @@ -0,0 +1,20 @@ + + + true + + + + + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Commands/BaseCostManagementCommand.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Commands/BaseCostManagementCommand.cs new file mode 100644 index 0000000000..f2104b504a --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Commands/BaseCostManagementCommand.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Tools.CostManagement.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.CostManagement.Commands; + +public abstract class BaseCostManagementCommand< + [DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions>(ILogger> logger) + : SubscriptionCommand where TOptions : BaseCostManagementOptions, new() +{ + protected readonly ILogger> _logger = logger; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); + } + + protected override TOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + return options; + } +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Commands/CostManagementJsonContext.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Commands/CostManagementJsonContext.cs new file mode 100644 index 0000000000..1974c6938d --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Commands/CostManagementJsonContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Tools.CostManagement.Commands.Query; +using Azure.Mcp.Tools.CostManagement.Models; + +namespace Azure.Mcp.Tools.CostManagement.Commands; + +[JsonSerializable(typeof(QueryRunCommand.QueryRunCommandResult))] +[JsonSerializable(typeof(CostQueryResult))] +[JsonSerializable(typeof(CostQueryRow))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal partial class CostManagementJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Commands/Query/QueryRunCommand.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Commands/Query/QueryRunCommand.cs new file mode 100644 index 0000000000..7df523d3e1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Commands/Query/QueryRunCommand.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tools.CostManagement.Models; +using Azure.Mcp.Tools.CostManagement.Options; +using Azure.Mcp.Tools.CostManagement.Options.Query; +using Azure.Mcp.Tools.CostManagement.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.CostManagement.Commands.Query; + +[CommandMetadata( + Id = "f7c4b3a8-9e62-4d18-bc41-2a5d8e6f1b09", + Name = "run", + Title = "Query Azure Costs", + Description = """ + Query actual Azure costs and usage data for a subscription or resource group over a time period. + Returns aggregated costs in the billing currency, optionally broken down by day/month and grouped by + a single Azure dimension. Caller must have 'Cost Management Reader' or 'Reader' on the scope. + """, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class QueryRunCommand(ILogger logger, ICostManagementService costManagementService) + : BaseCostManagementCommand(logger) +{ + private readonly ICostManagementService _costManagementService = costManagementService; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(CostManagementOptionDefinitions.Timeframe); + command.Options.Add(CostManagementOptionDefinitions.From); + command.Options.Add(CostManagementOptionDefinitions.To); + command.Options.Add(CostManagementOptionDefinitions.Granularity); + command.Options.Add(CostManagementOptionDefinitions.GroupBy); + + command.Validators.Add(result => + { + var timeframe = result.GetValue(CostManagementOptionDefinitions.Timeframe); + if (timeframe == QueryTimeframe.Custom) + { + var from = result.GetValue(CostManagementOptionDefinitions.From); + var to = result.GetValue(CostManagementOptionDefinitions.To); + if (from is null || to is null) + { + result.AddError( + "When --timeframe is Custom, both --from and --to must be provided (ISO-8601 dates, UTC)."); + } + else if (from > to) + { + result.AddError("--from must be earlier than or equal to --to."); + } + } + }); + } + + protected override QueryRunOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Timeframe = parseResult.GetValue(CostManagementOptionDefinitions.Timeframe); + options.From = parseResult.GetValue(CostManagementOptionDefinitions.From); + options.To = parseResult.GetValue(CostManagementOptionDefinitions.To); + options.Granularity = parseResult.GetValue(CostManagementOptionDefinitions.Granularity); + options.GroupBy = parseResult.GetValue(CostManagementOptionDefinitions.GroupBy); + return options; + } + + public override async Task ExecuteAsync( + CommandContext context, + ParseResult parseResult, + CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var timeframe = options.Timeframe ?? QueryTimeframe.MonthToDate; + var granularity = options.Granularity ?? QueryGranularity.None; + + var result = await _costManagementService.QueryAsync( + subscription: options.Subscription!, + resourceGroup: options.ResourceGroup, + timeframe: timeframe, + from: options.From, + to: options.To, + granularity: granularity, + groupBy: options.GroupBy, + tenant: options.Tenant, + retryPolicy: options.RetryPolicy, + cancellationToken: cancellationToken); + + context.Response.Results = ResponseResult.Create( + new QueryRunCommandResult(result), + CostManagementJsonContext.Default.QueryRunCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error querying Cost Management. Subscription: {Subscription}, ResourceGroup: {ResourceGroup}, Timeframe: {Timeframe}, Granularity: {Granularity}, GroupBy: {GroupBy}.", + options.Subscription, options.ResourceGroup, options.Timeframe, options.Granularity, options.GroupBy); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Cost data not found. Verify the subscription and resource group exist and are accessible.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + $"Authorization failed querying Cost Management. The caller needs the 'Cost Management Reader' or 'Reader' role on the subscription or resource group. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.TooManyRequests => + $"Cost Management API throttled the request. Retry after the duration specified in the 'x-ms-ratelimit-microsoft.consumption-retry-after' response header. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.BadRequest => + $"Cost Management API rejected the query. Common causes: unsupported --group-by dimension; --timeframe value not allowed at this scope (subscription/resource group accept MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate, Custom); --from later than --to; date range too large. Details: {reqEx.Message}", + RequestFailedException reqEx => reqEx.Message, + ArgumentException argEx => argEx.Message, + _ => base.GetErrorMessage(ex) + }; + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + RequestFailedException reqEx => (HttpStatusCode)reqEx.Status, + ArgumentException => HttpStatusCode.BadRequest, + _ => base.GetStatusCode(ex) + }; + + internal record QueryRunCommandResult(CostQueryResult Result); +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/CostManagementSetup.cs b/tools/Azure.Mcp.Tools.CostManagement/src/CostManagementSetup.cs new file mode 100644 index 0000000000..7674489c27 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/CostManagementSetup.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.CostManagement.Commands.Query; +using Azure.Mcp.Tools.CostManagement.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Mcp.Core.Areas; +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.CostManagement; + +public sealed class CostManagementSetup : IAreaSetup +{ + public string Name => "costmanagement"; + + public string Title => "Azure Cost Management"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + var costmanagement = new CommandGroup( + Name, + "Azure Cost Management operations - query actual Azure costs and usage by subscription or resource group.", + Title); + + var query = new CommandGroup("query", "Cost queries."); + costmanagement.AddSubGroup(query); + + query.AddCommand(serviceProvider); + + return costmanagement; + } +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/GlobalUsings.cs b/tools/Azure.Mcp.Tools.CostManagement/src/GlobalUsings.cs new file mode 100644 index 0000000000..b41cc886b4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using System.CommandLine; diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Models/CostQueryResult.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Models/CostQueryResult.cs new file mode 100644 index 0000000000..b270b50a95 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Models/CostQueryResult.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.CostManagement.Models; + +public record CostQueryResult( + string? Currency, + decimal TotalCost, + string Timeframe, + DateTime? FromDate, + DateTime? ToDate, + string Granularity, + string? GroupBy, + IReadOnlyList Rows); diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Models/CostQueryRow.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Models/CostQueryRow.cs new file mode 100644 index 0000000000..bb7ae640de --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Models/CostQueryRow.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.CostManagement.Models; + +public record CostQueryRow( + decimal Cost, + string? UsageDate, + string? GroupValue); diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs new file mode 100644 index 0000000000..7183b31712 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.CostManagement.Models; + +public enum QueryGranularity +{ + None, + Daily, + Monthly +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs new file mode 100644 index 0000000000..55ab1b63bd --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.CostManagement.Models; + +/// +/// Predefined time ranges accepted by the Cost Management Query API at subscription +/// and resource group scope. TheLastMonth is omitted because it is only supported +/// at billing-account scope; use for the previous full +/// billing period instead. +/// +public enum QueryTimeframe +{ + MonthToDate, + BillingMonthToDate, + TheCurrentMonth, + TheLastBillingMonth, + WeekToDate, + Custom +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Options/BaseCostManagementOptions.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Options/BaseCostManagementOptions.cs new file mode 100644 index 0000000000..a224802ef1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Options/BaseCostManagementOptions.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.CostManagement.Options; + +public class BaseCostManagementOptions : SubscriptionOptions; diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Options/CostManagementOptionDefinitions.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Options/CostManagementOptionDefinitions.cs new file mode 100644 index 0000000000..bf65b3ac9a --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Options/CostManagementOptionDefinitions.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Tools.CostManagement.Models; + +namespace Azure.Mcp.Tools.CostManagement.Options; + +public static class CostManagementOptionDefinitions +{ + public const string TimeframeName = "timeframe"; + public const string FromName = "from"; + public const string ToName = "to"; + public const string GranularityName = "granularity"; + public const string GroupByName = "group-by"; + + public static readonly Option Timeframe = new($"--{TimeframeName}") + { + Description = + "Predefined time range for the query. Defaults to MonthToDate. " + + "Use 'Custom' together with --from and --to for a specific window. " + + "Allowed values: MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate, Custom. " + + "Use TheLastBillingMonth for the previous full billing period.", + Required = false + }; + + public static readonly Option From = new($"--{FromName}") + { + Description = "Start date (UTC, ISO-8601, e.g. 2026-04-01) for Custom timeframe. Required when --timeframe Custom.", + Required = false + }; + + public static readonly Option To = new($"--{ToName}") + { + Description = "End date (UTC, ISO-8601, e.g. 2026-04-30) for Custom timeframe. Required when --timeframe Custom.", + Required = false + }; + + public static readonly Option Granularity = new($"--{GranularityName}") + { + Description = + "Row granularity. 'None' (default) returns a single aggregated total. " + + "'Daily' or 'Monthly' return one row per day or month. Allowed values: None, Daily, Monthly.", + Required = false + }; + + public static readonly Option GroupBy = new($"--{GroupByName}") + { + Description = + "Optional dimension to group costs by. Common values: ServiceName, ResourceGroupName, " + + "ResourceLocation, ResourceId, MeterCategory, MeterSubCategory, ChargeType, BillingPeriod. " + + "Other API-supported dimensions (including custom and tag-based dimensions) are also accepted; " + + "unrecognized values are passed through and surface as HTTP 400 if the API rejects them. " + + "Only one dimension may be specified in this command.", + Required = false + }; +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Options/Query/QueryRunOptions.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Options/Query/QueryRunOptions.cs new file mode 100644 index 0000000000..5619b7bb0c --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Options/Query/QueryRunOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.CostManagement.Models; + +namespace Azure.Mcp.Tools.CostManagement.Options.Query; + +public class QueryRunOptions : BaseCostManagementOptions +{ + public QueryTimeframe? Timeframe { get; set; } + public DateTime? From { get; set; } + public DateTime? To { get; set; } + public QueryGranularity? Granularity { get; set; } + public string? GroupBy { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs new file mode 100644 index 0000000000..9f998108bd --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text.Json; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Tools.CostManagement.Models; +using Azure.ResourceManager.CostManagement; +using Azure.ResourceManager.CostManagement.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.CostManagement.Services; + +public sealed class CostManagementService( + ISubscriptionService subscriptionService, + ITenantService tenantService, + ILogger logger) + : BaseAzureService(tenantService), ICostManagementService +{ + private const string CostColumn = "Cost"; + private const string CostUsdColumn = "CostUSD"; + private const string PreTaxCostColumn = "PreTaxCost"; + private const string CurrencyColumn = "Currency"; + private const string UsageDateColumn = "UsageDate"; + + internal static readonly IReadOnlySet KnownDimensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ServiceName", "ResourceGroupName", "ResourceLocation", "ResourceId", + "MeterCategory", "MeterSubCategory", "ChargeType", "BillingPeriod" + }; + + private readonly ISubscriptionService _subscriptionService = subscriptionService; + private readonly ILogger _logger = logger; + + public async Task QueryAsync( + string subscription, + string? resourceGroup, + QueryTimeframe timeframe, + DateTime? from, + DateTime? to, + QueryGranularity granularity, + string? groupBy, + string? tenant, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters(("subscription", subscription)); + + if (!string.IsNullOrWhiteSpace(groupBy) && !KnownDimensions.Contains(groupBy)) + { + _logger.LogWarning( + "--group-by '{GroupBy}' is not in the well-known dimension set. Passing through to the Cost Management API; an unsupported value will surface as HTTP 400.", + groupBy); + } + + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); + var subscriptionId = subscriptionResource.Data.SubscriptionId; + var armClient = await CreateArmClientAsync(tenant, retryPolicy, cancellationToken: cancellationToken); + + var scope = string.IsNullOrEmpty(resourceGroup) + ? new ResourceIdentifier($"/subscriptions/{subscriptionId}") + : new ResourceIdentifier($"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}"); + + var dataset = new QueryDataset(); + dataset.Aggregation["totalCost"] = new QueryAggregation(CostColumn, FunctionType.Sum); + + if (granularity != QueryGranularity.None) + { + dataset.Granularity = granularity == QueryGranularity.Daily + ? GranularityType.Daily + : GranularityType.Monthly; + } + + if (!string.IsNullOrWhiteSpace(groupBy)) + { + dataset.Grouping.Add(new QueryGrouping(QueryColumnType.Dimension, groupBy)); + } + + var queryDefinition = new QueryDefinition(ExportType.ActualCost, MapTimeframe(timeframe), dataset); + if (timeframe == QueryTimeframe.Custom) + { + queryDefinition.TimePeriod = new QueryTimePeriod(from!.Value, to!.Value); + } + + _logger.LogDebug( + "Querying Cost Management. Scope: {Scope}, Timeframe: {Timeframe}, Granularity: {Granularity}, GroupBy: {GroupBy}", + scope, timeframe, granularity, groupBy); + + var response = await armClient.UsageQueryAsync(scope, queryDefinition, cancellationToken); + return MapResult(response.Value, timeframe, from, to, granularity, groupBy); + } + + private static TimeframeType MapTimeframe(QueryTimeframe timeframe) => timeframe switch + { + QueryTimeframe.MonthToDate => TimeframeType.MonthToDate, + QueryTimeframe.BillingMonthToDate => TimeframeType.BillingMonthToDate, + QueryTimeframe.TheCurrentMonth => TimeframeType.TheCurrentMonth, + QueryTimeframe.TheLastBillingMonth => TimeframeType.TheLastBillingMonth, + QueryTimeframe.WeekToDate => TimeframeType.WeekToDate, + QueryTimeframe.Custom => TimeframeType.Custom, + _ => throw new ArgumentOutOfRangeException(nameof(timeframe), timeframe, "Unsupported timeframe.") + }; + + private static CostQueryResult MapResult( + QueryResult queryResult, + QueryTimeframe timeframe, + DateTime? from, + DateTime? to, + QueryGranularity granularity, + string? groupBy) + { + var columns = queryResult.Columns ?? []; + var columnIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < columns.Count; i++) + { + columnIndex[columns[i].Name] = i; + } + + int costIndex = ResolveColumnIndex(columnIndex, CostColumn, PreTaxCostColumn, CostUsdColumn); + int currencyIndex = columnIndex.TryGetValue(CurrencyColumn, out var ci) ? ci : -1; + int usageDateIndex = columnIndex.TryGetValue(UsageDateColumn, out var ui) ? ui : -1; + int groupByIndex = !string.IsNullOrWhiteSpace(groupBy) && columnIndex.TryGetValue(groupBy!, out var gi) ? gi : -1; + + var rows = queryResult.Rows ?? []; + var mapped = new List(rows.Count); + decimal total = 0m; + string? currency = null; + + foreach (var row in rows) + { + var cost = costIndex >= 0 && costIndex < row.Count ? ReadDecimal(row[costIndex], CostColumn) : 0m; + total += cost; + + if (currencyIndex >= 0 && currencyIndex < row.Count) + { + var rowCurrency = ReadString(row[currencyIndex], CurrencyColumn); + if (!string.IsNullOrEmpty(rowCurrency)) + { + currency = rowCurrency; + } + } + + string? usageDate = usageDateIndex >= 0 && usageDateIndex < row.Count + ? FormatUsageDate(row[usageDateIndex]) + : null; + + string? groupValue = groupByIndex >= 0 && groupByIndex < row.Count + ? ReadString(row[groupByIndex], groupBy!) + : null; + + mapped.Add(new CostQueryRow(cost, usageDate, groupValue)); + } + + return new CostQueryResult( + Currency: currency, + TotalCost: total, + Timeframe: timeframe.ToString(), + FromDate: timeframe == QueryTimeframe.Custom ? from : null, + ToDate: timeframe == QueryTimeframe.Custom ? to : null, + Granularity: granularity.ToString(), + GroupBy: groupBy, + Rows: mapped); + } + + internal static int ResolveColumnIndex(IReadOnlyDictionary columnIndex, params string[] candidates) + { + foreach (var name in candidates) + { + if (columnIndex.TryGetValue(name, out var idx)) + { + return idx; + } + } + return -1; + } + + internal static decimal ReadDecimal(BinaryData? data, string columnName) + { + if (data is null) + { + return 0m; + } + + try + { + using var doc = JsonDocument.Parse(data); + return doc.RootElement.ValueKind switch + { + JsonValueKind.Null => 0m, + JsonValueKind.Number => doc.RootElement.GetDecimal(), + JsonValueKind.String when decimal.TryParse( + doc.RootElement.GetString(), + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var parsed) => parsed, + _ => + throw new InvalidOperationException(BuildParseError(data, columnName, "decimal")) + }; + } + catch (JsonException ex) + { + throw new InvalidOperationException(BuildParseError(data, columnName, "decimal"), ex); + } + } + + internal static string? ReadString(BinaryData? data, string columnName) + { + if (data is null) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(data); + return doc.RootElement.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.String => doc.RootElement.GetString(), + JsonValueKind.Number => doc.RootElement.GetRawText(), + _ => throw new InvalidOperationException(BuildParseError(data, columnName, "string")) + }; + } + catch (JsonException ex) + { + throw new InvalidOperationException(BuildParseError(data, columnName, "string"), ex); + } + } + + private static string BuildParseError(BinaryData data, string columnName, string targetType) + { + var raw = data.ToString(); + var snippet = raw.Length > 120 ? raw[..120] + "..." : raw; + return $"Cost Management API returned a value for column '{columnName}' that could not be parsed as {targetType}: {snippet}"; + } + + internal static string? FormatUsageDate(BinaryData? data) + { + var raw = ReadString(data, UsageDateColumn); + if (string.IsNullOrEmpty(raw)) + { + return null; + } + + return raw.Length switch + { + 8 => $"{raw[..4]}-{raw.Substring(4, 2)}-{raw.Substring(6, 2)}", + 6 => $"{raw[..4]}-{raw.Substring(4, 2)}-01", + _ => raw + }; + } +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Services/ICostManagementService.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Services/ICostManagementService.cs new file mode 100644 index 0000000000..d822e92eac --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Services/ICostManagementService.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.CostManagement.Models; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.CostManagement.Services; + +public interface ICostManagementService +{ + /// + /// Queries actual Azure costs for a subscription, optionally narrowed to a resource group, + /// using the Azure Cost Management Query/Usage API. + /// + Task QueryAsync( + string subscription, + string? resourceGroup, + QueryTimeframe timeframe, + DateTime? from, + DateTime? to, + QueryGranularity granularity, + string? groupBy, + string? tenant, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default); +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/Azure.Mcp.Tools.CostManagement.LiveTests.csproj b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/Azure.Mcp.Tools.CostManagement.LiveTests.csproj new file mode 100644 index 0000000000..0f06a032a0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/Azure.Mcp.Tools.CostManagement.LiveTests.csproj @@ -0,0 +1,17 @@ + + + true + Exe + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/QueryRunCommandTests.cs b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/QueryRunCommandTests.cs new file mode 100644 index 0000000000..87ead12225 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/QueryRunCommandTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Mcp.Tests; +using Microsoft.Mcp.Tests.Client; +using Microsoft.Mcp.Tests.Client.Helpers; +using Xunit; + +namespace Azure.Mcp.Tools.CostManagement.LiveTests; + +public sealed class QueryRunCommandTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture) + : RecordedCommandTestsBase(output, fixture, liveServerFixture) +{ + private const string ResultKey = "result"; + + [Fact] + public async Task Should_query_subscription_costs_month_to_date() + { + var response = await CallToolAsync( + "costmanagement_query_run", + new() + { + { "subscription", Settings.SubscriptionId }, + { "timeframe", "MonthToDate" } + }); + + var result = response.AssertProperty(ResultKey); + Assert.Equal(JsonValueKind.Object, result.ValueKind); + + var totalCost = result.AssertProperty("totalCost"); + Assert.Equal(JsonValueKind.Number, totalCost.ValueKind); + Assert.True(totalCost.GetDecimal() >= 0m); + } + + [Fact] + public async Task Should_query_subscription_costs_grouped_by_service() + { + var response = await CallToolAsync( + "costmanagement_query_run", + new() + { + { "subscription", Settings.SubscriptionId }, + { "timeframe", "MonthToDate" }, + { "group-by", "ServiceName" } + }); + + var result = response.AssertProperty(ResultKey); + var groupBy = result.AssertProperty("groupBy"); + Assert.Equal("ServiceName", groupBy.GetString()); + + var rows = result.AssertProperty("rows"); + Assert.Equal(JsonValueKind.Array, rows.ValueKind); + Assert.True(rows.GetArrayLength() > 0, + "Expected at least one cost row when grouping by ServiceName MTD against a subscription with known spend."); + } + + [Fact] + public async Task Should_query_custom_timeframe_with_daily_granularity() + { + var response = await CallToolAsync( + "costmanagement_query_run", + new() + { + { "subscription", Settings.SubscriptionId }, + { "timeframe", "Custom" }, + { "from", "2026-04-01" }, + { "to", "2026-04-07" }, + { "granularity", "Daily" } + }); + + var result = response.AssertProperty(ResultKey); + var granularity = result.AssertProperty("granularity"); + Assert.Equal("Daily", granularity.GetString()); + + var rows = result.AssertProperty("rows"); + Assert.Equal(JsonValueKind.Array, rows.ValueKind); + } +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/assets.json b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/assets.json new file mode 100644 index 0000000000..2a49a7ce89 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.LiveTests/assets.json @@ -0,0 +1,6 @@ +{ + "AssetsRepo": "Azure/azure-sdk-assets", + "AssetsRepoPrefixPath": "", + "TagPrefix": "Azure.Mcp.Tools.CostManagement.LiveTests", + "Tag": "" +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Azure.Mcp.Tools.CostManagement.UnitTests.csproj b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Azure.Mcp.Tools.CostManagement.UnitTests.csproj new file mode 100644 index 0000000000..35f08392b5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Azure.Mcp.Tools.CostManagement.UnitTests.csproj @@ -0,0 +1,17 @@ + + + true + Exe + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Query/QueryRunCommandTests.cs b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Query/QueryRunCommandTests.cs new file mode 100644 index 0000000000..973459fa9b --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Query/QueryRunCommandTests.cs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tools.CostManagement.Commands; +using Azure.Mcp.Tools.CostManagement.Commands.Query; +using Azure.Mcp.Tools.CostManagement.Models; +using Azure.Mcp.Tools.CostManagement.Services; +using Microsoft.Mcp.Core.Options; +using Microsoft.Mcp.Tests.Client; +using Microsoft.Mcp.Tests.Helpers; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.CostManagement.UnitTests.Query; + +public class QueryRunCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("run", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.Contains("actual Azure costs", CommandDefinition.Description, StringComparison.Ordinal); + Assert.Contains("Cost Management Reader", CommandDefinition.Description, StringComparison.Ordinal); + + var optionNames = CommandDefinition.Options.Select(o => o.Name).ToList(); + Assert.Contains("--subscription", optionNames); + Assert.Contains("--resource-group", optionNames); + Assert.Contains("--timeframe", optionNames); + Assert.Contains("--from", optionNames); + Assert.Contains("--to", optionNames); + Assert.Contains("--granularity", optionNames); + Assert.Contains("--group-by", optionNames); + } + + [Fact] + public async Task ExecuteAsync_WithSubscriptionOnly_DefaultsToMonthToDate() + { + Service.QueryAsync( + Arg.Is("sub-1"), + Arg.Any(), + Arg.Is(QueryTimeframe.MonthToDate), + Arg.Any(), + Arg.Any(), + Arg.Is(QueryGranularity.None), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(BuildResult()); + + var response = await ExecuteCommandAsync("--subscription", "sub-1"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + } + + [Fact] + public async Task ExecuteAsync_WithResourceGroupAndGroupBy_PassesAllOptions() + { + Service.QueryAsync( + Arg.Is("sub-1"), + Arg.Is("rg-1"), + Arg.Is(QueryTimeframe.TheLastBillingMonth), + Arg.Any(), + Arg.Any(), + Arg.Is(QueryGranularity.Daily), + Arg.Is("ServiceName"), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(BuildResult()); + + var response = await ExecuteCommandAsync( + "--subscription", "sub-1", + "--resource-group", "rg-1", + "--timeframe", "TheLastBillingMonth", + "--granularity", "Daily", + "--group-by", "ServiceName"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_CustomTimeframe_RequiresFromAndTo() + { + var response = await ExecuteCommandAsync("--subscription", "sub-1", "--timeframe", "Custom"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("from", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("to", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_CustomTimeframe_RejectsFromGreaterThanTo() + { + var response = await ExecuteCommandAsync( + "--subscription", "sub-1", + "--timeframe", "Custom", + "--from", "2026-04-30", + "--to", "2026-04-01"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("earlier", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_CustomTimeframe_PassesFromAndTo() + { + Service.QueryAsync( + Arg.Is("sub-1"), + Arg.Any(), + Arg.Is(QueryTimeframe.Custom), + Arg.Is(d => d == new DateTime(2026, 4, 1)), + Arg.Is(d => d == new DateTime(2026, 4, 30)), + Arg.Is(QueryGranularity.None), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(BuildResult()); + + var response = await ExecuteCommandAsync( + "--subscription", "sub-1", + "--timeframe", "Custom", + "--from", "2026-04-01", + "--to", "2026-04-30"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_ReturnsResultsThatRoundTripThroughJsonContext() + { + var expected = BuildResult(); + Service.QueryAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(expected); + + var response = await ExecuteCommandAsync("--subscription", "sub-1"); + var roundTrip = ValidateAndDeserializeResponse(response, CostManagementJsonContext.Default.QueryRunCommandResult); + + Assert.Equal(expected.Currency, roundTrip.Result.Currency); + Assert.Equal(expected.TotalCost, roundTrip.Result.TotalCost); + Assert.Equal(expected.Rows.Count, roundTrip.Result.Rows.Count); + } + + [Fact] + public async Task ExecuteAsync_WithMissingSubscription_ReturnsValidationError() + { + TestEnvironment.ClearAzureSubscriptionId(); + + var response = await ExecuteCommandAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_Handles403Forbidden() + { + var forbidden = new RequestFailedException((int)HttpStatusCode.Forbidden, "Authorization failed"); + Service.QueryAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .ThrowsAsync(forbidden); + + var response = await ExecuteCommandAsync("--subscription", "sub-1"); + + Assert.Equal(HttpStatusCode.Forbidden, response.Status); + Assert.Contains("Cost Management Reader", response.Message); + } + + [Fact] + public async Task ExecuteAsync_Handles429Throttled() + { + var throttled = new RequestFailedException((int)HttpStatusCode.TooManyRequests, "Throttled"); + Service.QueryAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .ThrowsAsync(throttled); + + var response = await ExecuteCommandAsync("--subscription", "sub-1"); + + Assert.Equal(HttpStatusCode.TooManyRequests, response.Status); + Assert.Contains("throttled", response.Message, StringComparison.OrdinalIgnoreCase); + } + + private static CostQueryResult BuildResult() => new( + Currency: "EUR", + TotalCost: 123.45m, + Timeframe: nameof(QueryTimeframe.MonthToDate), + FromDate: null, + ToDate: null, + Granularity: nameof(QueryGranularity.None), + GroupBy: null, + Rows: + [ + new CostQueryRow(123.45m, null, null) + ]); +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Services/CostManagementServiceTests.cs b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Services/CostManagementServiceTests.cs new file mode 100644 index 0000000000..a79834815c --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/tests/Azure.Mcp.Tools.CostManagement.UnitTests/Services/CostManagementServiceTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.CostManagement.Services; +using Xunit; + +namespace Azure.Mcp.Tools.CostManagement.UnitTests.Services; + +// MapResult is covered via live tests; QueryResult has internal-only ctor in +// Azure.ResourceManager.CostManagement 1.0.3 and ArmCostManagementModelFactory +// does not expose a builder for it. The static helpers below are exercised directly. +public class CostManagementServiceTests +{ + [Theory] + [InlineData(new[] { "Cost", "Currency" }, "Cost", 0)] + [InlineData(new[] { "PreTaxCost", "Currency" }, "Cost", -1)] + [InlineData(new[] { "PreTaxCost", "Currency" }, "PreTaxCost", 0)] + [InlineData(new[] { "CostUSD", "Currency" }, "CostUSD", 0)] + public void ResolveColumnIndex_FindsFirstAvailableCandidate(string[] columns, string firstCandidate, int expected) + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < columns.Length; i++) + { + index[columns[i]] = i; + } + + var result = CostManagementService.ResolveColumnIndex(index, firstCandidate); + + Assert.Equal(expected, result); + } + + [Fact] + public void ResolveColumnIndex_PicksFirstAvailableFromMultipleCandidates() + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["PreTaxCost"] = 1, + ["Currency"] = 2 + }; + + var result = CostManagementService.ResolveColumnIndex(index, "Cost", "PreTaxCost", "CostUSD"); + + Assert.Equal(1, result); + } + + [Fact] + public void ReadDecimal_ReturnsZero_ForNullData() + { + var result = CostManagementService.ReadDecimal(null, "Cost"); + Assert.Equal(0m, result); + } + + [Fact] + public void ReadDecimal_ParsesNumber() + { + var data = BinaryData.FromString("123.45"); + var result = CostManagementService.ReadDecimal(data, "Cost"); + Assert.Equal(123.45m, result); + } + + [Fact] + public void ReadDecimal_ParsesQuotedString() + { + var data = BinaryData.FromString("\"99.5\""); + var result = CostManagementService.ReadDecimal(data, "Cost"); + Assert.Equal(99.5m, result); + } + + [Fact] + public void ReadDecimal_ReturnsZero_ForJsonNull() + { + var data = BinaryData.FromString("null"); + var result = CostManagementService.ReadDecimal(data, "Cost"); + Assert.Equal(0m, result); + } + + [Fact] + public void ReadDecimal_Throws_ForNonNumericContent() + { + var data = BinaryData.FromString("true"); + var ex = Assert.Throws(() => CostManagementService.ReadDecimal(data, "Cost")); + Assert.Contains("Cost", ex.Message); + Assert.Contains("decimal", ex.Message); + } + + [Fact] + public void ReadString_ReturnsNull_ForNullData() + { + var result = CostManagementService.ReadString(null, "Currency"); + Assert.Null(result); + } + + [Fact] + public void ReadString_ParsesQuotedString() + { + var data = BinaryData.FromString("\"EUR\""); + var result = CostManagementService.ReadString(data, "Currency"); + Assert.Equal("EUR", result); + } + + [Fact] + public void ReadString_ConvertsNumberToString() + { + var data = BinaryData.FromString("20180331"); + var result = CostManagementService.ReadString(data, "UsageDate"); + Assert.Equal("20180331", result); + } + + [Fact] + public void ReadString_ReturnsNull_ForJsonNull() + { + var data = BinaryData.FromString("null"); + var result = CostManagementService.ReadString(data, "Currency"); + Assert.Null(result); + } + + [Fact] + public void FormatUsageDate_ReturnsNull_ForNullData() + { + var result = CostManagementService.FormatUsageDate(null); + Assert.Null(result); + } + + [Fact] + public void FormatUsageDate_FormatsDailyAsIso8601() + { + var data = BinaryData.FromString("20260415"); + var result = CostManagementService.FormatUsageDate(data); + Assert.Equal("2026-04-15", result); + } + + [Fact] + public void FormatUsageDate_FormatsMonthlyAsFirstOfMonth() + { + var data = BinaryData.FromString("202604"); + var result = CostManagementService.FormatUsageDate(data); + Assert.Equal("2026-04-01", result); + } + + [Fact] + public void FormatUsageDate_PassesThroughUnexpectedLength() + { + var data = BinaryData.FromString("\"2026-04-15\""); + var result = CostManagementService.FormatUsageDate(data); + Assert.Equal("2026-04-15", result); + } + + [Fact] + public void KnownDimensions_ContainsTheEightDocumentedValues() + { + var expected = new[] + { + "ServiceName", "ResourceGroupName", "ResourceLocation", "ResourceId", + "MeterCategory", "MeterSubCategory", "ChargeType", "BillingPeriod" + }; + + Assert.Equal(expected.Length, CostManagementService.KnownDimensions.Count); + foreach (var dim in expected) + { + Assert.Contains(dim, CostManagementService.KnownDimensions); + } + } + + [Fact] + public void KnownDimensions_IsCaseInsensitive() + { + Assert.Contains("ServiceName".ToLowerInvariant(), CostManagementService.KnownDimensions); + Assert.Contains("ResourceGroupName".ToUpperInvariant(), CostManagementService.KnownDimensions); + } +} diff --git a/tools/Azure.Mcp.Tools.CostManagement/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.CostManagement/tests/test-resources-post.ps1 new file mode 100644 index 0000000000..a6d4b484de --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/tests/test-resources-post.ps1 @@ -0,0 +1,15 @@ +param( + [string] $TenantId, + [string] $TestApplicationId, + [string] $ResourceGroupName, + [string] $BaseName, + [hashtable] $DeploymentOutputs, + [hashtable] $AdditionalParameters +) + +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot/../../../eng/common/scripts/common.ps1" +. "$PSScriptRoot/../../../eng/scripts/helpers/TestResourcesHelpers.ps1" + +$testSettings = New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot diff --git a/tools/Azure.Mcp.Tools.CostManagement/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.CostManagement/tests/test-resources.bicep new file mode 100644 index 0000000000..a8846dfca5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.CostManagement/tests/test-resources.bicep @@ -0,0 +1,15 @@ +// Live test runs require a resource file, so we use an empty one here. +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(24) +@description('The base resource name.') +param baseName string + +@description('The client OID to grant access to test resources.') +param testApplicationOid string = deployer().objectId + +var location string = resourceGroup().location +var tenantId string = subscription().tenantId + +output location string = location From 489edbafda0f88abc573ee34f133bdcec72ac978 Mon Sep 17 00:00:00 2001 From: francesco1501 Date: Thu, 7 May 2026 15:48:59 +0000 Subject: [PATCH 2/2] Pin CostManagement to 1.0.2 to keep Azure.Core at 1.50 Azure.ResourceManager.CostManagement 1.0.3 transitively requires Azure.Core >= 1.53.0. Bumping Azure.Core to 1.53 introduces a CredentialUnavailableException type collision with Azure.Identity 1.17.1 in core/Azure.Mcp.Core/tests/.../CustomChainedCredentialTests.cs (CS0433). Downgrade to 1.0.2 (which only requires Azure.Core >= 1.44.1) to keep the existing Azure.Core 1.50 pin. Trade-off: 1.0.2 lacks two enum values that we removed from our QueryTimeframe / QueryGranularity: - QueryTimeframe.TheCurrentMonth (use MonthToDate instead) - QueryGranularity.Monthly (use Daily for breakdown, None for aggregated total) Updated docs (azmcp-commands.md, consolidated-tools.json, changelog entry) and option descriptions accordingly. --- Directory.Packages.props | 2 +- .../francesco1501-add-costmanagement-query.yml | 2 +- servers/Azure.Mcp.Server/docs/azmcp-commands.md | 4 ++-- .../Azure.Mcp.Server/src/Resources/consolidated-tools.json | 2 +- .../src/Commands/Query/QueryRunCommand.cs | 4 ++-- .../src/Models/QueryGranularity.cs | 3 +-- .../src/Models/QueryTimeframe.cs | 1 - .../src/Options/CostManagementOptionDefinitions.cs | 4 ++-- .../src/Services/CostManagementService.cs | 7 ++----- 9 files changed, 12 insertions(+), 17 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e3c4259354..b23b3a5828 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,7 +30,7 @@ - + diff --git a/servers/Azure.Mcp.Server/changelog-entries/francesco1501-add-costmanagement-query.yml b/servers/Azure.Mcp.Server/changelog-entries/francesco1501-add-costmanagement-query.yml index 58ad844759..74a3000953 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/francesco1501-add-costmanagement-query.yml +++ b/servers/Azure.Mcp.Server/changelog-entries/francesco1501-add-costmanagement-query.yml @@ -1,3 +1,3 @@ changes: - section: "Features Added" - description: "Added `azmcp costmanagement query run` tool to retrieve actual Azure costs and usage from the Azure Cost Management Query/Usage API for a subscription or resource group, with support for predefined timeframes (MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate) and a custom range, optional Daily/Monthly granularity, and grouping by a single dimension (ServiceName, ResourceGroupName, ResourceLocation, ResourceId, MeterCategory, MeterSubCategory, ChargeType, BillingPeriod). Closes the gap tracked in #1420 and supersedes #447." + description: "Added `azmcp costmanagement query run` tool to retrieve actual Azure costs and usage from the Azure Cost Management Query/Usage API for a subscription or resource group, with support for predefined timeframes (MonthToDate, BillingMonthToDate, TheLastBillingMonth, WeekToDate) and a custom range, optional Daily granularity, and grouping by a single dimension (ServiceName, ResourceGroupName, ResourceLocation, ResourceId, MeterCategory, MeterSubCategory, ChargeType, BillingPeriod). Closes the gap tracked in #1420 and supersedes #447." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 6d0482cf02..7473414f8d 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -1937,10 +1937,10 @@ azmcp acr registry repository list --subscription \ # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp costmanagement query run --subscription \ [--resource-group ] \ - [--timeframe ] \ + [--timeframe ] \ [--from ] \ [--to ] \ - [--granularity ] \ + [--granularity ] \ [--group-by ] ``` diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 850799552d..6c7138ecf9 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -771,7 +771,7 @@ }, { "name": "query_azure_costs", - "description": "Query actual Azure costs and usage data for a subscription or resource group over a time period. Returns aggregated costs in the billing currency. Supports common predefined timeframes (MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate) and a Custom range. Optional Daily/Monthly granularity and grouping by a single dimension (ServiceName, ResourceGroupName, ResourceLocation, ResourceId, MeterCategory, MeterSubCategory, ChargeType, BillingPeriod). Caller must have 'Cost Management Reader' or 'Reader' role on the scope.", + "description": "Query actual Azure costs and usage data for a subscription or resource group over a time period. Returns aggregated costs in the billing currency. Supports common predefined timeframes (MonthToDate, BillingMonthToDate, TheLastBillingMonth, WeekToDate) and a Custom range. Optional Daily granularity and grouping by a single dimension (ServiceName, ResourceGroupName, ResourceLocation, ResourceId, MeterCategory, MeterSubCategory, ChargeType, BillingPeriod). Caller must have 'Cost Management Reader' or 'Reader' role on the scope.", "toolMetadata": { "destructive": { "value": false, diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Commands/Query/QueryRunCommand.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Commands/Query/QueryRunCommand.cs index 7df523d3e1..c3b7c516b0 100644 --- a/tools/Azure.Mcp.Tools.CostManagement/src/Commands/Query/QueryRunCommand.cs +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Commands/Query/QueryRunCommand.cs @@ -18,7 +18,7 @@ namespace Azure.Mcp.Tools.CostManagement.Commands.Query; Title = "Query Azure Costs", Description = """ Query actual Azure costs and usage data for a subscription or resource group over a time period. - Returns aggregated costs in the billing currency, optionally broken down by day/month and grouped by + Returns aggregated costs in the billing currency, optionally broken down daily and grouped by a single Azure dimension. Caller must have 'Cost Management Reader' or 'Reader' on the scope. """, Destructive = false, @@ -125,7 +125,7 @@ public override async Task ExecuteAsync( RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.TooManyRequests => $"Cost Management API throttled the request. Retry after the duration specified in the 'x-ms-ratelimit-microsoft.consumption-retry-after' response header. Details: {reqEx.Message}", RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.BadRequest => - $"Cost Management API rejected the query. Common causes: unsupported --group-by dimension; --timeframe value not allowed at this scope (subscription/resource group accept MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate, Custom); --from later than --to; date range too large. Details: {reqEx.Message}", + $"Cost Management API rejected the query. Common causes: unsupported --group-by dimension; --timeframe value not allowed at this scope (subscription/resource group accept MonthToDate, BillingMonthToDate, TheLastBillingMonth, WeekToDate, Custom); --from later than --to; date range too large. Details: {reqEx.Message}", RequestFailedException reqEx => reqEx.Message, ArgumentException argEx => argEx.Message, _ => base.GetErrorMessage(ex) diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs index 7183b31712..29d7f8a11b 100644 --- a/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs @@ -6,6 +6,5 @@ namespace Azure.Mcp.Tools.CostManagement.Models; public enum QueryGranularity { None, - Daily, - Monthly + Daily } diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs index 55ab1b63bd..406368d02a 100644 --- a/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs @@ -13,7 +13,6 @@ public enum QueryTimeframe { MonthToDate, BillingMonthToDate, - TheCurrentMonth, TheLastBillingMonth, WeekToDate, Custom diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Options/CostManagementOptionDefinitions.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Options/CostManagementOptionDefinitions.cs index bf65b3ac9a..94d357fd85 100644 --- a/tools/Azure.Mcp.Tools.CostManagement/src/Options/CostManagementOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Options/CostManagementOptionDefinitions.cs @@ -19,7 +19,7 @@ public static class CostManagementOptionDefinitions Description = "Predefined time range for the query. Defaults to MonthToDate. " + "Use 'Custom' together with --from and --to for a specific window. " + - "Allowed values: MonthToDate, BillingMonthToDate, TheCurrentMonth, TheLastBillingMonth, WeekToDate, Custom. " + + "Allowed values: MonthToDate, BillingMonthToDate, TheLastBillingMonth, WeekToDate, Custom. " + "Use TheLastBillingMonth for the previous full billing period.", Required = false }; @@ -40,7 +40,7 @@ public static class CostManagementOptionDefinitions { Description = "Row granularity. 'None' (default) returns a single aggregated total. " + - "'Daily' or 'Monthly' return one row per day or month. Allowed values: None, Daily, Monthly.", + "'Daily' returns one row per day. Allowed values: None, Daily.", Required = false }; diff --git a/tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs b/tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs index 9f998108bd..483c83507c 100644 --- a/tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs +++ b/tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs @@ -68,11 +68,9 @@ public async Task QueryAsync( var dataset = new QueryDataset(); dataset.Aggregation["totalCost"] = new QueryAggregation(CostColumn, FunctionType.Sum); - if (granularity != QueryGranularity.None) + if (granularity == QueryGranularity.Daily) { - dataset.Granularity = granularity == QueryGranularity.Daily - ? GranularityType.Daily - : GranularityType.Monthly; + dataset.Granularity = GranularityType.Daily; } if (!string.IsNullOrWhiteSpace(groupBy)) @@ -98,7 +96,6 @@ public async Task QueryAsync( { QueryTimeframe.MonthToDate => TimeframeType.MonthToDate, QueryTimeframe.BillingMonthToDate => TimeframeType.BillingMonthToDate, - QueryTimeframe.TheCurrentMonth => TimeframeType.TheCurrentMonth, QueryTimeframe.TheLastBillingMonth => TimeframeType.TheLastBillingMonth, QueryTimeframe.WeekToDate => TimeframeType.WeekToDate, QueryTimeframe.Custom => TimeframeType.Custom,