Skip to content

Commit 10c712a

Browse files
PederHPstephentoub
andauthored
Logging Capability (#53)
* Logging Capability * Readability * Redundant using --------- Co-authored-by: Stephen Toub <[email protected]>
1 parent 4d61007 commit 10c712a

File tree

10 files changed

+253
-0
lines changed

10 files changed

+253
-0
lines changed

src/ModelContextProtocol/Client/McpClientExtensions.cs

+31
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,21 @@ internal static CreateMessageResult ToCreateMessageResult(this ChatResponse chat
474474
};
475475
}
476476

477+
/// <summary>
478+
/// Configures the minimum logging level for the server.
479+
/// </summary>
480+
/// <param name="client">The client.</param>
481+
/// <param name="level">The minimum log level of messages to be generated.</param>
482+
/// <param name="cancellationToken">A token to cancel the operation.</param>
483+
public static Task SetLoggingLevel(this IMcpClient client, LoggingLevel level, CancellationToken cancellationToken = default)
484+
{
485+
Throw.IfNull(client);
486+
487+
return client.SendRequestAsync<EmptyResult>(
488+
CreateRequest("logging/setLevel", new() { ["level"] = level.ToJsonValue() }),
489+
cancellationToken);
490+
}
491+
477492
private static JsonRpcRequest CreateRequest(string method, Dictionary<string, object?>? parameters) =>
478493
new()
479494
{
@@ -499,6 +514,22 @@ private static JsonRpcRequest CreateRequest(string method, Dictionary<string, ob
499514
return parameters;
500515
}
501516

517+
private static string ToJsonValue(this LoggingLevel level)
518+
{
519+
return level switch
520+
{
521+
LoggingLevel.Debug => "debug",
522+
LoggingLevel.Info => "info",
523+
LoggingLevel.Notice => "notice",
524+
LoggingLevel.Warning => "warning",
525+
LoggingLevel.Error => "error",
526+
LoggingLevel.Critical => "critical",
527+
LoggingLevel.Alert => "alert",
528+
LoggingLevel.Emergency => "emergency",
529+
_ => throw new ArgumentOutOfRangeException(nameof(level))
530+
};
531+
}
532+
502533
/// <summary>Provides an AI function that calls a tool through <see cref="IMcpClient"/>.</summary>
503534
private sealed class McpAIFunction(IMcpClient client, Tool tool) : AIFunction
504535
{

src/ModelContextProtocol/Protocol/Messages/NotificationMethods.cs

+5
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,9 @@ public static class NotificationMethods
2929
/// Sent by the client when roots have been updated.
3030
/// </summary>
3131
public const string RootsUpdatedNotification = "notifications/roots/list_changed";
32+
33+
/// <summary>
34+
/// Sent by the server when a log message is generated.
35+
/// </summary>
36+
public const string LoggingMessageNotification = "notifications/message";
3237
}

src/ModelContextProtocol/Protocol/Types/Capabilities.cs

+7
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ public record SamplingCapability
6565
public record LoggingCapability
6666
{
6767
// Currently empty in the spec, but may be extended in the future
68+
69+
70+
/// <summary>
71+
/// Gets or sets the handler for set logging level requests.
72+
/// </summary>
73+
[JsonIgnore]
74+
public Func<RequestContext<SetLevelRequestParams>, CancellationToken, Task<EmptyResult>>? SetLoggingLevelHandler { get; init; }
6875
}
6976

7077
/// <summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.Protocol.Types;
4+
5+
/// <summary>
6+
/// The severity of a log message.
7+
/// These map to syslog message severities, as specified in RFC-5424:
8+
/// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1
9+
/// </summary>
10+
public enum LoggingLevel
11+
{
12+
/// <summary>Detailed debug information, typically only valuable to developers.</summary>
13+
[JsonPropertyName("debug")]
14+
Debug,
15+
16+
/// <summary>Normal operational messages that require no action.</summary>
17+
[JsonPropertyName("info")]
18+
Info,
19+
20+
/// <summary>Normal but significant events that might deserve attention.</summary>
21+
[JsonPropertyName("notice")]
22+
Notice,
23+
24+
/// <summary>Warning conditions that don't represent an error but indicate potential issues.</summary>
25+
[JsonPropertyName("warning")]
26+
Warning,
27+
28+
/// <summary>Error conditions that should be addressed but don't require immediate action.</summary>
29+
[JsonPropertyName("error")]
30+
Error,
31+
32+
/// <summary>Critical conditions that require immediate attention.</summary>
33+
[JsonPropertyName("critical")]
34+
Critical,
35+
36+
/// <summary>Action must be taken immediately to address the condition.</summary>
37+
[JsonPropertyName("alert")]
38+
Alert,
39+
40+
/// <summary>System is unusable and requires immediate attention.</summary>
41+
[JsonPropertyName("emergency")]
42+
Emergency
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ModelContextProtocol.Protocol.Types;
5+
6+
/// <summary>
7+
/// Sent from the server as the payload of "notifications/message" notifications whenever a log message is generated.
8+
///
9+
/// If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.
10+
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see>
11+
/// </summary>
12+
public class LoggingMessageNotificationParams
13+
{
14+
/// <summary>
15+
/// The severity of this log message.
16+
/// </summary>
17+
[JsonPropertyName("level")]
18+
public LoggingLevel Level { get; init; }
19+
20+
/// <summary>
21+
/// An optional name of the logger issuing this message.
22+
/// </summary>
23+
[JsonPropertyName("logger")]
24+
public string? Logger { get; init; }
25+
26+
/// <summary>
27+
/// The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here.
28+
/// </summary>
29+
[JsonPropertyName("data")]
30+
public JsonElement? Data { get; init; }
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.Protocol.Types;
4+
5+
/// <summary>
6+
/// A request from the client to the server, to enable or adjust logging.
7+
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see>
8+
/// </summary>
9+
public class SetLevelRequestParams
10+
{
11+
/// <summary>
12+
/// The level of logging that the client wants to receive from the server.
13+
/// The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message.
14+
/// </summary>
15+
[JsonPropertyName("level")]
16+
public required LoggingLevel Level { get; init; }
17+
}

src/ModelContextProtocol/Server/McpServer.cs

+16
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory?
5151
SetToolsHandler(options);
5252
SetPromptsHandler(options);
5353
SetResourcesHandler(options);
54+
SetSetLoggingLevelHandler(options);
5455
}
5556

5657
public ClientCapabilities? ClientCapabilities { get; set; }
@@ -210,4 +211,19 @@ private void SetToolsHandler(McpServerOptions options)
210211
SetRequestHandler<ListToolsRequestParams, ListToolsResult>("tools/list", (request, ct) => listToolsHandler(new(this, request), ct));
211212
SetRequestHandler<CallToolRequestParams, CallToolResponse>("tools/call", (request, ct) => callToolHandler(new(this, request), ct));
212213
}
214+
215+
private void SetSetLoggingLevelHandler(McpServerOptions options)
216+
{
217+
if (options.Capabilities?.Logging is not { } loggingCapability)
218+
{
219+
return;
220+
}
221+
222+
if (loggingCapability.SetLoggingLevelHandler is not { } setLoggingLevelHandler)
223+
{
224+
throw new McpServerException("Logging capability was enabled, but SetLoggingLevelHandler was not specified.");
225+
}
226+
227+
SetRequestHandler<SetLevelRequestParams, EmptyResult>("logging/setLevel", (request, ct) => setLoggingLevelHandler(new(this, request), ct));
228+
}
213229
}

src/ModelContextProtocol/Server/McpServerHandlers.cs

+19
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ public sealed class McpServerHandlers
5757
/// </summary>
5858
public Func<RequestContext<UnsubscribeRequestParams>, CancellationToken, Task<EmptyResult>>? UnsubscribeFromResourcesHandler { get; set; }
5959

60+
/// <summary>
61+
/// Get or sets the handler for set logging level requests.
62+
/// </summary>
63+
public Func<RequestContext<SetLevelRequestParams>, CancellationToken, Task<EmptyResult>>? SetLoggingLevelHandler { get; set; }
64+
6065
/// <summary>
6166
/// Overwrite any handlers in McpServerOptions with non-null handlers from this instance.
6267
/// </summary>
@@ -125,6 +130,20 @@ toolsCapability with
125130
};
126131
}
127132

133+
LoggingCapability? loggingCapability = options.Capabilities?.Logging;
134+
if (SetLoggingLevelHandler is not null)
135+
{
136+
loggingCapability = loggingCapability is null ?
137+
new()
138+
{
139+
SetLoggingLevelHandler = SetLoggingLevelHandler,
140+
} :
141+
loggingCapability with
142+
{
143+
SetLoggingLevelHandler = SetLoggingLevelHandler ?? loggingCapability.SetLoggingLevelHandler,
144+
};
145+
}
146+
128147
options.Capabilities = options.Capabilities is null ?
129148
new()
130149
{

tests/ModelContextProtocol.TestServer/Program.cs

+46
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ private static async Task Main(string[] args)
3939
Tools = ConfigureTools(),
4040
Resources = ConfigureResources(),
4141
Prompts = ConfigurePrompts(),
42+
Logging = ConfigureLogging()
4243
},
4344
ProtocolVersion = "2024-11-05",
4445
ServerInstructions = "This is a test server with only stub functionality",
@@ -54,10 +55,35 @@ private static async Task Main(string[] args)
5455

5556
Log.Logger.Information("Server started.");
5657

58+
// everything server sends random log level messages every 15 seconds
59+
int loggingSeconds = 0;
60+
Random random = Random.Shared;
61+
var loggingLevels = Enum.GetValues<LoggingLevel>().ToList();
62+
5763
// Run until process is stopped by the client (parent process)
5864
while (true)
5965
{
6066
await Task.Delay(5000);
67+
if (_minimumLoggingLevel is not null)
68+
{
69+
loggingSeconds += 5;
70+
71+
// Send random log messages every 15 seconds
72+
if (loggingSeconds >= 15)
73+
{
74+
var logLevelIndex = random.Next(loggingLevels.Count);
75+
var logLevel = loggingLevels[logLevelIndex];
76+
await server.SendMessageAsync(new JsonRpcNotification()
77+
{
78+
Method = NotificationMethods.LoggingMessageNotification,
79+
Params = new LoggingMessageNotificationParams
80+
{
81+
Level = logLevel,
82+
Data = JsonSerializer.Deserialize<JsonElement>("\"Random log message\"")
83+
}
84+
});
85+
}
86+
}
6187

6288
// Snapshot the subscribed resources, rather than locking while sending notifications
6389
List<string> resources;
@@ -266,6 +292,26 @@ private static PromptsCapability ConfigurePrompts()
266292
};
267293
}
268294

295+
private static LoggingLevel? _minimumLoggingLevel = null;
296+
297+
private static LoggingCapability ConfigureLogging()
298+
{
299+
return new()
300+
{
301+
SetLoggingLevelHandler = (request, cancellationToken) =>
302+
{
303+
if (request.Params?.Level is null)
304+
{
305+
throw new McpServerException("Missing required argument 'level'");
306+
}
307+
308+
_minimumLoggingLevel = request.Params.Level;
309+
310+
return Task.FromResult(new EmptyResult());
311+
}
312+
};
313+
}
314+
269315
private static readonly HashSet<string> _subscribedResources = new();
270316
private static readonly object _subscribedResourcesLock = new();
271317

tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs

+38
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
using ModelContextProtocol.Configuration;
88
using ModelContextProtocol.Protocol.Transport;
99
using Xunit.Sdk;
10+
using System.Text.Encodings.Web;
11+
using System.Text.Json.Serialization.Metadata;
12+
using System.Text.Json.Serialization;
1013

1114
namespace ModelContextProtocol.Tests;
1215

@@ -550,6 +553,41 @@ public async Task SamplingViaChatClient_RequestResponseProperlyPropagated()
550553
Assert.Contains("Eiffel", result.Content[0].Text);
551554
}
552555

556+
[Theory]
557+
[MemberData(nameof(GetClients))]
558+
public async Task SetLoggingLevel_ReceivesLoggingMessages(string clientId)
559+
{
560+
// arrange
561+
JsonSerializerOptions jsonSerializerOptions = new(JsonSerializerDefaults.Web)
562+
{
563+
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
564+
Converters = { new JsonStringEnumConverter() },
565+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
566+
NumberHandling = JsonNumberHandling.AllowReadingFromString,
567+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
568+
};
569+
570+
int logCounter = 0;
571+
await using var client = await _fixture.CreateClientAsync(clientId);
572+
client.AddNotificationHandler(NotificationMethods.LoggingMessageNotification, (notification) =>
573+
{
574+
var loggingMessageNotificationParameters = JsonSerializer.Deserialize<LoggingMessageNotificationParams>(notification.Params!.ToString() ?? string.Empty,
575+
jsonSerializerOptions);
576+
if (loggingMessageNotificationParameters is not null)
577+
{
578+
++logCounter;
579+
}
580+
return Task.CompletedTask;
581+
});
582+
583+
// act
584+
await client.SetLoggingLevel(LoggingLevel.Debug, CancellationToken.None);
585+
await Task.Delay(16000, TestContext.Current.CancellationToken);
586+
587+
// assert
588+
Assert.True(logCounter > 0);
589+
}
590+
553591
private static void SkipTestIfNoOpenAIKey()
554592
{
555593
Assert.SkipWhen(s_openAIKey is null, "No OpenAI key provided. Skipping test.");

0 commit comments

Comments
 (0)