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); + } } 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..e3f7fc541b 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) /// /// @@ -83,6 +88,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 +163,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. " + + "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..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."