Skip to content
201 changes: 167 additions & 34 deletions core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Azure.Mcp.Core.Areas.Server.Options;
using Azure.Mcp.Core.Commands;
using Azure.Mcp.Core.Helpers;
using Azure.Mcp.Core.Services.Logging;
using Azure.Mcp.Core.Services.Telemetry;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
Expand Down Expand Up @@ -67,6 +68,8 @@ protected override void RegisterOptions(Command command)
command.Options.Add(ServiceOptionDefinitions.Debug);
command.Options.Add(ServiceOptionDefinitions.EnableInsecureTransports);
command.Options.Add(ServiceOptionDefinitions.InsecureDisableElicitation);
command.Options.Add(ServiceOptionDefinitions.LogLevel);
command.Options.Add(ServiceOptionDefinitions.LogFile);
command.Validators.Add(commandResult =>
{
ValidateMode(commandResult.GetValueOrDefault(ServiceOptionDefinitions.Mode), commandResult);
Expand All @@ -79,6 +82,33 @@ protected override void RegisterOptions(Command command)
});
}

/// <summary>
/// Resolves logging options from command line and environment variables.
/// Environment variables take precedence over defaults but not over explicit command line options.
/// </summary>
private static ServiceStartOptions ResolveLoggingOptions(ServiceStartOptions options)
{
// Environment variables (only if command line option wasn't explicitly set)
if (string.IsNullOrEmpty(options.LogLevel))
{
var envLogLevel = Environment.GetEnvironmentVariable("AZMCP_LOG_LEVEL");
if (!string.IsNullOrEmpty(envLogLevel))
{
options.LogLevel = envLogLevel;
}
}

if (string.IsNullOrEmpty(options.LogFile))
{
var envLogFile = Environment.GetEnvironmentVariable("AZMCP_LOG_FILE");
if (!string.IsNullOrEmpty(envLogFile))
{
options.LogFile = envLogFile;
}
}
return options;
}

/// <summary>
/// Binds the parsed command line arguments to the ServiceStartOptions object.
/// </summary>
Expand All @@ -104,9 +134,142 @@ protected override ServiceStartOptions BindOptions(ParseResult parseResult)
ReadOnly = parseResult.GetValueOrDefault<bool?>(ServiceOptionDefinitions.ReadOnly.Name),
Debug = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.Debug.Name),
EnableInsecureTransports = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.EnableInsecureTransports.Name),
InsecureDisableElicitation = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.InsecureDisableElicitation.Name)
InsecureDisableElicitation = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.InsecureDisableElicitation.Name),
LogLevel = parseResult.GetValueOrDefault<string?>(ServiceOptionDefinitions.LogLevel.Name),
LogFile = parseResult.GetValueOrDefault<string?>(ServiceOptionDefinitions.LogFile.Name)
};
return ResolveLoggingOptions(options);
}



/// <summary>
/// Configures logging using standard ASP.NET Core configuration.
/// Sets up console logging with appropriate levels based on command line options and environment variables.
/// </summary>
private static void ConfigureLogging(Microsoft.Extensions.Configuration.IConfiguration configuration, ILoggingBuilder logging, ServiceStartOptions serverOptions, bool isStdioMode)
{
logging.ClearProviders();
logging.ConfigureOpenTelemetryLogger();
logging.AddEventSourceLogger();
Copy link
Member

@conniey conniey Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can have a conversation offline if it is easier but I was hoping that we wouldn't have to add these switches.

Using a custom ILogger, we can leverage existing infrastructure to output logs. The user experience here is to do azmcp.exe server start --log-level debug. But this only applies to the logger we wrote, the settings won't also apply to the "EventSourceLogger" or any other ILogger.

I was hoping that an end user would be able to do something like:

set Logging__LogLevel__Default=Debug
./azmcp.exe server start

And this log level would apply to your new logger, and also the EventSourceLogger, any other Console logger, possibly a file logger if there is one.

Or they can use the same configuration system to be more granular about their logging. There's a bigger example here: Configure Logging

The bonus would be that they wouldn't have to learn something new about our logging because it uses the same configuration as other .NET loggers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or if you used the [ProviderAlias("AzureMcpDebugLogger")] or something, they could configure our logger via:

set Logging__AzureMcpDebugLogger__LogLevel=Debug


// Determine effective log level from options and configuration
var effectiveLogLevel = GetEffectiveLogLevel(configuration, serverOptions);

if (isStdioMode)
{
// For STDIO mode, send logs to STDERR to keep STDOUT clean for MCP protocol
logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = effectiveLogLevel;
options.FormatterName = Microsoft.Extensions.Logging.Console.ConsoleFormatterNames.Simple;
});
}
else
{
// For HTTP mode, normal console logging
logging.AddConsole(options =>
{
options.FormatterName = Microsoft.Extensions.Logging.Console.ConsoleFormatterNames.Simple;
});
}

logging.AddSimpleConsole(simple =>
{
simple.ColorBehavior = Microsoft.Extensions.Logging.Console.LoggerColorBehavior.Disabled;
simple.IncludeScopes = false;
simple.SingleLine = true;
simple.TimestampFormat = "[HH:mm:ss] ";
});

// Resolve log file path using ASP.NET Core configuration precedence
var logFilePath = GetEffectiveLogFilePath(configuration, serverOptions);
if (!string.IsNullOrEmpty(logFilePath))
{
logging.AddFile(logFilePath);
}

// Set minimum level - this works with standard ASP.NET Core configuration
logging.SetMinimumLevel(effectiveLogLevel);

// Configure console provider filter
logging.AddFilter("Microsoft.Extensions.Logging.Console.ConsoleLoggerProvider", effectiveLogLevel);
}

/// <summary>
/// Determines the effective log level using ASP.NET Core configuration precedence.
/// Priority: --log-level > --debug > AZMCP_LOG_LEVEL env var > appsettings.json > default (Information)
/// </summary>
private static LogLevel GetEffectiveLogLevel(Microsoft.Extensions.Configuration.IConfiguration configuration, ServiceStartOptions serverOptions)
{
if (!string.IsNullOrEmpty(serverOptions.LogLevel))
{
return ParseLogLevel(serverOptions.LogLevel);
}

if (serverOptions.Debug)
{
return LogLevel.Debug;
}

var envLogLevel = Environment.GetEnvironmentVariable("AZMCP_LOG_LEVEL")
?? Environment.GetEnvironmentVariable("LOGGING__LOGLEVEL__DEFAULT");
if (!string.IsNullOrEmpty(envLogLevel))
{
return ParseLogLevel(envLogLevel);
}

var configLogLevel = configuration["Logging:LogLevel:Default"];
if (!string.IsNullOrEmpty(configLogLevel))
{
return ParseLogLevel(configLogLevel);
}

return LogLevel.Information;
}

/// <summary>
/// Determines the effective log file path using ASP.NET Core configuration precedence.
/// Priority: --log-file > AZMCP_LOG_FILE env var > appsettings.json > none
/// </summary>
private static string? GetEffectiveLogFilePath(Microsoft.Extensions.Configuration.IConfiguration configuration, ServiceStartOptions serverOptions)
{
if (!string.IsNullOrEmpty(serverOptions.LogFile))
{
return serverOptions.LogFile;
}

var envLogFile = Environment.GetEnvironmentVariable("AZMCP_LOG_FILE")
?? Environment.GetEnvironmentVariable("LOGGING__FILE__PATH");
if (!string.IsNullOrEmpty(envLogFile))
{
return envLogFile;
}

var configLogFile = configuration["Logging:File:Path"];
if (!string.IsNullOrEmpty(configLogFile))
{
return configLogFile;
}

return null;
}

/// <summary>
/// Parses a log level string to LogLevel enum, with fallback to Information for invalid values.
/// </summary>
private static LogLevel ParseLogLevel(string logLevelString)
{
return logLevelString.ToLowerInvariant() switch
{
"trace" => LogLevel.Trace,
"debug" => LogLevel.Debug,
"info" or "information" => LogLevel.Information,
"warn" or "warning" => LogLevel.Warning,
"error" => LogLevel.Error,
"critical" => LogLevel.Critical,
_ => LogLevel.Information // Fallback for invalid values
};
return options;
}

/// <summary>
Expand Down Expand Up @@ -284,31 +447,7 @@ private IHost CreateHost(ServiceStartOptions serverOptions)
private IHost CreateStdioHost(ServiceStartOptions serverOptions)
{
return Host.CreateDefaultBuilder()
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.ConfigureOpenTelemetryLogger();
logging.AddEventSourceLogger();

if (serverOptions.Debug)
{
// Configure console logger to emit Debug+ to stderr so tests can capture logs from StandardError
logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Debug;
options.FormatterName = Microsoft.Extensions.Logging.Console.ConsoleFormatterNames.Simple;
});
logging.AddSimpleConsole(simple =>
{
simple.ColorBehavior = Microsoft.Extensions.Logging.Console.LoggerColorBehavior.Disabled;
simple.IncludeScopes = false;
simple.SingleLine = true;
simple.TimestampFormat = "[HH:mm:ss] ";
});
logging.AddFilter("Microsoft.Extensions.Logging.Console.ConsoleLoggerProvider", LogLevel.Debug);
logging.SetMinimumLevel(LogLevel.Debug);
}
})
.ConfigureLogging((context, logging) => ConfigureLogging(context.Configuration, logging, serverOptions, isStdioMode: true))
.ConfigureServices(services =>
{
ConfigureServices(services);
Expand All @@ -325,13 +464,7 @@ private IHost CreateStdioHost(ServiceStartOptions serverOptions)
private IHost CreateHttpHost(ServiceStartOptions serverOptions)
{
return Host.CreateDefaultBuilder()
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.ConfigureOpenTelemetryLogger();
logging.AddEventSourceLogger();
logging.AddConsole();
})
.ConfigureLogging((context, logging) => ConfigureLogging(context.Configuration, logging, serverOptions, isStdioMode: false))
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.ConfigureServices(services =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public static class ServiceOptionDefinitions
public const string DebugName = "debug";
public const string EnableInsecureTransportsName = "enable-insecure-transports";
public const string InsecureDisableElicitationName = "insecure-disable-elicitation";
public const string LogLevelName = "log-level";
public const string LogFileName = "log-file";

public static readonly Option<string> Transport = new($"--{TransportName}")
{
Expand Down Expand Up @@ -83,4 +85,18 @@ public static class ServiceOptionDefinitions
Description = "Disable elicitation (user confirmation) before allowing high risk commands to run, such as returning Secrets (passwords) from KeyVault.",
DefaultValueFactory = _ => false
};

public static readonly Option<string?> LogLevel = new($"--{LogLevelName}")
{
Required = false,
Description = "Set logging level: Trace, Debug, Information, Warning, Error, Critical. Default is 'Information'.",
DefaultValueFactory = _ => null
};

public static readonly Option<string?> LogFile = new($"--{LogFileName}")
{
Required = false,
Description = "Write logs to specified file path. Supports {timestamp} and {pid} placeholders.",
DefaultValueFactory = _ => null
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,17 @@ public class ServiceStartOptions
/// </summary>
[JsonPropertyName("insecureDisableElicitation")]
public bool InsecureDisableElicitation { get; set; } = false;

/// <summary>
/// Gets or sets the logging level. Valid values: Trace, Debug, Information, Warning, Error, Critical.
/// </summary>
[JsonPropertyName("logLevel")]
public string? LogLevel { get; set; } = null;

/// <summary>
/// Gets or sets the log file path. When specified, logs will be written to this file in addition to console.
/// Supports placeholders: {timestamp}, {pid}.
/// </summary>
[JsonPropertyName("logFile")]
public string? LogFile { get; set; } = null;
}
Loading
Loading