Skip to content
80 changes: 49 additions & 31 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,17 +261,26 @@ private void HandleListTools(JsonElement? id)
/// <param name="root">The root JSON element of the incoming JSON-RPC request.</param>
/// <remarks>
/// 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
/// 4. Default: None for MCP stdio mode (silent by default to keep stdout clean for JSON-RPC)
///
/// 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.
///
/// When MCP sets a level other than "none", this also restores Console.Error to the real stderr
/// stream so that logs become visible (Console may have been redirected to null at startup).
/// It also enables MCP log notifications so logs are sent to the client via notifications/message.
/// 1. MCP <c>logging/setLevel</c> (Agent) - always wins, overrides CLI and Config.
/// 2. CLI <c>--LogLevel</c> flag.
/// 3. Config <c>runtime.telemetry.log-level</c>.
/// 4. Default: <c>None</c> for MCP stdio mode (silent by default to keep stdout clean for JSON-RPC),
/// <c>Error</c> in Production, <c>Debug</c> in Development.
///
/// Per MCP spec the response is always success (empty result object) even when the input is
/// an unrecognized level — in that case no side effect runs and no state changes.
///
/// Side effects performed in order on a valid request:
/// 1. Toggle <see cref="IMcpLogNotificationWriter.IsEnabled"/> based on the level
/// (<c>"none"</c> disables, anything else enables). This is done BEFORE
/// <see cref="ILogLevelController.UpdateFromMcp"/> so the audit log line that
/// <c>UpdateFromMcp</c> emits is forwarded to the agent rather than dropped.
/// 2. Call <see cref="ILogLevelController.UpdateFromMcp"/>, which updates the level and
/// flips <see cref="ILogLevelController.IsAgentOverriding"/> so subsequent runtime-config
/// hot-reloads do not overwrite the agent's choice.
/// 3. Restore <see cref="Console.Error"/> to the real stderr stream when logging is enabled,
/// in case startup redirected it to <see cref="TextWriter.Null"/> (default for
/// <c>--mcp-stdio</c> or <c>--LogLevel none</c>).
/// </remarks>
private void HandleSetLogLevel(JsonElement? id, JsonElement root)
{
Expand Down Expand Up @@ -299,35 +308,44 @@ private void HandleSetLogLevel(JsonElement? id, JsonElement root)
return;
}

// Attempt to update the log level
// If CLI or Config overrode, this returns false but we still return success to the client
bool updated = logLevelController.UpdateFromMcp(level);

// Determine if logging is enabled (level != "none")
// Note: Even if CLI/Config overrode the level, we still enable notifications
// when the client requests logging. They'll get logs at the overridden level.
bool isLoggingEnabled = !string.Equals(level, "none", StringComparison.OrdinalIgnoreCase);

// Only restore stderr when this MCP call actually changed the effective level.
// If CLI/Config overrode (updated == false), stderr is already in the correct state:
// - CLI/Config level == "none": stderr was redirected to TextWriter.Null at startup
// and must stay that way; restoring it would re-introduce noisy output even
// though the operator explicitly asked for silence.
// - CLI/Config level != "none": stderr was never redirected, so restoring is a no-op.
if (updated && isLoggingEnabled)
// Validate the level BEFORE touching any side-effect (notification writer, stderr).
// "none" is the disable signal and is not a recognized MCP level; everything else
// must round-trip through McpLogLevelConverter so a typo can't silently turn the
// notification stream on while UpdateFromMcp ignores the bad value.
bool isDisableRequest = string.Equals(level, "none", StringComparison.OrdinalIgnoreCase);
bool isValidLevel = isDisableRequest || McpLogLevelConverter.TryConvertFromMcp(level, out _);
if (!isValidLevel)
Comment thread
anushakolan marked this conversation as resolved.
{
RestoreStderrIfNeeded();
// Unknown level - return success per MCP spec but make no state changes.
WriteResult(id, new { });
return;
}

// Enable or disable MCP log notifications based on the requested level
// When CLI/Config overrode, notifications are still enabled - client asked for logs,
// they just get them at the CLI/Config level instead of the requested level.
bool isLoggingEnabled = !isDisableRequest;

// Enable or disable MCP log notifications based on the requested level BEFORE updating
// the level. Doing it in this order means the agent-override Information line emitted
// by UpdateFromMcp is forwarded to the agent (otherwise it would be dropped because
// the notification writer was still disabled at the moment of emission).
IMcpLogNotificationWriter? notificationWriter = _serviceProvider.GetService<IMcpLogNotificationWriter>();
if (notificationWriter != null)
{
notificationWriter.IsEnabled = isLoggingEnabled;
}

// Update the log level. Validation above guarantees this returns true for non-"none"
// values; for "none" it returns false (no LogLevel mapping) and we just keep
// notifications off without touching the current level.
bool updated = logLevelController.UpdateFromMcp(level);

// Restore stderr if the agent successfully turned logging on. When `--mcp-stdio` (or
// `--LogLevel none`) was the startup default, stderr was redirected to TextWriter.Null;
// re-enable it now so subsequent logs flow.
if (updated && isLoggingEnabled)
{
RestoreStderrIfNeeded();
}

// Always return success (empty result object) per MCP spec
WriteResult(id, new { });
}
Expand Down
12 changes: 6 additions & 6 deletions src/Cli.Tests/CustomLoggerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ public class CustomLoggerTests
public void ResetMcpStaticState()
{
Cli.Utils.IsMcpStdioMode = false;
Cli.Utils.IsLogLevelOverriddenByCli = false;
Cli.Utils.IsLogLevelOverriddenByConfig = false;
Cli.Utils.IsCliOverriding = false;
Cli.Utils.IsConfigOverriding = false;
Cli.Utils.CliLogLevel = LogLevel.Information;
Cli.Utils.ConfigLogLevel = LogLevel.Information;
}
Expand Down Expand Up @@ -114,7 +114,7 @@ public void Mcp_NoOverrides_SuppressesAllOutput()
public void Mcp_CliOverride_WritesToStderrAndHonorsCliLevel()
{
Cli.Utils.IsMcpStdioMode = true;
Cli.Utils.IsLogLevelOverriddenByCli = true;
Cli.Utils.IsCliOverriding = true;
Cli.Utils.CliLogLevel = LogLevel.Warning;

(string stdout, string stderr) = CaptureConsole(() =>
Expand All @@ -140,7 +140,7 @@ public void Mcp_CliOverride_WritesToStderrAndHonorsCliLevel()
public void Mcp_ConfigOverride_WritesToStderrAndHonorsConfigLevel()
{
Cli.Utils.IsMcpStdioMode = true;
Cli.Utils.IsLogLevelOverriddenByConfig = true;
Cli.Utils.IsConfigOverriding = true;
Cli.Utils.ConfigLogLevel = LogLevel.Information;

(string stdout, string stderr) = CaptureConsole(() =>
Expand All @@ -163,9 +163,9 @@ public void Mcp_ConfigOverride_WritesToStderrAndHonorsConfigLevel()
public void Mcp_CliOverridePrecedesConfigOverride()
{
Cli.Utils.IsMcpStdioMode = true;
Cli.Utils.IsLogLevelOverriddenByCli = true;
Cli.Utils.IsCliOverriding = true;
Cli.Utils.CliLogLevel = LogLevel.Warning;
Cli.Utils.IsLogLevelOverriddenByConfig = true;
Cli.Utils.IsConfigOverriding = true;
Cli.Utils.ConfigLogLevel = LogLevel.Information;

(_, string stderr) = CaptureConsole(() =>
Expand Down
14 changes: 7 additions & 7 deletions src/Cli.Tests/EndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -914,17 +914,17 @@ public async Task TestEngineStartUpWithLogLevelNone(string logLevelOption)
Assert.IsTrue(string.IsNullOrEmpty(engineStdOut), $"Expected no output at LogLevel {logLevelOption}, but got: {engineStdOut}");
}

/// Validates that `dab start` correctly sets <see cref="Startup.IsLogLevelOverriddenByCli"/>
/// Validates that `dab start` correctly sets <see cref="Startup.IsCliOverriding"/>
/// based on whether the --LogLevel CLI flag is provided.
///
/// When the --LogLevel flag is provided, IsLogLevelOverriddenByCli should be true.
/// When the --LogLevel flag is omitted (log level comes from the config file), IsLogLevelOverriddenByCli should be false.
/// When the --LogLevel flag is provided, IsCliOverriding should be true.
/// When the --LogLevel flag is omitted (log level comes from the config file), IsCliOverriding should be false.
/// </summary>
/// <param name="cliLogLevel">The --LogLevel CLI flag value, or null to omit the flag.</param>
/// <param name="expectedIsOverridden">Expected value of Startup.IsLogLevelOverriddenByCli.</param>
/// <param name="expectedIsOverridden">Expected value of Startup.IsCliOverriding.</param>
[DataTestMethod]
[DataRow(null, false, DisplayName = "IsLogLevelOverriddenByCli is false")]
[DataRow(LogLevel.Error, true, DisplayName = "IsLogLevelOverriddenByCli is true")]
[DataRow(null, false, DisplayName = "IsCliOverriding is false")]
[DataRow(LogLevel.Error, true, DisplayName = "IsCliOverriding is true")]
public async Task TestStartCommandResolvesLogLevelFromConfigOrFlag(
LogLevel? cliLogLevel,
bool expectedIsOverridden)
Expand Down Expand Up @@ -987,7 +987,7 @@ public async Task TestStartCommandResolvesLogLevelFromConfigOrFlag(
// Wait for the engine to finish loading the config.
await Task.Delay(TimeSpan.FromSeconds(5));

Assert.AreEqual(expectedIsOverridden, Startup.IsLogLevelOverriddenByCli);
Assert.AreEqual(expectedIsOverridden, Startup.IsCliOverriding);
}

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions src/Cli/ConfigGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3002,7 +3002,7 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun

// Reset the config-based override flag so stale state from a prior call
// (these are static) cannot leak into the current run.
Utils.IsLogLevelOverriddenByConfig = false;
Utils.IsConfigOverriding = false;
Utils.ConfigLogLevel = LogLevel.Information;

if (options.LogLevel is not null)
Expand All @@ -3029,7 +3029,7 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun
// when the user expressed intent via the config file rather than --LogLevel.
if (deserializedRuntimeConfig.HasExplicitLogLevel())
{
Utils.IsLogLevelOverriddenByConfig = true;
Utils.IsConfigOverriding = true;
Utils.ConfigLogLevel = minimumLogLevel;
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/Cli/CustomLoggerProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ public class CustomConsoleLogger : ILogger
public CustomConsoleLogger(LogLevel minimumLogLevel = LogLevel.Information)
{
_minimumLogLevel = Cli.Utils.IsMcpStdioMode
? (Cli.Utils.IsLogLevelOverriddenByCli
? (Cli.Utils.IsCliOverriding
? Cli.Utils.CliLogLevel
: Cli.Utils.IsLogLevelOverriddenByConfig
: Cli.Utils.IsConfigOverriding
? Cli.Utils.ConfigLogLevel
: LogLevel.None)
: minimumLogLevel;
Expand Down Expand Up @@ -103,7 +103,7 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
// In that case, write to stderr to keep stdout clean for JSON-RPC.
if (Cli.Utils.IsMcpStdioMode)
{
if (!Cli.Utils.IsLogLevelOverriddenByCli && !Cli.Utils.IsLogLevelOverriddenByConfig)
if (!Cli.Utils.IsCliOverriding && !Cli.Utils.IsConfigOverriding)
{
return; // Suppress entirely when no explicit log level
}
Expand Down
2 changes: 1 addition & 1 deletion src/Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private static void ParseEarlyFlags(string[] args)
}
else if (string.Equals(arg, "--LogLevel", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
Utils.IsLogLevelOverriddenByCli = true;
Utils.IsCliOverriding = true;
if (Enum.TryParse<LogLevel>(args[i + 1], ignoreCase: true, out LogLevel cliLogLevel))
{
Utils.CliLogLevel = cliLogLevel;
Expand Down
13 changes: 7 additions & 6 deletions src/Cli/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,27 @@ public class Utils
public static bool IsMcpStdioMode { get; set; }

/// <summary>
/// When true, user explicitly set --LogLevel via CLI (even in MCP mode).
/// When true, the CLI is the source overriding the log level (i.e., <c>--LogLevel</c> was supplied).
/// This allows logs to be written to stderr instead of being completely suppressed.
/// </summary>
public static bool IsLogLevelOverriddenByCli { get; set; }
public static bool IsCliOverriding { get; set; }

/// <summary>
/// The log level specified via CLI --LogLevel flag.
/// Only valid when IsLogLevelOverriddenByCli is true.
/// Only valid when IsCliOverriding is true.
/// </summary>
public static LogLevel CliLogLevel { get; set; } = LogLevel.Information;

/// <summary>
/// When true, the runtime config file explicitly set a log-level value.
/// When true, the runtime config is the source overriding the log level
/// (i.e., <c>runtime.telemetry.log-level</c> was explicitly set).
/// This allows CLI logs to be written to stderr in MCP mode even when no --LogLevel flag was provided.
/// </summary>
public static bool IsLogLevelOverriddenByConfig { get; set; }
public static bool IsConfigOverriding { get; set; }

/// <summary>
/// The log level specified via runtime config file's log-level setting.
/// Only valid when IsLogLevelOverriddenByConfig is true.
/// Only valid when IsConfigOverriding is true.
/// </summary>
public static LogLevel ConfigLogLevel { get; set; } = LogLevel.Information;

Expand Down
31 changes: 20 additions & 11 deletions src/Core/Telemetry/ILogLevelController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,36 @@ namespace Azure.DataApiBuilder.Core.Telemetry
public interface ILogLevelController
{
/// <summary>
/// Gets a value indicating whether the log level was overridden by CLI arguments.
/// When true, MCP and config-based log level changes are ignored.
/// Gets a value indicating whether the CLI is the source overriding the log level
/// (i.e., <c>--LogLevel</c> was supplied). When true, runtime-config (hot-reload)
/// updates are ignored.
/// </summary>
bool IsCliOverridden { get; }
bool IsCliOverriding { get; }

/// <summary>
/// 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.
/// Gets a value indicating whether the runtime config is the source overriding the log
/// level (i.e., <c>runtime.telemetry.log-level</c> was explicitly set).
/// </summary>
bool IsConfigOverridden { get; }
bool IsConfigOverriding { get; }

/// <summary>
/// Gets a value indicating whether the agent is the source overriding the log level via
/// an MCP <c>logging/setLevel</c> request. When true, runtime-config (hot-reload) updates
/// are ignored so the agent's choice remains in effect.
/// </summary>
bool IsAgentOverriding { get; }

/// <summary>
/// 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)
/// Log-level precedence (highest to lowest):
/// 1. Agent (MCP <c>logging/setLevel</c>) — always wins.
/// 2. CLI <c>--LogLevel</c> flag.
/// 3. Config <c>runtime.telemetry.log-level</c>.
/// 4. Defaults.
/// </summary>
/// <param name="mcpLevel">The MCP log level string (e.g., "debug", "info", "warning", "error").</param>
/// <returns>True if the level was changed; false if CLI or Config override prevented the change.</returns>
/// <returns>True if the level was changed; false if the input was an unrecognized level.</returns>
bool UpdateFromMcp(string mcpLevel);
}
}
Loading