Skip to content

Logging Capability #53

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/ModelContextProtocol/Client/McpClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,21 @@ internal static CreateMessageResult ToCreateMessageResult(this ChatResponse chat
};
}

/// <summary>
/// Configures the minimum logging level for the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="level">The minimum log level of messages to be generated.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task SetLoggingLevel(this IMcpClient client, LoggingLevel level, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);

return client.SendRequestAsync<EmptyResult>(
CreateRequest("logging/setLevel", new() { ["level"] = level.ToJsonValue() }),
cancellationToken);
}

private static JsonRpcRequest CreateRequest(string method, Dictionary<string, object?>? parameters) =>
new()
{
Expand All @@ -499,6 +514,22 @@ private static JsonRpcRequest CreateRequest(string method, Dictionary<string, ob
return parameters;
}

private static string ToJsonValue(this LoggingLevel level)
{
return level switch
{
LoggingLevel.Debug => "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))
};
}

/// <summary>Provides an AI function that calls a tool through <see cref="IMcpClient"/>.</summary>
private sealed class McpAIFunction(IMcpClient client, Tool tool) : AIFunction
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ public static class NotificationMethods
/// Sent by the client when roots have been updated.
/// </summary>
public const string RootsUpdatedNotification = "notifications/roots/list_changed";

/// <summary>
/// Sent by the server when a log message is generated.
/// </summary>
public const string LoggingMessageNotification = "notifications/message";
}
7 changes: 7 additions & 0 deletions src/ModelContextProtocol/Protocol/Types/Capabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ public record SamplingCapability
public record LoggingCapability
{
// Currently empty in the spec, but may be extended in the future


/// <summary>
/// Gets or sets the handler for set logging level requests.
/// </summary>
[JsonIgnore]
public Func<RequestContext<SetLevelRequestParams>, CancellationToken, Task<EmptyResult>>? SetLoggingLevelHandler { get; init; }
}

/// <summary>
Expand Down
43 changes: 43 additions & 0 deletions src/ModelContextProtocol/Protocol/Types/LoggingLevel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol.Types;

/// <summary>
/// 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
/// </summary>
public enum LoggingLevel
{
/// <summary>Detailed debug information, typically only valuable to developers.</summary>
[JsonPropertyName("debug")]
Debug,

/// <summary>Normal operational messages that require no action.</summary>
[JsonPropertyName("info")]
Info,

/// <summary>Normal but significant events that might deserve attention.</summary>
[JsonPropertyName("notice")]
Notice,

/// <summary>Warning conditions that don't represent an error but indicate potential issues.</summary>
[JsonPropertyName("warning")]
Warning,

/// <summary>Error conditions that should be addressed but don't require immediate action.</summary>
[JsonPropertyName("error")]
Error,

/// <summary>Critical conditions that require immediate attention.</summary>
[JsonPropertyName("critical")]
Critical,

/// <summary>Action must be taken immediately to address the condition.</summary>
[JsonPropertyName("alert")]
Alert,

/// <summary>System is unusable and requires immediate attention.</summary>
[JsonPropertyName("emergency")]
Emergency
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol.Types;

/// <summary>
/// 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 href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see>
/// </summary>
public class LoggingMessageNotificationParams
{
/// <summary>
/// The severity of this log message.
/// </summary>
[JsonPropertyName("level")]
public LoggingLevel Level { get; init; }

/// <summary>
/// An optional name of the logger issuing this message.
/// </summary>
[JsonPropertyName("logger")]
public string? Logger { get; init; }

/// <summary>
/// The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here.
/// </summary>
[JsonPropertyName("data")]
public JsonElement? Data { get; init; }
}
17 changes: 17 additions & 0 deletions src/ModelContextProtocol/Protocol/Types/SetLevelRequestParams.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol.Types;

/// <summary>
/// A request from the client to the server, to enable or adjust logging.
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see>
/// </summary>
public class SetLevelRequestParams
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("level")]
public required LoggingLevel Level { get; init; }
}
16 changes: 16 additions & 0 deletions src/ModelContextProtocol/Server/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory?
SetToolsHandler(options);
SetPromptsHandler(options);
SetResourcesHandler(options);
SetSetLoggingLevelHandler(options);
}

public ClientCapabilities? ClientCapabilities { get; set; }
Expand Down Expand Up @@ -210,4 +211,19 @@ private void SetToolsHandler(McpServerOptions options)
SetRequestHandler<ListToolsRequestParams, ListToolsResult>("tools/list", (request, ct) => listToolsHandler(new(this, request), ct));
SetRequestHandler<CallToolRequestParams, CallToolResponse>("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<SetLevelRequestParams, EmptyResult>("logging/setLevel", (request, ct) => setLoggingLevelHandler(new(this, request), ct));
}
}
19 changes: 19 additions & 0 deletions src/ModelContextProtocol/Server/McpServerHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public sealed class McpServerHandlers
/// </summary>
public Func<RequestContext<UnsubscribeRequestParams>, CancellationToken, Task<EmptyResult>>? UnsubscribeFromResourcesHandler { get; set; }

/// <summary>
/// Get or sets the handler for set logging level requests.
/// </summary>
public Func<RequestContext<SetLevelRequestParams>, CancellationToken, Task<EmptyResult>>? SetLoggingLevelHandler { get; set; }

/// <summary>
/// Overwrite any handlers in McpServerOptions with non-null handlers from this instance.
/// </summary>
Expand Down Expand Up @@ -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()
{
Expand Down
46 changes: 46 additions & 0 deletions tests/ModelContextProtocol.TestServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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<LoggingLevel>().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<JsonElement>("\"Random log message\"")
}
});
}
}

// Snapshot the subscribed resources, rather than locking while sending notifications
List<string> resources;
Expand Down Expand Up @@ -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<string> _subscribedResources = new();
private static readonly object _subscribedResourcesLock = new();

Expand Down
38 changes: 38 additions & 0 deletions tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<LoggingMessageNotificationParams>(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.");
Expand Down