Add ModelContextProtocol.Core package (#428)
This commit is contained in:
parent
57458241b9
commit
703b0fe50b
1 changed files with 239 additions and 94 deletions
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue