diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json
index 84f8e5cfbd..53ac6e13e8 100644
--- a/schemas/dab.draft.schema.json
+++ b/schemas/dab.draft.schema.json
@@ -280,6 +280,10 @@
"description": "Allow enabling/disabling MCP requests for all entities.",
"default": true
},
+ "description": {
+ "type": "string",
+ "description": "Description of the MCP server, exposed as the 'instructions' field in the MCP initialize response to provide behavioral context to MCP clients and agents."
+ },
"dml-tools": {
"description": "Configuration for MCP Data Manipulation Language (DML) tools. Set to true/false to enable/disable all tools, or use an object to configure individual tools.",
"oneOf": [
@@ -432,7 +436,7 @@
"description": "Unauthenticated provider where all operations run as anonymous. Use when Data API builder is behind an app gateway or APIM where authentication is handled externally."
}
],
- "default": "AppService"
+ "default": "Unauthenticated"
},
"jwt": {
"type": "object",
diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs
index 2b48c37a83..387ea7427f 100644
--- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs
@@ -19,7 +19,7 @@ internal static class McpServerConfiguration
///
/// Configures the MCP server with tool capabilities.
///
- internal static IServiceCollection ConfigureMcpServer(this IServiceCollection services)
+ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection services, string? instructions)
{
services.AddMcpServer()
.WithListToolsHandler((RequestContext request, CancellationToken ct) =>
@@ -93,6 +93,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se
options.ServerInfo = new() { Name = McpProtocolDefaults.MCP_SERVER_NAME, Version = McpProtocolDefaults.MCP_SERVER_VERSION };
options.Capabilities ??= new();
options.Capabilities.Tools ??= new();
+ options.ServerInstructions = !string.IsNullOrWhiteSpace(instructions) ? instructions : null;
});
return services;
diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs
index bc87602da9..c88cae148d 100644
--- a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs
@@ -41,8 +41,8 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service
// Register custom tools from configuration
RegisterCustomTools(services, runtimeConfig);
- // Configure MCP server
- services.ConfigureMcpServer();
+ // Configure MCP server and propagate runtime description to MCP initialize instructions.
+ services.ConfigureMcpServer(runtimeConfig.Runtime?.Mcp?.Description);
return services;
}
diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
index 1ab1c73d05..0588050fb0 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;
@@ -46,8 +47,6 @@ public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProv
/// A task representing the asynchronous operation.
public async Task RunAsync(CancellationToken cancellationToken)
{
- Console.Error.WriteLine("[MCP DEBUG] MCP stdio server started.");
-
// Use UTF-8 WITHOUT BOM
UTF8Encoding utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
@@ -77,15 +76,13 @@ public async Task RunAsync(CancellationToken cancellationToken)
{
doc = JsonDocument.Parse(line);
}
- catch (JsonException jsonEx)
+ catch (JsonException)
{
- Console.Error.WriteLine($"[MCP DEBUG] JSON parse error: {jsonEx.Message}");
WriteError(id: null, code: McpStdioJsonRpcErrorCodes.PARSE_ERROR, message: "Parse error");
continue;
}
- catch (Exception ex)
+ catch (Exception)
{
- Console.Error.WriteLine($"[MCP DEBUG] Unexpected error parsing request: {ex.Message}");
WriteError(id: null, code: McpStdioJsonRpcErrorCodes.INTERNAL_ERROR, message: "Internal error");
continue;
}
@@ -131,6 +128,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;
@@ -171,30 +172,50 @@ private void HandleInitialize(JsonElement? id)
RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
instructions = runtimeConfig.Runtime?.Mcp?.Description;
}
- catch (Exception ex)
+ catch (Exception)
{
- // Log to stderr for diagnostics and rethrow to avoid masking configuration errors
- Console.Error.WriteLine($"[MCP WARNING] Failed to retrieve MCP description from config: {ex.Message}");
+ // Rethrow to avoid masking configuration errors
throw;
}
}
- // Create the initialize response
- object result = new
+ // Create the initialize response - only include instructions if non-empty
+ object result;
+ if (!string.IsNullOrWhiteSpace(instructions))
{
- protocolVersion = _protocolVersion,
- capabilities = new
+ result = new
{
- tools = new { listChanged = true },
- logging = new { }
- },
- serverInfo = new
+ protocolVersion = _protocolVersion,
+ capabilities = new
+ {
+ tools = new { listChanged = true },
+ logging = new { }
+ },
+ serverInfo = new
+ {
+ name = McpProtocolDefaults.MCP_SERVER_NAME,
+ version = McpProtocolDefaults.MCP_SERVER_VERSION
+ },
+ instructions = instructions
+ };
+ }
+ else
+ {
+ result = new
{
- name = McpProtocolDefaults.MCP_SERVER_NAME,
- version = McpProtocolDefaults.MCP_SERVER_VERSION
- },
- instructions = !string.IsNullOrWhiteSpace(instructions) ? instructions : null
- };
+ protocolVersion = _protocolVersion,
+ capabilities = new
+ {
+ tools = new { listChanged = true },
+ logging = new { }
+ },
+ serverInfo = new
+ {
+ name = McpProtocolDefaults.MCP_SERVER_NAME,
+ version = McpProtocolDefaults.MCP_SERVER_VERSION
+ }
+ };
+ }
WriteResult(id, result);
}
@@ -228,6 +249,85 @@ 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
+ /// 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.
+ ///
+ 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
+ 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 updated = logLevelController.UpdateFromMcp(level);
+
+ // If MCP successfully changed the log level to something other than "none",
+ // ensure Console.Error is pointing to the real stderr (not TextWriter.Null).
+ // This handles the case where MCP stdio mode started with LogLevel.None (quiet startup)
+ // and the client later enables logging via logging/setLevel.
+ bool isLoggingEnabled = !string.Equals(level, "none", StringComparison.OrdinalIgnoreCase);
+ if (updated && isLoggingEnabled)
+ {
+ RestoreStderrIfNeeded();
+ }
+
+ // Always return success (empty result object) per MCP spec
+ WriteResult(id, new { });
+ }
+
+ ///
+ /// Restores Console.Error to the real stderr stream if it was redirected to TextWriter.Null.
+ /// This enables log output after MCP client sends logging/setLevel with a level other than "none".
+ ///
+ private static void RestoreStderrIfNeeded()
+ {
+ // Always restore stderr to the real stream when MCP enables logging.
+ // This is safe to call multiple times - we just re-wrap the standard error stream.
+ Stream stderr = Console.OpenStandardError();
+ StreamWriter stderrWriter = new(stderr, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
+ {
+ AutoFlush = true
+ };
+ Console.SetError(stderrWriter);
+ }
+
///
/// Handles the "tools/call" JSON-RPC method by executing the specified tool with the provided arguments.
///
@@ -259,14 +359,12 @@ private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, Cancel
if (string.IsNullOrWhiteSpace(toolName))
{
- Console.Error.WriteLine("[MCP DEBUG] callTool → missing tool name.");
WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, "Missing tool name");
return;
}
if (!_toolRegistry.TryGetTool(toolName!, out IMcpTool? tool) || tool is null)
{
- Console.Error.WriteLine($"[MCP DEBUG] callTool → tool not found: {toolName}");
WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, $"Tool not found: {toolName}");
return;
}
@@ -276,13 +374,7 @@ private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, Cancel
{
if (@params.TryGetProperty("arguments", out JsonElement argsEl) && argsEl.ValueKind == JsonValueKind.Object)
{
- string rawArgs = argsEl.GetRawText();
- Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: {rawArgs}");
- argsDoc = JsonDocument.Parse(rawArgs);
- }
- else
- {
- Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: ");
+ argsDoc = JsonDocument.Parse(argsEl.GetRawText());
}
// Execute the tool with telemetry.
diff --git a/src/Cli.Tests/CustomLoggerTests.cs b/src/Cli.Tests/CustomLoggerTests.cs
new file mode 100644
index 0000000000..c7989f4f8a
--- /dev/null
+++ b/src/Cli.Tests/CustomLoggerTests.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Cli.Tests;
+
+///
+/// Tests for CustomLoggerProvider and CustomConsoleLogger, verifying
+/// that log level labels use ASP.NET Core abbreviated format.
+///
+[TestClass]
+public class CustomLoggerTests
+{
+ ///
+ /// Validates that each enabled log level produces the correct abbreviated label
+ /// matching ASP.NET Core's default console formatter convention.
+ /// Trace and Debug are below the logger's minimum level and produce no output.
+ ///
+ [DataTestMethod]
+ [DataRow(LogLevel.Information, "info:")]
+ [DataRow(LogLevel.Warning, "warn:")]
+ [DataRow(LogLevel.Error, "fail:")]
+ [DataRow(LogLevel.Critical, "crit:")]
+ public void LogOutput_UsesAbbreviatedLogLevelLabels(LogLevel logLevel, string expectedPrefix)
+ {
+ CustomLoggerProvider provider = new();
+ ILogger logger = provider.CreateLogger("TestCategory");
+
+ TextWriter originalOut = Console.Out;
+ try
+ {
+ StringWriter writer = new();
+ Console.SetOut(writer);
+
+ logger.Log(logLevel, "test message");
+
+ string output = writer.ToString();
+ Assert.IsTrue(
+ output.StartsWith(expectedPrefix),
+ $"Expected output to start with '{expectedPrefix}' but got: '{output}'");
+ Assert.IsTrue(
+ output.Contains("test message"),
+ $"Expected output to contain 'test message' but got: '{output}'");
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ }
+ }
+}
diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs
index af98be05ff..4e85c786f4 100644
--- a/src/Cli/ConfigGenerator.cs
+++ b/src/Cli/ConfigGenerator.cs
@@ -2582,8 +2582,12 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun
List args = new()
{ "--ConfigFileName", runtimeConfigFile };
- /// Add arguments for LogLevel. Checks if LogLevel is overridden with option `--LogLevel`.
- /// If not provided, Default minimum LogLevel is Debug for Development mode and Error for Production mode.
+ /// 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.
+ ///
+ /// When --LogLevel is NOT specified:
+ /// - MCP stdio mode: Service defaults to None for clean stdout output
+ /// - Non-MCP mode: Service defaults to Debug (Development) or Error (Production) based on config
LogLevel minimumLogLevel;
if (options.LogLevel is not null)
{
@@ -2596,6 +2600,8 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun
}
minimumLogLevel = (LogLevel)options.LogLevel;
+ // 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());
_logger.LogInformation("Setting minimum LogLevel: {minimumLogLevel}.", minimumLogLevel);
diff --git a/src/Cli/CustomLoggerProvider.cs b/src/Cli/CustomLoggerProvider.cs
index c06918b93f..e489dd8df3 100644
--- a/src/Cli/CustomLoggerProvider.cs
+++ b/src/Cli/CustomLoggerProvider.cs
@@ -18,8 +18,13 @@ public ILogger CreateLogger(string categoryName)
public class CustomConsoleLogger : ILogger
{
- // Minimum LogLevel. LogLevel below this would be disabled.
- private readonly LogLevel _minimumLogLevel = LogLevel.Information;
+ // Minimum LogLevel for CLI output.
+ // For MCP mode: use CLI's --LogLevel if specified, otherwise suppress all.
+ // For non-MCP mode: always use Information.
+ // Note: --LogLevel is meant for the ENGINE's log level, not CLI's output.
+ private static LogLevel MinimumLogLevel => Cli.Utils.IsMcpStdioMode
+ ? (Cli.Utils.IsLogLevelOverriddenByCli ? Cli.Utils.CliLogLevel : LogLevel.None)
+ : LogLevel.Information;
// Color values based on LogLevel
// LogLevel Foreground Background
@@ -56,21 +61,68 @@ public class CustomConsoleLogger : ILogger
{LogLevel.Critical, ConsoleColor.DarkRed}
};
+ ///
+ /// Maps LogLevel to abbreviated labels matching ASP.NET Core's default console formatter.
+ ///
+ private static readonly Dictionary _logLevelToAbbreviation = new()
+ {
+ {LogLevel.Trace, "trce"},
+ {LogLevel.Debug, "dbug"},
+ {LogLevel.Information, "info"},
+ {LogLevel.Warning, "warn"},
+ {LogLevel.Error, "fail"},
+ {LogLevel.Critical, "crit"}
+ };
+
///
/// Creates Log message by setting console message color based on LogLevel.
+ /// In MCP stdio mode:
+ /// - If user explicitly set --LogLevel: write to stderr (colored output)
+ /// - Otherwise: suppress entirely to keep stdout clean for JSON-RPC protocol.
///
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
- if (!IsEnabled(logLevel) || logLevel < _minimumLogLevel)
+ // In MCP stdio mode, only output logs if user explicitly requested a log level.
+ // In that case, write to stderr to keep stdout clean for JSON-RPC.
+ if (Cli.Utils.IsMcpStdioMode)
+ {
+ if (!Cli.Utils.IsLogLevelOverriddenByCli)
+ {
+ return; // Suppress entirely when no explicit log level
+ }
+
+ // User wants logs in MCP mode - write to stderr
+ if (!IsEnabled(logLevel) || logLevel < MinimumLogLevel)
+ {
+ return;
+ }
+
+ if (!_logLevelToAbbreviation.TryGetValue(logLevel, out string? mcpAbbreviation))
+ {
+ return;
+ }
+
+ // In MCP stdio mode, stdout is reserved for JSON-RPC protocol messages.
+ // Logs must go to stderr to avoid corrupting the MCP communication channel.
+ Console.Error.WriteLine($"{mcpAbbreviation}: {formatter(state, exception)}");
+ return;
+ }
+
+ if (!IsEnabled(logLevel) || logLevel < MinimumLogLevel)
+ {
+ return;
+ }
+
+ if (!_logLevelToAbbreviation.TryGetValue(logLevel, out string? abbreviation))
{
return;
}
ConsoleColor originalForeGroundColor = Console.ForegroundColor;
ConsoleColor originalBackGroundColor = Console.BackgroundColor;
- Console.ForegroundColor = _logLevelToForeGroundConsoleColorMap[logLevel];
- Console.BackgroundColor = _logLevelToBackGroundConsoleColorMap[logLevel];
- Console.Write($"{logLevel}:");
+ Console.ForegroundColor = _logLevelToForeGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.White);
+ Console.BackgroundColor = _logLevelToBackGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.Black);
+ Console.Write($"{abbreviation}:");
Console.ForegroundColor = originalForeGroundColor;
Console.BackgroundColor = originalBackGroundColor;
Console.WriteLine($" {formatter(state, exception)}");
diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs
index de16ed27f5..4783c2292b 100644
--- a/src/Cli/Program.cs
+++ b/src/Cli/Program.cs
@@ -26,6 +26,10 @@ public static int Main(string[] args)
// Load environment variables from .env file if present.
DotNetEnv.Env.Load();
+ // Parse MCP and LogLevel flags in a single pass for efficiency.
+ // These flags need to be known before logger creation.
+ ParseEarlyFlags(args);
+
// Logger setup and configuration
ILoggerFactory loggerFactory = Utils.LoggerFactoryForCli;
ILogger cliLogger = loggerFactory.CreateLogger();
@@ -41,6 +45,32 @@ public static int Main(string[] args)
return Execute(args, cliLogger, fileSystem, loader);
}
+ ///
+ /// Parses flags that need to be known before logger creation.
+ /// Scans args in a single pass for efficiency.
+ ///
+ /// Command line arguments
+ private static void ParseEarlyFlags(string[] args)
+ {
+ for (int i = 0; i < args.Length; i++)
+ {
+ string arg = args[i];
+
+ if (string.Equals(arg, "--mcp-stdio", StringComparison.OrdinalIgnoreCase))
+ {
+ Utils.IsMcpStdioMode = true;
+ }
+ else if (string.Equals(arg, "--LogLevel", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ Utils.IsLogLevelOverriddenByCli = true;
+ if (Enum.TryParse(args[i + 1], ignoreCase: true, out LogLevel cliLogLevel))
+ {
+ Utils.CliLogLevel = cliLogLevel;
+ }
+ }
+ }
+ }
+
///
/// Execute the CLI command
///
diff --git a/src/Cli/Utils.cs b/src/Cli/Utils.cs
index c1ff7f2a99..4dd0125f91 100644
--- a/src/Cli/Utils.cs
+++ b/src/Cli/Utils.cs
@@ -23,6 +23,23 @@ 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; }
+
+ ///
+ /// When true, user explicitly set --LogLevel via CLI (even in MCP mode).
+ /// This allows logs to be written to stderr instead of being completely suppressed.
+ ///
+ public static bool IsLogLevelOverriddenByCli { get; set; }
+
+ ///
+ /// The log level specified via CLI --LogLevel flag.
+ /// Only valid when IsLogLevelOverriddenByCli is true.
+ ///
+ public static LogLevel CliLogLevel { get; set; } = LogLevel.Information;
+
#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/Config/Converters/EntityCacheOptionsConverterFactory.cs b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs
index 30f1241131..cdb93a8602 100644
--- a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs
+++ b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs
@@ -125,7 +125,7 @@ public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? r
/// when its corresponding UserProvided* flag is true. This avoids polluting the written
/// JSON file with properties the user omitted (defaults or inherited values).
/// If the user provided a cache object (Entity.Cache is non-null), we always write the
- /// object — even if it ends up empty ("cache": {}) — because the user explicitly included it.
+ /// object — even if it ends up empty ("cache": {}) — because the user explicitly included it.
/// Entity.Cache being null means the user never wrote a cache property, and the serializer's
/// DefaultIgnoreCondition.WhenWritingNull suppresses the "cache" key entirely.
///
diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs
index 54c3e77556..ff8552d12b 100644
--- a/src/Config/ObjectModel/RuntimeConfig.cs
+++ b/src/Config/ObjectModel/RuntimeConfig.cs
@@ -770,6 +770,17 @@ Runtime.Telemetry.LoggerLevel is null ||
return false;
}
+ ///
+ /// Checks if config actually specifies a non-null log level value.
+ /// This is stricter than !IsLogLevelNull() because it verifies at least
+ /// one log level value is explicitly set (not null).
+ /// Used to determine if MCP logging/setLevel should be blocked.
+ ///
+ public bool HasExplicitLogLevel()
+ {
+ return Runtime?.Telemetry?.LoggerLevel?.Values.Any(v => v.HasValue) ?? false;
+ }
+
///
/// Takes in the RuntimeConfig object and checks the LogLevel.
/// If LogLevel is not null, it will return the current value as a LogLevel,
diff --git a/src/Core/Services/RequestValidator.cs b/src/Core/Services/RequestValidator.cs
index aef6ca9ab3..0b6b0734ad 100644
--- a/src/Core/Services/RequestValidator.cs
+++ b/src/Core/Services/RequestValidator.cs
@@ -401,7 +401,7 @@ public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx,
else
{
// Body-based PK: non-auto-generated PK columns MUST be present.
- // Auto-generated PK columns are skipped — they cannot be supplied by the caller.
+ // Auto-generated PK columns are skipped — they cannot be supplied by the caller.
if (column.Value.IsAutoGenerated)
{
continue;
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/Directory.Packages.props b/src/Directory.Packages.props
index dfd605cc8f..c7ada5f8a7 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -40,7 +40,7 @@
-
+
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index e37b0920e2..8abafb4bdb 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -2756,7 +2756,7 @@ public async Task TestGlobalFlagToEnableRestGraphQLAndMcpForHostedAndNonHostedEn
Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode, "The REST response is different from the expected result.");
// MCP request
- HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp);
+ (HttpStatusCode mcpResponseCode, _) = await GetMcpResponse(client, configuration.Runtime.Mcp);
Assert.AreEqual(expectedStatusCodeForMcp, mcpResponseCode, "The MCP response is different from the expected result.");
}
@@ -2783,6 +2783,44 @@ public async Task TestGlobalFlagToEnableRestGraphQLAndMcpForHostedAndNonHostedEn
}
}
+ [TestMethod]
+ [TestCategory(TestCategory.MSSQL)]
+ public async Task TestMcpInitializeIncludesInstructionsFromRuntimeDescription()
+ {
+ const string MCP_INSTRUCTIONS = "Use SQL tools to query the database.";
+ const string CUSTOM_CONFIG = "custom-config-mcp-instructions.json";
+
+ TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT);
+
+ GraphQLRuntimeOptions graphqlOptions = new(Enabled: false);
+ RestRuntimeOptions restRuntimeOptions = new(Enabled: false);
+ McpRuntimeOptions mcpRuntimeOptions = new(Enabled: true, Description: MCP_INSTRUCTIONS);
+
+ SqlConnectionStringBuilder connectionStringBuilder = new(GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))
+ {
+ TrustServerCertificate = true
+ };
+
+ DataSource dataSource = new(DatabaseType.MSSQL,
+ connectionStringBuilder.ConnectionString, Options: null);
+
+ RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions);
+ File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
+
+ string[] args = new[]
+ {
+ $"--ConfigFileName={CUSTOM_CONFIG}"
+ };
+
+ using TestServer server = new(Program.CreateWebHostBuilder(args));
+ using HttpClient client = server.CreateClient();
+
+ JsonElement initializeResponse = await GetMcpInitializeResponse(client, configuration.Runtime.Mcp);
+ JsonElement result = initializeResponse.GetProperty("result");
+
+ Assert.AreEqual(MCP_INSTRUCTIONS, result.GetProperty("instructions").GetString(), "MCP initialize response should include instructions from runtime.mcp.description.");
+ }
+
///
/// For mutation operations, both the respective operation(create/update/delete) + read permissions are needed to receive a valid response.
/// In this test, Anonymous role is configured with only create permission.
@@ -6208,13 +6246,13 @@ private static async Task GetGraphQLResponsePostConfigHydration(
return responseCode;
}
- ///
- /// Executing MCP POST requests against the engine until a non-503 error is received.
- ///
- /// Client used for request execution.
- /// ServiceUnavailable if service is not successfully hydrated with config,
- /// else the response code from the MCP request
- public static async Task GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp)
+ ///
+ /// Executing MCP POST requests against the engine until a non-503 error is received.
+ ///
+ /// Client used for request execution.
+ /// MCP runtime options containing path configuration.
+ /// A tuple containing the HTTP status code and response body.
+ public static async Task<(HttpStatusCode StatusCode, string ResponseBody)> GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp)
{
// Retry request RETRY_COUNT times in exponential increments to allow
// required services time to instantiate and hydrate permissions because
@@ -6224,6 +6262,8 @@ public static async Task GetMcpResponse(HttpClient httpClient, M
// but it is highly unlikely to be the case.
int retryCount = 0;
HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable;
+ string responseBody = string.Empty;
+
while (retryCount < RETRY_COUNT)
{
// Minimal MCP request (initialize) - valid JSON-RPC request.
@@ -6241,14 +6281,16 @@ public static async Task GetMcpResponse(HttpClient httpClient, M
clientInfo = new { name = "dab-test", version = "1.0.0" }
}
};
- HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path)
+
+ using HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path)
{
Content = JsonContent.Create(payload)
};
mcpRequest.Headers.Add("Accept", "application/json, text/event-stream");
- HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest);
+ using HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest);
responseCode = mcpResponse.StatusCode;
+ responseBody = await mcpResponse.Content.ReadAsStringAsync();
if (responseCode == HttpStatusCode.ServiceUnavailable || responseCode == HttpStatusCode.NotFound)
{
@@ -6260,7 +6302,85 @@ public static async Task GetMcpResponse(HttpClient httpClient, M
break;
}
- return responseCode;
+ return (responseCode, responseBody);
+ }
+
+ ///
+ /// Executes MCP initialize over HTTP and returns the parsed JSON response.
+ /// Reuses the core request/retry logic from GetMcpResponse.
+ ///
+ public static async Task GetMcpInitializeResponse(HttpClient httpClient, McpRuntimeOptions mcp)
+ {
+ (HttpStatusCode responseCode, string responseBody) = await GetMcpResponse(httpClient, mcp);
+
+ Assert.AreEqual(HttpStatusCode.OK, responseCode, "MCP initialize should return HTTP 200.");
+ Assert.IsFalse(string.IsNullOrWhiteSpace(responseBody), "MCP initialize response body should not be empty.");
+
+ // Depending on transport/content negotiation, initialize can return plain JSON
+ // or SSE-formatted text where JSON payload is carried in a data: line.
+ string payloadToParse = responseBody.TrimStart().StartsWith('{')
+ ? responseBody
+ : ExtractJsonFromSsePayload(responseBody);
+
+ Assert.IsFalse(string.IsNullOrWhiteSpace(payloadToParse), "MCP initialize response did not contain a JSON payload.");
+
+ using JsonDocument responseDocument = JsonDocument.Parse(payloadToParse);
+ return responseDocument.RootElement.Clone();
+ }
+
+ ///
+ /// Extracts JSON payload from SSE-formatted text.
+ /// SSE events can split JSON across multiple data: lines which should be concatenated.
+ ///
+ private static string ExtractJsonFromSsePayload(string ssePayload)
+ {
+ List eventDataLines = new();
+
+ static string GetJsonPayload(List dataLines)
+ {
+ if (dataLines.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ string combinedPayload = string.Join("\n", dataLines);
+ return !string.IsNullOrWhiteSpace(combinedPayload) && combinedPayload.TrimStart().StartsWith('{')
+ ? combinedPayload
+ : string.Empty;
+ }
+
+ foreach (string rawLine in ssePayload.Split('\n'))
+ {
+ string line = rawLine.TrimEnd('\r');
+
+ // Empty line signals end of an SSE event
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ string jsonPayload = GetJsonPayload(eventDataLines);
+ if (!string.IsNullOrEmpty(jsonPayload))
+ {
+ return jsonPayload;
+ }
+
+ eventDataLines.Clear();
+ continue;
+ }
+
+ if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ {
+ string data = line.Substring("data:".Length);
+ // SSE spec: if data starts with a space, strip one leading space
+ if (data.StartsWith(' '))
+ {
+ data = data.Substring(1);
+ }
+
+ eventDataLines.Add(data);
+ }
+ }
+
+ // Handle case where payload doesn't end with empty line
+ return GetJsonPayload(eventDataLines);
}
///
diff --git a/src/Service.Tests/UnitTests/DynamicLogLevelProviderTests.cs b/src/Service.Tests/UnitTests/DynamicLogLevelProviderTests.cs
new file mode 100644
index 0000000000..131155c171
--- /dev/null
+++ b/src/Service.Tests/UnitTests/DynamicLogLevelProviderTests.cs
@@ -0,0 +1,57 @@
+// 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
+ {
+ [DataTestMethod]
+ [DataRow(LogLevel.Error, false, false, "debug", true, LogLevel.Debug, DisplayName = "Valid level change succeeds")]
+ [DataRow(LogLevel.Error, true, false, "debug", false, LogLevel.Error, DisplayName = "CLI override blocks MCP change")]
+ [DataRow(LogLevel.Warning, false, true, "debug", false, LogLevel.Warning, DisplayName = "Config override blocks MCP change")]
+ [DataRow(LogLevel.Error, false, false, "invalid", false, LogLevel.Error, DisplayName = "Invalid level returns false")]
+ public void UpdateFromMcp_ReturnsExpectedResult(
+ LogLevel initialLevel,
+ bool isCliOverridden,
+ bool isConfigOverridden,
+ string mcpLevel,
+ bool expectedResult,
+ LogLevel expectedFinalLevel)
+ {
+ // Arrange
+ DynamicLogLevelProvider provider = new();
+ provider.SetInitialLogLevel(initialLevel, isCliOverridden, isConfigOverridden);
+
+ // Act
+ bool result = provider.UpdateFromMcp(mcpLevel);
+
+ // Assert
+ Assert.AreEqual(expectedResult, result);
+ Assert.AreEqual(expectedFinalLevel, 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..02719ac13a 100644
--- a/src/Service/Program.cs
+++ b/src/Service/Program.cs
@@ -4,11 +4,14 @@
using System;
using System.CommandLine;
using System.CommandLine.Parsing;
+using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Telemetry;
using Azure.DataApiBuilder.Service.Exceptions;
using Azure.DataApiBuilder.Service.Telemetry;
using Azure.DataApiBuilder.Service.Utilities;
@@ -62,6 +65,30 @@ 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.
+ // For MCP mode, we also read the config file early to check for log level override.
+ LogLevel initialLogLevel = GetLogLevelFromCommandLineArgsOrConfig(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)
@@ -110,16 +137,33 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st
.ConfigureServices((context, services) =>
{
services.AddSingleton(LogLevelProvider);
+ services.AddSingleton(LogLevelProvider);
})
.ConfigureLogging(logging =>
{
- logging.AddFilter("Microsoft", logLevel => LogLevelProvider.ShouldLog(logLevel));
- logging.AddFilter("Microsoft.Hosting.Lifetime", logLevel => LogLevelProvider.ShouldLog(logLevel));
+ // For MCP stdio mode, we need dynamic log level control via logging/setLevel.
+ // Set framework minimum to Trace so all logs pass through to the dynamic filter.
+ // The dynamic AddFilter() will do the actual filtering based on current level.
+ // For non-MCP mode, use the configured level directly.
+ if (runMcpStdio)
+ {
+ // Allow all logs through framework, filter dynamically
+ logging.SetMinimumLevel(LogLevel.Trace);
+ }
+ else
+ {
+ 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);
@@ -128,22 +172,61 @@ 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.
+ /// Extracts the log level from the command line arguments and optionally from config.
+ /// When --LogLevel is present, returns that value with CLI override flag set.
+ /// When in MCP stdio mode without explicit --LogLevel, reads the config file to check for log level.
+ /// When in normal mode without explicit --LogLevel, defaults to Error (UpdateFromRuntimeConfig()
+ /// will later adjust based on config: Debug for Development mode, Error for Production mode).
///
- /// array that may contain log level information.
- /// sets if log level is found in the args.
+ /// Array that may contain log level information.
+ /// Whether running in MCP stdio mode.
+ /// Sets if log level is found in the args from CLI.
+ /// Sets if log level is found in the config file (MCP mode only).
/// Appropriate log level.
- private static LogLevel GetLogLevelFromCommandLineArgs(string[] args, out bool isLogLevelOverridenByCli)
+ private static LogLevel GetLogLevelFromCommandLineArgsOrConfig(string[] args, bool runMcpStdio, out bool isLogLevelOverridenByCli, out bool isConfigOverridden)
{
- Command cmd = new(name: "start");
- Option logLevelOption = new(name: "--LogLevel");
- cmd.AddOption(logLevelOption);
- 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;
+ LogLevel logLevel;
+ isConfigOverridden = false;
+
+ // Check if --LogLevel was explicitly specified via CLI (case-insensitive parsing)
+ int logLevelIndex = Array.FindIndex(args, a => string.Equals(a, "--LogLevel", StringComparison.OrdinalIgnoreCase));
+ bool hasCliLogLevel = logLevelIndex >= 0 && logLevelIndex + 1 < args.Length;
+
+ if (hasCliLogLevel && Enum.TryParse(args[logLevelIndex + 1], ignoreCase: true, out LogLevel cliLogLevel))
+ {
+ // User explicitly set --LogLevel via CLI (highest priority)
+ logLevel = cliLogLevel;
+ isLogLevelOverridenByCli = true;
+ }
+ else if (runMcpStdio)
+ {
+ // MCP stdio mode without explicit --LogLevel: check config for log level (second priority)
+ isLogLevelOverridenByCli = false;
+ logLevel = LogLevel.None; // Default if config doesn't have log level
+
+ // Find --config or --ConfigFileName argument, or use default dab-config.json
+ int configIndex = Array.FindIndex(args, a =>
+ string.Equals(a, "--config", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(a, "--ConfigFileName", StringComparison.OrdinalIgnoreCase));
+ string? configFilePath = configIndex >= 0 && configIndex + 1 < args.Length
+ ? args[configIndex + 1]
+ : FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME;
+
+ if (!string.IsNullOrWhiteSpace(configFilePath) && TryGetLogLevelFromConfig(configFilePath, out LogLevel configLogLevel))
+ {
+ logLevel = configLogLevel;
+ isConfigOverridden = true;
+ }
+ }
+ else
+ {
+ // Normal (non-MCP) mode without explicit --LogLevel:
+ // Start with Error as fallback. UpdateFromRuntimeConfig() will later
+ // adjust based on config: Debug for Development mode, Error for Production mode.
+ // This initial value is used before config is loaded.
+ logLevel = LogLevel.Error;
+ isLogLevelOverridenByCli = false;
+ }
if (logLevel is > LogLevel.None or < LogLevel.Trace)
{
@@ -157,6 +240,42 @@ private static LogLevel GetLogLevelFromCommandLineArgs(string[] args, out bool i
return logLevel;
}
+ ///
+ /// Attempts to read the config file early to extract the log level.
+ /// This is used in MCP stdio mode to determine the Console redirect before host build.
+ ///
+ /// Path to the config file.
+ /// The log level from config, if found.
+ /// True if config has an explicit log level; false otherwise.
+ private static bool TryGetLogLevelFromConfig(string configFilePath, out LogLevel logLevel)
+ {
+ logLevel = LogLevel.None;
+ try
+ {
+ if (!File.Exists(configFilePath))
+ {
+ return false;
+ }
+
+ string configJson = File.ReadAllText(configFilePath);
+ if (RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config) && config is not null)
+ {
+ if (config.HasExplicitLogLevel())
+ {
+ // Use the config's method to get the resolved log level
+ logLevel = config.GetConfiguredLogLevel();
+ return true;
+ }
+ }
+ }
+ catch
+ {
+ // Ignore config parse errors - fall back to default log level
+ }
+
+ return false;
+ }
+
///
/// Helper function returns ParseResult for a given command and
/// arguments.
@@ -275,14 +394,21 @@ public static ILoggerFactory GetLoggerFactoryForLogLevel(
}
}
- // In stdio mode, route console logs to STDERR to keep STDOUT clean for MCP JSON
+ // In stdio mode, route console logs to STDERR to keep STDOUT clean for MCP JSON.
+ // Only add console logger if log level is not None (silent mode).
if (stdio)
{
builder.ClearProviders();
- builder.AddConsole(options =>
+
+ // Only add ConsoleLoggerProvider if we actually want logs.
+ // When LogLevel.None, skip the console logger entirely for true silence.
+ if (LogLevelProvider.CurrentLogLevel != LogLevel.None)
{
- options.LogToStandardErrorThreshold = LogLevel.Trace;
- });
+ builder.AddConsole(options =>
+ {
+ options.LogToStandardErrorThreshold = LogLevel.Trace;
+ });
+ }
}
else
{
diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs
index 12cbd18ab0..a1553d0a71 100644
--- a/src/Service/Startup.cs
+++ b/src/Service/Startup.cs
@@ -600,7 +600,7 @@ private void ConfigureResponseCompression(IServiceCollection services, RuntimeCo
options.Level = systemCompressionLevel;
});
- _logger.LogInformation("Response compression enabled with level '{compressionLevel}' for REST, GraphQL, and MCP endpoints.", compressionLevel);
+ _logger.LogDebug("Response compression enabled with level '{compressionLevel}' for REST, GraphQL, and MCP endpoints.", compressionLevel);
}
///
diff --git a/src/Service/Telemetry/DynamicLogLevelProvider.cs b/src/Service/Telemetry/DynamicLogLevelProvider.cs
index 3c35e295e6..ec8c08ca9b 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,54 @@ public void UpdateFromRuntimeConfig(RuntimeConfig runtimeConfig)
if (!IsCliOverridden)
{
CurrentLogLevel = runtimeConfig.GetConfiguredLogLevel();
+
+ // Track if config explicitly set a non-null log level value.
+ // This ensures MCP logging/setLevel is only blocked when config
+ // actually pins a log level, not just when the dictionary exists.
+ IsConfigOverridden = runtimeConfig.HasExplicitLogLevel();
+ }
+ }
+
+ ///
+ /// 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)