Skip to content

Commit 8f8ee11

Browse files
Implemented MCP Set Log Level (#3419)
## Why make this change? Closes #3274 - MCP Server returns "Method not found: logging/setLevel" error when clients send the standard MCP logging/setLevel request. Closes #3275 - Control output in MCP stdio mode (default to `LogLevel.None`, redirect/suppress console output). ## What is this change? ### MCP `logging/setLevel` Handler - Added handler for `logging/setLevel` JSON-RPC method in `McpStdioServer.cs` - Implemented `DynamicLogLevelProvider` with `ILogLevelController` interface to allow MCP to update log levels dynamically - Added `IsCliOverridden` and `IsConfigOverridden` properties to enforce precedence rules ### Log Level Precedence System **Precedence (highest to lowest):** 1. **CLI `--LogLevel` flag** - cannot be changed by MCP 2. **Config `runtime.telemetry.log-level`** - cannot be changed by MCP 3. **MCP `logging/setLevel`** - only works if neither CLI nor config set a level 4. Default (LogLevel.None for MCP stdio mode) If CLI or config set a level, MCP requests are accepted but silently ignored (no error returned per MCP spec). ### Early Config Reading for MCP Mode - Added `TryGetLogLevelFromConfig()` in `Program.cs` to read config file early (before host build) - This ensures config log level is detected before Console redirect decision - Console redirect for MCP stdio mode now respects config log level ### CLI Log Level Handling - Added `Utils.CliLogLevel` property to track the parsed `--LogLevel` value - CLI's `CustomLoggerProvider` now respects the `--LogLevel` value for its own logging ### Config Helpers - Added `HasExplicitLogLevel()` helper to `RuntimeConfig` to correctly detect when config actually pins a log level - This properly handles null values in telemetry section (null values don't count as explicit override) ## How was this tested? - [x] Unit Tests (`DynamicLogLevelProviderTests` - 5 tests) - [x] Manual Testing ### Manual Test 1: No override (MCP can change level) 1. Start MCP server without `--LogLevel` and without config `log-level` 2. MCP sends `logging/setLevel` with `level: info` 3. Result: Log level changes to info ### Manual Test 2: CLI override (MCP blocked) 1. Start MCP server with `--LogLevel Warning` 2. MCP sends `logging/setLevel` with `level: info` 3. Result: Log level stays at Warning, MCP request accepted silently ### Manual Test 3: Config override (MCP blocked) 1. Add `"telemetry": { "log-level": { "default": "Warning" } }` to config 2. Start MCP server without `--LogLevel` 3. MCP sends `logging/setLevel` with `level: info` 5. Result: Log level stays at Warning, MCP request accepted silently ### Manual Test 4: Config with null values (MCP can change level) 1. Add `"telemetry": { "log-level": { "default": null } }` to config 2. Start MCP server without `--LogLevel` 3. MCP sends `logging/setLevel` with `level: info` 4. Result: Log level changes to info (null values don't count as override) ## Sample Request(s) MCP client sends: ```json { "jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": { "level": "info" } } ``` Server responds with empty result (success per MCP spec) and updates log level if no CLI/config override is active. --------- Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com>
1 parent 3bc42d2 commit 8f8ee11

12 files changed

Lines changed: 544 additions & 61 deletions

File tree

src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs

Lines changed: 123 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Azure.DataApiBuilder.Config.ObjectModel;
77
using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator;
88
using Azure.DataApiBuilder.Core.Configurations;
9+
using Azure.DataApiBuilder.Core.Telemetry;
910
using Azure.DataApiBuilder.Mcp.Model;
1011
using Azure.DataApiBuilder.Mcp.Utils;
1112
using Microsoft.AspNetCore.Http;
@@ -46,8 +47,6 @@ public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProv
4647
/// <returns>A task representing the asynchronous operation.</returns>
4748
public async Task RunAsync(CancellationToken cancellationToken)
4849
{
49-
Console.Error.WriteLine("[MCP DEBUG] MCP stdio server started.");
50-
5150
// Use UTF-8 WITHOUT BOM
5251
UTF8Encoding utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
5352

@@ -77,15 +76,13 @@ public async Task RunAsync(CancellationToken cancellationToken)
7776
{
7877
doc = JsonDocument.Parse(line);
7978
}
80-
catch (JsonException jsonEx)
79+
catch (JsonException)
8180
{
82-
Console.Error.WriteLine($"[MCP DEBUG] JSON parse error: {jsonEx.Message}");
8381
WriteError(id: null, code: McpStdioJsonRpcErrorCodes.PARSE_ERROR, message: "Parse error");
8482
continue;
8583
}
86-
catch (Exception ex)
84+
catch (Exception)
8785
{
88-
Console.Error.WriteLine($"[MCP DEBUG] Unexpected error parsing request: {ex.Message}");
8986
WriteError(id: null, code: McpStdioJsonRpcErrorCodes.INTERNAL_ERROR, message: "Internal error");
9087
continue;
9188
}
@@ -131,6 +128,10 @@ public async Task RunAsync(CancellationToken cancellationToken)
131128
WriteResult(id, new { ok = true });
132129
break;
133130

131+
case "logging/setLevel":
132+
HandleSetLogLevel(id, root);
133+
break;
134+
134135
case "shutdown":
135136
WriteResult(id, new { ok = true });
136137
return;
@@ -171,30 +172,50 @@ private void HandleInitialize(JsonElement? id)
171172
RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
172173
instructions = runtimeConfig.Runtime?.Mcp?.Description;
173174
}
174-
catch (Exception ex)
175+
catch (Exception)
175176
{
176-
// Log to stderr for diagnostics and rethrow to avoid masking configuration errors
177-
Console.Error.WriteLine($"[MCP WARNING] Failed to retrieve MCP description from config: {ex.Message}");
177+
// Rethrow to avoid masking configuration errors
178178
throw;
179179
}
180180
}
181181

182-
// Create the initialize response
183-
object result = new
182+
// Create the initialize response - only include instructions if non-empty
183+
object result;
184+
if (!string.IsNullOrWhiteSpace(instructions))
184185
{
185-
protocolVersion = _protocolVersion,
186-
capabilities = new
186+
result = new
187187
{
188-
tools = new { listChanged = true },
189-
logging = new { }
190-
},
191-
serverInfo = new
188+
protocolVersion = _protocolVersion,
189+
capabilities = new
190+
{
191+
tools = new { listChanged = true },
192+
logging = new { }
193+
},
194+
serverInfo = new
195+
{
196+
name = McpProtocolDefaults.MCP_SERVER_NAME,
197+
version = McpProtocolDefaults.MCP_SERVER_VERSION
198+
},
199+
instructions = instructions
200+
};
201+
}
202+
else
203+
{
204+
result = new
192205
{
193-
name = McpProtocolDefaults.MCP_SERVER_NAME,
194-
version = McpProtocolDefaults.MCP_SERVER_VERSION
195-
},
196-
instructions = !string.IsNullOrWhiteSpace(instructions) ? instructions : null
197-
};
206+
protocolVersion = _protocolVersion,
207+
capabilities = new
208+
{
209+
tools = new { listChanged = true },
210+
logging = new { }
211+
},
212+
serverInfo = new
213+
{
214+
name = McpProtocolDefaults.MCP_SERVER_NAME,
215+
version = McpProtocolDefaults.MCP_SERVER_VERSION
216+
}
217+
};
218+
}
198219

199220
WriteResult(id, result);
200221
}
@@ -228,6 +249,85 @@ private void HandleListTools(JsonElement? id)
228249
WriteResult(id, new { tools = toolsWire });
229250
}
230251

252+
/// <summary>
253+
/// Handles the "logging/setLevel" JSON-RPC method by updating the runtime log level.
254+
/// </summary>
255+
/// <param name="id">The request identifier extracted from the incoming JSON-RPC request.</param>
256+
/// <param name="root">The root JSON element of the incoming JSON-RPC request.</param>
257+
/// <remarks>
258+
/// Log level precedence (highest to lowest):
259+
/// 1. CLI --LogLevel flag - cannot be overridden
260+
/// 2. Config runtime.telemetry.log-level - cannot be overridden by MCP
261+
/// 3. MCP logging/setLevel - only works if neither CLI nor Config explicitly set a level
262+
/// 4. Default: None for MCP stdio mode (silent by default to keep stdout clean for JSON-RPC)
263+
///
264+
/// If CLI or Config set the log level, this method accepts the request but silently ignores it.
265+
/// The client won't get an error, but CLI/Config wins.
266+
///
267+
/// When MCP sets a level other than "none", this also restores Console.Error to the real stderr
268+
/// stream so that logs become visible (Console may have been redirected to null at startup).
269+
/// It also enables MCP log notifications so logs are sent to the client via notifications/message.
270+
/// </remarks>
271+
private void HandleSetLogLevel(JsonElement? id, JsonElement root)
272+
{
273+
// Extract the level parameter from the request
274+
string? level = null;
275+
if (root.TryGetProperty("params", out JsonElement paramsEl) &&
276+
paramsEl.TryGetProperty("level", out JsonElement levelEl) &&
277+
levelEl.ValueKind == JsonValueKind.String)
278+
{
279+
level = levelEl.GetString();
280+
}
281+
282+
if (string.IsNullOrWhiteSpace(level))
283+
{
284+
WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, "Missing or invalid 'level' parameter");
285+
return;
286+
}
287+
288+
// Get the ILogLevelController from service provider
289+
ILogLevelController? logLevelController = _serviceProvider.GetService<ILogLevelController>();
290+
if (logLevelController is null)
291+
{
292+
// Log level controller not available - still accept request per MCP spec
293+
WriteResult(id, new { });
294+
return;
295+
}
296+
297+
// Attempt to update the log level
298+
// If CLI or Config overrode, this returns false but we still return success to the client
299+
bool updated = logLevelController.UpdateFromMcp(level);
300+
301+
// If MCP successfully changed the log level to something other than "none",
302+
// ensure Console.Error is pointing to the real stderr (not TextWriter.Null).
303+
// This handles the case where MCP stdio mode started with LogLevel.None (quiet startup)
304+
// and the client later enables logging via logging/setLevel.
305+
bool isLoggingEnabled = !string.Equals(level, "none", StringComparison.OrdinalIgnoreCase);
306+
if (updated && isLoggingEnabled)
307+
{
308+
RestoreStderrIfNeeded();
309+
}
310+
311+
// Always return success (empty result object) per MCP spec
312+
WriteResult(id, new { });
313+
}
314+
315+
/// <summary>
316+
/// Restores Console.Error to the real stderr stream if it was redirected to TextWriter.Null.
317+
/// This enables log output after MCP client sends logging/setLevel with a level other than "none".
318+
/// </summary>
319+
private static void RestoreStderrIfNeeded()
320+
{
321+
// Always restore stderr to the real stream when MCP enables logging.
322+
// This is safe to call multiple times - we just re-wrap the standard error stream.
323+
Stream stderr = Console.OpenStandardError();
324+
StreamWriter stderrWriter = new(stderr, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
325+
{
326+
AutoFlush = true
327+
};
328+
Console.SetError(stderrWriter);
329+
}
330+
231331
/// <summary>
232332
/// Handles the "tools/call" JSON-RPC method by executing the specified tool with the provided arguments.
233333
/// </summary>
@@ -259,14 +359,12 @@ private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, Cancel
259359

260360
if (string.IsNullOrWhiteSpace(toolName))
261361
{
262-
Console.Error.WriteLine("[MCP DEBUG] callTool → missing tool name.");
263362
WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, "Missing tool name");
264363
return;
265364
}
266365

267366
if (!_toolRegistry.TryGetTool(toolName!, out IMcpTool? tool) || tool is null)
268367
{
269-
Console.Error.WriteLine($"[MCP DEBUG] callTool → tool not found: {toolName}");
270368
WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, $"Tool not found: {toolName}");
271369
return;
272370
}
@@ -276,13 +374,7 @@ private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, Cancel
276374
{
277375
if (@params.TryGetProperty("arguments", out JsonElement argsEl) && argsEl.ValueKind == JsonValueKind.Object)
278376
{
279-
string rawArgs = argsEl.GetRawText();
280-
Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: {rawArgs}");
281-
argsDoc = JsonDocument.Parse(rawArgs);
282-
}
283-
else
284-
{
285-
Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: <none>");
377+
argsDoc = JsonDocument.Parse(argsEl.GetRawText());
286378
}
287379

288380
// Execute the tool with telemetry.

src/Cli/ConfigGenerator.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2582,8 +2582,12 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun
25822582
List<string> args = new()
25832583
{ "--ConfigFileName", runtimeConfigFile };
25842584

2585-
/// Add arguments for LogLevel. Checks if LogLevel is overridden with option `--LogLevel`.
2586-
/// If not provided, Default minimum LogLevel is Debug for Development mode and Error for Production mode.
2585+
/// Add arguments for LogLevel. Only pass --LogLevel when user explicitly specified it,
2586+
/// so that MCP logging/setLevel can still adjust the level when no CLI override is present.
2587+
///
2588+
/// When --LogLevel is NOT specified:
2589+
/// - MCP stdio mode: Service defaults to None for clean stdout output
2590+
/// - Non-MCP mode: Service defaults to Debug (Development) or Error (Production) based on config
25872591
LogLevel minimumLogLevel;
25882592
if (options.LogLevel is not null)
25892593
{
@@ -2596,6 +2600,8 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun
25962600
}
25972601

25982602
minimumLogLevel = (LogLevel)options.LogLevel;
2603+
// Only add --LogLevel when user explicitly specified it via CLI.
2604+
// This allows MCP logging/setLevel to work when no CLI override is present.
25992605
args.Add("--LogLevel");
26002606
args.Add(minimumLogLevel.ToString());
26012607
_logger.LogInformation("Setting minimum LogLevel: {minimumLogLevel}.", minimumLogLevel);

src/Cli/CustomLoggerProvider.cs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ public ILogger CreateLogger(string categoryName)
1818

1919
public class CustomConsoleLogger : ILogger
2020
{
21-
// Minimum LogLevel. LogLevel below this would be disabled.
22-
private readonly LogLevel _minimumLogLevel = LogLevel.Information;
21+
// Minimum LogLevel for CLI output.
22+
// For MCP mode: use CLI's --LogLevel if specified, otherwise suppress all.
23+
// For non-MCP mode: always use Information.
24+
// Note: --LogLevel is meant for the ENGINE's log level, not CLI's output.
25+
private static LogLevel MinimumLogLevel => Cli.Utils.IsMcpStdioMode
26+
? (Cli.Utils.IsLogLevelOverriddenByCli ? Cli.Utils.CliLogLevel : LogLevel.None)
27+
: LogLevel.Information;
2328

2429
// Color values based on LogLevel
2530
// LogLevel Foreground Background
@@ -58,10 +63,39 @@ public class CustomConsoleLogger : ILogger
5863

5964
/// <summary>
6065
/// Creates Log message by setting console message color based on LogLevel.
66+
/// In MCP stdio mode:
67+
/// - If user explicitly set --LogLevel: write to stderr (colored output)
68+
/// - Otherwise: suppress entirely to keep stdout clean for JSON-RPC protocol.
6169
/// </summary>
6270
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
6371
{
64-
if (!IsEnabled(logLevel) || logLevel < _minimumLogLevel)
72+
// In MCP stdio mode, only output logs if user explicitly requested a log level.
73+
// In that case, write to stderr to keep stdout clean for JSON-RPC.
74+
if (Cli.Utils.IsMcpStdioMode)
75+
{
76+
if (!Cli.Utils.IsLogLevelOverriddenByCli)
77+
{
78+
return; // Suppress entirely when no explicit log level
79+
}
80+
81+
// User wants logs in MCP mode - write to stderr
82+
if (!IsEnabled(logLevel) || logLevel < MinimumLogLevel)
83+
{
84+
return;
85+
}
86+
87+
if (!_logLevelToAbbreviation.TryGetValue(logLevel, out string? mcpAbbreviation))
88+
{
89+
return;
90+
}
91+
92+
// In MCP stdio mode, stdout is reserved for JSON-RPC protocol messages.
93+
// Logs must go to stderr to avoid corrupting the MCP communication channel.
94+
Console.Error.WriteLine($"{mcpAbbreviation}: {formatter(state, exception)}");
95+
return;
96+
}
97+
98+
if (!IsEnabled(logLevel) || logLevel < MinimumLogLevel)
6599
{
66100
return;
67101
}

src/Cli/Program.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ public static int Main(string[] args)
2626
// Load environment variables from .env file if present.
2727
DotNetEnv.Env.Load();
2828

29+
// Parse MCP and LogLevel flags in a single pass for efficiency.
30+
// These flags need to be known before logger creation.
31+
ParseEarlyFlags(args);
32+
2933
// Logger setup and configuration
3034
ILoggerFactory loggerFactory = Utils.LoggerFactoryForCli;
3135
ILogger<Program> cliLogger = loggerFactory.CreateLogger<Program>();
@@ -41,6 +45,32 @@ public static int Main(string[] args)
4145
return Execute(args, cliLogger, fileSystem, loader);
4246
}
4347

48+
/// <summary>
49+
/// Parses flags that need to be known before logger creation.
50+
/// Scans args in a single pass for efficiency.
51+
/// </summary>
52+
/// <param name="args">Command line arguments</param>
53+
private static void ParseEarlyFlags(string[] args)
54+
{
55+
for (int i = 0; i < args.Length; i++)
56+
{
57+
string arg = args[i];
58+
59+
if (string.Equals(arg, "--mcp-stdio", StringComparison.OrdinalIgnoreCase))
60+
{
61+
Utils.IsMcpStdioMode = true;
62+
}
63+
else if (string.Equals(arg, "--LogLevel", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
64+
{
65+
Utils.IsLogLevelOverriddenByCli = true;
66+
if (Enum.TryParse<LogLevel>(args[i + 1], ignoreCase: true, out LogLevel cliLogLevel))
67+
{
68+
Utils.CliLogLevel = cliLogLevel;
69+
}
70+
}
71+
}
72+
}
73+
4474
/// <summary>
4575
/// Execute the CLI command
4676
/// </summary>

src/Cli/Utils.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ public class Utils
2323
public const string WILDCARD = "*";
2424
public static readonly string SEPARATOR = ":";
2525

26+
/// <summary>
27+
/// When true, CLI logging to stdout is suppressed to keep the MCP stdio channel clean.
28+
/// </summary>
29+
public static bool IsMcpStdioMode { get; set; }
30+
31+
/// <summary>
32+
/// When true, user explicitly set --LogLevel via CLI (even in MCP mode).
33+
/// This allows logs to be written to stderr instead of being completely suppressed.
34+
/// </summary>
35+
public static bool IsLogLevelOverriddenByCli { get; set; }
36+
37+
/// <summary>
38+
/// The log level specified via CLI --LogLevel flag.
39+
/// Only valid when IsLogLevelOverriddenByCli is true.
40+
/// </summary>
41+
public static LogLevel CliLogLevel { get; set; } = LogLevel.Information;
42+
2643
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
2744
private static ILogger<Utils> _logger;
2845
#pragma warning restore CS8618

src/Config/Converters/EntityCacheOptionsConverterFactory.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? r
125125
/// when its corresponding UserProvided* flag is true. This avoids polluting the written
126126
/// JSON file with properties the user omitted (defaults or inherited values).
127127
/// If the user provided a cache object (Entity.Cache is non-null), we always write the
128-
/// object — even if it ends up empty ("cache": {}) — because the user explicitly included it.
128+
/// object — even if it ends up empty ("cache": {}) — because the user explicitly included it.
129129
/// Entity.Cache being null means the user never wrote a cache property, and the serializer's
130130
/// DefaultIgnoreCondition.WhenWritingNull suppresses the "cache" key entirely.
131131
/// </summary>

0 commit comments

Comments
 (0)