Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Reflection;
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Logging;
using Xunit;

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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");

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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");

Expand All @@ -239,6 +251,155 @@ public void VSCodeContext_WithExplicitProdSetting_CreatesCredentialSuccessfully(
Assert.IsAssignableFrom<TokenCredential>(credential);
}

/// <summary>
/// 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).
/// </summary>
[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<TokenCredential>(credential);
}

/// <summary>
/// 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.
/// </summary>
[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<CredentialUnavailableException>(() =>
credential.GetToken(new TokenRequestContext(["https://management.azure.com/.default"]), CancellationToken.None));
}
finally
{
SetActiveTransport(credentialType, string.Empty);
}
}

/// <summary>
/// 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.
/// </summary>
[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<TokenCredential>(credential);
}
finally
{
SetActiveTransport(credentialType, string.Empty);
}
}

/// <summary>
/// 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.
/// </summary>
[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<TokenCredential>(credential);
}
finally
{
SetActiveTransport(credentialType, string.Empty);
}
}

/// <summary>
/// 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.
/// </summary>
[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<TokenCredential>(credential);
}

/// <summary>
/// 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.
/// </summary>
[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<TokenCredential>(credential);
}

/// <summary>
/// Helper method to create CustomChainedCredential using reflection since it's an internal class.
/// </summary>
Expand Down Expand Up @@ -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);
}

/// <summary>
/// Saves the current values of the specified environment variables and restores them on disposal.
/// Use with <c>using var</c> to guarantee restoration even when a test throws.
/// </summary>
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ InvalidOperationException invOpEx when invOpEx.Message.Contains("Using --dangero
/// <returns>An IHost instance configured for the MCP server.</returns>
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)
{
Expand Down
Loading