From 1ba3f5419430dc85a660955ee3ab7f65c111c245 Mon Sep 17 00:00:00 2001 From: PederHP Date: Fri, 21 Mar 2025 21:04:42 +0100 Subject: [PATCH 1/3] Logging Capability --- .../Client/McpClientExtensions.cs | 31 ++++++++++++ .../Protocol/Messages/NotificationMethods.cs | 5 ++ .../Protocol/Types/Capabilities.cs | 7 +++ .../Protocol/Types/LoggingLevel.cs | 43 +++++++++++++++++ .../Types/LoggingMessageNotificationParams.cs | 30 ++++++++++++ .../Protocol/Types/SetLevelRequestParams.cs | 15 ++++++ src/ModelContextProtocol/Server/McpServer.cs | 15 ++++++ .../Server/McpServerHandlers.cs | 19 ++++++++ .../Program.cs | 47 +++++++++++++++++++ .../ClientIntegrationTests.cs | 38 +++++++++++++++ 10 files changed, 250 insertions(+) create mode 100644 src/ModelContextProtocol/Protocol/Types/LoggingLevel.cs create mode 100644 src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs create mode 100644 src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs diff --git a/src/ModelContextProtocol/Client/McpClientExtensions.cs b/src/ModelContextProtocol/Client/McpClientExtensions.cs index 9c2ca38b..65c0966b 100644 --- a/src/ModelContextProtocol/Client/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Client/McpClientExtensions.cs @@ -436,6 +436,21 @@ internal static CreateMessageResult ToCreateMessageResult(this ChatResponse chat }; } + /// + /// Configures the minimum logging level for the server. + /// + /// The client. + /// The minimum log level of messages to be generated. + /// A token to cancel the operation. + public static Task SetLoggingLevel(this IMcpClient client, LoggingLevel level, CancellationToken cancellationToken = default) + { + Throw.IfNull(client); + + return client.SendRequestAsync( + CreateRequest("logging/setLevel", new() { ["level"] = level.ToJsonValue() }), + cancellationToken); + } + private static JsonRpcRequest CreateRequest(string method, Dictionary? parameters) => new() { @@ -461,6 +476,22 @@ private static JsonRpcRequest CreateRequest(string method, Dictionary "debug", + LoggingLevel.Info => "info", + LoggingLevel.Notice => "notice", + LoggingLevel.Warning => "warning", + LoggingLevel.Error => "error", + LoggingLevel.Critical => "critical", + LoggingLevel.Alert => "alert", + LoggingLevel.Emergency => "emergency", + _ => throw new ArgumentOutOfRangeException(nameof(level)) + }; + } + /// Provides an AI function that calls a tool through . private sealed class McpAIFunction(IMcpClient client, Tool tool) : AIFunction { diff --git a/src/ModelContextProtocol/Protocol/Messages/NotificationMethods.cs b/src/ModelContextProtocol/Protocol/Messages/NotificationMethods.cs index 063f503a..521d3eb3 100644 --- a/src/ModelContextProtocol/Protocol/Messages/NotificationMethods.cs +++ b/src/ModelContextProtocol/Protocol/Messages/NotificationMethods.cs @@ -29,4 +29,9 @@ public static class NotificationMethods /// Sent by the client when roots have been updated. /// public const string RootsUpdatedNotification = "notifications/roots/list_changed"; + + /// + /// Sent by the server when a log message is generated. + /// + public const string LoggingMessageNotification = "notifications/message"; } \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/Capabilities.cs b/src/ModelContextProtocol/Protocol/Types/Capabilities.cs index 600e20f2..33c3b5b7 100644 --- a/src/ModelContextProtocol/Protocol/Types/Capabilities.cs +++ b/src/ModelContextProtocol/Protocol/Types/Capabilities.cs @@ -65,6 +65,13 @@ public record SamplingCapability public record LoggingCapability { // Currently empty in the spec, but may be extended in the future + + + /// + /// Gets or sets the handler for set logging level requests. + /// + [JsonIgnore] + public Func, CancellationToken, Task>? SetLoggingLevelHandler { get; init; } } /// diff --git a/src/ModelContextProtocol/Protocol/Types/LoggingLevel.cs b/src/ModelContextProtocol/Protocol/Types/LoggingLevel.cs new file mode 100644 index 00000000..4e603e69 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/LoggingLevel.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol.Types; + +/// +/// The severity of a log message. +/// These map to syslog message severities, as specified in RFC-5424: +/// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 +/// +public enum LoggingLevel +{ + /// Detailed debug information, typically only valuable to developers. + [JsonPropertyName("debug")] + Debug, + + /// Normal operational messages that require no action. + [JsonPropertyName("info")] + Info, + + /// Normal but significant events that might deserve attention. + [JsonPropertyName("notice")] + Notice, + + /// Warning conditions that don't represent an error but indicate potential issues. + [JsonPropertyName("warning")] + Warning, + + /// Error conditions that should be addressed but don't require immediate action. + [JsonPropertyName("error")] + Error, + + /// Critical conditions that require immediate attention. + [JsonPropertyName("critical")] + Critical, + + /// Action must be taken immediately to address the condition. + [JsonPropertyName("alert")] + Alert, + + /// System is unusable and requires immediate attention. + [JsonPropertyName("emergency")] + Emergency +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs b/src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs new file mode 100644 index 00000000..3396e376 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace ModelContextProtocol.Protocol.Types; + +/// +/// Sent from the server as the payload of "notifications/message" notifications whenever a log message is generated. +/// +/// If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. +/// See the schema for details +/// +public class LoggingMessageNotificationParams +{ + /// + /// The severity of this log message. + /// + [System.Text.Json.Serialization.JsonPropertyName("level")] + public LoggingLevel Level { get; init; } + + /// + /// An optional name of the logger issuing this message. + /// + [System.Text.Json.Serialization.JsonPropertyName("logger")] + public string? Logger { get; init; } + + /// + /// The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + /// + [System.Text.Json.Serialization.JsonPropertyName("data")] + public JsonElement? Data { get; init; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs b/src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs new file mode 100644 index 00000000..378b7493 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs @@ -0,0 +1,15 @@ +namespace ModelContextProtocol.Protocol.Types; + +/// +/// A request from the client to the server, to enable or adjust logging. +/// See the schema for details +/// +public class SetLevelRequestParams +{ + /// + /// The level of logging that the client wants to receive from the server. + /// The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + /// + [System.Text.Json.Serialization.JsonPropertyName("level")] + public required LoggingLevel Level { get; init; } +} diff --git a/src/ModelContextProtocol/Server/McpServer.cs b/src/ModelContextProtocol/Server/McpServer.cs index 5e813fef..1b9296f2 100644 --- a/src/ModelContextProtocol/Server/McpServer.cs +++ b/src/ModelContextProtocol/Server/McpServer.cs @@ -51,6 +51,7 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory? SetToolsHandler(options); SetPromptsHandler(options); SetResourcesHandler(options); + SetSetLoggingLevelHandler(options); } public ClientCapabilities? ClientCapabilities { get; set; } @@ -204,4 +205,18 @@ private void SetToolsHandler(McpServerOptions options) SetRequestHandler("tools/list", (request, ct) => listToolsHandler(new(this, request), ct)); SetRequestHandler("tools/call", (request, ct) => callToolHandler(new(this, request), ct)); } + + private void SetSetLoggingLevelHandler(McpServerOptions options) + { + if (options.Capabilities?.Logging is not { } loggingCapability) + { + return; + } + if (loggingCapability.SetLoggingLevelHandler is not { } setLoggingLevelHandler) + { + throw new McpServerException("Logging capability was enabled, but SetLoggingLevelHandler was not specified."); + } + + SetRequestHandler("logging/setLevel", (request, ct) => setLoggingLevelHandler(new(this, request), ct)); + } } diff --git a/src/ModelContextProtocol/Server/McpServerHandlers.cs b/src/ModelContextProtocol/Server/McpServerHandlers.cs index 56327339..812dc1bb 100644 --- a/src/ModelContextProtocol/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol/Server/McpServerHandlers.cs @@ -52,6 +52,11 @@ public sealed class McpServerHandlers /// public Func, CancellationToken, Task>? UnsubscribeFromResourcesHandler { get; set; } + /// + /// Get or sets the handler for set logging level requests. + /// + public Func, CancellationToken, Task>? SetLoggingLevelHandler { get; set; } + /// /// Overwrite any handlers in McpServerOptions with non-null handlers from this instance. /// @@ -118,6 +123,20 @@ toolsCapability with }; } + LoggingCapability? loggingCapability = options.Capabilities?.Logging; + if (SetLoggingLevelHandler is not null) + { + loggingCapability = loggingCapability is null ? + new() + { + SetLoggingLevelHandler = SetLoggingLevelHandler, + } : + loggingCapability with + { + SetLoggingLevelHandler = SetLoggingLevelHandler ?? loggingCapability.SetLoggingLevelHandler, + }; + } + options.Capabilities = options.Capabilities is null ? new() { diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index a59e1ec7..642b5d7e 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; using Serilog; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; @@ -39,6 +40,7 @@ private static async Task Main(string[] args) Tools = ConfigureTools(), Resources = ConfigureResources(), Prompts = ConfigurePrompts(), + Logging = ConfigureLogging() }, ProtocolVersion = "2024-11-05", ServerInstructions = "This is a test server with only stub functionality", @@ -54,10 +56,35 @@ private static async Task Main(string[] args) Log.Logger.Information("Server started."); + // everything server sends random log level messages every 15 seconds + int loggingSeconds = 0; + Random random = Random.Shared; + var loggingLevels = Enum.GetValues().ToList(); + // Run until process is stopped by the client (parent process) while (true) { await Task.Delay(5000); + if (_minimumLoggingLevel is not null) + { + loggingSeconds += 5; + + // Send random log messages every 15 seconds + if (loggingSeconds >= 15) + { + var logLevelIndex = random.Next(loggingLevels.Count); + var logLevel = loggingLevels[logLevelIndex]; + await server.SendMessageAsync(new JsonRpcNotification() + { + Method = NotificationMethods.LoggingMessageNotification, + Params = new LoggingMessageNotificationParams + { + Level = logLevel, + Data = JsonSerializer.Deserialize("\"Random log message\"") + } + }); + } + } // Snapshot the subscribed resources, rather than locking while sending notifications List resources; @@ -266,6 +293,26 @@ private static PromptsCapability ConfigurePrompts() }; } + private static LoggingLevel? _minimumLoggingLevel = null; + + private static LoggingCapability ConfigureLogging() + { + return new() + { + SetLoggingLevelHandler = (request, cancellationToken) => + { + if (request.Params?.Level is null) + { + throw new McpServerException("Missing required argument 'level'"); + } + + _minimumLoggingLevel = request.Params.Level; + + return Task.FromResult(new EmptyResult()); + } + }; + } + private static readonly HashSet _subscribedResources = new(); private static readonly object _subscribedResourcesLock = new(); diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index af13cc79..f575c3c3 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -7,6 +7,9 @@ using ModelContextProtocol.Configuration; using ModelContextProtocol.Protocol.Transport; using Xunit.Sdk; +using System.Text.Encodings.Web; +using System.Text.Json.Serialization.Metadata; +using System.Text.Json.Serialization; namespace ModelContextProtocol.Tests; @@ -535,6 +538,41 @@ public async Task SamplingViaChatClient_RequestResponseProperlyPropagated() Assert.Contains("Eiffel", result.Content[0].Text); } + [Theory] + [MemberData(nameof(GetClients))] + public async Task SetLoggingLevel_ReceivesLoggingMessages(string clientId) + { + // arrange + JsonSerializerOptions jsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + int logCounter = 0; + await using var client = await _fixture.CreateClientAsync(clientId); + client.AddNotificationHandler(NotificationMethods.LoggingMessageNotification, (notification) => + { + var loggingMessageNotificationParameters = JsonSerializer.Deserialize(notification.Params!.ToString() ?? string.Empty, + jsonSerializerOptions); + if (loggingMessageNotificationParameters is not null) + { + ++logCounter; + } + return Task.CompletedTask; + }); + + // act + await client.SetLoggingLevel(LoggingLevel.Debug, CancellationToken.None); + await Task.Delay(16000, TestContext.Current.CancellationToken); + + // assert + Assert.True(logCounter > 0); + } + private static void SkipTestIfNoOpenAIKey() { Assert.SkipWhen(s_openAIKey is null, "No OpenAI key provided. Skipping test."); From cb64136e87a98b1525d1f65cc208973f79455654 Mon Sep 17 00:00:00 2001 From: PederHP Date: Fri, 21 Mar 2025 22:01:15 +0100 Subject: [PATCH 2/3] Readability --- .../Protocol/Types/LoggingMessageNotificationParams.cs | 7 ++++--- .../Protocol/Types/SetLevelRequestParams.cs | 6 ++++-- src/ModelContextProtocol/Server/McpServer.cs | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs b/src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs index 3396e376..2784bbcb 100644 --- a/src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs +++ b/src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol.Types; @@ -13,18 +14,18 @@ public class LoggingMessageNotificationParams /// /// The severity of this log message. /// - [System.Text.Json.Serialization.JsonPropertyName("level")] + [JsonPropertyName("level")] public LoggingLevel Level { get; init; } /// /// An optional name of the logger issuing this message. /// - [System.Text.Json.Serialization.JsonPropertyName("logger")] + [JsonPropertyName("logger")] public string? Logger { get; init; } /// /// The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. /// - [System.Text.Json.Serialization.JsonPropertyName("data")] + [JsonPropertyName("data")] public JsonElement? Data { get; init; } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs b/src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs index 378b7493..9491cd77 100644 --- a/src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs +++ b/src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs @@ -1,4 +1,6 @@ -namespace ModelContextProtocol.Protocol.Types; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol.Types; /// /// A request from the client to the server, to enable or adjust logging. @@ -10,6 +12,6 @@ public class SetLevelRequestParams /// The level of logging that the client wants to receive from the server. /// The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. /// - [System.Text.Json.Serialization.JsonPropertyName("level")] + [JsonPropertyName("level")] public required LoggingLevel Level { get; init; } } diff --git a/src/ModelContextProtocol/Server/McpServer.cs b/src/ModelContextProtocol/Server/McpServer.cs index 59362216..c2fdb074 100644 --- a/src/ModelContextProtocol/Server/McpServer.cs +++ b/src/ModelContextProtocol/Server/McpServer.cs @@ -218,6 +218,7 @@ private void SetSetLoggingLevelHandler(McpServerOptions options) { return; } + if (loggingCapability.SetLoggingLevelHandler is not { } setLoggingLevelHandler) { throw new McpServerException("Logging capability was enabled, but SetLoggingLevelHandler was not specified."); From 09853c1b3203c6e061d0dc07b56ab578b328515e Mon Sep 17 00:00:00 2001 From: PederHP Date: Fri, 21 Mar 2025 22:03:03 +0100 Subject: [PATCH 3/3] Redundant using --- tests/ModelContextProtocol.TestServer/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index 262f66bd..e0558447 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -4,7 +4,6 @@ using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; using Serilog; -using System.Runtime.CompilerServices; using System.Text; using System.Text.Json;