Skip to content

Commit 60df92d

Browse files
committed
Enable servers to log to the client via ILogger
1 parent 5d7c2a1 commit 60df92d

File tree

8 files changed

+203
-18
lines changed

8 files changed

+203
-18
lines changed

src/ModelContextProtocol/Client/McpClientExtensions.cs

Lines changed: 11 additions & 0 deletions
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)

src/ModelContextProtocol/McpEndpointExtensions.cs

Lines changed: 1 addition & 1 deletion
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>
Lines changed: 5 additions & 2 deletions
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
}

src/ModelContextProtocol/Server/IMcpServer.cs

Lines changed: 3 additions & 0 deletions
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>

src/ModelContextProtocol/Server/McpServer.cs

Lines changed: 49 additions & 11 deletions
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
{
@@ -434,20 +444,48 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
434444

435445
private void SetSetLoggingLevelHandler(McpServerOptions options)
436446
{
437-
if (options.Capabilities?.Logging is not { } loggingCapability)
438-
{
439-
return;
440-
}
441-
442-
if (loggingCapability.SetLoggingLevelHandler is not { } setLoggingLevelHandler)
443-
{
444-
throw new McpException("Logging capability was enabled, but SetLoggingLevelHandler was not specified.");
445-
}
447+
// We don't require that the handler be provided, as we always store the provided
448+
// log level to the server.
449+
var setLoggingLevelHandler = options.Capabilities?.Logging?.SetLoggingLevelHandler;
446450

447451
RequestHandlers.Set(
448452
RequestMethods.LoggingSetLevel,
449-
(request, cancellationToken) => setLoggingLevelHandler(new(this, request), cancellationToken),
453+
(request, cancellationToken) =>
454+
{
455+
// Store the provided level.
456+
if (request is not null)
457+
{
458+
if (_loggingLevel is null)
459+
{
460+
Interlocked.CompareExchange(ref _loggingLevel, new(request.Level), null);
461+
}
462+
463+
_loggingLevel.Value = request.Level;
464+
}
465+
466+
// If a handler was provided, now delegate to it.
467+
if (setLoggingLevelHandler is not null)
468+
{
469+
return setLoggingLevelHandler(new(this, request), cancellationToken);
470+
}
471+
472+
// Otherwise, consider it handled.
473+
return EmptyResult.CompletedTask;
474+
},
450475
McpJsonUtilities.JsonContext.Default.SetLevelRequestParams,
451476
McpJsonUtilities.JsonContext.Default.EmptyResult);
452477
}
478+
479+
/// <summary>Maps a <see cref="LogLevel"/> to a <see cref="LoggingLevel"/>.</summary>
480+
internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
481+
level switch
482+
{
483+
LogLevel.Trace => Protocol.Types.LoggingLevel.Debug,
484+
LogLevel.Debug => Protocol.Types.LoggingLevel.Debug,
485+
LogLevel.Warning => Protocol.Types.LoggingLevel.Warning,
486+
LogLevel.Error => Protocol.Types.LoggingLevel.Error,
487+
LogLevel.Critical => Protocol.Types.LoggingLevel.Critical,
488+
LogLevel.Information => Protocol.Types.LoggingLevel.Info,
489+
_ => Protocol.Types.LoggingLevel.Debug,
490+
};
453491
}

src/ModelContextProtocol/Server/McpServerExtensions.cs

Lines changed: 53 additions & 2 deletions
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 ILogger AsClientLogger(this IMcpServer server)
162+
{
163+
Throw.IfNull(server);
164+
165+
return new ClientLogger(server);
166+
}
167+
156168
/// <summary>
157169
/// Requests the client to list the roots it exposes.
158170
/// </summary>
@@ -210,4 +222,43 @@ async IAsyncEnumerable<ChatResponseUpdate> IChatClient.GetStreamingResponseAsync
210222
/// <inheritdoc/>
211223
void IDisposable.Dispose() { } // nop
212224
}
225+
226+
/// <summary>
227+
/// Provides an <see cref="ILogger"/> implementation for sending logging message notifications
228+
/// to the client for logged messages.
229+
/// </summary>
230+
private sealed class ClientLogger(IMcpServer server) : ILogger
231+
{
232+
/// <inheritdoc />
233+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
234+
null;
235+
236+
/// <inheritdoc />
237+
public bool IsEnabled(LogLevel logLevel) =>
238+
server?.LoggingLevel is { } loggingLevel &&
239+
McpServer.ToLoggingLevel(logLevel) >= loggingLevel;
240+
241+
/// <inheritdoc />
242+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
243+
{
244+
if (!IsEnabled(logLevel))
245+
{
246+
return;
247+
}
248+
249+
Throw.IfNull(formatter);
250+
251+
Log(logLevel, formatter(state, exception));
252+
253+
void Log(LogLevel logLevel, string message)
254+
{
255+
_ = server.SendNotificationAsync(NotificationMethods.LoggingMessageNotification, new LoggingMessageNotificationParams()
256+
{
257+
Level = McpServer.ToLoggingLevel(logLevel),
258+
Data = JsonSerializer.SerializeToElement(message, McpJsonUtilities.JsonContext.Default.String),
259+
Logger = eventId.Name,
260+
});
261+
}
262+
}
263+
}
213264
}

tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.AI;
22
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging;
34
using ModelContextProtocol.Client;
45
using ModelContextProtocol.Protocol.Messages;
56
using ModelContextProtocol.Protocol.Transport;
@@ -10,6 +11,7 @@
1011
using System.IO.Pipelines;
1112
using System.Text.Json;
1213
using System.Text.Json.Serialization.Metadata;
14+
using System.Threading.Channels;
1315

1416
namespace ModelContextProtocol.Tests.Client;
1517

@@ -19,6 +21,7 @@ public class McpClientExtensionsTests : LoggedTest
1921
private readonly Pipe _serverToClientPipe = new();
2022
private readonly ServiceProvider _serviceProvider;
2123
private readonly CancellationTokenSource _cts;
24+
private readonly IMcpServer _server;
2225
private readonly Task _serverTask;
2326

2427
public McpClientExtensionsTests(ITestOutputHelper outputHelper)
@@ -36,9 +39,9 @@ public McpClientExtensionsTests(ITestOutputHelper outputHelper)
3639
sc.AddSingleton(McpServerTool.Create([McpServerTool(Destructive = false, OpenWorld = true)](string i) => $"{i} Result", new() { Name = "ValuesSetViaOptions", Destructive = true, OpenWorld = false, ReadOnly = true }));
3740
_serviceProvider = sc.BuildServiceProvider();
3841

39-
var server = _serviceProvider.GetRequiredService<IMcpServer>();
42+
_server = _serviceProvider.GetRequiredService<IMcpServer>();
4043
_cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
41-
_serverTask = server.RunAsync(cancellationToken: _cts.Token);
44+
_serverTask = _server.RunAsync(cancellationToken: _cts.Token);
4245
}
4346

4447
[Theory]
@@ -374,4 +377,79 @@ public async Task WithDescription_ChangesToolDescription()
374377
Assert.Equal("ToolWithNewDescription", redescribedTool.Description);
375378
Assert.Equal(originalDescription, tool?.Description);
376379
}
380+
381+
[Fact]
382+
public async Task AsClientLogger_MessagesSentToClient()
383+
{
384+
IMcpClient client = await CreateMcpClientForServer();
385+
386+
ILogger logger = _server.AsClientLogger();
387+
Assert.Null(logger.BeginScope(""));
388+
389+
Assert.Null(_server.LoggingLevel);
390+
Assert.False(logger.IsEnabled(LogLevel.Trace));
391+
Assert.False(logger.IsEnabled(LogLevel.Debug));
392+
Assert.False(logger.IsEnabled(LogLevel.Information));
393+
Assert.False(logger.IsEnabled(LogLevel.Warning));
394+
Assert.False(logger.IsEnabled(LogLevel.Error));
395+
Assert.False(logger.IsEnabled(LogLevel.Critical));
396+
397+
await client.SetLoggingLevel(LoggingLevel.Info, TestContext.Current.CancellationToken);
398+
399+
DateTime start = DateTime.UtcNow;
400+
while (_server.LoggingLevel is null)
401+
{
402+
await Task.Delay(1, TestContext.Current.CancellationToken);
403+
Assert.True(DateTime.UtcNow - start < TimeSpan.FromSeconds(10), "Timed out waiting for logging level to be set");
404+
}
405+
406+
Assert.Equal(LoggingLevel.Info, _server.LoggingLevel);
407+
Assert.False(logger.IsEnabled(LogLevel.Trace));
408+
Assert.False(logger.IsEnabled(LogLevel.Debug));
409+
Assert.True(logger.IsEnabled(LogLevel.Information));
410+
Assert.True(logger.IsEnabled(LogLevel.Warning));
411+
Assert.True(logger.IsEnabled(LogLevel.Error));
412+
Assert.True(logger.IsEnabled(LogLevel.Critical));
413+
414+
List<string> data = [];
415+
var channel = Channel.CreateUnbounded<LoggingMessageNotificationParams?>();
416+
417+
await using (client.RegisterNotificationHandler(NotificationMethods.LoggingMessageNotification,
418+
(notification, cancellationToken) =>
419+
{
420+
Assert.True(channel.Writer.TryWrite(JsonSerializer.Deserialize<LoggingMessageNotificationParams>(notification.Params)));
421+
return Task.CompletedTask;
422+
}))
423+
{
424+
logger.LogTrace("Trace {Message}", "message");
425+
logger.LogDebug("Debug {Message}", "message");
426+
logger.LogInformation("Information {Message}", "message");
427+
logger.LogWarning("Warning {Message}", "message");
428+
logger.LogError("Error {Message}", "message");
429+
logger.LogCritical("Critical {Message}", "message");
430+
431+
for (int i = 0; i < 4; i++)
432+
{
433+
var m = await channel.Reader.ReadAsync(TestContext.Current.CancellationToken);
434+
Assert.NotNull(m);
435+
Assert.NotNull(m.Data);
436+
437+
string? s = JsonSerializer.Deserialize<string>(m.Data.Value);
438+
Assert.NotNull(s);
439+
data.Add(s);
440+
}
441+
442+
channel.Writer.Complete();
443+
}
444+
445+
Assert.False(await channel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken));
446+
Assert.Equal(
447+
[
448+
"Critical message",
449+
"Error message",
450+
"Information message",
451+
"Warning message",
452+
],
453+
data.OrderBy(s => s));
454+
}
377455
}

tests/ModelContextProtocol.Tests/Server/McpServerTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ public Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, Cancellati
633633
public Implementation? ClientInfo => throw new NotImplementedException();
634634
public McpServerOptions ServerOptions => throw new NotImplementedException();
635635
public IServiceProvider? Services => throw new NotImplementedException();
636+
public LoggingLevel? LoggingLevel => throw new NotImplementedException();
636637
public Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) =>
637638
throw new NotImplementedException();
638639
public Task RunAsync(CancellationToken cancellationToken = default) =>

0 commit comments

Comments
 (0)