From 350a4b5ae017899230a5bd977065bd28d93bffcb Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 29 Apr 2026 12:21:17 -0700 Subject: [PATCH] Add MCP notifications/message for log streaming to clients --- .../Core/McpStdioServer.cs | 25 ++-- .../Model/McpStdioJsonRpcErrorCodes.cs | 5 + .../Telemetry/McpLogNotificationWriter.cs | 122 ++++++++++++++++++ .../Telemetry/McpLogger.cs | 86 ++++++++++++ .../Telemetry/McpLoggerProvider.cs | 45 +++++++ src/Core/Telemetry/McpLogLevelConverter.cs | 70 ++++++++++ .../UnitTests/McpLogNotificationTests.cs | 122 ++++++++++++++++++ src/Service/Program.cs | 23 ++++ .../Telemetry/DynamicLogLevelProvider.cs | 25 +--- 9 files changed, 491 insertions(+), 32 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogNotificationWriter.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogger.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Telemetry/McpLoggerProvider.cs create mode 100644 src/Core/Telemetry/McpLogLevelConverter.cs create mode 100644 src/Service.Tests/UnitTests/McpLogNotificationTests.cs diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs index 0588050fb0..41da3f1b3b 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs @@ -8,6 +8,7 @@ using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Telemetry; using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Telemetry; using Azure.DataApiBuilder.Mcp.Utils; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -296,18 +297,26 @@ private void HandleSetLogLevel(JsonElement? id, JsonElement root) // 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); + 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. + // Determine if logging is enabled (level != "none") + // Note: Even if CLI/Config overrode the level, we still enable notifications + // when the client requests logging. They'll get logs at the overridden level. bool isLoggingEnabled = !string.Equals(level, "none", StringComparison.OrdinalIgnoreCase); - if (updated && isLoggingEnabled) + if (isLoggingEnabled) { RestoreStderrIfNeeded(); } + // Enable or disable MCP log notifications based on the requested level + // When CLI/Config overrode, notifications are still enabled - client asked for logs, + // they just get them at the CLI/Config level instead of the requested level. + IMcpLogNotificationWriter? notificationWriter = _serviceProvider.GetService(); + if (notificationWriter != null) + { + notificationWriter.IsEnabled = isLoggingEnabled; + } + // Always return success (empty result object) per MCP spec WriteResult(id, new { }); } @@ -546,7 +555,7 @@ private static void WriteResult(JsonElement? id, object resultObject) { var response = new { - jsonrpc = "2.0", + jsonrpc = McpStdioJsonRpcErrorCodes.JSON_RPC_VERSION, id = id.HasValue ? GetIdValue(id.Value) : null, result = resultObject }; @@ -565,7 +574,7 @@ private static void WriteError(JsonElement? id, int code, string message) { var errorObj = new { - jsonrpc = "2.0", + jsonrpc = McpStdioJsonRpcErrorCodes.JSON_RPC_VERSION, id = id.HasValue ? GetIdValue(id.Value) : null, error = new { code, message } }; diff --git a/src/Azure.DataApiBuilder.Mcp/Model/McpStdioJsonRpcErrorCodes.cs b/src/Azure.DataApiBuilder.Mcp/Model/McpStdioJsonRpcErrorCodes.cs index 3bac194068..07e5c2c9b5 100644 --- a/src/Azure.DataApiBuilder.Mcp/Model/McpStdioJsonRpcErrorCodes.cs +++ b/src/Azure.DataApiBuilder.Mcp/Model/McpStdioJsonRpcErrorCodes.cs @@ -7,6 +7,11 @@ namespace Azure.DataApiBuilder.Mcp.Model /// internal static class McpStdioJsonRpcErrorCodes { + /// + /// JSON-RPC protocol version. + /// + public const string JSON_RPC_VERSION = "2.0"; + /// /// Invalid JSON was received by the server. /// An error occurred on the server while parsing the JSON text. diff --git a/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogNotificationWriter.cs b/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogNotificationWriter.cs new file mode 100644 index 0000000000..ea610f3d29 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogNotificationWriter.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Azure.DataApiBuilder.Core.Telemetry; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Mcp.Telemetry; + +/// +/// Writes log messages as MCP `notifications/message` JSON-RPC notifications. +/// This allows MCP clients (like MCP Inspector) to receive log output in real-time. +/// +/// +/// MCP spec: https://modelcontextprotocol.io/specification/2025-11-05/server/utilities/logging +/// The notification format is: +/// +/// { +/// "jsonrpc": "2.0", +/// "method": "notifications/message", +/// "params": { +/// "level": "info", +/// "logger": "CategoryName", +/// "data": "The log message" +/// } +/// } +/// +/// +public class McpLogNotificationWriter : IMcpLogNotificationWriter +{ + private readonly object _lock = new(); + private StreamWriter? _writer; + private bool _isEnabled; + + /// + /// Gets or sets whether MCP log notifications are enabled. + /// When false, no notifications are written (to keep stdout clean before client requests logging). + /// + public bool IsEnabled + { + get => _isEnabled; + set + { + lock (_lock) + { + _isEnabled = value; + if (value && _writer == null) + { + InitializeWriter(); + } + } + } + } + + /// + /// Initializes the stdout writer for MCP notifications. + /// Uses Console.OpenStandardOutput() to get the raw stdout stream, + /// bypassing any Console.SetOut() redirections. + /// + private void InitializeWriter() + { + // Use the same approach as McpStdioServer - get raw stdout + Stream stdout = Console.OpenStandardOutput(); + _writer = new StreamWriter(stdout, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true + }; + } + + /// + /// Writes a log message as an MCP notification. + /// + /// The .NET log level. + /// The logger category (typically class name). + /// The formatted log message. + public void WriteNotification(LogLevel logLevel, string categoryName, string message) + { + if (!_isEnabled || _writer == null) + { + return; + } + + string mcpLevel = McpLogLevelConverter.ConvertToMcp(logLevel); + + var notification = new + { + jsonrpc = McpStdioJsonRpcErrorCodes.JSON_RPC_VERSION, + method = "notifications/message", + @params = new + { + level = mcpLevel, + logger = categoryName, + data = message + } + }; + + string json = JsonSerializer.Serialize(notification); + + lock (_lock) + { + _writer?.WriteLine(json); + } + } +} + +/// +/// Interface for MCP log notification writing. +/// +public interface IMcpLogNotificationWriter +{ + /// + /// Gets or sets whether MCP log notifications are enabled. + /// + bool IsEnabled { get; set; } + + /// + /// Writes a log message as an MCP notification. + /// + void WriteNotification(LogLevel logLevel, string categoryName, string message); +} diff --git a/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogger.cs b/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogger.cs new file mode 100644 index 0000000000..49107597b4 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogger.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Mcp.Telemetry; + +/// +/// ILogger implementation that sends log messages as MCP notifications. +/// +public class McpLogger : ILogger +{ + private readonly string _categoryName; + private readonly IMcpLogNotificationWriter _writer; + private readonly Func _levelFilter; + + public McpLogger(string categoryName, IMcpLogNotificationWriter writer, Func levelFilter) + { + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _writer = writer ?? throw new ArgumentNullException(nameof(writer)); + _levelFilter = levelFilter ?? throw new ArgumentNullException(nameof(levelFilter)); + } + + /// + public IDisposable? BeginScope(TState state) where TState : notnull + { + // Scopes are not supported for MCP notifications + return NullScope.Instance; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { + return _writer.IsEnabled && logLevel != LogLevel.None && _levelFilter(logLevel); + } + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + if (formatter == null) + { + throw new ArgumentNullException(nameof(formatter)); + } + + string message = formatter(state, exception); + + if (string.IsNullOrEmpty(message) && exception == null) + { + return; + } + + // Include exception details if present + if (exception != null) + { + message = $"{message} Exception: {exception.GetType().Name}: {exception.Message}"; + } + + _writer.WriteNotification(logLevel, _categoryName, message); + } + + /// + /// Null scope implementation for when scopes are not supported. + /// + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new NullScope(); + + private NullScope() + { + } + + public void Dispose() + { + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLoggerProvider.cs b/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLoggerProvider.cs new file mode 100644 index 0000000000..b84e7bb830 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Telemetry/McpLoggerProvider.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Mcp.Telemetry; + +/// +/// Logger provider that creates McpLogger instances for sending logs as MCP notifications. +/// +public class McpLoggerProvider : ILoggerProvider +{ + private readonly IMcpLogNotificationWriter _writer; + private readonly Func _levelFilter; + private readonly ConcurrentDictionary _loggers = new(); + private bool _disposed; + + /// + /// Creates a new McpLoggerProvider. + /// + /// The notification writer to use for sending log messages. + /// A function to filter log levels. Returns true if the level should be logged. + public McpLoggerProvider(IMcpLogNotificationWriter writer, Func levelFilter) + { + _writer = writer ?? throw new ArgumentNullException(nameof(writer)); + _levelFilter = levelFilter ?? throw new ArgumentNullException(nameof(levelFilter)); + } + + /// + public ILogger CreateLogger(string categoryName) + { + return _loggers.GetOrAdd(categoryName, name => new McpLogger(name, _writer, _levelFilter)); + } + + /// + public void Dispose() + { + if (!_disposed) + { + _loggers.Clear(); + _disposed = true; + } + } +} diff --git a/src/Core/Telemetry/McpLogLevelConverter.cs b/src/Core/Telemetry/McpLogLevelConverter.cs new file mode 100644 index 0000000000..f8c4484e2c --- /dev/null +++ b/src/Core/Telemetry/McpLogLevelConverter.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Core.Telemetry +{ + /// + /// Provides conversion between .NET LogLevel and MCP log level strings. + /// MCP log levels: debug, info, notice, warning, error, critical, alert, emergency. + /// + /// + /// This class centralizes the mapping between MCP and .NET log levels, + /// avoiding duplication across DynamicLogLevelProvider and McpLogNotificationWriter. + /// + public static class McpLogLevelConverter + { + /// + /// Maps MCP log level strings to Microsoft.Extensions.Logging.LogLevel. + /// + private static readonly Dictionary _mcpToLogLevel = 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 + }; + + /// + /// Converts an MCP log level string to a .NET LogLevel. + /// + /// The MCP log level string (e.g., "debug", "info", "warning"). + /// The converted LogLevel if successful. + /// True if the conversion was successful; false if the MCP level was not recognized. + public static bool TryConvertFromMcp(string mcpLevel, out LogLevel logLevel) + { + if (string.IsNullOrWhiteSpace(mcpLevel)) + { + logLevel = LogLevel.None; + return false; + } + + return _mcpToLogLevel.TryGetValue(mcpLevel, out logLevel); + } + + /// + /// Converts a .NET LogLevel to an MCP log level string. + /// + /// The .NET LogLevel to convert. + /// The MCP log level string. + public static string ConvertToMcp(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => "debug", + LogLevel.Debug => "debug", + LogLevel.Information => "info", + LogLevel.Warning => "warning", + LogLevel.Error => "error", + LogLevel.Critical => "critical", + LogLevel.None => "debug", // Default to debug for None + _ => "info" + }; + } + } +} diff --git a/src/Service.Tests/UnitTests/McpLogNotificationTests.cs b/src/Service.Tests/UnitTests/McpLogNotificationTests.cs new file mode 100644 index 0000000000..6dd62cadec --- /dev/null +++ b/src/Service.Tests/UnitTests/McpLogNotificationTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using Azure.DataApiBuilder.Mcp.Telemetry; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Unit tests for the MCP logging notification components. + /// + [TestClass] + public class McpLogNotificationTests + { + [TestMethod] + public void McpLogNotificationWriter_IsEnabledFalseByDefault() + { + // Arrange & Act + McpLogNotificationWriter writer = new(); + + // Assert + Assert.IsFalse(writer.IsEnabled); + } + + [TestMethod] + public void McpLogNotificationWriter_CanBeEnabled() + { + // Arrange + McpLogNotificationWriter writer = new() + { + // Act + IsEnabled = true + }; + + // Assert + Assert.IsTrue(writer.IsEnabled); + } + + [TestMethod] + public void McpLogger_IsEnabledReturnsFalse_WhenWriterDisabled() + { + // Arrange + McpLogNotificationWriter writer = new() + { + IsEnabled = false + }; + McpLogger logger = new("TestCategory", writer, _ => true); + + // Act & Assert + Assert.IsFalse(logger.IsEnabled(LogLevel.Information)); + Assert.IsFalse(logger.IsEnabled(LogLevel.Error)); + } + + [TestMethod] + public void McpLogger_IsEnabledReturnsTrue_WhenWriterEnabled() + { + // Arrange + McpLogNotificationWriter writer = new() + { + IsEnabled = true + }; + McpLogger logger = new("TestCategory", writer, _ => true); + + // Act & Assert + Assert.IsTrue(logger.IsEnabled(LogLevel.Information)); + Assert.IsTrue(logger.IsEnabled(LogLevel.Error)); + } + + [TestMethod] + public void McpLogger_RespectsLevelFilter() + { + // Arrange + McpLogNotificationWriter writer = new() + { + IsEnabled = true + }; + + // Filter that only allows Warning and above + McpLogger logger = new("TestCategory", writer, level => level >= LogLevel.Warning); + + // Act & Assert + Assert.IsFalse(logger.IsEnabled(LogLevel.Debug)); + Assert.IsFalse(logger.IsEnabled(LogLevel.Information)); + Assert.IsTrue(logger.IsEnabled(LogLevel.Warning)); + Assert.IsTrue(logger.IsEnabled(LogLevel.Error)); + } + + [TestMethod] + public void McpLogger_NoneLevel_AlwaysReturnsFalse() + { + // Arrange + McpLogNotificationWriter writer = new() + { + IsEnabled = true + }; + McpLogger logger = new("TestCategory", writer, _ => true); + + // Act & Assert - LogLevel.None should always be disabled + Assert.IsFalse(logger.IsEnabled(LogLevel.None)); + } + + [TestMethod] + public void McpLoggerProvider_CreatesSameLoggerForSameCategory() + { + // Arrange + McpLogNotificationWriter writer = new(); + McpLoggerProvider provider = new(writer, _ => true); + + // Act + ILogger logger1 = provider.CreateLogger("TestCategory"); + ILogger logger2 = provider.CreateLogger("TestCategory"); + ILogger logger3 = provider.CreateLogger("OtherCategory"); + + // Assert - same category should return same logger instance + Assert.AreSame(logger1, logger2); + Assert.AreNotSame(logger1, logger3); + } + } +} diff --git a/src/Service/Program.cs b/src/Service/Program.cs index 11fb9f5cc7..fe9a753e16 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -12,6 +12,7 @@ using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Telemetry; +using Azure.DataApiBuilder.Mcp.Telemetry; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Telemetry; using Azure.DataApiBuilder.Service.Utilities; @@ -38,6 +39,12 @@ public class Program public static bool IsHttpsRedirectionDisabled { get; private set; } public static DynamicLogLevelProvider LogLevelProvider = new(); + /// + /// MCP log notification writer for sending logs to MCP clients via notifications/message. + /// Created once and shared between logging pipeline and MCP server. + /// + private static readonly McpLogNotificationWriter _mcpNotificationWriter = new(); + public static void Main(string[] args) { bool runMcpStdio = McpStdioHelper.ShouldRunMcpStdio(args, out string? mcpRole); @@ -138,6 +145,12 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st { services.AddSingleton(LogLevelProvider); services.AddSingleton(LogLevelProvider); + + // For MCP stdio mode, register the notification writer for sending logs to MCP clients + if (runMcpStdio) + { + services.AddSingleton(_mcpNotificationWriter); + } }) .ConfigureLogging(logging => { @@ -147,6 +160,10 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st // For non-MCP mode, use the configured level directly. if (runMcpStdio) { + // Clear all default providers (Console, Debug, EventSource, EventLog) + // to ensure stdout remains pure JSON-RPC for MCP protocol compliance. + logging.ClearProviders(); + // Allow all logs through framework, filter dynamically logging.SetMinimumLevel(LogLevel.Trace); } @@ -159,6 +176,12 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st logging.AddFilter(logLevel => LogLevelProvider.ShouldLog(logLevel)); logging.AddFilter("Microsoft", logLevel => LogLevelProvider.ShouldLog(logLevel)); logging.AddFilter("Microsoft.Hosting.Lifetime", logLevel => LogLevelProvider.ShouldLog(logLevel)); + + // For MCP stdio mode, add the MCP logger provider to send logs as notifications + if (runMcpStdio) + { + logging.AddProvider(new McpLoggerProvider(_mcpNotificationWriter, LogLevelProvider.ShouldLog)); + } }) .ConfigureWebHostDefaults(webBuilder => { diff --git a/src/Service/Telemetry/DynamicLogLevelProvider.cs b/src/Service/Telemetry/DynamicLogLevelProvider.cs index 517f901546..8e29086f52 100644 --- a/src/Service/Telemetry/DynamicLogLevelProvider.cs +++ b/src/Service/Telemetry/DynamicLogLevelProvider.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Telemetry; using Microsoft.Extensions.Logging; @@ -11,22 +9,6 @@ namespace Azure.DataApiBuilder.Service.Telemetry /// 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; } @@ -98,12 +80,7 @@ public bool UpdateFromMcp(string mcpLevel) return false; } - if (string.IsNullOrWhiteSpace(mcpLevel)) - { - return false; - } - - if (_mcpLevelMapping.TryGetValue(mcpLevel, out LogLevel logLevel)) + if (McpLogLevelConverter.TryConvertFromMcp(mcpLevel, out LogLevel logLevel)) { CurrentLogLevel = logLevel; return true;