From e5abb4bb8e4728fb4f88c675f0b9a9b6cd2afe89 Mon Sep 17 00:00:00 2001 From: Peder Holdgaard Pedersen <127606677+PederHP@users.noreply.github.com> Date: Thu, 9 Jan 2025 21:10:29 +0100 Subject: [PATCH] Roots capability support (#12) Notification sending support --- README.MD | 3 +- TODO.md | 13 +++ src/mcpdotnet.Tests/IntegrationTests.cs | 97 ++++++++++++++++++- src/mcpdotnet/Client/IMcpClient.cs | 24 ++++- src/mcpdotnet/Client/McpClient.cs | 66 ++++++++++++- src/mcpdotnet/Logging/Log.cs | 9 ++ .../Protocol/Messages/NotificationMethods.cs | 5 + .../Protocol/Types/ListRootsRequestParams.cs | 12 +++ .../Protocol/Types/ListRootsResult.cs | 17 ++++ src/mcpdotnet/Protocol/Types/Root.cs | 22 +++++ src/mcpdotnet/mcpdotnet.csproj | 2 +- 11 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 TODO.md create mode 100644 src/mcpdotnet/Protocol/Types/ListRootsRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/ListRootsResult.cs create mode 100644 src/mcpdotnet/Protocol/Types/Root.cs diff --git a/README.MD b/README.MD index 616975f..6c5e2bd 100644 --- a/README.MD +++ b/README.MD @@ -17,7 +17,6 @@ For more information about MCP: This project is in early development. While functional, it may undergo significant changes as the protocol and implementation evolve. ### Current Limitations -- Only supports the Tools, Resources, Prompts, Sampling capabilities (Roots not yet implemented) - Only stdio transport is currently supported (HTTP/SSE transport coming soon) - Client library only - server implementation support may be added in the future - Provider-agnostic implementation focused purely on the MCP protocol @@ -29,7 +28,7 @@ The library's current focus is on implementing the core MCP protocol, with the m - MCP client implementation for .NET applications - Support for stdio transport -- Support for Tool, Resource, Prompt, Sampling capabilities +- Support for all capabilities: Tool, Resource, Prompt, Sampling, Roots - Support for pagination and notifications - Async/await pattern throughout - Comprehensive logging support diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7aae238 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# TODOs for mcpdotnet + +## Integration Testing +- [ ] Add comprehensive Roots feature testing once reference servers implement support + - (Contribute PR to everything server adding `listRoots` tool) + - Add integration tests similar to Sampling tests + - Verify roots notification handling + +## Future Improvements +- [ ] Add HTTPS/SSE transport support + +## Code Improvements +- [ ] Consolidate notification handling in McpClient to reduce duplication between SendNotificationAsync overloads \ No newline at end of file diff --git a/src/mcpdotnet.Tests/IntegrationTests.cs b/src/mcpdotnet.Tests/IntegrationTests.cs index a3ec46b..080f444 100644 --- a/src/mcpdotnet.Tests/IntegrationTests.cs +++ b/src/mcpdotnet.Tests/IntegrationTests.cs @@ -1,5 +1,6 @@ using McpDotNet.Client; using McpDotNet.Configuration; +using McpDotNet.Protocol.Messages; using McpDotNet.Protocol.Types; using Microsoft.Extensions.Logging; @@ -215,7 +216,7 @@ public class IntegrationTests : IClassFixture // Set up the sampling handler int samplingHandlerCalls = 0; #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - client.SamplingHandler = async (_) => + client.SamplingHandler = async (_,_) => { samplingHandlerCalls++; return new CreateMessageResult @@ -247,4 +248,98 @@ public class IntegrationTests : IClassFixture Assert.Equal("text", textContent.Type); Assert.False(string.IsNullOrEmpty(textContent.Text)); } + + [Fact] + public async Task Roots_Stdio_EverythingServer() + { + // arrange + var config = new McpServerConfig + { + Id = "everything", + Name = "everything", + TransportType = "stdio", + TransportOptions = new Dictionary + { + ["command"] = "npx", + ["arguments"] = "-y @modelcontextprotocol/server-everything" + } + }; + + var options = new McpClientOptions + { + ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }, + Capabilities = new ClientCapabilities + { + Roots = new() + } + }; + + var rootsHandlerCalls = 0; + var testRoots = new List + { + new() { Uri = "file:///test/root1", Name = "Test Root 1" }, + new() { Uri = "file:///test/root2", Name = "Test Root 2" } + }; + + var factory = new McpClientFactory( + [config], + options, + _fixture.LoggerFactory + ); + var client = await factory.GetClientAsync("everything"); + + // Set up the roots handler +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + client.RootsHandler = async (request, ct) => + { + rootsHandlerCalls++; + return new ListRootsResult + { + Roots = testRoots + }; + }; +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + + // Connect + await client.ConnectAsync(CancellationToken.None); + + // assert + // nothing to assert, no servers implement roots, so we if no exception is thrown, it's a success + Assert.True(true); + } + + [Fact] + public async Task Notifications_Stdio_EverythingServer() + { + // arrange + var config = new McpServerConfig + { + Id = "everything", + Name = "everything", + TransportType = "stdio", + TransportOptions = new Dictionary + { + ["command"] = "npx", + ["arguments"] = "-y @modelcontextprotocol/server-everything" + } + }; + + var options = new McpClientOptions + { + ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" } + }; + + var factory = new McpClientFactory([config], options, _fixture.LoggerFactory); + var client = await factory.GetClientAsync("everything"); + + await client.ConnectAsync(); + + // Verify we can send notifications without errors + await client.SendNotificationAsync(NotificationMethods.RootsUpdatedNotification); + await client.SendNotificationAsync("test/notification", new { test = true }); + + // assert + // no response to check, if no exception is thrown, it's a success + Assert.True(true); + } } diff --git a/src/mcpdotnet/Client/IMcpClient.cs b/src/mcpdotnet/Client/IMcpClient.cs index 39783b2..fb4e470 100644 --- a/src/mcpdotnet/Client/IMcpClient.cs +++ b/src/mcpdotnet/Client/IMcpClient.cs @@ -26,9 +26,14 @@ public interface IMcpClient : IAsyncDisposable Implementation? ServerInfo { get; } /// - /// Gets or sets the handler for the server's sampling requests. + /// Gets or sets the handler for server sampling requests. /// - Func>? SamplingHandler { get; set; } + Func>? SamplingHandler { get; set; } + + /// + /// Gets or sets the handler for providing root URIs to servers. + /// + Func>? RootsHandler { get; set; } /// /// Establishes a connection to the server. @@ -129,4 +134,19 @@ public interface IMcpClient : IAsyncDisposable /// The notification method to handle. /// The async handler function to process notifications. void OnNotification(string method, Func handler); + + /// + /// Sends a notification to the server. + /// + /// The notification method name. + /// A token to cancel the operation. + Task SendNotificationAsync(string method, CancellationToken cancellationToken = default); + + /// + /// Sends a notification to the server with parameters. + /// + /// The notification method name. + /// The parameters to send with the notification. + /// A token to cancel the operation. + Task SendNotificationAsync(string method, T parameters, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/mcpdotnet/Client/McpClient.cs b/src/mcpdotnet/Client/McpClient.cs index 23d514f..40af3a3 100644 --- a/src/mcpdotnet/Client/McpClient.cs +++ b/src/mcpdotnet/Client/McpClient.cs @@ -66,7 +66,21 @@ internal class McpClient : IMcpClient throw new McpClientException("Sampling handler not configured"); } - return await SamplingHandler(request); + return await SamplingHandler(request, _cts?.Token ?? CancellationToken.None); + }); + } + if (options.Capabilities?.Roots != null) + { + SetRequestHandler("roots/list", + async (request) => + { + if (RootsHandler == null) + { + // Setting the capability, but not a handler means we have nothing to return to the server + _logger.RootsHandlerNotConfigured(_serverConfig.Id, _serverConfig.Name); + throw new McpClientException("Roots handler not configured"); + } + return await RootsHandler(request, _cts?.Token ?? CancellationToken.None); }); } } @@ -468,6 +482,52 @@ internal class McpClient : IMcpClient } } + /// + public async Task SendNotificationAsync(string method, CancellationToken cancellationToken = default) + { + if (!_transport.IsConnected) + { + _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); + throw new McpClientException("Transport is not connected"); + } + + var notification = new JsonRpcNotification { Method = method }; + + // Log if enabled + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification)); + } + + // Log basic info + _logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, method); + + await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SendNotificationAsync(string method, T parameters, CancellationToken cancellationToken = default) + { + if (!_transport.IsConnected) + { + _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); + throw new McpClientException("Transport is not connected"); + } + var notification = new JsonRpcNotification + { + Method = method, + Params = parameters + }; + // Log if enabled + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification)); + } + // Log basic info + _logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, method); + await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); + } + private async Task SendNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken) { if (!_transport.IsConnected) @@ -489,7 +549,9 @@ internal class McpClient : IMcpClient } } - public Func>? SamplingHandler { get; set; } + public Func>? SamplingHandler { get; set; } + + public Func>? RootsHandler { get; set; } private void SetRequestHandler(string method, Func> handler) { diff --git a/src/mcpdotnet/Logging/Log.cs b/src/mcpdotnet/Logging/Log.cs index dca0a7e..e307199 100644 --- a/src/mcpdotnet/Logging/Log.cs +++ b/src/mcpdotnet/Logging/Log.cs @@ -44,6 +44,9 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Error, Message = "Sampling handler not configured for server {serverId} ({serverName}), always set a handler when using this capability")] internal static partial void SamplingHandlerNotConfigured(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Roots handler not configured for server {serverId} ({serverName}), always set a handler when using this capability")] + internal static partial void RootsHandlerNotConfigured(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Client server {serverId} ({serverName}) already initializing")] internal static partial void ClientAlreadyInitializing(this ILogger logger, string serverId, string serverName); @@ -268,4 +271,10 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message write unknown message type: {type}")] internal static partial void JsonRpcMessageConverterWriteUnknownMessageType(this ILogger logger, string type); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Sending notification payload for {serverId} ({serverName}): {payload}")] + internal static partial void SendingNotificationPayload(this ILogger logger, string serverId, string serverName, string payload); + + [LoggerMessage(Level = LogLevel.Information, Message = "Sending notification for {serverId} ({serverName}): {method}")] + internal static partial void SendingNotification(this ILogger logger, string serverId, string serverName, string method); } \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Messages/NotificationMethods.cs b/src/mcpdotnet/Protocol/Messages/NotificationMethods.cs index 78a637c..58ac701 100644 --- a/src/mcpdotnet/Protocol/Messages/NotificationMethods.cs +++ b/src/mcpdotnet/Protocol/Messages/NotificationMethods.cs @@ -24,4 +24,9 @@ public static class NotificationMethods /// Sent by the server when a resource is updated. /// public const string ResourceUpdatedNotification = "notifications/resources/updated"; + + /// + /// Sent by the client when roots have been updated. + /// + public const string RootsUpdatedNotification = "notifications/roots/list_changed"; } \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/ListRootsRequestParams.cs b/src/mcpdotnet/Protocol/Types/ListRootsRequestParams.cs new file mode 100644 index 0000000..7000b47 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/ListRootsRequestParams.cs @@ -0,0 +1,12 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// A request from the server to get a list of root URIs from the client. +/// +public class ListRootsRequestParams +{ + /// + /// Optional progress token for out-of-band progress notifications. + /// + public string? ProgressToken { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/ListRootsResult.cs b/src/mcpdotnet/Protocol/Types/ListRootsResult.cs new file mode 100644 index 0000000..1107129 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/ListRootsResult.cs @@ -0,0 +1,17 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// The client's response to a roots/list request from the server. +/// +public class ListRootsResult +{ + /// + /// Additional metadata for the result. Reserved by the protocol for future use. + /// + public object? Meta { get; init; } + + /// + /// The list of root URIs provided by the client. + /// + public required IReadOnlyList Roots { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/Root.cs b/src/mcpdotnet/Protocol/Types/Root.cs new file mode 100644 index 0000000..cb29b9d --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/Root.cs @@ -0,0 +1,22 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Represents a root URI and its metadata. +/// +public class Root +{ + /// + /// The URI of the root. + /// + public required string Uri { get; init; } + + /// + /// A human-readable name for the root. + /// + public string? Name { get; init; } + + /// + /// Additional metadata for the root. Reserved by the protocol for future use. + /// + public object? Meta { get; init; } +} \ No newline at end of file diff --git a/src/mcpdotnet/mcpdotnet.csproj b/src/mcpdotnet/mcpdotnet.csproj index b1e726e..287459a 100644 --- a/src/mcpdotnet/mcpdotnet.csproj +++ b/src/mcpdotnet/mcpdotnet.csproj @@ -9,7 +9,7 @@ mcpdotnet - 0.4.0.1 + 0.5.0.1 PederHP .NET client library for the Model Context Protocol (MCP) https://github.com/PederHP/mcpdotnet