From 8fe797e1f374da3e72ac785653c8e89855401861 Mon Sep 17 00:00:00 2001 From: Peder Holdgaard Pedersen <127606677+PederHP@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:21:26 +0100 Subject: [PATCH] Server support (stdio) (#19) * Server support (stdio) * TestServer project * Refactored McpClient class to extract shared endpoint logic (with McpServer) to a base class, McpJsonRpcEndpoint. * Improved xmldoc comments for protocol types, mirroring specification schema comments. * Update README.MD --- README.MD | 78 ++- mcpdotnet.sln | 6 + .../tools/ToolsConsole/McpAIFunction.cs | 2 +- src/mcpdotnet/Client/IMcpClient.cs | 6 +- src/mcpdotnet/Client/McpClient.cs | 376 ++------------ src/mcpdotnet/Client/McpClientFactory.cs | 37 +- src/mcpdotnet/Client/McpClientOptions.cs | 2 +- .../Configuration/McpServerConfig.cs | 1 + src/mcpdotnet/Logging/Log.cs | 480 +++++++++++------- .../Protocol/Transport/IClientTransport.cs | 13 + .../Protocol/Transport/IServerTransport.cs | 13 + .../{IMcpTransport.cs => ITransport.cs} | 9 +- ...{SseTransport.cs => SseClientTransport.cs} | 54 +- ...ptions.cs => SseClientTransportOptions.cs} | 2 +- ...ioTransport.cs => StdioClientTransport.cs} | 83 +-- ...ions.cs => StdioClientTransportOptions.cs} | 2 +- .../Transport/StdioServerTransport.cs | 195 +++++++ .../Protocol/Transport/TransportBase.cs | 13 +- src/mcpdotnet/Protocol/Types/Annotations.cs | 5 +- src/mcpdotnet/Protocol/Types/Argument.cs | 1 + .../Protocol/Types/CallToolRequestParams.cs | 20 + .../Protocol/Types/CallToolResponse.cs | 13 +- src/mcpdotnet/Protocol/Types/Capabilities.cs | 10 +- .../Protocol/Types/CompleteRequestParams.cs | 20 + src/mcpdotnet/Protocol/Types/Completion.cs | 3 +- src/mcpdotnet/Protocol/Types/Content.cs | 2 +- .../Protocol/Types/ContextInclusion.cs | 1 + .../Types/CreateMessageRequestParams.cs | 1 + .../Protocol/Types/CreateMessageResult.cs | 1 + .../Protocol/Types/GetPromptRequestParams.cs | 20 + .../Protocol/Types/GetPromptResult.cs | 2 +- .../Protocol/Types/Implementation.cs | 1 + .../Protocol/Types/InitializeRequestParams.cs | 28 + .../Protocol/Types/InitializeResult.cs | 1 + src/mcpdotnet/Protocol/Types/JsonSchema.cs | 2 +- .../Protocol/Types/JsonSchemaProperty.cs | 4 +- .../Types/ListPromptsRequestParams.cs | 15 + .../Protocol/Types/ListPromptsResult.cs | 2 +- .../Types/ListResourcesRequestParams.cs | 15 + .../Protocol/Types/ListResourcesResult.cs | 2 +- .../Protocol/Types/ListRootsRequestParams.cs | 1 + .../Protocol/Types/ListRootsResult.cs | 1 + .../Protocol/Types/ListToolsRequestParams.cs | 15 + .../Protocol/Types/ListToolsResult.cs | 2 +- src/mcpdotnet/Protocol/Types/ModelHint.cs | 1 + .../Protocol/Types/ModelPreferences.cs | 1 + src/mcpdotnet/Protocol/Types/PingResult.cs | 9 + src/mcpdotnet/Protocol/Types/Prompt.cs | 2 +- .../Protocol/Types/PromptArgument.cs | 2 +- src/mcpdotnet/Protocol/Types/PromptMessage.cs | 2 +- .../Types/ReadResourceRequestParams.cs | 14 + .../Protocol/Types/ReadResourceResult.cs | 2 +- src/mcpdotnet/Protocol/Types/Reference.cs | 1 + .../Protocol/Types/ResourceContents.cs | 1 + src/mcpdotnet/Protocol/Types/Resources.cs | 1 + src/mcpdotnet/Protocol/Types/Role.cs | 1 + src/mcpdotnet/Protocol/Types/Root.cs | 1 + .../Protocol/Types/SamplingMessage.cs | 1 + .../Protocol/Types/ServerCapabilities.cs | 1 + .../Types/SubscribeToResourceRequestParams.cs | 14 + src/mcpdotnet/Protocol/Types/Tool.cs | 4 +- .../UnsubscribeFromResourceRequestParams.cs | 14 + src/mcpdotnet/Server/IMcpServer.cs | 121 +++++ src/mcpdotnet/Server/McpServer.cs | 279 ++++++++++ src/mcpdotnet/Server/McpServerException.cs | 41 ++ src/mcpdotnet/Server/McpServerFactory.cs | 49 ++ src/mcpdotnet/Server/McpServerOptions.cs | 32 ++ src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs | 425 ++++++++++++++++ src/mcpdotnet/mcpdotnet.csproj | 2 +- tests/mcpdotnet.TestServer/Program.cs | 349 +++++++++++++ .../mcpdotnet.TestServer.csproj | 21 + ...ure.cs => ClientIntegrationTestFixture.cs} | 4 +- ...tionTests.cs => ClientIntegrationTests.cs} | 45 +- .../mcpdotnet.Tests/McpClientFactoryTests.cs | 18 +- .../ServerIntegrationTestFixture.cs | 53 ++ .../mcpdotnet.Tests/ServerIntegrationTests.cs | 239 +++++++++ tests/mcpdotnet.Tests/SseIntegrationTests.cs | 2 +- tests/mcpdotnet.Tests/SseTransportTests.cs | 8 +- tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj | 9 +- 79 files changed, 2669 insertions(+), 660 deletions(-) create mode 100644 src/mcpdotnet/Protocol/Transport/IClientTransport.cs create mode 100644 src/mcpdotnet/Protocol/Transport/IServerTransport.cs rename src/mcpdotnet/Protocol/Transport/{IMcpTransport.cs => ITransport.cs} (74%) rename src/mcpdotnet/Protocol/Transport/{SseTransport.cs => SseClientTransport.cs} (79%) rename src/mcpdotnet/Protocol/Transport/{SseTransportOptions.cs => SseClientTransportOptions.cs} (95%) rename src/mcpdotnet/Protocol/Transport/{StdioTransport.cs => StdioClientTransport.cs} (68%) rename src/mcpdotnet/Protocol/Transport/{StdioTransportOptions.cs => StdioClientTransportOptions.cs} (95%) create mode 100644 src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs create mode 100644 src/mcpdotnet/Protocol/Types/CallToolRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/CompleteRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/GetPromptRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/InitializeRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/ListPromptsRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/ListResourcesRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/ListToolsRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/PingResult.cs create mode 100644 src/mcpdotnet/Protocol/Types/ReadResourceRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/SubscribeToResourceRequestParams.cs create mode 100644 src/mcpdotnet/Protocol/Types/UnsubscribeFromResourceRequestParams.cs create mode 100644 src/mcpdotnet/Server/IMcpServer.cs create mode 100644 src/mcpdotnet/Server/McpServer.cs create mode 100644 src/mcpdotnet/Server/McpServerException.cs create mode 100644 src/mcpdotnet/Server/McpServerFactory.cs create mode 100644 src/mcpdotnet/Server/McpServerOptions.cs create mode 100644 src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs create mode 100644 tests/mcpdotnet.TestServer/Program.cs create mode 100644 tests/mcpdotnet.TestServer/mcpdotnet.TestServer.csproj rename tests/mcpdotnet.Tests/{IntegrationTestFixture.cs => ClientIntegrationTestFixture.cs} (93%) rename tests/mcpdotnet.Tests/{IntegrationTests.cs => ClientIntegrationTests.cs} (90%) create mode 100644 tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs create mode 100644 tests/mcpdotnet.Tests/ServerIntegrationTests.cs diff --git a/README.MD b/README.MD index d5b26a4..903be4b 100644 --- a/README.MD +++ b/README.MD @@ -27,7 +27,7 @@ This library aims to provide a clean, specification-compliant implementation of - Comprehensive logging support - Compatible with .NET 8.0 and later -## Getting Started +## Getting Started (Client) To use mcpdotnet, first install it via NuGet: @@ -85,6 +85,75 @@ You can find samples demonstrating how to use mcpdotnet with an LLM SDK in the [ Additional examples and documentation will be added as in the near future. +Remember you can connect to any MCP server, not just ones created using mcpdotnet. The protocol is designed to be server-agnostic, so you can use this library to connect to any compliant server. + +## Getting Started (Server) + +Here is an example of how to create an MCP server with a single tool. You can also refer to the TestServer in the tests folder, which implements a wider range of capabilities. + +```csharp +McpServerOptions options = new McpServerOptions() +{ + ServerInfo = new Implementation() { Name = "MyServer", Version = "1.0.0" }, + Capabilities = new ServerCapabilities() + { + Tools = new() + } +}; +McpServerFactory factory = new McpServerFactory(new StdioServerTransport("MyServer", loggerFactory), options, loggerFactory); +IMcpServer server = factory.CreateServer(); + +server.ListToolsHandler = (request, cancellationToken) => +{ + return Task.FromResult(new ListToolsResult() + { + Tools = + [ + new Tool() + { + Name = "echo", + Description = "Echoes the input back to the client.", + InputSchema = new JsonSchema() + { + Type = "object", + Properties = new Dictionary() + { + ["message"] = new JsonSchemaProperty() { Type = "string", Description = "The input to echo back." } + } + }, + } + ] + }); +}; + +server.CallToolHandler = async (request, cancellationToken) => +{ + if (request.Name == "echo") + { + if (request.Arguments is null || !request.Arguments.TryGetValue("message", out var message)) + { + throw new McpServerException("Missing required argument 'message'"); + } + return new CallToolResponse() + { + Content = [new Content() { Text = "Echo: " + message.ToString(), Type = "text" }] + }; + } + else + { + throw new McpServerException($"Unknown tool: {request.Name}"); + } +}; + +await server.StartAsync(); + +// Run until process is stopped by the client (parent process) +while (true) +{ + await Task.Delay(1000); +} +``` + ## Roadmap - Expand documentation with detailed guides for: @@ -93,8 +162,11 @@ Additional examples and documentation will be added as in the near future. - Error handling and recovery - Increase test coverage - Add additional samples and examples -- Performance optimization and profiling +- Performance optimization +- SSE server support +- Authentication +- Linux/Mac support for stdio transport ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/mcpdotnet.sln b/mcpdotnet.sln index 11078af..dd6c82d 100644 --- a/mcpdotnet.sln +++ b/mcpdotnet.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MEAIToolsConsole", "samples EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnthropicToolsConsole", "samples\anthropic\tools\ToolsConsole\AnthropicToolsConsole.csproj", "{CA0BB450-1903-2F92-A68D-1285976551D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mcpdotnet.TestServer", "tests\mcpdotnet.TestServer\mcpdotnet.TestServer.csproj", "{7C229573-A085-4ECC-8131-958CDA2BE731}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {CA0BB450-1903-2F92-A68D-1285976551D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {CA0BB450-1903-2F92-A68D-1285976551D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {CA0BB450-1903-2F92-A68D-1285976551D6}.Release|Any CPU.Build.0 = Release|Any CPU + {7C229573-A085-4ECC-8131-958CDA2BE731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C229573-A085-4ECC-8131-958CDA2BE731}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C229573-A085-4ECC-8131-958CDA2BE731}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C229573-A085-4ECC-8131-958CDA2BE731}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/microsoft.extensions.ai/tools/ToolsConsole/McpAIFunction.cs b/samples/microsoft.extensions.ai/tools/ToolsConsole/McpAIFunction.cs index 7a22519..de65d3c 100644 --- a/samples/microsoft.extensions.ai/tools/ToolsConsole/McpAIFunction.cs +++ b/samples/microsoft.extensions.ai/tools/ToolsConsole/McpAIFunction.cs @@ -43,7 +43,7 @@ public class McpAIFunction : AIFunction // Call the tool through mcpdotnet var result = await _client.CallToolAsync( _tool.Name, - argDict.Count == 0 ? new(): argDict, + argDict.Count == 0 ? new() : argDict, cancellationToken: cancellationToken ); diff --git a/src/mcpdotnet/Client/IMcpClient.cs b/src/mcpdotnet/Client/IMcpClient.cs index 4cdfc5b..9d1f137 100644 --- a/src/mcpdotnet/Client/IMcpClient.cs +++ b/src/mcpdotnet/Client/IMcpClient.cs @@ -71,7 +71,7 @@ public interface IMcpClient : IAsyncDisposable /// Optional arguments for the tool. /// A token to cancel the operation. /// A task containing the tool's response. - Task CallToolAsync(string toolName, Dictionary? arguments = null, CancellationToken cancellationToken = default); + Task CallToolAsync(string toolName, Dictionary arguments, CancellationToken cancellationToken = default); /// /// Retrieves a list of available prompts from the server. @@ -134,8 +134,8 @@ public interface IMcpClient : IAsyncDisposable /// /// Sends a generic JSON-RPC request to the server. - /// NB! This is a temporary method that is available to send not yet implemented feature messages. - /// Once all MCP features are implemented this will be made private, as it is purely a convenience for those who wish to implement features ahead of the library. + /// 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 JSON-RPC request to send. diff --git a/src/mcpdotnet/Client/McpClient.cs b/src/mcpdotnet/Client/McpClient.cs index 71b07e9..0ffd16d 100644 --- a/src/mcpdotnet/Client/McpClient.cs +++ b/src/mcpdotnet/Client/McpClient.cs @@ -1,35 +1,23 @@  using McpDotNet.Protocol.Types; using McpDotNet.Protocol.Transport; -using System.Collections.Concurrent; using McpDotNet.Protocol.Messages; using System.Text.Json; -using McpDotNet.Utils.Json; using Microsoft.Extensions.Logging; using McpDotNet.Logging; using McpDotNet.Configuration; +using McpDotNet.Shared; namespace McpDotNet.Client; /// -internal class McpClient : IMcpClient +internal class McpClient : McpJsonRpcEndpoint, IMcpClient { - private readonly IMcpTransport _transport; private readonly McpClientOptions _options; - private readonly ConcurrentDictionary> _pendingRequests; - private readonly ConcurrentDictionary>> _notificationHandlers; - private readonly Dictionary>> _requestHandlers = new(); - private int _nextRequestId; private readonly McpServerConfig _serverConfig; - private bool _isInitialized; - private Task? _messageProcessingTask; - private CancellationTokenSource? _cts; - private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger _logger; private volatile bool _isInitializing; - - /// - public bool IsInitialized => _isInitialized; + private readonly IClientTransport _clientTransport; /// public ServerCapabilities? ServerCapabilities { get; private set; } @@ -43,20 +31,17 @@ internal class McpClient : IMcpClient /// /// Initializes a new instance of the class. /// - /// An MCP transport implementation. + /// The transport to use for communication with the server. /// Options for the client, defining protocol version and capabilities. /// The server configuration. /// The logger factory. - public McpClient(IMcpTransport transport, McpClientOptions options, McpServerConfig serverConfig, ILoggerFactory loggerFactory) + public McpClient(IClientTransport transport, McpClientOptions options, McpServerConfig serverConfig, ILoggerFactory loggerFactory) + : base(transport, loggerFactory) { - _transport = transport; _options = options; - _pendingRequests = new(); - _notificationHandlers = new(); - _nextRequestId = 1; _serverConfig = serverConfig; - _jsonOptions = new JsonSerializerOptions().ConfigureForMcp(loggerFactory); _logger = loggerFactory.CreateLogger(); + _clientTransport = transport; if (options.Capabilities?.Sampling != null) { @@ -65,11 +50,11 @@ internal class McpClient : IMcpClient if (SamplingHandler == null) { // Setting the capability, but not a handler means we have nothing to return to the server - _logger.SamplingHandlerNotConfigured(_serverConfig.Id, _serverConfig.Name); + _logger.SamplingHandlerNotConfigured(EndpointName); throw new McpClientException("Sampling handler not configured"); } - return await SamplingHandler(request, _cts?.Token ?? CancellationToken.None); + return await SamplingHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); }); } if (options.Capabilities?.Roots != null) @@ -80,10 +65,10 @@ internal class McpClient : IMcpClient if (RootsHandler == null) { // Setting the capability, but not a handler means we have nothing to return to the server - _logger.RootsHandlerNotConfigured(_serverConfig.Id, _serverConfig.Name); + _logger.RootsHandlerNotConfigured(EndpointName); throw new McpClientException("Roots handler not configured"); } - return await RootsHandler(request, _cts?.Token ?? CancellationToken.None); + return await RootsHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); }); } } @@ -93,35 +78,35 @@ internal class McpClient : IMcpClient { if (_isInitializing) { - _logger.ClientAlreadyInitializing(_serverConfig.Id, _serverConfig.Name); + _logger.ClientAlreadyInitializing(EndpointName); throw new InvalidOperationException("Client is already initializing"); } _isInitializing = true; - if (_isInitialized) + if (IsInitialized) { - _logger.ClientAlreadyInitialized(_serverConfig.Id, _serverConfig.Name); + _logger.ClientAlreadyInitialized(EndpointName); return; } try { - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Connect transport - await _transport.ConnectAsync(_cts.Token).ConfigureAwait(false); + await _clientTransport.ConnectAsync(CancellationTokenSource.Token).ConfigureAwait(false); // Start processing messages - _messageProcessingTask = ProcessMessagesAsync(_cts.Token); + MessageProcessingTask = ProcessMessagesAsync(CancellationTokenSource.Token); // Perform initialization sequence - await InitializeAsync(_cts.Token).ConfigureAwait(false); + await InitializeAsync(CancellationTokenSource.Token).ConfigureAwait(false); - _isInitialized = true; + IsInitialized = true; } catch (Exception e) { - _logger.ClientInitializationError(_serverConfig.Id, _serverConfig.Name, e); + _logger.ClientInitializationError(EndpointName, e); await CleanupAsync().ConfigureAwait(false); throw; } @@ -154,7 +139,7 @@ internal class McpClient : IMcpClient ).ConfigureAwait(false); // Store server information - _logger.ServerCapabilitiesReceived(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(initializeResponse.Capabilities), JsonSerializer.Serialize(initializeResponse.ServerInfo)); + _logger.ServerCapabilitiesReceived(EndpointName, JsonSerializer.Serialize(initializeResponse.Capabilities), JsonSerializer.Serialize(initializeResponse.ServerInfo)); ServerCapabilities = initializeResponse.Capabilities; ServerInfo = initializeResponse.ServerInfo; ServerInstructions = initializeResponse.Instructions; @@ -162,7 +147,7 @@ internal class McpClient : IMcpClient // Validate protocol version if (initializeResponse.ProtocolVersion != _options.ProtocolVersion) { - _logger.ServerProtocolVersionMismatch(_serverConfig.Id, _serverConfig.Name, _options.ProtocolVersion, initializeResponse.ProtocolVersion); + _logger.ServerProtocolVersionMismatch(EndpointName, _options.ProtocolVersion, initializeResponse.ProtocolVersion); throw new McpClientException($"Server protocol version mismatch. Expected {_options.ProtocolVersion}, got {initializeResponse.ProtocolVersion}"); } @@ -177,7 +162,7 @@ internal class McpClient : IMcpClient } catch (OperationCanceledException) when (initializationCts.IsCancellationRequested) { - _logger.ClientInitializationTimeout(_serverConfig.Id, _serverConfig.Name); + _logger.ClientInitializationTimeout(EndpointName); throw new McpClientException("Initialization timed out"); } } @@ -185,7 +170,7 @@ internal class McpClient : IMcpClient /// public async Task PingAsync(CancellationToken cancellationToken) { - _logger.PingingServer(_serverConfig.Id, _serverConfig.Name); + _logger.PingingServer(EndpointName); await SendRequestAsync( new JsonRpcRequest { @@ -198,7 +183,7 @@ internal class McpClient : IMcpClient /// public async Task ListToolsAsync(string? cursor = null, CancellationToken cancellationToken = default) { - _logger.ListingTools(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)"); + _logger.ListingTools(EndpointName, cursor ?? "(null)"); return await SendRequestAsync( new JsonRpcRequest { @@ -212,7 +197,7 @@ internal class McpClient : IMcpClient /// public async Task ListPromptsAsync(string? cursor = null, CancellationToken cancellationToken = default) { - _logger.ListingPrompts(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)"); + _logger.ListingPrompts(EndpointName, cursor ?? "(null)"); return await SendRequestAsync( new JsonRpcRequest { @@ -226,7 +211,7 @@ internal class McpClient : IMcpClient /// public async Task GetPromptAsync(string name, Dictionary? arguments = null, CancellationToken cancellationToken = default) { - _logger.GettingPrompt(_serverConfig.Id, _serverConfig.Name, name, arguments == null ? "{}" : JsonSerializer.Serialize(arguments)); + _logger.GettingPrompt(EndpointName, name, arguments == null ? "{}" : JsonSerializer.Serialize(arguments)); return await SendRequestAsync( new JsonRpcRequest { @@ -240,7 +225,7 @@ internal class McpClient : IMcpClient /// public async Task ListResourcesAsync(string? cursor = null, CancellationToken cancellationToken = default) { - _logger.ListingResources(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)"); + _logger.ListingResources(EndpointName, cursor ?? "(null)"); return await SendRequestAsync( new JsonRpcRequest { @@ -254,7 +239,7 @@ internal class McpClient : IMcpClient /// public async Task ReadResourceAsync(string uri, CancellationToken cancellationToken = default) { - _logger.ReadingResource(_serverConfig.Id, _serverConfig.Name, uri); + _logger.ReadingResource(EndpointName, uri); return await SendRequestAsync( new JsonRpcRequest { @@ -273,21 +258,21 @@ internal class McpClient : IMcpClient { if (!reference.Validate(out string validationMessage)) { - _logger.InvalidCompletionReference(_serverConfig.Id, _serverConfig.Name, reference.ToString(), validationMessage); + _logger.InvalidCompletionReference(EndpointName, reference.ToString(), validationMessage); throw new McpClientException($"Invalid reference: {validationMessage}"); } if (string.IsNullOrWhiteSpace(argumentName)) { - _logger.InvalidCompletionArgumentName(_serverConfig.Id, _serverConfig.Name, argumentName); + _logger.InvalidCompletionArgumentName(EndpointName, argumentName); throw new McpClientException("Argument name cannot be null or empty"); } if (argumentValue is null) { - _logger.InvalidCompletionArgumentValue(_serverConfig.Id, _serverConfig.Name, argumentValue); + _logger.InvalidCompletionArgumentValue(EndpointName, argumentValue); throw new McpClientException("Argument value cannot be null"); } - _logger.GettingCompletion(_serverConfig.Id, _serverConfig.Name, reference.ToString(), argumentName, argumentValue); + _logger.GettingCompletion(EndpointName, reference.ToString(), argumentName, argumentValue); return await SendRequestAsync( new JsonRpcRequest { @@ -305,7 +290,7 @@ internal class McpClient : IMcpClient /// public async Task SubscribeToResourceAsync(string uri, CancellationToken cancellationToken = default) { - _logger.SubscribingToResource(_serverConfig.Id, _serverConfig.Name, uri); + _logger.SubscribingToResource(EndpointName, uri); await SendRequestAsync( new JsonRpcRequest { @@ -319,11 +304,10 @@ internal class McpClient : IMcpClient ).ConfigureAwait(false); } - /// public async Task UnsubscribeFromResourceAsync(string uri, CancellationToken cancellationToken = default) { - _logger.UnsubscribingFromResource(_serverConfig.Id, _serverConfig.Name, uri); + _logger.UnsubscribingFromResource(EndpointName, uri); await SendRequestAsync( new JsonRpcRequest { @@ -338,10 +322,10 @@ internal class McpClient : IMcpClient } /// - public async Task CallToolAsync(string toolName, Dictionary? arguments = null, + public async Task CallToolAsync(string toolName, Dictionary arguments, CancellationToken cancellationToken = default) { - _logger.CallingTool(_serverConfig.Id, _serverConfig.Name, toolName, arguments == null ? "{}" : JsonSerializer.Serialize(arguments)); + _logger.CallingTool(EndpointName, toolName, JsonSerializer.Serialize(arguments)); return await SendRequestAsync( new JsonRpcRequest { @@ -352,301 +336,33 @@ internal class McpClient : IMcpClient ).ConfigureAwait(false); } - internal IMcpTransport Transport => _transport; - - private static Dictionary CreateParametersDictionary(string nameParameter, Dictionary? optionalArguments = null) + private static Dictionary CreateParametersDictionary(string nameParameter, Dictionary arguments) { var parameters = new Dictionary { ["name"] = nameParameter }; - if (optionalArguments != null) + if (arguments != null) { - parameters["arguments"] = optionalArguments; + parameters["arguments"] = arguments; } return parameters; } - private async Task ProcessMessagesAsync(CancellationToken cancellationToken) - { - try - { - await foreach (var message in _transport.MessageReader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) - { - await HandleMessageAsync(message, cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Normal shutdown - _logger.ClientMessageProcessingCancelled(_serverConfig.Id, _serverConfig.Name); - } - catch (NullReferenceException) - { - // Ignore reader disposal and mocked transport - } - } - - private async Task HandleMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken) - { - switch (message) - { - case JsonRpcRequest request: - if (_requestHandlers.TryGetValue(request.Method, out var handler)) - { - try - { - _logger.RequestHandlerCalled(_serverConfig.Id, _serverConfig.Name, request.Method); - var result = await handler(request); - _logger.RequestHandlerCompleted(_serverConfig.Id, _serverConfig.Name, request.Method); - await _transport.SendMessageAsync(new JsonRpcResponse - { - Id = request.Id, - JsonRpc = "2.0", - Result = result - }, cancellationToken); - } - catch (Exception ex) - { - _logger.RequestHandlerError(_serverConfig.Id, _serverConfig.Name, 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); - } - } - break; - case IJsonRpcMessageWithId messageWithId: - if (_pendingRequests.TryRemove(messageWithId.Id, out var tcs)) - { - tcs.TrySetResult(message); - } - else - { - _logger.NoRequestFoundForMessageWithId(_serverConfig.Id, _serverConfig.Name, messageWithId.Id.ToString()); - } - 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(_serverConfig.Id, _serverConfig.Name, notification.Method, ex); - } - } - } - break; - } - } - /// - public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where T : class - { - if (!_transport.IsConnected) - { - _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); - throw new McpClientException("Transport is not connected"); - } - - // Set request ID - request.Id = RequestId.FromNumber(Interlocked.Increment(ref _nextRequestId)); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingRequests[request.Id] = tcs; - - try - { - // Expensive logging, use the logging framework to check if the logger is enabled - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.SendingRequestPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(request)); - } - - // Less expensive information logging - _logger.SendingRequest(_serverConfig.Id, _serverConfig.Name, request.Method); - - await _transport.SendMessageAsync(request, cancellationToken).ConfigureAwait(false); - var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - - if (response is JsonRpcError error) - { - _logger.RequestFailed(_serverConfig.Id, _serverConfig.Name, request.Method, error.Error.Message, error.Error.Code); - throw new McpClientException($"Request failed: {error.Error.Message}", error.Error.Code); - } - - if (response is JsonRpcResponse success) - { - // 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); - - // Not expensive logging because we're already converting to JSON in order to get the result object - _logger.RequestResponseReceivedPayload(_serverConfig.Id, _serverConfig.Name, resultJson); - _logger.RequestResponseReceived(_serverConfig.Id, _serverConfig.Name, request.Method); - - if (resultObject != null) - { - return resultObject; - } - - // Result object was null, this is unexpected - _logger.RequestResponseTypeConversionError(_serverConfig.Id, _serverConfig.Name, request.Method, typeof(T)); - throw new McpClientException($"Unexpected response type {JsonSerializer.Serialize(success.Result)}, expected {typeof(T)}"); - } - - // Unexpected response type - _logger.RequestInvalidResponseType(_serverConfig.Id, _serverConfig.Name, request.Method); - throw new McpClientException("Invalid response type"); - } - finally - { - _pendingRequests.TryRemove(request.Id, out _); - } - } - - /// - public async Task SendNotificationAsync(string method, CancellationToken cancellationToken = default) - { - if (!_transport.IsConnected) - { - _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); - throw new McpClientException("Transport is not connected"); - } - - var notification = new JsonRpcNotification { Method = method }; - - // Log if enabled - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification)); - } - - // Log basic info - _logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, method); - - await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); - } - - /// - public async Task SendNotificationAsync(string method, T parameters, CancellationToken cancellationToken = default) - { - if (!_transport.IsConnected) - { - _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); - throw new McpClientException("Transport is not connected"); - } - var notification = new JsonRpcNotification - { - Method = method, - Params = parameters - }; - // Log if enabled - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification)); - } - // Log basic info - _logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, method); - await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); - } - - private async Task SendNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken) - { - if (!_transport.IsConnected) - { - _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); - throw new McpClientException("Transport is not connected"); - } - - await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); - } - - /// - public void OnNotification(string method, Func handler) - { - var handlers = _notificationHandlers.GetOrAdd(method, _ => new()); - lock (handlers) - { - handlers.Add(handler); - } - } - public Func>? SamplingHandler { get; set; } + /// public Func>? RootsHandler { get; set; } - private void SetRequestHandler(string method, Func> handler) - { - _requestHandlers[method] = async (request) => { - // Convert the params JsonElement to our type using the same options - var jsonString = JsonSerializer.Serialize(request.Params); - var typedRequest = JsonSerializer.Deserialize(jsonString, _jsonOptions); - - if (typedRequest == null) - { - _logger.RequestParamsTypeConversionError(_serverConfig.Id, _serverConfig.Name, method, typeof(TRequest)); - throw new McpClientException($"Invalid request parameters type {jsonString}, expected {typeof(TRequest)}"); - } - - return await handler(typedRequest); - }; - } - - private async Task CleanupAsync() - { - _logger.CleaningUpClient(_serverConfig.Id, _serverConfig.Name); - - _cts?.Cancel(); - - if (_messageProcessingTask != null) - { - try - { - await _messageProcessingTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Ignore cancellation - } - } - - // Complete all pending requests with cancellation - foreach (var (_, tcs) in _pendingRequests) - { - tcs.TrySetCanceled(); - } - _pendingRequests.Clear(); - - await _transport.DisposeAsync().ConfigureAwait(false); - _cts?.Dispose(); - - _isInitialized = false; - - _logger.ClientCleanedUp(_serverConfig.Id, _serverConfig.Name); - } - /// - public async ValueTask DisposeAsync() + public override string EndpointName { - await CleanupAsync().ConfigureAwait(false); - GC.SuppressFinalize(this); + get + { + return $"Client ({_serverConfig.Id}: {_serverConfig.Name})"; + } } } - diff --git a/src/mcpdotnet/Client/McpClientFactory.cs b/src/mcpdotnet/Client/McpClientFactory.cs index 5d2c77e..20a80ad 100644 --- a/src/mcpdotnet/Client/McpClientFactory.cs +++ b/src/mcpdotnet/Client/McpClientFactory.cs @@ -15,8 +15,8 @@ public class McpClientFactory private readonly Dictionary _serverConfigs; private readonly McpClientOptions _clientOptions; private readonly Dictionary _clients = new(); - private readonly Func _transportFactoryMethod; - private readonly Func _clientFactoryMethod; + private readonly Func _transportFactoryMethod; + private readonly Func _clientFactoryMethod; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; @@ -34,8 +34,8 @@ public class McpClientFactory IEnumerable serverConfigs, McpClientOptions clientOptions, ILoggerFactory loggerFactory, - Func? transportFactoryMethod = null, - Func? clientFactoryMethod = null) + Func? transportFactoryMethod = null, + Func? clientFactoryMethod = null) { _serverConfigs = serverConfigs.ToDictionary(c => c.Id); _clientOptions = clientOptions; @@ -71,33 +71,37 @@ public class McpClientFactory throw new ArgumentException($"Server with ID '{serverId}' not found.", nameof(serverId)); } + string endpointName = $"Client ({serverId}: {config.Name})"; + if (_clients.TryGetValue(serverId, out var existingClient)) { - _logger.ClientExists(serverId, config.Name); + _logger.ClientExists(endpointName); return existingClient; } - _logger.CreatingClient(serverId, config.Name); + _logger.CreatingClient(endpointName); var transport = _transportFactoryMethod(config); var client = _clientFactoryMethod(transport, config, _clientOptions); await client.ConnectAsync(cancellationToken); - _logger.ClientCreated(serverId, config.Name); + _logger.ClientCreated(endpointName); _clients[serverId] = client; return client; } - internal Func TransportFactoryMethod => _transportFactoryMethod; + internal Func TransportFactoryMethod => _transportFactoryMethod; - private IMcpTransport CreateTransport(McpServerConfig config) + private IClientTransport CreateTransport(McpServerConfig config) { + string endpointName = $"Client ({config.Id}: {config.Name})"; + var options = string.Join(", ", config.TransportOptions?.Select(kv => $"{kv.Key}={kv.Value}") ?? Enumerable.Empty()); - _logger.CreatingTransport(config.Id, config.Name, config.TransportType, options); + _logger.CreatingTransport(endpointName, config.TransportType, options); return config.TransportType.ToLowerInvariant() switch { - "stdio" => new StdioTransport(new StdioTransportOptions + "stdio" => new StdioClientTransport(new StdioClientTransportOptions { Command = GetCommand(config), Arguments = config.TransportOptions?.GetValueOrDefault("arguments")?.Split(' '), @@ -106,8 +110,8 @@ public class McpClientFactory .Where(kv => kv.Key.StartsWith("env:")) .ToDictionary(kv => kv.Key.Substring(4), kv => kv.Value) }, config, _loggerFactory), - "sse" or "http" => new SseTransport( - new SseTransportOptions + "sse" or "http" => new SseClientTransport( + new SseClientTransportOptions { ConnectionTimeout = TimeSpan.FromSeconds(ParseOrDefault(config.TransportOptions, "connectionTimeout", 30)), MaxReconnectAttempts = ParseOrDefault(config.TransportOptions, "maxReconnectAttempts", 3), @@ -147,6 +151,9 @@ public class McpClientFactory /// private void InitializeCommand(McpServerConfig config) { + + 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"); if (string.IsNullOrEmpty(command)) @@ -156,14 +163,14 @@ public class McpClientFactory if (command.Contains("cmd.exe")) { - _logger.SkippingShellWrapper(config.Id, config.Name); + _logger.SkippingShellWrapper(endpointName); return; } // If the command is not empty and does not contain cmd.exe, we need to inject /c {command} if (config.TransportOptions != null && !string.IsNullOrEmpty(command)) { - _logger.PromotingCommandToShellArgumentForStdio(config.Id, config.Name, command, config.TransportOptions.GetValueOrDefault("arguments") ?? ""); + _logger.PromotingCommandToShellArgumentForStdio(endpointName, command, config.TransportOptions.GetValueOrDefault("arguments") ?? ""); config.TransportOptions["arguments"] = config.TransportOptions.ContainsKey("arguments") ? $"/c {command} {config.TransportOptions["arguments"]}" : $"/c {command}"; diff --git a/src/mcpdotnet/Client/McpClientOptions.cs b/src/mcpdotnet/Client/McpClientOptions.cs index 8bc7902..5c54b93 100644 --- a/src/mcpdotnet/Client/McpClientOptions.cs +++ b/src/mcpdotnet/Client/McpClientOptions.cs @@ -5,7 +5,7 @@ namespace McpDotNet.Client; /// /// Configuration options for the MCP client. This is passed to servers during the initialization sequence, letting them know about the client's capabilities and /// protocol version. -/// See the protocol specification for details on capability negotiation +/// See the protocol specification for details on capability negotiation /// public record McpClientOptions { diff --git a/src/mcpdotnet/Configuration/McpServerConfig.cs b/src/mcpdotnet/Configuration/McpServerConfig.cs index 3f9d735..fe3fcec 100644 --- a/src/mcpdotnet/Configuration/McpServerConfig.cs +++ b/src/mcpdotnet/Configuration/McpServerConfig.cs @@ -2,6 +2,7 @@ /// /// Configuration for an MCP server connection. +/// This is passed to the client factory to create a client for a specific server. /// public record McpServerConfig { diff --git a/src/mcpdotnet/Logging/Log.cs b/src/mcpdotnet/Logging/Log.cs index 67251a7..1038cab 100644 --- a/src/mcpdotnet/Logging/Log.cs +++ b/src/mcpdotnet/Logging/Log.cs @@ -1,4 +1,5 @@ using McpDotNet.Configuration; +using McpDotNet.Protocol.Messages; using Microsoft.Extensions.Logging; namespace McpDotNet.Logging; @@ -17,233 +18,254 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Warning, Message = "Request {requestId} timed out")] internal static partial void RequestTimeout(this ILogger logger, int requestId); - [LoggerMessage(Level = LogLevel.Debug, Message = "Command for {ServerId} ({Name}) already contains shell wrapper, skipping argument injection")] - internal static partial void SkippingShellWrapper(this ILogger logger, string serverId, string name); + [LoggerMessage(Level = LogLevel.Debug, Message = "Command for {endpointName} already contains shell wrapper, skipping argument injection")] + internal static partial void SkippingShellWrapper(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Server config for Id={serverId} not found")] + [LoggerMessage(Level = LogLevel.Error, Message = "Server connection config for Id={serverId} not found")] internal static partial void ServerNotFound(this ILogger logger, string serverId); - [LoggerMessage(Level = LogLevel.Debug, Message = "Client for {serverId} ({name}) already created, returning cached client")] - internal static partial void ClientExists(this ILogger logger, string serverId, string name); + [LoggerMessage(Level = LogLevel.Debug, Message = "Client for {endpointName} already created, returning cached client")] + internal static partial void ClientExists(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Creating client for {serverId} ({name})")] - internal static partial void CreatingClient(this ILogger logger, string serverId, string name); + [LoggerMessage(Level = LogLevel.Information, Message = "Creating client for {endpointName}")] + internal static partial void CreatingClient(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Client for {serverId} ({name}) created and connected")] - internal static partial void ClientCreated(this ILogger logger, string serverId, string name); + [LoggerMessage(Level = LogLevel.Information, Message = "Client for {endpointName} created and connected")] + internal static partial void ClientCreated(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Creating transport for {serverId} ({name}) with type {transportType} and options {options}")] - internal static partial void CreatingTransport(this ILogger logger, string serverId, string name, string transportType, string options); + [LoggerMessage(Level = LogLevel.Information, Message = "Creating transport for {endpointName} with type {transportType} and options {options}")] + internal static partial void CreatingTransport(this ILogger logger, string endpointName, string transportType, string options); - [LoggerMessage(Level = LogLevel.Debug, Message = "Promoting command for {serverId} ({name}) to shell argument for stdio transport with command {command} and arguments {arguments}")] - internal static partial void PromotingCommandToShellArgumentForStdio(this ILogger logger, string serverId, string name, string command, string arguments); + [LoggerMessage(Level = LogLevel.Debug, Message = "Promoting command for {endpointName} to shell argument for stdio transport with command {command} and arguments {arguments}")] + internal static partial void PromotingCommandToShellArgumentForStdio(this ILogger logger, string endpointName, string command, string arguments); [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 {serverId} ({serverName}), always set a handler when using this capability")] - internal static partial void SamplingHandlerNotConfigured(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "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 {serverId} ({serverName}), always set a handler when using this capability")] - internal static partial void RootsHandlerNotConfigured(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "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 = "Client server {serverId} ({serverName}) already initializing")] - internal static partial void ClientAlreadyInitializing(this ILogger logger, string serverId, string serverName); + [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); - [LoggerMessage(Level = LogLevel.Information, Message = "Client server {serverId} ({serverName}) already initialized")] - internal static partial void ClientAlreadyInitialized(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Call tool handler not configured for server {endpointName}, always set a handler when using this capability")] + internal static partial void CallToolHandlerNotConfigured(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Client server {serverId} ({serverName}) initialization error")] - internal static partial void ClientInitializationError(this ILogger logger, string serverId, string serverName, Exception exception); + [LoggerMessage(Level = LogLevel.Error, Message = "List prompts handler not configured for server {endpointName}, always set a handler when using this capability")] + internal static partial void ListPromptsHandlerNotConfigured(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Server {serverId} ({serverName}) capabilities received: {capabilities}, server info: {serverInfo}")] - internal static partial void ServerCapabilitiesReceived(this ILogger logger, string serverId, string serverName, string capabilities, string serverInfo); + [LoggerMessage(Level = LogLevel.Error, Message = "Get prompt handler not configured for server {endpointName}, always set a handler when using this capability")] + internal static partial void GetPromptHandlerNotConfigured(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Server {serverId} ({serverName}) protocol version mismatch, expected {expected}, received {received}")] - internal static partial void ServerProtocolVersionMismatch(this ILogger logger, string serverId, string serverName, string expected, string received); + [LoggerMessage(Level = LogLevel.Error, Message = "List resources handler not configured for server {endpointName}, always set a handler when using this capability")] + internal static partial void ListResourcesHandlerNotConfigured(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Client server {serverId} ({serverName}) initialization timeout")] - internal static partial void ClientInitializationTimeout(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Read resource handler not configured for server {endpointName}, always set a handler when using this capability")] + internal static partial void ReadResourceHandlerNotConfigured(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Pinging server {serverId} ({serverName})")] - internal static partial void PingingServer(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Client server {endpointName} already initializing")] + internal static partial void ClientAlreadyInitializing(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Listing tools for server {serverId} ({serverName}) with cursor {cursor}")] - internal static partial void ListingTools(this ILogger logger, string serverId, string serverName, string cursor); + [LoggerMessage(Level = LogLevel.Information, Message = "Client server {endpointName} already initialized")] + internal static partial void ClientAlreadyInitialized(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Listing prompts for server {serverId} ({serverName}) with cursor {cursor}")] - internal static partial void ListingPrompts(this ILogger logger, string serverId, string serverName, string cursor); + [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 = "Getting prompt {name} for server {serverId} ({serverName}) with arguments {arguments}")] - internal static partial void GettingPrompt(this ILogger logger, string serverId, string serverName, string name, string arguments); + [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.Information, Message = "Listing resources for server {serverId} ({serverName}) with cursor {cursor}")] - internal static partial void ListingResources(this ILogger logger, string serverId, string serverName, string cursor); + [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); - [LoggerMessage(Level = LogLevel.Information, Message = "Reading resource {uri} for server {serverId} ({serverName})")] - internal static partial void ReadingResource(this ILogger logger, string serverId, string serverName, string uri); + [LoggerMessage(Level = LogLevel.Error, Message = "Client {endpointName} initialization timeout")] + internal static partial void ClientInitializationTimeout(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Subscribing to resource {uri} for server {serverId} ({serverName})")] - internal static partial void SubscribingToResource(this ILogger logger, string serverId, string serverName, string uri); + [LoggerMessage(Level = LogLevel.Information, Message = "Pinging sent ({endpointName})")] + internal static partial void PingingServer(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Unsubscribing from resource {uri} for server {serverId} ({serverName})")] - internal static partial void UnsubscribingFromResource(this ILogger logger, string serverId, string serverName, string uri); + [LoggerMessage(Level = LogLevel.Information, Message = "Listing tools for {endpointName} with cursor {cursor}")] + internal static partial void ListingTools(this ILogger logger, string endpointName, string cursor); - [LoggerMessage(Level = LogLevel.Information, Message = "Calling tool {toolName} for server {serverId} ({serverName}) with arguments {arguments}")] - internal static partial void CallingTool(this ILogger logger, string serverId, string serverName, string toolName, string arguments); + [LoggerMessage(Level = LogLevel.Information, Message = "Listing prompts for {endpointName} with cursor {cursor}")] + internal static partial void ListingPrompts(this ILogger logger, string endpointName, string cursor); - [LoggerMessage(Level = LogLevel.Information, Message = "Client message processing cancelled for server {serverId} ({serverName})")] - internal static partial void ClientMessageProcessingCancelled(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Getting prompt {name} for {endpointName} with arguments {arguments}")] + internal static partial void GettingPrompt(this ILogger logger, string endpointName, string name, string arguments); - [LoggerMessage(Level = LogLevel.Information, Message = "Request handler called for server {serverId} ({serverName}) with method {method}")] - internal static partial void RequestHandlerCalled(this ILogger logger, string serverId, string serverName, string method); + [LoggerMessage(Level = LogLevel.Information, Message = "Listing resources for {endpointName} with cursor {cursor}")] + internal static partial void ListingResources(this ILogger logger, string endpointName, string cursor); - [LoggerMessage(Level = LogLevel.Information, Message = "Request handler completed for server {serverId} ({serverName}) with method {method}")] - internal static partial void RequestHandlerCompleted(this ILogger logger, string serverId, string serverName, string method); + [LoggerMessage(Level = LogLevel.Information, Message = "Reading resource {uri} for {endpointName}")] + internal static partial void ReadingResource(this ILogger logger, string endpointName, string uri); - [LoggerMessage(Level = LogLevel.Error, Message = "Request handler error for server {serverId} ({serverName}) with method {method}")] - internal static partial void RequestHandlerError(this ILogger logger, string serverId, string serverName, string method, Exception exception); + [LoggerMessage(Level = LogLevel.Information, Message = "Subscribing to resource {uri} for {endpointName}")] + internal static partial void SubscribingToResource(this ILogger logger, string endpointName, string uri); - [LoggerMessage(Level = LogLevel.Warning, Message = "No request found for message with ID {messageWithId} for server {serverId} ({serverName})")] - internal static partial void NoRequestFoundForMessageWithId(this ILogger logger, string serverId, string serverName, string messageWithId); + [LoggerMessage(Level = LogLevel.Information, Message = "Unsubscribing from resource {uri} for {endpointName}")] + internal static partial void UnsubscribingFromResource(this ILogger logger, string endpointName, string uri); - [LoggerMessage(Level = LogLevel.Error, Message = "Notification handler error for server {serverId} ({serverName}) with method {method}")] - internal static partial void NotificationHandlerError(this ILogger logger, string serverId, string serverName, string method, Exception exception); + [LoggerMessage(Level = LogLevel.Information, Message = "Calling tool {toolName} for {endpointName} with arguments {arguments}")] + internal static partial void CallingTool(this ILogger logger, string endpointName, string toolName, string arguments); - [LoggerMessage(Level = LogLevel.Error, Message = "Client not connected for server {serverId} ({serverName})")] - internal static partial void ClientNotConnected(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Endpoint message processing cancelled for {endpointName}")] + internal static partial void EndpointMessageProcessingCancelled(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Sending request payload for server {serverId} ({serverName}): {payload}")] - internal static partial void SendingRequestPayload(this ILogger logger, string serverId, string serverName, string payload); + [LoggerMessage(Level = LogLevel.Information, Message = "Request handler called for {endpointName} with method {method}")] + internal static partial void RequestHandlerCalled(this ILogger logger, string endpointName, string method); - [LoggerMessage(Level = LogLevel.Information, Message = "Sending request for server {serverId} ({serverName}) with method {method}")] - internal static partial void SendingRequest(this ILogger logger, string serverId, string serverName, string method); + [LoggerMessage(Level = LogLevel.Information, Message = "Request handler completed for {endpointName} with method {method}")] + internal static partial void RequestHandlerCompleted(this ILogger logger, string endpointName, string method); - [LoggerMessage(Level = LogLevel.Error, Message = "Request failed for server {serverId} ({serverName}) with method {method}: {message} ({code})")] - internal static partial void RequestFailed(this ILogger logger, string serverId, string serverName, string method, string message, int code); + [LoggerMessage(Level = LogLevel.Error, Message = "Request handler error for {endpointName} with method {method}")] + internal static partial void RequestHandlerError(this ILogger logger, string endpointName, string method, Exception exception); - [LoggerMessage(Level = LogLevel.Information, Message = "Request response received payload for server {serverId} ({serverName}): {payload}")] - internal static partial void RequestResponseReceivedPayload(this ILogger logger, string serverId, string serverName, string payload); + [LoggerMessage(Level = LogLevel.Warning, Message = "No request found for message with ID {messageWithId} for {endpointName}")] + internal static partial void NoRequestFoundForMessageWithId(this ILogger logger, string endpointName, string messageWithId); - [LoggerMessage(Level = LogLevel.Information, Message = "Request response received for server {serverId} ({serverName}) with method {method}")] - internal static partial void RequestResponseReceived(this ILogger logger, string serverId, string serverName, string method); + [LoggerMessage(Level = LogLevel.Error, Message = "Notification handler error for {endpointName} with method {method}")] + internal static partial void NotificationHandlerError(this ILogger logger, string endpointName, string method, Exception exception); - [LoggerMessage(Level = LogLevel.Error, Message = "Request response type conversion error for server {serverId} ({serverName}) with method {method}: expected {expectedType}")] - internal static partial void RequestResponseTypeConversionError(this ILogger logger, string serverId, string serverName, string method, Type expectedType); + [LoggerMessage(Level = LogLevel.Error, Message = "Client not connected for {endpointName}")] + internal static partial void ClientNotConnected(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Request invalid response type for server {serverId} ({serverName}) with method {method}")] - internal static partial void RequestInvalidResponseType(this ILogger logger, string serverId, string serverName, string method); + [LoggerMessage(Level = LogLevel.Error, Message = "Endpoint not connected for {endpointName}")] + internal static partial void EndpointNotConnected(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Request params type conversion error for server {serverId} ({serverName}) with method {method}: expected {expectedType}")] - internal static partial void RequestParamsTypeConversionError(this ILogger logger, string serverId, string serverName, string method, Type expectedType); + [LoggerMessage(Level = LogLevel.Information, Message = "Sending request payload for {endpointName}: {payload}")] + internal static partial void SendingRequestPayload(this ILogger logger, string endpointName, string payload); - [LoggerMessage(Level = LogLevel.Information, Message = "Cleaning up client for server {serverId} ({serverName})")] - internal static partial void CleaningUpClient(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Sending request for {endpointName} with method {method}")] + internal static partial void SendingRequest(this ILogger logger, string endpointName, string method); - [LoggerMessage(Level = LogLevel.Information, Message = "Client cleaned up for server {serverId} ({serverName})")] - internal static partial void ClientCleanedUp(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Request failed for {endpointName} with method {method}: {message} ({code})")] + internal static partial void RequestFailed(this ILogger logger, string endpointName, string method, string message, int code); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport for server {serverId} ({serverName}) already connected")] - internal static partial void TransportAlreadyConnected(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Request response received payload for {endpointName}: {payload}")] + internal static partial void RequestResponseReceivedPayload(this ILogger logger, string endpointName, string payload); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport for server {serverId} ({serverName}) connecting")] - internal static partial void TransportConnecting(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Request response received for {endpointName} with method {method}")] + internal static partial void RequestResponseReceived(this ILogger logger, string endpointName, string method); - [LoggerMessage(Level = LogLevel.Information, Message = "Creating process for transport for server {serverId} ({serverName}) with command {command}, arguments {arguments}, environment {environment}, working directory {workingDirectory}, shutdown timeout {shutdownTimeout}")] - internal static partial void CreateProcessForTransport(this ILogger logger, string serverId, string serverName, string command, string arguments, string environment, string workingDirectory, string shutdownTimeout); + [LoggerMessage(Level = LogLevel.Error, Message = "Request response type conversion error for {endpointName} with method {method}: expected {expectedType}")] + internal static partial void RequestResponseTypeConversionError(this ILogger logger, string endpointName, string method, Type expectedType); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport for server {serverId} ({serverName}) error: {data}")] - internal static partial void TransportError(this ILogger logger, string serverId, string serverName, string data); + [LoggerMessage(Level = LogLevel.Error, Message = "Request invalid response type for {endpointName} with method {method}")] + internal static partial void RequestInvalidResponseType(this ILogger logger, string endpointName, string method); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport process start failed for server {serverId} ({serverName})")] - internal static partial void TransportProcessStartFailed(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Request params type conversion error for {endpointName} with method {method}: expected {expectedType}")] + internal static partial void RequestParamsTypeConversionError(this ILogger logger, string endpointName, string method, Type expectedType); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport process started for server {serverId} ({serverName}) with PID {processId}")] - internal static partial void TransportProcessStarted(this ILogger logger, string serverId, string serverName, int processId); + [LoggerMessage(Level = LogLevel.Information, Message = "Cleaning up endpoint {endpointName}")] + internal static partial void CleaningUpEndpoint(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport reading messages for server {serverId} ({serverName})")] - internal static partial void TransportReadingMessages(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Endpoint cleaned up for {endpointName}")] + internal static partial void EndpointCleanedUp(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport connect failed for server {serverId} ({serverName})")] - internal static partial void TransportConnectFailed(this ILogger logger, string serverId, string serverName, Exception exception); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport for {endpointName} already connected")] + internal static partial void TransportAlreadyConnected(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport not connected for server {serverId} ({serverName})")] - internal static partial void TransportNotConnected(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport for {endpointName} connecting")] + internal static partial void TransportConnecting(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport sending message for server {serverId} ({serverName}) with ID {messageId}, JSON {json}")] - internal static partial void TransportSendingMessage(this ILogger logger, string serverId, string serverName, string messageId, string json); + [LoggerMessage(Level = LogLevel.Information, Message = "Creating process for transport for {endpointName} with command {command}, arguments {arguments}, environment {environment}, working directory {workingDirectory}, shutdown timeout {shutdownTimeout}")] + internal static partial void CreateProcessForTransport(this ILogger logger, string endpointName, string command, string arguments, string environment, string workingDirectory, string shutdownTimeout); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport message sent for server {serverId} ({serverName}) with ID {messageId}")] - internal static partial void TransportSentMessage(this ILogger logger, string serverId, string serverName, string messageId); + [LoggerMessage(Level = LogLevel.Error, Message = "Transport for {endpointName} error: {data}")] + internal static partial void TransportError(this ILogger logger, string endpointName, string data); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport send failed for server {serverId} ({serverName}) with ID {messageId}")] - internal static partial void TransportSendFailed(this ILogger logger, string serverId, string serverName, string messageId, Exception exception); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport process start failed for {endpointName}")] + internal static partial void TransportProcessStartFailed(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport entering read messages loop for server {serverId} ({serverName})")] - internal static partial void TransportEnteringReadMessagesLoop(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport process started for {endpointName} with PID {processId}")] + internal static partial void TransportProcessStarted(this ILogger logger, string endpointName, int processId); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport waiting for message for server {serverId} ({serverName})")] - internal static partial void TransportWaitingForMessage(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport reading messages for {endpointName}")] + internal static partial void TransportReadingMessages(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport end of stream for server {serverId} ({serverName})")] - internal static partial void TransportEndOfStream(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Transport connect failed for {endpointName}")] + internal static partial void TransportConnectFailed(this ILogger logger, string endpointName, Exception exception); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport received message for server {serverId} ({serverName}): {line}")] - internal static partial void TransportReceivedMessage(this ILogger logger, string serverId, string serverName, string line); + [LoggerMessage(Level = LogLevel.Error, Message = "Transport not connected for {endpointName}")] + internal static partial void TransportNotConnected(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport received message parsed for server {serverId} ({serverName}): {messageId}")] - internal static partial void TransportReceivedMessageParsed(this ILogger logger, string serverId, string serverName, string messageId); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport sending message for {endpointName} with ID {messageId}, JSON {json}")] + internal static partial void TransportSendingMessage(this ILogger logger, string endpointName, string messageId, string json); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport message written for server {serverId} ({serverName}) with ID {messageId}")] - internal static partial void TransportMessageWritten(this ILogger logger, string serverId, string serverName, string messageId); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport message sent for {endpointName} with ID {messageId}")] + internal static partial void TransportSentMessage(this ILogger logger, string endpointName, string messageId); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport message parse failed due to unexpected message schema for server {serverId} ({serverName}): {line}")] - internal static partial void TransportMessageParseUnexpectedType(this ILogger logger, string serverId, string serverName, string line); + [LoggerMessage(Level = LogLevel.Error, Message = "Transport send failed for {endpointName} with ID {messageId}")] + internal static partial void TransportSendFailed(this ILogger logger, string endpointName, string messageId, Exception exception); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport message parse failed for server {serverId} ({serverName}): {line}")] - internal static partial void TransportMessageParseFailed(this ILogger logger, string serverId, string serverName, string line, Exception exception); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport entering read messages loop for {endpointName}")] + internal static partial void TransportEnteringReadMessagesLoop(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport exiting read messages loop for server {serverId} ({serverName})")] - internal static partial void TransportExitingReadMessagesLoop(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport waiting for message for {endpointName}")] + internal static partial void TransportWaitingForMessage(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport read messages cancelled for server {serverId} ({serverName})")] - internal static partial void TransportReadMessagesCancelled(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport end of stream for {endpointName}")] + internal static partial void TransportEndOfStream(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport read messages failed for server {serverId} ({serverName})")] - internal static partial void TransportReadMessagesFailed(this ILogger logger, string serverId, string serverName, Exception exception); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport received message for {endpointName}: {line}")] + internal static partial void TransportReceivedMessage(this ILogger logger, string endpointName, string line); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport cleaning up for server {serverId} ({serverName})")] - internal static partial void TransportCleaningUp(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport received message parsed for {endpointName}: {messageId}")] + internal static partial void TransportReceivedMessageParsed(this ILogger logger, string endpointName, string messageId); - [LoggerMessage(Level = LogLevel.Debug, Message = "Transport closing stdin for server {serverId} ({serverName})")] - internal static partial void TransportClosingStdin(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport message written for {endpointName} with ID {messageId}")] + internal static partial void TransportMessageWritten(this ILogger logger, string endpointName, string messageId); - [LoggerMessage(Level = LogLevel.Debug, Message = "Transport waiting for shutdown for server {serverId} ({serverName})")] - internal static partial void TransportWaitingForShutdown(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Transport message parse failed due to unexpected message schema for {endpointName}: {line}")] + internal static partial void TransportMessageParseUnexpectedType(this ILogger logger, string endpointName, string line); - [LoggerMessage(Level = LogLevel.Warning, Message = "Transport killing process for server {serverId} ({serverName})")] - internal static partial void TransportKillingProcess(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Transport message parse failed for {endpointName}: {line}")] + internal static partial void TransportMessageParseFailed(this ILogger logger, string endpointName, string line, Exception exception); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport shutdown failed for server {serverId} ({serverName})")] - internal static partial void TransportShutdownFailed(this ILogger logger, string serverId, string serverName, Exception exception); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport exiting read messages loop for {endpointName}")] + internal static partial void TransportExitingReadMessagesLoop(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Debug, Message = "Transport waiting for read task for server {serverId} ({serverName})")] - internal static partial void TransportWaitingForReadTask(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport read messages cancelled for {endpointName}")] + internal static partial void TransportReadMessagesCancelled(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Warning, Message = "Transport cleanup read task timeout for server {serverId} ({serverName})")] - internal static partial void TransportCleanupReadTaskTimeout(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Error, Message = "Transport read messages failed for {endpointName}")] + internal static partial void TransportReadMessagesFailed(this ILogger logger, string endpointName, Exception exception); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport cleanup read task cancelled for server {serverId} ({serverName})")] - internal static partial void TransportCleanupReadTaskCancelled(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Information, Message = "Transport cleaning up for {endpointName}")] + internal static partial void TransportCleaningUp(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Error, Message = "Transport cleanup read task failed for server {serverId} ({serverName})")] - internal static partial void TransportCleanupReadTaskFailed(this ILogger logger, string serverId, string serverName, Exception exception); + [LoggerMessage(Level = LogLevel.Debug, Message = "Transport closing stdin for {endpointName}")] + internal static partial void TransportClosingStdin(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport read task cleaned up for server {serverId} ({serverName})")] - internal static partial void TransportReadTaskCleanedUp(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Debug, Message = "Transport waiting for shutdown for {endpointName}")] + internal static partial void TransportWaitingForShutdown(this ILogger logger, string endpointName); - [LoggerMessage(Level = LogLevel.Information, Message = "Transport cleaned up for server {serverId} ({serverName})")] - internal static partial void TransportCleanedUp(this ILogger logger, string serverId, string serverName); + [LoggerMessage(Level = LogLevel.Warning, Message = "Transport killing process for {endpointName}")] + internal static partial void TransportKillingProcess(this ILogger logger, string endpointName); + + [LoggerMessage(Level = LogLevel.Error, Message = "Transport shutdown failed for {endpointName}")] + internal static partial void TransportShutdownFailed(this ILogger logger, string endpointName, Exception exception); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Transport waiting for read task for {endpointName}")] + internal static partial void TransportWaitingForReadTask(this ILogger logger, string endpointName); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Transport cleanup read task timeout for {endpointName}")] + internal static partial void TransportCleanupReadTaskTimeout(this ILogger logger, string endpointName); + + [LoggerMessage(Level = LogLevel.Information, Message = "Transport cleanup read task cancelled for {endpointName}")] + internal static partial void TransportCleanupReadTaskCancelled(this ILogger logger, string endpointName); + + [LoggerMessage(Level = LogLevel.Error, Message = "Transport cleanup read task failed for {endpointName}")] + internal static partial void TransportCleanupReadTaskFailed(this ILogger logger, string endpointName, Exception exception); + + [LoggerMessage(Level = LogLevel.Information, Message = "Transport read task cleaned up for {endpointName}")] + internal static partial void TransportReadTaskCleanedUp(this ILogger logger, string endpointName); + + [LoggerMessage(Level = LogLevel.Information, Message = "Transport cleaned up for {endpointName}")] + internal static partial void TransportCleanedUp(this ILogger logger, string endpointName); [LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message start object token expected")] internal static partial void JsonRpcMessageConverterExpectedStartObjectToken(this ILogger logger); @@ -272,105 +294,191 @@ internal static partial class Log [LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message write unknown message type: {type}")] internal static partial void JsonRpcMessageConverterWriteUnknownMessageType(this ILogger logger, string type); - [LoggerMessage(Level = LogLevel.Debug, Message = "Sending notification payload for {serverId} ({serverName}): {payload}")] - internal static partial void SendingNotificationPayload(this ILogger logger, string serverId, string serverName, string payload); + [LoggerMessage(Level = LogLevel.Debug, Message = "Sending notification payload for {endpointName}: {payload}")] + internal static partial void SendingNotificationPayload(this ILogger logger, string endpointName, string payload); - [LoggerMessage(Level = LogLevel.Information, Message = "Sending notification for {serverId} ({serverName}): {method}")] - internal static partial void SendingNotification(this ILogger logger, string serverId, string serverName, string method); + [LoggerMessage(Level = LogLevel.Information, Message = "Sending notification for {endpointName}: {method}")] + internal static partial void SendingNotification(this ILogger logger, string endpointName, string method); [LoggerMessage( - EventId = 7000, - Level = LogLevel.Error, - Message = "Transport connection error for server {serverId} ({serverName})" -)] + EventId = 7000, + Level = LogLevel.Error, + Message = "Transport connection error for {endpointName}" + )] public static partial void TransportConnectionError( - this ILogger logger, - string serverId, - string serverName, - Exception exception); + this ILogger logger, + string endpointName, + Exception exception); [LoggerMessage( EventId = 7001, Level = LogLevel.Warning, - Message = "Transport message received before connected for server {serverId} ({serverName}): {data}" + Message = "Transport message received before connected for {endpointName}: {data}" )] public static partial void TransportMessageReceivedBeforeConnected( this ILogger logger, - string serverId, - string serverName, + string endpointName, string data); [LoggerMessage( EventId = 7002, Level = LogLevel.Error, - Message = "Transport endpoint event received out of order for server {serverId} ({serverName}): {data}" + Message = "Transport endpoint event received out of order for {endpointName}: {data}" )] public static partial void TransportEndpointEventInvalid( this ILogger logger, - string serverId, - string serverName, + string endpointName, string data); [LoggerMessage( EventId = 7003, Level = LogLevel.Error, - Message = "Transport event parse failed for server {serverId} ({serverName}): {data}" + Message = "Transport event parse failed for {endpointName}: {data}" )] public static partial void TransportEndpointEventParseFailed( this ILogger logger, - string serverId, - string serverName, + string endpointName, string data, Exception exception); - // trace _logger.InvalidCompletionReference(_serverConfig.Id, _serverConfig.Name, reference, validationMessage); [LoggerMessage( EventId = 7004, Level = LogLevel.Trace, - Message = "Invalid completion reference {reference} for server {serverId} ({serverName}): {validationMessage}" + Message = "Invalid completion reference {reference} for {endpointName}: {validationMessage}" )] public static partial void InvalidCompletionReference( this ILogger logger, - string serverId, - string serverName, + string endpointName, string reference, string validationMessage); - // trace _logger.InvalidACompletionArgumentName(_serverConfig.Id, _serverConfig.Name, argumentName); [LoggerMessage( EventId = 7005, Level = LogLevel.Trace, - Message = "Invalid completion argument name {argumentName} for server {serverId} ({serverName})" + Message = "Invalid completion argument name {argumentName} for {endpointName}" )] public static partial void InvalidCompletionArgumentName( this ILogger logger, - string serverId, - string serverName, + string endpointName, string argumentName); - // trace _logger.InvalidCompletionArgumentValue(_serverConfig.Id, _serverConfig.Name, argumentValue); [LoggerMessage( EventId = 7006, Level = LogLevel.Trace, - Message = "Invalid completion argument value {argumentValue} for server {serverId} ({serverName})" + Message = "Invalid completion argument value {argumentValue} for {endpointName}" )] public static partial void InvalidCompletionArgumentValue( this ILogger logger, - string serverId, - string serverName, + string endpointName, string argumentValue); - // debug _logger.GettingCompletion(_serverConfig.Id, _serverConfig.Name, reference, argumentName, argumentValue); [LoggerMessage( EventId = 7007, Level = LogLevel.Debug, - Message = "Getting completion for server {serverId} ({serverName}) with reference {reference}, argument name {argumentName}, argument value {argumentValue}" + Message = "Getting completion for {endpointName} with reference {reference}, argument name {argumentName}, argument value {argumentValue}" )] public static partial void GettingCompletion( this ILogger logger, - string serverId, - string serverName, + string endpointName, string reference, string argumentName, string argumentValue); + + [LoggerMessage( + EventId = 7008, + Level = LogLevel.Error, + Message = "Message handler error for {endpointName} with message type {messageType}, payload {payload}" + )] + public static partial void MessageHandlerError( + this ILogger logger, + string endpointName, + string messageType, + string payload, + Exception exception); + + [LoggerMessage( + EventId = 7009, + Level = LogLevel.Trace, + Message = "Writing message to channel: {message}" + )] + public static partial void TransportWritingMessageToChannel( + this ILogger logger, + IJsonRpcMessage message); + + [LoggerMessage( + EventId = 7010, + Level = LogLevel.Trace, + Message = "Message written to channel" + )] + public static partial void TransportMessageWrittenToChannel(this ILogger logger); + + [LoggerMessage( + EventId = 7011, + Level = LogLevel.Trace, + Message = "Message read from channel for {endpointName} with type {messageType}" + )] + public static partial void TransportMessageRead( + this ILogger logger, + string endpointName, + string messageType); + + [LoggerMessage( + EventId = 7012, + Level = LogLevel.Warning, + Message = "No handler found for request {method} for server {endpointName}" + )] + public static partial void NoHandlerFoundForRequest( + this ILogger logger, + string endpointName, + string method); + + [LoggerMessage( + EventId = 7013, + Level = LogLevel.Trace, + Message = "Response matched pending request for {endpointName} with ID {messageId}" + )] + public static partial void ResponseMatchedPendingRequest( + this ILogger logger, + string endpointName, + string messageId); + + [LoggerMessage( + EventId = 7014, + Level = LogLevel.Warning, + Message = "Endpoint handler received unexpected message type for {endpointName}: {messageType}" + )] + public static partial void EndpointHandlerUnexpectedMessageType( + this ILogger logger, + string endpointName, + string messageType); + + [LoggerMessage( + EventId = 7015, + Level = LogLevel.Debug, + Message = "Request sent for {endpointName} with method {method}, ID {id}. Waiting for response." + )] + public static partial void RequestSentAwaitingResponse( + this ILogger logger, + string endpointName, + string method, + string id); + + [LoggerMessage( + EventId = 7016, + Level = LogLevel.Error, + Message = "{endpointName} already initializing" + )] + public static partial void ServerAlreadyInitializing( + this ILogger logger, + string endpointName); + + [LoggerMessage( + EventId = 7017, + Level = LogLevel.Error, + Message = "{endpointName} initialization error" + )] + public static partial void ServerInitializationError( + this ILogger logger, + string endpointName, + Exception e); } \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Transport/IClientTransport.cs b/src/mcpdotnet/Protocol/Transport/IClientTransport.cs new file mode 100644 index 0000000..f0ea7e7 --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/IClientTransport.cs @@ -0,0 +1,13 @@ +namespace McpDotNet.Protocol.Transport; + +/// +/// Represents a transport mechanism for MCP communication (from the client). +/// +public interface IClientTransport : ITransport +{ + /// + /// Establishes the transport connection. + /// + /// Token to cancel the operation. + Task ConnectAsync(CancellationToken cancellationToken = default); +} diff --git a/src/mcpdotnet/Protocol/Transport/IServerTransport.cs b/src/mcpdotnet/Protocol/Transport/IServerTransport.cs new file mode 100644 index 0000000..7e8c37c --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/IServerTransport.cs @@ -0,0 +1,13 @@ +namespace McpDotNet.Protocol.Transport; + +/// +/// Represents a transport mechanism for MCP communication (from the server). +/// +public interface IServerTransport : ITransport +{ + /// + /// Starts listening for incoming messages. + /// + /// Token to cancel the operation. + Task StartListeningAsync(CancellationToken cancellationToken = default); +} diff --git a/src/mcpdotnet/Protocol/Transport/IMcpTransport.cs b/src/mcpdotnet/Protocol/Transport/ITransport.cs similarity index 74% rename from src/mcpdotnet/Protocol/Transport/IMcpTransport.cs rename to src/mcpdotnet/Protocol/Transport/ITransport.cs index befea7a..2a7b2c6 100644 --- a/src/mcpdotnet/Protocol/Transport/IMcpTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/ITransport.cs @@ -2,10 +2,11 @@ using McpDotNet.Protocol.Messages; namespace McpDotNet.Protocol.Transport; + /// /// Represents a transport mechanism for MCP communication. /// -public interface IMcpTransport : IAsyncDisposable +public interface ITransport : IAsyncDisposable { /// /// Gets whether the transport is currently connected. @@ -17,12 +18,6 @@ public interface IMcpTransport : IAsyncDisposable /// ChannelReader MessageReader { get; } - /// - /// Establishes the transport connection. - /// - /// Token to cancel the operation. - Task ConnectAsync(CancellationToken cancellationToken = default); - /// /// Sends a message through the transport. /// diff --git a/src/mcpdotnet/Protocol/Transport/SseTransport.cs b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs similarity index 79% rename from src/mcpdotnet/Protocol/Transport/SseTransport.cs rename to src/mcpdotnet/Protocol/Transport/SseClientTransport.cs index 2b26db1..8118bc7 100644 --- a/src/mcpdotnet/Protocol/Transport/SseTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/SseClientTransport.cs @@ -16,52 +16,56 @@ namespace McpDotNet.Protocol.Transport; /// /// /// -public sealed class SseTransport : TransportBase +public sealed class SseClientTransport : TransportBase, IClientTransport { private readonly HttpClient _httpClient; - private readonly SseTransportOptions _options; + private readonly SseClientTransportOptions _options; private readonly Uri _sseEndpoint; private Uri? _messageEndpoint; private readonly CancellationTokenSource _connectionCts; private Task? _receiveTask; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly McpServerConfig _serverConfig; private readonly JsonSerializerOptions _jsonOptions; private TaskCompletionSource _connectionEstablished; + private string EndpointName => $"Client (SSE) for ({_serverConfig.Id}: {_serverConfig.Name})"; + /// - /// + /// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server. + /// The HTTP server can be local or remote, and must support the SSE protocol. /// - /// - /// - /// - public SseTransport(SseTransportOptions transportOptions, McpServerConfig serverConfig, ILoggerFactory loggerFactory) + /// Configuration options for the transport. + /// The configuration object indicating which server to connect to. + /// Logger factory for creating loggers. + public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, ILoggerFactory loggerFactory) + : base(loggerFactory) { _options = transportOptions; _serverConfig = serverConfig; _sseEndpoint = new Uri(serverConfig.Location!); _httpClient = new HttpClient(); _connectionCts = new CancellationTokenSource(); - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory.CreateLogger(); _jsonOptions = new JsonSerializerOptions().ConfigureForMcp(loggerFactory); _connectionEstablished = new TaskCompletionSource(); } /// - public override async Task ConnectAsync(CancellationToken cancellationToken = default) + public async Task ConnectAsync(CancellationToken cancellationToken = default) { try { if (IsConnected) { - _logger.TransportAlreadyConnected(_serverConfig.Id, _serverConfig.Name); + _logger.TransportAlreadyConnected(EndpointName); throw new McpTransportException("Transport is already connected"); } // Start message receiving loop _receiveTask = ReceiveMessagesAsync(_connectionCts.Token); - _logger.TransportReadingMessages(_serverConfig.Id, _serverConfig.Name); + _logger.TransportReadingMessages(EndpointName); await Task.WhenAny( _connectionEstablished.Task, @@ -78,7 +82,7 @@ public sealed class SseTransport : TransportBase } catch (Exception ex) { - _logger.TransportConnectFailed(_serverConfig.Id, _serverConfig.Name, ex); + _logger.TransportConnectFailed(EndpointName, ex); await CloseAsync(); throw new McpTransportException("Failed to connect transport", ex); } @@ -124,13 +128,13 @@ public sealed class SseTransport : TransportBase { messageId = messageWithId.Id.ToString(); } - _logger.TransportReceivedMessageParsed(_serverConfig.Id, _serverConfig.Name, messageId); + _logger.TransportReceivedMessageParsed(EndpointName, messageId); await WriteMessageAsync(responseMessage, cancellationToken); - _logger.TransportMessageWritten(_serverConfig.Id, _serverConfig.Name, messageId); + _logger.TransportMessageWritten(EndpointName, messageId); } else { - _logger.TransportMessageParseUnexpectedType(_serverConfig.Id, _serverConfig.Name, responseContent); + _logger.TransportMessageParseUnexpectedType(EndpointName, responseContent); } } @@ -177,7 +181,7 @@ public sealed class SseTransport : TransportBase internal Uri? MessageEndpoint => _messageEndpoint; - internal SseTransportOptions Options => _options; + internal SseClientTransportOptions Options => _options; private async Task ReceiveMessagesAsync(CancellationToken cancellationToken) { @@ -228,7 +232,7 @@ public sealed class SseTransport : TransportBase } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { - _logger.TransportConnectionError(_serverConfig.Id, _serverConfig.Name, ex); + _logger.TransportConnectionError(EndpointName, ex); reconnectAttempts++; if (reconnectAttempts >= _options.MaxReconnectAttempts) @@ -245,7 +249,7 @@ public sealed class SseTransport : TransportBase { if (!IsConnected) { - _logger.TransportMessageReceivedBeforeConnected(_serverConfig.Id, _serverConfig.Name, data); + _logger.TransportMessageReceivedBeforeConnected(EndpointName, data); return; } @@ -254,7 +258,7 @@ public sealed class SseTransport : TransportBase var message = JsonSerializer.Deserialize(data, _jsonOptions); if (message == null) { - _logger.TransportMessageParseUnexpectedType(_serverConfig.Id, _serverConfig.Name, data); + _logger.TransportMessageParseUnexpectedType(EndpointName, data); return; } @@ -264,13 +268,13 @@ public sealed class SseTransport : TransportBase messageId = messageWithId.Id.ToString(); } - _logger.TransportReceivedMessageParsed(_serverConfig.Id, _serverConfig.Name, messageId); + _logger.TransportReceivedMessageParsed(EndpointName, messageId); await WriteMessageAsync(message, cancellationToken); - _logger.TransportMessageWritten(_serverConfig.Id, _serverConfig.Name, messageId); + _logger.TransportMessageWritten(EndpointName, messageId); } catch (JsonException ex) { - _logger.TransportMessageParseFailed(_serverConfig.Id, _serverConfig.Name, data, ex); + _logger.TransportMessageParseFailed(EndpointName, data, ex); } } @@ -281,7 +285,7 @@ public sealed class SseTransport : TransportBase var endpointData = JsonSerializer.Deserialize(data, _jsonOptions); if (endpointData?.Uri == null) { - _logger.TransportEndpointEventInvalid(_serverConfig.Id, _serverConfig.Name, data); + _logger.TransportEndpointEventInvalid(EndpointName, data); return; } @@ -291,7 +295,7 @@ public sealed class SseTransport : TransportBase } catch (JsonException ex) { - _logger.TransportEndpointEventParseFailed(_serverConfig.Id, _serverConfig.Name, data, ex); + _logger.TransportEndpointEventParseFailed(EndpointName, data, ex); throw new McpTransportException("Failed to parse endpoint event", ex); } } diff --git a/src/mcpdotnet/Protocol/Transport/SseTransportOptions.cs b/src/mcpdotnet/Protocol/Transport/SseClientTransportOptions.cs similarity index 95% rename from src/mcpdotnet/Protocol/Transport/SseTransportOptions.cs rename to src/mcpdotnet/Protocol/Transport/SseClientTransportOptions.cs index a9810cd..01a164b 100644 --- a/src/mcpdotnet/Protocol/Transport/SseTransportOptions.cs +++ b/src/mcpdotnet/Protocol/Transport/SseClientTransportOptions.cs @@ -3,7 +3,7 @@ /// /// Options for configuring the SSE transport. /// -public record SseTransportOptions +public record SseClientTransportOptions { /// /// Timeout for initial connection and endpoint event. diff --git a/src/mcpdotnet/Protocol/Transport/StdioTransport.cs b/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs similarity index 68% rename from src/mcpdotnet/Protocol/Transport/StdioTransport.cs rename to src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs index 8966428..a576f1a 100644 --- a/src/mcpdotnet/Protocol/Transport/StdioTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/StdioClientTransport.cs @@ -11,43 +11,46 @@ namespace McpDotNet.Protocol.Transport; /// /// Implements the MCP transport protocol over standard input/output streams. /// -public sealed class StdioTransport : TransportBase +public sealed class StdioClientTransport : TransportBase, IClientTransport { - private readonly StdioTransportOptions _options; + private readonly StdioClientTransportOptions _options; private readonly McpServerConfig _serverConfig; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonOptions; private Process? _process; private Task? _readTask; private CancellationTokenSource? _shutdownCts; private bool _processStarted; + private string EndpointName => $"Client (stdio) for ({_serverConfig.Id}: {_serverConfig.Name})"; + /// /// Initializes a new instance of the StdioTransport class. /// /// Configuration options for the transport. /// The server configuration for the transport. /// A logger factory for creating loggers. - public StdioTransport(StdioTransportOptions options, McpServerConfig serverConfig, ILoggerFactory loggerFactory) + public StdioClientTransport(StdioClientTransportOptions options, McpServerConfig serverConfig, ILoggerFactory loggerFactory) + : base(loggerFactory) { _options = options; _serverConfig = serverConfig; - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory.CreateLogger(); _jsonOptions = new JsonSerializerOptions().ConfigureForMcp(loggerFactory); } /// - public override async Task ConnectAsync(CancellationToken cancellationToken = default) + public async Task ConnectAsync(CancellationToken cancellationToken = default) { if (IsConnected) { - _logger.TransportAlreadyConnected(_serverConfig.Id, _serverConfig.Name); + _logger.TransportAlreadyConnected(EndpointName); throw new McpTransportException("Transport is already connected"); } try { - _logger.TransportConnecting(_serverConfig.Id, _serverConfig.Name); + _logger.TransportConnecting(EndpointName); _shutdownCts = new CancellationTokenSource(); @@ -78,7 +81,7 @@ public sealed class StdioTransport : TransportBase } } - _logger.CreateProcessForTransport(_serverConfig.Id, _serverConfig.Name, _options.Command, + _logger.CreateProcessForTransport(EndpointName, _options.Command, string.Join(", ", startInfo.ArgumentList), string.Join(", ", startInfo.Environment.Select(kvp => kvp.Key + "=" + kvp.Value)), startInfo.WorkingDirectory, _options.ShutdownTimeout.ToString()); @@ -87,28 +90,28 @@ public sealed class StdioTransport : TransportBase // Set up error logging _process.ErrorDataReceived += (sender, args) => { - _logger.TransportError(_serverConfig.Id, _serverConfig.Name, args.Data ?? "(no data)"); + _logger.TransportError(EndpointName, args.Data ?? "(no data)"); }; if (!_process.Start()) { - _logger.TransportProcessStartFailed(_serverConfig.Id, _serverConfig.Name); + _logger.TransportProcessStartFailed(EndpointName); throw new McpTransportException("Failed to start MCP server process"); } - _logger.TransportProcessStarted(_serverConfig.Id, _serverConfig.Name, _process.Id); + _logger.TransportProcessStarted(EndpointName, _process.Id); _processStarted = true; _process.BeginErrorReadLine(); // Start reading messages in the background _readTask = Task.Run(async () => await ReadMessagesAsync(_shutdownCts.Token)); - _logger.TransportReadingMessages(_serverConfig.Id, _serverConfig.Name); + _logger.TransportReadingMessages(EndpointName); SetConnected(true); } catch (Exception ex) { - _logger.TransportConnectFailed(_serverConfig.Id, _serverConfig.Name, ex); + _logger.TransportConnectFailed(EndpointName, ex); await CleanupAsync(cancellationToken); throw new McpTransportException("Failed to connect transport", ex); } @@ -119,7 +122,7 @@ public sealed class StdioTransport : TransportBase { if (!IsConnected || _process?.HasExited == true) { - _logger.TransportNotConnected(_serverConfig.Id, _serverConfig.Name); + _logger.TransportNotConnected(EndpointName); throw new McpTransportException("Transport is not connected"); } @@ -132,17 +135,17 @@ public sealed class StdioTransport : TransportBase try { var json = JsonSerializer.Serialize(message, _jsonOptions); - _logger.TransportSendingMessage(_serverConfig.Id, _serverConfig.Name, id, json); + _logger.TransportSendingMessage(EndpointName, id, json); // Write the message followed by a newline await _process!.StandardInput.WriteLineAsync(json.AsMemory(), cancellationToken); await _process.StandardInput.FlushAsync(); - _logger.TransportSentMessage(_serverConfig.Id, _serverConfig.Name, id); + _logger.TransportSentMessage(EndpointName, id); } catch (Exception ex) { - _logger.TransportSendFailed(_serverConfig.Id, _serverConfig.Name, id, ex); + _logger.TransportSendFailed(EndpointName, id, ex); throw new McpTransportException("Failed to send message", ex); } } @@ -158,17 +161,17 @@ public sealed class StdioTransport : TransportBase { try { - _logger.TransportEnteringReadMessagesLoop(_serverConfig.Id, _serverConfig.Name); + _logger.TransportEnteringReadMessagesLoop(EndpointName); using var reader = _process!.StandardOutput; while (!cancellationToken.IsCancellationRequested && !_process.HasExited) { - _logger.TransportWaitingForMessage(_serverConfig.Id, _serverConfig.Name); + _logger.TransportWaitingForMessage(EndpointName); var line = await reader.ReadLineAsync(cancellationToken); if (line == null) { - _logger.TransportEndOfStream(_serverConfig.Id, _serverConfig.Name); + _logger.TransportEndOfStream(EndpointName); break; } @@ -177,7 +180,7 @@ public sealed class StdioTransport : TransportBase continue; } - _logger.TransportReceivedMessage(_serverConfig.Id, _serverConfig.Name, line); + _logger.TransportReceivedMessage(EndpointName, line); try { @@ -189,31 +192,31 @@ public sealed class StdioTransport : TransportBase { messageId = messageWithId.Id.ToString(); } - _logger.TransportReceivedMessageParsed(_serverConfig.Id, _serverConfig.Name, messageId); + _logger.TransportReceivedMessageParsed(EndpointName, messageId); await WriteMessageAsync(message, cancellationToken); - _logger.TransportMessageWritten(_serverConfig.Id, _serverConfig.Name, messageId); + _logger.TransportMessageWritten(EndpointName, messageId); } else { - _logger.TransportMessageParseUnexpectedType(_serverConfig.Id, _serverConfig.Name, line); + _logger.TransportMessageParseUnexpectedType(EndpointName, line); } } catch (JsonException ex) { - _logger.TransportMessageParseFailed(_serverConfig.Id, _serverConfig.Name, line, ex); + _logger.TransportMessageParseFailed(EndpointName, line, ex); // Continue reading even if we fail to parse a message } } - _logger.TransportExitingReadMessagesLoop(_serverConfig.Id, _serverConfig.Name); + _logger.TransportExitingReadMessagesLoop(EndpointName); } catch (OperationCanceledException) { - _logger.TransportReadMessagesCancelled(_serverConfig.Id, _serverConfig.Name); + _logger.TransportReadMessagesCancelled(EndpointName); // Normal shutdown } catch (Exception ex) { - _logger.TransportReadMessagesFailed(_serverConfig.Id, _serverConfig.Name, ex); + _logger.TransportReadMessagesFailed(EndpointName, ex); } finally { @@ -223,27 +226,27 @@ public sealed class StdioTransport : TransportBase private async Task CleanupAsync(CancellationToken cancellationToken) { - _logger.TransportCleaningUp(_serverConfig.Id, _serverConfig.Name); + _logger.TransportCleaningUp(EndpointName); if (_process != null && _processStarted && !_process.HasExited) { try { // Try to close stdin to signal the process to exit - _logger.TransportClosingStdin(_serverConfig.Id, _serverConfig.Name); + _logger.TransportClosingStdin(EndpointName); _process.StandardInput.Close(); // Wait for the process to exit - _logger.TransportWaitingForShutdown(_serverConfig.Id, _serverConfig.Name); + _logger.TransportWaitingForShutdown(EndpointName); if (!_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds)) { // If it doesn't exit gracefully, terminate it - _logger.TransportKillingProcess(_serverConfig.Id, _serverConfig.Name); + _logger.TransportKillingProcess(EndpointName); _process.Kill(true); } } catch (Exception ex) { - _logger.TransportShutdownFailed(_serverConfig.Id, _serverConfig.Name, ex); + _logger.TransportShutdownFailed(EndpointName, ex); } _process.Dispose(); @@ -258,29 +261,29 @@ public sealed class StdioTransport : TransportBase { try { - _logger.TransportWaitingForReadTask(_serverConfig.Id, _serverConfig.Name); + _logger.TransportWaitingForReadTask(EndpointName); await _readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); } catch (TimeoutException) { - _logger.TransportCleanupReadTaskTimeout(_serverConfig.Id, _serverConfig.Name); + _logger.TransportCleanupReadTaskTimeout(EndpointName); // Continue with cleanup } catch (OperationCanceledException) { - _logger.TransportCleanupReadTaskCancelled(_serverConfig.Id, _serverConfig.Name); + _logger.TransportCleanupReadTaskCancelled(EndpointName); // Ignore cancellation } catch (Exception ex) { - _logger.TransportCleanupReadTaskFailed(_serverConfig.Id, _serverConfig.Name, ex); + _logger.TransportCleanupReadTaskFailed(EndpointName, ex); } _readTask = null; - _logger.TransportReadTaskCleanedUp(_serverConfig.Id, _serverConfig.Name); + _logger.TransportReadTaskCleanedUp(EndpointName); } SetConnected(false); - _logger.TransportCleanedUp(_serverConfig.Id, _serverConfig.Name); + _logger.TransportCleanedUp(EndpointName); } } \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Transport/StdioTransportOptions.cs b/src/mcpdotnet/Protocol/Transport/StdioClientTransportOptions.cs similarity index 95% rename from src/mcpdotnet/Protocol/Transport/StdioTransportOptions.cs rename to src/mcpdotnet/Protocol/Transport/StdioClientTransportOptions.cs index 220131f..0213ed7 100644 --- a/src/mcpdotnet/Protocol/Transport/StdioTransportOptions.cs +++ b/src/mcpdotnet/Protocol/Transport/StdioClientTransportOptions.cs @@ -4,7 +4,7 @@ namespace McpDotNet.Protocol.Transport; /// /// Represents configuration options for the stdio transport. /// -public record StdioTransportOptions +public record StdioClientTransportOptions { /// /// The command to execute to start the server process. diff --git a/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs b/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs new file mode 100644 index 0000000..a9820dc --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs @@ -0,0 +1,195 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using McpDotNet.Protocol.Messages; +using McpDotNet.Utils.Json; +using McpDotNet.Logging; + +namespace McpDotNet.Protocol.Transport; + +/// +/// Implements the MCP transport protocol over standard input/output streams. +/// +public sealed class StdioServerTransport : TransportBase, IServerTransport +{ + private readonly string _serverName; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + private Task? _readTask; + private CancellationTokenSource? _shutdownCts; + + private string EndpointName => $"Server (stdio) ({_serverName})"; + + /// + /// Initializes a new instance of the StdioServerTransport class. + /// + /// The name of the server. + /// A logger factory for creating loggers. + public StdioServerTransport(string serverName, ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _serverName = serverName; + _logger = loggerFactory.CreateLogger(); + _jsonOptions = new JsonSerializerOptions().ConfigureForMcp(loggerFactory); + } + + /// + public Task StartListeningAsync(CancellationToken cancellationToken = default) + { + _shutdownCts = new CancellationTokenSource(); + + _readTask = Task.Run(async () => await ReadMessagesAsync(_shutdownCts.Token)); + + SetConnected(true); + + return Task.CompletedTask; + } + + + /// + public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + _logger.TransportNotConnected(EndpointName); + throw new McpTransportException("Transport is not connected"); + } + + string id = "(no id)"; + if (message is IJsonRpcMessageWithId messageWithId) + { + id = messageWithId.Id.ToString(); + } + + try + { + var json = JsonSerializer.Serialize(message, _jsonOptions); + _logger.TransportSendingMessage(EndpointName, id, json); + + using (Console.OpenStandardOutput()) + { + await Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken); + await Console.Out.FlushAsync(); + } + + _logger.TransportSentMessage(EndpointName, id); + } + catch (Exception ex) + { + _logger.TransportSendFailed(EndpointName, id, ex); + throw new McpTransportException("Failed to send message", ex); + } + } + + /// + public override async ValueTask DisposeAsync() + { + await CleanupAsync(CancellationToken.None); + GC.SuppressFinalize(this); + } + + private async Task ReadMessagesAsync(CancellationToken cancellationToken) + { + try + { + _logger.TransportEnteringReadMessagesLoop(EndpointName); + + while (!cancellationToken.IsCancellationRequested) + { + _logger.TransportWaitingForMessage(EndpointName); + using (Console.OpenStandardInput()) + { + var reader = Console.In; + var line = await reader.ReadLineAsync(cancellationToken); + if (line == null) + { + _logger.TransportEndOfStream(EndpointName); + break; + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + _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); + _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 + } + } + } + _logger.TransportExitingReadMessagesLoop(EndpointName); + } + catch (OperationCanceledException) + { + _logger.TransportReadMessagesCancelled(EndpointName); + // Normal shutdown + } + catch (Exception ex) + { + _logger.TransportReadMessagesFailed(EndpointName, ex); + } + finally + { + await CleanupAsync(cancellationToken); + } + } + + private async Task CleanupAsync(CancellationToken cancellationToken) + { + _logger.TransportCleaningUp(EndpointName); + + _shutdownCts?.Cancel(); + _shutdownCts?.Dispose(); + _shutdownCts = null; + + if (_readTask != null) + { + try + { + _logger.TransportWaitingForReadTask(EndpointName); + await _readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + } + catch (TimeoutException) + { + _logger.TransportCleanupReadTaskTimeout(EndpointName); + // Continue with cleanup + } + catch (OperationCanceledException) + { + _logger.TransportCleanupReadTaskCancelled(EndpointName); + // Ignore cancellation + } + catch (Exception ex) + { + _logger.TransportCleanupReadTaskFailed(EndpointName, ex); + } + _readTask = null; + _logger.TransportReadTaskCleanedUp(EndpointName); + } + + SetConnected(false); + _logger.TransportCleanedUp(EndpointName); + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Transport/TransportBase.cs b/src/mcpdotnet/Protocol/Transport/TransportBase.cs index 62055d5..c0b559a 100644 --- a/src/mcpdotnet/Protocol/Transport/TransportBase.cs +++ b/src/mcpdotnet/Protocol/Transport/TransportBase.cs @@ -1,4 +1,6 @@ using System.Threading.Channels; +using Microsoft.Extensions.Logging; +using McpDotNet.Logging; using McpDotNet.Protocol.Messages; namespace McpDotNet.Protocol.Transport; @@ -6,15 +8,16 @@ namespace McpDotNet.Protocol.Transport; /// /// Base class for implementing MCP transports with common functionality. /// -public abstract class TransportBase : IMcpTransport +public abstract class TransportBase : ITransport { private readonly Channel _messageChannel; + private readonly ILogger _logger; private bool _isConnected; /// /// Initializes a new instance of the class. /// - protected TransportBase() + protected TransportBase(ILoggerFactory loggerFactory) { // Unbounded channel to prevent blocking on writes _messageChannel = Channel.CreateUnbounded(new UnboundedChannelOptions @@ -22,6 +25,7 @@ public abstract class TransportBase : IMcpTransport SingleReader = true, SingleWriter = true, }); + _logger = loggerFactory.CreateLogger(); } /// @@ -30,9 +34,6 @@ public abstract class TransportBase : IMcpTransport /// public ChannelReader MessageReader => _messageChannel.Reader; - /// - public abstract Task ConnectAsync(CancellationToken cancellationToken = default); - /// public abstract Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default); @@ -51,7 +52,9 @@ public abstract class TransportBase : IMcpTransport throw new McpTransportException("Transport is not connected"); } + _logger.TransportWritingMessageToChannel(message); await _messageChannel.Writer.WriteAsync(message, cancellationToken); + _logger.TransportMessageWrittenToChannel(); } /// diff --git a/src/mcpdotnet/Protocol/Types/Annotations.cs b/src/mcpdotnet/Protocol/Types/Annotations.cs index 6622a65..e168ea5 100644 --- a/src/mcpdotnet/Protocol/Types/Annotations.cs +++ b/src/mcpdotnet/Protocol/Types/Annotations.cs @@ -1,9 +1,10 @@ - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace McpDotNet.Protocol.Types; + /// /// Represents annotations that can be attached to content. +/// See the schema for details /// public record Annotations { diff --git a/src/mcpdotnet/Protocol/Types/Argument.cs b/src/mcpdotnet/Protocol/Types/Argument.cs index 8aa6397..e5eba40 100644 --- a/src/mcpdotnet/Protocol/Types/Argument.cs +++ b/src/mcpdotnet/Protocol/Types/Argument.cs @@ -4,6 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// Used for completion requests to provide additional context for the completion options. +/// See the schema for details /// public class Argument { diff --git a/src/mcpdotnet/Protocol/Types/CallToolRequestParams.cs b/src/mcpdotnet/Protocol/Types/CallToolRequestParams.cs new file mode 100644 index 0000000..9e05d77 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/CallToolRequestParams.cs @@ -0,0 +1,20 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Used by the client to invoke a tool provided by the server. +/// See the schema for details +/// +public class CallToolRequestParams +{ + /// + /// Tool name. + /// + [System.Text.Json.Serialization.JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Optional arguments to pass to the tool. + /// + [System.Text.Json.Serialization.JsonPropertyName("arguments")] + public Dictionary? Arguments { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/CallToolResponse.cs b/src/mcpdotnet/Protocol/Types/CallToolResponse.cs index 5005ced..f2ff6fe 100644 --- a/src/mcpdotnet/Protocol/Types/CallToolResponse.cs +++ b/src/mcpdotnet/Protocol/Types/CallToolResponse.cs @@ -1,8 +1,17 @@ namespace McpDotNet.Protocol.Types; /// -/// A response to a request to call a tool on the server. -/// See the schema for details +/// The server's response to a tool call. +/// +/// Any errors that originate from the tool SHOULD be reported inside the result +/// object, with `isError` set to true, _not_ as an MCP protocol-level error +/// response. Otherwise, the LLM would not be able to see that an error occurred +/// and self-correct. +/// +/// However, any errors in _finding_ the tool, an error indicating that the +/// server does not support tool calls, or any other exceptional conditions, +/// should be reported as an MCP error response. +/// See the schema for details /// public class CallToolResponse { diff --git a/src/mcpdotnet/Protocol/Types/Capabilities.cs b/src/mcpdotnet/Protocol/Types/Capabilities.cs index 53a75e2..20a5e13 100644 --- a/src/mcpdotnet/Protocol/Types/Capabilities.cs +++ b/src/mcpdotnet/Protocol/Types/Capabilities.cs @@ -1,9 +1,9 @@ - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace McpDotNet.Protocol.Types; /// /// Represents the capabilities that a client may support. +/// See the schema for details /// public record ClientCapabilities { @@ -28,6 +28,7 @@ public record ClientCapabilities /// /// Represents the roots capability configuration. +/// See the schema for details /// public record RootsCapability { @@ -40,6 +41,7 @@ public record RootsCapability /// /// Represents the sampling capability configuration. +/// See the schema for details /// public record SamplingCapability { @@ -48,6 +50,7 @@ public record SamplingCapability /// /// Represents the logging capability configuration. +/// See the schema for details /// public record LoggingCapability { @@ -56,6 +59,7 @@ public record LoggingCapability /// /// Represents the prompts capability configuration. +/// See the schema for details /// public record PromptsCapability { @@ -68,6 +72,7 @@ public record PromptsCapability /// /// Represents the resources capability configuration. +/// See the schema for details /// public record ResourcesCapability { @@ -86,6 +91,7 @@ public record ResourcesCapability /// /// Represents the tools capability configuration. +/// See the schema for details /// public record ToolsCapability { diff --git a/src/mcpdotnet/Protocol/Types/CompleteRequestParams.cs b/src/mcpdotnet/Protocol/Types/CompleteRequestParams.cs new file mode 100644 index 0000000..155ef33 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/CompleteRequestParams.cs @@ -0,0 +1,20 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// A request from the client to the server, to ask for completion options. +/// See the schema for details +/// +public class CompleteRequestParams +{ + /// + /// The reference's information + /// + [System.Text.Json.Serialization.JsonPropertyName("ref")] + public required Reference Ref { get; init; } + + /// + /// The argument's information + /// + [System.Text.Json.Serialization.JsonPropertyName("argument")] + public required Argument Argument { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/Completion.cs b/src/mcpdotnet/Protocol/Types/Completion.cs index 0f53706..c55931d 100644 --- a/src/mcpdotnet/Protocol/Types/Completion.cs +++ b/src/mcpdotnet/Protocol/Types/Completion.cs @@ -3,7 +3,8 @@ namespace McpDotNet.Protocol.Types; /// -/// Represents a completion object in the server's response. +/// Represents a completion object in the server's response +/// See the schema for details /// public class Completion { diff --git a/src/mcpdotnet/Protocol/Types/Content.cs b/src/mcpdotnet/Protocol/Types/Content.cs index 46ab08b..731eae3 100644 --- a/src/mcpdotnet/Protocol/Types/Content.cs +++ b/src/mcpdotnet/Protocol/Types/Content.cs @@ -4,7 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// Represents the content of a tool response. -/// See the schema for details +/// See the schema for details /// There are multiple subtypes of content, depending on the "type" field, these are represented as separate classes. /// public class Content diff --git a/src/mcpdotnet/Protocol/Types/ContextInclusion.cs b/src/mcpdotnet/Protocol/Types/ContextInclusion.cs index 013f4f4..12bb484 100644 --- a/src/mcpdotnet/Protocol/Types/ContextInclusion.cs +++ b/src/mcpdotnet/Protocol/Types/ContextInclusion.cs @@ -2,6 +2,7 @@ /// /// A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. +/// See the schema for details /// public enum ContextInclusion { diff --git a/src/mcpdotnet/Protocol/Types/CreateMessageRequestParams.cs b/src/mcpdotnet/Protocol/Types/CreateMessageRequestParams.cs index 040de51..c53d925 100644 --- a/src/mcpdotnet/Protocol/Types/CreateMessageRequestParams.cs +++ b/src/mcpdotnet/Protocol/Types/CreateMessageRequestParams.cs @@ -7,6 +7,7 @@ /// /// While these align with the protocol specification, /// clients have full discretion over model selection and should inform users before sampling. +/// See the schema for details /// public class CreateMessageRequestParams { diff --git a/src/mcpdotnet/Protocol/Types/CreateMessageResult.cs b/src/mcpdotnet/Protocol/Types/CreateMessageResult.cs index ff2dd44..afef1fc 100644 --- a/src/mcpdotnet/Protocol/Types/CreateMessageResult.cs +++ b/src/mcpdotnet/Protocol/Types/CreateMessageResult.cs @@ -6,6 +6,7 @@ namespace McpDotNet.Protocol.Types; /// The client's response to a sampling/create_message request from the server. /// The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) /// and decide whether to allow the server to see it. +/// See the schema for details /// public class CreateMessageResult { diff --git a/src/mcpdotnet/Protocol/Types/GetPromptRequestParams.cs b/src/mcpdotnet/Protocol/Types/GetPromptRequestParams.cs new file mode 100644 index 0000000..244cd78 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/GetPromptRequestParams.cs @@ -0,0 +1,20 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Used by the client to get a prompt provided by the server. +/// See the schema for details +/// +public class GetPromptRequestParams +{ + /// + /// he name of the prompt or prompt template. + /// + [System.Text.Json.Serialization.JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Arguments to use for templating the prompt. + /// + [System.Text.Json.Serialization.JsonPropertyName("arguments")] + public Dictionary? Arguments { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/GetPromptResult.cs b/src/mcpdotnet/Protocol/Types/GetPromptResult.cs index ec5939e..db1a51c 100644 --- a/src/mcpdotnet/Protocol/Types/GetPromptResult.cs +++ b/src/mcpdotnet/Protocol/Types/GetPromptResult.cs @@ -2,7 +2,7 @@ /// /// The server's response to a prompts/get request from the client. -/// See the schema for details +/// See the schema for details /// public class GetPromptResult { diff --git a/src/mcpdotnet/Protocol/Types/Implementation.cs b/src/mcpdotnet/Protocol/Types/Implementation.cs index d93cd12..bc860c9 100644 --- a/src/mcpdotnet/Protocol/Types/Implementation.cs +++ b/src/mcpdotnet/Protocol/Types/Implementation.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; /// /// Describes the name and version of an MCP implementation. +/// See the schema for details /// public record Implementation { diff --git a/src/mcpdotnet/Protocol/Types/InitializeRequestParams.cs b/src/mcpdotnet/Protocol/Types/InitializeRequestParams.cs new file mode 100644 index 0000000..417da14 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/InitializeRequestParams.cs @@ -0,0 +1,28 @@ +namespace McpDotNet.Protocol.Types; + +using System.Text.Json.Serialization; + +/// +/// Parameters for an initialization request sent to the server. +/// See the schema for details +/// +public record InitializeRequestParams +{ + /// + /// The version of the Model Context Protocol that the client wants to use. + /// + [JsonPropertyName("protocolVersion")] + + public required string ProtocolVersion { get; init; } + /// + /// The client's capabilities. + /// + [JsonPropertyName("capabilities")] + public ClientCapabilities? Capabilities { get; init; } + + /// + /// Information about the client implementation. + /// + [JsonPropertyName("clientInfo")] + public required Implementation ClientInfo { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/InitializeResult.cs b/src/mcpdotnet/Protocol/Types/InitializeResult.cs index 738f971..f319303 100644 --- a/src/mcpdotnet/Protocol/Types/InitializeResult.cs +++ b/src/mcpdotnet/Protocol/Types/InitializeResult.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; /// /// Result of the initialization request sent to the server. +/// See the schema for details /// public record InitializeResult { diff --git a/src/mcpdotnet/Protocol/Types/JsonSchema.cs b/src/mcpdotnet/Protocol/Types/JsonSchema.cs index 8dc868b..6881ffa 100644 --- a/src/mcpdotnet/Protocol/Types/JsonSchema.cs +++ b/src/mcpdotnet/Protocol/Types/JsonSchema.cs @@ -2,7 +2,7 @@ /// /// Represents a JSON schema for a tool's input arguments. -/// See the schema for details +/// See the schema for details /// public class JsonSchema { diff --git a/src/mcpdotnet/Protocol/Types/JsonSchemaProperty.cs b/src/mcpdotnet/Protocol/Types/JsonSchemaProperty.cs index f472ea5..d07d609 100644 --- a/src/mcpdotnet/Protocol/Types/JsonSchemaProperty.cs +++ b/src/mcpdotnet/Protocol/Types/JsonSchemaProperty.cs @@ -2,7 +2,7 @@ /// /// Represents a property in a JSON schema. -/// See the schema for details +/// See the schema for details /// public class JsonSchemaProperty { @@ -17,4 +17,4 @@ public class JsonSchemaProperty /// [System.Text.Json.Serialization.JsonPropertyName("description")] public string? Description { get; set; } -} +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/ListPromptsRequestParams.cs b/src/mcpdotnet/Protocol/Types/ListPromptsRequestParams.cs new file mode 100644 index 0000000..9b20907 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/ListPromptsRequestParams.cs @@ -0,0 +1,15 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Sent from the client to request a list of prompts and prompt templates the server has. +/// See the schema for details +/// +public class ListPromptsRequestParams +{ + /// + /// An opaque token representing the current pagination position. + /// If provided, the server should return results starting after this cursor. + /// + [System.Text.Json.Serialization.JsonPropertyName("cursor")] + public string? Cursor { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/ListPromptsResult.cs b/src/mcpdotnet/Protocol/Types/ListPromptsResult.cs index 749d2e0..d37689b 100644 --- a/src/mcpdotnet/Protocol/Types/ListPromptsResult.cs +++ b/src/mcpdotnet/Protocol/Types/ListPromptsResult.cs @@ -4,7 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// The server's response to a prompts/list request from the client. -/// See the schema for details +/// See the schema for details /// public class ListPromptsResult : PaginatedResult { diff --git a/src/mcpdotnet/Protocol/Types/ListResourcesRequestParams.cs b/src/mcpdotnet/Protocol/Types/ListResourcesRequestParams.cs new file mode 100644 index 0000000..0284ce0 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/ListResourcesRequestParams.cs @@ -0,0 +1,15 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Sent from the client to request a list of resources the server has. +/// See the schema for details +/// +public class ListResourcesRequestParams +{ + /// + /// An opaque token representing the current pagination position. + /// If provided, the server should return results starting after this cursor. + /// + [System.Text.Json.Serialization.JsonPropertyName("cursor")] + public string? Cursor { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/ListResourcesResult.cs b/src/mcpdotnet/Protocol/Types/ListResourcesResult.cs index afa60a4..a67f6f6 100644 --- a/src/mcpdotnet/Protocol/Types/ListResourcesResult.cs +++ b/src/mcpdotnet/Protocol/Types/ListResourcesResult.cs @@ -4,7 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// The server's response to a resources/list request from the client. -/// See the schema for details +/// See the schema for details /// public class ListResourcesResult : PaginatedResult { diff --git a/src/mcpdotnet/Protocol/Types/ListRootsRequestParams.cs b/src/mcpdotnet/Protocol/Types/ListRootsRequestParams.cs index b4f6d0b..4bacf85 100644 --- a/src/mcpdotnet/Protocol/Types/ListRootsRequestParams.cs +++ b/src/mcpdotnet/Protocol/Types/ListRootsRequestParams.cs @@ -2,6 +2,7 @@ /// /// A request from the server to get a list of root URIs from the client. +/// See the schema for details /// public class ListRootsRequestParams { diff --git a/src/mcpdotnet/Protocol/Types/ListRootsResult.cs b/src/mcpdotnet/Protocol/Types/ListRootsResult.cs index da87358..c641688 100644 --- a/src/mcpdotnet/Protocol/Types/ListRootsResult.cs +++ b/src/mcpdotnet/Protocol/Types/ListRootsResult.cs @@ -2,6 +2,7 @@ /// /// The client's response to a roots/list request from the server. +/// See the schema for details /// public class ListRootsResult { diff --git a/src/mcpdotnet/Protocol/Types/ListToolsRequestParams.cs b/src/mcpdotnet/Protocol/Types/ListToolsRequestParams.cs new file mode 100644 index 0000000..922a6b9 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/ListToolsRequestParams.cs @@ -0,0 +1,15 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Sent from the client to request a list of tools the server has. +/// See the schema for details +/// +public class ListToolsRequestParams +{ + /// + /// An opaque token representing the current pagination position. + /// If provided, the server should return results starting after this cursor. + /// + [System.Text.Json.Serialization.JsonPropertyName("cursor")] + public string? Cursor { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/ListToolsResult.cs b/src/mcpdotnet/Protocol/Types/ListToolsResult.cs index 15e59d0..8a70c61 100644 --- a/src/mcpdotnet/Protocol/Types/ListToolsResult.cs +++ b/src/mcpdotnet/Protocol/Types/ListToolsResult.cs @@ -4,7 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// A response to a request to list the tools available on the server. -/// See the schema for details +/// See the schema for details /// public class ListToolsResult : PaginatedResult { diff --git a/src/mcpdotnet/Protocol/Types/ModelHint.cs b/src/mcpdotnet/Protocol/Types/ModelHint.cs index dedc237..26e3188 100644 --- a/src/mcpdotnet/Protocol/Types/ModelHint.cs +++ b/src/mcpdotnet/Protocol/Types/ModelHint.cs @@ -4,6 +4,7 @@ /// Hints to use for model selection. /// Keys not declared here are currently left unspecified by the spec and are up /// to the client to interpret. +/// See the schema for details /// public class ModelHint { diff --git a/src/mcpdotnet/Protocol/Types/ModelPreferences.cs b/src/mcpdotnet/Protocol/Types/ModelPreferences.cs index e0b8d2b..97c2f21 100644 --- a/src/mcpdotnet/Protocol/Types/ModelPreferences.cs +++ b/src/mcpdotnet/Protocol/Types/ModelPreferences.cs @@ -11,6 +11,7 @@ /// These preferences are always advisory. The client MAY ignore them. It is also /// up to the client to decide how to interpret these preferences and how to /// balance them against other considerations. +/// See the schema for details /// public class ModelPreferences { diff --git a/src/mcpdotnet/Protocol/Types/PingResult.cs b/src/mcpdotnet/Protocol/Types/PingResult.cs new file mode 100644 index 0000000..b3359f7 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/PingResult.cs @@ -0,0 +1,9 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Dummy result for the ping request. +/// See the schema for details +/// +public record PingResult +{ +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/Prompt.cs b/src/mcpdotnet/Protocol/Types/Prompt.cs index ff67506..72c53ff 100644 --- a/src/mcpdotnet/Protocol/Types/Prompt.cs +++ b/src/mcpdotnet/Protocol/Types/Prompt.cs @@ -2,7 +2,7 @@ /// /// A prompt or prompt template that the server offers. -/// See the schema for details +/// See the schema for details /// public class Prompt { diff --git a/src/mcpdotnet/Protocol/Types/PromptArgument.cs b/src/mcpdotnet/Protocol/Types/PromptArgument.cs index 0a8d543..3a5f40b 100644 --- a/src/mcpdotnet/Protocol/Types/PromptArgument.cs +++ b/src/mcpdotnet/Protocol/Types/PromptArgument.cs @@ -2,7 +2,7 @@ /// /// Describes an argument that a prompt can accept. -/// See the schema for details +/// See the schema for details /// public class PromptArgument { diff --git a/src/mcpdotnet/Protocol/Types/PromptMessage.cs b/src/mcpdotnet/Protocol/Types/PromptMessage.cs index 7bbc054..e83b191 100644 --- a/src/mcpdotnet/Protocol/Types/PromptMessage.cs +++ b/src/mcpdotnet/Protocol/Types/PromptMessage.cs @@ -5,7 +5,7 @@ /// /// This is similar to `SamplingMessage`, but also supports the embedding of /// resources from the MCP server. -/// See the schema for details +/// See the schema for details /// public class PromptMessage { diff --git a/src/mcpdotnet/Protocol/Types/ReadResourceRequestParams.cs b/src/mcpdotnet/Protocol/Types/ReadResourceRequestParams.cs new file mode 100644 index 0000000..f6cd4a8 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/ReadResourceRequestParams.cs @@ -0,0 +1,14 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Sent from the client to the server, to read a specific resource URI. +/// See the schema for details +/// +public class ReadResourceRequestParams +{ + /// + /// The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. + /// + [System.Text.Json.Serialization.JsonPropertyName("uri")] + public string? Uri { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/ReadResourceResult.cs b/src/mcpdotnet/Protocol/Types/ReadResourceResult.cs index c2d35bd..69f47fd 100644 --- a/src/mcpdotnet/Protocol/Types/ReadResourceResult.cs +++ b/src/mcpdotnet/Protocol/Types/ReadResourceResult.cs @@ -2,7 +2,7 @@ /// /// The server's response to a resources/read request from the client. -/// See the schema for details +/// See the schema for details /// public class ReadResourceResult { diff --git a/src/mcpdotnet/Protocol/Types/Reference.cs b/src/mcpdotnet/Protocol/Types/Reference.cs index bf259c7..f4fb40a 100644 --- a/src/mcpdotnet/Protocol/Types/Reference.cs +++ b/src/mcpdotnet/Protocol/Types/Reference.cs @@ -4,6 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// Represents a reference to a resource or prompt. Umbrella type for both ResourceReference and PromptReference from the spec schema. +/// See the schema for details /// public class Reference { diff --git a/src/mcpdotnet/Protocol/Types/ResourceContents.cs b/src/mcpdotnet/Protocol/Types/ResourceContents.cs index 7943431..cb1ef2f 100644 --- a/src/mcpdotnet/Protocol/Types/ResourceContents.cs +++ b/src/mcpdotnet/Protocol/Types/ResourceContents.cs @@ -4,6 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// Represents the content of a resource. +/// See the schema for details /// public class ResourceContents { diff --git a/src/mcpdotnet/Protocol/Types/Resources.cs b/src/mcpdotnet/Protocol/Types/Resources.cs index a746ff8..7390fcd 100644 --- a/src/mcpdotnet/Protocol/Types/Resources.cs +++ b/src/mcpdotnet/Protocol/Types/Resources.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; /// /// Represents a known resource that the server is capable of reading. +/// See the schema for details /// public record Resource { diff --git a/src/mcpdotnet/Protocol/Types/Role.cs b/src/mcpdotnet/Protocol/Types/Role.cs index a41afce..104f0ea 100644 --- a/src/mcpdotnet/Protocol/Types/Role.cs +++ b/src/mcpdotnet/Protocol/Types/Role.cs @@ -4,6 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// Represents the type of role in the conversation. +/// See the schema for details /// public enum Role { diff --git a/src/mcpdotnet/Protocol/Types/Root.cs b/src/mcpdotnet/Protocol/Types/Root.cs index ee84529..aaceb6b 100644 --- a/src/mcpdotnet/Protocol/Types/Root.cs +++ b/src/mcpdotnet/Protocol/Types/Root.cs @@ -2,6 +2,7 @@ /// /// Represents a root URI and its metadata. +/// See the schema for details /// public class Root { diff --git a/src/mcpdotnet/Protocol/Types/SamplingMessage.cs b/src/mcpdotnet/Protocol/Types/SamplingMessage.cs index 49dc769..516c02b 100644 --- a/src/mcpdotnet/Protocol/Types/SamplingMessage.cs +++ b/src/mcpdotnet/Protocol/Types/SamplingMessage.cs @@ -4,6 +4,7 @@ namespace McpDotNet.Protocol.Types; /// /// Describes a message issued to or received from an LLM API. +/// See the schema for details /// public class SamplingMessage { diff --git a/src/mcpdotnet/Protocol/Types/ServerCapabilities.cs b/src/mcpdotnet/Protocol/Types/ServerCapabilities.cs index 50256d5..243c998 100644 --- a/src/mcpdotnet/Protocol/Types/ServerCapabilities.cs +++ b/src/mcpdotnet/Protocol/Types/ServerCapabilities.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; /// /// Represents the capabilities that a server may support. +/// See the schema for details /// public record ServerCapabilities { diff --git a/src/mcpdotnet/Protocol/Types/SubscribeToResourceRequestParams.cs b/src/mcpdotnet/Protocol/Types/SubscribeToResourceRequestParams.cs new file mode 100644 index 0000000..70f4f6c --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/SubscribeToResourceRequestParams.cs @@ -0,0 +1,14 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. +/// See the schema for details +/// +public class SubscribeToResourceRequestParams +{ + /// + /// The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. + /// + [System.Text.Json.Serialization.JsonPropertyName("uri")] + public string? Uri { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/Tool.cs b/src/mcpdotnet/Protocol/Types/Tool.cs index 42ad534..13de8e8 100644 --- a/src/mcpdotnet/Protocol/Types/Tool.cs +++ b/src/mcpdotnet/Protocol/Types/Tool.cs @@ -2,12 +2,12 @@ /// /// Represents a tool that the server is capable of calling. Part of the ListToolsResponse. -/// See the schema for details +/// See the schema for details /// public class Tool { /// - /// he name of the tool. + /// The name of the tool. /// [System.Text.Json.Serialization.JsonPropertyName("name")] public string Name { get; set; } = string.Empty; diff --git a/src/mcpdotnet/Protocol/Types/UnsubscribeFromResourceRequestParams.cs b/src/mcpdotnet/Protocol/Types/UnsubscribeFromResourceRequestParams.cs new file mode 100644 index 0000000..eb19d3c --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/UnsubscribeFromResourceRequestParams.cs @@ -0,0 +1,14 @@ +namespace McpDotNet.Protocol.Types; + +/// +/// Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. +/// See the schema for details +/// +public class UnsubscribeFromResourceRequestParams +{ + /// + /// The URI of the resource to unsubscribe from. + /// + [System.Text.Json.Serialization.JsonPropertyName("uri")] + public string? Uri { get; init; } +} diff --git a/src/mcpdotnet/Server/IMcpServer.cs b/src/mcpdotnet/Server/IMcpServer.cs new file mode 100644 index 0000000..2839619 --- /dev/null +++ b/src/mcpdotnet/Server/IMcpServer.cs @@ -0,0 +1,121 @@ +using McpDotNet.Protocol.Types; +using McpDotNet.Protocol.Messages; + +namespace McpDotNet.Server; + +/// +/// Represents a server that can communicate with a client using the MCP protocol. +/// +public interface IMcpServer : IAsyncDisposable +{ + /// + /// Gets a value indicating whether the server has been initialized. + /// + bool IsInitialized { get; } + + /// + /// Gets the capabilities supported by the client. + /// + ClientCapabilities? ClientCapabilities { get; } + + /// + /// Gets the version and implementation information of the client. + /// + Implementation? ClientInfo { get; } + + /// + /// Starts the server and begins listening for client requests. + /// + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Gets or sets the handler for list tools requests. + /// + Func>? ListToolsHandler { get; set; } + + /// + /// Gets or sets the handler for call tool requests. + /// + Func>? CallToolHandler { get; set; } + + /// + /// Gets or sets the handler for list prompts requests. + /// + Func>? ListPromptsHandler { get; set; } + + /// + /// Gets or sets the handler for get prompt requests. + /// + Func>? GetPromptHandler { get; set; } + + /// + /// Gets or sets the handler for list resources requests. + /// + Func>? ListResourcesHandler { get; set; } + + /// + /// Gets or sets the handler for read resources requests. + /// + Func>? ReadResourceHandler { get; set; } + + /// + /// Gets or sets the handler for get resources requests. + /// + Func>? GetCompletionHandler { get; set; } + + /// + /// Gets or sets the handler for subscribe to resources messages. + /// + Func? SubscribeToResourcesHandler { get; set; } + + /// + /// Gets or sets the handler for subscribe to resources messages. + /// + Func? 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. + /// Once all MCP features are implemented this will be made private, as it is purely a convenience for those who wish to implement features ahead of the library. + /// + /// The expected response type. + /// The JSON-RPC request to send. + /// A token to cancel the operation. + /// A task containing the client's response. + Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where T : class; + + /// + /// Registers a handler for notifications of a specific method. + /// + /// Constants for common notification methods + /// + /// 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. + /// 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); +} diff --git a/src/mcpdotnet/Server/McpServer.cs b/src/mcpdotnet/Server/McpServer.cs new file mode 100644 index 0000000..4355c9b --- /dev/null +++ b/src/mcpdotnet/Server/McpServer.cs @@ -0,0 +1,279 @@ + +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using McpDotNet.Protocol.Types; +using McpDotNet.Shared; +using McpDotNet.Protocol.Transport; +using McpDotNet.Logging; +using McpDotNet.Protocol.Messages; + +namespace McpDotNet.Server; + +/// +internal class McpServer : McpJsonRpcEndpoint, IMcpServer +{ + private IServerTransport _serverTransport; + private McpServerOptions _options; + private volatile bool _isInitializing; + private readonly ILogger _logger; + + /// + /// Creates a new instance of . + /// + /// Transport to use for the server + /// Configuration options for this server, including capabilities. + /// Make sure to accurately reflect exactly what capabilities the server supports and does not support. + /// Optional server instructions to send to clients + /// Logger factory to use for logging + /// + public McpServer(IServerTransport transport, McpServerOptions options, string? serverInstructions, ILoggerFactory loggerFactory) + : base(transport, loggerFactory) + { + _serverTransport = transport; + _options = options; + _logger = loggerFactory.CreateLogger(); + ServerInstructions = serverInstructions; + + if (options.Capabilities?.Tools != null) + { + SetRequestHandler("tools/list", + async (request) => { + if (ListToolsHandler == 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(request, CancellationTokenSource?.Token ?? CancellationToken.None); + }); + + SetRequestHandler("tools/call", + async (request) => { + if (CallToolHandler == 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(request, CancellationTokenSource?.Token ?? CancellationToken.None); + }); + } + if (options.Capabilities?.Prompts != null) + { + SetRequestHandler("prompts/list", + async (request) => + { + if (ListPromptsHandler == 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(request, CancellationTokenSource?.Token ?? CancellationToken.None); + }); + + SetRequestHandler("prompts/get", + async (request) => + { + if (GetPromptHandler == 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(request, CancellationTokenSource?.Token ?? CancellationToken.None); + }); + } + if (options.Capabilities?.Resources != null) + { + SetRequestHandler("resources/list", + async (request) => + { + if (ListResourcesHandler == 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(request, CancellationTokenSource?.Token ?? CancellationToken.None); + }); + + SetRequestHandler("resources/read", + async (request) => + { + if (ReadResourceHandler == 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(request, CancellationTokenSource?.Token ?? CancellationToken.None); + }); + } + SetRequestHandler("completion/complete", + async (request) => + { + if (GetCompletionHandler == null) + { + // This capability is not optional, so return an empty result if there is no handler + return new CompleteResult() + { + Completion = new() + { + Values = [], + Total = 0, + HasMore = false + } + }; + } + + return await GetCompletionHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); + }); + SetRequestHandler("initialize", + (request) => + { + ClientCapabilities = request.Capabilities ?? new(); + ClientInfo = request.ClientInfo; + return Task.FromResult(new InitializeResult() + { + ProtocolVersion = options.ProtocolVersion, + Instructions = ServerInstructions, + ServerInfo = _options.ServerInfo, + Capabilities = options.Capabilities ?? new ServerCapabilities(), + }); + }); + SetRequestHandler("ping", + (request) => + { + return Task.FromResult(new PingResult()); + }); + + OnNotification("notifications/initialized", (notification) => + { + IsInitialized = true; + return Task.CompletedTask; + }); + } + + public ClientCapabilities? ClientCapabilities { get; set; } + + /// + public Implementation? ClientInfo { get; set; } + + /// + public string? ServerInstructions { get; set; } + + /// + public Func>? ListToolsHandler { get; set; } + + /// + public Func>? CallToolHandler { get; set; } + + /// + public Func>? ListPromptsHandler { get; set; } + + /// + public Func>? GetPromptHandler { get; set; } + + /// + public Func>? ListResourcesHandler { get; set; } + + /// + public Func>? ReadResourceHandler { get; set; } + + /// + public Func>? GetCompletionHandler { get; set; } + + /// + public Func? SubscribeToResourcesHandler { get; set; } + + /// + public Func? UnsubscribeFromResourcesHandler { get; set; } + + /// + public override string EndpointName + { + get + { + return $"Server ({_options.ServerInfo.Name} {_options.ServerInfo.Version}), Client ({ClientInfo?.Name} {ClientInfo?.Version})"; + } + } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + if (_isInitializing) + { + _logger.ServerAlreadyInitializing(EndpointName); + throw new InvalidOperationException("Server is already initializing"); + } + _isInitializing = true; + + if (IsInitialized) + { + _logger.ServerAlreadyInitializing(EndpointName); + return; + } + + try + { + CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // Start listening for messages + await _serverTransport.StartListeningAsync(CancellationTokenSource.Token).ConfigureAwait(false); + + // Start processing messages + MessageProcessingTask = ProcessMessagesAsync(CancellationTokenSource.Token); + + // Unlike McpClient, we're not done initializing until we've received a message from the client, so we don't set IsInitialized here + } + catch (Exception e) + { + _logger.ServerInitializationError(EndpointName, e); + await CleanupAsync().ConfigureAwait(false); + throw; + } + } + + /// + public async Task RequestSamplingAsync(CreateMessageRequestParams request, CancellationToken cancellationToken) + { + 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); + } +} diff --git a/src/mcpdotnet/Server/McpServerException.cs b/src/mcpdotnet/Server/McpServerException.cs new file mode 100644 index 0000000..87232dd --- /dev/null +++ b/src/mcpdotnet/Server/McpServerException.cs @@ -0,0 +1,41 @@ +using McpDotNet.Client; + +namespace McpDotNet.Server; + +/// +/// Represents errors that occur in the MCP server. +/// +public class McpServerException : Exception +{ + /// + /// Gets the error code if this exception was caused by a JSON-RPC error response. + /// + public int? ErrorCode { get; } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public McpServerException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and error code. + /// + /// The message that describes the error. + /// The error code associated with the JSON-RPC error response. + public McpServerException(string message, int errorCode) : base(message) + { + ErrorCode = errorCode; + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public McpServerException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Server/McpServerFactory.cs b/src/mcpdotnet/Server/McpServerFactory.cs new file mode 100644 index 0000000..858fd4f --- /dev/null +++ b/src/mcpdotnet/Server/McpServerFactory.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Logging; +using McpDotNet.Protocol.Transport; + +namespace McpDotNet.Server; + +/// +/// Factory for creating instances. +/// This is the main entry point for creating a server. +/// Pass the server transport, options, and logger factory to the constructor. Server instructions are optional. +/// +/// Then call CreateServer to create a new server instance. +/// You can create multiple servers with the same factory, but the transport must be able to handle multiple connections. +/// +/// You must register handlers for all supported capabilities on the server instance, before calling BeginListeningAsync. +/// +public class McpServerFactory +{ + private readonly IServerTransport _serverTransport; + private readonly McpServerOptions _options; + private readonly ILoggerFactory _loggerFactory; + private readonly string? _serverInstructions; + + /// + /// Initializes a new instance of the class. + /// + /// Transport to use for the server + /// Configuration options for this server, including capabilities. + /// Make sure to accurately reflect exactly what capabilities the server supports and does not support. + /// Optional server instructions to send to clients + /// Logger factory to use for logging + public McpServerFactory(IServerTransport serverTransport, McpServerOptions options, ILoggerFactory loggerFactory, + string? serverInstructions = null) + { + _serverTransport = serverTransport; + _options = options; + _loggerFactory = loggerFactory; + _serverInstructions = serverInstructions; + } + + /// + /// Creates a new server instance. + /// + /// NB! You must register handlers for all supported capabilities on the server instance, before calling BeginListeningAsync. + /// + public IMcpServer CreateServer() + { + return new McpServer(_serverTransport, _options, _serverInstructions, _loggerFactory); + } +} diff --git a/src/mcpdotnet/Server/McpServerOptions.cs b/src/mcpdotnet/Server/McpServerOptions.cs new file mode 100644 index 0000000..37b577c --- /dev/null +++ b/src/mcpdotnet/Server/McpServerOptions.cs @@ -0,0 +1,32 @@ + +using McpDotNet.Protocol.Types; + +namespace McpDotNet.Server; + +/// +/// Configuration options for the MCP server. This is passed to the client during the initialization sequence, letting it know about the server's capabilities and +/// protocol version. +/// See the protocol specification for details on capability negotiation +/// +public record McpServerOptions +{ + /// + /// Information about this server implementation. + /// + public required Implementation ServerInfo { get; init; } + + /// + /// Server capabilities to advertise to the server. + /// + public ServerCapabilities? Capabilities { get; init; } + + /// + /// Protocol version to request from the server. + /// + public string ProtocolVersion { get; init; } = "2024-11-05"; + + /// + /// Timeout for initialization sequence. + /// + public TimeSpan InitializationTimeout { get; init; } = TimeSpan.FromSeconds(60); +} diff --git a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs new file mode 100644 index 0000000..f500a41 --- /dev/null +++ b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs @@ -0,0 +1,425 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using McpDotNet.Client; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Transport; +using McpDotNet.Utils.Json; +using McpDotNet.Logging; + +namespace McpDotNet.Shared; + +/// +/// Base class for an MCP JSON-RPC endpoint. This covers both MCP clients and servers. +/// It is not supported, nor necessary, to implement both client and server functionality in the same class. +/// If an application needs to act as both a client and a server, it should use separate objects for each. +/// This is especially true as a client represents a connection to one and only one server, and vice versa. +/// Any multi-client or multi-server functionality should be implemented at a higher level of abstraction. +/// +internal abstract class McpJsonRpcEndpoint +{ + private readonly ITransport _transport; + private readonly ConcurrentDictionary> _pendingRequests; + private readonly ConcurrentDictionary>> _notificationHandlers; + private readonly Dictionary>> _requestHandlers = new(); + private int _nextRequestId; + private Task? _messageProcessingTask; + private CancellationTokenSource? _cts; + private readonly JsonSerializerOptions _jsonOptions; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// An MCP transport implementation. + /// The logger factory. + public McpJsonRpcEndpoint(ITransport transport, ILoggerFactory loggerFactory) + { + _transport = transport; + _pendingRequests = new(); + _notificationHandlers = new(); + _nextRequestId = 1; + _jsonOptions = new JsonSerializerOptions().ConfigureForMcp(loggerFactory); + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Gets whether the endpoint is initialized and ready to process messages. + /// + public bool IsInitialized { get; set; } + + /// + /// Gets the name of the endpoint for logging and debug purposes. + /// + public abstract string EndpointName { get; } + + /// + /// Gets the transport implementation for the endpoint. Should generally not be needed outside of tests. + /// Sub-classes should store IClientTransport or IServerTransport injected during construction instead of casting this field. + /// + internal ITransport Transport => _transport; + + /// + /// Starts processing messages from the transport. This method will block until the transport is disconnected. + /// This is generally started in a background task or thread from the initialization logic of the derived class. + /// + internal async Task ProcessMessagesAsync(CancellationToken cancellationToken) + { + try + { + 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)), message); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Normal shutdown + _logger.EndpointMessageProcessingCancelled(EndpointName); + } + catch (NullReferenceException) + { + // Ignore reader disposal and mocked transport + } + } + + 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); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + + private async Task HandleMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken) + { + switch (message) + { + case JsonRpcRequest request: + if (_requestHandlers.TryGetValue(request.Method, out var handler)) + { + try + { + _logger.RequestHandlerCalled(EndpointName, request.Method); + var result = await handler(request); + _logger.RequestHandlerCompleted(EndpointName, request.Method); + await _transport.SendMessageAsync(new JsonRpcResponse + { + Id = request.Id, + JsonRpc = "2.0", + Result = result + }, cancellationToken); + } + 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); + } + } + else + { + _logger.NoHandlerFoundForRequest(EndpointName, request.Method); + } + 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()); + } + 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); + } + } + } + break; + + default: + _logger.EndpointHandlerUnexpectedMessageType(EndpointName, message.GetType().Name); + break; + } + } + + /// + /// 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 endpoint implementation. + /// + /// 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 + { + if (!_transport.IsConnected) + { + _logger.EndpointNotConnected(EndpointName); + throw new McpClientException("Transport is not connected"); + } + + // Set request ID + request.Id = RequestId.FromNumber(Interlocked.Increment(ref _nextRequestId)); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[request.Id] = tcs; + + try + { + // Expensive logging, use the logging framework to check if the logger is enabled + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.SendingRequestPayload(EndpointName, JsonSerializer.Serialize(request)); + } + + // Less expensive information logging + _logger.SendingRequest(EndpointName, request.Method); + + await _transport.SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + + _logger.RequestSentAwaitingResponse(EndpointName, request.Method, request.Id.ToString()); + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (response is JsonRpcError error) + { + _logger.RequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code); + throw new McpClientException($"Request failed (server side): {error.Error.Message}", error.Error.Code); + } + + if (response is JsonRpcResponse success) + { + // 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); + + // Not expensive logging because we're already converting to JSON in order to get the result object + _logger.RequestResponseReceivedPayload(EndpointName, resultJson); + _logger.RequestResponseReceived(EndpointName, request.Method); + + if (resultObject != null) + { + return resultObject; + } + + // 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)}"); + } + + // Unexpected response type + _logger.RequestInvalidResponseType(EndpointName, request.Method); + throw new McpClientException("Invalid response type"); + } + finally + { + _pendingRequests.TryRemove(request.Id, out _); + } + } + + /// + /// 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) + { + if (!_transport.IsConnected) + { + // TODO: _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); + throw new McpClientException("Transport is not connected"); + } + + var notification = new JsonRpcNotification { Method = method }; + + // Log if enabled + if (_logger.IsEnabled(LogLevel.Debug)) + { + // TODO: _logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification)); + } + + // Log basic info + // TODO: _logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, 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) + { + // TODO: _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); + throw new McpClientException("Transport is not connected"); + } + var notification = new JsonRpcNotification + { + Method = method, + Params = parameters + }; + // Log if enabled + if (_logger.IsEnabled(LogLevel.Debug)) + { + // TODO: _logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification)); + } + // Log basic info + // TODO: _logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, 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) + { + // TODO: _logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name); + throw new McpClientException("Transport is not connected"); + } + + await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false); + } + + /// + /// Registers a handler for incoming notifications of a specific method. + /// + /// Constants for common notification methods + /// + /// The notification method to handle. + /// The async handler function to process notifications. + public void OnNotification(string method, Func handler) + { + var handlers = _notificationHandlers.GetOrAdd(method, _ => new()); + lock (handlers) + { + handlers.Add(handler); + } + } + + /// + public async ValueTask DisposeAsync() + { + await CleanupAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } + + /// + /// Registers a handler for incoming requests of a specific method. + /// + /// Type of request payload + /// Type of response payload (not full RPC response + /// Method identifier to register for + /// Handler to be called when a request with specified method identifier is received + protected void SetRequestHandler(string method, Func> handler) + { + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + _requestHandlers[method] = async (request) => + { + // Convert the params JsonElement to our type using the same options + var jsonString = JsonSerializer.Serialize(request.Params); + var typedRequest = JsonSerializer.Deserialize(jsonString, _jsonOptions); + + return await handler(typedRequest); + }; + } + + /// + /// Task that processes incoming messages from the transport. + /// + protected Task? MessageProcessingTask + { + get => _messageProcessingTask; + set => _messageProcessingTask = value; + } + + /// + /// CancellationTokenSource used to cancel the message processing task. + /// + protected CancellationTokenSource? CancellationTokenSource + { + get => _cts; + set => _cts = value; + } + + /// + /// Cleans up the endpoint and releases resources. + /// + /// + protected async Task CleanupAsync() + { + _logger.CleaningUpEndpoint(EndpointName); + + _cts?.Cancel(); + + if (_messageProcessingTask != null) + { + try + { + await _messageProcessingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + } + + // Complete all pending requests with cancellation + foreach (var (_, tcs) in _pendingRequests) + { + tcs.TrySetCanceled(); + } + _pendingRequests.Clear(); + + await _transport.DisposeAsync().ConfigureAwait(false); + _cts?.Dispose(); + + IsInitialized = false; + + _logger.EndpointCleanedUp(EndpointName); + } +} diff --git a/src/mcpdotnet/mcpdotnet.csproj b/src/mcpdotnet/mcpdotnet.csproj index ff0163b..bdad25a 100644 --- a/src/mcpdotnet/mcpdotnet.csproj +++ b/src/mcpdotnet/mcpdotnet.csproj @@ -9,7 +9,7 @@ mcpdotnet - 0.10.0.1 + 1.0.0.1 PederHP .NET client library for the Model Context Protocol (MCP) https://github.com/PederHP/mcpdotnet diff --git a/tests/mcpdotnet.TestServer/Program.cs b/tests/mcpdotnet.TestServer/Program.cs new file mode 100644 index 0000000..9bd5b4b --- /dev/null +++ b/tests/mcpdotnet.TestServer/Program.cs @@ -0,0 +1,349 @@ +using McpDotNet.Protocol.Transport; +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Serilog; +using System.Text; + +internal class Program +{ + private static ILoggerFactory CreateLoggerFactory() + { + // Use serilog + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() // Capture all log levels + .WriteTo.File(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "TestServer_.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + + var logsPath = Path.Combine(AppContext.BaseDirectory, "testserver.log"); + return LoggerFactory.Create(builder => + { + builder.AddSerilog(); + }); + } + + private static async Task Main(string[] args) + { + Console.WriteLine("Starting server..."); + + McpServerOptions options = new McpServerOptions() + { + ServerInfo = new Implementation() { Name = "TestServer", Version = "1.0.0" }, + Capabilities = new ServerCapabilities() + { + Tools = new(), + Resources = new(), + Prompts = new(), + }, + ProtocolVersion = "2024-11-05" + }; + var loggerFactory = CreateLoggerFactory(); + McpServerFactory factory = new McpServerFactory(new StdioServerTransport("TestServer", loggerFactory), options, loggerFactory, + "This is a test server with only stub functionality"); + IMcpServer server = factory.CreateServer(); + + Console.WriteLine("Server object created, registering handlers."); + + #region Helped method + 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 + }; + } + #endregion + + #region Tools + server.ListToolsHandler = (request, cancellationToken) => + { + return Task.FromResult(new ListToolsResult() + { + Tools = + [ + new Tool() + { + Name = "echo", + Description = "Echoes the input back to the client.", + InputSchema = new JsonSchema() + { + Type = "object", + Properties = new Dictionary() + { + ["message"] = new JsonSchemaProperty() { Type = "string", Description = "The input to echo back." } + } + }, + }, + new Tool() + { + Name = "sampleLLM", + Description = "Samples from an LLM using MCP's sampling feature.", + InputSchema = new JsonSchema() + { + Type = "object", + Properties = new Dictionary() + { + ["prompt"] = new JsonSchemaProperty() { Type = "string", Description = "The prompt to send to the LLM" }, + ["maxTokens"] = new JsonSchemaProperty() { Type = "number", Description = "Maximum number of tokens to generate" } + } + }, + } + ] + }); + }; + + server.CallToolHandler = async (request, cancellationToken) => + { + if (request.Name == "echo") + { + if (request.Arguments is null || !request.Arguments.TryGetValue("message", out var message)) + { + throw new McpServerException("Missing required argument 'message'"); + } + return new CallToolResponse() + { + Content = [new Content() { Text = "Echo: " + message.ToString(), Type = "text" }] + }; + } + else if (request.Name == "sampleLLM") + { + if (request.Arguments is null || + !request.Arguments.TryGetValue("prompt", out var prompt) || + !request.Arguments.TryGetValue("maxTokens", out var maxTokens)) + { + throw new McpServerException("Missing required arguments 'prompt' and 'maxTokens'"); + } + var sampleResult = await server.RequestSamplingAsync(CreateRequestSamplingParams(prompt?.ToString() ?? "", "sampleLLM", Convert.ToInt32(maxTokens?.ToString())), + cancellationToken); + + return new CallToolResponse() + { + Content = [new Content() { Text = $"LLM sampling result: {sampleResult.Content.Text}", Type = "text" }] + }; + } + else + { + throw new McpServerException($"Unknown tool: {request.Name}"); + } + }; + #endregion + + #region Resources + List resources = new(); + List resourceContents = new(); + 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; + if (request is null) request = new(); + if (request.Cursor is not null) + { + try + { + var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.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.Uri is null) + { + throw new McpServerException("Missing required argument 'uri'"); + } + ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Uri); + if (contents is null) + { + throw new McpServerException("Resource not found"); + } + return Task.FromResult(new ReadResourceResult() + { + Contents = [contents] + }); + }; + #endregion + + #region Prompts + server.ListPromptsHandler = (request, cancellationToken) => + { + return Task.FromResult(new ListPromptsResult() + { + Prompts = [ + new Prompt() + { + Name = "simple_prompt", + Description = "A prompt without arguments" + }, + new Prompt() + { + Name = "complex_prompt", + Description = "A prompt with arguments", + Arguments = new() + { + new PromptArgument() + { + Name = "temperature", + Description = "Temperature setting", + Required = true + }, + new PromptArgument() + { + Name = "style", + Description = "Output style", + Required = false + } + } + } + ] + }); + }; + + server.GetPromptHandler = (request, cancellationToken) => + { + List messages = new(); + if (request.Name == "simple_prompt") + { + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = "This is a simple prompt without arguments." + } + }); + } + else if (request.Name == "complex_prompt") + { + string temperature = request.Arguments?["temperature"]?.ToString() ?? "unknown"; + string style = request.Arguments?["style"]?.ToString() ?? "unknown"; + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = $"This is a complex prompt with arguments: temperature={temperature}, style={style}" + } + }); + messages.Add(new PromptMessage() + { + Role = Role.Assistant, + Content = new Content() + { + Type = "text", + Text = "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?" + } + }); + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "image", + Data = MCP_TINY_IMAGE, + MimeType = "image/png" + } + }); + } + else + { + throw new McpServerException($"Unknown prompt: {request.Name}"); + } + + return Task.FromResult(new GetPromptResult() + { + Messages = messages + }); + }; + #endregion + + #region Sampling + #endregion + + Console.WriteLine("Server initialized."); + + await server.StartAsync(); + + Console.WriteLine("Server started."); + + // Run until process is stopped by the client (parent process) + while (true) + { + await Task.Delay(1000); + } + } + + const string MCP_TINY_IMAGE = + "iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAKsGlDQ1BJQ0MgUHJvZmlsZQAASImVlwdUU+kSgOfe9JDQEiIgJfQmSCeAlBBaAAXpYCMkAUKJMRBU7MriClZURLCs6KqIgo0idizYFsWC3QVZBNR1sWDDlXeBQ9jdd9575805c+a7c+efmf+e/z9nLgCdKZDJMlF1gCxpjjwyyI8dn5DIJvUABRiY0kBdIMyWcSMiwgCTUft3+dgGyJC9YzuU69/f/1fREImzhQBIBMbJomxhFsbHMe0TyuQ5ALg9mN9kbo5siK9gzJRjDWL8ZIhTR7hviJOHGY8fjomO5GGsDUCmCQTyVACaKeZn5wpTsTw0f4ztpSKJFGPsGbyzsmaLMMbqgiUWI8N4KD8n+S95Uv+WM1mZUyBIVfLIXoaF7C/JlmUK5v+fn+N/S1amYrSGOaa0NHlwJGaxvpAHGbNDlSxNnhI+yhLRcPwwpymCY0ZZmM1LHGWRwD9UuTZzStgop0gC+co8OfzoURZnB0SNsnx2pLJWipzHHWWBfKyuIiNG6U8T85X589Ki40Y5VxI7ZZSzM6JCx2J4Sr9cEansXywN8hurG6jce1b2X/Yr4SvX5qRFByv3LhjrXyzljuXMjlf2JhL7B4zFxCjjZTl+ylqyzAhlvDgzSOnPzo1Srs3BDuTY2gjlN0wXhESMMoRBELAhBjIhB+QggECQgBTEOeJ5Q2cUeLNl8+WS1LQcNhe7ZWI2Xyq0m8B2tHd0Bhi6syNH4j1r+C4irGtjvhWVAF4nBgcHT475Qm4BHEkCoNaO+SxnAKh3A1w5JVTIc0d8Q9cJCEAFNWCCDhiACViCLTiCK3iCLwRACIRDNCTATBBCGmRhnc+FhbAMCqAI1sNmKIOdsBv2wyE4CvVwCs7DZbgOt+AePIZ26IJX0AcfYQBBEBJCRxiIDmKImCE2iCPCQbyRACQMiUQSkCQkFZEiCmQhsgIpQoqRMmQXUokcQU4g55GrSCvyEOlAepF3yFcUh9JQJqqPmqMTUQ7KRUPRaHQGmorOQfPQfHQtWopWoAfROvQ8eh29h7ajr9B+HOBUcCycEc4Wx8HxcOG4RFwKTo5bjCvEleAqcNW4Rlwz7g6uHfca9wVPxDPwbLwt3hMfjI/BC/Fz8Ivxq/Fl+P34OvxF/B18B74P/51AJ+gRbAgeBD4hnpBKmEsoIJQQ9hJqCZcI9whdhI9EIpFFtCC6EYOJCcR04gLiauJ2Yg3xHLGV2EnsJ5FIOiQbkhcpnCQg5ZAKSFtJB0lnSbdJXaTPZBWyIdmRHEhOJEvJy8kl5APkM+Tb5G7yAEWdYkbxoIRTRJT5lHWUPZRGyk1KF2WAqkG1oHpRo6np1GXUUmo19RL1CfW9ioqKsYq7ylQVicpSlVKVwypXVDpUvtA0adY0Hm06TUFbS9tHO0d7SHtPp9PN6b70RHoOfS29kn6B/oz+WZWhaqfKVxWpLlEtV61Tva36Ro2iZqbGVZuplqdWonZM7abaa3WKurk6T12gvli9XP2E+n31fg2GhoNGuEaWxmqNAxpXNXo0SZrmmgGaIs18zd2aFzQ7GTiGCYPHEDJWMPYwLjG6mESmBZPPTGcWMQ8xW5h9WppazlqxWvO0yrVOa7WzcCxzFp+VyVrHOspqY30dpz+OO048btW46nG3x33SHq/tqy3WLtSu0b6n/VWHrROgk6GzQade56kuXtdad6ruXN0dupd0X49njvccLxxfOP7o+Ed6qJ61XqTeAr3dejf0+vUN9IP0Zfpb9S/ovzZgGfgapBtsMjhj0GvIMPQ2lBhuMjxr+JKtxeayM9ml7IvsPiM9o2AjhdEuoxajAWML4xjj5cY1xk9NqCYckxSTTSZNJn2mhqaTTReaVpk+MqOYcczSzLaYNZt9MrcwjzNfaV5v3mOhbcG3yLOosnhiSbf0sZxjWWF514poxbHKsNpudcsatXaxTrMut75pg9q42khsttu0TiBMcJ8gnVAx4b4tzZZrm2tbZdthx7ILs1tuV2/3ZqLpxMSJGyY2T/xu72Kfab/H/rGDpkOIw3KHRod3jtaOQsdyx7tOdKdApyVODU5vnW2cxc47nB+4MFwmu6x0aXL509XNVe5a7drrZuqW5LbN7T6HyYngrOZccSe4+7kvcT/l/sXD1SPH46jHH562nhmeBzx7JllMEk/aM6nTy9hL4LXLq92b7Z3k/ZN3u4+Rj8Cnwue5r4mvyHevbzfXipvOPch942fvJ/er9fvE8+At4p3zx/kH+Rf6twRoBsQElAU8CzQOTA2sCuwLcglaEHQumBAcGrwh+D5fny/kV/L7QtxCFoVcDKWFRoWWhT4Psw6ThzVORieHTN44+ckUsynSKfXhEM4P3xj+NMIiYk7EyanEqRFTy6e+iHSIXBjZHMWImhV1IOpjtF/0uujHMZYxipimWLXY6bGVsZ/i/OOK49rjJ8Yvir+eoJsgSWhIJCXGJu5N7J8WMG3ztK7pLtMLprfNsJgxb8bVmbozM2eenqU2SzDrWBIhKS7pQNI3QbigQtCfzE/eltwn5Am3CF+JfEWbRL1iL3GxuDvFK6U4pSfVK3Vjam+aT1pJ2msJT1ImeZsenL4z/VNGeMa+jMHMuMyaLHJWUtYJqaY0Q3pxtsHsebNbZTayAln7HI85m+f0yUPle7OR7BnZDTlMbDi6obBU/KDoyPXOLc/9PDd27rF5GvOk827Mt56/an53XmDezwvwC4QLmhYaLVy2sGMRd9Guxcji5MVNS0yW5C/pWhq0dP8y6rKMZb8st19evPzDirgVjfn6+UvzO38I+qGqQLVAXnB/pefKnT/if5T82LLKadXWVd8LRYXXiuyLSoq+rRauvrbGYU3pmsG1KWtb1rmu27GeuF66vm2Dz4b9xRrFecWdGydvrNvE3lS46cPmWZuvljiX7NxC3aLY0l4aVtqw1XTr+q3fytLK7pX7ldds09u2atun7aLtt3f47qjeqb+zaOfXnyQ/PdgVtKuuwryiZDdxd+7uF3ti9zT/zPm5cq/u3qK9f+6T7mvfH7n/YqVbZeUBvQPrqtAqRVXvwekHbx3yP9RQbVu9q4ZVU3QYDisOvzySdKTtaOjRpmOcY9XHzY5vq2XUFtYhdfPr+urT6tsbEhpaT4ScaGr0bKw9aXdy3ymjU+WntU6vO0M9k39m8Gze2f5zsnOvz6ee72ya1fT4QvyFuxenXmy5FHrpyuXAyxeauc1nr3hdOXXV4+qJa5xr9dddr9fdcLlR+4vLL7Utri11N91uNtzyv9XYOqn1zG2f2+fv+N+5fJd/9/q9Kfda22LaHtyffr/9gehBz8PMh28f5T4aeLz0CeFJ4VP1pyXP9J5V/Gr1a027a/vpDv+OG8+jnj/uFHa++i37t29d+S/oL0q6Dbsrexx7TvUG9t56Oe1l1yvZq4HXBb9r/L7tjeWb43/4/nGjL76v66387eC71e913u/74PyhqT+i/9nHrI8Dnwo/63ze/4Xzpflr3NfugbnfSN9K/7T6s/F76Pcng1mDgzKBXDA8CuAwRVNSAN7tA6AnADCwGYI6bWSmHhZk5D9gmOA/8cjcPSyuANWYGRqNeOcADmNqvhRAzRdgaCyK9gXUyUmpo/Pv8Kw+JAbYv8K0HECi2x6tebQU/iEjc/xf+v6nBWXWv9l/AV0EC6JTIblRAAAAeGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAJAAAAABAAAAkAAAAAEAAqACAAQAAAABAAAAFKADAAQAAAABAAAAFAAAAAAXNii1AAAACXBIWXMAABYlAAAWJQFJUiTwAAAB82lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOllSZXNvbHV0aW9uPjE0NDwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6T3JpZW50YXRpb24+MTwvdGlmZjpPcmllbnRhdGlvbj4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+MTQ0PC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KReh49gAAAjRJREFUOBGFlD2vMUEUx2clvoNCcW8hCqFAo1dKhEQpvsF9KrWEBh/ALbQ0KkInBI3SWyGPCCJEQliXgsTLefaca/bBWjvJzs6cOf/fnDkzOQJIjWm06/XKBEGgD8c6nU5VIWgBtQDPZPWtJE8O63a7LBgMMo/Hw0ql0jPjcY4RvmqXy4XMjUYDUwLtdhtmsxnYbDbI5/O0djqdFFKmsEiGZ9jP9gem0yn0ej2Yz+fg9XpfycimAD7DttstQTDKfr8Po9GIIg6Hw1Cr1RTgB+A72GAwgMPhQLBMJgNSXsFqtUI2myUo18pA6QJogefsPrLBX4QdCVatViklw+EQRFGEj88P2O12pEUGATmsXq+TaLPZ0AXgMRF2vMEqlQoJTSYTpNNpApvNZliv1/+BHDaZTAi2Wq1A3Ig0xmMej7+RcZjdbodUKkWAaDQK+GHjHPnImB88JrZIJAKFQgH2+z2BOczhcMiwRCIBgUAA+NN5BP6mj2DYff35gk6nA61WCzBn2JxO5wPM7/fLz4vD0E+OECfn8xl/0Gw2KbLxeAyLxQIsFgt8p75pDSO7h/HbpUWpewCike9WLpfB7XaDy+WCYrFI/slk8i0MnRRAUt46hPMI4vE4+Hw+ec7t9/44VgWigEeby+UgFArJWjUYOqhWG6x50rpcSfR6PVUfNOgEVRlTX0HhrZBKz4MZjUYWi8VoA+lc9H/VaRZYjBKrtXR8tlwumcFgeMWRbZpA9ORQWfVm8A/FsrLaxebd5wAAAABJRU5ErkJggg=="; +} \ No newline at end of file diff --git a/tests/mcpdotnet.TestServer/mcpdotnet.TestServer.csproj b/tests/mcpdotnet.TestServer/mcpdotnet.TestServer.csproj new file mode 100644 index 0000000..6451d08 --- /dev/null +++ b/tests/mcpdotnet.TestServer/mcpdotnet.TestServer.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + TestServer + + + + + + + + + + + + + diff --git a/tests/mcpdotnet.Tests/IntegrationTestFixture.cs b/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs similarity index 93% rename from tests/mcpdotnet.Tests/IntegrationTestFixture.cs rename to tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs index 9d95cc6..af0db42 100644 --- a/tests/mcpdotnet.Tests/IntegrationTestFixture.cs +++ b/tests/mcpdotnet.Tests/ClientIntegrationTestFixture.cs @@ -4,14 +4,14 @@ using Microsoft.Extensions.Logging; namespace McpDotNet.Tests; -public class IntegrationTestFixture : IDisposable +public class ClientIntegrationTestFixture : IDisposable { public ILoggerFactory LoggerFactory { get; } public McpClientFactory Factory { get; } public McpClientOptions DefaultOptions { get; } public McpServerConfig DefaultConfig { get; } - public IntegrationTestFixture() + public ClientIntegrationTestFixture() { LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddConsole() diff --git a/tests/mcpdotnet.Tests/IntegrationTests.cs b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs similarity index 90% rename from tests/mcpdotnet.Tests/IntegrationTests.cs rename to tests/mcpdotnet.Tests/ClientIntegrationTests.cs index 69dd543..d7a9bc6 100644 --- a/tests/mcpdotnet.Tests/IntegrationTests.cs +++ b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs @@ -5,11 +5,11 @@ using McpDotNet.Protocol.Types; namespace McpDotNet.Tests; -public class IntegrationTests : IClassFixture +public class ClientIntegrationTests : IClassFixture { - private readonly IntegrationTestFixture _fixture; + private readonly ClientIntegrationTestFixture _fixture; - public IntegrationTests(IntegrationTestFixture fixture) + public ClientIntegrationTests(ClientIntegrationTestFixture fixture) { _fixture = fixture; } @@ -419,4 +419,43 @@ public class IntegrationTests : IClassFixture // no response to check, if no exception is thrown, it's a success Assert.True(true); } + + [Fact] + public async Task CallTool_Stdio_MemoryServer() + { + // arrange + var config = new McpServerConfig + { + Id = "memory", + Name = "memory", + TransportType = "stdio", + TransportOptions = new Dictionary + { + ["command"] = "npx", + ["arguments"] = "-y @modelcontextprotocol/server-memory" + } + }; + + var options = new McpClientOptions + { + ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" } + }; + + var factory = new McpClientFactory([config], options, _fixture.LoggerFactory); + var client = await factory.GetClientAsync("memory"); + + await client.ConnectAsync(); + + // act + var result = await client.CallToolAsync( + "read_graph", + new Dictionary(), + CancellationToken.None + ); + + // assert + Assert.NotNull(result); + Assert.False(result.IsError); + var textContent = Assert.Single(result.Content, c => c.Type == "text"); + } } diff --git a/tests/mcpdotnet.Tests/McpClientFactoryTests.cs b/tests/mcpdotnet.Tests/McpClientFactoryTests.cs index 1a27adb..27cd9df 100644 --- a/tests/mcpdotnet.Tests/McpClientFactoryTests.cs +++ b/tests/mcpdotnet.Tests/McpClientFactoryTests.cs @@ -33,7 +33,7 @@ public class McpClientFactoryTests }; // Create a mock transport - var mockTransport = new Mock(); + var mockTransport = new Mock(); mockTransport.Setup(t => t.ConnectAsync(It.IsAny())) .Returns(Task.CompletedTask); mockTransport.Setup(t => t.IsConnected).Returns(true); @@ -75,7 +75,7 @@ public class McpClientFactoryTests }; // Create a mock transport - var mockTransport = new Mock(); + var mockTransport = new Mock(); mockTransport.Setup(t => t.ConnectAsync(It.IsAny())) .Returns(Task.CompletedTask); mockTransport.Setup(t => t.IsConnected).Returns(true); @@ -149,7 +149,7 @@ public class McpClientFactoryTests }; // Create a mock transport - var mockTransport = new Mock(); + var mockTransport = new Mock(); mockTransport.Setup(t => t.ConnectAsync(It.IsAny())) .Returns(Task.CompletedTask); mockTransport.Setup(t => t.IsConnected).Returns(true); @@ -202,7 +202,7 @@ public class McpClientFactoryTests }; // Create a mock transport - var mockTransport = new Mock(); + var mockTransport = new Mock(); mockTransport.Setup(t => t.ConnectAsync(It.IsAny())) .Returns(Task.CompletedTask); mockTransport.Setup(t => t.IsConnected).Returns(true); @@ -256,7 +256,7 @@ public class McpClientFactoryTests NullLoggerFactory.Instance ); - var transport = factory.TransportFactoryMethod(config) as SseTransport; + var transport = factory.TransportFactoryMethod(config) as SseClientTransport; // Assert Assert.NotNull(transport); @@ -278,7 +278,7 @@ public class McpClientFactoryTests Location = "http://localhost:8080" }; - var defaultOptions = new SseTransportOptions(); + var defaultOptions = new SseClientTransportOptions(); // Act var factory = new McpClientFactory( @@ -287,7 +287,7 @@ public class McpClientFactoryTests NullLoggerFactory.Instance ); - var transport = factory.TransportFactoryMethod(config) as SseTransport; + var transport = factory.TransportFactoryMethod(config) as SseClientTransport; // Assert Assert.NotNull(transport); @@ -314,7 +314,7 @@ public class McpClientFactoryTests } }; - var defaultOptions = new SseTransportOptions(); + var defaultOptions = new SseClientTransportOptions(); // Act var factory = new McpClientFactory( @@ -323,7 +323,7 @@ public class McpClientFactoryTests NullLoggerFactory.Instance ); - var transport = factory.TransportFactoryMethod(config) as SseTransport; + var transport = factory.TransportFactoryMethod(config) as SseClientTransport; // Assert Assert.NotNull(transport); diff --git a/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs b/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs new file mode 100644 index 0000000..0c395c1 --- /dev/null +++ b/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs @@ -0,0 +1,53 @@ +using McpDotNet.Client; +using McpDotNet.Configuration; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; + +namespace McpDotNet.Tests; + +public class ServerIntegrationTestFixture : IDisposable +{ + public ILoggerFactory LoggerFactory { get; } + public McpClientFactory Factory { get; } + public McpClientOptions DefaultOptions { get; } + public McpServerConfig DefaultConfig { get; } + + public ServerIntegrationTestFixture() + { + LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder => + builder.AddConsole() + .SetMinimumLevel(LogLevel.Debug)); + + DefaultOptions = new() + { + ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }, + Capabilities = new() { Sampling = new(), Roots = new() } + }; + + DefaultConfig = new McpServerConfig + { + Id = "test_server", + Name = "TestServer", + TransportType = "stdio", + TransportOptions = new Dictionary + { + ["command"] = "TestServer.exe", + // Change to ["arguments"] = "mcp-server-everything" if you want to run the server locally after creating a symlink + } + }; + + // Inject the mock transport into the factory + Factory = new McpClientFactory( + [DefaultConfig], + DefaultOptions, + LoggerFactory + ); + } + + public void Dispose() + { + var client = Factory.GetClientAsync("test_server").Result; + client.DisposeAsync().AsTask().Wait(); + LoggerFactory?.Dispose(); + } +} \ No newline at end of file diff --git a/tests/mcpdotnet.Tests/ServerIntegrationTests.cs b/tests/mcpdotnet.Tests/ServerIntegrationTests.cs new file mode 100644 index 0000000..bb7db7c --- /dev/null +++ b/tests/mcpdotnet.Tests/ServerIntegrationTests.cs @@ -0,0 +1,239 @@ +using McpDotNet.Client; +using McpDotNet.Configuration; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Types; + +namespace McpDotNet.Tests; + +public class ServerIntegrationTests : IClassFixture +{ + private readonly ServerIntegrationTestFixture _fixture; + + public ServerIntegrationTests(ServerIntegrationTestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task ConnectAndPing_Stdio_TestServer() + { + // Arrange + + // Act + var client = await _fixture.Factory.GetClientAsync("test_server"); + await client.PingAsync(CancellationToken.None); + + // Assert + Assert.NotNull(client); + } + + [Fact] + public async Task Connect_TestServer_ShouldProvideServerFields() + { + // Arrange + + // Act + var client = await _fixture.Factory.GetClientAsync("test_server"); + + // Assert + Assert.NotNull(client.ServerCapabilities); + Assert.NotNull(client.ServerInfo); + Assert.NotNull(client.ServerInstructions); + } + + [Fact] + public async Task ListTools_Stdio_TestServer() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var tools = await client.ListToolsAsync(); + + // assert + Assert.NotNull(tools); + Assert.NotEmpty(tools.Tools); + } + + [Fact] + public async Task CallTool_Stdio_EchoServer() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var result = await client.CallToolAsync( + "echo", + new Dictionary + { + ["message"] = "Hello MCP!" + }, + CancellationToken.None + ); + + // assert + Assert.NotNull(result); + Assert.False(result.IsError); + var textContent = Assert.Single(result.Content, c => c.Type == "text"); + Assert.Equal("Echo: Hello MCP!", textContent.Text); + } + + [Fact] + public async Task ListResources_Stdio_TestServer() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + + List allResources = new(); + string? cursor = null; + do + { + var resources = await client.ListResourcesAsync(cursor, CancellationToken.None); + allResources.AddRange(resources.Resources); + cursor = resources.NextCursor; + } + while (cursor != null); + + // The everything server provides 100 test resources + Assert.Equal(100, allResources.Count); + } + + [Fact] + public async Task ReadResource_Stdio_TextResource() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + // Odd numbered resources are text in the everything server (despite the docs saying otherwise) + // 1 is index 0, which is "even" in the 0-based index + // We copied this oddity to the test server + var result = await client.ReadResourceAsync("test://static/resource/1", CancellationToken.None); + + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.NotNull(result.Contents[0].Text); + } + + [Fact] + public async Task ReadResource_Stdio_BinaryResource() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + // Even numbered resources are binary in the everything server (despite the docs saying otherwise) + // 2 is index 1, which is "odd" in the 0-based index + // We copied this oddity to the test server + var result = await client.ReadResourceAsync("test://static/resource/2", CancellationToken.None); + + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.NotNull(result.Contents[0].Blob); + } + + [Fact] + public async Task ListPrompts_Stdio_TestServer() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var prompts = await client.ListPromptsAsync(); + + // assert + Assert.NotNull(prompts); + Assert.NotEmpty(prompts.Prompts); + // We could add specific assertions for the known prompts + Assert.Contains(prompts.Prompts, p => p.Name == "simple_prompt"); + Assert.Contains(prompts.Prompts, p => p.Name == "complex_prompt"); + } + + [Fact] + public async Task GetPrompt_Stdio_SimplePrompt() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var result = await client.GetPromptAsync("simple_prompt", null, CancellationToken.None); + + // assert + Assert.NotNull(result); + Assert.NotEmpty(result.Messages); + } + + [Fact] + public async Task GetPrompt_Stdio_ComplexPrompt() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + var arguments = new Dictionary + { + { "temperature", 0.7 }, + { "style", "formal" } + }; + var result = await client.GetPromptAsync("complex_prompt", arguments, CancellationToken.None); + + // assert + Assert.NotNull(result); + Assert.NotEmpty(result.Messages); + } + + [Fact] + public async Task GetPrompt_NonExistent_ThrowsException() + { + // arrange + + // act + var client = await _fixture.Factory.GetClientAsync("test_server"); + await Assert.ThrowsAsync(() => + client.GetPromptAsync("non_existent_prompt", null, CancellationToken.None)); + } + + [Fact] + public async Task Sampling_Stdio_TestServer() + { + // arrange + var client = await _fixture.Factory.GetClientAsync("test_server"); + + // Set up the sampling handler + int samplingHandlerCalls = 0; +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + client.SamplingHandler = async (_, _) => + { + samplingHandlerCalls++; + return new CreateMessageResult + { + Model = "test-model", + Role = "assistant", + Content = new Content + { + Type = "text", + Text = "Test response" + } + }; + }; +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + + // Call the server's sampleLLM tool which should trigger our sampling handler + var result = await client.CallToolAsync( + "sampleLLM", + new Dictionary + { + ["prompt"] = "Test prompt", + ["maxTokens"] = 100 + } + ); + + // assert + Assert.NotNull(result); + var textContent = Assert.Single(result.Content); + Assert.Equal("text", textContent.Type); + Assert.False(string.IsNullOrEmpty(textContent.Text)); + } +} diff --git a/tests/mcpdotnet.Tests/SseIntegrationTests.cs b/tests/mcpdotnet.Tests/SseIntegrationTests.cs index c429884..5ec5f34 100644 --- a/tests/mcpdotnet.Tests/SseIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/SseIntegrationTests.cs @@ -153,7 +153,7 @@ public class SseIntegrationTests // Act var client = await factory.GetClientAsync("test_server"); var mcpClient = (McpClient)client; - var transport = (SseTransport)mcpClient.Transport; + var transport = (SseClientTransport)mcpClient.Transport; // Wait for SSE connection to be established await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10)); diff --git a/tests/mcpdotnet.Tests/SseTransportTests.cs b/tests/mcpdotnet.Tests/SseTransportTests.cs index d95dfc4..d534461 100644 --- a/tests/mcpdotnet.Tests/SseTransportTests.cs +++ b/tests/mcpdotnet.Tests/SseTransportTests.cs @@ -19,7 +19,7 @@ public class SseTransportTests Location = "http://localhost:8080" }; - var transportOptions = new SseTransportOptions + var transportOptions = new SseClientTransportOptions { ConnectionTimeout = TimeSpan.FromSeconds(10), MaxReconnectAttempts = 2, @@ -31,7 +31,7 @@ public class SseTransportTests }; // Act - var transport = new SseTransport(transportOptions, config, NullLoggerFactory.Instance); + var transport = new SseClientTransport(transportOptions, config, NullLoggerFactory.Instance); // Assert Assert.NotNull(transport); @@ -53,7 +53,7 @@ public class SseTransportTests Location = "http://localhost:8080" }; - var transportOptions = new SseTransportOptions + var transportOptions = new SseClientTransportOptions { ConnectionTimeout = TimeSpan.FromSeconds(10), MaxReconnectAttempts = 2, @@ -65,7 +65,7 @@ public class SseTransportTests }; // Act - var transport = new SseTransport(transportOptions, config, NullLoggerFactory.Instance); + var transport = new SseClientTransport(transportOptions, config, NullLoggerFactory.Instance); // Assert Assert.True(string.IsNullOrEmpty(transport.MessageEndpoint?.ToString())); diff --git a/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj b/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj index 0aa0385..6d1db93 100644 --- a/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj +++ b/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -27,6 +27,13 @@ + + + + + + PreserveNewest +