88using Azure . DataApiBuilder . Core . Configurations ;
99using Azure . DataApiBuilder . Core . Telemetry ;
1010using Azure . DataApiBuilder . Mcp . Model ;
11+ using Azure . DataApiBuilder . Mcp . Telemetry ;
1112using Azure . DataApiBuilder . Mcp . Utils ;
1213using Microsoft . AspNetCore . Http ;
1314using Microsoft . Extensions . Configuration ;
@@ -26,6 +27,7 @@ public class McpStdioServer : IMcpStdioServer
2627 {
2728 private readonly McpToolRegistry _toolRegistry ;
2829 private readonly IServiceProvider _serviceProvider ;
30+ private readonly McpStdoutWriter _stdoutWriter ;
2931 private readonly string _protocolVersion ;
3032
3133 private const int MAX_LINE_LENGTH = 1024 * 1024 ; // 1 MB limit for incoming JSON-RPC requests
@@ -35,6 +37,11 @@ public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProv
3537 _toolRegistry = toolRegistry ?? throw new ArgumentNullException ( nameof ( toolRegistry ) ) ;
3638 _serviceProvider = serviceProvider ?? throw new ArgumentNullException ( nameof ( serviceProvider ) ) ;
3739
40+ // Resolve the shared stdout writer so JSON-RPC responses and
41+ // notifications/message frames are serialized through one lock.
42+ // Falls back to a fresh instance if DI didn't register one (defensive).
43+ _stdoutWriter = _serviceProvider . GetService < McpStdoutWriter > ( ) ?? new McpStdoutWriter ( ) ;
44+
3845 // Allow protocol version to be configured via IConfiguration, using centralized defaults.
3946 IConfiguration ? configuration = _serviceProvider . GetService < IConfiguration > ( ) ;
4047 _protocolVersion = McpProtocolDefaults . ResolveProtocolVersion ( configuration ) ;
@@ -47,16 +54,14 @@ public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProv
4754 /// <returns>A task representing the asynchronous operation.</returns>
4855 public async Task RunAsync ( CancellationToken cancellationToken )
4956 {
50- // Use UTF-8 WITHOUT BOM
57+ // Use UTF-8 WITHOUT BOM for stdin. Stdout is owned by McpStdoutWriter,
58+ // which serializes all writes from McpStdioServer and the MCP logging
59+ // pipeline so JSON-RPC frames cannot interleave at the byte level.
5160 UTF8Encoding utf8NoBom = new ( encoderShouldEmitUTF8Identifier : false ) ;
5261
5362 using Stream stdin = Console . OpenStandardInput ( ) ;
54- using Stream stdout = Console . OpenStandardOutput ( ) ;
5563 using StreamReader reader = new ( stdin , utf8NoBom ) ;
56- using StreamWriter writer = new ( stdout , utf8NoBom ) { AutoFlush = true } ;
5764
58- // Redirect Console.Out to use our writer
59- Console . SetOut ( writer ) ;
6065 while ( ! cancellationToken . IsCancellationRequested )
6166 {
6267 string ? line = await reader . ReadLineAsync ( cancellationToken ) ;
@@ -298,16 +303,31 @@ private void HandleSetLogLevel(JsonElement? id, JsonElement root)
298303 // If CLI or Config overrode, this returns false but we still return success to the client
299304 bool updated = logLevelController . UpdateFromMcp ( level ) ;
300305
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.
306+ // Determine if logging is enabled (level != "none")
307+ // Note: Even if CLI/Config overrode the level, we still enable notifications
308+ // when the client requests logging. They'll get logs at the overridden level.
305309 bool isLoggingEnabled = ! string . Equals ( level , "none" , StringComparison . OrdinalIgnoreCase ) ;
310+
311+ // Only restore stderr when this MCP call actually changed the effective level.
312+ // If CLI/Config overrode (updated == false), stderr is already in the correct state:
313+ // - CLI/Config level == "none": stderr was redirected to TextWriter.Null at startup
314+ // and must stay that way; restoring it would re-introduce noisy output even
315+ // though the operator explicitly asked for silence.
316+ // - CLI/Config level != "none": stderr was never redirected, so restoring is a no-op.
306317 if ( updated && isLoggingEnabled )
307318 {
308319 RestoreStderrIfNeeded ( ) ;
309320 }
310321
322+ // Enable or disable MCP log notifications based on the requested level
323+ // When CLI/Config overrode, notifications are still enabled - client asked for logs,
324+ // they just get them at the CLI/Config level instead of the requested level.
325+ IMcpLogNotificationWriter ? notificationWriter = _serviceProvider . GetService < IMcpLogNotificationWriter > ( ) ;
326+ if ( notificationWriter != null )
327+ {
328+ notificationWriter . IsEnabled = isLoggingEnabled ;
329+ }
330+
311331 // Always return success (empty result object) per MCP spec
312332 WriteResult ( id , new { } ) ;
313333 }
@@ -539,39 +559,41 @@ private static string SafeToString(object obj)
539559
540560 /// <summary>
541561 /// Writes a JSON-RPC result response to the standard output.
562+ /// Routed through <see cref="McpStdoutWriter"/> so the write is serialized
563+ /// with notifications/message frames from the logging pipeline.
542564 /// </summary>
543565 /// <param name="id">The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request.</param>
544566 /// <param name="resultObject">The result object to include in the response.</param>
545- private static void WriteResult ( JsonElement ? id , object resultObject )
567+ private void WriteResult ( JsonElement ? id , object resultObject )
546568 {
547569 var response = new
548570 {
549- jsonrpc = "2.0" ,
571+ jsonrpc = McpStdioJsonRpcErrorCodes . JSON_RPC_VERSION ,
550572 id = id . HasValue ? GetIdValue ( id . Value ) : null ,
551573 result = resultObject
552574 } ;
553575
554- string json = JsonSerializer . Serialize ( response ) ;
555- Console . Out . WriteLine ( json ) ;
576+ _stdoutWriter . WriteLine ( JsonSerializer . Serialize ( response ) ) ;
556577 }
557578
558579 /// <summary>
559580 /// Writes a JSON-RPC error response to the standard output.
581+ /// Routed through <see cref="McpStdoutWriter"/> so the write is serialized
582+ /// with notifications/message frames from the logging pipeline.
560583 /// </summary>
561584 /// <param name="id">The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request.</param>
562585 /// <param name="code">The error code.</param>
563586 /// <param name="message">The error message.</param>
564- private static void WriteError ( JsonElement ? id , int code , string message )
587+ private void WriteError ( JsonElement ? id , int code , string message )
565588 {
566589 var errorObj = new
567590 {
568- jsonrpc = "2.0" ,
591+ jsonrpc = McpStdioJsonRpcErrorCodes . JSON_RPC_VERSION ,
569592 id = id . HasValue ? GetIdValue ( id . Value ) : null ,
570593 error = new { code , message }
571594 } ;
572595
573- string json = JsonSerializer . Serialize ( errorObj ) ;
574- Console . Out . WriteLine ( json ) ;
596+ _stdoutWriter . WriteLine ( JsonSerializer . Serialize ( errorObj ) ) ;
575597 }
576598
577599 /// <summary>
0 commit comments