Roots capability support (#12)

Notification sending support
This commit is contained in:
Peder Holdgaard Pedersen 2025-01-09 21:10:29 +01:00 • committed by GitHub
commit e5abb4bb8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 262 additions and 8 deletions

View file

@ -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

13
TODO.md Normal file
View file

@ -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

View file

@ -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<IntegrationTestFixture>
// 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<IntegrationTestFixture>
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<string, string>
{
["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<Root>
{
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<string, string>
{
["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);
}
}

View file

@ -26,9 +26,14 @@ public interface IMcpClient : IAsyncDisposable
Implementation? ServerInfo { get; }
/// <summary>
/// Gets or sets the handler for the server's sampling requests.
/// Gets or sets the handler for server sampling requests.
/// </summary>
Func<CreateMessageRequestParams, Task<CreateMessageResult>>? SamplingHandler { get; set; }
Func<CreateMessageRequestParams, CancellationToken, Task<CreateMessageResult>>? SamplingHandler { get; set; }
/// <summary>
/// Gets or sets the handler for providing root URIs to servers.
/// </summary>
Func<ListRootsRequestParams, CancellationToken, Task<ListRootsResult>>? RootsHandler { get; set; }
/// <summary>
/// Establishes a connection to the server.
@ -129,4 +134,19 @@ public interface IMcpClient : IAsyncDisposable
/// <param name="method">The notification method to handle.</param>
/// <param name="handler">The async handler function to process notifications.</param>
void OnNotification(string method, Func<JsonRpcNotification, Task> handler);
/// <summary>
/// Sends a notification to the server.
/// </summary>
/// <param name="method">The notification method name.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
Task SendNotificationAsync(string method, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a notification to the server with parameters.
/// </summary>
/// <param name="method">The notification method name.</param>
/// <param name="parameters">The parameters to send with the notification.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
Task SendNotificationAsync<T>(string method, T parameters, CancellationToken cancellationToken = default);
}

View file

@ -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<ListRootsRequestParams, ListRootsResult>("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
}
}
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
public async Task SendNotificationAsync<T>(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<CreateMessageRequestParams, Task<CreateMessageResult>>? SamplingHandler { get; set; }
public Func<CreateMessageRequestParams, CancellationToken, Task<CreateMessageResult>>? SamplingHandler { get; set; }
public Func<ListRootsRequestParams, CancellationToken, Task<ListRootsResult>>? RootsHandler { get; set; }
private void SetRequestHandler<TRequest, TResponse>(string method, Func<TRequest, Task<TResponse>> handler)
{

View file

@ -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);
}

View file

@ -24,4 +24,9 @@ public static class NotificationMethods
/// Sent by the server when a resource is updated.
/// </summary>
public const string ResourceUpdatedNotification = "notifications/resources/updated";
/// <summary>
/// Sent by the client when roots have been updated.
/// </summary>
public const string RootsUpdatedNotification = "notifications/roots/list_changed";
}

View file

@ -0,0 +1,12 @@
namespace McpDotNet.Protocol.Types;
/// <summary>
/// A request from the server to get a list of root URIs from the client.
/// </summary>
public class ListRootsRequestParams
{
/// <summary>
/// Optional progress token for out-of-band progress notifications.
/// </summary>
public string? ProgressToken { get; init; }
}

View file

@ -0,0 +1,17 @@
namespace McpDotNet.Protocol.Types;
/// <summary>
/// The client's response to a roots/list request from the server.
/// </summary>
public class ListRootsResult
{
/// <summary>
/// Additional metadata for the result. Reserved by the protocol for future use.
/// </summary>
public object? Meta { get; init; }
/// <summary>
/// The list of root URIs provided by the client.
/// </summary>
public required IReadOnlyList<Root> Roots { get; init; }
}

View file

@ -0,0 +1,22 @@
namespace McpDotNet.Protocol.Types;
/// <summary>
/// Represents a root URI and its metadata.
/// </summary>
public class Root
{
/// <summary>
/// The URI of the root.
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// A human-readable name for the root.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Additional metadata for the root. Reserved by the protocol for future use.
/// </summary>
public object? Meta { get; init; }
}

View file

@ -9,7 +9,7 @@
<PropertyGroup>
<!-- license and package properties -->
<PackageId>mcpdotnet</PackageId>
<Version>0.4.0.1</Version>
<Version>0.5.0.1</Version>
<Authors>PederHP</Authors>
<Description>.NET client library for the Model Context Protocol (MCP)</Description>
<PackageProjectUrl>https://github.com/PederHP/mcpdotnet</PackageProjectUrl>