Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -120,7 +123,9 @@ protected override ServiceStartOptions BindOptions(ParseResult parseResult)
Debug = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.Debug.Name),
DangerouslyDisableHttpIncomingAuth = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth.Name),
InsecureDisableElicitation = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.InsecureDisableElicitation.Name),
OutgoingAuthStrategy = outgoingAuthStrategy
OutgoingAuthStrategy = outgoingAuthStrategy,
LogLevel = parseResult.GetValueOrDefault<string?>(ServiceOptionDefinitions.LogLevel.Name),
LogFilePath = parseResult.GetValueOrDefault<string?>(ServiceOptionDefinitions.LogFilePath.Name)
};
return options;
}
Expand Down Expand Up @@ -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 =>
{
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -607,6 +621,57 @@ private IHost CreateIncomingAuthDisabledHttpHost(ServiceStartOptions serverOptio
return app;
}

/// <summary>
/// Configures logging based on the server options.
/// </summary>
/// <param name="logging">The logging builder to configure.</param>
/// <param name="options">The server configuration options.</param>
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<LogLevel>(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();
Copy link
Member

Choose a reason for hiding this comment

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

@xiangyan99, please chat with @srnagar on the changes that he's making for logging. We're trying to improve the situation for remote MCP servers, and Srikanta's changes similarly have a switch to shut off all telemetry.

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));
}
}

/// <summary>
/// Configures the MCP server services.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Transport = new($"--{TransportName}")
{
Expand Down Expand Up @@ -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<string?> LogLevel = new(
Copy link
Member

Choose a reason for hiding this comment

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

Can we avoid creating a new argument for log levels? Controlling log levels with configurations is built into the ILogger infrastructure with log categories and log levels. These can both be controlled using any number of sources of configurations, including command line and environment variables.

$"--{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<string?> 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
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,19 @@ public class ServiceStartOptions
/// </summary>
[JsonPropertyName("outgoingAuthStrategy")]
public OutgoingAuthStrategy OutgoingAuthStrategy { get; set; } = OutgoingAuthStrategy.NotSet;

/// <summary>
/// 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).
/// </summary>
[JsonPropertyName("logLevel")]
public string? LogLevel { get; set; } = null;

/// <summary>
/// Gets or sets the path to write log file output.
/// When specified, logs will be written to this file in addition to console output.
/// </summary>
[JsonPropertyName("logFilePath")]
public string? LogFilePath { get; set; } = null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Logging;

namespace Azure.Mcp.Core.Areas.Server.Services;

/// <summary>
/// A simple file logger provider for writing logs to a file.
/// </summary>
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>(TState state) where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> 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
}
}
}
}
}
127 changes: 121 additions & 6 deletions core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.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.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;
Expand Down Expand Up @@ -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<AzureSdkEventSourceLogForwarder>(sp =>
{
var forwarder = new AzureEventSourceLogForwarder(sp.GetRequiredService<ILoggerFactory>());
forwarder.Start();
return forwarder;
var options = sp.GetService<IOptions<ServiceStartOptions>>();
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<ILoggerFactory>(),
logLevel);
});
#endif

// Register a hosted service to keep the forwarder alive and ensure proper disposal
services.AddHostedService<AzureSdkLogForwarderHostedService>();

services.ConfigureOpenTelemetryTracerProvider((sp, builder) =>
{
Expand Down Expand Up @@ -140,6 +156,105 @@ private static void EnableAzureMonitor(this IServiceCollection services)
}
}

/// <summary>
/// Maps the configured log level to an appropriate EventSource EventLevel for Azure SDK logging.
/// </summary>
/// <param name="options">Service start options containing log level configuration.</param>
/// <returns>The EventLevel to use for Azure SDK event sources.</returns>
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<LogLevel>(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;
}

/// <summary>
/// Checks if logging is disabled based on the configured log level.
/// </summary>
/// <param name="options">Service start options containing log level configuration.</param>
/// <returns>True if logging is disabled (LogLevel.None), false otherwise.</returns>
private static bool IsLoggingDisabled(ServiceStartOptions? options)
{
if (options == null)
{
return false;
}

if (!string.IsNullOrWhiteSpace(options.LogLevel))
{
if (Enum.TryParse<LogLevel>(options.LogLevel, ignoreCase: true, out var logLevel))
{
return logLevel == LogLevel.None;
}
}

return false;
}

/// <summary>
/// Maps Microsoft.Extensions.Logging.LogLevel to System.Diagnostics.Tracing.EventLevel.
/// </summary>
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
};

/// <summary>
/// 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"
/// </summary>
/// <param name="entryAssembly">The entry assembly to extract name and version information from.</param>
/// <returns>A version string.</returns>
internal static string GetServerVersion(Assembly entryAssembly)
{
AssemblyInformationalVersionAttribute? versionAttribute = entryAssembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
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)
Expand Down
Loading