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 54694b5d8d..27d9f2e52b 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 @@ -4,6 +4,7 @@ using System.Reflection; using Azure.Core; using Azure.Identity; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Xunit; @@ -400,6 +401,66 @@ public void PinnedCredentialMode_DoesNotAddDeviceCodeFallback_CreatesCredentialS Assert.IsAssignableFrom(credential); } + /// + /// Tests that when Configuration is set, token credentials are read from IConfiguration + /// rather than only from environment variables. + /// + [Fact] + public void Configuration_WhenSet_ReadsTokenCredentialsFromConfiguration() + { + // Arrange: env var cleared; IConfiguration provides the credential type + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", null); + + var credentialType = GetCustomChainedCredentialType(); + var configProp = credentialType.GetProperty("Configuration", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(configProp); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["AZURE_TOKEN_CREDENTIALS"] = "AzureCliCredential" }) + .Build(); + configProp.SetValue(null, config); + + try + { + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + finally + { + configProp.SetValue(null, null); + } + } + + /// + /// Tests that when Configuration is null, token credentials fall back to environment variables. + /// + [Fact] + public void Configuration_WhenNull_FallsBackToEnvironmentVariable() + { + // Arrange: Configuration is null; env var provides the credential type + using var env = new EnvironmentScope("AZURE_TOKEN_CREDENTIALS"); + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "AzureCliCredential"); + + var credentialType = GetCustomChainedCredentialType(); + var configProp = credentialType.GetProperty("Configuration", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(configProp); + configProp.SetValue(null, null); // Ensure Configuration is null + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + /// /// Tests that prod mode with forceBrowserFallback=true does NOT add a browser fallback. /// prod always signals a non-interactive environment — the browser popup must never appear. 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 700a55e2ce..a56fc73b04 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs @@ -198,7 +198,21 @@ public override async Task ExecuteAsync(CommandContext context, try { - using var tracerProvider = AddIncomingAndOutgoingHttpSpans(options); + // Build configuration using the same sources the host will use so AddIncomingAndOutgoingHttpSpans + // has access to IConfiguration before the host DI container is constructed. + // This mirrors Host.CreateDefaultBuilder / WebApplication.CreateBuilder source order: + // appsettings.json → appsettings.{env}.json → environment variables. + string hostEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") + ?? "Production"; + IConfiguration preHostConfiguration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddJsonFile($"appsettings.{hostEnvironment}.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + using var tracerProvider = AddIncomingAndOutgoingHttpSpans(options, preHostConfiguration); using var host = CreateHost(options); @@ -462,14 +476,11 @@ private IHost CreateHttpHost(ServiceStartOptions serverOptions) { WebApplicationBuilder builder = WebApplication.CreateBuilder(HttpWebApplicationOptions); - // Read once at host setup time — this env var is process-wide and effectively static, + // Read once at host setup time — this value is process-wide and effectively static, // so there is no need to re-read it on every incoming request. - // Default to false; the env var must be present and parse to "true" to enable. - bool enableForwardedHeaders = - bool.TryParse( - Environment.GetEnvironmentVariable("AZURE_MCP_DANGEROUSLY_ENABLE_FORWARDED_HEADERS"), - out bool parsedEnvVar) - && parsedEnvVar; + // Default to false; the configuration value must parse to "true" to enable. + string? forwardedHeadersSetting = builder.Configuration["AZURE_MCP_DANGEROUSLY_ENABLE_FORWARDED_HEADERS"]; + bool enableForwardedHeaders = bool.TryParse(forwardedHeadersSetting, out bool parsedForwardedHeaders) && parsedForwardedHeaders; // Configure logging builder.Logging.ClearProviders(); @@ -555,7 +566,6 @@ private IHost CreateHttpHost(ServiceStartOptions serverOptions) // Add a multi-user, HTTP context-aware caching strategy to isolate cache entries. services.AddHttpServiceCacheService(); - // Configure non-MCP controllers/endpoints/routes/etc. services.AddHealthChecks(); @@ -820,7 +830,7 @@ private static void InitializeListingUrls(WebApplicationBuilder builder, Service return; } - string url = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://127.0.0.1:5001"; + string url = builder.Configuration["ASPNETCORE_URLS"] ?? "http://127.0.0.1:5001"; if (url.Contains(';')) { @@ -849,7 +859,13 @@ private static void InitializeListingUrls(WebApplicationBuilder builder, Service throw new InvalidOperationException($"Explicit external binding is not supported for '{url}'."); } - if (isWildcard && !EnvironmentHelpers.GetEnvironmentVariableAsBool("ALLOW_INSECURE_EXTERNAL_BINDING")) + var allowInsecureExternalBindingRaw = builder.Configuration["ALLOW_INSECURE_EXTERNAL_BINDING"]; + bool allowInsecureExternalBinding = false; + if (!string.IsNullOrWhiteSpace(allowInsecureExternalBindingRaw)) + { + _ = bool.TryParse(allowInsecureExternalBindingRaw, out allowInsecureExternalBinding); + } + if (isWildcard && !allowInsecureExternalBinding) { throw new InvalidOperationException( $"External binding blocked for '{url}'. " + @@ -917,8 +933,9 @@ private static WebApplication UseHttpsRedirectionIfEnabled(WebApplication app) // - The application or server's HTTP stack is not listening for non-HTTPS requests. // // Safe default to enable HTTPS redirection unless explicitly opted-out. - string? httpsRedirectionOptOut = Environment.GetEnvironmentVariable("AZURE_MCP_DANGEROUSLY_DISABLE_HTTPS_REDIRECTION"); - if (!bool.TryParse(httpsRedirectionOptOut, out bool isOptedOut) || !isOptedOut) + var disableHttpsRedirectionSetting = app.Configuration["AZURE_MCP_DANGEROUSLY_DISABLE_HTTPS_REDIRECTION"]; + var httpsRedirectionDisabled = bool.TryParse(disableHttpsRedirectionSetting, out var parsedValue) && parsedValue; + if (!httpsRedirectionDisabled) { app.UseHttpsRedirection(); } @@ -930,6 +947,7 @@ private static WebApplication UseHttpsRedirectionIfEnabled(WebApplication app) /// Configures incoming and outgoing HTTP spans for self-hosted HTTP mode with Azure Monitor exporter. /// /// The server configuration options. + /// The configuration used to read telemetry settings. /// /// A instance if telemetry is enabled and properly configured for HTTP transport; /// otherwise, null. @@ -939,17 +957,17 @@ private static WebApplication UseHttpsRedirectionIfEnabled(WebApplication app) /// /// The transport is HTTP (not STDIO) /// AZURE_MCP_COLLECT_TELEMETRY is not explicitly set to false - /// APPLICATIONINSIGHTS_CONNECTION_STRING environment variable is set + /// APPLICATIONINSIGHTS_CONNECTION_STRING is set /// /// The tracer provider includes ASP.NET Core and HttpClient instrumentation with filtering /// to avoid duplicate spans and telemetry loops. /// This telemetry configuration is intended for self-hosted scenarios where /// the MCP server is running in HTTP mode. This creates an independent telemetry pipeline using TracerProvider to export - /// traces to user-configured Application Insights instance only when the necessary environment variables are set. This also honors - /// the AZURE_MCP_COLLECT_TELEMETRY environment variable to allow users to disable telemetry collection if desired. Note that this is + /// traces to user-configured Application Insights instance only when the necessary configuration values are set. This also honors + /// the AZURE_MCP_COLLECT_TELEMETRY setting to allow users to disable telemetry collection if desired. Note that this is /// in addition to the telemetry configured in . /// - private static TracerProvider? AddIncomingAndOutgoingHttpSpans(ServiceStartOptions options) + private static TracerProvider? AddIncomingAndOutgoingHttpSpans(ServiceStartOptions options, IConfiguration configuration) { if (options.Transport != TransportTypes.Http) { @@ -964,11 +982,20 @@ private static WebApplication UseHttpsRedirectionIfEnabled(WebApplication app) return null; } - string? collectTelemetry = Environment.GetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY"); - bool isTelemetryEnabled = string.IsNullOrWhiteSpace(collectTelemetry) || - (bool.TryParse(collectTelemetry, out bool shouldCollectTelemetry) && shouldCollectTelemetry); + string? telemetrySetting = configuration["AZURE_MCP_COLLECT_TELEMETRY"]; + bool isTelemetryEnabled; + if (string.IsNullOrWhiteSpace(telemetrySetting)) + { + // Preserve default behavior when the setting is not provided. + isTelemetryEnabled = true; + } + else if (!bool.TryParse(telemetrySetting, out isTelemetryEnabled)) + { + // Treat invalid/unknown values as telemetry disabled to avoid startup failures. + isTelemetryEnabled = false; + } - string? connectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); + string? connectionString = configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; if (!isTelemetryEnabled || string.IsNullOrWhiteSpace(connectionString)) { return null; diff --git a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs index f0e4430e53..048ea26075 100644 --- a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -41,7 +42,9 @@ public static IServiceCollection AddSingleIdentityTokenCredentialProvider(this I services.TryAddSingleton(sp => { var cloudConfig = sp.GetRequiredService(); + var configuration = sp.GetService(); CustomChainedCredential.CloudConfiguration = cloudConfig; + CustomChainedCredential.Configuration = configuration; return new SingleIdentityTokenCredentialProvider(sp.GetRequiredService()); }); 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 69ae0bea74..846f461b0d 100644 --- a/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs +++ b/core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs @@ -5,7 +5,7 @@ using Azure.Core; using Azure.Identity; using Azure.Identity.Broker; -using Azure.Mcp.Core.Helpers; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Azure.Mcp.Core.Services.Azure.Authentication; @@ -94,6 +94,20 @@ internal class CustomChainedCredential(string? tenantId = null, ILogger internal static IAzureCloudConfiguration? CloudConfiguration { get; set; } + /// + /// Configuration for reading settings. Set by DI container during service registration. + /// When set, configuration-based retrieval is preferred over direct environment variable access, + /// allowing values to come from non-environment sources (e.g. appsettings.json) in addition to environment variables. + /// + internal static IConfiguration? Configuration { get; set; } + + /// + /// Reads a configuration value, preferring when available + /// and falling back to otherwise. + /// + private static string? GetConfigValue(string key) => + Configuration?[key] ?? Environment.GetEnvironmentVariable(key); + /// /// 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). @@ -120,13 +134,14 @@ public override ValueTask GetTokenAsync(TokenRequestContext request private static bool ShouldUseOnlyBrokerCredential() { - return EnvironmentHelpers.GetEnvironmentVariableAsBool(OnlyUseBrokerCredentialEnvVarName); + string? value = GetConfigValue(OnlyUseBrokerCredentialEnvVarName); + return value is "true" or "True" or "T" or "1"; } private static TokenCredential CreateCredential(string? tenantId, ILogger? logger = null, bool forceBrowserFallback = false) { // Check if AZURE_TOKEN_CREDENTIALS is explicitly set - string? tokenCredentials = Environment.GetEnvironmentVariable(TokenCredentialsEnvVarName); + string? tokenCredentials = GetConfigValue(TokenCredentialsEnvVarName); bool hasExplicitCredentialSetting = !string.IsNullOrEmpty(tokenCredentials); #if DEBUG @@ -139,7 +154,7 @@ private static TokenCredential CreateCredential(string? tenantId, ILogger(); // Check if we are running in a VS Code context. VSCODE_PID is set by VS Code when launching processes, and is a reliable indicator for VS Code-hosted processes. - bool isVsCodeContext = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSCODE_PID")); + bool isVsCodeContext = !string.IsNullOrEmpty(GetConfigValue("VSCODE_PID")); if (isVsCodeContext && !hasExplicitCredentialSetting) { @@ -220,7 +235,7 @@ private static TokenCredential CreateCredential(string? tenantId, ILogger 0) { @@ -259,7 +274,7 @@ private static TokenCredential CreateBrowserCredential(string? tenantId, Authent private static ChainedTokenCredential CreateDefaultCredential(string? tenantId) { - string? tokenCredentials = Environment.GetEnvironmentVariable(TokenCredentialsEnvVarName); + string? tokenCredentials = GetConfigValue(TokenCredentialsEnvVarName); var credentials = new List(); // Handle specific credential targeting @@ -367,7 +382,7 @@ private static void AddWorkloadIdentityCredential(List credenti private static void AddManagedIdentityCredential(List credentials) { // Check if AZURE_CLIENT_ID is set for User-Assigned Managed Identity - string? clientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"); + string? clientId = GetConfigValue("AZURE_CLIENT_ID"); ManagedIdentityCredential managedIdentityCredential; if (!string.IsNullOrEmpty(clientId)) @@ -474,7 +489,7 @@ private static void AddDeviceCodeCredential(List credentials, s "DeviceCodeCredential requires an interactive terminal to display the device code prompt."); } - string? clientId = Environment.GetEnvironmentVariable(ClientIdEnvVarName); + string? clientId = GetConfigValue(ClientIdEnvVarName); var deviceCodeOptions = new DeviceCodeCredentialOptions { @@ -492,8 +507,8 @@ private static void AddDeviceCodeCredential(List credentials, s deviceCodeOptions.AuthorityHost = CloudConfiguration.AuthorityHost; } - // Hydrate an existing AuthenticationRecord from the environment to enable silent token cache reuse - string? authRecordJson = Environment.GetEnvironmentVariable(AuthenticationRecordEnvVarName); + // Hydrate an existing AuthenticationRecord from the configuration to enable silent token cache reuse + string? authRecordJson = GetConfigValue(AuthenticationRecordEnvVarName); if (!string.IsNullOrEmpty(authRecordJson)) { byte[] bytes = Encoding.UTF8.GetBytes(authRecordJson);