Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -228,6 +233,71 @@ private void HandleListTools(JsonElement? id)
WriteResult(id, new { tools = toolsWire });
}

/// <summary>
/// Handles the "logging/setLevel" JSON-RPC method by updating the runtime log level.
/// </summary>
/// <param name="id">The request identifier extracted from the incoming JSON-RPC request.</param>
/// <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
///
/// 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.
/// </remarks>
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<ILogLevelController>();
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 { });
}

/// <summary>
/// Handles the "tools/call" JSON-RPC method by executing the specified tool with the provided arguments.
/// </summary>
Expand Down
14 changes: 10 additions & 4 deletions src/Cli/ConfigGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2582,7 +2582,8 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun
List<string> 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)
Expand All @@ -2597,17 +2598,22 @@ 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
{
minimumLogLevel = deserializedRuntimeConfig.GetConfiguredLogLevel();
HostMode hostModeType = deserializedRuntimeConfig.IsDevelopmentMode() ? HostMode.Development : HostMode.Production;

_logger.LogInformation($"Setting default minimum LogLevel: {minimumLogLevel} for {hostModeType} mode.", minimumLogLevel, hostModeType);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

In the default log-level branch, _logger.LogInformation($"Setting default minimum LogLevel: {minimumLogLevel} for {hostModeType} mode.", minimumLogLevel, hostModeType); uses an interpolated string while also passing structured args—those args won’t be captured as structured fields. Use a message template without $"..." (or remove the extra args) so logging behaves as intended.

Suggested change
_logger.LogInformation($"Setting default minimum LogLevel: {minimumLogLevel} for {hostModeType} mode.", minimumLogLevel, hostModeType);
_logger.LogInformation("Setting default minimum LogLevel: {minimumLogLevel} for {hostModeType} mode.", minimumLogLevel, hostModeType);

Copilot uses AI. Check for mistakes.
}

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.
}
Comment on lines 2585 to +2616
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

After this change, when options.LogLevel is not provided the CLI no longer passes --LogLevel to the engine. The Service defaults to LogLevel.Error when --LogLevel is absent (Program.GetLogLevelFromCommandLineArgs), so early startup logs will be suppressed even in Development until DynamicLogLevelProvider.UpdateFromRuntimeConfig(...) runs. If the intent is to keep the existing “Debug in Development / Error in Production” default behavior while still allowing MCP to change the level, consider setting the initial log level from the loaded config earlier in host construction (or introducing a non-override mechanism distinct from the CLI override flag).

Copilot uses AI. Check for mistakes.

// This will add args to disable automatic redirects to https if specified by user
if (options.IsHttpsRedirectionDisabled)
Expand Down
37 changes: 37 additions & 0 deletions src/Core/Telemetry/ILogLevelController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.DataApiBuilder.Core.Telemetry
{
/// <summary>
/// Interface for controlling log levels dynamically at runtime.
/// This allows MCP and other components to adjust logging without
/// direct coupling to the concrete implementation.
/// </summary>
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.
/// </summary>
bool IsCliOverridden { 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.
/// </summary>
bool IsConfigOverridden { 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)
/// </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>
bool UpdateFromMcp(string mcpLevel);
}
}
92 changes: 92 additions & 0 deletions src/Service.Tests/UnitTests/DynamicLogLevelProviderTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Unit tests for the DynamicLogLevelProvider class.
/// Tests the MCP logging/setLevel support.
/// </summary>
[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));
}
}
}
2 changes: 2 additions & 0 deletions src/Service/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -110,6 +111,7 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st
.ConfigureServices((context, services) =>
{
services.AddSingleton(LogLevelProvider);
services.AddSingleton<ILogLevelController>(LogLevelProvider);
})
.ConfigureLogging(logging =>
{
Expand Down
75 changes: 73 additions & 2 deletions src/Service/Telemetry/DynamicLogLevelProvider.cs
Original file line number Diff line number Diff line change
@@ -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
/// <summary>
/// Provides dynamic log level control with support for CLI override, runtime config, and MCP.
/// </summary>
public class DynamicLogLevelProvider : ILogLevelController
{
/// <summary>
/// Maps MCP log level strings to Microsoft.Extensions.Logging.LogLevel.
/// MCP levels: debug, info, notice, warning, error, critical, alert, emergency.
/// </summary>
private static readonly Dictionary<string, LogLevel> _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)
Expand All @@ -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();
}
Comment on lines 46 to +52
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

IsConfigOverridden is derived from !runtimeConfig.IsLogLevelNull(), but the schema allows runtime.telemetry.log-level values to be null (meaning “use host-mode defaults”). In that case IsLogLevelNull() returns false (because the dictionary exists), causing MCP logging/setLevel to be blocked even though config did not actually pin a log level. Consider treating config as “overridden” only when at least one configured log-level value is non-null (e.g., any entry value != null), or add a dedicated RuntimeConfig helper for this distinction.

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// 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.
/// </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/Config override prevented the change or level was invalid.</returns>
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)
Expand Down
Loading