From cf93ecb7c0f26b8900aa21799b7fecbf5efc0291 Mon Sep 17 00:00:00 2001 From: g2vinay Date: Tue, 3 Mar 2026 11:09:54 -0800 Subject: [PATCH 1/6] feat(auth): add DeviceCodeCredential support via AZURE_TOKEN_CREDENTIALS - Add DeviceCodeCredential as a named opt-in: set AZURE_TOKEN_CREDENTIALS=DeviceCodeCredential to enable device code flow for headless environments (Docker, WSL, SSH, CI) - Add AddDeviceCodeCredential() helper following the same pattern as all other Add*Credential() methods: respects AZURE_MCP_CLIENT_ID, TokenCachePersistenceOptions, CloudConfiguration.AuthorityHost, and AZURE_MCP_AUTHENTICATION_RECORD for token cache reuse - Add ActiveTransport static property to CustomChainedCredential; set by ServiceStartCommand before the credential chain is first built - Guard DeviceCodeCredential activation: throws CredentialUnavailableException when ActiveTransport is non-empty (stdio or http server mode) because stdout is a protocol pipe in stdio mode and there is no attached user terminal in http mode - Update XML doc comments to document the new credential option and update the 'not added' browser fallback section --- .../Server/Commands/ServiceStartCommand.cs | 4 ++ .../Authentication/CustomChainedCredential.cs | 58 +++++++++++++++++++ .../changelog-entries/1748900000000.yaml | 3 + 3 files changed, 65 insertions(+) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs index baca56c051..b6f90ada0b 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs @@ -373,6 +373,10 @@ InvalidOperationException invOpEx when invOpEx.Message.Contains("Using --dangero /// An IHost instance configured for the MCP server. private IHost CreateHost(ServiceStartOptions serverOptions) { + // Inform the credential chain which transport is active so that interactive credentials + // that require a user-facing terminal (e.g. DeviceCodeCredential) can refuse to activate. + CustomChainedCredential.ActiveTransport = serverOptions.Transport; + #if ENABLE_HTTP if (serverOptions.Transport == TransportTypes.Http) { diff --git a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs index b612f74ccf..a75887652f 100644 --- a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs +++ b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs @@ -36,6 +36,10 @@ namespace Azure.Mcp.Core.Services.Azure.Authentication; /// Environment → Workload Identity → Managed Identity (no interactive fallback) /// /// +/// "DeviceCodeCredential" +/// Device code flow — prints a URL and one-time code to stdout; works in headless environments (Docker, WSL, SSH, CI) +/// +/// /// Specific credential name /// Only that credential (e.g., "AzureCliCredential" or "ManagedIdentityCredential") with no fallback /// @@ -60,6 +64,7 @@ namespace Azure.Mcp.Core.Services.Azure.Authentication; /// /// It is NOT added when: /// - AZURE_TOKEN_CREDENTIALS="prod" (production credentials only, fail fast if unavailable) +/// - AZURE_TOKEN_CREDENTIALS="DeviceCodeCredential" (non-interactive device code flow requested) /// - AZURE_TOKEN_CREDENTIALS=specific credential name (user wants only that credential, fail fast) /// /// @@ -77,6 +82,12 @@ internal class CustomChainedCredential(string? tenantId = null, ILogger internal static IAzureCloudConfiguration? CloudConfiguration { get; set; } + /// + /// Active transport type ("stdio" or "http"). Set by + /// before the credential chain is first used. Empty when not running as a server (e.g. direct CLI invocation). + /// + internal static string ActiveTransport { get; set; } = string.Empty; + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) { _credential ??= CreateCredential(tenantId, _logger); @@ -262,6 +273,10 @@ private static ChainedTokenCredential CreateDefaultCredential(string? tenantId) AddAzureDeveloperCliCredential(credentials, tenantId); break; + case "devicecodecredential": + AddDeviceCodeCredential(credentials, tenantId); + break; + default: // Unknown value, fall back to default chain AddDefaultCredentialChain(credentials, tenantId); @@ -405,6 +420,49 @@ private static void AddAzureDeveloperCliCredential(List credent credentials.Add(new SafeTokenCredential(new AzureDeveloperCliCredential(azdOptions), "AzureDeveloperCliCredential")); } + private static void AddDeviceCodeCredential(List credentials, string? tenantId) + { + // DeviceCodeCredential requires an interactive terminal to display the device code prompt. + // In stdio mode stdout is the MCP protocol pipe — writing to it would corrupt the transport. + // In http mode there is no user-facing terminal attached to the server process. + if (!string.IsNullOrEmpty(ActiveTransport)) + { + throw new CredentialUnavailableException( + $"DeviceCodeCredential is not available when the server is running in '{ActiveTransport}' transport mode. " + + "DeviceCodeCredential requires an interactive terminal to display the device code prompt. " + + "Use an automated credential such as AzureCliCredential or ManagedIdentityCredential instead."); + } + + string? clientId = Environment.GetEnvironmentVariable(ClientIdEnvVarName); + + var deviceCodeOptions = new DeviceCodeCredentialOptions + { + TenantId = string.IsNullOrEmpty(tenantId) ? null : tenantId, + TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = TokenCacheName } + }; + + if (!string.IsNullOrEmpty(clientId)) + { + deviceCodeOptions.ClientId = clientId; + } + + if (CloudConfiguration != null) + { + deviceCodeOptions.AuthorityHost = CloudConfiguration.AuthorityHost; + } + + // Hydrate an existing AuthenticationRecord from the environment to enable silent token cache reuse + string? authRecordJson = Environment.GetEnvironmentVariable(AuthenticationRecordEnvVarName); + if (!string.IsNullOrEmpty(authRecordJson)) + { + byte[] bytes = Encoding.UTF8.GetBytes(authRecordJson); + using MemoryStream stream = new(bytes); + deviceCodeOptions.AuthenticationRecord = AuthenticationRecord.Deserialize(stream); + } + + credentials.Add(new SafeTokenCredential(new DeviceCodeCredential(deviceCodeOptions), "DeviceCodeCredential")); + } + private static ChainedTokenCredential CreateVsCodePrioritizedCredential(string? tenantId) { var credentials = new List(); diff --git a/servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml b/servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml new file mode 100644 index 0000000000..77f9c3cb98 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added `DeviceCodeCredential` support. Set `AZURE_TOKEN_CREDENTIALS=DeviceCodeCredential` for headless environments (Docker, WSL, SSH tunnels, CI) where browser-based interactive authentication is unavailable." From 6f686340ac6d22359c4b9f88e98745779ac49188 Mon Sep 17 00:00:00 2001 From: g2vinay Date: Fri, 6 Mar 2026 13:46:50 -0800 Subject: [PATCH 2/6] feat(auth): add DeviceCodeCredential as interactive fallback in default and dev chains --- .../Authentication/CustomChainedCredential.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs index ef4d71f878..e3f7fc541b 100644 --- a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs +++ b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs @@ -163,7 +163,7 @@ private static TokenCredential CreateCredential(string? tenantId, ILogger Date: Fri, 6 Mar 2026 15:59:23 -0800 Subject: [PATCH 3/6] update changelog --- servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml b/servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml index 77f9c3cb98..f0fd977741 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml @@ -1,3 +1,3 @@ changes: - section: "Features Added" - description: "Added `DeviceCodeCredential` support. Set `AZURE_TOKEN_CREDENTIALS=DeviceCodeCredential` for headless environments (Docker, WSL, SSH tunnels, CI) where browser-based interactive authentication is unavailable." + description: "Added `DeviceCodeCredential` support for headless environments (Docker, WSL, SSH tunnels, CI) where browser-based interactive authentication is unavailable. It is automatically used as a last-resort fallback in the default and `dev` credential chains, and can also be selected exclusively by setting `AZURE_TOKEN_CREDENTIALS=DeviceCodeCredential`. Not available in `stdio` or `http` server transport modes." From 636bac742b07079b6358433662443b713df8cef4 Mon Sep 17 00:00:00 2001 From: g2vinay Date: Fri, 6 Mar 2026 16:10:08 -0800 Subject: [PATCH 4/6] add unit tests --- .../CustomChainedCredentialTests.cs | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs index 4f77cf62b5..db7e1c4df6 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs @@ -3,6 +3,7 @@ using System.Reflection; using Azure.Core; +using Azure.Identity; using Microsoft.Extensions.Logging; using Xunit; @@ -239,6 +240,150 @@ public void VSCodeContext_WithExplicitProdSetting_CreatesCredentialSuccessfully( Assert.IsAssignableFrom(credential); } + /// + /// Tests that explicit DeviceCodeCredential request creates successfully in CLI mode. + /// Expected: DeviceCodeCredential is created when AZURE_TOKEN_CREDENTIALS="DeviceCodeCredential" + /// and no server transport is active (ActiveTransport is empty). + /// + [Fact] + public void DeviceCodeCredential_ExplicitMode_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "DeviceCodeCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that DeviceCodeCredential throws CredentialUnavailableException when the server is in a + /// transport mode (stdio or http), because stdout is the protocol pipe and no terminal is attached. + /// Expected: GetToken throws CredentialUnavailableException. + /// + [Theory] + [InlineData("stdio")] + [InlineData("http")] + public void DeviceCodeCredential_InServerTransportMode_ThrowsCredentialUnavailableException(string transport) + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "DeviceCodeCredential"); + var credentialType = GetCustomChainedCredentialType(); + SetActiveTransport(credentialType, transport); + + try + { + var credential = CreateCustomChainedCredential(); + + // Act & Assert — GetToken triggers lazy credential construction, which throws + Assert.Throws(() => + credential.GetToken(new TokenRequestContext(["https://management.azure.com/.default"]), CancellationToken.None)); + } + finally + { + SetActiveTransport(credentialType, string.Empty); + } + } + + /// + /// Tests that the default credential chain in server transport mode creates a credential + /// successfully. DeviceCodeCredential fallback is suppressed but the rest of the chain is intact. + /// + [Theory] + [InlineData("stdio")] + [InlineData("http")] + public void DefaultBehavior_InServerTransportMode_CreatesCredentialSuccessfully(string transport) + { + // Arrange + var credentialType = GetCustomChainedCredentialType(); + SetActiveTransport(credentialType, transport); + + try + { + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + finally + { + SetActiveTransport(credentialType, string.Empty); + } + } + + /// + /// Tests that dev mode in server transport mode creates a credential successfully. + /// DeviceCodeCredential fallback is suppressed, but the dev chain (VS, VS Code, CLI, etc.) remains. + /// + [Theory] + [InlineData("stdio")] + [InlineData("http")] + public void DevMode_InServerTransportMode_CreatesCredentialSuccessfully(string transport) + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "dev"); + var credentialType = GetCustomChainedCredentialType(); + SetActiveTransport(credentialType, transport); + + try + { + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + finally + { + SetActiveTransport(credentialType, string.Empty); + } + } + + /// + /// Tests that prod mode does not add DeviceCodeCredential as a fallback. + /// Prod is a pinned credential mode, so no interactive fallbacks (browser or device code) are added. + /// + [Fact] + public void ProdMode_DoesNotAddDeviceCodeFallback_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "prod"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that a pinned specific credential does not add DeviceCodeCredential as a fallback. + /// Any explicit non-dev, non-browser credential setting is a pinned mode. + /// + [Theory] + [InlineData("AzureCliCredential")] + [InlineData("ManagedIdentityCredential")] + [InlineData("EnvironmentCredential")] + public void PinnedCredentialMode_DoesNotAddDeviceCodeFallback_CreatesCredentialSuccessfully(string credentialType) + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", credentialType); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + /// /// Helper method to create CustomChainedCredential using reflection since it's an internal class. /// @@ -266,4 +411,20 @@ private static TokenCredential CreateCustomChainedCredential() return credential; } + + private static Type GetCustomChainedCredentialType() + { + var assembly = typeof(global::Azure.Mcp.Core.Services.Azure.Authentication.IAzureTokenCredentialProvider).Assembly; + var type = assembly.GetType("Azure.Mcp.Core.Services.Azure.Authentication.CustomChainedCredential"); + Assert.NotNull(type); + return type; + } + + private static void SetActiveTransport(Type credentialType, string value) + { + var prop = credentialType.GetProperty("ActiveTransport", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(prop); + prop.SetValue(null, value); + } } From 01404bf02ef68ad278142dd6721ad20ada9e1e02 Mon Sep 17 00:00:00 2001 From: g2vinay Date: Sun, 8 Mar 2026 20:50:17 -0700 Subject: [PATCH 5/6] Refactor --- .../Authentication/CustomChainedCredentialTests.cs | 4 ---- .../Azure/Authentication/CustomChainedCredential.cs | 10 ++++------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs index db7e1c4df6..5653acd760 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs @@ -2,10 +2,6 @@ // Licensed under the MIT License. using System.Reflection; -using Azure.Core; -using Azure.Identity; -using Microsoft.Extensions.Logging; -using Xunit; namespace Azure.Mcp.Core.UnitTests.Services.Azure.Authentication; diff --git a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs index e3f7fc541b..600e34ab87 100644 --- a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs +++ b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs @@ -178,11 +178,10 @@ private static TokenCredential CreateCredential(string? tenantId, ILogger credentials, s { throw new CredentialUnavailableException( $"DeviceCodeCredential is not available when the server is running in '{ActiveTransport}' transport mode. " + - "DeviceCodeCredential requires an interactive terminal to display the device code prompt. " + - "Use an automated credential such as AzureCliCredential or ManagedIdentityCredential instead."); + "DeviceCodeCredential requires an interactive terminal to display the device code prompt."); } string? clientId = Environment.GetEnvironmentVariable(ClientIdEnvVarName); From 345e54365784fbe0692741f73cbe11229d667ee4 Mon Sep 17 00:00:00 2001 From: g2vinay Date: Mon, 9 Mar 2026 15:58:49 -0700 Subject: [PATCH 6/6] fix tests + docs. --- .../CustomChainedCredentialTests.cs | 36 +++++++++++++++++++ .../Authentication/CustomChainedCredential.cs | 21 ++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs index 5653acd760..6898e72345 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs @@ -2,6 +2,10 @@ // Licensed under the MIT License. using System.Reflection; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging; +using Xunit; namespace Azure.Mcp.Core.UnitTests.Services.Azure.Authentication; @@ -35,6 +39,7 @@ public void DefaultBehavior_CreatesCredentialSuccessfully() public void DevMode_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "dev"); // Act @@ -54,6 +59,7 @@ public void DevMode_CreatesCredentialSuccessfully() public void ProdMode_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "prod"); // Act @@ -72,6 +78,7 @@ public void ProdMode_CreatesCredentialSuccessfully() public void SpecificCredential_ManagedIdentity_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); // Act @@ -90,6 +97,7 @@ public void SpecificCredential_ManagedIdentity_CreatesCredentialSuccessfully() public void SpecificCredential_AzureCli_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "AzureCliCredential"); // Act @@ -108,6 +116,7 @@ public void SpecificCredential_AzureCli_CreatesCredentialSuccessfully() public void SpecificCredential_InteractiveBrowser_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "InteractiveBrowserCredential"); // Act @@ -132,6 +141,7 @@ public void SpecificCredential_InteractiveBrowser_CreatesCredentialSuccessfully( public void SpecificCredential_VariousTypes_CreateCredentialSuccessfully(string credentialType) { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", credentialType); // Act @@ -150,6 +160,7 @@ public void SpecificCredential_VariousTypes_CreateCredentialSuccessfully(string public void ManagedIdentityCredential_WithClientId_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS", "AZURE_CLIENT_ID"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); Environment.SetEnvironmentVariable("AZURE_CLIENT_ID", "12345678-1234-1234-1234-123456789012"); @@ -169,6 +180,7 @@ public void ManagedIdentityCredential_WithClientId_CreatesCredentialSuccessfully public void ManagedIdentityCredential_WithoutClientId_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); // Act @@ -187,6 +199,7 @@ public void ManagedIdentityCredential_WithoutClientId_CreatesCredentialSuccessfu public void OnlyUseBrokerCredential_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL"); Environment.SetEnvironmentVariable("AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL", "true"); // Act @@ -206,6 +219,7 @@ public void OnlyUseBrokerCredential_CreatesCredentialSuccessfully() public void VSCodeContext_WithoutExplicitSetting_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("VSCODE_PID"); Environment.SetEnvironmentVariable("VSCODE_PID", "12345"); // Act @@ -225,6 +239,7 @@ public void VSCodeContext_WithoutExplicitSetting_CreatesCredentialSuccessfully() public void VSCodeContext_WithExplicitProdSetting_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("VSCODE_PID", "AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("VSCODE_PID", "12345"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "prod"); @@ -245,6 +260,7 @@ public void VSCodeContext_WithExplicitProdSetting_CreatesCredentialSuccessfully( public void DeviceCodeCredential_ExplicitMode_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "DeviceCodeCredential"); // Act @@ -266,6 +282,7 @@ public void DeviceCodeCredential_ExplicitMode_CreatesCredentialSuccessfully() public void DeviceCodeCredential_InServerTransportMode_ThrowsCredentialUnavailableException(string transport) { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "DeviceCodeCredential"); var credentialType = GetCustomChainedCredentialType(); SetActiveTransport(credentialType, transport); @@ -322,6 +339,7 @@ public void DefaultBehavior_InServerTransportMode_CreatesCredentialSuccessfully( public void DevMode_InServerTransportMode_CreatesCredentialSuccessfully(string transport) { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "dev"); var credentialType = GetCustomChainedCredentialType(); SetActiveTransport(credentialType, transport); @@ -349,6 +367,7 @@ public void DevMode_InServerTransportMode_CreatesCredentialSuccessfully(string t public void ProdMode_DoesNotAddDeviceCodeFallback_CreatesCredentialSuccessfully() { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "prod"); // Act @@ -370,6 +389,7 @@ public void ProdMode_DoesNotAddDeviceCodeFallback_CreatesCredentialSuccessfully( public void PinnedCredentialMode_DoesNotAddDeviceCodeFallback_CreatesCredentialSuccessfully(string credentialType) { // Arrange + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", credentialType); // Act @@ -423,4 +443,20 @@ private static void SetActiveTransport(Type credentialType, string value) Assert.NotNull(prop); prop.SetValue(null, value); } + + /// + /// Saves the current values of the specified environment variables and restores them on disposal. + /// Use with using var to guarantee restoration even when a test throws. + /// + private sealed class EnvironmentScope(params string[] names) : IDisposable + { + private readonly (string Name, string? Value)[] _saved = + names.Select(n => (n, Environment.GetEnvironmentVariable(n))).ToArray(); + + public void Dispose() + { + foreach (var (name, value) in _saved) + Environment.SetEnvironmentVariable(name, value); + } + } } diff --git a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs index 600e34ab87..d90ae6d187 100644 --- a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs +++ b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs @@ -29,7 +29,7 @@ namespace Azure.Mcp.Core.Services.Azure.Authentication; /// /// /// "dev" -/// Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI → InteractiveBrowserCredential +/// Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI → InteractiveBrowserCredential → DeviceCodeCredential (CLI mode; fallbacks suppressed in server transport mode) /// /// /// "prod" @@ -37,7 +37,7 @@ namespace Azure.Mcp.Core.Services.Azure.Authentication; /// /// /// "DeviceCodeCredential" -/// Device code flow — prints a URL and one-time code to stdout; works in headless environments (Docker, WSL, SSH, CI) +/// Device code flow — displays a URL and one-time code on the console; works in headless environments (Docker, WSL, SSH, CI). Not available in server transport mode (stdio/http). /// /// /// Specific credential name @@ -45,7 +45,7 @@ namespace Azure.Mcp.Core.Services.Azure.Authentication; /// /// /// Not set or empty -/// Development chain (Environment → Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI) + InteractiveBrowserCredential fallback +/// Development chain (Environment → Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI) + InteractiveBrowserCredential + DeviceCodeCredential as last-resort fallbacks (CLI mode only; both suppressed in server transport mode) /// /// /// @@ -56,16 +56,21 @@ namespace Azure.Mcp.Core.Services.Azure.Authentication; /// Visual Studio Code credential is automatically prioritized first in the chain. /// /// -/// InteractiveBrowserCredential with Identity Broker is added as a final fallback only when: +/// InteractiveBrowserCredential with Identity Broker is added as an interactive fallback only when: /// - AZURE_TOKEN_CREDENTIALS is not set (default behavior) /// - AZURE_TOKEN_CREDENTIALS="dev" (development credentials with interactive fallback) /// - AZURE_TOKEN_CREDENTIALS="InteractiveBrowserCredential" (explicitly requested) +/// It is NOT added when AZURE_TOKEN_CREDENTIALS is "prod" or any specific credential name +/// (user wants only that credential, no interactive popup). /// /// -/// It is NOT added when: -/// - AZURE_TOKEN_CREDENTIALS="prod" (production credentials only, fail fast if unavailable) -/// - AZURE_TOKEN_CREDENTIALS="DeviceCodeCredential" (non-interactive device code flow requested) -/// - AZURE_TOKEN_CREDENTIALS=specific credential name (user wants only that credential, fail fast) +/// DeviceCodeCredential is appended automatically as a last-resort fallback (after +/// InteractiveBrowserCredential) only when ALL of the following are true: +/// - AZURE_TOKEN_CREDENTIALS is not set or is "dev" (non-pinned mode) +/// - AZURE_TOKEN_CREDENTIALS is not "InteractiveBrowserCredential" +/// - ActiveTransport is empty (not running as an MCP server in stdio or http mode) +/// It is NOT appended when a specific credential is pinned (including "prod"), +/// when "InteractiveBrowserCredential" is explicitly requested, or when running as a server. /// /// /// The forceBrowserFallback constructor parameter lets callers (e.g. registry server OAuth)