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..b23b3a5828 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..74a3000953
--- /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, 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 2ecee91355..7473414f8d 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..6c7138ecf9 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, 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,
+ "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..c3b7c516b0
--- /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 daily 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, 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..29d7f8a11b
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryGranularity.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Azure.Mcp.Tools.CostManagement.Models;
+
+public enum QueryGranularity
+{
+ None,
+ Daily
+}
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..406368d02a
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.CostManagement/src/Models/QueryTimeframe.cs
@@ -0,0 +1,19 @@
+// 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,
+ 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..94d357fd85
--- /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, 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' returns one row per day. Allowed values: None, Daily.",
+ 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..483c83507c
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.CostManagement/src/Services/CostManagementService.cs
@@ -0,0 +1,253 @@
+// 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.Daily)
+ {
+ dataset.Granularity = GranularityType.Daily;
+ }
+
+ 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.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