Skip to content

Commit 7771129

Browse files
authored
Merge branch 'main' into dev/aaronburtle/Fix-Pagination-Column-Response
2 parents e0de2b2 + 0512b00 commit 7771129

32 files changed

Lines changed: 2112 additions & 151 deletions

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

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Azure.DataApiBuilder.Core.Configurations;
99
using Azure.DataApiBuilder.Core.Telemetry;
1010
using Azure.DataApiBuilder.Mcp.Model;
11+
using Azure.DataApiBuilder.Mcp.Telemetry;
1112
using Azure.DataApiBuilder.Mcp.Utils;
1213
using Microsoft.AspNetCore.Http;
1314
using 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>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text;
5+
6+
namespace Azure.DataApiBuilder.Mcp.Core
7+
{
8+
/// <summary>
9+
/// Process-wide owner of the MCP stdio server's stdout stream.
10+
///
11+
/// In MCP stdio mode, stdout is the JSON-RPC channel and is shared by
12+
/// multiple writers — JSON-RPC responses from <see cref="McpStdioServer"/>
13+
/// and asynchronous <c>notifications/message</c> frames from the logging
14+
/// pipeline. Without coordination, two writers calling <c>WriteLine</c>
15+
/// concurrently can interleave at the byte level and corrupt the channel.
16+
///
17+
/// This class wraps the underlying <see cref="StreamWriter"/> and serializes
18+
/// every write through a single lock so JSON-RPC frames stay intact.
19+
/// Registered as a singleton in DI for MCP stdio mode; instantiated lazily
20+
/// (the underlying stream is opened on the first write) so non-MCP code
21+
/// paths and unit tests can construct the type without side effects.
22+
/// </summary>
23+
public sealed class McpStdoutWriter : IDisposable
24+
{
25+
private readonly object _lock = new();
26+
private TextWriter? _writer;
27+
private bool _disposed;
28+
29+
/// <summary>
30+
/// Production constructor. The underlying stdout stream is opened
31+
/// lazily on the first <see cref="WriteLine"/> call.
32+
/// </summary>
33+
public McpStdoutWriter()
34+
{
35+
}
36+
37+
/// <summary>
38+
/// Test-only constructor that injects a pre-built writer so unit tests
39+
/// can verify lock behavior, disposal semantics, and notification
40+
/// framing without touching the real stdout stream.
41+
/// </summary>
42+
internal McpStdoutWriter(TextWriter writer)
43+
{
44+
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
45+
}
46+
47+
/// <summary>
48+
/// Writes a single line to stdout under a process-wide lock so
49+
/// concurrent JSON-RPC responses and notifications cannot interleave.
50+
/// No-op after <see cref="Dispose"/>.
51+
/// </summary>
52+
public void WriteLine(string line)
53+
{
54+
lock (_lock)
55+
{
56+
if (_disposed)
57+
{
58+
return;
59+
}
60+
61+
EnsureInitialized();
62+
_writer!.WriteLine(line);
63+
}
64+
}
65+
66+
public void Dispose()
67+
{
68+
lock (_lock)
69+
{
70+
if (_disposed)
71+
{
72+
return;
73+
}
74+
75+
_disposed = true;
76+
_writer?.Dispose();
77+
_writer = null;
78+
}
79+
}
80+
81+
private void EnsureInitialized()
82+
{
83+
if (_writer is not null)
84+
{
85+
return;
86+
}
87+
88+
// Opening the raw stdout stream bypasses any Console.SetOut(...)
89+
// redirection. This is intentional: in MCP stdio mode, Program.cs
90+
// redirects Console.Out to a sink (TextWriter.Null or stderr) so
91+
// stray Console.WriteLine calls from third-party code cannot
92+
// corrupt the JSON-RPC channel. Only this class - and only via
93+
// WriteLine() - is allowed to write to the real stdout.
94+
Stream stdout = Console.OpenStandardOutput();
95+
_writer = new StreamWriter(stdout, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
96+
{
97+
AutoFlush = true
98+
};
99+
}
100+
}
101+
}

src/Azure.DataApiBuilder.Mcp/Model/McpStdioJsonRpcErrorCodes.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ namespace Azure.DataApiBuilder.Mcp.Model
77
/// </summary>
88
internal static class McpStdioJsonRpcErrorCodes
99
{
10+
/// <summary>
11+
/// JSON-RPC protocol version.
12+
/// </summary>
13+
public const string JSON_RPC_VERSION = "2.0";
14+
1015
/// <summary>
1116
/// Invalid JSON was received by the server.
1217
/// An error occurred on the server while parsing the JSON text.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text.Json;
5+
using Azure.DataApiBuilder.Core.Telemetry;
6+
using Azure.DataApiBuilder.Mcp.Core;
7+
using Azure.DataApiBuilder.Mcp.Model;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace Azure.DataApiBuilder.Mcp.Telemetry;
11+
12+
/// <summary>
13+
/// Writes log messages as MCP `notifications/message` JSON-RPC notifications.
14+
/// This allows MCP clients (like MCP Inspector) to receive log output in real-time.
15+
/// </summary>
16+
/// <remarks>
17+
/// MCP spec: https://modelcontextprotocol.io/specification/2025-11-05/server/utilities/logging
18+
/// The notification format is:
19+
/// <code>
20+
/// {
21+
/// "jsonrpc": "2.0",
22+
/// "method": "notifications/message",
23+
/// "params": {
24+
/// "level": "info",
25+
/// "logger": "CategoryName",
26+
/// "data": "The log message"
27+
/// }
28+
/// }
29+
/// </code>
30+
/// All writes are routed through the shared <see cref="McpStdoutWriter"/> so
31+
/// notifications cannot interleave with JSON-RPC responses written by
32+
/// <see cref="McpStdioServer"/>.
33+
/// </remarks>
34+
public class McpLogNotificationWriter : IMcpLogNotificationWriter
35+
{
36+
private readonly McpStdoutWriter? _stdoutWriter;
37+
38+
/// <summary>
39+
/// Creates a notification writer that writes through the shared stdout
40+
/// writer. The shared writer serializes notifications with JSON-RPC
41+
/// responses so concurrent writes do not interleave on the wire.
42+
/// </summary>
43+
/// <param name="stdoutWriter">
44+
/// Shared stdout writer. May be <c>null</c> for unit tests that do not
45+
/// exercise the write path; in that case <see cref="WriteNotification"/>
46+
/// becomes a no-op.
47+
/// </param>
48+
public McpLogNotificationWriter(McpStdoutWriter? stdoutWriter = null)
49+
{
50+
_stdoutWriter = stdoutWriter;
51+
}
52+
53+
/// <summary>
54+
/// Gets or sets whether MCP log notifications are enabled. This is the
55+
/// single source of truth for whether notifications flow to the client;
56+
/// it is consulted by <see cref="McpLogger.IsEnabled(LogLevel)"/> so that
57+
/// the gate is enforced once, at log time, before any formatter work runs.
58+
/// <see cref="WriteNotification"/> intentionally does not re-check this
59+
/// flag — callers must gate via <see cref="McpLogger"/>.
60+
/// </summary>
61+
public bool IsEnabled { get; set; }
62+
63+
/// <summary>
64+
/// Writes a log message as an MCP notification. The caller is responsible
65+
/// for gating on <see cref="IsEnabled"/>; <see cref="McpLogger"/> already
66+
/// does this in its <see cref="McpLogger.IsEnabled(LogLevel)"/> override.
67+
/// </summary>
68+
/// <param name="logLevel">The .NET log level.</param>
69+
/// <param name="categoryName">The logger category (typically class name).</param>
70+
/// <param name="message">The formatted log message.</param>
71+
public void WriteNotification(LogLevel logLevel, string categoryName, string message)
72+
{
73+
// No IsEnabled check here: the gate lives in McpLogger.IsEnabled so
74+
// that we have a single source of truth and a single check site.
75+
// The _stdoutWriter null check remains as a defensive guard for unit
76+
// tests that construct the writer without a backing stdout.
77+
if (_stdoutWriter is null)
78+
{
79+
return;
80+
}
81+
82+
string mcpLevel = McpLogLevelConverter.ConvertToMcp(logLevel);
83+
84+
var notification = new
85+
{
86+
jsonrpc = McpStdioJsonRpcErrorCodes.JSON_RPC_VERSION,
87+
method = "notifications/message",
88+
@params = new
89+
{
90+
level = mcpLevel,
91+
logger = categoryName,
92+
data = message
93+
}
94+
};
95+
96+
_stdoutWriter.WriteLine(JsonSerializer.Serialize(notification));
97+
}
98+
}
99+
100+
/// <summary>
101+
/// Interface for MCP log notification writing.
102+
/// </summary>
103+
public interface IMcpLogNotificationWriter
104+
{
105+
/// <summary>
106+
/// Gets or sets whether MCP log notifications are enabled.
107+
/// </summary>
108+
bool IsEnabled { get; set; }
109+
110+
/// <summary>
111+
/// Writes a log message as an MCP notification.
112+
/// </summary>
113+
void WriteNotification(LogLevel logLevel, string categoryName, string message);
114+
}

0 commit comments

Comments
 (0)