Add ModelContextProtocol.Core package (#428)
This commit is contained in:
parent
57458241b9
commit
703b0fe50b
157 changed files with 239 additions and 94 deletions
206
src/ModelContextProtocol.Core/AIContentExtensions.cs
Normal file
206
src/ModelContextProtocol.Core/AIContentExtensions.cs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Protocol;
|
||||
#if !NET
|
||||
using System.Runtime.InteropServices;
|
||||
#endif
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for converting between Model Context Protocol (MCP) types and Microsoft.Extensions.AI types.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class serves as an adapter layer between Model Context Protocol (MCP) types and the <see cref="AIContent"/> model types
|
||||
/// from the Microsoft.Extensions.AI namespace.
|
||||
/// </remarks>
|
||||
public static class AIContentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a <see cref="PromptMessage"/> to a <see cref="ChatMessage"/> object.
|
||||
/// </summary>
|
||||
/// <param name="promptMessage">The prompt message to convert.</param>
|
||||
/// <returns>A <see cref="ChatMessage"/> object created from the prompt message.</returns>
|
||||
/// <remarks>
|
||||
/// This method transforms a protocol-specific <see cref="PromptMessage"/> from the Model Context Protocol
|
||||
/// into a standard <see cref="ChatMessage"/> object that can be used with AI client libraries.
|
||||
/// </remarks>
|
||||
public static ChatMessage ToChatMessage(this PromptMessage promptMessage)
|
||||
{
|
||||
Throw.IfNull(promptMessage);
|
||||
|
||||
return new()
|
||||
{
|
||||
RawRepresentation = promptMessage,
|
||||
Role = promptMessage.Role == Role.User ? ChatRole.User : ChatRole.Assistant,
|
||||
Contents = [ToAIContent(promptMessage.Content)]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="GetPromptResult"/> to a list of <see cref="ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
/// <param name="promptResult">The prompt result containing messages to convert.</param>
|
||||
/// <returns>A list of <see cref="ChatMessage"/> objects created from the prompt messages.</returns>
|
||||
/// <remarks>
|
||||
/// This method transforms protocol-specific <see cref="PromptMessage"/> objects from a Model Context Protocol
|
||||
/// prompt result into standard <see cref="ChatMessage"/> objects that can be used with AI client libraries.
|
||||
/// </remarks>
|
||||
public static IList<ChatMessage> ToChatMessages(this GetPromptResult promptResult)
|
||||
{
|
||||
Throw.IfNull(promptResult);
|
||||
|
||||
return promptResult.Messages.Select(m => m.ToChatMessage()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="ChatMessage"/> to a list of <see cref="PromptMessage"/> objects.
|
||||
/// </summary>
|
||||
/// <param name="chatMessage">The chat message to convert.</param>
|
||||
/// <returns>A list of <see cref="PromptMessage"/> objects created from the chat message's contents.</returns>
|
||||
/// <remarks>
|
||||
/// This method transforms standard <see cref="ChatMessage"/> objects used with AI client libraries into
|
||||
/// protocol-specific <see cref="PromptMessage"/> objects for the Model Context Protocol system.
|
||||
/// Only representable content items are processed.
|
||||
/// </remarks>
|
||||
public static IList<PromptMessage> ToPromptMessages(this ChatMessage chatMessage)
|
||||
{
|
||||
Throw.IfNull(chatMessage);
|
||||
|
||||
Role r = chatMessage.Role == ChatRole.User ? Role.User : Role.Assistant;
|
||||
|
||||
List<PromptMessage> messages = [];
|
||||
foreach (var content in chatMessage.Contents)
|
||||
{
|
||||
if (content is TextContent or DataContent)
|
||||
{
|
||||
messages.Add(new PromptMessage { Role = r, Content = content.ToContent() });
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new <see cref="AIContent"/> from the content of a <see cref="Content"/>.</summary>
|
||||
/// <param name="content">The <see cref="Content"/> to convert.</param>
|
||||
/// <returns>The created <see cref="AIContent"/>.</returns>
|
||||
/// <remarks>
|
||||
/// This method converts Model Context Protocol content types to the equivalent Microsoft.Extensions.AI
|
||||
/// content types, enabling seamless integration between the protocol and AI client libraries.
|
||||
/// </remarks>
|
||||
public static AIContent ToAIContent(this Content content)
|
||||
{
|
||||
Throw.IfNull(content);
|
||||
|
||||
AIContent ac;
|
||||
if (content is { Type: "image" or "audio", MimeType: not null, Data: not null })
|
||||
{
|
||||
ac = new DataContent(Convert.FromBase64String(content.Data), content.MimeType);
|
||||
}
|
||||
else if (content is { Type: "resource" } && content.Resource is { } resourceContents)
|
||||
{
|
||||
ac = resourceContents.ToAIContent();
|
||||
}
|
||||
else
|
||||
{
|
||||
ac = new TextContent(content.Text);
|
||||
}
|
||||
|
||||
ac.RawRepresentation = content;
|
||||
|
||||
return ac;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new <see cref="AIContent"/> from the content of a <see cref="ResourceContents"/>.</summary>
|
||||
/// <param name="content">The <see cref="ResourceContents"/> to convert.</param>
|
||||
/// <returns>The created <see cref="AIContent"/>.</returns>
|
||||
/// <remarks>
|
||||
/// This method converts Model Context Protocol resource types to the equivalent Microsoft.Extensions.AI
|
||||
/// content types, enabling seamless integration between the protocol and AI client libraries.
|
||||
/// </remarks>
|
||||
public static AIContent ToAIContent(this ResourceContents content)
|
||||
{
|
||||
Throw.IfNull(content);
|
||||
|
||||
AIContent ac = content switch
|
||||
{
|
||||
BlobResourceContents blobResource => new DataContent(Convert.FromBase64String(blobResource.Blob), blobResource.MimeType ?? "application/octet-stream"),
|
||||
TextResourceContents textResource => new TextContent(textResource.Text),
|
||||
_ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.")
|
||||
};
|
||||
|
||||
(ac.AdditionalProperties ??= [])["uri"] = content.Uri;
|
||||
ac.RawRepresentation = content;
|
||||
|
||||
return ac;
|
||||
}
|
||||
|
||||
/// <summary>Creates a list of <see cref="AIContent"/> from a sequence of <see cref="Content"/>.</summary>
|
||||
/// <param name="contents">The <see cref="Content"/> instances to convert.</param>
|
||||
/// <returns>The created <see cref="AIContent"/> instances.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method converts a collection of Model Context Protocol content objects into a collection of
|
||||
/// Microsoft.Extensions.AI content objects. It's useful when working with multiple content items, such as
|
||||
/// when processing the contents of a message or response.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each <see cref="Content"/> object is converted using <see cref="ToAIContent(Content)"/>,
|
||||
/// preserving the type-specific conversion logic for text, images, audio, and resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IList<AIContent> ToAIContents(this IEnumerable<Content> contents)
|
||||
{
|
||||
Throw.IfNull(contents);
|
||||
|
||||
return [.. contents.Select(ToAIContent)];
|
||||
}
|
||||
|
||||
/// <summary>Creates a list of <see cref="AIContent"/> from a sequence of <see cref="ResourceContents"/>.</summary>
|
||||
/// <param name="contents">The <see cref="ResourceContents"/> instances to convert.</param>
|
||||
/// <returns>A list of <see cref="AIContent"/> objects created from the resource contents.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method converts a collection of Model Context Protocol resource objects into a collection of
|
||||
/// Microsoft.Extensions.AI content objects. It's useful when working with multiple resources, such as
|
||||
/// when processing the contents of a <see cref="ReadResourceResult"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each <see cref="ResourceContents"/> object is converted using <see cref="ToAIContent(ResourceContents)"/>,
|
||||
/// preserving the type-specific conversion logic: text resources become <see cref="TextContent"/> objects and
|
||||
/// binary resources become <see cref="DataContent"/> objects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IList<AIContent> ToAIContents(this IEnumerable<ResourceContents> contents)
|
||||
{
|
||||
Throw.IfNull(contents);
|
||||
|
||||
return [.. contents.Select(ToAIContent)];
|
||||
}
|
||||
|
||||
internal static Content ToContent(this AIContent content) =>
|
||||
content switch
|
||||
{
|
||||
TextContent textContent => new()
|
||||
{
|
||||
Text = textContent.Text,
|
||||
Type = "text",
|
||||
},
|
||||
|
||||
DataContent dataContent => new()
|
||||
{
|
||||
Data = dataContent.Base64Data.ToString(),
|
||||
MimeType = dataContent.MediaType,
|
||||
Type =
|
||||
dataContent.HasTopLevelMediaType("image") ? "image" :
|
||||
dataContent.HasTopLevelMediaType("audio") ? "audio" :
|
||||
"resource",
|
||||
},
|
||||
|
||||
_ => new()
|
||||
{
|
||||
Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))),
|
||||
Type = "text",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// A transport that automatically detects whether to use Streamable HTTP or SSE transport
|
||||
/// by trying Streamable HTTP first and falling back to SSE if that fails.
|
||||
/// </summary>
|
||||
internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
||||
{
|
||||
private readonly SseClientTransportOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _name;
|
||||
private readonly Channel<JsonRpcMessage> _messageChannel;
|
||||
|
||||
public AutoDetectingClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
|
||||
{
|
||||
Throw.IfNull(transportOptions);
|
||||
Throw.IfNull(httpClient);
|
||||
|
||||
_options = transportOptions;
|
||||
_httpClient = httpClient;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<AutoDetectingClientSessionTransport>() ?? NullLogger.Instance;
|
||||
_name = endpointName;
|
||||
|
||||
// Same as TransportBase.cs.
|
||||
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active transport (either StreamableHttp or SSE)
|
||||
/// </summary>
|
||||
internal ITransport? ActiveTransport { get; private set; }
|
||||
|
||||
public ChannelReader<JsonRpcMessage> MessageReader => _messageChannel.Reader;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ActiveTransport is null)
|
||||
{
|
||||
return InitializeAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
return ActiveTransport.SendMessageAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task InitializeAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
// Try StreamableHttp first
|
||||
var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
|
||||
|
||||
try
|
||||
{
|
||||
LogAttemptingStreamableHttp(_name);
|
||||
using var response = await streamableHttpTransport.SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
LogUsingStreamableHttp(_name);
|
||||
ActiveTransport = streamableHttpTransport;
|
||||
}
|
||||
else
|
||||
{
|
||||
// If the status code is not success, fall back to SSE
|
||||
LogStreamableHttpFailed(_name, response.StatusCode);
|
||||
|
||||
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
|
||||
await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If nothing threw inside the try block, we've either set streamableHttpTransport as the
|
||||
// ActiveTransport, or else we will have disposed it in the !IsSuccessStatusCode else block.
|
||||
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var sseTransport = new SseClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
|
||||
|
||||
try
|
||||
{
|
||||
LogAttemptingSSE(_name);
|
||||
await sseTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
LogUsingSSE(_name);
|
||||
ActiveTransport = sseTransport;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await sseTransport.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ActiveTransport is not null)
|
||||
{
|
||||
await ActiveTransport.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// In the majority of cases, either the Streamable HTTP transport or SSE transport has completed the channel by now.
|
||||
// However, this may not be the case if HttpClient throws during the initial request due to misconfiguration.
|
||||
_messageChannel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using Streamable HTTP transport.")]
|
||||
private partial void LogAttemptingStreamableHttp(string endpointName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")]
|
||||
private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")]
|
||||
private partial void LogUsingStreamableHttp(string endpointName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using SSE transport.")]
|
||||
private partial void LogAttemptingSSE(string endpointName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")]
|
||||
private partial void LogUsingSSE(string endpointName);
|
||||
}
|
||||
23
src/ModelContextProtocol.Core/Client/HttpTransportMode.cs
Normal file
23
src/ModelContextProtocol.Core/Client/HttpTransportMode.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the transport mode for HTTP client connections.
|
||||
/// </summary>
|
||||
public enum HttpTransportMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically detect the appropriate transport by trying Streamable HTTP first, then falling back to SSE if that fails.
|
||||
/// This is the recommended mode for maximum compatibility.
|
||||
/// </summary>
|
||||
AutoDetect,
|
||||
|
||||
/// <summary>
|
||||
/// Use only the Streamable HTTP transport.
|
||||
/// </summary>
|
||||
StreamableHttp,
|
||||
|
||||
/// <summary>
|
||||
/// Use only the HTTP with SSE transport.
|
||||
/// </summary>
|
||||
Sse
|
||||
}
|
||||
47
src/ModelContextProtocol.Core/Client/IClientTransport.cs
Normal file
47
src/ModelContextProtocol.Core/Client/IClientTransport.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a transport mechanism for Model Context Protocol (MCP) client-to-server communication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="IClientTransport"/> interface abstracts the communication layer between MCP clients
|
||||
/// and servers, allowing different transport protocols to be used interchangeably.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When creating an <see cref="IMcpClient"/>, <see cref="McpClientFactory"/> is typically used, and is
|
||||
/// provided with the <see cref="IClientTransport"/> based on expected server configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IClientTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a transport identifier, used for logging purposes.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously establishes a transport session with an MCP server and returns a transport for the duplex message stream.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>Returns an interface for the duplex message stream.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method is responsible for initializing the connection to the server using the specific transport
|
||||
/// mechanism implemented by the derived class. The returned <see cref="ITransport"/> interface
|
||||
/// provides methods to send and receive messages over the established connection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The lifetime of the returned <see cref="ITransport"/> instance is typically managed by the
|
||||
/// <see cref="McpClient"/> that uses this transport. When the client is disposed, it will dispose
|
||||
/// the transport session as well.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This method is used by <see cref="McpClientFactory"/> to initialize the connection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">The transport connection could not be established.</exception>
|
||||
Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
47
src/ModelContextProtocol.Core/Client/IMcpClient.cs
Normal file
47
src/ModelContextProtocol.Core/Client/IMcpClient.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an instance of a Model Context Protocol (MCP) client that connects to and communicates with an MCP server.
|
||||
/// </summary>
|
||||
public interface IMcpClient : IMcpEndpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the capabilities supported by the connected server.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The client is not connected.</exception>
|
||||
ServerCapabilities ServerCapabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the implementation information of the connected server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property provides identification details about the connected server, including its name and version.
|
||||
/// It is populated during the initialization handshake and is available after a successful connection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This information can be useful for logging, debugging, compatibility checks, and displaying server
|
||||
/// information to users.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">The client is not connected.</exception>
|
||||
Implementation ServerInfo { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any instructions describing how to use the connected server and its features.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property contains instructions provided by the server during initialization that explain
|
||||
/// how to effectively use its capabilities. These instructions can include details about available
|
||||
/// tools, expected input formats, limitations, or any other helpful information.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This can be used by clients to improve an LLM's understanding of available tools, prompts, and resources.
|
||||
/// It can be thought of like a "hint" to the model and may be added to a system prompt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
string? ServerInstructions { get; }
|
||||
}
|
||||
215
src/ModelContextProtocol.Core/Client/McpClient.cs
Normal file
215
src/ModelContextProtocol.Core/Client/McpClient.cs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <inheritdoc/>
|
||||
internal sealed partial class McpClient : McpEndpoint, IMcpClient
|
||||
{
|
||||
private static Implementation DefaultImplementation { get; } = new()
|
||||
{
|
||||
Name = DefaultAssemblyName.Name ?? nameof(McpClient),
|
||||
Version = DefaultAssemblyName.Version?.ToString() ?? "1.0.0",
|
||||
};
|
||||
|
||||
private readonly IClientTransport _clientTransport;
|
||||
private readonly McpClientOptions _options;
|
||||
|
||||
private ITransport? _sessionTransport;
|
||||
private CancellationTokenSource? _connectCts;
|
||||
|
||||
private ServerCapabilities? _serverCapabilities;
|
||||
private Implementation? _serverInfo;
|
||||
private string? _serverInstructions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="clientTransport">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="loggerFactory">The logger factory.</param>
|
||||
public McpClient(IClientTransport clientTransport, McpClientOptions? options, ILoggerFactory? loggerFactory)
|
||||
: base(loggerFactory)
|
||||
{
|
||||
options ??= new();
|
||||
|
||||
_clientTransport = clientTransport;
|
||||
_options = options;
|
||||
|
||||
EndpointName = clientTransport.Name;
|
||||
|
||||
if (options.Capabilities is { } capabilities)
|
||||
{
|
||||
if (capabilities.NotificationHandlers is { } notificationHandlers)
|
||||
{
|
||||
NotificationHandlers.RegisterRange(notificationHandlers);
|
||||
}
|
||||
|
||||
if (capabilities.Sampling is { } samplingCapability)
|
||||
{
|
||||
if (samplingCapability.SamplingHandler is not { } samplingHandler)
|
||||
{
|
||||
throw new InvalidOperationException("Sampling capability was set but it did not provide a handler.");
|
||||
}
|
||||
|
||||
RequestHandlers.Set(
|
||||
RequestMethods.SamplingCreateMessage,
|
||||
(request, _, cancellationToken) => samplingHandler(
|
||||
request,
|
||||
request?.Meta?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
|
||||
cancellationToken),
|
||||
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateMessageResult);
|
||||
}
|
||||
|
||||
if (capabilities.Roots is { } rootsCapability)
|
||||
{
|
||||
if (rootsCapability.RootsHandler is not { } rootsHandler)
|
||||
{
|
||||
throw new InvalidOperationException("Roots capability was set but it did not provide a handler.");
|
||||
}
|
||||
|
||||
RequestHandlers.Set(
|
||||
RequestMethods.RootsList,
|
||||
(request, _, cancellationToken) => rootsHandler(request, cancellationToken),
|
||||
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ListRootsResult);
|
||||
}
|
||||
|
||||
if (capabilities.Elicitation is { } elicitationCapability)
|
||||
{
|
||||
if (elicitationCapability.ElicitationHandler is not { } elicitationHandler)
|
||||
{
|
||||
throw new InvalidOperationException("Elicitation capability was set but it did not provide a handler.");
|
||||
}
|
||||
|
||||
RequestHandlers.Set(
|
||||
RequestMethods.ElicitationCreate,
|
||||
(request, _, cancellationToken) => elicitationHandler(request, cancellationToken),
|
||||
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ElicitResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ServerCapabilities ServerCapabilities => _serverCapabilities ?? throw new InvalidOperationException("The client is not connected.");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException("The client is not connected.");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? ServerInstructions => _serverInstructions;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string EndpointName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously connects to an MCP server, establishes the transport connection, and completes the initialization handshake.
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cancellationToken = _connectCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
// Connect transport
|
||||
_sessionTransport = await _clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
InitializeSession(_sessionTransport);
|
||||
// We don't want the ConnectAsync token to cancel the session after we've successfully connected.
|
||||
// The base class handles cleaning up the session in DisposeAsync without our help.
|
||||
StartSession(_sessionTransport, fullSessionCancellationToken: CancellationToken.None);
|
||||
|
||||
// Perform initialization sequence
|
||||
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
initializationCts.CancelAfter(_options.InitializationTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
// Send initialize request
|
||||
var initializeResponse = await this.SendRequestAsync(
|
||||
RequestMethods.Initialize,
|
||||
new InitializeRequestParams
|
||||
{
|
||||
ProtocolVersion = _options.ProtocolVersion,
|
||||
Capabilities = _options.Capabilities ?? new ClientCapabilities(),
|
||||
ClientInfo = _options.ClientInfo ?? DefaultImplementation,
|
||||
},
|
||||
McpJsonUtilities.JsonContext.Default.InitializeRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.InitializeResult,
|
||||
cancellationToken: initializationCts.Token).ConfigureAwait(false);
|
||||
|
||||
// Store server information
|
||||
if (_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
LogServerCapabilitiesReceived(EndpointName,
|
||||
capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities),
|
||||
serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
|
||||
}
|
||||
|
||||
_serverCapabilities = initializeResponse.Capabilities;
|
||||
_serverInfo = initializeResponse.ServerInfo;
|
||||
_serverInstructions = initializeResponse.Instructions;
|
||||
|
||||
// Validate protocol version
|
||||
if (initializeResponse.ProtocolVersion != _options.ProtocolVersion)
|
||||
{
|
||||
LogServerProtocolVersionMismatch(EndpointName, _options.ProtocolVersion, initializeResponse.ProtocolVersion);
|
||||
throw new McpException($"Server protocol version mismatch. Expected {_options.ProtocolVersion}, got {initializeResponse.ProtocolVersion}");
|
||||
}
|
||||
|
||||
// Send initialized notification
|
||||
await SendMessageAsync(
|
||||
new JsonRpcNotification { Method = NotificationMethods.InitializedNotification },
|
||||
initializationCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
LogClientInitializationTimeout(EndpointName);
|
||||
throw new TimeoutException("Initialization timed out", oce);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogClientInitializationError(EndpointName, e);
|
||||
await DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask DisposeUnsynchronizedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_connectCts is not null)
|
||||
{
|
||||
await _connectCts.CancelAsync().ConfigureAwait(false);
|
||||
_connectCts.Dispose();
|
||||
}
|
||||
|
||||
await base.DisposeUnsynchronizedAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_sessionTransport is not null)
|
||||
{
|
||||
await _sessionTransport.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client received server '{ServerInfo}' capabilities: '{Capabilities}'.")]
|
||||
private partial void LogServerCapabilitiesReceived(string endpointName, string capabilities, string serverInfo);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "{EndpointName} client initialization error.")]
|
||||
private partial void LogClientInitializationError(string endpointName, Exception exception);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "{EndpointName} client initialization timed out.")]
|
||||
private partial void LogClientInitializationTimeout(string endpointName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "{EndpointName} client protocol version mismatch with server. Expected '{Expected}', received '{Received}'.")]
|
||||
private partial void LogServerProtocolVersionMismatch(string endpointName, string expected, string received);
|
||||
}
|
||||
1127
src/ModelContextProtocol.Core/Client/McpClientExtensions.cs
Normal file
1127
src/ModelContextProtocol.Core/Client/McpClientExtensions.cs
Normal file
File diff suppressed because it is too large
Load diff
54
src/ModelContextProtocol.Core/Client/McpClientFactory.cs
Normal file
54
src/ModelContextProtocol.Core/Client/McpClientFactory.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides factory methods for creating Model Context Protocol (MCP) clients.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This factory class is the primary way to instantiate <see cref="IMcpClient"/> instances
|
||||
/// that connect to MCP servers. It handles the creation and connection
|
||||
/// of appropriate implementations through the supplied transport.
|
||||
/// </remarks>
|
||||
public static partial class McpClientFactory
|
||||
{
|
||||
/// <summary>Creates an <see cref="IMcpClient"/>, connecting it to the specified server.</summary>
|
||||
/// <param name="clientTransport">The transport instance used to communicate with the server.</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="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="clientTransport"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="clientOptions"/> is <see langword="null"/>.</exception>
|
||||
public static async Task<IMcpClient> CreateAsync(
|
||||
IClientTransport clientTransport,
|
||||
McpClientOptions? clientOptions = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(clientTransport);
|
||||
|
||||
McpClient client = new(clientTransport, clientOptions, loggerFactory);
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (loggerFactory?.CreateLogger(typeof(McpClientFactory)) is ILogger logger)
|
||||
{
|
||||
logger.LogClientCreated(client.EndpointName);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client created and connected.")]
|
||||
private static partial void LogClientCreated(this ILogger logger, string endpointName);
|
||||
}
|
||||
59
src/ModelContextProtocol.Core/Client/McpClientOptions.cs
Normal file
59
src/ModelContextProtocol.Core/Client/McpClientOptions.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for creating <see cref="IMcpClient"/> instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These options are typically passed to <see cref="McpClientFactory.CreateAsync"/> when creating a client.
|
||||
/// They define client capabilities, protocol version, and other client-specific settings.
|
||||
/// </remarks>
|
||||
public class McpClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets information about this client implementation, including its name and version.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This information is sent to the server during initialization to identify the client.
|
||||
/// It's often displayed in server logs and can be used for debugging and compatibility checks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When not specified, information sourced from the current process will be used.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Implementation? ClientInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client capabilities to advertise to the server.
|
||||
/// </summary>
|
||||
public ClientCapabilities? Capabilities { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protocol version to request from the server, using a date-based versioning scheme.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The protocol version is a key part of the initialization handshake. The client and server must
|
||||
/// agree on a compatible protocol version to communicate successfully. If the server doesn't support
|
||||
/// the requested version, it will respond with a version mismatch error.
|
||||
/// </remarks>
|
||||
public string ProtocolVersion { get; set; } = "2024-11-05";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a timeout for the client-server initialization handshake sequence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This timeout determines how long the client will wait for the server to respond during
|
||||
/// the initialization protocol handshake. If the server doesn't respond within this timeframe,
|
||||
/// an exception will be thrown.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Setting an appropriate timeout prevents the client from hanging indefinitely when
|
||||
/// connecting to unresponsive servers.
|
||||
/// </para>
|
||||
/// <para>The default value is 60 seconds.</para>
|
||||
/// </remarks>
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(60);
|
||||
}
|
||||
78
src/ModelContextProtocol.Core/Client/McpClientPrompt.cs
Normal file
78
src/ModelContextProtocol.Core/Client/McpClientPrompt.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a named prompt that can be retrieved from an MCP server and invoked with arguments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class provides a client-side wrapper around a prompt defined on an MCP server. It allows
|
||||
/// retrieving the prompt's content by sending a request to the server with optional arguments.
|
||||
/// Instances of this class are typically obtained by calling <see cref="McpClientExtensions.ListPromptsAsync"/>
|
||||
/// or <see cref="McpClientExtensions.EnumeratePromptsAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each prompt has a name and optionally a description, and it can be invoked with arguments
|
||||
/// to produce customized prompt content from the server.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class McpClientPrompt
|
||||
{
|
||||
private readonly IMcpClient _client;
|
||||
|
||||
internal McpClientPrompt(IMcpClient client, Prompt prompt)
|
||||
{
|
||||
_client = client;
|
||||
ProtocolPrompt = prompt;
|
||||
}
|
||||
|
||||
/// <summary>Gets the underlying protocol <see cref="Prompt"/> type for this instance.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property provides direct access to the underlying protocol representation of the prompt,
|
||||
/// which can be useful for advanced scenarios or when implementing custom MCP client extensions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For most common use cases, you can use the more convenient <see cref="Name"/> and
|
||||
/// <see cref="Description"/> properties instead of accessing the <see cref="ProtocolPrompt"/> directly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Prompt ProtocolPrompt { get; }
|
||||
|
||||
/// <summary>Gets the name of the prompt.</summary>
|
||||
public string Name => ProtocolPrompt.Name;
|
||||
|
||||
/// <summary>Gets a description of the prompt.</summary>
|
||||
public string? Description => ProtocolPrompt.Description;
|
||||
|
||||
/// <summary>
|
||||
/// Gets this prompt's content by sending a request to the server with optional arguments.
|
||||
/// </summary>
|
||||
/// <param name="arguments">Optional arguments to pass to the prompt. Keys are parameter names, and values are the argument values.</param>
|
||||
/// <param name="serializerOptions">The serialization options governing argument serialization.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> containing the prompt's result with content and messages.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method sends a request to the MCP server to execute this prompt with the provided arguments.
|
||||
/// The server will process the request and return a result containing messages or other content.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is a convenience method that internally calls <see cref="McpClientExtensions.GetPromptAsync"/>
|
||||
/// with this prompt's name and arguments.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<GetPromptResult> GetAsync(
|
||||
IEnumerable<KeyValuePair<string, object?>>? arguments = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyDictionary<string, object?>? argDict =
|
||||
arguments as IReadOnlyDictionary<string, object?> ??
|
||||
arguments?.ToDictionary();
|
||||
|
||||
return await _client.GetPromptAsync(ProtocolPrompt.Name, argDict, serializerOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
64
src/ModelContextProtocol.Core/Client/McpClientResource.cs
Normal file
64
src/ModelContextProtocol.Core/Client/McpClientResource.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a named resource that can be retrieved from an MCP server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class provides a client-side wrapper around a resource defined on an MCP server. It allows
|
||||
/// retrieving the resource's content by sending a request to the server with the resource's URI.
|
||||
/// Instances of this class are typically obtained by calling <see cref="McpClientExtensions.ListResourcesAsync"/>
|
||||
/// or <see cref="McpClientExtensions.EnumerateResourcesAsync"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class McpClientResource
|
||||
{
|
||||
private readonly IMcpClient _client;
|
||||
|
||||
internal McpClientResource(IMcpClient client, Resource resource)
|
||||
{
|
||||
_client = client;
|
||||
ProtocolResource = resource;
|
||||
}
|
||||
|
||||
/// <summary>Gets the underlying protocol <see cref="Resource"/> type for this instance.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property provides direct access to the underlying protocol representation of the resource,
|
||||
/// which can be useful for advanced scenarios or when implementing custom MCP client extensions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For most common use cases, you can use the more convenient <see cref="Name"/> and
|
||||
/// <see cref="Description"/> properties instead of accessing the <see cref="ProtocolResource"/> directly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Resource ProtocolResource { get; }
|
||||
|
||||
/// <summary>Gets the URI of the resource.</summary>
|
||||
public string Uri => ProtocolResource.Uri;
|
||||
|
||||
/// <summary>Gets the name of the resource.</summary>
|
||||
public string Name => ProtocolResource.Name;
|
||||
|
||||
/// <summary>Gets a description of the resource.</summary>
|
||||
public string? Description => ProtocolResource.Description;
|
||||
|
||||
/// <summary>Gets a media (MIME) type of the resource.</summary>
|
||||
public string? MimeType => ProtocolResource.MimeType;
|
||||
|
||||
/// <summary>
|
||||
/// Gets this resource's content by sending a request to the server.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ValueTask{ReadResourceResult}"/> containing the resource's result with content and messages.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is a convenience method that internally calls <see cref="McpClientExtensions.ReadResourceAsync(IMcpClient, string, CancellationToken)"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<ReadResourceResult> ReadAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_client.ReadResourceAsync(Uri, cancellationToken);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a named resource template that can be retrieved from an MCP server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class provides a client-side wrapper around a resource template defined on an MCP server. It allows
|
||||
/// retrieving the resource template's content by sending a request to the server with the resource's URI.
|
||||
/// Instances of this class are typically obtained by calling <see cref="McpClientExtensions.ListResourceTemplatesAsync"/>
|
||||
/// or <see cref="McpClientExtensions.EnumerateResourceTemplatesAsync"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class McpClientResourceTemplate
|
||||
{
|
||||
private readonly IMcpClient _client;
|
||||
|
||||
internal McpClientResourceTemplate(IMcpClient client, ResourceTemplate resourceTemplate)
|
||||
{
|
||||
_client = client;
|
||||
ProtocolResourceTemplate = resourceTemplate;
|
||||
}
|
||||
|
||||
/// <summary>Gets the underlying protocol <see cref="ResourceTemplate"/> type for this instance.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property provides direct access to the underlying protocol representation of the resource template,
|
||||
/// which can be useful for advanced scenarios or when implementing custom MCP client extensions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For most common use cases, you can use the more convenient <see cref="UriTemplate"/> and
|
||||
/// <see cref="Description"/> properties instead of accessing the <see cref="ProtocolResourceTemplate"/> directly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ResourceTemplate ProtocolResourceTemplate { get; }
|
||||
|
||||
/// <summary>Gets the URI template of the resource template.</summary>
|
||||
public string UriTemplate => ProtocolResourceTemplate.UriTemplate;
|
||||
|
||||
/// <summary>Gets the name of the resource template.</summary>
|
||||
public string Name => ProtocolResourceTemplate.Name;
|
||||
|
||||
/// <summary>Gets a description of the resource template.</summary>
|
||||
public string? Description => ProtocolResourceTemplate.Description;
|
||||
|
||||
/// <summary>Gets a media (MIME) type of the resource template.</summary>
|
||||
public string? MimeType => ProtocolResourceTemplate.MimeType;
|
||||
|
||||
/// <summary>
|
||||
/// Gets this resource template's content by formatting a URI from the template and supplied arguments
|
||||
/// and sending a request to the server.
|
||||
/// </summary>
|
||||
/// <param name="arguments">A dictionary of arguments to pass to the tool. Each key represents a parameter name,
|
||||
/// and its associated value represents the argument value.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ValueTask{ReadResourceResult}"/> containing the resource template's result with content and messages.</returns>
|
||||
public ValueTask<ReadResourceResult> ReadAsync(
|
||||
IReadOnlyDictionary<string, object?> arguments,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_client.ReadResourceAsync(UriTemplate, arguments, cancellationToken);
|
||||
}
|
||||
208
src/ModelContextProtocol.Core/Client/McpClientTool.cs
Normal file
208
src/ModelContextProtocol.Core/Client/McpClientTool.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an <see cref="AIFunction"/> that calls a tool via an <see cref="IMcpClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="McpClientTool"/> class encapsulates an <see cref="IMcpClient"/> along with a description of
|
||||
/// a tool available via that client, allowing it to be invoked as an <see cref="AIFunction"/>. This enables integration
|
||||
/// with AI models that support function calling capabilities.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tools retrieved from an MCP server can be customized for model presentation using methods like
|
||||
/// <see cref="WithName"/> and <see cref="WithDescription"/> without changing the underlying tool functionality.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Typically, you would get instances of this class by calling the <see cref="McpClientExtensions.ListToolsAsync"/>
|
||||
/// or <see cref="McpClientExtensions.EnumerateToolsAsync"/> extension methods on an <see cref="IMcpClient"/> instance.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class McpClientTool : AIFunction
|
||||
{
|
||||
/// <summary>Additional properties exposed from tools.</summary>
|
||||
private static readonly ReadOnlyDictionary<string, object?> s_additionalProperties =
|
||||
new(new Dictionary<string, object?>()
|
||||
{
|
||||
["Strict"] = false, // some MCP schemas may not meet "strict" requirements
|
||||
});
|
||||
|
||||
private readonly IMcpClient _client;
|
||||
private readonly string _name;
|
||||
private readonly string _description;
|
||||
private readonly IProgress<ProgressNotificationValue>? _progress;
|
||||
|
||||
internal McpClientTool(
|
||||
IMcpClient client,
|
||||
Tool tool,
|
||||
JsonSerializerOptions serializerOptions,
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
{
|
||||
_client = client;
|
||||
ProtocolTool = tool;
|
||||
JsonSerializerOptions = serializerOptions;
|
||||
_name = name ?? tool.Name;
|
||||
_description = description ?? tool.Description ?? string.Empty;
|
||||
_progress = progress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the protocol <see cref="Tool"/> type for this instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property provides direct access to the underlying protocol representation of the tool,
|
||||
/// which can be useful for advanced scenarios or when implementing custom MCP client extensions.
|
||||
/// It contains the original metadata about the tool as provided by the server, including its
|
||||
/// name, description, and schema information before any customizations applied through methods
|
||||
/// like <see cref="WithName"/> or <see cref="WithDescription"/>.
|
||||
/// </remarks>
|
||||
public Tool ProtocolTool { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => _name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Description => _description;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement JsonSchema => ProtocolTool.InputSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonSerializerOptions JsonSerializerOptions { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyDictionary<string, object?> AdditionalProperties => s_additionalProperties;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected async override ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
CallToolResponse result = await CallAsync(arguments, _progress, JsonSerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the tool on the server.
|
||||
/// </summary>
|
||||
/// <param name="arguments">An optional dictionary of arguments to pass to the tool. Each key represents a parameter name,
|
||||
/// and its associated value represents the argument value.
|
||||
/// </param>
|
||||
/// <param name="progress">
|
||||
/// An optional <see cref="IProgress{T}"/> to have progress notifications reported to it. Setting this to a non-<see langword="null"/>
|
||||
/// value will result in a progress token being included in the call, and any resulting progress notifications during the operation
|
||||
/// routed to this instance.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">
|
||||
/// The JSON serialization options governing argument serialization. If <see langword="null"/>, the default serialization options will be used.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>
|
||||
/// A task containing the <see cref="CallToolResponse"/> from the tool execution. The response includes
|
||||
/// the tool's output content, which may be structured data, text, or an error message.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The base <see cref="AIFunction.InvokeAsync"/> method is overridden to invoke this <see cref="CallAsync"/> method.
|
||||
/// The only difference in behavior is <see cref="AIFunction.InvokeAsync"/> will serialize the resulting <see cref="CallToolResponse"/>"/>
|
||||
/// such that the <see cref="object"/> returned is a <see cref="JsonElement"/> containing the serialized <see cref="CallToolResponse"/>.
|
||||
/// This <see cref="CallToolResponse"/> method is intended to be called directly by user code, whereas the base <see cref="AIFunction.InvokeAsync"/>
|
||||
/// is intended to be used polymorphically via the base class, typically as part of an <see cref="IChatClient"/> operation.
|
||||
/// </remarks>
|
||||
/// <exception cref="McpException">The server could not find the requested tool, or the server encountered an error while processing the request.</exception>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var result = await tool.CallAsync(
|
||||
/// new Dictionary<string, object?>
|
||||
/// {
|
||||
/// ["message"] = "Hello MCP!"
|
||||
/// });
|
||||
/// </code>
|
||||
/// </example>
|
||||
public ValueTask<CallToolResponse> CallAsync(
|
||||
IReadOnlyDictionary<string, object?>? arguments = null,
|
||||
IProgress<ProgressNotificationValue>? progress = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_client.CallToolAsync(ProtocolTool.Name, arguments, progress, serializerOptions, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the tool but modified to return the specified name from its <see cref="Name"/> property.
|
||||
/// </summary>
|
||||
/// <param name="name">The model-facing name to give the tool.</param>
|
||||
/// <returns>A new instance of <see cref="McpClientTool"/> with the provided name.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is useful for optimizing the tool name for specific models or for prefixing the tool name
|
||||
/// with a namespace to avoid conflicts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Changing the name can help with:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Making the tool name more intuitive for the model</item>
|
||||
/// <item>Preventing name collisions when using tools from multiple sources</item>
|
||||
/// <item>Creating specialized versions of a general tool for specific contexts</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// When invoking <see cref="AIFunction.InvokeAsync"/>, the MCP server will still be called with
|
||||
/// the original tool name, so no mapping is required on the server side. This new name only affects
|
||||
/// the value returned from this instance's <see cref="AITool.Name"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public McpClientTool WithName(string name) =>
|
||||
new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, name, _description, _progress);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the tool but modified to return the specified description from its <see cref="Description"/> property.
|
||||
/// </summary>
|
||||
/// <param name="description">The description to give the tool.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Changing the description can help the model better understand the tool's purpose or provide more
|
||||
/// context about how the tool should be used. This is particularly useful when:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>The original description is too technical or lacks clarity for the model</item>
|
||||
/// <item>You want to add example usage scenarios to improve the model's understanding</item>
|
||||
/// <item>You need to tailor the tool's description for specific model requirements</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// When invoking <see cref="AIFunction.InvokeAsync"/>, the MCP server will still be called with
|
||||
/// the original tool description, so no mapping is required on the server side. This new description only affects
|
||||
/// the value returned from this instance's <see cref="AITool.Description"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns>A new instance of <see cref="McpClientTool"/> with the provided description.</returns>
|
||||
public McpClientTool WithDescription(string description) =>
|
||||
new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, description, _progress);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the tool but modified to report progress via the specified <see cref="IProgress{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="progress">The <see cref="IProgress{T}"/> to which progress notifications should be reported.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Adding an <see cref="IProgress{T}"/> to the tool does not impact how it is reported to any AI model.
|
||||
/// Rather, when the tool is invoked, the request to the MCP server will include a unique progress token,
|
||||
/// and any progress notifications issued by the server with that progress token while the operation is in
|
||||
/// flight will be reported to the <paramref name="progress"/> instance.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only one <see cref="IProgress{T}"/> can be specified at a time. Calling <see cref="WithProgress"/> again
|
||||
/// will overwrite any previously specified progress instance.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns>A new instance of <see cref="McpClientTool"/>, configured with the provided progress instance.</returns>
|
||||
public McpClientTool WithProgress(IProgress<ProgressNotificationValue> progress)
|
||||
{
|
||||
Throw.IfNull(progress);
|
||||
|
||||
return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, progress);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// The ServerSideEvents client transport implementation
|
||||
/// </summary>
|
||||
internal sealed partial class SseClientSessionTransport : TransportBase
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SseClientTransportOptions _options;
|
||||
private readonly Uri _sseEndpoint;
|
||||
private Uri? _messageEndpoint;
|
||||
private readonly CancellationTokenSource _connectionCts;
|
||||
private Task? _receiveTask;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TaskCompletionSource<bool> _connectionEstablished;
|
||||
|
||||
/// <summary>
|
||||
/// SSE transport for a single session. Unlike stdio it does not launch a process, but connects to an existing server.
|
||||
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
||||
/// </summary>
|
||||
public SseClientSessionTransport(
|
||||
string endpointName,
|
||||
SseClientTransportOptions transportOptions,
|
||||
HttpClient httpClient,
|
||||
Channel<JsonRpcMessage>? messageChannel,
|
||||
ILoggerFactory? loggerFactory)
|
||||
: base(endpointName, messageChannel, loggerFactory)
|
||||
{
|
||||
Throw.IfNull(transportOptions);
|
||||
Throw.IfNull(httpClient);
|
||||
|
||||
_options = transportOptions;
|
||||
_sseEndpoint = transportOptions.Endpoint;
|
||||
_httpClient = httpClient;
|
||||
_connectionCts = new CancellationTokenSource();
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<SseClientTransport>() ?? NullLogger.Instance;
|
||||
_connectionEstablished = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Debug.Assert(!IsConnected);
|
||||
try
|
||||
{
|
||||
// Start message receiving loop
|
||||
_receiveTask = ReceiveMessagesAsync(_connectionCts.Token);
|
||||
|
||||
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogTransportConnectFailed(Name, ex);
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
throw new InvalidOperationException("Failed to connect transport", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task SendMessageAsync(
|
||||
JsonRpcMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_messageEndpoint == null)
|
||||
throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
|
||||
string messageId = "(no id)";
|
||||
|
||||
if (message is JsonRpcMessageWithId messageWithId)
|
||||
{
|
||||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders);
|
||||
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
LogRejectedPostSensitive(Name, messageId, await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
else
|
||||
{
|
||||
LogRejectedPost(Name, messageId);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connectionCts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (_receiveTask != null)
|
||||
{
|
||||
await _receiveTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetDisconnected();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore exceptions on close
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders);
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken
|
||||
).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await foreach (SseItem<string> sseEvent in SseParser.Create(stream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
switch (sseEvent.EventType)
|
||||
{
|
||||
case "endpoint":
|
||||
HandleEndpointEvent(sseEvent.Data);
|
||||
break;
|
||||
|
||||
case "message":
|
||||
await ProcessSseMessage(sseEvent.Data, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Normal shutdown
|
||||
LogTransportReadMessagesCancelled(Name);
|
||||
_connectionEstablished.TrySetCanceled(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogTransportReadMessagesFailed(Name, ex);
|
||||
_connectionEstablished.TrySetException(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetDisconnected();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessSseMessage(string data, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConnected)
|
||||
{
|
||||
LogTransportMessageReceivedBeforeConnected(Name);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
|
||||
if (message == null)
|
||||
{
|
||||
LogTransportMessageParseUnexpectedTypeSensitive(Name, data);
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
LogTransportMessageParseFailedSensitive(Name, data, ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogTransportMessageParseFailed(Name, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEndpointEvent(string data)
|
||||
{
|
||||
if (string.IsNullOrEmpty(data))
|
||||
{
|
||||
LogTransportEndpointEventInvalid(Name);
|
||||
return;
|
||||
}
|
||||
|
||||
// If data is an absolute URL, the Uri will be constructed entirely from it and not the _sseEndpoint.
|
||||
_messageEndpoint = new Uri(_sseEndpoint, data);
|
||||
|
||||
// Set connected state
|
||||
SetConnected();
|
||||
_connectionEstablished.TrySetResult(true);
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} accepted SSE transport POST for message ID '{MessageId}'.")]
|
||||
private partial void LogAcceptedPost(string endpointName, string messageId);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} rejected SSE transport POST for message ID '{MessageId}'.")]
|
||||
private partial void LogRejectedPost(string endpointName, string messageId);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} rejected SSE transport POST for message ID '{MessageId}'. Server response: '{responseContent}'.")]
|
||||
private partial void LogRejectedPostSensitive(string endpointName, string messageId, string responseContent);
|
||||
}
|
||||
95
src/ModelContextProtocol.Core/Client/SseClientTransport.cs
Normal file
95
src/ModelContextProtocol.Core/Client/SseClientTransport.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an <see cref="IClientTransport"/> over HTTP using the Server-Sent Events (SSE) or Streamable HTTP protocol.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This transport connects to an MCP server over HTTP using SSE or Streamable HTTP,
|
||||
/// allowing for real-time server-to-client communication with a standard HTTP requests.
|
||||
/// Unlike the <see cref="StdioClientTransport"/>, this transport connects to an existing server
|
||||
/// rather than launching a new process.
|
||||
/// </remarks>
|
||||
public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||
{
|
||||
private readonly SseClientTransportOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SseClientTransport"/> class.
|
||||
/// </summary>
|
||||
/// <param name="transportOptions">Configuration options for the transport.</param>
|
||||
/// <param name="loggerFactory">Logger factory for creating loggers used for diagnostic output during transport operations.</param>
|
||||
public SseClientTransport(SseClientTransportOptions transportOptions, ILoggerFactory? loggerFactory = null)
|
||||
: this(transportOptions, new HttpClient(), loggerFactory, ownsHttpClient: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SseClientTransport"/> class with a provided HTTP client.
|
||||
/// </summary>
|
||||
/// <param name="transportOptions">Configuration options for the transport.</param>
|
||||
/// <param name="httpClient">The HTTP client instance used for requests.</param>
|
||||
/// <param name="loggerFactory">Logger factory for creating loggers used for diagnostic output during transport operations.</param>
|
||||
/// <param name="ownsHttpClient">
|
||||
/// <see langword="true"/> to dispose of <paramref name="httpClient"/> when the transport is disposed;
|
||||
/// <see langword="false"/> if the caller is retaining ownership of the <paramref name="httpClient"/>'s lifetime.
|
||||
/// </param>
|
||||
public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory = null, bool ownsHttpClient = false)
|
||||
{
|
||||
Throw.IfNull(transportOptions);
|
||||
Throw.IfNull(httpClient);
|
||||
|
||||
_options = transportOptions;
|
||||
_httpClient = httpClient;
|
||||
_loggerFactory = loggerFactory;
|
||||
_ownsHttpClient = ownsHttpClient;
|
||||
Name = transportOptions.Name ?? transportOptions.Endpoint.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _options.TransportMode switch
|
||||
{
|
||||
HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(_options, _httpClient, _loggerFactory, Name),
|
||||
HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory),
|
||||
HttpTransportMode.Sse => await ConnectSseTransportAsync(cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new InvalidOperationException($"Unsupported transport mode: {_options.TransportMode}"),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ITransport> ConnectSseTransportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionTransport = new SseClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);
|
||||
|
||||
try
|
||||
{
|
||||
await sessionTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
return sessionTransport;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await sessionTransport.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides options for configuring <see cref="SseClientTransport"/> instances.
|
||||
/// </summary>
|
||||
public class SseClientTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the base address of the server for SSE connections.
|
||||
/// </summary>
|
||||
public required Uri Endpoint
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(value), "Endpoint cannot be null.");
|
||||
}
|
||||
if (!value.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("Endpoint must be an absolute URI.", nameof(value));
|
||||
}
|
||||
if (value.Scheme != Uri.UriSchemeHttp && value.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
throw new ArgumentException("Endpoint must use HTTP or HTTPS scheme.", nameof(value));
|
||||
}
|
||||
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transport mode to use for the connection. Defaults to <see cref="HttpTransportMode.AutoDetect"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set to <see cref="HttpTransportMode.AutoDetect"/> (the default), the client will first attempt to use
|
||||
/// Streamable HTTP transport and automatically fall back to SSE transport if the server doesn't support it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable HTTP transport specification</see>.
|
||||
/// <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">HTTP with SSE transport specification</see>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public HttpTransportMode TransportMode { get; set; } = HttpTransportMode.AutoDetect;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a transport identifier used for logging purposes.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a timeout used to establish the initial connection to the SSE server. Defaults to 30 seconds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This timeout controls how long the client waits for:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The initial HTTP connection to be established with the SSE server</description></item>
|
||||
/// <item><description>The endpoint event to be received, which indicates the message endpoint URL</description></item>
|
||||
/// </list>
|
||||
/// If the timeout expires before the connection is established, a <see cref="TimeoutException"/> will be thrown.
|
||||
/// </remarks>
|
||||
public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets custom HTTP headers to include in requests to the SSE server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this property to specify custom HTTP headers that should be sent with each request to the server.
|
||||
/// </remarks>
|
||||
public Dictionary<string, string>? AdditionalHeaders { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>Provides the client side of a stdio-based session transport.</summary>
|
||||
internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
||||
{
|
||||
private readonly StdioClientTransportOptions _options;
|
||||
private readonly Process _process;
|
||||
private readonly Queue<string> _stderrRollingLog;
|
||||
private int _cleanedUp = 0;
|
||||
|
||||
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory)
|
||||
: base(process.StandardInput, process.StandardOutput, endpointName, loggerFactory)
|
||||
{
|
||||
_process = process;
|
||||
_options = options;
|
||||
_stderrRollingLog = stderrRollingLog;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await base.SendMessageAsync(message, cancellationToken);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
|
||||
// for the I/O error, we should throw an exception for that instead.
|
||||
if (await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false) is Exception processExitException)
|
||||
{
|
||||
throw processExitException;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Only clean up once.
|
||||
if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
|
||||
// so create an exception with details about that.
|
||||
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Now terminate the server process.
|
||||
try
|
||||
{
|
||||
StdioClientTransport.DisposeProcess(_process, processRunning: true, _options.ShutdownTimeout, Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogTransportShutdownFailed(Name, ex);
|
||||
}
|
||||
|
||||
// And handle cleanup in the base type.
|
||||
await base.CleanupAsync(error, cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!StdioClientTransport.HasExited(_process))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Debug.Assert(StdioClientTransport.HasExited(_process));
|
||||
try
|
||||
{
|
||||
// The process has exited, but we still need to ensure stderr has been flushed.
|
||||
#if NET
|
||||
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
_process.WaitForExit();
|
||||
#endif
|
||||
}
|
||||
catch { }
|
||||
|
||||
string errorMessage = "MCP server process exited unexpectedly";
|
||||
|
||||
string? exitCode = null;
|
||||
try
|
||||
{
|
||||
exitCode = $" (exit code: {(uint)_process.ExitCode})";
|
||||
}
|
||||
catch { }
|
||||
|
||||
lock (_stderrRollingLog)
|
||||
{
|
||||
if (_stderrRollingLog.Count > 0)
|
||||
{
|
||||
errorMessage =
|
||||
$"{errorMessage}{exitCode}{Environment.NewLine}" +
|
||||
$"Server's stderr tail:{Environment.NewLine}" +
|
||||
$"{string.Join(Environment.NewLine, _stderrRollingLog)}";
|
||||
}
|
||||
}
|
||||
|
||||
return new IOException(errorMessage);
|
||||
}
|
||||
}
|
||||
262
src/ModelContextProtocol.Core/Client/StdioClientTransport.cs
Normal file
262
src/ModelContextProtocol.Core/Client/StdioClientTransport.cs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a <see cref="IClientTransport"/> implemented via "stdio" (standard input/output).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This transport launches an external process and communicates with it through standard input and output streams.
|
||||
/// It's used to connect to MCP servers launched and hosted in child processes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The transport manages the entire lifecycle of the process: starting it with specified command-line arguments
|
||||
/// and environment variables, handling output, and properly terminating the process when the transport is closed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class StdioClientTransport : IClientTransport
|
||||
{
|
||||
private readonly StdioClientTransportOptions _options;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StdioClientTransport"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">Configuration options for the transport, including the command to execute, arguments, working directory, and environment variables.</param>
|
||||
/// <param name="loggerFactory">Logger factory for creating loggers used for diagnostic output during transport operations.</param>
|
||||
public StdioClientTransport(StdioClientTransportOptions options, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
Throw.IfNull(options);
|
||||
|
||||
_options = options;
|
||||
_loggerFactory = loggerFactory;
|
||||
Name = options.Name ?? $"stdio-{Regex.Replace(Path.GetFileName(options.Command), @"[\s\.]+", "-")}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
string endpointName = Name;
|
||||
|
||||
Process? process = null;
|
||||
bool processStarted = false;
|
||||
|
||||
string command = _options.Command;
|
||||
IList<string>? arguments = _options.Arguments;
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
|
||||
!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 = arguments is null or [] ? ["/c", command] : ["/c", command, ..arguments];
|
||||
command = "cmd.exe";
|
||||
}
|
||||
|
||||
ILogger logger = (ILogger?)_loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
|
||||
try
|
||||
{
|
||||
LogTransportConnecting(logger, endpointName);
|
||||
|
||||
UTF8Encoding noBomUTF8 = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = command,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = _options.WorkingDirectory ?? Environment.CurrentDirectory,
|
||||
StandardOutputEncoding = noBomUTF8,
|
||||
StandardErrorEncoding = noBomUTF8,
|
||||
#if NET
|
||||
StandardInputEncoding = noBomUTF8,
|
||||
#endif
|
||||
};
|
||||
|
||||
if (arguments is not null)
|
||||
{
|
||||
#if NET
|
||||
foreach (string arg in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
#else
|
||||
StringBuilder argsBuilder = new();
|
||||
foreach (string arg in arguments)
|
||||
{
|
||||
PasteArguments.AppendArgument(argsBuilder, arg);
|
||||
}
|
||||
|
||||
startInfo.Arguments = argsBuilder.ToString();
|
||||
#endif
|
||||
}
|
||||
|
||||
if (_options.EnvironmentVariables != null)
|
||||
{
|
||||
foreach (var entry in _options.EnvironmentVariables)
|
||||
{
|
||||
startInfo.Environment[entry.Key] = entry.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
LogCreateProcessForTransportSensitive(logger, endpointName, _options.Command,
|
||||
startInfo.Arguments,
|
||||
string.Join(", ", startInfo.Environment.Select(kvp => $"{kvp.Key}={kvp.Value}")),
|
||||
startInfo.WorkingDirectory);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogCreateProcessForTransport(logger, endpointName, _options.Command);
|
||||
}
|
||||
|
||||
process = new() { StartInfo = startInfo };
|
||||
|
||||
// Set up stderr handling. Log all stderr output, and keep the last
|
||||
// few lines in a rolling log for use in exceptions.
|
||||
const int MaxStderrLength = 10; // keep the last 10 lines of stderr
|
||||
Queue<string> stderrRollingLog = new(MaxStderrLength);
|
||||
process.ErrorDataReceived += (sender, args) =>
|
||||
{
|
||||
string? data = args.Data;
|
||||
if (data is not null)
|
||||
{
|
||||
lock (stderrRollingLog)
|
||||
{
|
||||
if (stderrRollingLog.Count >= MaxStderrLength)
|
||||
{
|
||||
stderrRollingLog.Dequeue();
|
||||
}
|
||||
|
||||
stderrRollingLog.Enqueue(data);
|
||||
}
|
||||
|
||||
LogReadStderr(logger, endpointName, data);
|
||||
}
|
||||
};
|
||||
|
||||
// We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
|
||||
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
|
||||
// StandardInputEncoding doesn't exist on .NET Framework; instead, it always picks
|
||||
// up the encoding from Console.InputEncoding. As such, when not targeting .NET Core,
|
||||
// we temporarily change Console.InputEncoding to no-BOM UTF-8 around the Process.Start
|
||||
// call, to ensure it picks up the correct encoding.
|
||||
#if NET
|
||||
processStarted = process.Start();
|
||||
#else
|
||||
Encoding originalInputEncoding = Console.InputEncoding;
|
||||
try
|
||||
{
|
||||
Console.InputEncoding = noBomUTF8;
|
||||
processStarted = process.Start();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.InputEncoding = originalInputEncoding;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!processStarted)
|
||||
{
|
||||
LogTransportProcessStartFailed(logger, endpointName);
|
||||
throw new IOException("Failed to start MCP server process.");
|
||||
}
|
||||
|
||||
LogTransportProcessStarted(logger, endpointName, process.Id);
|
||||
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, _loggerFactory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogTransportConnectFailed(logger, endpointName, ex);
|
||||
|
||||
try
|
||||
{
|
||||
DisposeProcess(process, processStarted, _options.ShutdownTimeout, endpointName);
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
LogTransportShutdownFailed(logger, endpointName, ex2);
|
||||
}
|
||||
|
||||
throw new IOException("Failed to connect transport.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void DisposeProcess(
|
||||
Process? process, bool processRunning, TimeSpan shutdownTimeout, string endpointName)
|
||||
{
|
||||
if (process is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
processRunning = processRunning && !HasExited(process);
|
||||
if (processRunning)
|
||||
{
|
||||
// Wait for the process to exit.
|
||||
// Kill the while process tree because the process may spawn child processes
|
||||
// and Node.js does not kill its children when it exits properly.
|
||||
process.KillTree(shutdownTimeout);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets whether <paramref name="process"/> has exited.</summary>
|
||||
internal static bool HasExited(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
return process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} connecting.")]
|
||||
private static partial void LogTransportConnecting(ILogger logger, string endpointName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} starting server process. Command: '{Command}'.")]
|
||||
private static partial void LogCreateProcessForTransport(ILogger logger, string endpointName, string command);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} starting server process. Command: '{Command}', Arguments: {Arguments}, Environment: {Environment}, Working directory: {WorkingDirectory}.")]
|
||||
private static partial void LogCreateProcessForTransportSensitive(ILogger logger, string endpointName, string command, string? arguments, string environment, string workingDirectory);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} failed to start server process.")]
|
||||
private static partial void LogTransportProcessStartFailed(ILogger logger, string endpointName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received stderr log: '{Data}'.")]
|
||||
private static partial void LogReadStderr(ILogger logger, string endpointName, string data);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} started server process with PID {ProcessId}.")]
|
||||
private static partial void LogTransportProcessStarted(ILogger logger, string endpointName, int processId);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} connect failed.")]
|
||||
private static partial void LogTransportConnectFailed(ILogger logger, string endpointName, Exception exception);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} shutdown failed.")]
|
||||
private static partial void LogTransportShutdownFailed(ILogger logger, string endpointName, Exception exception);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides options for configuring <see cref="StdioClientTransport"/> instances.
|
||||
/// </summary>
|
||||
public class StdioClientTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the command to execute to start the server process.
|
||||
/// </summary>
|
||||
public required string Command
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Command cannot be null or empty.", nameof(value));
|
||||
}
|
||||
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the arguments to pass to the server process when it is started.
|
||||
/// </summary>
|
||||
public IList<string>? Arguments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a transport identifier used for logging purposes.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the working directory for the server process.
|
||||
/// </summary>
|
||||
public string? WorkingDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets environment variables to set for the server process.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property allows you to specify environment variables that will be set in the server process's
|
||||
/// environment. This is useful for passing configuration, authentication information, or runtime flags
|
||||
/// to the server without modifying its code.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// By default, when starting the server process, the server process will inherit the current environment's variables,
|
||||
/// as discovered via <see cref="Environment.GetEnvironmentVariables()"/>. After those variables are found, the entries
|
||||
/// in this <see cref="EnvironmentVariables"/> dictionary are used to augment and overwrite the entries read from the environment.
|
||||
/// That includes removing the variables for any of this collection's entries with a null value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Dictionary<string, string?>? EnvironmentVariables { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout to wait for the server to shut down gracefully.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property dictates how long the client should wait for the server process to exit cleanly during shutdown
|
||||
/// before forcibly terminating it. This balances between giving the server enough time to clean up
|
||||
/// resources and not hanging indefinitely if a server process becomes unresponsive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default is five seconds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>Provides the client side of a stream-based session transport.</summary>
|
||||
internal class StreamClientSessionTransport : TransportBase
|
||||
{
|
||||
private readonly TextReader _serverOutput;
|
||||
private readonly TextWriter _serverInput;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private CancellationTokenSource? _shutdownCts = new();
|
||||
private Task? _readTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamClientSessionTransport"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serverInput">
|
||||
/// The text writer connected to the server's input stream.
|
||||
/// Messages written to this writer will be sent to the server.
|
||||
/// </param>
|
||||
/// <param name="serverOutput">
|
||||
/// The text reader connected to the server's output stream.
|
||||
/// Messages read from this reader will be received from the server.
|
||||
/// </param>
|
||||
/// <param name="endpointName">
|
||||
/// A name that identifies this transport endpoint in logs.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional factory for creating loggers. If null, a NullLogger will be used.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// This constructor starts a background task to read messages from the server output stream.
|
||||
/// The transport will be marked as connected once initialized.
|
||||
/// </remarks>
|
||||
public StreamClientSessionTransport(
|
||||
TextWriter serverInput, TextReader serverOutput, string endpointName, ILoggerFactory? loggerFactory)
|
||||
: base(endpointName, loggerFactory)
|
||||
{
|
||||
_serverOutput = serverOutput;
|
||||
_serverInput = serverInput;
|
||||
|
||||
SetConnected();
|
||||
|
||||
// Start reading messages in the background. We use the rarer pattern of new Task + Start
|
||||
// in order to ensure that the body of the task will always see _readTask initialized.
|
||||
// It is then able to reliably null it out on completion.
|
||||
var readTask = new Task<Task>(
|
||||
thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
|
||||
this,
|
||||
TaskCreationOptions.DenyChildAttach);
|
||||
_readTask = readTask.Unwrap();
|
||||
readTask.Start();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string id = "(no id)";
|
||||
if (message is JsonRpcMessageWithId messageWithId)
|
||||
{
|
||||
id = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
|
||||
|
||||
using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Write the message followed by a newline using our UTF-8 writer
|
||||
await _serverInput.WriteLineAsync(json).ConfigureAwait(false);
|
||||
await _serverInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogTransportSendFailed(Name, id, ex);
|
||||
throw new IOException("Failed to send message.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask DisposeAsync() =>
|
||||
CleanupAsync(cancellationToken: CancellationToken.None);
|
||||
|
||||
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Exception? error = null;
|
||||
try
|
||||
{
|
||||
LogTransportEnteringReadMessagesLoop(Name);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (await _serverOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false) is not string line)
|
||||
{
|
||||
LogTransportEndOfStream(Name);
|
||||
break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LogTransportReceivedMessageSensitive(Name, line);
|
||||
|
||||
await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
LogTransportReadMessagesCancelled(Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex;
|
||||
LogTransportReadMessagesFailed(Name, ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_readTask = null;
|
||||
await CleanupAsync(error, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessMessageAsync(string line, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = (JsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)));
|
||||
if (message != null)
|
||||
{
|
||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogTransportMessageParseUnexpectedTypeSensitive(Name, line);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
LogTransportMessageParseFailedSensitive(Name, line, ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogTransportMessageParseFailed(Name, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LogTransportShuttingDown(Name);
|
||||
|
||||
if (Interlocked.Exchange(ref _shutdownCts, null) is { } shutdownCts)
|
||||
{
|
||||
await shutdownCts.CancelAsync().ConfigureAwait(false);
|
||||
shutdownCts.Dispose();
|
||||
}
|
||||
|
||||
if (Interlocked.Exchange(ref _readTask, null) is Task readTask)
|
||||
{
|
||||
try
|
||||
{
|
||||
await readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogTransportCleanupReadTaskFailed(Name, ex);
|
||||
}
|
||||
}
|
||||
|
||||
SetDisconnected(error);
|
||||
LogTransportShutDown(Name);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue