From 709b2e0a74b83fad9537800f48b2d992b865a6cf Mon Sep 17 00:00:00 2001 From: Toni Wenzel Date: Mon, 10 Mar 2025 18:03:47 +0100 Subject: [PATCH] Fix some code smells (#39) * Fix some code smells * Fix build * Fix come issues * Revert some changes --- .github/workflows/ci.yml | 4 +- .../McpSessionScope.cs | 6 +- src/mcpdotnet/Client/McpClientFactory.cs | 16 +- src/mcpdotnet/Logging/Log.cs | 7 +- .../Protocol/Transport/SseClientTransport.cs | 2 +- .../Transport/StdioClientTransport.cs | 63 +++-- src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs | 155 ++++++----- .../Utils/Json/JsonRpcMessageConverter.cs | 11 +- .../AIFunctionUtilitiesTests.cs | 2 +- tests/mcpdotnet.TestServer/Program.cs | 249 +++++++++--------- .../ClientIntegrationTestFixture.cs | 1 + .../EverythingSseServerFixture.cs | 4 +- .../McpServerBuilderExtensionsToolsTests.cs | 3 + .../ServerIntegrationTestFixture.cs | 1 + 14 files changed, 275 insertions(+), 249 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be2565b..a99570a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,11 @@ jobs: - name: Setup Mono run: sudo apt-get install -y mono-devel - + - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: '20' + node-version: '20' - name: Install dependencies for tests run: npm install @modelcontextprotocol/server-everything diff --git a/src/McpDotNet.Extensions.AI/McpSessionScope.cs b/src/McpDotNet.Extensions.AI/McpSessionScope.cs index ca21cc3..5982b22 100644 --- a/src/McpDotNet.Extensions.AI/McpSessionScope.cs +++ b/src/McpDotNet.Extensions.AI/McpSessionScope.cs @@ -35,7 +35,7 @@ public sealed class McpSessionScope : IAsyncDisposable /// An options object with the client name and capabilities. Passed to the server. /// A logger factory for mcpdotnet. /// A session scope which will keep the connection alive until disposed. - public static async Task CreateAsync(McpServerConfig serverConfig, + public static async Task CreateAsync(McpServerConfig serverConfig, McpClientOptions? options = null, ILoggerFactory? loggerFactory = null) { @@ -62,7 +62,7 @@ public sealed class McpSessionScope : IAsyncDisposable /// An options object with the client name and capabilities. Passed to the servers. /// A logger factory for mcpdotnet. /// A session scope which keep the connections alive until disposed. - public static async Task CreateAsync(IEnumerable serverConfigs, + public static async Task CreateAsync(IEnumerable serverConfigs, McpClientOptions? options = null, ILoggerFactory? loggerFactory = null) { @@ -106,5 +106,7 @@ public sealed class McpSessionScope : IAsyncDisposable } _clients.Clear(); Tools = []; + + GC.SuppressFinalize(this); } } \ No newline at end of file diff --git a/src/mcpdotnet/Client/McpClientFactory.cs b/src/mcpdotnet/Client/McpClientFactory.cs index 99699b3..6f5e7da 100644 --- a/src/mcpdotnet/Client/McpClientFactory.cs +++ b/src/mcpdotnet/Client/McpClientFactory.cs @@ -1,8 +1,8 @@ -using McpDotNet.Configuration; +using System.Runtime.InteropServices; +using McpDotNet.Configuration; using McpDotNet.Logging; using McpDotNet.Protocol.Transport; using Microsoft.Extensions.Logging; -using System.Runtime.InteropServices; namespace McpDotNet.Client; /// @@ -13,6 +13,8 @@ namespace McpDotNet.Client; /// public class McpClientFactory { + private const string ARGUMENTS_OPTIONS_KEY = "arguments"; + private const string COMMAND_OPTIONS_KEY = "command"; private readonly Dictionary _serverConfigs; private readonly McpClientOptions _clientOptions; private readonly Dictionary _clients = []; @@ -103,7 +105,7 @@ public class McpClientFactory return new StdioClientTransport(new StdioClientTransportOptions { Command = GetCommand(config), - Arguments = config.TransportOptions?.GetValueOrDefault("arguments")?.Split(' '), + Arguments = config.TransportOptions?.GetValueOrDefault(ARGUMENTS_OPTIONS_KEY)?.Split(' '), WorkingDirectory = config.TransportOptions?.GetValueOrDefault("workingDirectory"), EnvironmentVariables = config.TransportOptions? .Where(kv => kv.Key.StartsWith("env:", StringComparison.Ordinal)) @@ -142,7 +144,7 @@ public class McpClientFactory private static string GetCommand(McpServerConfig config) { - var command = config.TransportOptions?.GetValueOrDefault("command"); + var command = config.TransportOptions?.GetValueOrDefault(COMMAND_OPTIONS_KEY); if (string.IsNullOrEmpty(command)) return config.Location!; @@ -158,7 +160,7 @@ public class McpClientFactory string endpointName = $"Client ({config.Id}: {config.Name})"; // If the command is empty or already contains cmd.exe, we don't need to do anything - var command = config.TransportOptions?.GetValueOrDefault("command"); + var command = config.TransportOptions?.GetValueOrDefault(COMMAND_OPTIONS_KEY); if (string.IsNullOrEmpty(command) || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -174,8 +176,8 @@ public class McpClientFactory // If the command is not empty and does not contain cmd.exe, we need to inject /c {command} (usually npx or uvicorn) // This is because the stdio transport will not work correctly if the command is not run in a shell - _logger.PromotingCommandToShellArgumentForStdio(endpointName, command, config.TransportOptions!.GetValueOrDefault("arguments") ?? ""); - config.TransportOptions!["arguments"] = config.TransportOptions.TryGetValue("arguments", out var args) + _logger.PromotingCommandToShellArgumentForStdio(endpointName, command, config.TransportOptions!.GetValueOrDefault(ARGUMENTS_OPTIONS_KEY) ?? ""); + config.TransportOptions![ARGUMENTS_OPTIONS_KEY] = config.TransportOptions.TryGetValue(ARGUMENTS_OPTIONS_KEY, out var args) ? $"/c {command} {args}" : $"/c {command}"; } diff --git a/src/mcpdotnet/Logging/Log.cs b/src/mcpdotnet/Logging/Log.cs index 9a38b83..fe5f868 100644 --- a/src/mcpdotnet/Logging/Log.cs +++ b/src/mcpdotnet/Logging/Log.cs @@ -11,8 +11,8 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Debug, Message = "Client {clientId} initializing connection to server {serverId}")] internal static partial void ClientConnecting(this ILogger logger, string clientId, string serverId); - [LoggerMessage(Level = LogLevel.Information, Message = "Server capabilities received: {capabilities}")] - internal static partial void ServerCapabilitiesReceived(this ILogger logger, string capabilities); + [LoggerMessage(Level = LogLevel.Information, Message = "Server {endpointName} capabilities received: {capabilities}, server info: {serverInfo}")] + internal static partial void ServerCapabilitiesReceived(this ILogger logger, string endpointName, string capabilities, string serverInfo); [LoggerMessage(Level = LogLevel.Warning, Message = "Request {requestId} timed out")] internal static partial void RequestTimeout(this ILogger logger, int requestId); @@ -74,9 +74,6 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Error, Message = "Client server {endpointName} initialization error")] internal static partial void ClientInitializationError(this ILogger logger, string endpointName, Exception exception); - [LoggerMessage(Level = LogLevel.Information, Message = "Server {endpointName} capabilities received: {capabilities}, server info: {serverInfo}")] - internal static partial void ServerCapabilitiesReceived(this ILogger logger, string endpointName, string capabilities, string serverInfo); - [LoggerMessage(Level = LogLevel.Error, Message = "Server {endpointName} protocol version mismatch, expected {expected}, received {received}")] internal static partial void ServerProtocolVersionMismatch(this ILogger logger, string endpointName, string expected, string received); diff --git a/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs index bb1f49c..cb70777 100644 --- a/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace McpDotNet.Protocol.Transport; /// -/// +/// The ServerSideEvents client transport implementation /// public sealed class SseClientTransport : TransportBase, IClientTransport { diff --git a/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs b/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs index d70d16a..5c42139 100644 --- a/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs @@ -1,12 +1,12 @@ -using McpDotNet.Configuration; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using McpDotNet.Configuration; using McpDotNet.Logging; using McpDotNet.Protocol.Messages; using McpDotNet.Utils.Json; using Microsoft.Extensions.Logging; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.Json; namespace McpDotNet.Protocol.Transport; @@ -186,30 +186,7 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport _logger.TransportReceivedMessage(EndpointName, line); - try - { - var message = JsonSerializer.Deserialize(line, _jsonOptions); - if (message != null) - { - string messageId = "(no id)"; - if (message is IJsonRpcMessageWithId messageWithId) - { - messageId = messageWithId.Id.ToString(); - } - _logger.TransportReceivedMessageParsed(EndpointName, messageId); - await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false); - _logger.TransportMessageWritten(EndpointName, messageId); - } - else - { - _logger.TransportMessageParseUnexpectedType(EndpointName, line); - } - } - catch (JsonException ex) - { - _logger.TransportMessageParseFailed(EndpointName, line, ex); - // Continue reading even if we fail to parse a message - } + await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false); } _logger.TransportExitingReadMessagesLoop(EndpointName); } @@ -228,6 +205,34 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport } } + private async Task ProcessMessageAsync(string line, CancellationToken cancellationToken) + { + try + { + var message = JsonSerializer.Deserialize(line, _jsonOptions); + if (message != null) + { + string messageId = "(no id)"; + if (message is IJsonRpcMessageWithId messageWithId) + { + messageId = messageWithId.Id.ToString(); + } + _logger.TransportReceivedMessageParsed(EndpointName, messageId); + await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false); + _logger.TransportMessageWritten(EndpointName, messageId); + } + else + { + _logger.TransportMessageParseUnexpectedType(EndpointName, line); + } + } + catch (JsonException ex) + { + _logger.TransportMessageParseFailed(EndpointName, line, ex); + // Continue reading even if we fail to parse a message + } + } + private async Task CleanupAsync(CancellationToken cancellationToken) { _logger.TransportCleaningUp(EndpointName); diff --git a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs index 3a406f2..7c4cff1 100644 --- a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs +++ b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs @@ -102,77 +102,90 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable switch (message) { case JsonRpcRequest request: - if (_requestHandlers.TryGetValue(request.Method, out var handler)) - { - try - { - _logger.RequestHandlerCalled(EndpointName, request.Method); - var result = await handler(request).ConfigureAwait(false); - _logger.RequestHandlerCompleted(EndpointName, request.Method); - await _transport.SendMessageAsync(new JsonRpcResponse - { - Id = request.Id, - JsonRpc = "2.0", - Result = result - }, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.RequestHandlerError(EndpointName, request.Method, ex); - // Send error response - await _transport.SendMessageAsync(new JsonRpcError - { - Id = request.Id, - JsonRpc = "2.0", - Error = new JsonRpcErrorDetail - { - Code = -32000, // Implementation defined error - Message = ex.Message - } - }, cancellationToken).ConfigureAwait(false); - } - } - else - { - _logger.NoHandlerFoundForRequest(EndpointName, request.Method); - } + await HandleRequest(request, cancellationToken).ConfigureAwait(false); break; case IJsonRpcMessageWithId messageWithId: - if (_pendingRequests.TryRemove(messageWithId.Id, out var tcs)) - { - _logger.ResponseMatchedPendingRequest(EndpointName, messageWithId.Id.ToString()); - tcs.TrySetResult(message); - } - else - { - _logger.NoRequestFoundForMessageWithId(EndpointName, messageWithId.Id.ToString()); - } + HandleMessageWithId(message, messageWithId); break; - case JsonRpcNotification notification: - if (_notificationHandlers.TryGetValue(notification.Method, out var handlers)) - { - foreach (var notificationHandler in handlers) - { - try - { - await notificationHandler(notification).ConfigureAwait(false); - } - catch (Exception ex) - { - // Log handler error but continue processing - _logger.NotificationHandlerError(EndpointName, notification.Method, ex); - } - } - } + await HandleNotification(notification).ConfigureAwait(false); break; - default: _logger.EndpointHandlerUnexpectedMessageType(EndpointName, message.GetType().Name); break; } } + private async Task HandleNotification(JsonRpcNotification notification) + { + if (_notificationHandlers.TryGetValue(notification.Method, out var handlers)) + { + foreach (var notificationHandler in handlers) + { + try + { + await notificationHandler(notification).ConfigureAwait(false); + } + catch (Exception ex) + { + // Log handler error but continue processing + _logger.NotificationHandlerError(EndpointName, notification.Method, ex); + } + } + } + } + + private void HandleMessageWithId(IJsonRpcMessage message, IJsonRpcMessageWithId messageWithId) + { + if (_pendingRequests.TryRemove(messageWithId.Id, out var tcs)) + { + _logger.ResponseMatchedPendingRequest(EndpointName, messageWithId.Id.ToString()); + tcs.TrySetResult(message); + } + else + { + _logger.NoRequestFoundForMessageWithId(EndpointName, messageWithId.Id.ToString()); + } + } + + private async Task HandleRequest(JsonRpcRequest request, CancellationToken cancellationToken) + { + if (_requestHandlers.TryGetValue(request.Method, out var handler)) + { + try + { + _logger.RequestHandlerCalled(EndpointName, request.Method); + var result = await handler(request).ConfigureAwait(false); + _logger.RequestHandlerCompleted(EndpointName, request.Method); + await _transport.SendMessageAsync(new JsonRpcResponse + { + Id = request.Id, + JsonRpc = "2.0", + Result = result + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.RequestHandlerError(EndpointName, request.Method, ex); + // Send error response + await _transport.SendMessageAsync(new JsonRpcError + { + Id = request.Id, + JsonRpc = "2.0", + Error = new JsonRpcErrorDetail + { + Code = -32000, // Implementation defined error + Message = ex.Message + } + }, cancellationToken).ConfigureAwait(false); + } + } + else + { + _logger.NoHandlerFoundForRequest(EndpointName, request.Method); + } + } + /// /// Sends a generic JSON-RPC request to the server. /// It is strongly recommended use the capability-specific methods instead of this one. @@ -257,7 +270,7 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable { if (!_transport.IsConnected) { - // TODO: _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); + _logger.ClientNotConnected(EndpointName); throw new McpClientException("Transport is not connected"); } @@ -265,13 +278,10 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable // Log if enabled if (_logger.IsEnabled(LogLevel.Debug)) - { - // TODO: _logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification)); - } + _logger.SendingNotificationPayload(EndpointName, JsonSerializer.Serialize(notification)); // Log basic info - // TODO: _logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, method); - + _logger.SendingNotification(EndpointName, method); await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); } @@ -285,21 +295,22 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable { if (!_transport.IsConnected) { - // TODO: _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); + _logger.ClientNotConnected(EndpointName); throw new McpClientException("Transport is not connected"); } + var notification = new JsonRpcNotification { Method = method, Params = parameters }; + // Log if enabled if (_logger.IsEnabled(LogLevel.Debug)) - { - // TODO: _logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification)); - } + _logger.SendingNotificationPayload(EndpointName, JsonSerializer.Serialize(notification)); + // Log basic info - // TODO: _logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, method); + _logger.SendingNotification(EndpointName, method); await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); } @@ -312,7 +323,7 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable { if (!_transport.IsConnected) { - // TODO: _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); + _logger.ClientNotConnected(EndpointName); throw new McpClientException("Transport is not connected"); } diff --git a/src/mcpdotnet/Utils/Json/JsonRpcMessageConverter.cs b/src/mcpdotnet/Utils/Json/JsonRpcMessageConverter.cs index 32b49c2..db84e4f 100644 --- a/src/mcpdotnet/Utils/Json/JsonRpcMessageConverter.cs +++ b/src/mcpdotnet/Utils/Json/JsonRpcMessageConverter.cs @@ -1,8 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using McpDotNet.Protocol.Messages; -using Microsoft.Extensions.Logging; -using McpDotNet.Logging; namespace McpDotNet.Utils.Json; @@ -44,21 +42,24 @@ internal sealed class JsonRpcMessageConverter : JsonConverter { return JsonSerializer.Deserialize(rawText, options); } + // Messages with a result property are success responses - else if (root.TryGetProperty("result", out _)) + if (root.TryGetProperty("result", out _)) { return JsonSerializer.Deserialize(rawText, options); } throw new JsonException("Response must have either result or error"); } + // Messages with a method but no id are notifications - else if (hasMethod && !hasId) + if (hasMethod && !hasId) { return JsonSerializer.Deserialize(rawText, options); } + // Messages with both method and id are requests - else if (hasMethod && hasId) + if (hasMethod && hasId) { return JsonSerializer.Deserialize(rawText, options); } diff --git a/tests/McpDotNet.Extensions.AI.Tests/AIFunctionUtilitiesTests.cs b/tests/McpDotNet.Extensions.AI.Tests/AIFunctionUtilitiesTests.cs index e3a8e0f..b5c115b 100644 --- a/tests/McpDotNet.Extensions.AI.Tests/AIFunctionUtilitiesTests.cs +++ b/tests/McpDotNet.Extensions.AI.Tests/AIFunctionUtilitiesTests.cs @@ -1,5 +1,5 @@ -using McpDotNet.Protocol.Types; using System.Text.Json; +using McpDotNet.Protocol.Types; namespace McpDotNet.Extensions.AI.Tests; diff --git a/tests/mcpdotnet.TestServer/Program.cs b/tests/mcpdotnet.TestServer/Program.cs index 74cb874..38b6e94 100644 --- a/tests/mcpdotnet.TestServer/Program.cs +++ b/tests/mcpdotnet.TestServer/Program.cs @@ -48,30 +48,25 @@ internal static class Program Log.Logger.Information("Server object created, registering handlers."); - #region Helped method - static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100) + ConfigureTools(server); + ConfigureResources(server); + ConfigurePrompts(server); + + Log.Logger.Information("Server initialized."); + + await server.StartAsync(); + + Log.Logger.Information("Server started."); + + // Run until process is stopped by the client (parent process) + while (true) // NOSONAR { - return new CreateMessageRequestParams() - { - Messages = [new SamplingMessage() - { - Role = Role.User, - Content = new Content() - { - Type = "text", - Text = $"Resource {uri} context: {context}" - } - }], - SystemPrompt = "You are a helpful test server.", - MaxTokens = maxTokens, - Temperature = 0.7f, - IncludeContext = ContextInclusion.ThisServer - }; + await Task.Delay(1000); } - #endregion - - #region Tools + } + private static void ConfigureTools(IMcpServer server) + { server.ListToolsHandler = (request, cancellationToken) => { return Task.FromResult(new ListToolsResult() @@ -143,97 +138,10 @@ internal static class Program throw new McpServerException($"Unknown tool: {request.Params?.Name}"); } }; - #endregion + } - #region Resources - List resources = []; - List resourceContents = []; - for (int i = 0; i < 100; ++i) - { - string uri = $"test://static/resource/{i + 1}"; - if (i % 2 == 0) - { - resources.Add(new Resource() - { - Uri = uri, - Name = $"Resource {i + 1}", - MimeType = "text/plain" - }); - resourceContents.Add(new ResourceContents() - { - Uri = uri, - MimeType = "text/plain", - Text = $"Resource {i + 1}: This is a plaintext resource" - }); - } - else - { - var buffer = Encoding.UTF8.GetBytes($"Resource {i + 1}: This is a base64 blob"); - resources.Add(new Resource() - { - Uri = uri, - Name = $"Resource {i + 1}", - MimeType = "application/octet-stream" - }); - resourceContents.Add(new ResourceContents() - { - Uri = uri, - MimeType = "application/octet-stream", - Blob = Convert.ToBase64String(buffer) - }); - } - } - - const int pageSize = 10; - - server.ListResourcesHandler = (request, cancellationToken) => - { - int startIndex = 0; - request ??= new(server, new()); - if (request.Params?.Cursor is not null) - { - try - { - var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Params.Cursor)); - startIndex = Convert.ToInt32(startIndexAsString); - } - catch - { - throw new McpServerException("Invalid cursor"); - } - } - - int endIndex = Math.Min(startIndex + pageSize, resources.Count); - string? nextCursor = null; - - if (endIndex < resources.Count) - { - nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(endIndex.ToString())); - } - return Task.FromResult(new ListResourcesResult() - { - NextCursor = nextCursor, - Resources = resources.GetRange(startIndex, endIndex - startIndex) - }); - }; - - server.ReadResourceHandler = (request, cancellationToken) => - { - if (request.Params?.Uri is null) - { - throw new McpServerException("Missing required argument 'uri'"); - } - ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) - ?? throw new McpServerException("Resource not found"); - - return Task.FromResult(new ReadResourceResult() - { - Contents = [contents] - }); - }; - #endregion - - #region Prompts + private static void ConfigurePrompts(IMcpServer server) + { server.ListPromptsHandler = (request, cancellationToken) => { return Task.FromResult(new ListPromptsResult() @@ -326,22 +234,115 @@ internal static class Program Messages = messages }); }; - #endregion + } - #region Sampling - #endregion - - Log.Logger.Information("Server initialized."); - - await server.StartAsync(); - - Log.Logger.Information("Server started."); - - // Run until process is stopped by the client (parent process) - while (true) // NOSONAR + private static void ConfigureResources(IMcpServer server) + { + List resources = []; + List resourceContents = []; + for (int i = 0; i < 100; ++i) { - await Task.Delay(1000); + string uri = $"test://static/resource/{i + 1}"; + if (i % 2 == 0) + { + resources.Add(new Resource() + { + Uri = uri, + Name = $"Resource {i + 1}", + MimeType = "text/plain" + }); + resourceContents.Add(new ResourceContents() + { + Uri = uri, + MimeType = "text/plain", + Text = $"Resource {i + 1}: This is a plaintext resource" + }); + } + else + { + var buffer = Encoding.UTF8.GetBytes($"Resource {i + 1}: This is a base64 blob"); + resources.Add(new Resource() + { + Uri = uri, + Name = $"Resource {i + 1}", + MimeType = "application/octet-stream" + }); + resourceContents.Add(new ResourceContents() + { + Uri = uri, + MimeType = "application/octet-stream", + Blob = Convert.ToBase64String(buffer) + }); + } } + + const int pageSize = 10; + + server.ListResourcesHandler = (request, cancellationToken) => + { + int startIndex = 0; + request ??= new(server, new()); + if (request.Params?.Cursor is not null) + { + try + { + var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Params.Cursor)); + startIndex = Convert.ToInt32(startIndexAsString); + } + catch + { + throw new McpServerException("Invalid cursor"); + } + } + + int endIndex = Math.Min(startIndex + pageSize, resources.Count); + string? nextCursor = null; + + if (endIndex < resources.Count) + { + nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(endIndex.ToString())); + } + return Task.FromResult(new ListResourcesResult() + { + NextCursor = nextCursor, + Resources = resources.GetRange(startIndex, endIndex - startIndex) + }); + }; + + server.ReadResourceHandler = (request, cancellationToken) => + { + if (request.Params?.Uri is null) + { + throw new McpServerException("Missing required argument 'uri'"); + } + ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) + ?? throw new McpServerException("Resource not found"); + + return Task.FromResult(new ReadResourceResult() + { + Contents = [contents] + }); + }; + } + + static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100) + { + return new CreateMessageRequestParams() + { + Messages = [new SamplingMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = $"Resource {uri} context: {context}" + } + }], + SystemPrompt = "You are a helpful test server.", + MaxTokens = maxTokens, + Temperature = 0.7f, + IncludeContext = ContextInclusion.ThisServer + }; } const string MCP_TINY_IMAGE = diff --git a/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs b/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs index 8d79ec0..3c35fb7 100644 --- a/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs +++ b/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs @@ -47,5 +47,6 @@ public class ClientIntegrationTestFixture : IDisposable public void Dispose() { LoggerFactory?.Dispose(); + GC.SuppressFinalize(this); } } \ No newline at end of file diff --git a/tests/mcpdotnet.Tests/EverythingSseServerFixture.cs b/tests/mcpdotnet.Tests/EverythingSseServerFixture.cs index ac05c8d..16d8f33 100644 --- a/tests/mcpdotnet.Tests/EverythingSseServerFixture.cs +++ b/tests/mcpdotnet.Tests/EverythingSseServerFixture.cs @@ -60,7 +60,9 @@ public class EverythingSseServerFixture : IAsyncDisposable catch (Exception ex) { // Log the exception but don't throw - Console.Error.WriteLine($"Error stopping Docker container: {ex.Message}"); + await Console.Error.WriteLineAsync($"Error stopping Docker container: {ex.Message}"); } + + GC.SuppressFinalize(this); } } \ No newline at end of file diff --git a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs index 0c2eca6..57b99a9 100644 --- a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs @@ -153,10 +153,13 @@ public class McpServerBuilderExtensionsToolsTests { public EchoToolWithDi(IDependendService service) { + } [McpTool, Description("Echoes the input back to the client.")] +#pragma warning disable CA1822 // Mark members as static public Task Echo(string message) +#pragma warning restore CA1822 // Mark members as static { return Task.FromResult("hello " + message); } diff --git a/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs b/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs index 73a25ba..2030373 100644 --- a/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs +++ b/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs @@ -52,5 +52,6 @@ public class ServerIntegrationTestFixture : IDisposable var client = Factory.GetClientAsync("test_server").Result; client.DisposeAsync().AsTask().Wait(); LoggerFactory?.Dispose(); + GC.SuppressFinalize(this); } } \ No newline at end of file