* Move notification handler registrations to capabilities Currently request handlers are set on the capability objects, but notification handlers are set after construction via an AddNotificationHandler method on the IMcpEndpoint interface. This moves handler specification to be at construction as well. This makes it more consistent with request handlers, simplifies the IMcpEndpoint interface to just be about message sending, and avoids a concurrency bug that could occur if someone tried to add a handler while the endpoint was processing notifications. * Address more feedback and further cleanup
139 lines
No EOL
7.3 KiB
C#
139 lines
No EOL
7.3 KiB
C#
using System.Globalization;
|
|
using System.Runtime.InteropServices;
|
|
using ModelContextProtocol.Logging;
|
|
using ModelContextProtocol.Protocol.Transport;
|
|
using ModelContextProtocol.Utils;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace ModelContextProtocol.Client;
|
|
|
|
/// <summary>Provides factory methods for creating MCP clients.</summary>
|
|
public static class McpClientFactory
|
|
{
|
|
/// <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.
|
|
/// If <see langword="null"/>, details based on the current process will be employed.
|
|
/// </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="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
|
/// <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 = null,
|
|
Func<McpServerConfig, ILoggerFactory?, IClientTransport>? createTransportFunc = null,
|
|
ILoggerFactory? loggerFactory = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Throw.IfNull(serverConfig);
|
|
|
|
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
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
if (transport is IAsyncDisposable asyncDisposableTransport)
|
|
{
|
|
await asyncDisposableTransport.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
else if (transport is IDisposable disposableTransport)
|
|
{
|
|
disposableTransport.Dispose();
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static IClientTransport CreateTransport(McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
|
{
|
|
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 = command!,
|
|
Arguments = arguments,
|
|
WorkingDirectory = serverConfig.TransportOptions?.GetValueOrDefault("workingDirectory"),
|
|
EnvironmentVariables = serverConfig.TransportOptions?
|
|
.Where(kv => kv.Key.StartsWith("env:", StringComparison.Ordinal))
|
|
.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(serverConfig.TransportType, TransportTypes.Sse, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(serverConfig.TransportType, "http", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
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 '{serverConfig.TransportType}'.", nameof(serverConfig));
|
|
}
|
|
} |