Skip to content

Commit f91bf90

Browse files
authored
Read default subscription from Azure CLI profile for all subscription-scoped commands (#1974)
1 parent 66e5fd2 commit f91bf90

12 files changed

Lines changed: 508 additions & 14 deletions

File tree

core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
// Licensed under the MIT License.
33

44
using System.Text.Json.Serialization;
5+
using Azure.Mcp.Core.Areas.Subscription.Models;
56

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

89
[JsonSerializable(typeof(SubscriptionListCommand.SubscriptionListCommandResult))]
10+
[JsonSerializable(typeof(SubscriptionInfo))]
911
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
1012
internal partial class SubscriptionJsonContext : JsonSerializerContext
1113
{

core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

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

2324
public override string Description =>
24-
"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.";
25+
"List all Azure subscriptions for the current account. Returns subscriptionId, displayName, state, tenantId, and isDefault for each subscription. " +
26+
"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. " +
27+
"When the user has not specified a subscription, prefer the subscription where isDefault is true. " +
28+
"If no default can be determined from either source and multiple subscriptions exist, ask the user which subscription to use.";
29+
2530
public override string Title => CommandTitle;
2631

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

51-
context.Response.Results = ResponseResult.Create(new(subscriptions), SubscriptionJsonContext.Default.SubscriptionListCommandResult);
56+
var defaultSubscriptionId = subscriptionService.GetDefaultSubscriptionId();
57+
var subscriptionInfos = MapToSubscriptionInfos(subscriptions, defaultSubscriptionId);
58+
59+
context.Response.Results = ResponseResult.Create(
60+
new SubscriptionListCommandResult(subscriptionInfos),
61+
SubscriptionJsonContext.Default.SubscriptionListCommandResult);
5262
}
5363
catch (Exception ex)
5464
{
@@ -59,5 +69,26 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
5969
return context.Response;
6070
}
6171

62-
internal record SubscriptionListCommandResult(List<SubscriptionData> Subscriptions);
72+
internal static List<SubscriptionInfo> MapToSubscriptionInfos(List<SubscriptionData> subscriptions, string? defaultSubscriptionId)
73+
{
74+
var hasDefault = !string.IsNullOrEmpty(defaultSubscriptionId);
75+
76+
var infos = subscriptions.Select(s => new SubscriptionInfo(
77+
s.SubscriptionId,
78+
s.DisplayName,
79+
s.State?.ToString(),
80+
s.TenantId?.ToString(),
81+
hasDefault && s.SubscriptionId.Equals(defaultSubscriptionId, StringComparison.OrdinalIgnoreCase)
82+
)).ToList();
83+
84+
// Sort so the default subscription appears first
85+
if (hasDefault)
86+
{
87+
infos = [.. infos.OrderByDescending(s => s.IsDefault)];
88+
}
89+
90+
return infos;
91+
}
92+
93+
internal record SubscriptionListCommandResult(List<SubscriptionInfo> Subscriptions);
6394
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Azure.Mcp.Core.Areas.Subscription.Models;
5+
6+
/// <summary>
7+
/// Represents an Azure subscription with default subscription indicator.
8+
/// </summary>
9+
/// <param name="SubscriptionId">The subscription ID.</param>
10+
/// <param name="DisplayName">The display name of the subscription.</param>
11+
/// <param name="State">The subscription state (e.g., Enabled, Disabled).</param>
12+
/// <param name="TenantId">The tenant ID associated with the subscription.</param>
13+
/// <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>
14+
internal record SubscriptionInfo(
15+
string SubscriptionId,
16+
string DisplayName,
17+
string? State,
18+
string? TenantId,
19+
bool IsDefault);

core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ protected override void RegisterOptions(Command command)
1818
command.Options.Add(OptionDefinitions.Common.Subscription);
1919
command.Validators.Add(commandResult =>
2020
{
21-
// Command-level validation for presence: allow either --subscription or AZURE_SUBSCRIPTION_ID
22-
// This mirrors the prior behavior that preferred the explicit option but fell back to env var.
21+
// Command-level validation for presence: allow either --subscription,
22+
// Azure CLI profile default, or AZURE_SUBSCRIPTION_ID env var.
2323
if (!CommandHelper.HasSubscriptionAvailable(commandResult))
2424
{
2525
commandResult.AddError("Missing Required options: --subscription");

core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,11 @@ public interface ISubscriptionService
1313
bool IsSubscriptionId(string subscription, string? tenant = null);
1414
Task<string> GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);
1515
Task<string> GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);
16+
17+
/// <summary>
18+
/// Gets the default subscription ID from the Azure CLI profile (~/.azure/azureProfile.json).
19+
/// Falls back to the AZURE_SUBSCRIPTION_ID environment variable if the profile is unavailable.
20+
/// </summary>
21+
/// <returns>The default subscription ID if found, null otherwise.</returns>
22+
string? GetDefaultSubscriptionId();
1623
}

core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using Azure.Mcp.Core.Helpers;
45
using Azure.Mcp.Core.Options;
56
using Azure.Mcp.Core.Services.Azure.Tenant;
67
using Azure.Mcp.Core.Services.Caching;
@@ -96,6 +97,12 @@ public async Task<string> GetSubscriptionNameById(string subscriptionId, string?
9697
return subscription.DisplayName;
9798
}
9899

100+
/// <inheritdoc/>
101+
public string? GetDefaultSubscriptionId()
102+
{
103+
return CommandHelper.GetDefaultSubscription();
104+
}
105+
99106
private async Task<string> GetSubscriptionId(string subscription, string? tenant, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken)
100107
{
101108
if (IsSubscriptionId(subscription))
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Azure.Mcp.Core.Helpers;
5+
using Xunit;
6+
7+
namespace Azure.Mcp.Core.UnitTests.Areas.Subscription;
8+
9+
public class AzureCliProfileHelperTests
10+
{
11+
[Fact]
12+
public void ParseDefaultSubscriptionId_ValidProfile_ReturnsDefaultId()
13+
{
14+
var profileJson = """
15+
{
16+
"subscriptions": [
17+
{
18+
"id": "sub-1111-1111",
19+
"name": "Subscription One",
20+
"state": "Enabled",
21+
"tenantId": "tenant-1111",
22+
"isDefault": false
23+
},
24+
{
25+
"id": "sub-2222-2222",
26+
"name": "Subscription Two",
27+
"state": "Enabled",
28+
"tenantId": "tenant-2222",
29+
"isDefault": true
30+
},
31+
{
32+
"id": "sub-3333-3333",
33+
"name": "Subscription Three",
34+
"state": "Enabled",
35+
"tenantId": "tenant-3333",
36+
"isDefault": false
37+
}
38+
]
39+
}
40+
""";
41+
42+
var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);
43+
44+
Assert.Equal("sub-2222-2222", result);
45+
}
46+
47+
[Fact]
48+
public void ParseDefaultSubscriptionId_NoDefaultInProfile_ReturnsNull()
49+
{
50+
var profileJson = """
51+
{
52+
"subscriptions": [
53+
{
54+
"id": "sub-1111-1111",
55+
"name": "Subscription One",
56+
"state": "Enabled",
57+
"tenantId": "tenant-1111",
58+
"isDefault": false
59+
}
60+
]
61+
}
62+
""";
63+
64+
var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);
65+
66+
Assert.Null(result);
67+
}
68+
69+
[Fact]
70+
public void ParseDefaultSubscriptionId_EmptySubscriptions_ReturnsNull()
71+
{
72+
var profileJson = """
73+
{
74+
"subscriptions": []
75+
}
76+
""";
77+
78+
var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);
79+
80+
Assert.Null(result);
81+
}
82+
83+
[Fact]
84+
public void ParseDefaultSubscriptionId_NoSubscriptionsProperty_ReturnsNull()
85+
{
86+
var profileJson = """
87+
{
88+
"installationId": "some-id"
89+
}
90+
""";
91+
92+
var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);
93+
94+
Assert.Null(result);
95+
}
96+
97+
[Fact]
98+
public void ParseDefaultSubscriptionId_MissingIdOnDefault_ReturnsNull()
99+
{
100+
var profileJson = """
101+
{
102+
"subscriptions": [
103+
{
104+
"name": "Subscription One",
105+
"isDefault": true
106+
}
107+
]
108+
}
109+
""";
110+
111+
var result = AzureCliProfileHelper.ParseDefaultSubscriptionId(profileJson);
112+
113+
Assert.Null(result);
114+
}
115+
116+
[Fact]
117+
public void GetAzureProfilePath_WhenUserProfileAvailable_ReturnsExpectedPath()
118+
{
119+
var result = AzureCliProfileHelper.GetAzureProfilePath();
120+
121+
// In containerized/CI environments, user profile may not be available
122+
if (string.IsNullOrEmpty(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)))
123+
{
124+
Assert.Null(result);
125+
}
126+
else
127+
{
128+
Assert.NotNull(result);
129+
Assert.Contains(".azure", result);
130+
Assert.EndsWith("azureProfile.json", result);
131+
}
132+
}
133+
134+
[Fact]
135+
public void GetAzureProfilePath_ReturnsNullOrValidPath()
136+
{
137+
var result = AzureCliProfileHelper.GetAzureProfilePath();
138+
139+
// The method must either return null (empty user profile) or a valid absolute path
140+
if (result != null)
141+
{
142+
Assert.False(string.IsNullOrWhiteSpace(result));
143+
Assert.Contains(".azure", result);
144+
Assert.EndsWith("azureProfile.json", result);
145+
Assert.True(Path.IsPathRooted(result), "Path should be absolute, not relative");
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)