Logging Capability (#53)
* Logging Capability * Readability * Redundant using --------- Co-authored-by: Stephen Toub <stoub@microsoft.com>
This commit is contained in:
parent
4d61007c11
commit
10c712a12f
10 changed files with 253 additions and 0 deletions
|
|
@ -474,6 +474,21 @@ public static class McpClientExtensions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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) =>
|
private static JsonRpcRequest CreateRequest(string method, Dictionary<string, object?>? parameters) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|
@ -499,6 +514,22 @@ public static class McpClientExtensions
|
||||||
return parameters;
|
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>
|
/// <summary>Provides an AI function that calls a tool through <see cref="IMcpClient"/>.</summary>
|
||||||
private sealed class McpAIFunction(IMcpClient client, Tool tool) : AIFunction
|
private sealed class McpAIFunction(IMcpClient client, Tool tool) : AIFunction
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,9 @@ public static class NotificationMethods
|
||||||
/// Sent by the client when roots have been updated.
|
/// Sent by the client when roots have been updated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string RootsUpdatedNotification = "notifications/roots/list_changed";
|
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";
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +65,13 @@ public record SamplingCapability
|
||||||
public record LoggingCapability
|
public record LoggingCapability
|
||||||
{
|
{
|
||||||
// Currently empty in the spec, but may be extended in the future
|
// 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>
|
/// <summary>
|
||||||
|
|
|
||||||
43
src/ModelContextProtocol/Protocol/Types/LoggingLevel.cs
Normal file
43
src/ModelContextProtocol/Protocol/Types/LoggingLevel.cs
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
||||||
SetToolsHandler(options);
|
SetToolsHandler(options);
|
||||||
SetPromptsHandler(options);
|
SetPromptsHandler(options);
|
||||||
SetResourcesHandler(options);
|
SetResourcesHandler(options);
|
||||||
|
SetSetLoggingLevelHandler(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClientCapabilities? ClientCapabilities { get; set; }
|
public ClientCapabilities? ClientCapabilities { get; set; }
|
||||||
|
|
@ -210,4 +211,19 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
||||||
SetRequestHandler<ListToolsRequestParams, ListToolsResult>("tools/list", (request, ct) => listToolsHandler(new(this, request), ct));
|
SetRequestHandler<ListToolsRequestParams, ListToolsResult>("tools/list", (request, ct) => listToolsHandler(new(this, request), ct));
|
||||||
SetRequestHandler<CallToolRequestParams, CallToolResponse>("tools/call", (request, ct) => callToolHandler(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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@ public sealed class McpServerHandlers
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<RequestContext<UnsubscribeRequestParams>, CancellationToken, Task<EmptyResult>>? UnsubscribeFromResourcesHandler { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Overwrite any handlers in McpServerOptions with non-null handlers from this instance.
|
/// Overwrite any handlers in McpServerOptions with non-null handlers from this instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -125,6 +130,20 @@ public sealed class McpServerHandlers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ?
|
options.Capabilities = options.Capabilities is null ?
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ internal static class Program
|
||||||
Tools = ConfigureTools(),
|
Tools = ConfigureTools(),
|
||||||
Resources = ConfigureResources(),
|
Resources = ConfigureResources(),
|
||||||
Prompts = ConfigurePrompts(),
|
Prompts = ConfigurePrompts(),
|
||||||
|
Logging = ConfigureLogging()
|
||||||
},
|
},
|
||||||
ProtocolVersion = "2024-11-05",
|
ProtocolVersion = "2024-11-05",
|
||||||
ServerInstructions = "This is a test server with only stub functionality",
|
ServerInstructions = "This is a test server with only stub functionality",
|
||||||
|
|
@ -54,10 +55,35 @@ internal static class Program
|
||||||
|
|
||||||
Log.Logger.Information("Server started.");
|
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)
|
// Run until process is stopped by the client (parent process)
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
await Task.Delay(5000);
|
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
|
// Snapshot the subscribed resources, rather than locking while sending notifications
|
||||||
List<string> resources;
|
List<string> resources;
|
||||||
|
|
@ -266,6 +292,26 @@ internal static class Program
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 HashSet<string> _subscribedResources = new();
|
||||||
private static readonly object _subscribedResourcesLock = new();
|
private static readonly object _subscribedResourcesLock = new();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ using System.Text.Json;
|
||||||
using ModelContextProtocol.Configuration;
|
using ModelContextProtocol.Configuration;
|
||||||
using ModelContextProtocol.Protocol.Transport;
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
using Xunit.Sdk;
|
using Xunit.Sdk;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests;
|
namespace ModelContextProtocol.Tests;
|
||||||
|
|
||||||
|
|
@ -550,6 +553,41 @@ public class ClientIntegrationTests : IClassFixture<ClientIntegrationTestFixture
|
||||||
Assert.Contains("Eiffel", result.Content[0].Text);
|
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()
|
private static void SkipTestIfNoOpenAIKey()
|
||||||
{
|
{
|
||||||
Assert.SkipWhen(s_openAIKey is null, "No OpenAI key provided. Skipping test.");
|
Assert.SkipWhen(s_openAIKey is null, "No OpenAI key provided. Skipping test.");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue