Overhaul McpClientFactory/McpServerFactory (#87)
Handlers are now specified before connecting the client/server. Otherwise, race conditions exist. Handlers move into being a part of the McpClientOptions/McpServerOptions, so that they're provided to the factories. Where relevant, the handlers are specified as part of the capability descriptors, so that they go hand in hand. The factories are no longer stateful. Instead of allocating a factory and then calling a create method on it, you just call a static factory method.
This commit is contained in:
parent
63b24a4036
commit
08b0a04dab
4 changed files with 1306 additions and 2243 deletions
|
|
@ -30,20 +30,6 @@ public interface IMcpClient : IAsyncDisposable
|
|||
/// </summary>
|
||||
string? ServerInstructions { get; }
|
||||
|
||||
/// <summary>Sets a handler for the named operation.</summary>
|
||||
/// <param name="operationName">The name of the operation.</param>
|
||||
/// <param name="handler">The handler. Each operation requires a specific delegate signature.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each operation may have only a single handler. Setting a handler for an operation that already has one
|
||||
/// will replace the existing handler.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="OperationNames"> provides constants for common operations.</see>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void SetOperationHandler(string operationName, Delegate handler);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a handler for server notifications of a specific method.
|
||||
/// </summary>
|
||||
|
|
@ -60,13 +46,6 @@ public interface IMcpClient : IAsyncDisposable
|
|||
/// </remarks>
|
||||
void AddNotificationHandler(string method, Func<JsonRpcNotification, Task> handler);
|
||||
|
||||
/// <summary>
|
||||
/// Establishes a connection to the server.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a generic JSON-RPC request to the server.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ using McpDotNet.Protocol.Transport;
|
|||
using McpDotNet.Protocol.Types;
|
||||
using McpDotNet.Shared;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
#pragma warning disable CA1508 // Avoid dead conditional code
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace McpDotNet.Client;
|
||||
|
||||
|
|
@ -15,15 +14,11 @@ namespace McpDotNet.Client;
|
|||
internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
|
||||
{
|
||||
private readonly McpClientOptions _options;
|
||||
private readonly McpServerConfig _serverConfig;
|
||||
private readonly ILogger<McpClient> _logger;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IClientTransport _clientTransport;
|
||||
|
||||
private volatile bool _isInitializing;
|
||||
|
||||
private Func<CreateMessageRequestParams?, CancellationToken, Task<CreateMessageResult>>? _samplingHandler;
|
||||
private Func<ListRootsRequestParams?, CancellationToken, Task<ListRootsResult>>? _rootsHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpClient"/> class.
|
||||
/// </summary>
|
||||
|
|
@ -31,26 +26,37 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
|
|||
/// <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(IClientTransport transport, McpClientOptions options, McpServerConfig serverConfig, ILoggerFactory loggerFactory)
|
||||
public McpClient(IClientTransport transport, McpClientOptions options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
||||
: base(transport, loggerFactory)
|
||||
{
|
||||
_options = options;
|
||||
_serverConfig = serverConfig;
|
||||
_logger = loggerFactory.CreateLogger<McpClient>();
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<McpClient>() ?? NullLogger.Instance;
|
||||
_clientTransport = transport;
|
||||
|
||||
EndpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
|
||||
|
||||
if (options.Capabilities?.Sampling is not null)
|
||||
if (options.Capabilities?.Sampling is { } samplingCapability)
|
||||
{
|
||||
if (samplingCapability.SamplingHandler is not { } samplingHandler)
|
||||
{
|
||||
throw new InvalidOperationException($"Sampling capability was set but it did not provide a handler.");
|
||||
}
|
||||
|
||||
SetRequestHandler<CreateMessageRequestParams, CreateMessageResult>(
|
||||
"sampling/createMessage", request => HandleRequest("Sampling", _samplingHandler, request));
|
||||
"sampling/createMessage",
|
||||
request => samplingHandler(request, CancellationTokenSource?.Token ?? default));
|
||||
}
|
||||
|
||||
if (options.Capabilities?.Roots is not null)
|
||||
if (options.Capabilities?.Roots is { } rootsCapability)
|
||||
{
|
||||
if (rootsCapability.RootsHandler is not { } rootsHandler)
|
||||
{
|
||||
throw new InvalidOperationException($"Roots capability was set but it did not provide a handler.");
|
||||
}
|
||||
|
||||
SetRequestHandler<ListRootsRequestParams, ListRootsResult>(
|
||||
"roots/list", request => HandleRequest("Roots", _rootsHandler, request));
|
||||
"roots/list",
|
||||
request => rootsHandler(request, CancellationTokenSource?.Token ?? default));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,47 +72,6 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
|
|||
/// <inheritdoc/>
|
||||
public override string EndpointName { get; }
|
||||
|
||||
public void SetOperationHandler(string operationName, Delegate handler)
|
||||
{
|
||||
if (operationName is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(operationName));
|
||||
}
|
||||
|
||||
if (handler is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(handler));
|
||||
}
|
||||
|
||||
if (!TrySetOperationHandler(OperationNames.Sampling, operationName, handler, ref _samplingHandler) &&
|
||||
!TrySetOperationHandler(OperationNames.Roots, operationName, handler, ref _rootsHandler))
|
||||
{
|
||||
throw new ArgumentException($"Unknown operation '{operationName}'", nameof(operationName));
|
||||
}
|
||||
|
||||
static bool TrySetOperationHandler<TFieldRequest, TFieldResponse>(
|
||||
string targetOperationName,
|
||||
string operationName,
|
||||
Delegate handler,
|
||||
ref Func<TFieldRequest, CancellationToken, Task<TFieldResponse>>? field)
|
||||
{
|
||||
if (operationName == targetOperationName)
|
||||
{
|
||||
if (handler is Func<TFieldRequest, CancellationToken, Task<TFieldResponse>> typed)
|
||||
{
|
||||
field = typed;
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Handler must be of type {typeof(Func<TFieldRequest, CancellationToken, Task<TFieldResponse>>)}",
|
||||
nameof(handler));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
|
@ -195,19 +160,4 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
|
|||
throw new McpClientException("Initialization timed out");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TResponse> HandleRequest<TRequest, TResponse>(
|
||||
string friendlyName,
|
||||
Func<TRequest, CancellationToken, Task<TResponse>>? handler,
|
||||
TRequest args)
|
||||
{
|
||||
if (handler is not null)
|
||||
{
|
||||
return await handler(args, CancellationTokenSource?.Token ?? CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Setting the capability, but not a handler means we have nothing to return to the server
|
||||
_logger.HandlerNotConfigured(friendlyName, EndpointName);
|
||||
throw new McpClientException($"{friendlyName} handler not configured.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
using McpDotNet.Protocol.Messages;
|
||||
using McpDotNet.Protocol.Types;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using McpDotNet.Utils;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Xml.XPath;
|
||||
|
||||
namespace McpDotNet.Client;
|
||||
|
||||
|
|
@ -20,10 +19,7 @@ public static class McpClientExtensions
|
|||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
public static Task SendNotificationAsync(this IMcpClient client, string method, object? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendMessageAsync(
|
||||
new JsonRpcNotification { Method = method, Params = parameters },
|
||||
|
|
@ -38,10 +34,7 @@ public static class McpClientExtensions
|
|||
/// <returns>A task that completes when the ping is successful.</returns>
|
||||
public static Task PingAsync(this IMcpClient client, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<dynamic>(
|
||||
CreateRequest("ping", null),
|
||||
|
|
@ -80,10 +73,7 @@ public static class McpClientExtensions
|
|||
/// <returns>A task containing the server's response with tool information.</returns>
|
||||
public static Task<ListToolsResult> ListToolsAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<ListToolsResult>(
|
||||
CreateRequest("tools/list", CreateCursorDictionary(cursor)),
|
||||
|
|
@ -122,10 +112,7 @@ public static class McpClientExtensions
|
|||
/// <returns>A task containing the server's response with prompt information.</returns>
|
||||
public static Task<ListPromptsResult> ListPromptsAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<ListPromptsResult>(
|
||||
CreateRequest("prompts/list", CreateCursorDictionary(cursor)),
|
||||
|
|
@ -142,10 +129,7 @@ public static class McpClientExtensions
|
|||
/// <returns>A task containing the prompt's content and messages.</returns>
|
||||
public static Task<GetPromptResult> GetPromptAsync(this IMcpClient client, string name, Dictionary<string, object>? arguments = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<GetPromptResult>(
|
||||
CreateRequest("prompts/get", CreateParametersDictionary(name, arguments)),
|
||||
|
|
@ -183,10 +167,7 @@ public static class McpClientExtensions
|
|||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
public static Task<ListResourcesResult> ListResourcesAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<ListResourcesResult>(
|
||||
CreateRequest("resources/list", CreateCursorDictionary(cursor)),
|
||||
|
|
@ -201,10 +182,7 @@ public static class McpClientExtensions
|
|||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
public static Task<ReadResourceResult> ReadResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<ReadResourceResult>(
|
||||
CreateRequest("resources/read", new() { ["uri"] = uri }),
|
||||
|
|
@ -221,22 +199,9 @@ public static class McpClientExtensions
|
|||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
public static Task<CompleteResult> GetCompletionAsync(this IMcpClient client, Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
if (reference is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(reference));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(argumentName))
|
||||
{
|
||||
throw argumentName is null ?
|
||||
new ArgumentNullException(nameof(argumentName)) :
|
||||
new ArgumentException("Argument name cannot be empty.", nameof(argumentName));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNull(reference);
|
||||
Throw.IfNullOrWhiteSpace(argumentName);
|
||||
|
||||
if (!reference.Validate(out string? validationMessage))
|
||||
{
|
||||
|
|
@ -260,10 +225,7 @@ public static class McpClientExtensions
|
|||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
public static Task SubscribeToResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<dynamic>(
|
||||
CreateRequest("resources/subscribe", new() { ["uri"] = uri }),
|
||||
|
|
@ -278,10 +240,7 @@ public static class McpClientExtensions
|
|||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
public static Task UnsubscribeFromResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<dynamic>(
|
||||
CreateRequest("resources/unsubscribe", new() { ["uri"] = uri }),
|
||||
|
|
@ -298,42 +257,13 @@ public static class McpClientExtensions
|
|||
/// <returns>A task containing the tool's response.</returns>
|
||||
public static Task<CallToolResponse> CallToolAsync(this IMcpClient client, string toolName, Dictionary<string, object> arguments, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
Throw.IfNull(client);
|
||||
|
||||
return client.SendRequestAsync<CallToolResponse>(
|
||||
CreateRequest("tools/call", CreateParametersDictionary(toolName, arguments)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Sets the handler for server sampling requests.</summary>
|
||||
/// <param name="client">The client.</param>
|
||||
/// <param name="handler">The sampling request handler.</param>
|
||||
public static void SetSamplingHandler(this IMcpClient client, Func<CreateMessageRequestParams?, CancellationToken, Task<CreateMessageResult>> handler)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
client.SetOperationHandler(OperationNames.Sampling, handler);
|
||||
}
|
||||
|
||||
/// <summary>Sets the handler for server roots requests.</summary>
|
||||
/// <param name="client">The client.</param>
|
||||
/// <param name="handler">The roots request handler.</param>
|
||||
public static void SetRootsHandler(this IMcpClient client, Func<ListRootsRequestParams?, CancellationToken, Task<ListRootsResult>> handler)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
client.SetOperationHandler(OperationNames.Roots, handler);
|
||||
}
|
||||
|
||||
private static JsonRpcRequest CreateRequest(string method, Dictionary<string, object?>? parameters) =>
|
||||
new JsonRpcRequest
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,236 +3,129 @@ using System.Runtime.InteropServices;
|
|||
using McpDotNet.Configuration;
|
||||
using McpDotNet.Logging;
|
||||
using McpDotNet.Protocol.Transport;
|
||||
using McpDotNet.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace McpDotNet.Client;
|
||||
/// <summary>
|
||||
/// Factory for creating MCP clients based on configuration. It caches clients for reuse, so it is safe to call GetClientAsync multiple times.
|
||||
/// Call GetClientAsync to get a client for a specific server (by ID), which will create a new client and connect if it doesn't already exist.
|
||||
/// All server configurations must be passed in the constructor.
|
||||
/// Capabilities (as defined in client options) are shared across all clients, as the client host can always decide not to use certain capabilities.
|
||||
/// </summary>
|
||||
public class McpClientFactory : IDisposable
|
||||
|
||||
/// <summary>Provides factory methods for creating MCP clients.</summary>
|
||||
public static class McpClientFactory
|
||||
{
|
||||
private const string ARGUMENTS_OPTIONS_KEY = "arguments";
|
||||
private const string COMMAND_OPTIONS_KEY = "command";
|
||||
private readonly Dictionary<string, McpServerConfig> _serverConfigs;
|
||||
private readonly McpClientOptions _clientOptions;
|
||||
private readonly Dictionary<string, IMcpClient> _clients = [];
|
||||
private readonly Func<McpServerConfig, IClientTransport> _transportFactoryMethod;
|
||||
private readonly Func<IClientTransport, McpServerConfig, McpClientOptions, IMcpClient> _clientFactoryMethod;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<McpClientFactory> _logger;
|
||||
private bool _isDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether clients should be disposed when the factory is disposed.
|
||||
/// </summary>
|
||||
public bool DisposeClientsOnDispose { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpClientFactory"/> class.
|
||||
/// It is not necessary to pass factory methods for creating transports and clients, as default implementations are provided.
|
||||
/// Custom factory methods can be provided for mocking or to use custom transport or client implementations.
|
||||
/// </summary>
|
||||
/// <param name="serverConfigs">Configuration objects for each server the factory should support.</param>
|
||||
/// <param name="clientOptions">A configuration object which specifies client capabilities and protocol version.</param>
|
||||
/// <summary>Creates an <see cref="IMcpClient"/>, connecting it to the specified server.</summary>
|
||||
/// <param name="serverConfig">Configuration for the target server to which the client should connect.</param>
|
||||
/// <param name="clientOptions">A client configuration object which specifies client capabilities and protocol version.</param>
|
||||
/// <param name="createTransportFunc">An optional factory method which returns transport implementations based on a server configuration.</param>
|
||||
/// <param name="loggerFactory">A logger factory for creating loggers for clients.</param>
|
||||
/// <param name="transportFactoryMethod">An optional factory method which returns transport implementations based on a server configuration.</param>
|
||||
/// <param name="clientFactoryMethod">An optional factory method which creates a client based on client options and transport implementation. </param>
|
||||
public McpClientFactory(
|
||||
IEnumerable<McpServerConfig> serverConfigs,
|
||||
McpClientOptions clientOptions,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
Func<McpServerConfig, IClientTransport>? transportFactoryMethod = null,
|
||||
Func<IClientTransport, McpServerConfig, McpClientOptions, IMcpClient>? clientFactoryMethod = null)
|
||||
{
|
||||
if (serverConfigs is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(serverConfigs));
|
||||
}
|
||||
|
||||
if (clientOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(clientOptions));
|
||||
}
|
||||
|
||||
loggerFactory ??= NullLoggerFactory.Instance;
|
||||
|
||||
_serverConfigs = serverConfigs.ToDictionary(c => c.Id);
|
||||
_clientOptions = clientOptions;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<McpClientFactory>();
|
||||
_transportFactoryMethod = transportFactoryMethod ?? CreateTransport;
|
||||
_clientFactoryMethod = clientFactoryMethod ?? ((transport, serverConfig, options) => new McpClient(transport, options, serverConfig, loggerFactory));
|
||||
|
||||
// Initialize commands for stdio transport, this is to run commands in a shell even if specified directly, as otherwise
|
||||
// the stdio protocol will not work correctly.
|
||||
_logger.InitializingStdioCommands();
|
||||
foreach (var config in _serverConfigs.Values.Where(c => c.TransportType.Equals(TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
InitializeCommand(config);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a client for the specified server. The first time a server is requested, a new client is created and connected.
|
||||
/// Note that this will often spawn the server process during connection, so in some cases you want to call this method only when needed.
|
||||
/// In other cases, you may want to call it ahead of time to ensure the server is ready when needed, as it may take some time to start up.
|
||||
/// </summary>
|
||||
/// <param name="serverId">The ID of the server to connect to. It must have been passed in the serverConfigs when constructing the factory.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
public async Task<IMcpClient> GetClientAsync(string serverId, CancellationToken cancellationToken = default)
|
||||
/// <returns>An <see cref="IMcpClient"/> that's connected to the specified server.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serverConfig"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="clientOptions"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="serverConfig"/> contains invalid information.</exception>
|
||||
/// <exception cref="InvalidOperationException"><paramref name="createTransportFunc"/> returns an invalid transport.</exception>
|
||||
public static async Task<IMcpClient> CreateAsync(
|
||||
McpServerConfig serverConfig,
|
||||
McpClientOptions clientOptions,
|
||||
Func<McpServerConfig, ILoggerFactory?, IClientTransport>? createTransportFunc = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_serverConfigs.TryGetValue(serverId, out var config))
|
||||
Throw.IfNull(serverConfig);
|
||||
Throw.IfNull(clientOptions);
|
||||
|
||||
createTransportFunc ??= CreateTransport;
|
||||
|
||||
string endpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
|
||||
|
||||
var logger = loggerFactory?.CreateLogger(typeof(McpClientFactory)) ?? NullLogger.Instance;
|
||||
logger.CreatingClient(endpointName);
|
||||
|
||||
var transport =
|
||||
createTransportFunc(serverConfig, loggerFactory) ??
|
||||
throw new InvalidOperationException($"{nameof(createTransportFunc)} returned a null transport.");
|
||||
|
||||
try
|
||||
{
|
||||
_logger.ServerNotFound(serverId);
|
||||
throw new ArgumentException($"Server with ID '{serverId}' not found.", nameof(serverId));
|
||||
McpClient client = new(transport, clientOptions, serverConfig, loggerFactory);
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.ClientCreated(endpointName);
|
||||
return client;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
string endpointName = $"Client ({serverId}: {config.Name})";
|
||||
|
||||
if (_clients.TryGetValue(serverId, out var existingClient))
|
||||
catch
|
||||
{
|
||||
_logger.ClientExists(endpointName);
|
||||
return existingClient;
|
||||
await transport.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.CreatingClient(endpointName);
|
||||
|
||||
var transport = _transportFactoryMethod(config);
|
||||
var client = _clientFactoryMethod(transport, config, _clientOptions);
|
||||
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.ClientCreated(endpointName);
|
||||
|
||||
_clients[serverId] = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
internal Func<McpServerConfig, IClientTransport> TransportFactoryMethod => _transportFactoryMethod;
|
||||
|
||||
private IClientTransport CreateTransport(McpServerConfig config)
|
||||
private static IClientTransport CreateTransport(McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
string endpointName = $"Client ({config.Id}: {config.Name})";
|
||||
|
||||
var options = string.Join(", ", config.TransportOptions?.Select(kv => $"{kv.Key}={kv.Value}") ?? []);
|
||||
_logger.CreatingTransport(endpointName, config.TransportType, options);
|
||||
|
||||
if (string.Equals(config.TransportType, TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(serverConfig.TransportType, TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string? command = serverConfig.TransportOptions?.GetValueOrDefault("command");
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
command = serverConfig.Location;
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
throw new ArgumentException("Command is required for stdio transport.", nameof(serverConfig));
|
||||
}
|
||||
}
|
||||
|
||||
string? arguments = serverConfig.TransportOptions?.GetValueOrDefault("arguments");
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
|
||||
serverConfig.TransportType.Equals(TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrEmpty(command) &&
|
||||
!string.Equals(Path.GetFileName(command), "cmd.exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// On Windows, for stdio, we need to wrap non-shell commands with cmd.exe /c {command} (usually npx or uvicorn).
|
||||
// The stdio transport will not work correctly if the command is not run in a shell.
|
||||
arguments = string.IsNullOrWhiteSpace(arguments) ?
|
||||
$"/c {command}" :
|
||||
$"/c {command} {arguments}";
|
||||
command = "cmd.exe";
|
||||
}
|
||||
|
||||
return new StdioClientTransport(new StdioClientTransportOptions
|
||||
{
|
||||
Command = GetCommand(config),
|
||||
Arguments = config.TransportOptions?.GetValueOrDefault(ARGUMENTS_OPTIONS_KEY)?.Split(' '),
|
||||
WorkingDirectory = config.TransportOptions?.GetValueOrDefault("workingDirectory"),
|
||||
EnvironmentVariables = config.TransportOptions?
|
||||
Command = command!,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = serverConfig.TransportOptions?.GetValueOrDefault("workingDirectory"),
|
||||
EnvironmentVariables = serverConfig.TransportOptions?
|
||||
.Where(kv => kv.Key.StartsWith("env:", StringComparison.Ordinal))
|
||||
.ToDictionary(kv => kv.Key.Substring(4), kv => kv.Value),
|
||||
ShutdownTimeout = TimeSpan.TryParse(config.TransportOptions?.GetValueOrDefault("shutdownTimeout"), CultureInfo.InvariantCulture, out var timespan) ? timespan : StdioClientTransportOptions.DefaultShutdownTimeout
|
||||
}, config, _loggerFactory);
|
||||
.ToDictionary(kv => kv.Key.Substring("env:".Length), kv => kv.Value),
|
||||
ShutdownTimeout = TimeSpan.TryParse(serverConfig.TransportOptions?.GetValueOrDefault("shutdownTimeout"), CultureInfo.InvariantCulture, out var timespan) ? timespan : StdioClientTransportOptions.DefaultShutdownTimeout
|
||||
}, serverConfig, loggerFactory);
|
||||
}
|
||||
|
||||
if (string.Equals(config.TransportType, TransportTypes.Sse, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(config.TransportType, "http", StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(serverConfig.TransportType, TransportTypes.Sse, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(serverConfig.TransportType, "http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new SseClientTransport(
|
||||
new SseClientTransportOptions
|
||||
{
|
||||
ConnectionTimeout = TimeSpan.FromSeconds(ParseOrDefault(config.TransportOptions, "connectionTimeout", 30)),
|
||||
MaxReconnectAttempts = ParseOrDefault(config.TransportOptions, "maxReconnectAttempts", 3),
|
||||
ReconnectDelay = TimeSpan.FromSeconds(ParseOrDefault(config.TransportOptions, "reconnectDelay", 5)),
|
||||
AdditionalHeaders = config.TransportOptions?
|
||||
.Where(kv => kv.Key.StartsWith("header.", StringComparison.Ordinal))
|
||||
.ToDictionary(kv => kv.Key.Substring(7), kv => kv.Value)
|
||||
}, config, _loggerFactory);
|
||||
return new SseClientTransport(new SseClientTransportOptions
|
||||
{
|
||||
ConnectionTimeout = TimeSpan.FromSeconds(ParseInt32OrDefault(serverConfig.TransportOptions, "connectionTimeout", 30)),
|
||||
MaxReconnectAttempts = ParseInt32OrDefault(serverConfig.TransportOptions, "maxReconnectAttempts", 3),
|
||||
ReconnectDelay = TimeSpan.FromSeconds(ParseInt32OrDefault(serverConfig.TransportOptions, "reconnectDelay", 5)),
|
||||
AdditionalHeaders = serverConfig.TransportOptions?
|
||||
.Where(kv => kv.Key.StartsWith("header.", StringComparison.Ordinal))
|
||||
.ToDictionary(kv => kv.Key.Substring("header.".Length), kv => kv.Value)
|
||||
}, serverConfig, loggerFactory);
|
||||
|
||||
static int ParseInt32OrDefault(Dictionary<string, string>? options, string key, int defaultValue) =>
|
||||
options?.TryGetValue(key, out var value) is not true ? defaultValue :
|
||||
int.TryParse(value, out var result) ? result :
|
||||
throw new ArgumentException($"Invalid value '{value}' for option '{key}' in transport options.", nameof(serverConfig));
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Unsupported transport type '{config.TransportType}'.", nameof(config));
|
||||
}
|
||||
|
||||
private static int ParseOrDefault(Dictionary<string, string>? options, string key, int defaultValue)
|
||||
{
|
||||
if (options?.TryGetValue(key, out var value) ?? false)
|
||||
{
|
||||
if (!int.TryParse(value, out var result))
|
||||
throw new FormatException($"Invalid value '{value}' for option '{key}'");
|
||||
return result;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static string GetCommand(McpServerConfig config)
|
||||
{
|
||||
var command = config.TransportOptions?.GetValueOrDefault(COMMAND_OPTIONS_KEY);
|
||||
|
||||
if (string.IsNullOrEmpty(command))
|
||||
return config.Location!;
|
||||
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : command!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a non-shell command by injecting a /c {command} argument, as the command will be run in a shell.
|
||||
/// </summary>
|
||||
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_OPTIONS_KEY);
|
||||
|
||||
if (string.IsNullOrEmpty(command) || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// On Windows, we need to wrap non-shell commands with cmd.exe /c
|
||||
if (command!.IndexOf("cmd.exe", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
_logger.SkippingShellWrapper(endpointName);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the command is not empty and does not contain cmd.exe, we need to inject /c {command} (usually npx or uvicorn)
|
||||
// This is because the stdio transport will not work correctly if the command is not run in a shell
|
||||
_logger.PromotingCommandToShellArgumentForStdio(endpointName, command, config.TransportOptions!.GetValueOrDefault(ARGUMENTS_OPTIONS_KEY) ?? "");
|
||||
config.TransportOptions![ARGUMENTS_OPTIONS_KEY] = config.TransportOptions.TryGetValue(ARGUMENTS_OPTIONS_KEY, out var args)
|
||||
? $"/c {command} {args}"
|
||||
: $"/c {command}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes all clients created by the factory.
|
||||
/// </summary>
|
||||
/// <param name="disposing"></param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
if (disposing && DisposeClientsOnDispose)
|
||||
DisposeClients();
|
||||
|
||||
_isDisposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeClients()
|
||||
{
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
client?.DisposeAsync().AsTask().Wait();
|
||||
}
|
||||
|
||||
_clients.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
throw new ArgumentException($"Unsupported transport type '{serverConfig.TransportType}'.", nameof(serverConfig));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue