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..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 @@ -3,6 +3,7 @@ using System.Reflection; using Azure.Core; +using Azure.Identity; using Microsoft.Extensions.Logging; using Xunit; @@ -38,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 @@ -57,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 @@ -75,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 @@ -93,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 @@ -111,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 @@ -135,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 @@ -153,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"); @@ -172,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 @@ -190,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 @@ -209,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 @@ -228,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"); @@ -239,6 +251,155 @@ 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 + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); + 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 + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); + 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 + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); + 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 + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); + 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 + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); + 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 +427,36 @@ 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); + } + + /// + /// 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/Areas/Server/Commands/ServiceStartCommand.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs index fe8b56a51a..914dcc2334 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs @@ -383,6 +383,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 2ef1c89ba2..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,19 +29,23 @@ 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" /// Environment → Workload Identity → Managed Identity (no interactive fallback) /// /// +/// "DeviceCodeCredential" +/// 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 /// Only that credential (e.g., "AzureCliCredential" or "ManagedIdentityCredential") with no fallback /// /// /// 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) /// /// /// @@ -52,15 +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=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) @@ -83,6 +93,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, forceBrowserFallback); @@ -152,7 +168,7 @@ private static TokenCredential CreateCredential(string? tenantId, ILogger credent credentials.Add(new SafeTokenCredential(new AzureDeveloperCliCredential(azdOptions), "AzureDeveloperCliCredential", normalizeScopes: true)); } + 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."); + } + + 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..f0fd977741 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1748900000000.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + 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."