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
This commit is contained in:
Peder Holdgaard Pedersen 2025-02-08 16:21:26 +01:00 • committed by GitHub
commit 8fe797e1f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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;
/// <inheritdoc/>
internal class McpClient : IMcpClient
internal class McpClient : McpJsonRpcEndpoint, IMcpClient
{
private readonly IMcpTransport _transport;
private readonly McpClientOptions _options;
private readonly ConcurrentDictionary<RequestId, TaskCompletionSource<IJsonRpcMessage>> _pendingRequests;
private readonly ConcurrentDictionary<string, List<Func<JsonRpcNotification,Task>>> _notificationHandlers;
private readonly Dictionary<string, Func<JsonRpcRequest, Task<object>>> _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<McpClient> _logger;
private volatile bool _isInitializing;
/// <inheritdoc/>
public bool IsInitialized => _isInitialized;
private readonly IClientTransport _clientTransport;
/// <inheritdoc/>
public ServerCapabilities? ServerCapabilities { get; private set; }
@ -43,20 +31,17 @@ internal class McpClient : IMcpClient
/// <summary>
/// Initializes a new instance of the <see cref="McpClient"/> class.
/// </summary>
/// <param name="transport">An MCP transport implementation.</param>
/// <param name="transport">The transport to use for communication with the server.</param>
/// <param name="options">Options for the client, defining protocol version and capabilities.</param>
/// <param name="serverConfig">The server configuration.</param>
/// <param name="loggerFactory">The logger factory.</param>
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<McpClient>();
_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
/// <inheritdoc/>
public async Task PingAsync(CancellationToken cancellationToken)
{
_logger.PingingServer(_serverConfig.Id, _serverConfig.Name);
_logger.PingingServer(EndpointName);
await SendRequestAsync<dynamic>(
new JsonRpcRequest
{
@ -198,7 +183,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<ListToolsResult> ListToolsAsync(string? cursor = null, CancellationToken cancellationToken = default)
{
_logger.ListingTools(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)");
_logger.ListingTools(EndpointName, cursor ?? "(null)");
return await SendRequestAsync<ListToolsResult>(
new JsonRpcRequest
{
@ -212,7 +197,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<ListPromptsResult> ListPromptsAsync(string? cursor = null, CancellationToken cancellationToken = default)
{
_logger.ListingPrompts(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)");
_logger.ListingPrompts(EndpointName, cursor ?? "(null)");
return await SendRequestAsync<ListPromptsResult>(
new JsonRpcRequest
{
@ -226,7 +211,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<GetPromptResult> GetPromptAsync(string name, Dictionary<string, object>? 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<GetPromptResult>(
new JsonRpcRequest
{
@ -240,7 +225,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<ListResourcesResult> ListResourcesAsync(string? cursor = null, CancellationToken cancellationToken = default)
{
_logger.ListingResources(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)");
_logger.ListingResources(EndpointName, cursor ?? "(null)");
return await SendRequestAsync<ListResourcesResult>(
new JsonRpcRequest
{
@ -254,7 +239,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<ReadResourceResult> ReadResourceAsync(string uri, CancellationToken cancellationToken = default)
{
_logger.ReadingResource(_serverConfig.Id, _serverConfig.Name, uri);
_logger.ReadingResource(EndpointName, uri);
return await SendRequestAsync<ReadResourceResult>(
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<CompleteResult>(
new JsonRpcRequest
{
@ -305,7 +290,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task SubscribeToResourceAsync(string uri, CancellationToken cancellationToken = default)
{
_logger.SubscribingToResource(_serverConfig.Id, _serverConfig.Name, uri);
_logger.SubscribingToResource(EndpointName, uri);
await SendRequestAsync<dynamic>(
new JsonRpcRequest
{
@ -319,11 +304,10 @@ internal class McpClient : IMcpClient
).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task UnsubscribeFromResourceAsync(string uri, CancellationToken cancellationToken = default)
{
_logger.UnsubscribingFromResource(_serverConfig.Id, _serverConfig.Name, uri);
_logger.UnsubscribingFromResource(EndpointName, uri);
await SendRequestAsync<dynamic>(
new JsonRpcRequest
{
@ -338,10 +322,10 @@ internal class McpClient : IMcpClient
}
/// <inheritdoc/>
public async Task<CallToolResponse> CallToolAsync(string toolName, Dictionary<string, object>? arguments = null,
public async Task<CallToolResponse> CallToolAsync(string toolName, Dictionary<string, object> 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<CallToolResponse>(
new JsonRpcRequest
{
@ -352,301 +336,33 @@ internal class McpClient : IMcpClient
).ConfigureAwait(false);
}
internal IMcpTransport Transport => _transport;
private static Dictionary<string, object?> CreateParametersDictionary(string nameParameter, Dictionary<string, object>? optionalArguments = null)
private static Dictionary<string, object?> CreateParametersDictionary(string nameParameter, Dictionary<string, object> arguments)
{
var parameters = new Dictionary<string, object?>
{
["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;
}
}
/// <inheritdoc/>
public async Task<T> SendRequestAsync<T>(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<IJsonRpcMessage>(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<T>(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 _);
}
}
/// <inheritdoc/>
public async Task SendNotificationAsync(string method, CancellationToken cancellationToken = default)
{
if (!_transport.IsConnected)
{
_logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name);
throw new McpClientException("Transport is not connected");
}
var notification = new JsonRpcNotification { Method = method };
// Log if enabled
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification));
}
// Log basic info
_logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, method);
await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task SendNotificationAsync<T>(string method, T parameters, CancellationToken cancellationToken = default)
{
if (!_transport.IsConnected)
{
_logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name);
throw new McpClientException("Transport is not connected");
}
var notification = new JsonRpcNotification
{
Method = method,
Params = parameters
};
// Log if enabled
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.SendingNotificationPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(notification));
}
// Log basic info
_logger.SendingNotification(_serverConfig.Id, _serverConfig.Name, method);
await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false);
}
private async Task SendNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken)
{
if (!_transport.IsConnected)
{
_logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name);
throw new McpClientException("Transport is not connected");
}
await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public void OnNotification(string method, Func<JsonRpcNotification,Task> handler)
{
var handlers = _notificationHandlers.GetOrAdd(method, _ => new());
lock (handlers)
{
handlers.Add(handler);
}
}
public Func<CreateMessageRequestParams, CancellationToken, Task<CreateMessageResult>>? SamplingHandler { get; set; }
/// <inheritdoc/>
public Func<ListRootsRequestParams, CancellationToken, Task<ListRootsResult>>? RootsHandler { get; set; }
private void SetRequestHandler<TRequest, TResponse>(string method, Func<TRequest, Task<TResponse>> 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<TRequest>(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);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
public override string EndpointName
{
await CleanupAsync().ConfigureAwait(false);
GC.SuppressFinalize(this);
get
{
return $"Client ({_serverConfig.Id}: {_serverConfig.Name})";
}
}
}