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. This project is in early development. While functional, it may undergo significant changes as the protocol and implementation evolve.
### Current Limitations ### 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) - Only stdio transport is currently supported (HTTP/SSE transport coming soon)
- Client library only - server implementation support may be added in the future - Client library only - server implementation support may be added in the future
- Provider-agnostic implementation focused purely on the MCP protocol - 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 - MCP client implementation for .NET applications
- Support for stdio transport - 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 - Support for pagination and notifications
- Async/await pattern throughout - Async/await pattern throughout
- Comprehensive logging support - 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.Client;
using McpDotNet.Configuration; using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Types; using McpDotNet.Protocol.Types;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -215,7 +216,7 @@ public class IntegrationTests : IClassFixture<IntegrationTestFixture>
// Set up the sampling handler // Set up the sampling handler
int samplingHandlerCalls = 0; int samplingHandlerCalls = 0;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
client.SamplingHandler = async (_) => client.SamplingHandler = async (_,_) =>
{ {
samplingHandlerCalls++; samplingHandlerCalls++;
return new CreateMessageResult return new CreateMessageResult
@ -247,4 +248,98 @@ public class IntegrationTests : IClassFixture<IntegrationTestFixture>
Assert.Equal("text", textContent.Type); Assert.Equal("text", textContent.Type);
Assert.False(string.IsNullOrEmpty(textContent.Text)); 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; } Implementation? ServerInfo { get; }
/// <summary> /// <summary>
/// Gets or sets the handler for the server's sampling requests. /// Gets or sets the handler for server sampling requests.
/// </summary> /// </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> /// <summary>
/// Establishes a connection to the server. /// 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="method">The notification method to handle.</param>
/// <param name="handler">The async handler function to process notifications.</param> /// <param name="handler">The async handler function to process notifications.</param>
void OnNotification(string method, Func<JsonRpcNotification, Task> handler); 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"); 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) private async Task SendNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken)
{ {
if (!_transport.IsConnected) 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) 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")] [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); 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")] [LoggerMessage(Level = LogLevel.Error, Message = "Client server {serverId} ({serverName}) already initializing")]
internal static partial void ClientAlreadyInitializing(this ILogger logger, string serverId, string serverName); 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}")] [LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message write unknown message type: {type}")]
internal static partial void JsonRpcMessageConverterWriteUnknownMessageType(this ILogger logger, string 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. /// Sent by the server when a resource is updated.
/// </summary> /// </summary>
public const string ResourceUpdatedNotification = "notifications/resources/updated"; 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> <PropertyGroup>
<!-- license and package properties --> <!-- license and package properties -->
<PackageId>mcpdotnet</PackageId> <PackageId>mcpdotnet</PackageId>
<Version>0.4.0.1</Version> <Version>0.5.0.1</Version>
<Authors>PederHP</Authors> <Authors>PederHP</Authors>
<Description>.NET client library for the Model Context Protocol (MCP)</Description> <Description>.NET client library for the Model Context Protocol (MCP)</Description>
<PackageProjectUrl>https://github.com/PederHP/mcpdotnet</PackageProjectUrl> <PackageProjectUrl>https://github.com/PederHP/mcpdotnet</PackageProjectUrl>