From cc02ab9d3195e90f062362a094f08c466c90aa63 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 8 Apr 2026 13:11:54 -0700 Subject: [PATCH 1/2] Implemented MCP Set Log Level --- .../Core/McpStdioServer.cs | 70 ++++++++++++++ src/Cli/ConfigGenerator.cs | 14 ++- src/Core/Telemetry/ILogLevelController.cs | 37 ++++++++ .../UnitTests/DynamicLogLevelProviderTests.cs | 92 +++++++++++++++++++ src/Service/Program.cs | 2 + .../Telemetry/DynamicLogLevelProvider.cs | 75 ++++++++++++++- 6 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 src/Core/Telemetry/ILogLevelController.cs create mode 100644 src/Service.Tests/UnitTests/DynamicLogLevelProviderTests.cs diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs index 1ab1c73d05..c6300a09cc 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs @@ -6,6 +6,7 @@ using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator; using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Telemetry; using Azure.DataApiBuilder.Mcp.Model; using Azure.DataApiBuilder.Mcp.Utils; using Microsoft.AspNetCore.Http; @@ -131,6 +132,10 @@ public async Task RunAsync(CancellationToken cancellationToken) WriteResult(id, new { ok = true }); break; + case "logging/setLevel": + HandleSetLogLevel(id, root); + break; + case "shutdown": WriteResult(id, new { ok = true }); return; @@ -228,6 +233,71 @@ private void HandleListTools(JsonElement? id) WriteResult(id, new { tools = toolsWire }); } + /// + /// Handles the "logging/setLevel" JSON-RPC method by updating the runtime log level. + /// + /// The request identifier extracted from the incoming JSON-RPC request. + /// The root JSON element of the incoming JSON-RPC request. + /// + /// Log level precedence (highest to lowest): + /// 1. CLI --LogLevel flag - cannot be overridden + /// 2. Config runtime.telemetry.log-level - cannot be overridden by MCP + /// 3. MCP logging/setLevel - only works if neither CLI nor Config explicitly set a level + /// + /// If CLI or Config set the log level, this method accepts the request but silently ignores it. + /// The client won't get an error, but CLI/Config wins. + /// + private void HandleSetLogLevel(JsonElement? id, JsonElement root) + { + // Extract the level parameter from the request + string? level = null; + if (root.TryGetProperty("params", out JsonElement paramsEl) && + paramsEl.TryGetProperty("level", out JsonElement levelEl) && + levelEl.ValueKind == JsonValueKind.String) + { + level = levelEl.GetString(); + } + + if (string.IsNullOrWhiteSpace(level)) + { + WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, "Missing or invalid 'level' parameter"); + return; + } + + // Get the ILogLevelController from service provider + ILogLevelController? logLevelController = _serviceProvider.GetService(); + if (logLevelController is null) + { + // Log level controller not available - still accept request per MCP spec + Console.Error.WriteLine("[MCP DEBUG] ILogLevelController not available, logging/setLevel ignored."); + WriteResult(id, new { }); + return; + } + + // Attempt to update the log level + // If CLI or Config overrode, this returns false but we still return success to the client + bool changed = logLevelController.UpdateFromMcp(level); + if (changed) + { + Console.Error.WriteLine($"[MCP DEBUG] Log level changed to: {level}"); + } + else if (logLevelController.IsCliOverridden) + { + Console.Error.WriteLine($"[MCP DEBUG] Log level not changed (CLI override active), requested: {level}"); + } + else if (logLevelController.IsConfigOverridden) + { + Console.Error.WriteLine($"[MCP DEBUG] Log level not changed (Config override active), requested: {level}"); + } + else + { + Console.Error.WriteLine($"[MCP DEBUG] Log level not changed, invalid level: {level}"); + } + + // Always return success (empty result object) per MCP spec + WriteResult(id, new { }); + } + /// /// Handles the "tools/call" JSON-RPC method by executing the specified tool with the provided arguments. /// diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index c89da760e4..497d387e72 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -2582,7 +2582,8 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun List args = new() { "--ConfigFileName", runtimeConfigFile }; - /// Add arguments for LogLevel. Checks if LogLevel is overridden with option `--LogLevel`. + /// Add arguments for LogLevel. Only pass --LogLevel when user explicitly specified it, + /// so that MCP logging/setLevel can still adjust the level when no CLI override is present. /// If not provided, Default minimum LogLevel is Debug for Development mode and Error for Production mode. LogLevel minimumLogLevel; if (options.LogLevel is not null) @@ -2597,6 +2598,11 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun minimumLogLevel = (LogLevel)options.LogLevel; _logger.LogInformation("Setting minimum LogLevel: {minimumLogLevel}.", minimumLogLevel); + + // Only add --LogLevel when user explicitly specified it via CLI. + // This allows MCP logging/setLevel to work when no CLI override is present. + args.Add("--LogLevel"); + args.Add(minimumLogLevel.ToString()); } else { @@ -2604,10 +2610,10 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun HostMode hostModeType = deserializedRuntimeConfig.IsDevelopmentMode() ? HostMode.Development : HostMode.Production; _logger.LogInformation($"Setting default minimum LogLevel: {minimumLogLevel} for {hostModeType} mode.", minimumLogLevel, hostModeType); - } - args.Add("--LogLevel"); - args.Add(minimumLogLevel.ToString()); + // Don't add --LogLevel arg since user didn't explicitly set it. + // Service will determine default log level based on config or host mode. + } // This will add args to disable automatic redirects to https if specified by user if (options.IsHttpsRedirectionDisabled) diff --git a/src/Core/Telemetry/ILogLevelController.cs b/src/Core/Telemetry/ILogLevelController.cs new file mode 100644 index 0000000000..f67424b155 --- /dev/null +++ b/src/Core/Telemetry/ILogLevelController.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Core.Telemetry +{ + /// + /// Interface for controlling log levels dynamically at runtime. + /// This allows MCP and other components to adjust logging without + /// direct coupling to the concrete implementation. + /// + public interface ILogLevelController + { + /// + /// Gets a value indicating whether the log level was overridden by CLI arguments. + /// When true, MCP and config-based log level changes are ignored. + /// + bool IsCliOverridden { get; } + + /// + /// Gets a value indicating whether the log level was explicitly set in the config file. + /// When true along with IsCliOverridden being false, MCP log level changes are ignored. + /// + bool IsConfigOverridden { get; } + + /// + /// Updates the log level from an MCP logging/setLevel request. + /// The MCP level string is mapped to the appropriate LogLevel. + /// Log level precedence (highest to lowest): + /// 1. CLI --LogLevel flag (IsCliOverridden = true) + /// 2. Config runtime.telemetry.log-level (IsConfigOverridden = true) + /// 3. MCP logging/setLevel (only works if neither CLI nor Config set a level) + /// + /// The MCP log level string (e.g., "debug", "info", "warning", "error"). + /// True if the level was changed; false if CLI or Config override prevented the change. + bool UpdateFromMcp(string mcpLevel); + } +} diff --git a/src/Service.Tests/UnitTests/DynamicLogLevelProviderTests.cs b/src/Service.Tests/UnitTests/DynamicLogLevelProviderTests.cs new file mode 100644 index 0000000000..3071b7265d --- /dev/null +++ b/src/Service.Tests/UnitTests/DynamicLogLevelProviderTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using Azure.DataApiBuilder.Service.Telemetry; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Unit tests for the DynamicLogLevelProvider class. + /// Tests the MCP logging/setLevel support. + /// + [TestClass] + public class DynamicLogLevelProviderTests + { + [TestMethod] + public void UpdateFromMcp_ValidLevel_ChangesLogLevel() + { + // Arrange + DynamicLogLevelProvider provider = new(); + provider.SetInitialLogLevel(LogLevel.Error, isCliOverridden: false); + + // Act + bool result = provider.UpdateFromMcp("debug"); + + // Assert + Assert.IsTrue(result); + Assert.AreEqual(LogLevel.Debug, provider.CurrentLogLevel); + } + + [TestMethod] + public void UpdateFromMcp_CliOverridden_DoesNotChangeLogLevel() + { + // Arrange + DynamicLogLevelProvider provider = new(); + provider.SetInitialLogLevel(LogLevel.Error, isCliOverridden: true); + + // Act + bool result = provider.UpdateFromMcp("debug"); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(LogLevel.Error, provider.CurrentLogLevel); + } + + [TestMethod] + public void UpdateFromMcp_ConfigOverridden_DoesNotChangeLogLevel() + { + // Arrange + DynamicLogLevelProvider provider = new(); + provider.SetInitialLogLevel(LogLevel.Warning, isCliOverridden: false, isConfigOverridden: true); + + // Act + bool result = provider.UpdateFromMcp("debug"); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(LogLevel.Warning, provider.CurrentLogLevel); + } + + [TestMethod] + public void UpdateFromMcp_InvalidLevel_ReturnsFalse() + { + // Arrange + DynamicLogLevelProvider provider = new(); + provider.SetInitialLogLevel(LogLevel.Error, isCliOverridden: false); + + // Act + bool result = provider.UpdateFromMcp("invalid"); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(LogLevel.Error, provider.CurrentLogLevel); + } + + [TestMethod] + public void ShouldLog_ReturnsCorrectResult() + { + // Arrange + DynamicLogLevelProvider provider = new(); + provider.SetInitialLogLevel(LogLevel.Warning, isCliOverridden: false); + + // Assert - logs at or above Warning should pass + Assert.IsTrue(provider.ShouldLog(LogLevel.Warning)); + Assert.IsTrue(provider.ShouldLog(LogLevel.Error)); + Assert.IsFalse(provider.ShouldLog(LogLevel.Debug)); + } + } +} diff --git a/src/Service/Program.cs b/src/Service/Program.cs index d601f4f6b4..61829238b6 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -9,6 +9,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Core.Telemetry; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Telemetry; using Azure.DataApiBuilder.Service.Utilities; @@ -110,6 +111,7 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st .ConfigureServices((context, services) => { services.AddSingleton(LogLevelProvider); + services.AddSingleton(LogLevelProvider); }) .ConfigureLogging(logging => { diff --git a/src/Service/Telemetry/DynamicLogLevelProvider.cs b/src/Service/Telemetry/DynamicLogLevelProvider.cs index 3c35e295e6..c1b4e4b851 100644 --- a/src/Service/Telemetry/DynamicLogLevelProvider.cs +++ b/src/Service/Telemetry/DynamicLogLevelProvider.cs @@ -1,17 +1,43 @@ +using System; +using System.Collections.Generic; using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Telemetry; using Microsoft.Extensions.Logging; namespace Azure.DataApiBuilder.Service.Telemetry { - public class DynamicLogLevelProvider + /// + /// Provides dynamic log level control with support for CLI override, runtime config, and MCP. + /// + public class DynamicLogLevelProvider : ILogLevelController { + /// + /// Maps MCP log level strings to Microsoft.Extensions.Logging.LogLevel. + /// MCP levels: debug, info, notice, warning, error, critical, alert, emergency. + /// + private static readonly Dictionary _mcpLevelMapping = new(StringComparer.OrdinalIgnoreCase) + { + ["debug"] = LogLevel.Debug, + ["info"] = LogLevel.Information, + ["notice"] = LogLevel.Information, // MCP "notice" maps to Information (no direct equivalent) + ["warning"] = LogLevel.Warning, + ["error"] = LogLevel.Error, + ["critical"] = LogLevel.Critical, + ["alert"] = LogLevel.Critical, // MCP "alert" maps to Critical + ["emergency"] = LogLevel.Critical // MCP "emergency" maps to Critical + }; + public LogLevel CurrentLogLevel { get; private set; } + public bool IsCliOverridden { get; private set; } - public void SetInitialLogLevel(LogLevel logLevel = LogLevel.Error, bool isCliOverridden = false) + public bool IsConfigOverridden { get; private set; } + + public void SetInitialLogLevel(LogLevel logLevel = LogLevel.Error, bool isCliOverridden = false, bool isConfigOverridden = false) { CurrentLogLevel = logLevel; IsCliOverridden = isCliOverridden; + IsConfigOverridden = isConfigOverridden; } public void UpdateFromRuntimeConfig(RuntimeConfig runtimeConfig) @@ -20,7 +46,52 @@ public void UpdateFromRuntimeConfig(RuntimeConfig runtimeConfig) if (!IsCliOverridden) { CurrentLogLevel = runtimeConfig.GetConfiguredLogLevel(); + + // Track if config explicitly set a log level (not just using defaults) + IsConfigOverridden = !runtimeConfig.IsLogLevelNull(); + } + } + + /// + /// Updates the log level from an MCP logging/setLevel request. + /// Precedence (highest to lowest): + /// 1. CLI --LogLevel flag (IsCliOverridden = true) + /// 2. Config runtime.telemetry.log-level (IsConfigOverridden = true) + /// 3. MCP logging/setLevel + /// + /// If CLI or Config overrode, this method accepts the request silently but does not change the level. + /// + /// The MCP log level string (e.g., "debug", "info", "warning", "error"). + /// True if the level was changed; false if CLI/Config override prevented the change or level was invalid. + public bool UpdateFromMcp(string mcpLevel) + { + // If CLI overrode the log level, accept the request but don't change anything. + // This prevents MCP clients from getting errors, but CLI wins. + if (IsCliOverridden) + { + return false; } + + // If Config explicitly set the log level, accept the request but don't change anything. + // Config has second precedence after CLI. + if (IsConfigOverridden) + { + return false; + } + + if (string.IsNullOrWhiteSpace(mcpLevel)) + { + return false; + } + + if (_mcpLevelMapping.TryGetValue(mcpLevel, out LogLevel logLevel)) + { + CurrentLogLevel = logLevel; + return true; + } + + // Unknown level - don't change, but don't fail either + return false; } public bool ShouldLog(LogLevel logLevel) From e534adbb6b76191d48733ddd933f706db8baea93 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 8 Apr 2026 15:59:27 -0700 Subject: [PATCH 2/2] Changed default mcp stdio log level to "None". --- src/Cli/ConfigGenerator.cs | 35 ++++++++++++-- src/Cli/CustomLoggerProvider.cs | 7 +++ src/Cli/Program.cs | 3 ++ src/Cli/Utils.cs | 5 ++ src/Service/Program.cs | 86 ++++++++++++++++++++++++++++++--- 5 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 497d387e72..2fddad734c 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -2606,13 +2606,38 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun } else { - minimumLogLevel = deserializedRuntimeConfig.GetConfiguredLogLevel(); - HostMode hostModeType = deserializedRuntimeConfig.IsDevelopmentMode() ? HostMode.Development : HostMode.Production; + // When --mcp-stdio is used without explicit --LogLevel: + // 1. Check if config has log-level set - use that (Config has priority 2) + // 2. Otherwise default to None for clean MCP stdio output + if (options.McpStdio) + { + // Check if config explicitly sets a log level + if (!deserializedRuntimeConfig.IsLogLevelNull()) + { + minimumLogLevel = deserializedRuntimeConfig.GetConfiguredLogLevel(); + _logger.LogInformation("MCP stdio mode: Using config log-level: {minimumLogLevel}.", minimumLogLevel); + // Pass --LogLevel to Service with special marker to indicate it's from config, not CLI. + // This allows MCP logging/setLevel to be blocked by config override. + args.Add("--LogLevel"); + args.Add(minimumLogLevel.ToString()); + args.Add("--LogLevelFromConfig"); + } + else + { + _logger.LogInformation("MCP stdio mode: Defaulting to LogLevel.None (no config override)."); + // Don't add --LogLevel to args - let Service handle the default. + } + } + else + { + minimumLogLevel = deserializedRuntimeConfig.GetConfiguredLogLevel(); + HostMode hostModeType = deserializedRuntimeConfig.IsDevelopmentMode() ? HostMode.Development : HostMode.Production; - _logger.LogInformation($"Setting default minimum LogLevel: {minimumLogLevel} for {hostModeType} mode.", minimumLogLevel, hostModeType); + _logger.LogInformation($"Setting default minimum LogLevel: {minimumLogLevel} for {hostModeType} mode.", minimumLogLevel, hostModeType); - // Don't add --LogLevel arg since user didn't explicitly set it. - // Service will determine default log level based on config or host mode. + // Don't add --LogLevel arg since user didn't explicitly set it. + // Service will determine default log level based on config or host mode. + } } // This will add args to disable automatic redirects to https if specified by user diff --git a/src/Cli/CustomLoggerProvider.cs b/src/Cli/CustomLoggerProvider.cs index 0f7a881da8..89836c73ee 100644 --- a/src/Cli/CustomLoggerProvider.cs +++ b/src/Cli/CustomLoggerProvider.cs @@ -71,9 +71,16 @@ public class CustomConsoleLogger : ILogger /// /// Creates Log message by setting console message color based on LogLevel. + /// Skips logging when in MCP stdio mode to keep stdout clean for JSON-RPC protocol. /// public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { + // In MCP stdio mode, suppress all CLI logging to keep stdout clean for JSON-RPC. + if (Cli.Utils.IsMcpStdioMode) + { + return; + } + if (!IsEnabled(logLevel) || logLevel < _minimumLogLevel) { return; diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index de16ed27f5..0a139bf1bf 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -26,6 +26,9 @@ public static int Main(string[] args) // Load environment variables from .env file if present. DotNetEnv.Env.Load(); + // Check if MCP stdio mode is requested - suppress CLI logging to keep stdout clean for JSON-RPC. + Utils.IsMcpStdioMode = args.Any(a => string.Equals(a, "--mcp-stdio", StringComparison.OrdinalIgnoreCase)); + // Logger setup and configuration ILoggerFactory loggerFactory = Utils.LoggerFactoryForCli; ILogger cliLogger = loggerFactory.CreateLogger(); diff --git a/src/Cli/Utils.cs b/src/Cli/Utils.cs index c1ff7f2a99..3ef475a9e4 100644 --- a/src/Cli/Utils.cs +++ b/src/Cli/Utils.cs @@ -23,6 +23,11 @@ public class Utils public const string WILDCARD = "*"; public static readonly string SEPARATOR = ":"; + /// + /// When true, CLI logging to stdout is suppressed to keep the MCP stdio channel clean. + /// + public static bool IsMcpStdioMode { get; set; } + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. private static ILogger _logger; #pragma warning restore CS8618 diff --git a/src/Service/Program.cs b/src/Service/Program.cs index 61829238b6..c429069050 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -4,6 +4,7 @@ using System; using System.CommandLine; using System.CommandLine.Parsing; +using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; @@ -63,6 +64,28 @@ public static bool StartEngine(string[] args, bool runMcpStdio, string? mcpRole) { try { + // Initialize log level EARLY, before building the host. + // This ensures logging filters are effective during the entire host build process. + LogLevel initialLogLevel = GetLogLevelFromCommandLineArgs(args, runMcpStdio, out bool isCliOverridden, out bool isConfigOverridden); + LogLevelProvider.SetInitialLogLevel(initialLogLevel, isCliOverridden, isConfigOverridden); + + // For MCP stdio mode, redirect Console.Out to keep stdout clean for JSON-RPC. + // MCP SDK uses Console.OpenStandardOutput() which gets the real stdout, unaffected by this redirect. + if (runMcpStdio) + { + // When LogLevel.None, redirect to null stream for ZERO output. + // Otherwise redirect to stderr so logs don't pollute JSON-RPC. + if (initialLogLevel == LogLevel.None) + { + Console.SetOut(TextWriter.Null); + Console.SetError(TextWriter.Null); + } + else + { + Console.SetOut(Console.Error); + } + } + IHost host = CreateHostBuilder(args, runMcpStdio, mcpRole).Build(); if (runMcpStdio) @@ -115,13 +138,20 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st }) .ConfigureLogging(logging => { - logging.AddFilter("Microsoft", logLevel => LogLevelProvider.ShouldLog(logLevel)); - logging.AddFilter("Microsoft.Hosting.Lifetime", logLevel => LogLevelProvider.ShouldLog(logLevel)); + // Set minimum level at the framework level - this affects all loggers. + // For MCP stdio mode, Console.Out is redirected to stderr in Main(), + // so any logging output goes to stderr and doesn't pollute the JSON-RPC channel. + logging.SetMinimumLevel(LogLevelProvider.CurrentLogLevel); + + // Add filter for dynamic log level changes (e.g., via MCP logging/setLevel) + logging.AddFilter(logLevel => LogLevelProvider.ShouldLog(logLevel)); }) .ConfigureWebHostDefaults(webBuilder => { - Startup.MinimumLogLevel = GetLogLevelFromCommandLineArgs(args, out Startup.IsLogLevelOverriddenByCli); - LogLevelProvider.SetInitialLogLevel(Startup.MinimumLogLevel, Startup.IsLogLevelOverriddenByCli); + // LogLevelProvider was already initialized in StartEngine before CreateHostBuilder. + // Use the already-set values to avoid re-parsing args. + Startup.MinimumLogLevel = LogLevelProvider.CurrentLogLevel; + Startup.IsLogLevelOverriddenByCli = LogLevelProvider.IsCliOverridden; ILoggerFactory loggerFactory = GetLoggerFactoryForLogLevel(Startup.MinimumLogLevel, stdio: runMcpStdio); ILogger startupLogger = loggerFactory.CreateLogger(); DisableHttpsRedirectionIfNeeded(args); @@ -133,19 +163,59 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st /// Using System.CommandLine Parser to parse args and return /// the correct log level. We save if there is a log level in args through /// the out param. For log level out of range we throw an exception. + /// When in MCP stdio mode without explicit --LogLevel, defaults to None without CLI override. /// /// array that may contain log level information. - /// sets if log level is found in the args. + /// whether running in MCP stdio mode. + /// sets if log level is found in the args from CLI. + /// sets if log level came from config file. /// Appropriate log level. - private static LogLevel GetLogLevelFromCommandLineArgs(string[] args, out bool isLogLevelOverridenByCli) + private static LogLevel GetLogLevelFromCommandLineArgs(string[] args, bool runMcpStdio, out bool isLogLevelOverridenByCli, out bool isLogLevelOverridenByConfig) { Command cmd = new(name: "start"); Option logLevelOption = new(name: "--LogLevel"); + Option logLevelFromConfigOption = new(name: "--LogLevelFromConfig"); cmd.AddOption(logLevelOption); + cmd.AddOption(logLevelFromConfigOption); ParseResult result = GetParseResult(cmd, args); bool matchedToken = result.Tokens.Count - result.UnmatchedTokens.Count - result.UnparsedTokens.Count > 1; - LogLevel logLevel = matchedToken ? result.GetValueForOption(logLevelOption) : LogLevel.Error; - isLogLevelOverridenByCli = matchedToken; + + // Check if --LogLevelFromConfig flag is present (indicates config override, not CLI) + bool isFromConfig = result.GetValueForOption(logLevelFromConfigOption); + + LogLevel logLevel; + if (matchedToken) + { + logLevel = result.GetValueForOption(logLevelOption); + + if (isFromConfig) + { + // Log level came from config file (passed by CLI with --LogLevelFromConfig marker) + isLogLevelOverridenByCli = false; + isLogLevelOverridenByConfig = true; + } + else + { + // User explicitly set --LogLevel via CLI (highest priority) + isLogLevelOverridenByCli = true; + isLogLevelOverridenByConfig = false; + } + } + else if (runMcpStdio) + { + // MCP stdio mode without explicit --LogLevel: default to None to keep stdout clean. + // This is NOT a CLI or config override, so MCP logging/setLevel can still change it. + logLevel = LogLevel.None; + isLogLevelOverridenByCli = false; + isLogLevelOverridenByConfig = false; + } + else + { + // Normal mode without explicit --LogLevel + logLevel = LogLevel.Error; + isLogLevelOverridenByCli = false; + isLogLevelOverridenByConfig = false; + } if (logLevel is > LogLevel.None or < LogLevel.Trace) {