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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
// Licensed under the MIT License.

using System.Text.Json.Serialization;
using Azure.Mcp.Core.Areas.Subscription.Models;

namespace Azure.Mcp.Core.Areas.Subscription.Commands;

[JsonSerializable(typeof(SubscriptionListCommand.SubscriptionListCommandResult))]
[JsonSerializable(typeof(SubscriptionInfo))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class SubscriptionJsonContext : JsonSerializerContext
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Areas.Subscription.Models;
using Azure.Mcp.Core.Areas.Subscription.Options;
using Azure.Mcp.Core.Commands;
using Azure.Mcp.Core.Services.Azure.Subscription;
Expand All @@ -21,7 +22,11 @@ public sealed class SubscriptionListCommand(ILogger<SubscriptionListCommand> log
public override string Name => "list";

public override string Description =>
"List all or current subscriptions for an account in Azure; returns subscriptionId, displayName, state, tenantId, and isDefault. Use for scope selection in governance, policy, access, cost management, or deployment.";
"List all Azure subscriptions for the current account. Returns subscriptionId, displayName, state, tenantId, and isDefault for each subscription. " +
"The isDefault field indicates the user's default subscription as resolved from the Azure CLI profile (configured via 'az account set') or, if not set there, from the AZURE_SUBSCRIPTION_ID environment variable. " +
"When the user has not specified a subscription, prefer the subscription where isDefault is true. " +
"If no default can be determined from either source and multiple subscriptions exist, ask the user which subscription to use.";

public override string Title => CommandTitle;

public override ToolMetadata Metadata => new()
Expand All @@ -48,7 +53,12 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
var subscriptionService = context.GetService<ISubscriptionService>();
var subscriptions = await subscriptionService.GetSubscriptions(options.Tenant, options.RetryPolicy, cancellationToken);

context.Response.Results = ResponseResult.Create(new(subscriptions), SubscriptionJsonContext.Default.SubscriptionListCommandResult);
var defaultSubscriptionId = subscriptionService.GetDefaultSubscriptionId();
var subscriptionInfos = MapToSubscriptionInfos(subscriptions, defaultSubscriptionId);

context.Response.Results = ResponseResult.Create(
new SubscriptionListCommandResult(subscriptionInfos),
SubscriptionJsonContext.Default.SubscriptionListCommandResult);
}
catch (Exception ex)
{
Expand All @@ -59,5 +69,26 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
return context.Response;
}

internal record SubscriptionListCommandResult(List<SubscriptionData> Subscriptions);
internal static List<SubscriptionInfo> MapToSubscriptionInfos(List<SubscriptionData> subscriptions, string? defaultSubscriptionId)
{
var hasDefault = !string.IsNullOrEmpty(defaultSubscriptionId);

var infos = subscriptions.Select(s => new SubscriptionInfo(
s.SubscriptionId,
s.DisplayName,
s.State?.ToString(),
s.TenantId?.ToString(),
hasDefault && s.SubscriptionId.Equals(defaultSubscriptionId, StringComparison.OrdinalIgnoreCase)
)).ToList();

// Sort so the default subscription appears first
if (hasDefault)
{
infos = [.. infos.OrderByDescending(s => s.IsDefault)];
}

return infos;
}

internal record SubscriptionListCommandResult(List<SubscriptionInfo> Subscriptions);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.Mcp.Core.Areas.Subscription.Models;

/// <summary>
/// Represents an Azure subscription with default subscription indicator.
/// </summary>
/// <param name="SubscriptionId">The subscription ID.</param>
/// <param name="DisplayName">The display name of the subscription.</param>
/// <param name="State">The subscription state (e.g., Enabled, Disabled).</param>
/// <param name="TenantId">The tenant ID associated with the subscription.</param>
/// <param name="IsDefault">Whether this subscription is the default as configured via 'az account set' in the Azure CLI profile, or via the AZURE_SUBSCRIPTION_ID environment variable.</param>
internal record SubscriptionInfo(
string SubscriptionId,
string DisplayName,
string? State,
string? TenantId,
bool IsDefault);
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ protected override void RegisterOptions(Command command)
command.Options.Add(OptionDefinitions.Common.Subscription);
command.Validators.Add(commandResult =>
{
// Command-level validation for presence: allow either --subscription or AZURE_SUBSCRIPTION_ID
// This mirrors the prior behavior that preferred the explicit option but fell back to env var.
// Command-level validation for presence: allow either --subscription,
// Azure CLI profile default, or AZURE_SUBSCRIPTION_ID env var.
if (!CommandHelper.HasSubscriptionAvailable(commandResult))
{
commandResult.AddError("Missing Required options: --subscription");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,11 @@ public interface ISubscriptionService
bool IsSubscriptionId(string subscription, string? tenant = null);
Task<string> GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);
Task<string> GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the default subscription ID from the Azure CLI profile (~/.azure/azureProfile.json).
/// Falls back to the AZURE_SUBSCRIPTION_ID environment variable if the profile is unavailable.
/// </summary>
/// <returns>The default subscription ID if found, null otherwise.</returns>
string? GetDefaultSubscriptionId();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Helpers;
using Azure.Mcp.Core.Options;
using Azure.Mcp.Core.Services.Azure.Tenant;
using Azure.Mcp.Core.Services.Caching;
Expand Down Expand Up @@ -96,6 +97,12 @@ public async Task<string> GetSubscriptionNameById(string subscriptionId, string?
return subscription.DisplayName;
}

/// <inheritdoc/>
public string? GetDefaultSubscriptionId()
{
return CommandHelper.GetDefaultSubscription();
}

private async Task<string> GetSubscriptionId(string subscription, string? tenant, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken)
{
if (IsSubscriptionId(subscription))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Helpers;
using Xunit;

namespace Azure.Mcp.Core.UnitTests.Areas.Subscription;

public class AzureCliProfileHelperTests
{
[Fact]
public void ParseDefaultSubscriptionId_ValidProfile_ReturnsDefaultId()
{
var profileJson = """
{
"subscriptions": [
{
"id": "sub-1111-1111",
"name": "Subscription One",
"state": "Enabled",
"tenantId": "tenant-1111",
"isDefault": false
},
{
"id": "sub-2222-2222",
"name": "Subscription Two",
"state": "Enabled",
"tenantId": "tenant-2222",
"isDefault": true
},
{
"id": "sub-3333-3333",
"name": "Subscription Three",
"state": "Enabled",
"tenantId": "tenant-3333",
"isDefault": false
}
]
}
""";

var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);

Assert.Equal("sub-2222-2222", result);
}

[Fact]
public void ParseDefaultSubscriptionId_NoDefaultInProfile_ReturnsNull()
{
var profileJson = """
{
"subscriptions": [
{
"id": "sub-1111-1111",
"name": "Subscription One",
"state": "Enabled",
"tenantId": "tenant-1111",
"isDefault": false
}
]
}
""";

var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);

Assert.Null(result);
}

[Fact]
public void ParseDefaultSubscriptionId_EmptySubscriptions_ReturnsNull()
{
var profileJson = """
{
"subscriptions": []
}
""";

var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);

Assert.Null(result);
}

[Fact]
public void ParseDefaultSubscriptionId_NoSubscriptionsProperty_ReturnsNull()
{
var profileJson = """
{
"installationId": "some-id"
}
""";

var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);

Assert.Null(result);
}

[Fact]
public void ParseDefaultSubscriptionId_MissingIdOnDefault_ReturnsNull()
{
var profileJson = """
{
"subscriptions": [
{
"name": "Subscription One",
"isDefault": true
}
]
}
""";

var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);

Assert.Null(result);
}

[Fact]
public void GetAzureProfilePath_WhenUserProfileAvailable_ReturnsExpectedPath()
{
var result = AzureCliProfileHelper.GetAzureProfilePath();

// In containerized/CI environments, user profile may not be available
if (string.IsNullOrEmpty(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)))
{
Assert.Null(result);
}
else
{
Assert.NotNull(result);
Assert.Contains(".azure", result);
Assert.EndsWith("azureProfile.json", result);
}
}

[Fact]
public void GetAzureProfilePath_ReturnsNullOrValidPath()
{
var result = AzureCliProfileHelper.GetAzureProfilePath();

// The method must either return null (empty user profile) or a valid absolute path
if (result != null)
{
Assert.False(string.IsNullOrWhiteSpace(result));
Assert.Contains(".azure", result);
Assert.EndsWith("azureProfile.json", result);
Assert.True(Path.IsPathRooted(result), "Path should be absolute, not relative");
}
}
}
Loading