Skip to content

Commit 3b83ad4

Browse files
authored
Enable servers to log to the clients via ILogger (#229)
* Enable servers to log to the client via ILogger * Address feedback
1 parent 3a2e9a2 commit 3b83ad4

File tree

8 files changed

+242
-18
lines changed

8 files changed

+242
-18
lines changed

Diff for: src/ModelContextProtocol/Client/McpClientExtensions.cs

+11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using Microsoft.Extensions.AI;
2+
using Microsoft.Extensions.Logging;
23
using ModelContextProtocol.Protocol.Messages;
34
using ModelContextProtocol.Protocol.Types;
5+
using ModelContextProtocol.Server;
46
using ModelContextProtocol.Utils;
57
using ModelContextProtocol.Utils.Json;
68
using System.Runtime.CompilerServices;
@@ -631,6 +633,15 @@ public static Task SetLoggingLevel(this IMcpClient client, LoggingLevel level, C
631633
cancellationToken: cancellationToken);
632634
}
633635

636+
/// <summary>
637+
/// Configures the minimum logging level for the server.
638+
/// </summary>
639+
/// <param name="client">The client.</param>
640+
/// <param name="level">The minimum log level of messages to be generated.</param>
641+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
642+
public static Task SetLoggingLevel(this IMcpClient client, LogLevel level, CancellationToken cancellationToken = default) =>
643+
SetLoggingLevel(client, McpServer.ToLoggingLevel(level), cancellationToken);
644+
634645
/// <summary>Convers a dictionary with <see cref="object"/> values to a dictionary with <see cref="JsonElement"/> values.</summary>
635646
private static IReadOnlyDictionary<string, JsonElement>? ToArgumentsDictionary(
636647
IReadOnlyDictionary<string, object?>? arguments, JsonSerializerOptions options)

Diff for: src/ModelContextProtocol/McpEndpointExtensions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ internal static async Task<TResult> SendRequestAsync<TParameters, TResult>(
8383
}
8484

8585
/// <summary>
86-
/// Sends a notification to the server with parameters.
86+
/// Sends a notification to the server with no parameters.
8787
/// </summary>
8888
/// <param name="client">The client.</param>
8989
/// <param name="method">The notification method name.</param>
+5-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
namespace ModelContextProtocol.Protocol.Types;
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.Protocol.Types;
24

35
/// <summary>
46
/// An empty result object.
57
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">See the schema for details</see>
68
/// </summary>
79
public class EmptyResult
810
{
9-
11+
[JsonIgnore]
12+
internal static Task<EmptyResult> CompletedTask { get; } = Task.FromResult(new EmptyResult());
1013
}

Diff for: src/ModelContextProtocol/Server/IMcpServer.cs

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ public interface IMcpServer : IMcpEndpoint
2525
/// </summary>
2626
IServiceProvider? Services { get; }
2727

28+
/// <summary>Gets the last logging level set by the client, or <see langword="null"/> if it's never been set.</summary>
29+
LoggingLevel? LoggingLevel { get; }
30+
2831
/// <summary>
2932
/// Runs the server, listening for and handling client requests.
3033
/// </summary>

Diff for: src/ModelContextProtocol/Server/McpServer.cs

+49-11
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
using ModelContextProtocol.Shared;
66
using ModelContextProtocol.Utils;
77
using ModelContextProtocol.Utils.Json;
8-
using System.Diagnostics;
8+
using System.Runtime.CompilerServices;
99

1010
namespace ModelContextProtocol.Server;
1111

@@ -26,6 +26,13 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
2626
private string _endpointName;
2727
private int _started;
2828

29+
/// <summary>Holds a boxed <see cref="LoggingLevel"/> value for the server.</summary>
30+
/// <remarks>
31+
/// Initialized to non-null the first time SetLevel is used. This is stored as a strong box
32+
/// rather than a nullable to be able to manipulate it atomically.
33+
/// </remarks>
34+
private StrongBox<LoggingLevel>? _loggingLevel;
35+
2936
/// <summary>
3037
/// Creates a new instance of <see cref="McpServer"/>.
3138
/// </summary>
@@ -105,6 +112,9 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory?
105112
/// <inheritdoc />
106113
public override string EndpointName => _endpointName;
107114

115+
/// <inheritdoc />
116+
public LoggingLevel? LoggingLevel => _loggingLevel?.Value;
117+
108118
/// <inheritdoc />
109119
public async Task RunAsync(CancellationToken cancellationToken = default)
110120
{
@@ -441,20 +451,48 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
441451

442452
private void SetSetLoggingLevelHandler(McpServerOptions options)
443453
{
444-
if (options.Capabilities?.Logging is not { } loggingCapability)
445-
{
446-
return;
447-
}
448-
449-
if (loggingCapability.SetLoggingLevelHandler is not { } setLoggingLevelHandler)
450-
{
451-
throw new McpException("Logging capability was enabled, but SetLoggingLevelHandler was not specified.");
452-
}
454+
// We don't require that the handler be provided, as we always store the provided
455+
// log level to the server.
456+
var setLoggingLevelHandler = options.Capabilities?.Logging?.SetLoggingLevelHandler;
453457

454458
RequestHandlers.Set(
455459
RequestMethods.LoggingSetLevel,
456-
(request, cancellationToken) => setLoggingLevelHandler(new(this, request), cancellationToken),
460+
(request, cancellationToken) =>
461+
{
462+
// Store the provided level.
463+
if (request is not null)
464+
{
465+
if (_loggingLevel is null)
466+
{
467+
Interlocked.CompareExchange(ref _loggingLevel, new(request.Level), null);
468+
}
469+
470+
_loggingLevel.Value = request.Level;
471+
}
472+
473+
// If a handler was provided, now delegate to it.
474+
if (setLoggingLevelHandler is not null)
475+
{
476+
return setLoggingLevelHandler(new(this, request), cancellationToken);
477+
}
478+
479+
// Otherwise, consider it handled.
480+
return EmptyResult.CompletedTask;
481+
},
457482
McpJsonUtilities.JsonContext.Default.SetLevelRequestParams,
458483
McpJsonUtilities.JsonContext.Default.EmptyResult);
459484
}
485+
486+
/// <summary>Maps a <see cref="LogLevel"/> to a <see cref="LoggingLevel"/>.</summary>
487+
internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
488+
level switch
489+
{
490+
LogLevel.Trace => Protocol.Types.LoggingLevel.Debug,
491+
LogLevel.Debug => Protocol.Types.LoggingLevel.Debug,
492+
LogLevel.Information => Protocol.Types.LoggingLevel.Info,
493+
LogLevel.Warning => Protocol.Types.LoggingLevel.Warning,
494+
LogLevel.Error => Protocol.Types.LoggingLevel.Error,
495+
LogLevel.Critical => Protocol.Types.LoggingLevel.Critical,
496+
_ => Protocol.Types.LoggingLevel.Emergency,
497+
};
460498
}

Diff for: src/ModelContextProtocol/Server/McpServerExtensions.cs

+67-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using Microsoft.Extensions.AI;
2+
using Microsoft.Extensions.Logging;
23
using ModelContextProtocol.Protocol.Messages;
34
using ModelContextProtocol.Protocol.Types;
45
using ModelContextProtocol.Utils;
56
using ModelContextProtocol.Utils.Json;
67
using System.Runtime.CompilerServices;
78
using System.Text;
9+
using System.Text.Json;
810

911
namespace ModelContextProtocol.Server;
1012

@@ -28,7 +30,7 @@ public static Task<CreateMessageResult> RequestSamplingAsync(
2830

2931
return server.SendRequestAsync(
3032
RequestMethods.SamplingCreateMessage,
31-
request,
33+
request,
3234
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
3335
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
3436
cancellationToken: cancellationToken);
@@ -46,7 +48,7 @@ public static Task<CreateMessageResult> RequestSamplingAsync(
4648
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
4749
/// <exception cref="ArgumentException">The client does not support sampling.</exception>
4850
public static async Task<ChatResponse> RequestSamplingAsync(
49-
this IMcpServer server,
51+
this IMcpServer server,
5052
IEnumerable<ChatMessage> messages, ChatOptions? options = default, CancellationToken cancellationToken = default)
5153
{
5254
Throw.IfNull(server);
@@ -153,6 +155,16 @@ public static IChatClient AsSamplingChatClient(this IMcpServer server)
153155
return new SamplingChatClient(server);
154156
}
155157

158+
/// <summary>Gets an <see cref="ILogger"/> on which logged messages will be sent as notifications to the client.</summary>
159+
/// <param name="server">The server to wrap as an <see cref="ILogger"/>.</param>
160+
/// <returns>An <see cref="ILogger"/> that can be used to log to the client..</returns>
161+
public static ILoggerProvider AsClientLoggerProvider(this IMcpServer server)
162+
{
163+
Throw.IfNull(server);
164+
165+
return new ClientLoggerProvider(server);
166+
}
167+
156168
/// <summary>
157169
/// Requests the client to list the roots it exposes.
158170
/// </summary>
@@ -210,4 +222,57 @@ async IAsyncEnumerable<ChatResponseUpdate> IChatClient.GetStreamingResponseAsync
210222
/// <inheritdoc/>
211223
void IDisposable.Dispose() { } // nop
212224
}
225+
226+
/// <summary>
227+
/// Provides an <see cref="ILoggerProvider"/> implementation for creating loggers
228+
/// that send logging message notifications to the client for logged messages.
229+
/// </summary>
230+
private sealed class ClientLoggerProvider(IMcpServer server) : ILoggerProvider
231+
{
232+
/// <inheritdoc />
233+
public ILogger CreateLogger(string categoryName)
234+
{
235+
Throw.IfNull(categoryName);
236+
237+
return new ClientLogger(server, categoryName);
238+
}
239+
240+
/// <inheritdoc />
241+
void IDisposable.Dispose() { }
242+
243+
private sealed class ClientLogger(IMcpServer server, string categoryName) : ILogger
244+
{
245+
/// <inheritdoc />
246+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
247+
null;
248+
249+
/// <inheritdoc />
250+
public bool IsEnabled(LogLevel logLevel) =>
251+
server?.LoggingLevel is { } loggingLevel &&
252+
McpServer.ToLoggingLevel(logLevel) >= loggingLevel;
253+
254+
/// <inheritdoc />
255+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
256+
{
257+
if (!IsEnabled(logLevel))
258+
{
259+
return;
260+
}
261+
262+
Throw.IfNull(formatter);
263+
264+
Log(logLevel, formatter(state, exception));
265+
266+
void Log(LogLevel logLevel, string message)
267+
{
268+
_ = server.SendNotificationAsync(NotificationMethods.LoggingMessageNotification, new LoggingMessageNotificationParams()
269+
{
270+
Level = McpServer.ToLoggingLevel(logLevel),
271+
Data = JsonSerializer.SerializeToElement(message, McpJsonUtilities.JsonContext.Default.String),
272+
Logger = categoryName,
273+
});
274+
}
275+
}
276+
}
277+
}
213278
}

0 commit comments

Comments
 (0)