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)