Rename project and namespaces

This commit is contained in:
Eirik Tsarpalis 2025-03-19 10:51:27 +02:00
commit c9ed453745
6 changed files with 426 additions and 918 deletions

View file

@ -0,0 +1,63 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Types;
namespace ModelContextProtocol.Client;
/// <summary>
/// Represents an instance of an MCP client connecting to a specific server.
/// </summary>
public interface IMcpClient : IAsyncDisposable
{
/// <summary>
/// Gets the capabilities supported by the server.
/// </summary>
ServerCapabilities? ServerCapabilities { get; }
/// <summary>
/// Gets the version and implementation information of the server.
/// </summary>
Implementation? ServerInfo { get; }
/// <summary>
/// Instructions describing how to use the server and its features.
/// This can be used by clients to improve the LLM's understanding of available tools, resources, etc.
/// It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt.
/// </summary>
string? ServerInstructions { get; }
/// <summary>
/// Adds a handler for server notifications of a specific method.
/// </summary>
/// <param name="method">The notification method to handle.</param>
/// <param name="handler">The async handler function to process notifications.</param>
/// <remarks>
/// <para>
/// Each method may have multiple handlers. Adding a handler for a method that already has one
/// will not replace the existing handler.
/// </para>
/// <para>
/// <see cref="NotificationMethods"> provides constants for common notification methods.</see>
/// </para>
/// </remarks>
void AddNotificationHandler(string method, Func<JsonRpcNotification, Task> handler);
/// <summary>
/// Sends a generic JSON-RPC request to the server.
/// </summary>
/// <typeparam name="TResult">The expected response type.</typeparam>
/// <param name="request">The JSON-RPC request to send.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing the server's response.</returns>
/// <remarks>
/// It is recommended to use the capability-specific methods that use this one in their implementation.
/// Use this method for custom requests or those not yet covered explicitly.
/// </remarks>
Task<TResult> SendRequestAsync<TResult>(JsonRpcRequest request, CancellationToken cancellationToken = default) where TResult : class;
/// <summary>
/// Sends a message to the server.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,167 @@
using ModelContextProtocol.Configuration;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Shared;
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
namespace ModelContextProtocol.Client;
/// <inheritdoc/>
internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
{
private readonly McpClientOptions _options;
private readonly ILogger _logger;
private readonly IClientTransport _clientTransport;
private volatile bool _isInitializing;
/// <summary>
/// Initializes a new instance of the <see cref="McpClient"/> class.
/// </summary>
/// <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(IClientTransport transport, McpClientOptions options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
: base(transport, loggerFactory)
{
_options = options;
_logger = (ILogger?)loggerFactory?.CreateLogger<McpClient>() ?? NullLogger.Instance;
_clientTransport = transport;
EndpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
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, ct) => samplingHandler(request, ct));
}
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, ct) => rootsHandler(request, ct));
}
}
/// <inheritdoc/>
public ServerCapabilities? ServerCapabilities { get; private set; }
/// <inheritdoc/>
public Implementation? ServerInfo { get; private set; }
/// <inheritdoc/>
public string? ServerInstructions { get; private set; }
/// <inheritdoc/>
public override string EndpointName { get; }
/// <inheritdoc/>
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
if (IsInitialized)
{
_logger.ClientAlreadyInitialized(EndpointName);
return;
}
if (_isInitializing)
{
_logger.ClientAlreadyInitializing(EndpointName);
throw new InvalidOperationException("Client is already initializing");
}
_isInitializing = true;
try
{
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// Connect transport
await _clientTransport.ConnectAsync(CancellationTokenSource.Token).ConfigureAwait(false);
// Start processing messages
MessageProcessingTask = ProcessMessagesAsync(CancellationTokenSource.Token);
// Perform initialization sequence
await InitializeAsync(CancellationTokenSource.Token).ConfigureAwait(false);
IsInitialized = true;
}
catch (Exception e)
{
_logger.ClientInitializationError(EndpointName, e);
await CleanupAsync().ConfigureAwait(false);
throw;
}
finally
{
_isInitializing = false;
}
}
private async Task InitializeAsync(CancellationToken cancellationToken)
{
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
initializationCts.CancelAfter(_options.InitializationTimeout);
try
{
// Send initialize request
var initializeResponse = await SendRequestAsync<InitializeResult>(
new JsonRpcRequest
{
Method = "initialize",
Params = new
{
protocolVersion = _options.ProtocolVersion,
capabilities = _options.Capabilities ?? new ClientCapabilities(),
clientInfo = _options.ClientInfo
}
},
initializationCts.Token).ConfigureAwait(false);
// Store server information
_logger.ServerCapabilitiesReceived(EndpointName,
capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, JsonSerializerOptionsExtensions.JsonContext.Default.ServerCapabilities),
serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, JsonSerializerOptionsExtensions.JsonContext.Default.Implementation));
ServerCapabilities = initializeResponse.Capabilities;
ServerInfo = initializeResponse.ServerInfo;
ServerInstructions = initializeResponse.Instructions;
// Validate protocol version
if (initializeResponse.ProtocolVersion != _options.ProtocolVersion)
{
_logger.ServerProtocolVersionMismatch(EndpointName, _options.ProtocolVersion, initializeResponse.ProtocolVersion);
throw new McpClientException($"Server protocol version mismatch. Expected {_options.ProtocolVersion}, got {initializeResponse.ProtocolVersion}");
}
// Send initialized notification
await SendMessageAsync(
new JsonRpcNotification { Method = "notifications/initialized" },
initializationCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (initializationCts.IsCancellationRequested)
{
_logger.ClientInitializationTimeout(EndpointName);
throw new McpClientException("Initialization timed out");
}
}
}

View file

@ -0,0 +1,46 @@
namespace ModelContextProtocol.Client;
/// <summary>
/// Represents errors that occur in the MCP client.
/// </summary>
public class McpClientException : Exception
{
/// <summary>
/// Gets the error code if this exception was caused by a JSON-RPC error response.
/// </summary>
public int? ErrorCode { get; }
/// <summary>
/// Initializes a new instance of the <see cref="McpClientException"/> class.
/// </summary>
public McpClientException()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="McpClientException"/> class with a specified error message.
/// </summary>
/// <param name="message">The message that describes the error.</param>
public McpClientException(string message) : base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="McpClientException"/> class with a specified error message and error code.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="errorCode">The error code associated with the JSON-RPC error response.</param>
public McpClientException(string message, int errorCode) : base(message)
{
ErrorCode = errorCode;
}
/// <summary>
/// Initializes a new instance of the <see cref="McpClientException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
public McpClientException(string message, Exception innerException) : base(message, innerException)
{
}
}

View file

@ -0,0 +1,507 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.AI;
using System.Runtime.CompilerServices;
using System.Text.Json;
#pragma warning disable CA1508 // Avoid dead conditional code
namespace ModelContextProtocol.Client;
/// <summary>
/// Provides extensions for operating on MCP clients.
/// </summary>
public static class McpClientExtensions
{
/// <summary>
/// Sends a notification to the server with parameters.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="method">The notification method name.</param>
/// <param name="parameters">The parameters to send with the notification.</param>
/// <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)
{
Throw.IfNull(client);
return client.SendMessageAsync(
new JsonRpcNotification { Method = method, Params = parameters },
cancellationToken);
}
/// <summary>
/// Sends a ping request to verify server connectivity.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task that completes when the ping is successful.</returns>
public static Task PingAsync(this IMcpClient client, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<dynamic>(
CreateRequest("ping", null),
cancellationToken);
}
/// <summary>
/// Retrieves a sequence of available tools from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of tool information.</returns>
public static async IAsyncEnumerable<Tool> ListToolsAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? cursor = null;
do
{
var tools = await ListToolsAsync(client, cursor, cancellationToken).ConfigureAwait(false);
foreach (var tool in tools.Tools)
{
yield return tool;
}
cursor = tools.NextCursor;
}
while (cursor is not null);
}
/// <summary>
/// Retrieves a sequence of available tools from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cursor">A cursor to paginate the results.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <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)
{
Throw.IfNull(client);
return client.SendRequestAsync<ListToolsResult>(
CreateRequest("tools/list", CreateCursorDictionary(cursor)),
cancellationToken);
}
/// <summary>
/// Retrieves a list of available prompts from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of prompt information.</returns>
public static async IAsyncEnumerable<Prompt> ListPromptsAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? cursor = null;
do
{
var prompts = await ListPromptsAsync(client, cursor, cancellationToken).ConfigureAwait(false);
foreach (var prompt in prompts.Prompts)
{
yield return prompt;
}
cursor = prompts.NextCursor;
}
while (cursor is not null);
}
/// <summary>
/// Retrieves a list of available prompts from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cursor">A cursor to paginate the results.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <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)
{
Throw.IfNull(client);
return client.SendRequestAsync<ListPromptsResult>(
CreateRequest("prompts/list", CreateCursorDictionary(cursor)),
cancellationToken);
}
/// <summary>
/// Retrieves a specific prompt with optional arguments.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="name">The name of the prompt to retrieve</param>
/// <param name="arguments">Optional arguments for the prompt</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <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)
{
Throw.IfNull(client);
return client.SendRequestAsync<GetPromptResult>(
CreateRequest("prompts/get", CreateParametersDictionary(name, arguments)),
cancellationToken);
}
/// <summary>
/// Retrieves a sequence of available resources from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of resource information.</returns>
public static async IAsyncEnumerable<Resource> ListResourcesAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? cursor = null;
do
{
var resources = await ListResourcesAsync(client, cursor, cancellationToken).ConfigureAwait(false);
foreach (var resource in resources.Resources)
{
yield return resource;
}
cursor = resources.NextCursor;
}
while (cursor is not null);
}
/// <summary>
/// Retrieves a list of available resources from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cursor">A cursor to paginate the results.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task<ListResourcesResult> ListResourcesAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<ListResourcesResult>(
CreateRequest("resources/list", CreateCursorDictionary(cursor)),
cancellationToken);
}
/// <summary>
/// Reads a resource from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="uri">The uri of the resource.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task<ReadResourceResult> ReadResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<ReadResourceResult>(
CreateRequest("resources/read", new() { ["uri"] = uri }),
cancellationToken);
}
/// <summary>
/// Gets the completion options for a resource or prompt reference and (named) argument.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="reference">A resource (uri) or prompt (name) reference</param>
/// <param name="argumentName">Name of argument. Must be non-null and non-empty.</param>
/// <param name="argumentValue">Value of argument. Must be non-null.</param>
/// <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)
{
Throw.IfNull(client);
Throw.IfNull(reference);
Throw.IfNullOrWhiteSpace(argumentName);
if (!reference.Validate(out string? validationMessage))
{
throw new ArgumentException($"Invalid reference: {validationMessage}", nameof(reference));
}
return client.SendRequestAsync<CompleteResult>(
CreateRequest("completion/complete", new()
{
["ref"] = reference,
["argument"] = new Argument { Name = argumentName, Value = argumentValue }
}),
cancellationToken);
}
/// <summary>
/// Subscribes to a resource on the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="uri">The uri of the resource.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task SubscribeToResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<EmptyResult>(
CreateRequest("resources/subscribe", new() { ["uri"] = uri }),
cancellationToken);
}
/// <summary>
/// Unsubscribes from a resource on the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="uri">The uri of the resource.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task UnsubscribeFromResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<EmptyResult>(
CreateRequest("resources/unsubscribe", new() { ["uri"] = uri }),
cancellationToken);
}
/// <summary>
/// Invokes a tool on the server with optional arguments.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="toolName">The name of the tool to call.</param>
/// <param name="arguments">Optional arguments for the tool.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <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)
{
Throw.IfNull(client);
return client.SendRequestAsync<CallToolResponse>(
CreateRequest("tools/call", CreateParametersDictionary(toolName, arguments)),
cancellationToken);
}
/// <summary>Gets <see cref="AIFunction"/> instances for all of the tools available through the specified <see cref="IMcpClient"/>.</summary>
/// <param name="client">The client for which <see cref="AIFunction"/> instances should be created.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing a list of the available functions.</returns>
public static async Task<IList<AIFunction>> GetAIFunctionsAsync(this IMcpClient client, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
List<AIFunction> functions = [];
await foreach (var tool in client.ListToolsAsync(cancellationToken).ConfigureAwait(false))
{
functions.Add(AsAIFunction(client, tool));
}
return functions;
}
/// <summary>Gets an <see cref="AIFunction"/> for invoking <see cref="Tool"/> via this <see cref="IMcpClient"/>.</summary>
/// <param name="client">The client with which to perform the invocation.</param>
/// <param name="tool">The tool to be invoked.</param>
/// <returns>An <see cref="AIFunction"/> for performing the call.</returns>
/// <remarks>
/// This operation does not validate that <paramref name="tool"/> is valid for the specified <paramref name="client"/>.
/// If the tool is not valid for the client, it will fail when invoked.
/// </remarks>
public static AIFunction AsAIFunction(this IMcpClient client, Tool tool)
{
Throw.IfNull(client);
Throw.IfNull(tool);
return new McpAIFunction(client, tool);
}
/// <summary>
/// Converts the contents of a <see cref="CreateMessageRequestParams"/> into a pair of
/// <see cref="IEnumerable{ChatMessage}"/> and <see cref="ChatOptions"/> instances to use
/// as inputs into a <see cref="IChatClient"/> operation.
/// </summary>
/// <param name="requestParams"></param>
/// <returns>The created pair of messages and options.</returns>
internal static (IList<ChatMessage> Messages, ChatOptions? Options) ToChatClientArguments(
this CreateMessageRequestParams requestParams)
{
Throw.IfNull(requestParams);
ChatOptions? options = null;
if (requestParams.MaxTokens is int maxTokens)
{
(options ??= new()).MaxOutputTokens = maxTokens;
}
if (requestParams.Temperature is float temperature)
{
(options ??= new()).Temperature = temperature;
}
if (requestParams.StopSequences is { } stopSequences)
{
(options ??= new()).StopSequences = stopSequences.ToArray();
}
List<ChatMessage> messages = [];
foreach (SamplingMessage sm in requestParams.Messages)
{
ChatMessage message = new()
{
Role = sm.Role == Role.User ? ChatRole.User : ChatRole.Assistant,
};
if (sm.Content is { Type: "text" })
{
message.Contents.Add(new TextContent(sm.Content.Text));
}
else if (sm.Content is { Type: "image", MimeType: not null, Data: not null })
{
message.Contents.Add(new DataContent(Convert.FromBase64String(sm.Content.Data), sm.Content.MimeType));
}
else if (sm.Content is { Type: "resource", Resource: not null })
{
ResourceContents resource = sm.Content.Resource;
if (resource.Text is not null)
{
message.Contents.Add(new TextContent(resource.Text));
}
if (resource.Blob is not null && resource.MimeType is not null)
{
message.Contents.Add(new DataContent(Convert.FromBase64String(resource.Blob), resource.MimeType));
}
}
messages.Add(message);
}
return (messages, options);
}
/// <summary>Converts the contents of a <see cref="ChatResponse"/> into a <see cref="CreateMessageResult"/>.</summary>
/// <param name="chatResponse">The <see cref="ChatResponse"/> whose contents should be extracted.</param>
/// <returns>The created <see cref="CreateMessageResult"/>.</returns>
internal static CreateMessageResult ToCreateMessageResult(this ChatResponse chatResponse)
{
Throw.IfNull(chatResponse);
// The ChatResponse can include multiple messages, of varying modalities, but CreateMessageResult supports
// only either a single blob of text or a single image. Heuristically, we'll use an image if there is one
// in any of the response messages, or we'll use all the text from them concatenated, otherwise.
ChatMessage? lastMessage = chatResponse.Messages.LastOrDefault();
Content? content = null;
if (lastMessage is not null)
{
foreach (var lmc in lastMessage.Contents)
{
if (lmc is DataContent dc && dc.HasTopLevelMediaType("image"))
{
content = new()
{
Type = "image",
MimeType = dc.MediaType,
Data = Convert.ToBase64String(dc.Data
#if NET
.Span),
#else
.ToArray()),
#endif
};
}
}
}
content ??= new()
{
Text = lastMessage?.Text ?? string.Empty,
Type = "text",
};
return new()
{
Content = content,
Model = chatResponse.ModelId ?? "unknown",
Role = lastMessage?.Role == ChatRole.User ? "user" : "assistant",
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",
};
}
/// <summary>
/// Creates a sampling handler for use with <see cref="SamplingCapability.SamplingHandler"/> that will
/// satisfy sampling requests using the specified <see cref="IChatClient"/>.
/// </summary>
/// <param name="chatClient">The <see cref="IChatClient"/> with which to satisfy sampling requests.</param>
/// <returns>The created handler delegate.</returns>
public static Func<CreateMessageRequestParams?, CancellationToken, Task<CreateMessageResult>> CreateSamplingHandler(this IChatClient chatClient)
{
Throw.IfNull(chatClient);
return async (requestParams, cancellationToken) =>
{
Throw.IfNull(requestParams);
var (messages, options) = requestParams.ToChatClientArguments();
var response = await chatClient.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
return response.ToCreateMessageResult();
};
}
private static JsonRpcRequest CreateRequest(string method, Dictionary<string, object?>? parameters) =>
new JsonRpcRequest
{
Method = method,
Params = parameters
};
private static Dictionary<string, object?>? CreateCursorDictionary(string? cursor) =>
cursor != null ? new() { ["cursor"] = cursor } : null;
private static Dictionary<string, object?> CreateParametersDictionary(string nameParameter, Dictionary<string, object>? arguments)
{
Dictionary<string, object?> parameters = new()
{
["name"] = nameParameter
};
if (arguments != null)
{
parameters["arguments"] = arguments;
}
return parameters;
}
/// <summary>Provides an AI function that calls a tool through <see cref="IMcpClient"/>.</summary>
private sealed class McpAIFunction(IMcpClient client, Tool tool) : AIFunction
{
private JsonElement? _jsonSchema;
/// <inheritdoc/>
public override string Name => tool.Name;
/// <inheritdoc/>
public override string Description => tool.Description ?? string.Empty;
/// <inheritdoc/>
public override JsonElement JsonSchema => _jsonSchema ??=
JsonSerializer.SerializeToElement(new Dictionary<string, object>
{
["type"] = "object",
["title"] = tool.Name,
["description"] = tool.Description ?? string.Empty,
["properties"] = tool.InputSchema?.Properties ?? [],
["required"] = tool.InputSchema?.Required ?? []
}, JsonSerializerOptionsExtensions.JsonContext.Default.DictionaryStringObject);
/// <inheritdoc/>
protected async override Task<object?> InvokeCoreAsync(
IEnumerable<KeyValuePair<string, object?>> arguments, CancellationToken cancellationToken)
{
Throw.IfNull(arguments);
Dictionary<string, object> argDict = [];
foreach (var arg in arguments)
{
if (arg.Value is not null)
{
argDict[arg.Key] = arg.Value;
}
}
CallToolResponse result = await client.CallToolAsync(tool.Name, argDict, cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(result, JsonSerializerOptionsExtensions.JsonContext.Default.CallToolResponse);
}
}
}

View file

@ -0,0 +1,131 @@
using System.Globalization;
using System.Runtime.InteropServices;
using ModelContextProtocol.Configuration;
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.</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">A token to cancel the operation.</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,
Func<McpServerConfig, ILoggerFactory?, IClientTransport>? createTransportFunc = null,
ILoggerFactory? loggerFactory = null,
CancellationToken cancellationToken = default)
{
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
{
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
{
await transport.DisposeAsync().ConfigureAwait(false);
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));
}
}

View file

@ -0,0 +1,31 @@
using ModelContextProtocol.Protocol.Types;
namespace ModelContextProtocol.Client;
/// <summary>
/// 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 href="https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle/">See the protocol specification for details on capability negotiation</see>
/// </summary>
public record McpClientOptions
{
/// <summary>
/// Information about this client implementation.
/// </summary>
public required Implementation ClientInfo { get; init; }
/// <summary>
/// Client capabilities to advertise to the server.
/// </summary>
public ClientCapabilities? Capabilities { get; init; }
/// <summary>
/// Protocol version to request from the server.
/// </summary>
public string ProtocolVersion { get; init; } = "2024-11-05";
/// <summary>
/// Timeout for initialization sequence.
/// </summary>
public TimeSpan InitializationTimeout { get; init; } = TimeSpan.FromSeconds(60);
}