diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs index 26dbb55174..8fdfb69a46 100644 --- a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs @@ -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 { diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs index f572bbbb16..762d323e25 100644 --- a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs @@ -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; @@ -21,7 +22,11 @@ public sealed class SubscriptionListCommand(ILogger 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() @@ -48,8 +53,11 @@ public override async Task ExecuteAsync(CommandContext context, var subscriptionService = context.GetService(); var subscriptions = await subscriptionService.GetSubscriptions(options.Tenant, options.RetryPolicy, cancellationToken); + var defaultSubscriptionId = subscriptionService.GetDefaultSubscriptionId(); + var subscriptionInfos = MapToSubscriptionInfos(subscriptions, defaultSubscriptionId); + context.Response.Results = ResponseResult.Create( - new SubscriptionListCommandResult(subscriptions), + new SubscriptionListCommandResult(subscriptionInfos), SubscriptionJsonContext.Default.SubscriptionListCommandResult); } catch (Exception ex) @@ -61,5 +69,26 @@ public override async Task ExecuteAsync(CommandContext context, return context.Response; } - internal record SubscriptionListCommandResult(List Subscriptions); + internal static List MapToSubscriptionInfos(List 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 Subscriptions); } diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/Models/SubscriptionInfo.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/Models/SubscriptionInfo.cs new file mode 100644 index 0000000000..cb585934ed --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/Models/SubscriptionInfo.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Subscription.Models; + +/// +/// Represents an Azure subscription with default subscription indicator. +/// +/// The subscription ID. +/// The display name of the subscription. +/// The subscription state (e.g., Enabled, Disabled). +/// The tenant ID associated with the subscription. +/// 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. +internal record SubscriptionInfo( + string SubscriptionId, + string DisplayName, + string? State, + string? TenantId, + bool IsDefault); diff --git a/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs b/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs index f54b0ed857..093cd39606 100644 --- a/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs +++ b/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs @@ -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"); diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs index 09c67cf977..d0b0b8dac1 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs @@ -13,4 +13,11 @@ public interface ISubscriptionService bool IsSubscriptionId(string subscription, string? tenant = null); Task GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + /// The default subscription ID if found, null otherwise. + string? GetDefaultSubscriptionId(); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs index 09a1df6f69..2f13a09811 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs @@ -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; @@ -96,6 +97,12 @@ public async Task GetSubscriptionNameById(string subscriptionId, string? return subscription.DisplayName; } + /// + public string? GetDefaultSubscriptionId() + { + return CommandHelper.GetDefaultSubscription(); + } + private async Task GetSubscriptionId(string subscription, string? tenant, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken) { if (IsSubscriptionId(subscription)) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/AzureCliProfileHelperTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/AzureCliProfileHelperTests.cs new file mode 100644 index 0000000000..6c395c965b --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/AzureCliProfileHelperTests.cs @@ -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"); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs index cb7eee27b8..7bc22c0e26 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs @@ -56,6 +56,7 @@ public async Task ExecuteAsync_NoParameters_ReturnsSubscriptions() _subscriptionService .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(expectedSubscriptions); + _subscriptionService.GetDefaultSubscriptionId().Returns((string?)null); var args = _commandDefinition.Parse(""); @@ -77,8 +78,10 @@ public async Task ExecuteAsync_NoParameters_ReturnsSubscriptions() Assert.Equal("sub1", first.GetProperty("subscriptionId").GetString()); Assert.Equal("Subscription 1", first.GetProperty("displayName").GetString()); + Assert.False(first.GetProperty("isDefault").GetBoolean()); Assert.Equal("sub2", second.GetProperty("subscriptionId").GetString()); Assert.Equal("Subscription 2", second.GetProperty("displayName").GetString()); + Assert.False(second.GetProperty("isDefault").GetBoolean()); await _subscriptionService.Received(1).GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -93,6 +96,7 @@ public async Task ExecuteAsync_WithTenantId_PassesTenantToService() _subscriptionService .GetSubscriptions(Arg.Is(x => x == tenantId), Arg.Any(), Arg.Any()) .Returns([SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Sub1")]); + _subscriptionService.GetDefaultSubscriptionId().Returns((string?)null); // Act var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); @@ -113,6 +117,7 @@ public async Task ExecuteAsync_EmptySubscriptionList_ReturnsNotNullResults() _subscriptionService .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) .Returns([]); + _subscriptionService.GetDefaultSubscriptionId().Returns((string?)null); var args = _commandDefinition.Parse(""); @@ -155,6 +160,7 @@ public async Task ExecuteAsync_WithAuthMethod_PassesAuthMethodToCommand() _subscriptionService .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) .Returns([SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Sub1")]); + _subscriptionService.GetDefaultSubscriptionId().Returns((string?)null); // Act var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); @@ -168,4 +174,158 @@ await _subscriptionService.Received(1).GetSubscriptions( Arg.Any()); } + [Fact] + public async Task ExecuteAsync_WithDefaultSubscription_MarksDefaultSubscription() + { + // Arrange + var expectedSubscriptions = new List + { + SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Subscription 1"), + SubscriptionTestHelpers.CreateSubscriptionData("sub2", "Subscription 2") + }; + + _subscriptionService + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(expectedSubscriptions); + _subscriptionService.GetDefaultSubscriptionId().Returns("sub2"); + + var args = _commandDefinition.Parse(""); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + Assert.NotNull(result.Results); + + var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(result.Results)); + var subscriptionsArray = jsonDoc.RootElement.GetProperty("subscriptions"); + + Assert.Equal(2, subscriptionsArray.GetArrayLength()); + + // Default subscription should be first + var first = subscriptionsArray[0]; + Assert.Equal("sub2", first.GetProperty("subscriptionId").GetString()); + Assert.True(first.GetProperty("isDefault").GetBoolean()); + + var second = subscriptionsArray[1]; + Assert.Equal("sub1", second.GetProperty("subscriptionId").GetString()); + Assert.False(second.GetProperty("isDefault").GetBoolean()); + } + + [Fact] + public async Task ExecuteAsync_WithNoDefaultSubscription_AllSubscriptionsNotDefault() + { + // Arrange + var expectedSubscriptions = new List + { + SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Subscription 1"), + SubscriptionTestHelpers.CreateSubscriptionData("sub2", "Subscription 2") + }; + + _subscriptionService + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(expectedSubscriptions); + _subscriptionService.GetDefaultSubscriptionId().Returns((string?)null); + + var args = _commandDefinition.Parse(""); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + Assert.NotNull(result.Results); + + var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(result.Results)); + var subscriptionsArray = jsonDoc.RootElement.GetProperty("subscriptions"); + + Assert.Equal(2, subscriptionsArray.GetArrayLength()); + + // No subscription should be marked as default + for (int i = 0; i < subscriptionsArray.GetArrayLength(); i++) + { + Assert.False(subscriptionsArray[i].GetProperty("isDefault").GetBoolean()); + } + } + + [Fact] + public void MapToSubscriptionInfos_WithDefaultSubscriptionId_DefaultIsFirst() + { + // Arrange + var subscriptions = new List + { + SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Subscription 1"), + SubscriptionTestHelpers.CreateSubscriptionData("sub2", "Subscription 2"), + SubscriptionTestHelpers.CreateSubscriptionData("sub3", "Subscription 3") + }; + + // Act + var result = SubscriptionListCommand.MapToSubscriptionInfos(subscriptions, "sub2"); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("sub2", result[0].SubscriptionId); + Assert.True(result[0].IsDefault); + Assert.False(result[1].IsDefault); + Assert.False(result[2].IsDefault); + } + + [Fact] + public void MapToSubscriptionInfos_WithNoDefaultSubscriptionId_NoneMarkedDefault() + { + // Arrange + var subscriptions = new List + { + SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Subscription 1"), + SubscriptionTestHelpers.CreateSubscriptionData("sub2", "Subscription 2") + }; + + // Act + var result = SubscriptionListCommand.MapToSubscriptionInfos(subscriptions, null); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.False(s.IsDefault)); + } + + [Fact] + public void MapToSubscriptionInfos_WithNonMatchingDefaultId_NoneMarkedDefault() + { + // Arrange + var subscriptions = new List + { + SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Subscription 1"), + SubscriptionTestHelpers.CreateSubscriptionData("sub2", "Subscription 2") + }; + + // Act + var result = SubscriptionListCommand.MapToSubscriptionInfos(subscriptions, "non-existent"); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.False(s.IsDefault)); + } + + [Fact] + public void MapToSubscriptionInfos_IncludesStateAndTenantId() + { + // Arrange + var subscriptions = new List + { + SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Subscription 1") + }; + + // Act + var result = SubscriptionListCommand.MapToSubscriptionInfos(subscriptions, null); + + // Assert + Assert.Single(result); + Assert.Equal("sub1", result[0].SubscriptionId); + Assert.Equal("Subscription 1", result[0].DisplayName); + Assert.NotNull(result[0].State); + Assert.NotNull(result[0].TenantId); + } } diff --git a/core/Microsoft.Mcp.Core/src/Helpers/AzureCliProfileHelper.cs b/core/Microsoft.Mcp.Core/src/Helpers/AzureCliProfileHelper.cs new file mode 100644 index 0000000000..41507cc630 --- /dev/null +++ b/core/Microsoft.Mcp.Core/src/Helpers/AzureCliProfileHelper.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security; +using System.Text.Json; + +namespace Azure.Mcp.Core.Helpers +{ + /// + /// Reads the default Azure subscription from the Azure CLI profile + /// stored at ~/.azure/azureProfile.json (set via 'az account set'). + /// + public static class AzureCliProfileHelper + { + /// + /// Gets the default subscription ID from the Azure CLI profile (~/.azure/azureProfile.json). + /// + /// The default subscription ID if found, null otherwise. + public static string? GetDefaultSubscriptionId() + { + try + { + var profilePath = GetAzureProfilePath(); + if (string.IsNullOrEmpty(profilePath) || !File.Exists(profilePath)) + { + return null; + } + + // Synchronous read is intentional: the Azure CLI profile is a small local file + // and the result is cached by CommandHelper so this runs at most once per process. + var json = File.ReadAllText(profilePath); + return ParseDefaultSubscriptionId(json); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or SecurityException) + { + // Best-effort: profile may be missing, corrupted, or inaccessible + return null; + } + } + + /// + /// Parses the default subscription ID from the given Azure CLI profile JSON content. + /// + /// The JSON content of the Azure CLI profile. + /// The default subscription ID if found, null otherwise. + internal static string? ParseDefaultSubscriptionId(string json) + { + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("subscriptions", out var subscriptions) || + subscriptions.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var sub in subscriptions.EnumerateArray()) + { + if (sub.TryGetProperty("isDefault", out var isDefault) && + isDefault.ValueKind == JsonValueKind.True && + sub.TryGetProperty("id", out var id) && + id.ValueKind == JsonValueKind.String) + { + return id.GetString(); + } + } + + return null; + } + + internal static string? GetAzureProfilePath() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(userProfile)) + { + return null; + } + + var azureDir = Path.Combine(userProfile, ".azure"); + return Path.Combine(azureDir, "azureProfile.json"); + } + } +} diff --git a/core/Microsoft.Mcp.Core/src/Helpers/CommandHelper.cs b/core/Microsoft.Mcp.Core/src/Helpers/CommandHelper.cs index 6cd8327d2a..aef414f879 100644 --- a/core/Microsoft.Mcp.Core/src/Helpers/CommandHelper.cs +++ b/core/Microsoft.Mcp.Core/src/Helpers/CommandHelper.cs @@ -9,29 +9,64 @@ namespace Azure.Mcp.Core.Helpers { public static class CommandHelper { + // Cache the default subscription to avoid redundant file I/O. + // The Azure CLI profile is read at most once per process invocation. + private static readonly Lazy s_defaultSubscription = new(ResolveDefaultSubscription); + /// - /// Checks if a subscription is available either from the command option or AZURE_SUBSCRIPTION_ID environment variable. + /// Checks if a subscription is available from the command option, Azure CLI profile, or AZURE_SUBSCRIPTION_ID environment variable. /// /// The command result to check for the subscription option. /// True if a subscription is available, false otherwise. public static bool HasSubscriptionAvailable(CommandResult commandResult) { - var hasOption = commandResult.HasOptionResult(OptionDefinitions.Common.Subscription.Name); - var hasEnv = !string.IsNullOrEmpty(EnvironmentHelpers.GetAzureSubscriptionId()); - return hasOption || hasEnv; + if (commandResult.HasOptionResult(OptionDefinitions.Common.Subscription.Name)) + { + return true; + } + + return !string.IsNullOrEmpty(GetDefaultSubscription()); } public static string? GetSubscription(ParseResult parseResult) { - // Get subscription from command line option or fallback to environment variable + // Get subscription from command line option or fallback to default subscription var subscriptionValue = parseResult.GetValueOrDefault(OptionDefinitions.Common.Subscription.Name); - var envSubscription = EnvironmentHelpers.GetAzureSubscriptionId(); - return (string.IsNullOrEmpty(subscriptionValue) || IsPlaceholder(subscriptionValue)) && !string.IsNullOrEmpty(envSubscription) - ? envSubscription + if (!string.IsNullOrEmpty(subscriptionValue) && !IsPlaceholder(subscriptionValue)) + { + return subscriptionValue; + } + + var defaultSubscription = GetDefaultSubscription(); + return !string.IsNullOrEmpty(defaultSubscription) + ? defaultSubscription : subscriptionValue; } + /// + /// Gets the default subscription from the Azure CLI profile (~/.azure/azureProfile.json), + /// falling back to the AZURE_SUBSCRIPTION_ID environment variable. + /// The result is cached for the lifetime of the process to avoid redundant file I/O. + /// + public static string? GetDefaultSubscription() + { + return s_defaultSubscription.Value; + } + + private static string? ResolveDefaultSubscription() + { + // Primary: Azure CLI profile (set via 'az account set') + var profileDefault = AzureCliProfileHelper.GetDefaultSubscriptionId(); + if (!string.IsNullOrEmpty(profileDefault)) + { + return profileDefault; + } + + // Fallback: AZURE_SUBSCRIPTION_ID environment variable + return EnvironmentHelpers.GetAzureSubscriptionId(); + } + private static bool IsPlaceholder(string value) => value.Contains("subscription") || value.Contains("default"); } } diff --git a/servers/Azure.Mcp.Server/changelog-entries/1774050000001.yaml b/servers/Azure.Mcp.Server/changelog-entries/1774050000001.yaml new file mode 100644 index 0000000000..df61742a12 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1774050000001.yaml @@ -0,0 +1,6 @@ +pr: 1974 +changes: + - section: "Features Added" + description: "Subscription: Read default subscription from Azure CLI profile (~/.azure/azureProfile.json) for all subscription-scoped commands, falling back to AZURE_SUBSCRIPTION_ID environment variable" + - section: "Breaking Changes" + description: "Subscription: The `subscription list` command now returns a narrower model (subscriptionId, displayName, state, tenantId, isDefault) instead of the full Azure SDK SubscriptionData type" diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 82c7f6a270..55db05398c 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3086,7 +3086,9 @@ azmcp storagesync serverendpoint update --subscription \ ### Azure Subscription Management ```bash -# List available Azure subscriptions +# List available Azure subscriptions with default subscription indicator +# Returns subscriptionId, displayName, state, tenantId, and isDefault for each subscription +# The isDefault field is true for the default subscription set via 'az account set' or AZURE_SUBSCRIPTION_ID env var # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp subscription list [--tenant-id ] ```