diff --git a/src/ModelContextProtocol/Client/McpClientExtensions.cs b/src/ModelContextProtocol/Client/McpClientExtensions.cs index 56b77f69..f11f0295 100644 --- a/src/ModelContextProtocol/Client/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Client/McpClientExtensions.cs @@ -474,6 +474,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() { @@ -499,6 +514,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 3392f57c..0e4b88f9 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..2784bbcb --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/LoggingMessageNotificationParams.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +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. + /// + [JsonPropertyName("level")] + public LoggingLevel Level { get; init; } + + /// + /// An optional name of the logger issuing this message. + /// + [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. + /// + [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..9491cd77 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +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. + /// + [JsonPropertyName("level")] + public required LoggingLevel Level { get; init; } +} diff --git a/src/ModelContextProtocol/Server/McpServer.cs b/src/ModelContextProtocol/Server/McpServer.cs index 6ad1defe..c2fdb074 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; } @@ -210,4 +211,19 @@ 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 c41c601e..32deca49 100644 --- a/src/ModelContextProtocol/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol/Server/McpServerHandlers.cs @@ -57,6 +57,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. /// @@ -125,6 +130,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 8574a73a..e0558447 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -39,6 +39,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 +55,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 +292,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 121c4eac..f1996ca3 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; @@ -550,6 +553,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.");