Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IMcpLogNotificationWriter>();
if (notificationWriter != null)
{
notificationWriter.IsEnabled = isLoggingEnabled;
}

// Always return success (empty result object) per MCP spec
WriteResult(id, new { });
}
Expand Down Expand Up @@ -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
};
Expand All @@ -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 }
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ namespace Azure.DataApiBuilder.Mcp.Model
/// </summary>
internal static class McpStdioJsonRpcErrorCodes
{
/// <summary>
/// JSON-RPC protocol version.
/// </summary>
public const string JSON_RPC_VERSION = "2.0";
Comment on lines 8 to +13

/// <summary>
/// Invalid JSON was received by the server.
/// An error occurred on the server while parsing the JSON text.
Expand Down
122 changes: 122 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogNotificationWriter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Writes log messages as MCP `notifications/message` JSON-RPC notifications.
/// This allows MCP clients (like MCP Inspector) to receive log output in real-time.
/// </summary>
/// <remarks>
/// MCP spec: https://modelcontextprotocol.io/specification/2025-11-05/server/utilities/logging
/// The notification format is:
/// <code>
/// {
/// "jsonrpc": "2.0",
/// "method": "notifications/message",
/// "params": {
/// "level": "info",
/// "logger": "CategoryName",
/// "data": "The log message"
/// }
/// }
/// </code>
/// </remarks>
public class McpLogNotificationWriter : IMcpLogNotificationWriter
{
private readonly object _lock = new();
private StreamWriter? _writer;
private bool _isEnabled;

/// <summary>
/// Gets or sets whether MCP log notifications are enabled.
/// When false, no notifications are written (to keep stdout clean before client requests logging).
/// </summary>
public bool IsEnabled
{
get => _isEnabled;
set
{
lock (_lock)
{
_isEnabled = value;
if (value && _writer == null)
{
InitializeWriter();
}
}
}
}

/// <summary>
/// Initializes the stdout writer for MCP notifications.
/// Uses Console.OpenStandardOutput() to get the raw stdout stream,
/// bypassing any Console.SetOut() redirections.
/// </summary>
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
};
Comment on lines +65 to +69
}

/// <summary>
/// Writes a log message as an MCP notification.
/// </summary>
/// <param name="logLevel">The .NET log level.</param>
/// <param name="categoryName">The logger category (typically class name).</param>
/// <param name="message">The formatted log message.</param>
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);
}
Comment on lines +101 to +104
}
}

/// <summary>
/// Interface for MCP log notification writing.
/// </summary>
public interface IMcpLogNotificationWriter
{
/// <summary>
/// Gets or sets whether MCP log notifications are enabled.
/// </summary>
bool IsEnabled { get; set; }

/// <summary>
/// Writes a log message as an MCP notification.
/// </summary>
void WriteNotification(LogLevel logLevel, string categoryName, string message);
}
86 changes: 86 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/Telemetry/McpLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Logging;

namespace Azure.DataApiBuilder.Mcp.Telemetry;

/// <summary>
/// ILogger implementation that sends log messages as MCP notifications.
/// </summary>
public class McpLogger : ILogger
{
private readonly string _categoryName;
private readonly IMcpLogNotificationWriter _writer;
private readonly Func<LogLevel, bool> _levelFilter;

public McpLogger(string categoryName, IMcpLogNotificationWriter writer, Func<LogLevel, bool> levelFilter)
{
_categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName));
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
_levelFilter = levelFilter ?? throw new ArgumentNullException(nameof(levelFilter));
}

/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
// Scopes are not supported for MCP notifications
return NullScope.Instance;
}

/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel)
{
return _writer.IsEnabled && logLevel != LogLevel.None && _levelFilter(logLevel);
}

/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> 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 full exception details if present, including stack trace and inner exceptions.
if (exception != null)
{
message = $"{message} Exception: {exception}";
}

_writer.WriteNotification(logLevel, _categoryName, message);
}

/// <summary>
/// Null scope implementation for when scopes are not supported.
/// </summary>
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new NullScope();

private NullScope()
{
}

public void Dispose()
{
}
}
}
45 changes: 45 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/Telemetry/McpLoggerProvider.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Logger provider that creates McpLogger instances for sending logs as MCP notifications.
/// </summary>
public class McpLoggerProvider : ILoggerProvider
{
private readonly IMcpLogNotificationWriter _writer;
private readonly Func<LogLevel, bool> _levelFilter;
private readonly ConcurrentDictionary<string, McpLogger> _loggers = new();
private bool _disposed;

/// <summary>
/// Creates a new McpLoggerProvider.
/// </summary>
/// <param name="writer">The notification writer to use for sending log messages.</param>
/// <param name="levelFilter">A function to filter log levels. Returns true if the level should be logged.</param>
public McpLoggerProvider(IMcpLogNotificationWriter writer, Func<LogLevel, bool> levelFilter)
{
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
_levelFilter = levelFilter ?? throw new ArgumentNullException(nameof(levelFilter));
}

/// <inheritdoc />
public ILogger CreateLogger(string categoryName)
{
return _loggers.GetOrAdd(categoryName, name => new McpLogger(name, _writer, _levelFilter));
}

/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_loggers.Clear();
_disposed = true;
}
}
}
70 changes: 70 additions & 0 deletions src/Core/Telemetry/McpLogLevelConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Logging;

namespace Azure.DataApiBuilder.Core.Telemetry
{
/// <summary>
/// Provides conversion between .NET LogLevel and MCP log level strings.
/// MCP log levels: debug, info, notice, warning, error, critical, alert, emergency.
/// </summary>
/// <remarks>
/// This class centralizes the mapping between MCP and .NET log levels,
/// avoiding duplication across DynamicLogLevelProvider and McpLogNotificationWriter.
/// </remarks>
public static class McpLogLevelConverter
{
/// <summary>
/// Maps MCP log level strings to Microsoft.Extensions.Logging.LogLevel.
/// </summary>
private static readonly Dictionary<string, LogLevel> _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
};

/// <summary>
/// Converts an MCP log level string to a .NET LogLevel.
/// </summary>
/// <param name="mcpLevel">The MCP log level string (e.g., "debug", "info", "warning").</param>
/// <param name="logLevel">The converted LogLevel if successful.</param>
/// <returns>True if the conversion was successful; false if the MCP level was not recognized.</returns>
public static bool TryConvertFromMcp(string mcpLevel, out LogLevel logLevel)
{
if (string.IsNullOrWhiteSpace(mcpLevel))
{
logLevel = LogLevel.None;
return false;
}

return _mcpToLogLevel.TryGetValue(mcpLevel, out logLevel);
}

/// <summary>
/// Converts a .NET LogLevel to an MCP log level string.
/// </summary>
/// <param name="logLevel">The .NET LogLevel to convert.</param>
/// <returns>The MCP log level string.</returns>
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"
};
}
}
}
Loading
Loading