diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs index fdff54681d..8ae1edca2d 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs @@ -5,6 +5,7 @@ using System.Net; using Azure.Mcp.Core.Areas.Server.Models; using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Areas.Server.Services; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Services.Azure; @@ -78,6 +79,8 @@ protected override void RegisterOptions(Command command) command.Options.Add(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth); command.Options.Add(ServiceOptionDefinitions.InsecureDisableElicitation); command.Options.Add(ServiceOptionDefinitions.OutgoingAuthStrategy); + command.Options.Add(ServiceOptionDefinitions.LogLevel); + command.Options.Add(ServiceOptionDefinitions.LogFilePath); command.Validators.Add(commandResult => { string transport = ResolveTransport(commandResult); @@ -120,7 +123,9 @@ protected override ServiceStartOptions BindOptions(ParseResult parseResult) Debug = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Debug.Name), DangerouslyDisableHttpIncomingAuth = parseResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth.Name), InsecureDisableElicitation = parseResult.GetValueOrDefault(ServiceOptionDefinitions.InsecureDisableElicitation.Name), - OutgoingAuthStrategy = outgoingAuthStrategy + OutgoingAuthStrategy = outgoingAuthStrategy, + LogLevel = parseResult.GetValueOrDefault(ServiceOptionDefinitions.LogLevel.Name), + LogFilePath = parseResult.GetValueOrDefault(ServiceOptionDefinitions.LogFilePath.Name) }; return options; } @@ -362,6 +367,9 @@ private IHost CreateStdioHost(ServiceStartOptions serverOptions) logging.AddFilter("Microsoft.Extensions.Logging.Console.ConsoleLoggerProvider", LogLevel.Debug); logging.SetMinimumLevel(LogLevel.Debug); } + + // Apply custom logging configuration + ConfigureLogging(logging, serverOptions); }) .ConfigureServices(services => { @@ -389,6 +397,9 @@ private IHost CreateHttpHost(ServiceStartOptions serverOptions) builder.Logging.AddEventSourceLogger(); builder.Logging.AddConsole(); + // Apply custom logging configuration + ConfigureLogging(builder.Logging, serverOptions); + IServiceCollection services = builder.Services; // Configure outgoing and incoming authentication and authorization. @@ -566,6 +577,9 @@ private IHost CreateIncomingAuthDisabledHttpHost(ServiceStartOptions serverOptio builder.Logging.AddEventSourceLogger(); builder.Logging.AddConsole(); + // Apply custom logging configuration + ConfigureLogging(builder.Logging, serverOptions); + IServiceCollection services = builder.Services; // Configure single identity token credential provider for outgoing authentication @@ -607,6 +621,57 @@ private IHost CreateIncomingAuthDisabledHttpHost(ServiceStartOptions serverOptio return app; } + /// + /// Configures logging based on the server options. + /// + /// The logging builder to configure. + /// The server configuration options. + private static void ConfigureLogging(ILoggingBuilder logging, ServiceStartOptions options) + { + // Set minimum log level + // Default to Information to avoid logging sensitive data (Debug/Trace may contain secrets) + if (!string.IsNullOrWhiteSpace(options.LogLevel)) + { + if (Enum.TryParse(options.LogLevel, ignoreCase: true, out var logLevel)) + { + logging.SetMinimumLevel(logLevel); + + // If LogLevel.None is specified, clear all providers to disable logging + if (logLevel == LogLevel.None) + { + logging.ClearProviders(); + return; // Exit early, don't add file logging + } + } + } + else if (options.Debug) + { + // Debug mode overrides default + logging.SetMinimumLevel(LogLevel.Debug); + } + else + { + // Default to Information level to prevent logging sensitive data + // (Debug and Trace levels may contain secrets, tokens, and other sensitive information) + logging.SetMinimumLevel(LogLevel.Information); + } + + // Add file logging if path is specified + if (!string.IsNullOrWhiteSpace(options.LogFilePath)) + { + // Ensure directory exists + var directory = Path.GetDirectoryName(options.LogFilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // Add simple file logger + // Note: For production scenarios, consider using a more robust file logging provider + logging.AddProvider(new SimpleFileLoggerProvider(options.LogFilePath)); + } + } + /// /// Configures the MCP server services. /// diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs index 4b554e79e9..0f7d1df1b9 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs @@ -14,6 +14,8 @@ public static class ServiceOptionDefinitions public const string DangerouslyDisableHttpIncomingAuthName = "dangerously-disable-http-incoming-auth"; public const string InsecureDisableElicitationName = "insecure-disable-elicitation"; public const string OutgoingAuthStrategyName = "outgoing-auth-strategy"; + public const string LogLevelName = "log-level"; + public const string LogFilePathName = "log-file-path"; public static readonly Option Transport = new($"--{TransportName}") { @@ -91,4 +93,20 @@ public static class ServiceOptionDefinitions Description = "Outgoing authentication strategy for Azure service requests. Valid values: NotSet, UseHostingEnvironmentIdentity, UseOnBehalfOf.", DefaultValueFactory = _ => Options.OutgoingAuthStrategy.NotSet }; + + public static readonly Option LogLevel = new( + $"--{LogLevelName}") + { + Required = false, + Description = "Minimum logging level. Valid values: Trace, Debug, Information, Warning, Error, Critical, None. Default is Information (or Debug if --debug is set).", + DefaultValueFactory = _ => null + }; + + public static readonly Option LogFilePath = new( + $"--{LogFilePathName}") + { + Required = false, + Description = "Path to write log file output. When specified, logs will be written to the specified file in addition to console output.", + DefaultValueFactory = _ => null + }; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs index 8023c60605..65b681ce9a 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs @@ -72,4 +72,19 @@ public class ServiceStartOptions /// [JsonPropertyName("outgoingAuthStrategy")] public OutgoingAuthStrategy OutgoingAuthStrategy { get; set; } = OutgoingAuthStrategy.NotSet; + + /// + /// Gets or sets the minimum logging level. + /// Valid values: Trace, Debug, Information, Warning, Error, Critical, None. + /// When null, defaults to Information (or Debug if Debug mode is enabled). + /// + [JsonPropertyName("logLevel")] + public string? LogLevel { get; set; } = null; + + /// + /// Gets or sets the path to write log file output. + /// When specified, logs will be written to this file in addition to console output. + /// + [JsonPropertyName("logFilePath")] + public string? LogFilePath { get; set; } = null; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Services/SimpleFileLoggerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Services/SimpleFileLoggerProvider.cs new file mode 100644 index 0000000000..c92982fe3d --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Services/SimpleFileLoggerProvider.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Areas.Server.Services; + +/// +/// A simple file logger provider for writing logs to a file. +/// +internal sealed class SimpleFileLoggerProvider : ILoggerProvider +{ + private readonly string _filePath; + private readonly object _lock = new(); + + public SimpleFileLoggerProvider(string filePath) + { + _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + } + + public ILogger CreateLogger(string categoryName) + { + return new SimpleFileLogger(categoryName, _filePath, _lock); + } + + public void Dispose() + { + // Nothing to dispose + } + + private sealed class SimpleFileLogger(string categoryName, string filePath, object lockObject) : ILogger + { + private readonly string _categoryName = categoryName; + private readonly string _filePath = filePath; + private readonly object _lock = lockObject; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + var logEntry = $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}] [{logLevel}] [{_categoryName}] {message}"; + + if (exception != null) + { + logEntry += Environment.NewLine + exception; + } + + lock (_lock) + { + try + { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + File.AppendAllText(_filePath, logEntry + Environment.NewLine); + } + catch + { + // Silently fail if we can't write to the log file + // to avoid breaking the application + } + } + } + } +} diff --git a/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs index bd9ef0cb75..79cff4fbca 100644 --- a/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs +++ b/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs @@ -6,6 +6,7 @@ using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Configuration; using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Logging; using Azure.Mcp.Core.Services.Telemetry; using Azure.Monitor.OpenTelemetry.Exporter; // Don't believe this is unused, it is needed for UseAzureMonitorExporter using Microsoft.Extensions.Azure; @@ -78,14 +79,29 @@ public static void ConfigureOpenTelemetryLogger(this ILoggingBuilder builder) private static void EnableAzureMonitor(this IServiceCollection services) { -#if DEBUG - services.AddSingleton(sp => + // Enable Azure SDK event source logging based on configuration + // This captures Azure SDK diagnostic events (requests, responses, retries, authentication) + // and forwards them to the configured logging providers + services.AddSingleton(sp => { - var forwarder = new AzureEventSourceLogForwarder(sp.GetRequiredService()); - forwarder.Start(); - return forwarder; + var options = sp.GetService>(); + var logLevel = GetAzureEventSourceLevel(options?.Value); + + // Don't create the forwarder if logging is disabled (LogLevel.None) + if (IsLoggingDisabled(options?.Value)) + { + return null!; + } + + // Create the forwarder - OnEventSourceCreated will be called automatically + // for all existing and future EventSources + return new AzureSdkEventSourceLogForwarder( + sp.GetRequiredService(), + logLevel); }); -#endif + + // Register a hosted service to keep the forwarder alive and ensure proper disposal + services.AddHostedService(); services.ConfigureOpenTelemetryTracerProvider((sp, builder) => { @@ -140,6 +156,105 @@ private static void EnableAzureMonitor(this IServiceCollection services) } } + /// + /// Maps the configured log level to an appropriate EventSource EventLevel for Azure SDK logging. + /// + /// Service start options containing log level configuration. + /// The EventLevel to use for Azure SDK event sources. + private static System.Diagnostics.Tracing.EventLevel GetAzureEventSourceLevel(ServiceStartOptions? options) + { + // Default to Warning to avoid excessive logging from Azure SDK + // Azure SDK can be very verbose at Information/Debug levels + var defaultLevel = System.Diagnostics.Tracing.EventLevel.Warning; + + if (options == null) + { + return defaultLevel; + } + + // If LogLevel is explicitly set, use it + if (!string.IsNullOrWhiteSpace(options.LogLevel)) + { + if (Enum.TryParse(options.LogLevel, ignoreCase: true, out var logLevel)) + { + return MapLogLevelToEventLevel(logLevel); + } + } + + // If Debug mode is enabled, use Verbose for maximum Azure SDK diagnostics + if (options.Debug) + { + return System.Diagnostics.Tracing.EventLevel.Verbose; + } + + return defaultLevel; + } + + /// + /// Checks if logging is disabled based on the configured log level. + /// + /// Service start options containing log level configuration. + /// True if logging is disabled (LogLevel.None), false otherwise. + private static bool IsLoggingDisabled(ServiceStartOptions? options) + { + if (options == null) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(options.LogLevel)) + { + if (Enum.TryParse(options.LogLevel, ignoreCase: true, out var logLevel)) + { + return logLevel == LogLevel.None; + } + } + + return false; + } + + /// + /// Maps Microsoft.Extensions.Logging.LogLevel to System.Diagnostics.Tracing.EventLevel. + /// + private static System.Diagnostics.Tracing.EventLevel MapLogLevelToEventLevel(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => System.Diagnostics.Tracing.EventLevel.Verbose, + LogLevel.Debug => System.Diagnostics.Tracing.EventLevel.Verbose, + LogLevel.Information => System.Diagnostics.Tracing.EventLevel.Informational, + LogLevel.Warning => System.Diagnostics.Tracing.EventLevel.Warning, + LogLevel.Error => System.Diagnostics.Tracing.EventLevel.Error, + LogLevel.Critical => System.Diagnostics.Tracing.EventLevel.Critical, + LogLevel.None => System.Diagnostics.Tracing.EventLevel.Critical, // Effectively disable logging + _ => System.Diagnostics.Tracing.EventLevel.Warning + }; + + /// + /// Gets the version information for the server. Uses logic from Azure SDK for .NET to generate the same version string. + /// https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/System.ClientModel/src/Pipeline/UserAgentPolicy.cs#L91 + /// For example, an informational version of "6.14.0-rc.116+54d611f7" will return "6.14.0-rc.116" + /// + /// The entry assembly to extract name and version information from. + /// A version string. + internal static string GetServerVersion(Assembly entryAssembly) + { + AssemblyInformationalVersionAttribute? versionAttribute = entryAssembly.GetCustomAttribute(); + if (versionAttribute == null) + { + throw new InvalidOperationException( + $"{nameof(AssemblyInformationalVersionAttribute)} is required on client SDK assembly '{entryAssembly.FullName}'."); + } + + string version = versionAttribute.InformationalVersion; + + int hashSeparator = version.IndexOf('+'); + if (hashSeparator != -1) + { + version = version.Substring(0, hashSeparator); + } + + return version; + } + private static void ConfigureAzureMonitorExporters(OpenTelemetry.OpenTelemetryBuilder otelBuilder, List<(string Name, string ConnectionString)> appInsightsConnectionStrings) { foreach (var exporter in appInsightsConnectionStrings) diff --git a/core/Azure.Mcp.Core/src/Logging/AzureEventSourceLogForwarder.cs b/core/Azure.Mcp.Core/src/Logging/AzureEventSourceLogForwarder.cs new file mode 100644 index 0000000000..02521a1a13 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Logging/AzureEventSourceLogForwarder.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.Tracing; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Logging; + +/// +/// Forwards Azure SDK EventSource events to the Microsoft.Extensions.Logging infrastructure. +/// This enables capturing Azure SDK diagnostic events (requests, responses, retries, authentication) +/// and forwarding them to configured logging providers. +/// +/// +/// Based on Azure SDK diagnostics documentation: +/// https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/Azure.Core/samples/Diagnostics.md#Logging +/// +public sealed class AzureSdkEventSourceLogForwarder : EventListener +{ + private readonly ILoggerFactory _loggerFactory; + private readonly Dictionary _loggers = new(); + private EventLevel _level = EventLevel.Informational; + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory to create loggers for each Azure event source. + /// The minimum event level to capture. Defaults to Informational. + public AzureSdkEventSourceLogForwarder(ILoggerFactory loggerFactory, EventLevel level = EventLevel.Informational) + { + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _level = level; + } + + /// + /// Called when a new EventSource is created. Enables listening to Azure-related event sources. + /// + /// The event source that was created. + protected override void OnEventSourceCreated(EventSource eventSource) + { + base.OnEventSourceCreated(eventSource); + + // Listen to all Azure SDK event sources + // Azure SDK event sources start with "Azure-" + if (eventSource.Name?.StartsWith("Azure-", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + EnableEvents(eventSource, _level, EventKeywords.All); + + // Create a logger for this event source if not already created + if (!_loggers.ContainsKey(eventSource.Name)) + { + _loggers[eventSource.Name] = _loggerFactory.CreateLogger($"Azure.SDK.{eventSource.Name}"); + } + } + catch (Exception) + { + // Ignore errors enabling event sources + } + } + } + + /// + /// Called when an event is written. Forwards the event to the appropriate logger. + /// + /// The event data. + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (eventData.EventSource?.Name == null) + { + return; + } + + // Get or create a logger for this event source + if (!_loggers.TryGetValue(eventData.EventSource.Name, out var logger)) + { + logger = _loggerFactory.CreateLogger($"Azure.SDK.{eventData.EventSource.Name}"); + _loggers[eventData.EventSource.Name] = logger; + } + + // Map EventSource EventLevel to Microsoft.Extensions.Logging LogLevel + var logLevel = MapEventLevel(eventData.Level); + + // Format the message + var message = FormatMessage(eventData); + + // Log the event + logger.Log(logLevel, eventData.EventId, message, null, (state, _) => state); + } + + /// + /// Maps EventSource EventLevel to Microsoft.Extensions.Logging LogLevel. + /// + private static LogLevel MapEventLevel(EventLevel eventLevel) => eventLevel switch + { + EventLevel.Critical => LogLevel.Critical, + EventLevel.Error => LogLevel.Error, + EventLevel.Warning => LogLevel.Warning, + EventLevel.Informational => LogLevel.Information, + EventLevel.Verbose => LogLevel.Debug, + EventLevel.LogAlways => LogLevel.Information, + _ => LogLevel.Trace + }; + + /// + /// Formats the event message with its payload. + /// + private static string FormatMessage(EventWrittenEventArgs eventData) + { + if (eventData.Payload == null || eventData.Payload.Count == 0) + { + return eventData.Message ?? $"[{eventData.EventName}]"; + } + + // Try to format with payload + try + { + if (!string.IsNullOrEmpty(eventData.Message)) + { + return string.Format(eventData.Message, eventData.Payload.ToArray()); + } + } + catch + { + // If formatting fails, fall back to simple concatenation + } + + // Build message with payload names and values + var payloadNames = eventData.PayloadNames; + if (payloadNames != null && payloadNames.Count == eventData.Payload.Count) + { + var parts = new List { $"[{eventData.EventName}]" }; + for (int i = 0; i < payloadNames.Count; i++) + { + parts.Add($"{payloadNames[i]}={eventData.Payload[i]}"); + } + return string.Join(" ", parts); + } + + // Fallback: just event name and payload values + return $"[{eventData.EventName}] {string.Join(", ", eventData.Payload)}"; + } + + /// + /// Disposes the event listener and stops listening to event sources. + /// + public override void Dispose() + { + base.Dispose(); + _loggers.Clear(); + } +} diff --git a/core/Azure.Mcp.Core/src/Logging/AzureSdkLogForwarderHostedService.cs b/core/Azure.Mcp.Core/src/Logging/AzureSdkLogForwarderHostedService.cs new file mode 100644 index 0000000000..5592b5766f --- /dev/null +++ b/core/Azure.Mcp.Core/src/Logging/AzureSdkLogForwarderHostedService.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Hosting; + +namespace Azure.Mcp.Core.Logging; + +/// +/// Hosted service that ensures the Azure SDK EventSource log forwarder is kept alive +/// for the lifetime of the application. +/// +internal sealed class AzureSdkLogForwarderHostedService : IHostedService +{ + private readonly AzureSdkEventSourceLogForwarder _forwarder; + + public AzureSdkLogForwarderHostedService(AzureSdkEventSourceLogForwarder forwarder) + { + _forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder)); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + // The forwarder is already listening through its constructor + // This service just keeps a reference to prevent garbage collection + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + // Dispose the forwarder when the host shuts down + _forwarder?.Dispose(); + return Task.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs index e03b2d95b7..728296ed1b 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs @@ -106,6 +106,30 @@ public void AllOptionsRegistered_IncludesTool() Assert.True(hasToolOption, "Tool option should be registered"); } + [Fact] + public void AllOptionsRegistered_IncludesLogLevel() + { + // Arrange & Act + var command = _command.GetCommand(); + + // Assert + var hasLogLevelOption = command.Options.Any(o => + o.Name == ServiceOptionDefinitions.LogLevel.Name); + Assert.True(hasLogLevelOption, "LogLevel option should be registered"); + } + + [Fact] + public void AllOptionsRegistered_IncludesLogFilePath() + { + // Arrange & Act + var command = _command.GetCommand(); + + // Assert + var hasLogFilePathOption = command.Options.Any(o => + o.Name == ServiceOptionDefinitions.LogFilePath.Name); + Assert.True(hasLogFilePathOption, "LogFilePath option should be registered"); + } + [Theory] [InlineData("azmcp_storage_account_get")] [InlineData("azmcp_keyvault_secret_get")] @@ -131,6 +155,40 @@ public void ToolOption_ParsesCorrectly(string? expectedTool) } } + [Theory] + [InlineData("Debug")] + [InlineData("Information")] + [InlineData("Warning")] + [InlineData("Error")] + [InlineData(null)] + public void LogLevelOption_ParsesCorrectly(string? expectedLogLevel) + { + // Arrange + var parseResult = CreateParseResultWithLogLevel(expectedLogLevel); + + // Act + var actualLogLevel = parseResult.GetValue(ServiceOptionDefinitions.LogLevel); + + // Assert + Assert.Equal(expectedLogLevel, actualLogLevel); + } + + [Theory] + [InlineData("c:\\logs\\azmcp.log")] + [InlineData("/var/log/azmcp.log")] + [InlineData(null)] + public void LogFilePathOption_ParsesCorrectly(string? expectedPath) + { + // Arrange + var parseResult = CreateParseResultWithLogFilePath(expectedPath); + + // Act + var actualPath = parseResult.GetValue(ServiceOptionDefinitions.LogFilePath); + + // Assert + Assert.Equal(expectedPath, actualPath); + } + [Fact] public void ToolOption_ParsesMultipleToolsCorrectly() { @@ -280,6 +338,65 @@ public void BindOptions_WithDefaults_ReturnsDefaultValues() Assert.False(options.Debug); Assert.False(options.DangerouslyDisableHttpIncomingAuth); Assert.False(options.InsecureDisableElicitation); + Assert.Null(options.LogLevel); + Assert.Null(options.LogFilePath); + } + + [Fact] + public void BindOptions_WithLogLevel_ReturnsCorrectlyConfiguredOptions() + { + // Arrange + var expectedLogLevel = "Debug"; + var parseResult = CreateParseResultWithLogLevel(expectedLogLevel); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.Equal(expectedLogLevel, options.LogLevel); + } + + [Fact] + public void BindOptions_WithLogFilePath_ReturnsCorrectlyConfiguredOptions() + { + // Arrange + var expectedPath = "c:\\logs\\azmcp.log"; + var parseResult = CreateParseResultWithLogFilePath(expectedPath); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.Equal(expectedPath, options.LogFilePath); + } + + [Fact] + public void BindOptions_WithLogLevelAndFilePath_ReturnsCorrectlyConfiguredOptions() + { + // Arrange + var expectedLogLevel = "Warning"; + var expectedPath = "/var/log/azmcp.log"; + var parseResult = CreateParseResultWithLogLevelAndFilePath(expectedLogLevel, expectedPath); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.Equal(expectedLogLevel, options.LogLevel); + Assert.Equal(expectedPath, options.LogFilePath); + } + + [Fact] + public void LogLevelOption_ParsesNone_Correctly() + { + // Arrange + var parseResult = CreateParseResultWithLogLevel("None"); + + // Act + var actualLogLevel = parseResult.GetValue(ServiceOptionDefinitions.LogLevel); + + // Assert + Assert.Equal("None", actualLogLevel); } [Fact] @@ -750,6 +867,50 @@ private ParseResult CreateParseResultWithNamespaceAndTool() return _command.GetCommand().Parse([.. args]); } + private ParseResult CreateParseResultWithLogLevel(string? logLevel) + { + var args = new List + { + "--transport", "stdio" + }; + + if (logLevel is not null) + { + args.Add("--log-level"); + args.Add(logLevel); + } + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithLogFilePath(string? logFilePath) + { + var args = new List + { + "--transport", "stdio" + }; + + if (logFilePath is not null) + { + args.Add("--log-file-path"); + args.Add(logFilePath); + } + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithLogLevelAndFilePath(string logLevel, string logFilePath) + { + var args = new List + { + "--transport", "stdio", + "--log-level", logLevel, + "--log-file-path", logFilePath + }; + + return _command.GetCommand().Parse([.. args]); + } + private ServiceStartOptions GetBoundOptions(ParseResult parseResult) { // Use reflection to access the protected BindOptions method diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/SimpleFileLoggerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/SimpleFileLoggerProviderTests.cs new file mode 100644 index 0000000000..88b3c3186e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/SimpleFileLoggerProviderTests.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Services; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server; + +public class SimpleFileLoggerProviderTests : IDisposable +{ + private readonly string _testLogFilePath; + private readonly List _createdFiles = []; + + public SimpleFileLoggerProviderTests() + { + _testLogFilePath = Path.Combine(Path.GetTempPath(), $"test_log_{Guid.NewGuid()}.log"); + } + + public void Dispose() + { + // Clean up test files + foreach (var file in _createdFiles) + { + if (File.Exists(file)) + { + try + { + File.Delete(file); + } + catch + { + // Ignore cleanup errors + } + } + } + + if (File.Exists(_testLogFilePath)) + { + try + { + File.Delete(_testLogFilePath); + } + catch + { + // Ignore cleanup errors + } + } + } + + [Fact] + public void CreateLogger_ReturnsNonNullLogger() + { + // Arrange + using var provider = new SimpleFileLoggerProvider(_testLogFilePath); + + // Act + var logger = provider.CreateLogger("TestCategory"); + + // Assert + Assert.NotNull(logger); + } + + [Fact] + public void Logger_IsEnabled_ReturnsTrueForNonNoneLevel() + { + // Arrange + using var provider = new SimpleFileLoggerProvider(_testLogFilePath); + var logger = provider.CreateLogger("TestCategory"); + + // Act & Assert + Assert.True(logger.IsEnabled(LogLevel.Trace)); + Assert.True(logger.IsEnabled(LogLevel.Debug)); + Assert.True(logger.IsEnabled(LogLevel.Information)); + Assert.True(logger.IsEnabled(LogLevel.Warning)); + Assert.True(logger.IsEnabled(LogLevel.Error)); + Assert.True(logger.IsEnabled(LogLevel.Critical)); + Assert.False(logger.IsEnabled(LogLevel.None)); + } + + [Fact] + public void Logger_Log_WritesToFile() + { + // Arrange + _createdFiles.Add(_testLogFilePath); + using var provider = new SimpleFileLoggerProvider(_testLogFilePath); + var logger = provider.CreateLogger("TestCategory"); + var testMessage = "Test log message"; + + // Act + logger.LogInformation(testMessage); + + // Assert + Assert.True(File.Exists(_testLogFilePath)); + var content = File.ReadAllText(_testLogFilePath); + Assert.Contains(testMessage, content); + Assert.Contains("[Information]", content); + Assert.Contains("[TestCategory]", content); + } + + [Fact] + public void Logger_Log_WithException_IncludesExceptionInLog() + { + // Arrange + _createdFiles.Add(_testLogFilePath); + using var provider = new SimpleFileLoggerProvider(_testLogFilePath); + var logger = provider.CreateLogger("TestCategory"); + var testMessage = "Error occurred"; + var exception = new InvalidOperationException("Test exception"); + + // Act + logger.LogError(exception, testMessage); + + // Assert + var content = File.ReadAllText(_testLogFilePath); + Assert.Contains(testMessage, content); + Assert.Contains("Test exception", content); + Assert.Contains("InvalidOperationException", content); + } + + [Fact] + public void Logger_Log_MultipleMessages_AppendsToFile() + { + // Arrange + _createdFiles.Add(_testLogFilePath); + using var provider = new SimpleFileLoggerProvider(_testLogFilePath); + var logger = provider.CreateLogger("TestCategory"); + var message1 = "First message"; + var message2 = "Second message"; + + // Act + logger.LogInformation(message1); + logger.LogWarning(message2); + + // Assert + var content = File.ReadAllText(_testLogFilePath); + Assert.Contains(message1, content); + Assert.Contains(message2, content); + Assert.Contains("[Information]", content); + Assert.Contains("[Warning]", content); + } + + [Fact] + public void Logger_Log_IncludesTimestamp() + { + // Arrange + _createdFiles.Add(_testLogFilePath); + using var provider = new SimpleFileLoggerProvider(_testLogFilePath); + var logger = provider.CreateLogger("TestCategory"); + var testMessage = "Timestamped message"; + + // Act + logger.LogInformation(testMessage); + + // Assert + var content = File.ReadAllText(_testLogFilePath); + // Check for timestamp pattern [yyyy-MM-dd HH:mm:ss.fff] + Assert.Matches(@"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]", content); + } + + [Fact] + public void Logger_Log_DifferentCategories_WritesToSameFile() + { + // Arrange + _createdFiles.Add(_testLogFilePath); + using var provider = new SimpleFileLoggerProvider(_testLogFilePath); + var logger1 = provider.CreateLogger("Category1"); + var logger2 = provider.CreateLogger("Category2"); + + // Act + logger1.LogInformation("Message from category 1"); + logger2.LogWarning("Message from category 2"); + + // Assert + var content = File.ReadAllText(_testLogFilePath); + Assert.Contains("[Category1]", content); + Assert.Contains("[Category2]", content); + Assert.Contains("Message from category 1", content); + Assert.Contains("Message from category 2", content); + } + + [Fact] + public void Logger_Log_CreatesDirectoryIfNotExists() + { + // Arrange + var directoryPath = Path.Combine(Path.GetTempPath(), $"test_dir_{Guid.NewGuid()}"); + var filePath = Path.Combine(directoryPath, "test.log"); + _createdFiles.Add(filePath); + + using var provider = new SimpleFileLoggerProvider(filePath); + var logger = provider.CreateLogger("TestCategory"); + + // Act + logger.LogInformation("Test message"); + + // Assert + Assert.True(Directory.Exists(directoryPath)); + Assert.True(File.Exists(filePath)); + + // Cleanup + try + { + Directory.Delete(directoryPath, true); + } + catch + { + // Ignore cleanup errors + } + } + + [Fact] + public void Constructor_WithNullPath_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new SimpleFileLoggerProvider(null!)); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/OpenTelemetryExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/OpenTelemetryExtensionsTests.cs new file mode 100644 index 0000000000..d46ea39711 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/OpenTelemetryExtensionsTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Extensions; + +public class OpenTelemetryExtensionsTests +{ + [Theory] + [InlineData("none")] + [InlineData("NONE")] + [InlineData("None")] + public void ConfigureOpenTelemetry_WithLogLevelNone_DoesNotCreateAzureSdkEventSourceLogForwarder(string logLevel) + { + // Arrange + var services = new ServiceCollection(); + services.AddOptions() + .Configure(options => + { + options.LogLevel = logLevel; + }); + services.AddLogging(); + + // Act + services.ConfigureOpenTelemetry(); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var forwarder = serviceProvider.GetService(); + Assert.Null(forwarder); + } +} diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index 21664cdcfa..921691acb1 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -15,6 +15,9 @@ The Azure MCP Server updates automatically by default whenever a new release com ### Other Changes - Begin capturing information for the MCP client request's `_meta` store. [[#1154](https://github.com/microsoft/mcp/pull/1154)] +- Added logging customization options to the `azmcp server start` command: + - Added `--log-level` option to set the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) + - Added `--log-file-path` option to write logs to a specified file with automatic directory creation and thread-safe file handling - Renamed Microsoft Azure AI Foundry to Microsoft Foundry. [[#1211](https://github.com/microsoft/mcp/pull/1211/)] - Added version display to CLI help output. The version now appears on the first line when running any help command (e.g., `azmcp --help`). [[#1161](https://github.com/microsoft/mcp/pull/1161)] diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 334cfd6fae..ca8d93b058 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -350,6 +350,110 @@ Microsoft Foundry and Microsoft Copilot Studio require remote MCP server endpoin 1. Follow the [deployment guide](https://github.com/microsoft/mcp/tree/main/servers/Azure.Mcp.Server/azd-templates/aca-copilot-studio-managed-identity/) for Microsoft Copilot Studio. +## Logging Configuration + +The Azure MCP Server supports flexible logging configuration for monitoring and troubleshooting through command-line options and environment variables. + +### Command-Line Options + +Configure logging behavior when starting the server: + +```bash +azmcp server start [--log-level ] [--log-file-path ] +``` + +**Available Options:** + +| Option | Description | Default | +|--------|-------------|---------| +| `--log-level` | Minimum logging level for all logging providers | `Information` (or `Debug` if `--debug` is set) | +| `--log-file-path` | Path to write log file output | None (no file logging) | +| `--debug` | Enable verbose debug logging to stderr | `false` | + +**Log Levels:** +- `Trace` - Most verbose, includes all diagnostic information +- `Debug` - Detailed debugging information +- `Information` - General informational messages (default) +- `Warning` - Warning messages for potential issues +- `Error` - Error messages for failures +- `Critical` - Critical failures requiring immediate attention +- `None` - Disable all logging + +### Usage Examples + +**Example 1: Set log level only (no file output)** +```bash +azmcp server start --log-level Debug +``` +Logs at Debug level and above go to configured providers (console, OpenTelemetry, EventSource), but NOT to a file. + +**Example 2: Enable file logging with default level** +```bash +azmcp server start --log-file-path c:\logs\azmcp.log +``` +Logs at Information level and above are written to the file. Directory is created automatically if it doesn't exist. + +**Example 3: Custom log level with file output** +```bash +azmcp server start --log-level Warning --log-file-path /var/log/azmcp.log +``` +Only Warning, Error, and Critical logs are written to the file and other configured providers. + +**Example 4: Configure in mcp.json** +```json +{ + "mcpServers": { + "Azure MCP Server": { + "command": "azmcp", + "args": [ + "server", + "start", + "--log-level", "Debug", + "--log-file-path", "/var/log/azmcp.log" + ] + } + } +} +``` + +### Important Behaviors + +1. **`--log-level` affects all logging providers** - Sets the minimum log level globally for console, file, OpenTelemetry, and EventSource loggers. + +2. **`--log-file-path` enables file logging** - Adds file output in addition to other configured logging providers (doesn't replace them). + +3. **These options are independent** - You can use `--log-level` without `--log-file-path` and vice versa. + +4. **Directory auto-creation** - Parent directories in the log file path are created automatically if they don't exist. + +5. **Thread-safe file operations** - Log file writes use lock-based synchronization for multi-threaded scenarios. + +6. **Graceful failure handling** - File write failures are silently suppressed to prevent application crashes. + +### Security Considerations + +⚠️ **Security Warning**: `Debug` and `Trace` log levels may expose sensitive data including: +- Azure access tokens and credentials +- API keys and secrets +- Connection strings +- Personal or confidential data in API requests/responses + +**Best Practices:** +- Use `Information` level (default) in production environments +- Only enable `Debug` or `Trace` in secure, non-production environments +- Ensure log files have appropriate file system permissions +- Regularly rotate and purge log files containing sensitive data +- Review log contents before sharing with others + +### File Logging Details + +When `--log-file-path` is specified: +- **Format**: UTF-8 text with timestamps, log level, category name, and message +- **Timestamp**: UTC time in format `[yyyy-MM-dd HH:mm:ss.fff]` +- **Append mode**: Logs are appended to existing files (not overwritten) +- **Entry format**: `[timestamp] [level] [category] message` +- **Exception details**: Full exception stack traces are included when errors occur + # Usage ## Getting Started diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 4062a9dc18..32a6ff9d55 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -180,6 +180,8 @@ The `azmcp server start` command supports the following options: | `--tool` | No | All tools | Expose specific tools by name (e.g., 'azmcp_storage_account_get'). It automatically switches to `all` mode. It can't be used together with `--namespace`. | | `--read-only` | No | `false` | Only expose read-only operations | | `--debug` | No | `false` | Enable verbose debug logging to stderr | +| `--log-level` | No | `Information` (or `Debug` if `--debug` is set) | Minimum logging level. Valid values: `Trace`, `Debug`, `Information`, `Warning`, `Error`, `Critical`, `None`. **⚠️ Security Note:** `Debug` and `Trace` levels may log sensitive data including secrets and tokens. | +| `--log-file-path` | No | None | Path to write log file output. When specified, logs are written to the file in addition to console output | | `--dangerously-disable-http-incoming-auth` | No | false | Dangerously disable HTTP incoming authentication | | `--insecure-disable-elicitation` | No | `false` | **⚠️ INSECURE**: Disable user consent prompts for sensitive operations |