diff --git a/samples/anthropic/tools/ToolsConsole/AnthropicToolsConsole.csproj b/samples/anthropic/tools/ToolsConsole/AnthropicToolsConsole.csproj index 04a14b4..a34ee4b 100644 --- a/samples/anthropic/tools/ToolsConsole/AnthropicToolsConsole.csproj +++ b/samples/anthropic/tools/ToolsConsole/AnthropicToolsConsole.csproj @@ -9,6 +9,7 @@ + diff --git a/samples/anthropic/tools/ToolsConsole/Program.cs b/samples/anthropic/tools/ToolsConsole/Program.cs index 8801ffd..c24e34d 100644 --- a/samples/anthropic/tools/ToolsConsole/Program.cs +++ b/samples/anthropic/tools/ToolsConsole/Program.cs @@ -1,6 +1,7 @@ using Anthropic.SDK; using Anthropic.SDK.Constants; using Anthropic.SDK.Messaging; +using System.Linq; using McpDotNet; using McpDotNet.Client; using McpDotNet.Configuration; @@ -46,8 +47,8 @@ internal class Program var client = await GetMcpClientAsync(); Console.WriteLine("MCP 'everything' server initialized"); Console.WriteLine("Listing tools..."); - var tools = await client.ListToolsAsync(); - var anthropicTools = tools.Tools.ToAnthropicTools(); + var tools = await client.ListToolsAsync().ToListAsync(); + var anthropicTools = tools.ToAnthropicTools(); Console.WriteLine("Tools available:"); foreach (var tool in anthropicTools) { diff --git a/samples/microsoft.extensions.ai/tools/ToolsConsole/MEAIToolsConsole.csproj b/samples/microsoft.extensions.ai/tools/ToolsConsole/MEAIToolsConsole.csproj index 8a2b33e..decc435 100644 --- a/samples/microsoft.extensions.ai/tools/ToolsConsole/MEAIToolsConsole.csproj +++ b/samples/microsoft.extensions.ai/tools/ToolsConsole/MEAIToolsConsole.csproj @@ -10,6 +10,7 @@ + diff --git a/samples/microsoft.extensions.ai/tools/ToolsConsole/Program.cs b/samples/microsoft.extensions.ai/tools/ToolsConsole/Program.cs index e1e7388..73894b0 100644 --- a/samples/microsoft.extensions.ai/tools/ToolsConsole/Program.cs +++ b/samples/microsoft.extensions.ai/tools/ToolsConsole/Program.cs @@ -45,8 +45,7 @@ internal class Program var client = await GetMcpClientAsync(); Console.WriteLine("MCP 'everything' server initialized"); Console.WriteLine("Listing tools..."); - var tools = await client.ListToolsAsync(); - var mappedTools = tools.Tools.Select(t => t.ToAITool(client)).ToList(); + var mappedTools = await client.ListToolsAsync().Select(t => t.ToAITool(client)).ToListAsync(); Console.WriteLine("Tools available:"); foreach (var tool in mappedTools) { diff --git a/src/McpDotNet.Extensions.AI/McpSessionScope.cs b/src/McpDotNet.Extensions.AI/McpSessionScope.cs index c448c92..0a528e2 100644 --- a/src/McpDotNet.Extensions.AI/McpSessionScope.cs +++ b/src/McpDotNet.Extensions.AI/McpSessionScope.cs @@ -46,12 +46,18 @@ public sealed class McpSessionScope : IAsyncDisposable var scope = new McpSessionScope(); var client = await scope.AddClientAsync(serverConfig, options, loggerFactory).ConfigureAwait(false); - var tools = await client.ListToolsAsync().ConfigureAwait(false); - scope.Tools = tools.Tools.Select(t => new McpAIFunction(t, client)).ToList(); + + scope.Tools = []; + await foreach (var tool in client.ListToolsAsync().ConfigureAwait(false)) + { + scope.Tools.Add(new McpAIFunction(tool, client)); + } + if (!string.IsNullOrEmpty(client.ServerInstructions)) { scope.ServerInstructions = [client.ServerInstructions!]; } + return scope; } @@ -72,13 +78,20 @@ public sealed class McpSessionScope : IAsyncDisposable } var scope = new McpSessionScope(); + foreach (var config in serverConfigs) { var client = await scope.AddClientAsync(config, options, loggerFactory).ConfigureAwait(false); - var tools = await client.ListToolsAsync().ConfigureAwait(false); - scope.Tools = scope.Tools.Concat(tools.Tools.Select(t => new McpAIFunction(t, client))).ToList(); + + scope.Tools ??= []; + await foreach (var tool in client.ListToolsAsync().ConfigureAwait(false)) + { + scope.Tools.Add(new McpAIFunction(tool, client)); + } } + scope.ServerInstructions = scope._clients.Select(c => c.ServerInstructions).Where(s => !string.IsNullOrEmpty(s)).ToList()!; + return scope; } diff --git a/src/mcpdotnet/Client/IMcpClient.cs b/src/mcpdotnet/Client/IMcpClient.cs index 8a42a8f..0d7442f 100644 --- a/src/mcpdotnet/Client/IMcpClient.cs +++ b/src/mcpdotnet/Client/IMcpClient.cs @@ -4,9 +4,7 @@ using McpDotNet.Protocol.Types; namespace McpDotNet.Client; /// -/// Represents an instance of the MCP client connecting to a specific server. -/// Use PingAsync, ListToolsAsync and CallToolAsync instead of SendRequestAsync for common operations. -/// SendRequestAsync is provided for custom request and those not yet covered explicitly by the client. +/// Represents an instance of an MCP client connecting to a specific server. /// public interface IMcpClient : IAsyncDisposable { @@ -32,15 +30,35 @@ public interface IMcpClient : IAsyncDisposable /// string? ServerInstructions { get; } - /// - /// Gets or sets the handler for server sampling requests. - /// - Func>? SamplingHandler { get; set; } + /// Sets a handler for the named operation. + /// The name of the operation. + /// The handler. Each operation requires a specific delegate signature. + /// + /// + /// Each operation may have only a single handler. Setting a handler for an operation that already has one + /// will replace the existing handler. + /// + /// + /// provides constants for common operations. + /// + /// + void SetOperationHandler(string operationName, Delegate handler); /// - /// Gets or sets the handler for providing root URIs to servers. + /// Adds a handler for server notifications of a specific method. /// - Func>? RootsHandler { get; set; } + /// The notification method to handle. + /// The async handler function to process notifications. + /// + /// + /// Each method may have multiple handlers. Adding a handler for a method that already has one + /// will not replace the existing handler. + /// + /// + /// provides constants for common notification methods. + /// + /// + void AddNotificationHandler(string method, Func handler); /// /// Establishes a connection to the server. @@ -49,121 +67,23 @@ public interface IMcpClient : IAsyncDisposable /// A task representing the asynchronous operation. Task ConnectAsync(CancellationToken cancellationToken = default); - /// - /// Sends a ping request to verify server connectivity. - /// - /// A token to cancel the operation. - /// A task that completes when the ping is successful. - Task PingAsync(CancellationToken cancellationToken = default); - - /// - /// Retrieves a list of available tools from the server. - /// - /// An optional cursor to paginate the results. - /// A token to cancel the operation. - /// A task containing the server's response with tool information. - Task ListToolsAsync(string? cursor = null, CancellationToken cancellationToken = default); - - /// - /// Invokes a tool on the server with optional arguments. - /// - /// The name of the tool to call. - /// Optional arguments for the tool. - /// A token to cancel the operation. - /// A task containing the tool's response. - Task CallToolAsync(string toolName, Dictionary arguments, CancellationToken cancellationToken = default); - - /// - /// Retrieves a list of available prompts from the server. - /// - /// An optional cursor to paginate the results. - /// A token to cancel the operation. - /// A task containing the server's response with prompt information. - Task ListPromptsAsync(string? cursor = null, CancellationToken cancellationToken = default); - - /// - /// Retrieves a specific prompt with optional arguments. - /// - /// The name of the prompt to retrieve - /// Optional arguments for the prompt - /// A token to cancel the operation. - /// A task containing the prompt's content and messages. - Task GetPromptAsync(string name, Dictionary? arguments = null, CancellationToken cancellationToken = default); - - /// - /// Retrieves a list of available resources from the server. - /// - /// An optional cursor to paginate the results. - /// A token to cancel the operation. - /// - Task ListResourcesAsync(string? cursor = null, CancellationToken cancellationToken = default); - - /// - /// Reads a resource from the server. - /// - /// The uri of the resource. - /// A token to cancel the operation. - /// - Task ReadResourceAsync(string uri, CancellationToken cancellationToken = default); - - /// - /// Gets the completion options for a resource or prompt reference and (named) argument. - /// - /// A resource (uri) or prompt (name) reference - /// Name of argument. Must be non-null and non-empty. - /// Value of argument. Must be non-null. - /// A token to cancel the operation. - /// - Task GetCompletionAsync(Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default); - - /// - /// Subscribes to a resource on the server. - /// - /// The uri of the resource. - /// A token to cancel the operation. - /// - Task SubscribeToResourceAsync(string uri, CancellationToken cancellationToken = default); - - /// - /// Unsubscribes from a resource on the server. - /// - /// The uri of the resource. - /// A token to cancel the operation. - /// - Task UnsubscribeFromResourceAsync(string uri, CancellationToken cancellationToken = default); - /// /// Sends a generic JSON-RPC request to the server. - /// It is strongly recommended use the capability-specific methods instead of this one. - /// Use this method for custom requests or those not yet covered explicitly by the client. /// - /// The expected response type. + /// The expected response type. /// The JSON-RPC request to send. /// A token to cancel the operation. /// A task containing the server's response. - Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where T : class; + /// + /// It is recommended to use the capability-specific methods that use this one in their implementation. + /// Use this method for custom requests or those not yet covered explicitly. + /// + Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) where TResult : class; /// - /// Registers a handler for server notifications of a specific method. - /// - /// Constants for common notification methods + /// Sends a message to the server. /// - /// 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. + /// The message. /// 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); + Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/mcpdotnet/Client/McpClient.cs b/src/mcpdotnet/Client/McpClient.cs index acf6036..c7e4e24 100644 --- a/src/mcpdotnet/Client/McpClient.cs +++ b/src/mcpdotnet/Client/McpClient.cs @@ -1,5 +1,4 @@ - -using System.Text.Json; +using System.Text.Json; using McpDotNet.Configuration; using McpDotNet.Logging; using McpDotNet.Protocol.Messages; @@ -8,25 +7,22 @@ using McpDotNet.Protocol.Types; using McpDotNet.Shared; using Microsoft.Extensions.Logging; +#pragma warning disable CA1508 // Avoid dead conditional code + namespace McpDotNet.Client; /// -internal class McpClient : McpJsonRpcEndpoint, IMcpClient +internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient { private readonly McpClientOptions _options; private readonly McpServerConfig _serverConfig; private readonly ILogger _logger; - private volatile bool _isInitializing; private readonly IClientTransport _clientTransport; + + private volatile bool _isInitializing; - /// - public ServerCapabilities? ServerCapabilities { get; private set; } - - /// - public Implementation? ServerInfo { get; private set; } - - /// - public string? ServerInstructions { get; private set; } + private Func>? _samplingHandler; + private Func>? _rootsHandler; /// /// Initializes a new instance of the class. @@ -43,53 +39,90 @@ internal class McpClient : McpJsonRpcEndpoint, IMcpClient _logger = loggerFactory.CreateLogger(); _clientTransport = transport; - if (options.Capabilities?.Sampling != null) - { - SetRequestHandler("sampling/createMessage", - async (request) => - { - if (SamplingHandler == null) - { - // Setting the capability, but not a handler means we have nothing to return to the server - _logger.SamplingHandlerNotConfigured(EndpointName); - throw new McpClientException("Sampling handler not configured"); - } + EndpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})"; - return await SamplingHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); - }); - } - if (options.Capabilities?.Roots != null) + if (options.Capabilities?.Sampling is not null) { - SetRequestHandler("roots/list", - async (request) => + SetRequestHandler( + "sampling/createMessage", request => HandleRequest("Sampling", _samplingHandler, request)); + } + + if (options.Capabilities?.Roots is not null) + { + SetRequestHandler( + "roots/list", request => HandleRequest("Roots", _rootsHandler, request)); + } + } + + /// + public ServerCapabilities? ServerCapabilities { get; private set; } + + /// + public Implementation? ServerInfo { get; private set; } + + /// + public string? ServerInstructions { get; private set; } + + /// + public override string EndpointName { get; } + + public void SetOperationHandler(string operationName, Delegate handler) + { + if (operationName is null) + { + throw new ArgumentNullException(nameof(operationName)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + if (!TrySetOperationHandler(OperationNames.Sampling, operationName, handler, ref _samplingHandler) && + !TrySetOperationHandler(OperationNames.Roots, operationName, handler, ref _rootsHandler)) + { + throw new ArgumentException($"Unknown operation '{operationName}'", nameof(operationName)); + } + + static bool TrySetOperationHandler( + string targetOperationName, + string operationName, + Delegate handler, + ref Func>? field) + { + if (operationName == targetOperationName) + { + if (handler is Func> typed) { - if (RootsHandler == null) - { - // Setting the capability, but not a handler means we have nothing to return to the server - _logger.RootsHandlerNotConfigured(EndpointName); - throw new McpClientException("Roots handler not configured"); - } - return await RootsHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); - }); + field = typed; + return true; + } + + throw new ArgumentException( + $"Handler must be of type {typeof(Func>)}", + nameof(handler)); + } + + return false; } } /// public async Task ConnectAsync(CancellationToken cancellationToken = default) { - if (_isInitializing) - { - _logger.ClientAlreadyInitializing(EndpointName); - throw new InvalidOperationException("Client is already initializing"); - } - _isInitializing = true; - if (IsInitialized) { _logger.ClientAlreadyInitialized(EndpointName); return; } + if (_isInitializing) + { + _logger.ClientAlreadyInitializing(EndpointName); + throw new InvalidOperationException("Client is already initializing"); + } + + _isInitializing = true; try { CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -136,8 +169,7 @@ internal class McpClient : McpJsonRpcEndpoint, IMcpClient clientInfo = _options.ClientInfo } }, - initializationCts.Token - ).ConfigureAwait(false); + initializationCts.Token).ConfigureAwait(false); // Store server information _logger.ServerCapabilitiesReceived(EndpointName, JsonSerializer.Serialize(initializeResponse.Capabilities), JsonSerializer.Serialize(initializeResponse.ServerInfo)); @@ -153,13 +185,9 @@ internal class McpClient : McpJsonRpcEndpoint, IMcpClient } // Send initialized notification - await SendNotificationAsync( - new JsonRpcNotification - { - Method = "notifications/initialized" - }, - initializationCts.Token - ).ConfigureAwait(false); + await SendMessageAsync( + new JsonRpcNotification { Method = "notifications/initialized" }, + initializationCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (initializationCts.IsCancellationRequested) { @@ -168,202 +196,18 @@ internal class McpClient : McpJsonRpcEndpoint, IMcpClient } } - /// - public async Task PingAsync(CancellationToken cancellationToken = default) + private async Task HandleRequest( + string friendlyName, + Func>? handler, + TRequest args) { - _logger.PingingServer(EndpointName); - await SendRequestAsync( - new JsonRpcRequest - { - Method = "ping" - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task ListToolsAsync(string? cursor = null, CancellationToken cancellationToken = default) - { - _logger.ListingTools(EndpointName, cursor ?? "(null)"); - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "tools/list", - Params = cursor != null ? new Dictionary { ["cursor"] = cursor } : null - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task ListPromptsAsync(string? cursor = null, CancellationToken cancellationToken = default) - { - _logger.ListingPrompts(EndpointName, cursor ?? "(null)"); - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "prompts/list", - Params = cursor != null ? new Dictionary { ["cursor"] = cursor } : null - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task GetPromptAsync(string name, Dictionary? arguments = null, CancellationToken cancellationToken = default) - { - _logger.GettingPrompt(EndpointName, name, arguments == null ? "{}" : JsonSerializer.Serialize(arguments)); - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "prompts/get", - Params = CreateParametersDictionary(name, arguments) - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task ListResourcesAsync(string? cursor = null, CancellationToken cancellationToken = default) - { - _logger.ListingResources(EndpointName, cursor ?? "(null)"); - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "resources/list", - Params = cursor != null ? new Dictionary { ["cursor"] = cursor } : null - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task ReadResourceAsync(string uri, CancellationToken cancellationToken = default) - { - _logger.ReadingResource(EndpointName, uri); - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "resources/read", - Params = new Dictionary - { - ["uri"] = uri - } - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task GetCompletionAsync(Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default) - { - if (!reference.Validate(out string validationMessage)) + if (handler is not null) { - _logger.InvalidCompletionReference(EndpointName, reference.ToString(), validationMessage); - throw new McpClientException($"Invalid reference: {validationMessage}"); - } - if (string.IsNullOrWhiteSpace(argumentName)) - { - _logger.InvalidCompletionArgumentName(EndpointName, argumentName); - throw new McpClientException("Argument name cannot be null or empty"); - } - if (argumentValue is null) - { - _logger.InvalidCompletionArgumentValue(EndpointName, "null", argumentName); - throw new McpClientException("Argument value cannot be null"); + return await handler(args, CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); } - _logger.GettingCompletion(EndpointName, reference.ToString(), argumentName, argumentValue); - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "completion/complete", - Params = new Dictionary - { - ["ref"] = reference, - ["argument"] = new Argument { Name = argumentName, Value = argumentValue } - } - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task SubscribeToResourceAsync(string uri, CancellationToken cancellationToken = default) - { - _logger.SubscribingToResource(EndpointName, uri); - await SendRequestAsync( - new JsonRpcRequest - { - Method = "resources/subscribe", - Params = new Dictionary - { - ["uri"] = uri - } - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task UnsubscribeFromResourceAsync(string uri, CancellationToken cancellationToken = default) - { - _logger.UnsubscribingFromResource(EndpointName, uri); - await SendRequestAsync( - new JsonRpcRequest - { - Method = "resources/unsubscribe", - Params = new Dictionary - { - ["uri"] = uri - } - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task CallToolAsync(string toolName, Dictionary arguments, - CancellationToken cancellationToken = default) - { - _logger.CallingTool(EndpointName, toolName, JsonSerializer.Serialize(arguments)); - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "tools/call", - Params = CreateParametersDictionary(toolName, arguments ?? []) - }, - cancellationToken - ).ConfigureAwait(false); - } - - private static Dictionary CreateParametersDictionary(string nameParameter, Dictionary? arguments) - { - var parameters = new Dictionary - { - ["name"] = nameParameter - }; - - if (arguments != null) - { - parameters["arguments"] = arguments; - } - - return parameters; - } - - /// - public Func>? SamplingHandler { get; set; } - - /// - public Func>? RootsHandler { get; set; } - - /// - public override string EndpointName - { - get - { - return $"Client ({_serverConfig.Id}: {_serverConfig.Name})"; - } + // Setting the capability, but not a handler means we have nothing to return to the server + _logger.HandlerNotConfigured(friendlyName, EndpointName); + throw new McpClientException($"{friendlyName} handler not configured."); } } diff --git a/src/mcpdotnet/Client/McpClientExtensions.cs b/src/mcpdotnet/Client/McpClientExtensions.cs new file mode 100644 index 0000000..6b8a93a --- /dev/null +++ b/src/mcpdotnet/Client/McpClientExtensions.cs @@ -0,0 +1,361 @@ +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Types; +using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; +using System.Xml.XPath; + +namespace McpDotNet.Client; + +/// +/// Provides extensions for operating on MCP clients. +/// +public static class McpClientExtensions +{ + /// + /// Sends a notification to the server with parameters. + /// + /// The client. + /// The notification method name. + /// The parameters to send with the notification. + /// A token to cancel the operation. + public static Task SendNotificationAsync(this IMcpClient client, string method, object? parameters = null, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendMessageAsync( + new JsonRpcNotification { Method = method, Params = parameters }, + cancellationToken); + } + + /// + /// Sends a ping request to verify server connectivity. + /// + /// The client. + /// A token to cancel the operation. + /// A task that completes when the ping is successful. + public static Task PingAsync(this IMcpClient client, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("ping", null), + cancellationToken); + } + + /// + /// Retrieves a sequence of available tools from the server. + /// + /// The client. + /// A token to cancel the operation. + /// An asynchronous sequence of tool information. + public static async IAsyncEnumerable ListToolsAsync( + this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? cursor = null; + do + { + var tools = await ListToolsAsync(client, cursor, cancellationToken).ConfigureAwait(false); + foreach (var tool in tools.Tools) + { + yield return tool; + } + + cursor = tools.NextCursor; + } + while (cursor is not null); + } + + /// + /// Retrieves a sequence of available tools from the server. + /// + /// The client. + /// A cursor to paginate the results. + /// A token to cancel the operation. + /// A task containing the server's response with tool information. + public static Task ListToolsAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("tools/list", CreateCursorDictionary(cursor)), + cancellationToken); + } + + /// + /// Retrieves a list of available prompts from the server. + /// + /// The client. + /// A token to cancel the operation. + /// An asynchronous sequence of prompt information. + public static async IAsyncEnumerable ListPromptsAsync( + this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? cursor = null; + do + { + var prompts = await ListPromptsAsync(client, cursor, cancellationToken).ConfigureAwait(false); + foreach (var prompt in prompts.Prompts) + { + yield return prompt; + } + + cursor = prompts.NextCursor; + } + while (cursor is not null); + } + + /// + /// Retrieves a list of available prompts from the server. + /// + /// The client. + /// A cursor to paginate the results. + /// A token to cancel the operation. + /// A task containing the server's response with prompt information. + public static Task ListPromptsAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("prompts/list", CreateCursorDictionary(cursor)), + cancellationToken); + } + + /// + /// Retrieves a specific prompt with optional arguments. + /// + /// The client. + /// The name of the prompt to retrieve + /// Optional arguments for the prompt + /// A token to cancel the operation. + /// A task containing the prompt's content and messages. + public static Task GetPromptAsync(this IMcpClient client, string name, Dictionary? arguments = null, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("prompts/get", CreateParametersDictionary(name, arguments)), + cancellationToken); + } + + /// + /// Retrieves a sequence of available resources from the server. + /// + /// The client. + /// A token to cancel the operation. + /// An asynchronous sequence of resource information. + public static async IAsyncEnumerable ListResourcesAsync( + this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? cursor = null; + do + { + var resources = await ListResourcesAsync(client, cursor, cancellationToken).ConfigureAwait(false); + foreach (var resource in resources.Resources) + { + yield return resource; + } + + cursor = resources.NextCursor; + } + while (cursor is not null); + } + + /// + /// Retrieves a list of available resources from the server. + /// + /// The client. + /// A cursor to paginate the results. + /// A token to cancel the operation. + public static Task ListResourcesAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("resources/list", CreateCursorDictionary(cursor)), + cancellationToken); + } + + /// + /// Reads a resource from the server. + /// + /// The client. + /// The uri of the resource. + /// A token to cancel the operation. + public static Task ReadResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("resources/read", new() { ["uri"] = uri }), + cancellationToken); + } + + /// + /// Gets the completion options for a resource or prompt reference and (named) argument. + /// + /// The client. + /// A resource (uri) or prompt (name) reference + /// Name of argument. Must be non-null and non-empty. + /// Value of argument. Must be non-null. + /// A token to cancel the operation. + public static Task GetCompletionAsync(this IMcpClient client, Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + if (reference is null) + { + throw new ArgumentNullException(nameof(reference)); + } + + if (string.IsNullOrWhiteSpace(argumentName)) + { + throw argumentName is null ? + new ArgumentNullException(nameof(argumentName)) : + new ArgumentException("Argument name cannot be empty.", nameof(argumentName)); + } + + if (!reference.Validate(out string? validationMessage)) + { + throw new ArgumentException($"Invalid reference: {validationMessage}", nameof(reference)); + } + + return client.SendRequestAsync( + CreateRequest("completion/complete", new() + { + ["ref"] = reference, + ["argument"] = new Argument { Name = argumentName, Value = argumentValue } + }), + cancellationToken); + } + + /// + /// Subscribes to a resource on the server. + /// + /// The client. + /// The uri of the resource. + /// A token to cancel the operation. + public static Task SubscribeToResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("resources/subscribe", new() { ["uri"] = uri }), + cancellationToken); + } + + /// + /// Unsubscribes from a resource on the server. + /// + /// The client. + /// The uri of the resource. + /// A token to cancel the operation. + public static Task UnsubscribeFromResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("resources/unsubscribe", new() { ["uri"] = uri }), + cancellationToken); + } + + /// + /// Invokes a tool on the server with optional arguments. + /// + /// The client. + /// The name of the tool to call. + /// Optional arguments for the tool. + /// A token to cancel the operation. + /// A task containing the tool's response. + public static Task CallToolAsync(this IMcpClient client, string toolName, Dictionary arguments, CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendRequestAsync( + CreateRequest("tools/call", CreateParametersDictionary(toolName, arguments)), + cancellationToken); + } + + /// Sets the handler for server sampling requests. + /// The client. + /// The sampling request handler. + public static void SetSamplingHandler(this IMcpClient client, Func> handler) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + client.SetOperationHandler(OperationNames.Sampling, handler); + } + + /// Sets the handler for server roots requests. + /// The client. + /// The roots request handler. + public static void SetRootsHandler(this IMcpClient client, Func> handler) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + client.SetOperationHandler(OperationNames.Roots, handler); + } + + private static JsonRpcRequest CreateRequest(string method, Dictionary? parameters) => + new JsonRpcRequest + { + Method = method, + Params = parameters + }; + + private static Dictionary? CreateCursorDictionary(string? cursor) => + cursor != null ? new() { ["cursor"] = cursor } : null; + + private static Dictionary CreateParametersDictionary(string nameParameter, Dictionary? arguments) + { + Dictionary parameters = new() + { + ["name"] = nameParameter + }; + + if (arguments != null) + { + parameters["arguments"] = arguments; + } + + return parameters; + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Logging/Log.cs b/src/mcpdotnet/Logging/Log.cs index c00c959..551c5aa 100644 --- a/src/mcpdotnet/Logging/Log.cs +++ b/src/mcpdotnet/Logging/Log.cs @@ -41,11 +41,8 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Debug, Message = "Initializing stdio commands")] internal static partial void InitializingStdioCommands(this ILogger logger); - [LoggerMessage(Level = LogLevel.Error, Message = "Sampling handler not configured for server {endpointName}, always set a handler when using this capability")] - internal static partial void SamplingHandlerNotConfigured(this ILogger logger, string endpointName); - - [LoggerMessage(Level = LogLevel.Error, Message = "Roots handler not configured for server {endpointName}, always set a handler when using this capability")] - internal static partial void RootsHandlerNotConfigured(this ILogger logger, string endpointName); + [LoggerMessage(Level = LogLevel.Error, Message = "{handlerName} handler not configured for server {endpointName}, always set a handler when using this capability")] + internal static partial void HandlerNotConfigured(this ILogger logger, string handlerName, string endpointName); [LoggerMessage(Level = LogLevel.Error, Message = "List tools handler not configured for server {endpointName}, always set a handler when using this capability")] internal static partial void ListToolsHandlerNotConfigured(this ILogger logger, string endpointName); @@ -266,8 +263,8 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Information, Message = "Transport cleaned up for {endpointName}")] internal static partial void TransportCleanedUp(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Debug, Message = "Sending notification payload for {endpointName}: {payload}")] - internal static partial void SendingNotificationPayload(this ILogger logger, string endpointName, string payload); + [LoggerMessage(Level = LogLevel.Debug, Message = "Sending message to {endpointName}: {message}")] + internal static partial void SendingMessage(this ILogger logger, string endpointName, string message); [LoggerMessage(Level = LogLevel.Information, Message = "Sending notification for {endpointName}: {method}")] internal static partial void SendingNotification(this ILogger logger, string endpointName, string method); diff --git a/src/mcpdotnet/Protocol/Messages/OperationNames.cs b/src/mcpdotnet/Protocol/Messages/OperationNames.cs new file mode 100644 index 0000000..f8f3bcc --- /dev/null +++ b/src/mcpdotnet/Protocol/Messages/OperationNames.cs @@ -0,0 +1,47 @@ +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Types; +using Microsoft.Extensions.Logging; + +namespace McpDotNet.Protocol.Messages; + +/// Provides names of standard operations for use with registering handlers. +/// +/// These values should not be inspected or relied on for their exact values. +/// They serve only as opaque keys. They will be stable for the lifetime of a process +/// but may change between versions of this library. +/// +public static class OperationNames +{ + /// Gets the name of the sampling operation. + public static string Sampling { get; } = "operation/sampling"; + + /// Gets the name of the roots operation. + public static string Roots { get; } = "operation/roots"; + + /// Gets the name of the list tools operation. + public static string ListTools { get; } = "operation/listTools"; + + /// Gets the name of the call tool operation. + public static string CallTool { get; } = "operation/callTool"; + + /// Gets the name of the list prompts operation. + public static string ListPrompts { get; } = "operation/listPrompts"; + + /// Gets the name of the get prompt operation. + public static string GetPrompt { get; } = "operation/getPrompt"; + + /// Gets the name of the list resources operation. + public static string ListResources { get; } = "operation/listResources"; + + /// Gets the name of the read resource operation. + public static string ReadResource { get; } = "operation/readResource"; + + /// Gets the name of the get completion operation. + public static string GetCompletion { get; } = "operation/getCompletion"; + + /// Gets the name of the subscribe to resources operation. + public static string SubscribeToResources { get; } = "operation/subscribeToResources"; + + /// Gets the name of the subscribe to resources operation. + public static string UnsubscribeFromResources { get; } = "operation/unsubscribeFromResources"; +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/Reference.cs b/src/mcpdotnet/Protocol/Types/Reference.cs index 46669cb..d313db4 100644 --- a/src/mcpdotnet/Protocol/Types/Reference.cs +++ b/src/mcpdotnet/Protocol/Types/Reference.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; namespace McpDotNet.Protocol.Types; @@ -37,7 +38,7 @@ public class Reference /// /// Validates the reference object. /// - public bool Validate(out string validationMessage) + public bool Validate([NotNullWhen(false)] out string? validationMessage) { if (Type == "ref/resource") { @@ -61,7 +62,7 @@ public class Reference return false; } - validationMessage = string.Empty; + validationMessage = null; return true; } } diff --git a/src/mcpdotnet/Server/IMcpServer.cs b/src/mcpdotnet/Server/IMcpServer.cs index d8cd4c5..8672701 100644 --- a/src/mcpdotnet/Server/IMcpServer.cs +++ b/src/mcpdotnet/Server/IMcpServer.cs @@ -28,67 +28,41 @@ public interface IMcpServer : IAsyncDisposable /// IServiceProvider? ServiceProvider { get; } + /// Sets a handler for the named operation. + /// The name of the operation. + /// The handler. Each operation requires a specific delegate signature. + /// + /// + /// Each operation may have only a single handler. Setting a handler for an operation that already has one + /// will replace the existing handler. + /// + /// + /// provides constants for common operations. + /// + /// + void SetOperationHandler(string operationName, Delegate handler); + + /// + /// Adds a handler for client notifications of a specific method. + /// + /// The notification method to handle. + /// The async handler function to process notifications. + /// + /// + /// Each method may have multiple handlers. Adding a handler for a method that already has one + /// will not replace the existing handler. + /// + /// + /// provides constants for common notification methods. + /// + /// + void AddNotificationHandler(string method, Func handler); + /// /// Starts the server and begins listening for client requests. /// Task StartAsync(CancellationToken cancellationToken = default); - /// - /// Gets or sets the handler for list tools requests. - /// - Func, CancellationToken, Task>? ListToolsHandler { get; set; } - - /// - /// Gets or sets the handler for call tool requests. - /// - Func, CancellationToken, Task>? CallToolHandler { get; set; } - - /// - /// Gets or sets the handler for list prompts requests. - /// - Func, CancellationToken, Task>? ListPromptsHandler { get; set; } - - /// - /// Gets or sets the handler for get prompt requests. - /// - Func, CancellationToken, Task>? GetPromptHandler { get; set; } - - /// - /// Gets or sets the handler for list resources requests. - /// - Func, CancellationToken, Task>? ListResourcesHandler { get; set; } - - /// - /// Gets or sets the handler for read resources requests. - /// - Func, CancellationToken, Task>? ReadResourceHandler { get; set; } - - /// - /// Gets or sets the handler for get resources requests. - /// - Func, CancellationToken, Task>? GetCompletionHandler { get; set; } - - /// - /// Gets or sets the handler for subscribe to resources messages. - /// - Func, CancellationToken, Task>? SubscribeToResourcesHandler { get; set; } - - /// - /// Gets or sets the handler for subscribe to resources messages. - /// - Func, CancellationToken, Task>? UnsubscribeFromResourcesHandler { get; set; } - - - /// - /// Requests the client to create a new message. - /// - Task RequestSamplingAsync(CreateMessageRequestParams request, CancellationToken cancellationToken); - - /// - /// Requests the client to list the roots it exposes. - /// - Task RequestRootsAsync(ListRootsRequestParams request, CancellationToken cancellationToken); - /// /// Sends a generic JSON-RPC request to the client. /// NB! This is a temporary method that is available to send not yet implemented feature messages. @@ -101,26 +75,9 @@ public interface IMcpServer : IAsyncDisposable Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where T : class; /// - /// Registers a handler for notifications of a specific method. - /// - /// Constants for common notification methods + /// Sends a message to the server. /// - /// The notification method to handle. - /// The async handler function to process notifications. - void OnNotification(string method, Func handler); - - /// - /// Sends a notification to the client. - /// - /// The notification method name. + /// The message. /// A token to cancel the operation. - Task SendNotificationAsync(string method, CancellationToken cancellationToken = default); - - /// - /// Sends a notification to the client 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); + Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default); } diff --git a/src/mcpdotnet/Server/McpServer.cs b/src/mcpdotnet/Server/McpServer.cs index 4516617..2c525c4 100644 --- a/src/mcpdotnet/Server/McpServer.cs +++ b/src/mcpdotnet/Server/McpServer.cs @@ -10,13 +10,23 @@ using Microsoft.Extensions.Logging; namespace McpDotNet.Server; /// -internal class McpServer : McpJsonRpcEndpoint, IMcpServer +internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer { private readonly IServerTransport _serverTransport; private readonly McpServerOptions _options; private volatile bool _isInitializing; private readonly ILogger _logger; + private Func, CancellationToken, Task>? _listToolsHandler; + private Func, CancellationToken, Task>? _callToolHandler; + private Func, CancellationToken, Task>? _listPromptsHandler; + private Func, CancellationToken, Task>? _getPromptHandler; + private Func, CancellationToken, Task>? _listResourcesHandler; + private Func, CancellationToken, Task>? _readResourceHandler; + private Func, CancellationToken, Task>? _getCompletionHandler; + private Func, CancellationToken, Task>? _subscribeToResourcesHandler; + private Func, CancellationToken, Task>? _unsubscribeFromResourcesHandler; + /// /// Creates a new instance of . /// @@ -41,7 +51,7 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer SetCompletionHandler(); SetInitializeHandler(options); SetPingHandler(); - SetNotificationHandler(); + AddNotificationHandler(); } public ClientCapabilities? ClientCapabilities { get; set; } @@ -56,38 +66,54 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer public IServiceProvider? ServiceProvider { get; } /// - public Func, CancellationToken, Task>? ListToolsHandler { get; set; } + public override string EndpointName => + $"Server ({_options.ServerInfo.Name} {_options.ServerInfo.Version}), Client ({ClientInfo?.Name} {ClientInfo?.Version})"; - /// - public Func, CancellationToken, Task>? CallToolHandler { get; set; } - - /// - public Func, CancellationToken, Task>? ListPromptsHandler { get; set; } - - /// - public Func, CancellationToken, Task>? GetPromptHandler { get; set; } - - /// - public Func, CancellationToken, Task>? ListResourcesHandler { get; set; } - - /// - public Func, CancellationToken, Task>? ReadResourceHandler { get; set; } - - /// - public Func, CancellationToken, Task>? GetCompletionHandler { get; set; } - - /// - public Func, CancellationToken, Task>? SubscribeToResourcesHandler { get; set; } - - /// - public Func, CancellationToken, Task>? UnsubscribeFromResourcesHandler { get; set; } - - /// - public override string EndpointName + public void SetOperationHandler(string operationName, Delegate handler) { - get + if (operationName is null) { - return $"Server ({_options.ServerInfo.Name} {_options.ServerInfo.Version}), Client ({ClientInfo?.Name} {ClientInfo?.Version})"; + throw new ArgumentNullException(nameof(operationName)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + if (!TrySetOperationHandler(OperationNames.ListTools, operationName, handler, ref _listToolsHandler) && + !TrySetOperationHandler(OperationNames.CallTool, operationName, handler, ref _callToolHandler) && + !TrySetOperationHandler(OperationNames.ListPrompts, operationName, handler, ref _listPromptsHandler) && + !TrySetOperationHandler(OperationNames.GetPrompt, operationName, handler, ref _getPromptHandler) && + !TrySetOperationHandler(OperationNames.ListResources, operationName, handler, ref _listResourcesHandler) && + !TrySetOperationHandler(OperationNames.ReadResource, operationName, handler, ref _readResourceHandler) && + !TrySetOperationHandler(OperationNames.GetCompletion, operationName, handler, ref _getCompletionHandler) && + !TrySetOperationHandler(OperationNames.SubscribeToResources, operationName, handler, ref _subscribeToResourcesHandler) && + !TrySetOperationHandler(OperationNames.UnsubscribeFromResources, operationName, handler, ref _unsubscribeFromResourcesHandler)) + { + throw new ArgumentException($"Unknown operation '{operationName}'", nameof(operationName)); + } + + static bool TrySetOperationHandler( + string targetOperationName, + string operationName, + Delegate handler, + ref Func? field) + { + if (operationName == targetOperationName) + { + if (handler is Func typed) + { + field = typed; + return true; + } + + throw new ArgumentException( + $"Handler must be of type {typeof(Func)}", + nameof(handler)); + } + + return false; } } @@ -127,45 +153,9 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer } } - /// - public async Task RequestSamplingAsync(CreateMessageRequestParams request, CancellationToken cancellationToken) + private void AddNotificationHandler() { - if (ClientCapabilities?.Sampling == null) - { - throw new McpServerException("Client does not support sampling"); - } - - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "sampling/createMessage", - Params = request - }, - cancellationToken - ).ConfigureAwait(false); - } - - /// - public async Task RequestRootsAsync(ListRootsRequestParams request, CancellationToken cancellationToken) - { - if (ClientCapabilities?.Roots == null) - { - throw new McpServerException("Client does not support roots"); - } - - return await SendRequestAsync( - new JsonRpcRequest - { - Method = "roots/list", - Params = request - }, - cancellationToken - ).ConfigureAwait(false); - } - - private void SetNotificationHandler() - { - OnNotification("notifications/initialized", (notification) => + AddNotificationHandler("notifications/initialized", (notification) => { IsInitialized = true; return Task.CompletedTask; @@ -174,11 +164,8 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer private void SetPingHandler() { - SetRequestHandler("ping", - (request) => - { - return Task.FromResult(new PingResult()); - }); + SetRequestHandler("ping", + request => Task.FromResult(new PingResult())); } private void SetInitializeHandler(McpServerOptions options) @@ -203,7 +190,7 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer SetRequestHandler("completion/complete", async (request) => { - if (GetCompletionHandler == null) + if (_getCompletionHandler is null) { // This capability is not optional, so return an empty result if there is no handler return new CompleteResult() @@ -217,102 +204,102 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer }; } - return await GetCompletionHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); + return await _getCompletionHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); }); } private void SetResourcesHandler(McpServerOptions options) { - if (options.Capabilities?.Resources != null) + if (options.Capabilities?.Resources is not null) { SetRequestHandler("resources/list", async (request) => { - if (ListResourcesHandler == null) + if (_listResourcesHandler is null) { // Setting the capability, but not a handler means we have nothing to return to the server _logger.ListResourcesHandlerNotConfigured(EndpointName); throw new McpServerException("ListResources handler not configured"); } - return await ListResourcesHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); + return await _listResourcesHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); }); SetRequestHandler("resources/read", async (request) => { - if (ReadResourceHandler == null) + if (_readResourceHandler is null) { // Setting the capability, but not a handler means we have nothing to return to the server _logger.ReadResourceHandlerNotConfigured(EndpointName); throw new McpServerException("ReadResource handler not configured"); } - return await ReadResourceHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); + return await _readResourceHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); }); } } private void SetPromptsHandler(McpServerOptions options) { - if (options.Capabilities?.Prompts != null) + if (options.Capabilities?.Prompts is not null) { SetRequestHandler("prompts/list", async (request) => { - if (ListPromptsHandler == null) + if (_listPromptsHandler is null) { // Setting the capability, but not a handler means we have nothing to return to the server _logger.ListPromptsHandlerNotConfigured(EndpointName); throw new McpServerException("ListPrompts handler not configured"); } - return await ListPromptsHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); + return await _listPromptsHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); }); SetRequestHandler("prompts/get", async (request) => { - if (GetPromptHandler == null) + if (_getPromptHandler is null) { // Setting the capability, but not a handler means we have nothing to return to the server _logger.GetPromptHandlerNotConfigured(EndpointName); throw new McpServerException("GetPrompt handler not configured"); } - return await GetPromptHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); + return await _getPromptHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); }); } } private void SetToolsHandler(McpServerOptions options) { - if (options.Capabilities?.Tools != null) + if (options.Capabilities?.Tools is not null) { SetRequestHandler("tools/list", async (request) => { - if (ListToolsHandler == null) + if (_listToolsHandler is null) { // Setting the capability, but not a handler means we have nothing to return to the server _logger.ListToolsHandlerNotConfigured(EndpointName); throw new McpServerException("ListTools handler not configured"); } - return await ListToolsHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); + return await _listToolsHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); }); SetRequestHandler("tools/call", async (request) => { - if (CallToolHandler == null) + if (_callToolHandler is null) { // Setting the capability, but not a handler means we have nothing to return to the server _logger.CallToolHandlerNotConfigured(EndpointName); throw new McpServerException("CallTool handler not configured"); } - return await CallToolHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); + return await _callToolHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false); }); } } diff --git a/src/mcpdotnet/Server/McpServerDelegates.cs b/src/mcpdotnet/Server/McpServerDelegates.cs index 5c2a539..419eabd 100644 --- a/src/mcpdotnet/Server/McpServerDelegates.cs +++ b/src/mcpdotnet/Server/McpServerDelegates.cs @@ -64,30 +64,30 @@ public class McpServerDelegates } if (ListToolsHandler != null) - server.ListToolsHandler = ListToolsHandler; + server.SetListToolsHandler(ListToolsHandler); if (CallToolHandler != null) - server.CallToolHandler = CallToolHandler; + server.SetCallToolHandler(CallToolHandler); if (ListPromptsHandler != null) - server.ListPromptsHandler = ListPromptsHandler; + server.SetListPromptsHandler(ListPromptsHandler); if (GetPromptHandler != null) - server.GetPromptHandler = GetPromptHandler; + server.SetGetPromptHandler(GetPromptHandler); if (ListResourcesHandler != null) - server.ListResourcesHandler = ListResourcesHandler; + server.SetListResourcesHandler(ListResourcesHandler); if (ReadResourceHandler != null) - server.ReadResourceHandler = ReadResourceHandler; + server.SetReadResourceHandler(ReadResourceHandler); if (GetCompletionHandler != null) - server.GetCompletionHandler = GetCompletionHandler; + server.SetGetCompletionHandler(GetCompletionHandler); if (SubscribeToResourcesHandler != null) - server.SubscribeToResourcesHandler = SubscribeToResourcesHandler; + server.SetSubscribeToResourcesHandler(SubscribeToResourcesHandler); if (UnsubscribeFromResourcesHandler != null) - server.UnsubscribeFromResourcesHandler = UnsubscribeFromResourcesHandler; + server.SetUnsubscribeFromResourcesHandler(UnsubscribeFromResourcesHandler); } } diff --git a/src/mcpdotnet/Server/McpServerExtensions.cs b/src/mcpdotnet/Server/McpServerExtensions.cs new file mode 100644 index 0000000..5ed5f4c --- /dev/null +++ b/src/mcpdotnet/Server/McpServerExtensions.cs @@ -0,0 +1,221 @@ +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Types; + +namespace McpDotNet.Server; + +/// +public static class McpServerExtensions +{ + /// + /// Requests the client to create a new message. + /// + public static Task RequestSamplingAsync( + this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (server.ClientCapabilities?.Sampling is null) + { + throw new McpServerException("Client does not support sampling"); + } + + return server.SendRequestAsync( + new JsonRpcRequest { Method = "sampling/createMessage", Params = request }, + cancellationToken); + } + + /// + /// Requests the client to list the roots it exposes. + /// + public static Task RequestRootsAsync( + this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (server.ClientCapabilities?.Roots is null) + { + throw new McpServerException("Client does not support roots"); + } + + return server.SendRequestAsync( + new JsonRpcRequest { Method = "roots/list", Params = request }, + cancellationToken); + } + + /// + /// Sets the handler for list tools requests. + /// + public static void SetListToolsHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.ListTools, handler); + } + + /// + /// Sets the handler for call tool requests. + /// + public static void SetCallToolHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.CallTool, handler); + } + + /// + /// Sets the handler for list prompts requests. + /// + public static void SetListPromptsHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.ListPrompts, handler); + } + + /// + /// Sets the handler for get prompt requests. + /// + public static void SetGetPromptHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.GetPrompt, handler); + } + + /// + /// Sets the handler for list resources requests. + /// + public static void SetListResourcesHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.ListResources, handler); + } + + /// + /// Sets the handler for read resource requests. + /// + public static void SetReadResourceHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.ReadResource, handler); + } + + /// + /// Sets the handler for get completion requests. + /// + public static void SetGetCompletionHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.GetCompletion, handler); + } + + /// + /// Sets the handler for subscribe to resources requests. + /// + public static void SetSubscribeToResourcesHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.SubscribeToResources, handler); + } + + /// + /// Sets the handler for unsubscribe from resources requests. + /// + public static void SetUnsubscribeFromResourcesHandler( + this IMcpServer server, Func, CancellationToken, Task> handler) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + server.SetOperationHandler(OperationNames.UnsubscribeFromResources, handler); + } +} diff --git a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs index d9a5e19..85eb384 100644 --- a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs +++ b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs @@ -34,14 +34,16 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable /// The logger factory. protected McpJsonRpcEndpoint(ITransport transport, ILoggerFactory loggerFactory) { - _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + if (transport is null) + { + throw new ArgumentNullException(nameof(transport)); + } + + _transport = transport; _pendingRequests = new(); _notificationHandlers = new(); _nextRequestId = 1; _jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions; - - if (loggerFactory is null) - throw new ArgumentNullException(nameof(loggerFactory)); _logger = loggerFactory.CreateLogger(); } @@ -72,10 +74,28 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable await foreach (var message in _transport.MessageReader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { _logger.TransportMessageRead(EndpointName, message.GetType().Name); + // Fire and forget the message handling task to avoid blocking the transport // If awaiting the task, the transport will not be able to read more messages, // which could lead to a deadlock if the handler sends a message back - FireAndForget(Task.Run(() => HandleMessageAsync(message, cancellationToken), CancellationToken.None), message); + _ = ProcessMessageAsync(); + async Task ProcessMessageAsync() + { +#if NET + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); +#else + await Task.Yield(); // TODO: Fix this +#endif + try + { + await HandleMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + var payload = JsonSerializer.Serialize(message); + _logger.MessageHandlerError(EndpointName, message.GetType().Name, payload, ex); + } + } } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -89,18 +109,6 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable } } - private void FireAndForget(Task task, IJsonRpcMessage message) - { - task.ContinueWith(t => - { - if (t.IsFaulted) - { - var payload = JsonSerializer.Serialize(message); - _logger.MessageHandlerError(EndpointName, message.GetType().Name, payload, t.Exception); - } - }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); - } - private async Task HandleMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken) { switch (message) @@ -108,12 +116,15 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable case JsonRpcRequest request: await HandleRequest(request, cancellationToken).ConfigureAwait(false); break; + case IJsonRpcMessageWithId messageWithId: HandleMessageWithId(message, messageWithId); break; + case JsonRpcNotification notification: await HandleNotification(notification).ConfigureAwait(false); break; + default: _logger.EndpointHandlerUnexpectedMessageType(EndpointName, message.GetType().Name); break; @@ -142,7 +153,9 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable private void HandleMessageWithId(IJsonRpcMessage message, IJsonRpcMessageWithId messageWithId) { if (!messageWithId.Id.IsValid) + { _logger.RequestHasInvalidId(EndpointName); + } else if (_pendingRequests.TryRemove(messageWithId.Id, out var tcs)) { _logger.ResponseMatchedPendingRequest(EndpointName, messageWithId.Id.ToString()); @@ -197,11 +210,11 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable /// It is strongly recommended use the capability-specific methods instead of this one. /// Use this method for custom requests or those not yet covered explicitly by the endpoint implementation. /// - /// The expected response type. + /// The expected response type. /// The JSON-RPC request to send. /// A token to cancel the operation. /// A task containing the server's response. - public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where T : class + public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where TResult : class { if (!_transport.IsConnected) { @@ -241,7 +254,7 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable { // Convert the Result object to JSON and back to get our strongly-typed result var resultJson = JsonSerializer.Serialize(success.Result, _jsonOptions); - var resultObject = JsonSerializer.Deserialize(resultJson, _jsonOptions); + var resultObject = JsonSerializer.Deserialize(resultJson, _jsonOptions); // Not expensive logging because we're already converting to JSON in order to get the result object _logger.RequestResponseReceivedPayload(EndpointName, resultJson); @@ -253,8 +266,8 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable } // Result object was null, this is unexpected - _logger.RequestResponseTypeConversionError(EndpointName, request.Method, typeof(T)); - throw new McpClientException($"Unexpected response type {JsonSerializer.Serialize(success.Result)}, expected {typeof(T)}"); + _logger.RequestResponseTypeConversionError(EndpointName, request.Method, typeof(TResult)); + throw new McpClientException($"Unexpected response type {JsonSerializer.Serialize(success.Result)}, expected {typeof(TResult)}"); } // Unexpected response type @@ -267,73 +280,25 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable } } - /// - /// Sends a notification to the connected endpoint. - /// - /// The notification method name. - /// A token to cancel the operation. - public async Task SendNotificationAsync(string method, CancellationToken cancellationToken = default) + public Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) { + if (message is null) + { + throw new ArgumentNullException(nameof(message)); + } + if (!_transport.IsConnected) { _logger.ClientNotConnected(EndpointName); throw new McpClientException("Transport is not connected"); } - var notification = new JsonRpcNotification { Method = method }; - - // Log if enabled if (_logger.IsEnabled(LogLevel.Debug)) - _logger.SendingNotificationPayload(EndpointName, JsonSerializer.Serialize(notification)); - - // Log basic info - _logger.SendingNotification(EndpointName, method); - await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); - } - - /// - /// Sends a notification to the connected endpoint with parameters. - /// - /// The notification method name. - /// The parameters to send with the notification. - /// A token to cancel the operation. - public async Task SendNotificationAsync(string method, T parameters, CancellationToken cancellationToken = default) - { - if (!_transport.IsConnected) { - _logger.ClientNotConnected(EndpointName); - throw new McpClientException("Transport is not connected"); + _logger.SendingMessage(EndpointName, JsonSerializer.Serialize(message)); } - var notification = new JsonRpcNotification - { - Method = method, - Params = parameters - }; - - // Log if enabled - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.SendingNotificationPayload(EndpointName, JsonSerializer.Serialize(notification)); - - // Log basic info - _logger.SendingNotification(EndpointName, method); - await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); - } - - /// - /// Sends a notification to the connected endpoint. - /// - /// The notification to send. - /// A token to cancel the operation. - internal async Task SendNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken) - { - if (!_transport.IsConnected) - { - _logger.ClientNotConnected(EndpointName); - throw new McpClientException("Transport is not connected"); - } - - await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); + return _transport.SendMessageAsync(message, cancellationToken); } /// @@ -343,7 +308,7 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable /// /// The notification method to handle. /// The async handler function to process notifications. - public void OnNotification(string method, Func handler) + public void AddNotificationHandler(string method, Func handler) { var handlers = _notificationHandlers.GetOrAdd(method, _ => []); lock (handlers) @@ -368,6 +333,11 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable /// Handler to be called when a request with specified method identifier is received protected void SetRequestHandler(string method, Func> handler) { + if (method is null) + { + throw new ArgumentNullException(nameof(method)); + } + if (handler is null) { throw new ArgumentNullException(nameof(handler)); diff --git a/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs b/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs index 752f3dd..b88d6a8 100644 --- a/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs +++ b/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs @@ -48,8 +48,7 @@ public class IntegrationTests public async Task IntegrateWithMeai_UsingEverythingServer_ToolsAreProperlyCalled() { var client = await GetMcpClientAsync(); - var tools = await client.ListToolsAsync(); - var mappedTools = tools.Tools.Select(t => t.ToAITool(client)).ToList(); + var mappedTools = await client.ListToolsAsync().Select(t => t.ToAITool(client)).ToListAsync(); IChatClient openaiClient = new OpenAIClient(_openAIKey) .AsChatClient("gpt-4o-mini"); diff --git a/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj b/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj index 3e21b8e..be1bdbe 100644 --- a/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj +++ b/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj @@ -15,6 +15,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/mcpdotnet.TestServer/Program.cs b/tests/mcpdotnet.TestServer/Program.cs index ef65228..7d77bb2 100644 --- a/tests/mcpdotnet.TestServer/Program.cs +++ b/tests/mcpdotnet.TestServer/Program.cs @@ -68,7 +68,7 @@ internal static class Program private static void ConfigureTools(IMcpServer server) { - server.ListToolsHandler = (request, cancellationToken) => + server.SetListToolsHandler((request, cancellationToken) => { return Task.FromResult(new ListToolsResult() { @@ -103,9 +103,9 @@ internal static class Program } ] }); - }; + }); - server.CallToolHandler = async (request, cancellationToken) => + server.SetCallToolHandler(async (request, cancellationToken) => { if (request.Params?.Name == "echo") { @@ -138,12 +138,12 @@ internal static class Program { throw new McpServerException($"Unknown tool: {request.Params?.Name}"); } - }; + }); } private static void ConfigurePrompts(IMcpServer server) { - server.ListPromptsHandler = (request, cancellationToken) => + server.SetListPromptsHandler((request, cancellationToken) => { return Task.FromResult(new ListPromptsResult() { @@ -175,9 +175,9 @@ internal static class Program } ] }); - }; + }); - server.GetPromptHandler = (request, cancellationToken) => + server.SetGetPromptHandler((request, cancellationToken) => { List messages = new(); if (request.Params?.Name == "simple_prompt") @@ -234,7 +234,7 @@ internal static class Program { Messages = messages }); - }; + }); } private static void ConfigureResources(IMcpServer server) @@ -279,7 +279,7 @@ internal static class Program const int pageSize = 10; - server.ListResourcesHandler = (request, cancellationToken) => + server.SetListResourcesHandler((request, cancellationToken) => { int startIndex = 0; request ??= new(server, new()); @@ -308,9 +308,9 @@ internal static class Program NextCursor = nextCursor, Resources = resources.GetRange(startIndex, endIndex - startIndex) }); - }; + }); - server.ReadResourceHandler = (request, cancellationToken) => + server.SetReadResourceHandler((request, cancellationToken) => { if (request.Params?.Uri is null) { @@ -323,7 +323,7 @@ internal static class Program { Contents = [contents] }); - }; + }); } private static void ConfigureCompletion(IMcpServer server) @@ -335,32 +335,32 @@ internal static class Program {"temperature", ["0", "0.5", "0.7", "1.0"]}, }; - server.GetCompletionHandler = (request, cancellationToken) => + server.SetGetCompletionHandler((request, cancellationToken) => + { + if (request.Params?.Ref?.Type == "ref/resource") { - if (request.Params?.Ref?.Type == "ref/resource") - { - var resourceId = request.Params?.Ref?.Uri?.Split('/').LastOrDefault(); - if (string.IsNullOrEmpty(resourceId)) - return Task.FromResult(new CompleteResult() { Completion = new() { Values = [] } }); + var resourceId = request.Params?.Ref?.Uri?.Split('/').LastOrDefault(); + if (string.IsNullOrEmpty(resourceId)) + return Task.FromResult(new CompleteResult() { Completion = new() { Values = [] } }); - // Filter resource IDs that start with the input value - var values = sampleResourceIds.Where(id => id.StartsWith(request.Params.Argument.Value)).ToArray(); - return Task.FromResult(new CompleteResult() { Completion = new() { Values = values, HasMore = false, Total = values.Length } }); + // Filter resource IDs that start with the input value + var values = sampleResourceIds.Where(id => id.StartsWith(request.Params.Argument.Value)).ToArray(); + return Task.FromResult(new CompleteResult() { Completion = new() { Values = values, HasMore = false, Total = values.Length } }); - } + } - if (request.Params?.Ref?.Type == "ref/prompt") - { - // Handle completion for prompt arguments - if (!exampleCompletions.TryGetValue(request.Params.Argument.Name, out var completions)) - return Task.FromResult(new CompleteResult() { Completion = new() { Values = [] } }); + if (request.Params?.Ref?.Type == "ref/prompt") + { + // Handle completion for prompt arguments + if (!exampleCompletions.TryGetValue(request.Params.Argument.Name, out var completions)) + return Task.FromResult(new CompleteResult() { Completion = new() { Values = [] } }); - var values = completions.Where(value => value.StartsWith(request.Params.Argument.Value)).ToArray(); - return Task.FromResult(new CompleteResult() { Completion = new() { Values = values, HasMore = false, Total = values.Length } }); - } + var values = completions.Where(value => value.StartsWith(request.Params.Argument.Value)).ToArray(); + return Task.FromResult(new CompleteResult() { Completion = new() { Values = values, HasMore = false, Total = values.Length } }); + } - throw new McpServerException($"Unknown reference type: {request.Params?.Ref.Type}"); - }; + throw new McpServerException($"Unknown reference type: {request.Params?.Ref.Type}"); + }); } static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100) diff --git a/tests/mcpdotnet.Tests/ClientIntegrationTests.cs b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs index a7a584f..cb0a728 100644 --- a/tests/mcpdotnet.Tests/ClientIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs @@ -59,11 +59,10 @@ public class ClientIntegrationTests : IClassFixture p.Name == "simple_prompt"); - Assert.Contains(prompts.Prompts, p => p.Name == "complex_prompt"); + Assert.Contains(prompts, p => p.Name == "simple_prompt"); + Assert.Contains(prompts, p => p.Name == "complex_prompt"); } [Theory] @@ -265,7 +263,7 @@ public class ClientIntegrationTests : IClassFixture + client.SetSamplingHandler((_, _) => { samplingHandlerCalls++; return Task.FromResult(new CreateMessageResult @@ -278,7 +276,7 @@ public class ClientIntegrationTests : IClassFixture + // client.SetRootsHandler((request, ct) => // { // rootsHandlerCalls++; // return Task.FromResult(new ListRootsResult // { // Roots = testRoots // }); - // }; + // }); // // Connect // await client.ConnectAsync(CancellationToken.None); diff --git a/tests/mcpdotnet.Tests/Server/McpServerDelegatesTests.cs b/tests/mcpdotnet.Tests/Server/McpServerDelegatesTests.cs index 7da27b6..95f5891 100644 --- a/tests/mcpdotnet.Tests/Server/McpServerDelegatesTests.cs +++ b/tests/mcpdotnet.Tests/Server/McpServerDelegatesTests.cs @@ -1,6 +1,6 @@ -using McpDotNet.Protocol.Types; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Types; using McpDotNet.Server; -using Moq; namespace McpDotNet.Tests.Server; @@ -10,8 +10,7 @@ public class McpServerDelegatesTests public void Applies_All_Given_Delegates() { var container = new McpServerDelegates(); - var server = new Mock(); - server.SetupAllProperties(); + var server = new ExposeSetHandlersServer(); container.ListToolsHandler = (p, c) => Task.FromResult(new ListToolsResult()); container.CallToolHandler = (p, c) => Task.FromResult(new CallToolResponse()); @@ -23,16 +22,33 @@ public class McpServerDelegatesTests container.SubscribeToResourcesHandler = (s, c) => Task.CompletedTask; container.UnsubscribeFromResourcesHandler = (s, c) => Task.CompletedTask; - container.Apply(server.Object); + container.Apply(server); - Assert.Equal(container.ListToolsHandler, server.Object.ListToolsHandler); - Assert.Equal(container.CallToolHandler, server.Object.CallToolHandler); - Assert.Equal(container.ListPromptsHandler, server.Object.ListPromptsHandler); - Assert.Equal(container.GetPromptHandler, server.Object.GetPromptHandler); - Assert.Equal(container.ListResourcesHandler, server.Object.ListResourcesHandler); - Assert.Equal(container.ReadResourceHandler, server.Object.ReadResourceHandler); - Assert.Equal(container.GetCompletionHandler, server.Object.GetCompletionHandler); - Assert.Equal(container.SubscribeToResourcesHandler, server.Object.SubscribeToResourcesHandler); - Assert.Equal(container.UnsubscribeFromResourcesHandler, server.Object.UnsubscribeFromResourcesHandler); + Assert.Equal(container.ListToolsHandler, server.Handlers[OperationNames.ListTools]); + Assert.Equal(container.CallToolHandler, server.Handlers[OperationNames.CallTool]); + Assert.Equal(container.ListPromptsHandler, server.Handlers[OperationNames.ListPrompts]); + Assert.Equal(container.GetPromptHandler, server.Handlers[OperationNames.GetPrompt]); + Assert.Equal(container.ListResourcesHandler, server.Handlers[OperationNames.ListResources]); + Assert.Equal(container.ReadResourceHandler, server.Handlers[OperationNames.ReadResource]); + Assert.Equal(container.GetCompletionHandler, server.Handlers[OperationNames.GetCompletion]); + Assert.Equal(container.SubscribeToResourcesHandler, server.Handlers[OperationNames.SubscribeToResources]); + Assert.Equal(container.UnsubscribeFromResourcesHandler, server.Handlers[OperationNames.UnsubscribeFromResources]); + } + + private sealed class ExposeSetHandlersServer : IMcpServer + { + public Dictionary Handlers = []; + + public void SetOperationHandler(string operationName, Delegate handler) => Handlers[operationName] = handler; + + public ValueTask DisposeAsync() => default; + public bool IsInitialized => throw new NotImplementedException(); + public ClientCapabilities? ClientCapabilities => throw new NotImplementedException(); + public Implementation? ClientInfo => throw new NotImplementedException(); + public IServiceProvider? ServiceProvider => throw new NotImplementedException(); + public void AddNotificationHandler(string method, Func handler) => throw new NotImplementedException(); + public Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where T : class => throw new NotImplementedException(); + public Task StartAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); } } diff --git a/tests/mcpdotnet.Tests/Server/McpServerTests.cs b/tests/mcpdotnet.Tests/Server/McpServerTests.cs index 0bf228b..67d5545 100644 --- a/tests/mcpdotnet.Tests/Server/McpServerTests.cs +++ b/tests/mcpdotnet.Tests/Server/McpServerTests.cs @@ -273,7 +273,7 @@ public class McpServerTests await Can_Handle_Requests(null, "completion/complete", configureServer: server => { - server.GetCompletionHandler = (request, ct) => + server.SetGetCompletionHandler((request, ct) => { return Task.FromResult(new CompleteResult { @@ -284,7 +284,7 @@ public class McpServerTests HasMore = true } }); - }; + }); }, assertResult: response => { @@ -305,13 +305,13 @@ public class McpServerTests await Can_Handle_Requests(new ServerCapabilities { Resources = new() }, "resources/list", configureServer: server => { - server.ListResourcesHandler = (request, ct) => + server.SetListResourcesHandler((request, ct) => { return Task.FromResult(new ListResourcesResult { Resources = [new() { Uri = "test", Name = "Test Resource" }] }); - }; + }); }, assertResult: response => @@ -337,13 +337,13 @@ public class McpServerTests await Can_Handle_Requests(new ServerCapabilities { Resources = new() }, "resources/read", configureServer: server => { - server.ReadResourceHandler = (request, ct) => + server.SetReadResourceHandler((request, ct) => { return Task.FromResult(new ReadResourceResult { Contents = [new() { Text = "test" }] }); - }; + }); }, assertResult: response => { @@ -368,13 +368,13 @@ public class McpServerTests await Can_Handle_Requests(new ServerCapabilities { Prompts = new() }, "prompts/list", configureServer: server => { - server.ListPromptsHandler = (request, ct) => + server.SetListPromptsHandler((request, ct) => { return Task.FromResult(new ListPromptsResult { Prompts = [new() { Name = "test" }] }); - }; + }); }, assertResult: response => { @@ -399,13 +399,13 @@ public class McpServerTests await Can_Handle_Requests(new ServerCapabilities { Prompts = new() }, "prompts/get", configureServer: server => { - server.GetPromptHandler = (request, ct) => + server.SetGetPromptHandler((request, ct) => { return Task.FromResult(new GetPromptResult { Description = "test" }); - }; + }); }, assertResult: response => { @@ -428,13 +428,13 @@ public class McpServerTests await Can_Handle_Requests(new ServerCapabilities { Tools = new() }, "tools/list", configureServer: server => { - server.ListToolsHandler = (request, ct) => + server.SetListToolsHandler((request, ct) => { return Task.FromResult(new ListToolsResult { Tools = [new() { Name = "test" }] }); - }; + }); }, assertResult: response => { @@ -458,13 +458,13 @@ public class McpServerTests await Can_Handle_Requests(new ServerCapabilities { Tools = new() }, "tools/call", configureServer: server => { - server.CallToolHandler = (request, ct) => + server.SetCallToolHandler((request, ct) => { return Task.FromResult(new CallToolResponse { Content = [new Content { Text = "test" }] }); - }; + }); }, assertResult: response => { diff --git a/tests/mcpdotnet.Tests/SseIntegrationTests.cs b/tests/mcpdotnet.Tests/SseIntegrationTests.cs index 5f961fef..7fdec8d 100644 --- a/tests/mcpdotnet.Tests/SseIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/SseIntegrationTests.cs @@ -87,11 +87,10 @@ public class SseIntegrationTests // Create client and run tests var client = await factory.GetClientAsync("everything"); - var tools = await client.ListToolsAsync(); + var tools = await client.ListToolsAsync().ToListAsync(); // assert - Assert.NotNull(tools); - Assert.NotEmpty(tools.Tools); + Assert.NotEmpty(tools); } [Fact] @@ -136,7 +135,7 @@ public class SseIntegrationTests // Set up the sampling handler int samplingHandlerCalls = 0; - client.SamplingHandler = (_, _) => + client.SetSamplingHandler((_, _) => { samplingHandlerCalls++; return Task.FromResult(new CreateMessageResult @@ -149,7 +148,7 @@ public class SseIntegrationTests Text = "Test response" } }); - }; + }); // Call the server's sampleLLM tool which should trigger our sampling handler var result = await client.CallToolAsync( @@ -253,7 +252,7 @@ public class SseIntegrationTests await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10)); var receivedNotification = new TaskCompletionSource(); - client.OnNotification("test/notification", (args) => + client.AddNotificationHandler("test/notification", (args) => { var msg = ((JsonElement?)args.Params)?.GetProperty("message").GetString(); receivedNotification.SetResult(msg); diff --git a/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj b/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj index 00cb111..c1d7664 100644 --- a/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj +++ b/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj @@ -16,6 +16,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive