Automatically fall back from Streamable HTTP to SSE on the client by default (#456)
This commit is contained in:
parent
adb2098e4d
commit
317f468807
18 changed files with 489 additions and 49 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);
|
||||||
|
}
|
||||||
23
src/ModelContextProtocol/Client/HttpTransportMode.cs
Normal file
23
src/ModelContextProtocol/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
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ using System.Net.Http.Headers;
|
||||||
using System.Net.ServerSentEvents;
|
using System.Net.ServerSentEvents;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Client;
|
namespace ModelContextProtocol.Client;
|
||||||
|
|
||||||
|
|
@ -24,15 +25,16 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
||||||
private readonly TaskCompletionSource<bool> _connectionEstablished;
|
private readonly TaskCompletionSource<bool> _connectionEstablished;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
|
/// 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.
|
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="transportOptions">Configuration options for the transport.</param>
|
public SseClientSessionTransport(
|
||||||
/// <param name="httpClient">The HTTP client instance used for requests.</param>
|
string endpointName,
|
||||||
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
SseClientTransportOptions transportOptions,
|
||||||
/// <param name="endpointName">The endpoint name used for logging purposes.</param>
|
HttpClient httpClient,
|
||||||
public SseClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
|
Channel<JsonRpcMessage>? messageChannel,
|
||||||
: base(endpointName, loggerFactory)
|
ILoggerFactory? loggerFactory)
|
||||||
|
: base(endpointName, messageChannel, loggerFactory)
|
||||||
{
|
{
|
||||||
Throw.IfNull(transportOptions);
|
Throw.IfNull(transportOptions);
|
||||||
Throw.IfNull(httpClient);
|
Throw.IfNull(httpClient);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ using ModelContextProtocol.Protocol;
|
||||||
namespace ModelContextProtocol.Client;
|
namespace ModelContextProtocol.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides an <see cref="IClientTransport"/> over HTTP using the Server-Sent Events (SSE) protocol.
|
/// Provides an <see cref="IClientTransport"/> over HTTP using the Server-Sent Events (SSE) or Streamable HTTP protocol.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This transport connects to an MCP server over HTTP using SSE,
|
/// 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 request.
|
/// 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
|
/// Unlike the <see cref="StdioClientTransport"/>, this transport connects to an existing server
|
||||||
/// rather than launching a new process.
|
/// rather than launching a new process.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
|
@ -36,7 +36,7 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||||
/// <param name="httpClient">The HTTP client instance used for requests.</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="loggerFactory">Logger factory for creating loggers used for diagnostic output during transport operations.</param>
|
||||||
/// <param name="ownsHttpClient">
|
/// <param name="ownsHttpClient">
|
||||||
/// <see langword="true"/> to dispose of <paramref name="httpClient"/> when the transport is disposed;
|
/// <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.
|
/// <see langword="false"/> if the caller is retaining ownership of the <paramref name="httpClient"/>'s lifetime.
|
||||||
/// </param>
|
/// </param>
|
||||||
public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory = null, bool ownsHttpClient = false)
|
public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory = null, bool ownsHttpClient = false)
|
||||||
|
|
@ -57,12 +57,22 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_options.UseStreamableHttp)
|
switch (_options.TransportMode)
|
||||||
{
|
{
|
||||||
return new StreamableHttpClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
|
case HttpTransportMode.AutoDetect:
|
||||||
|
return new AutoDetectingClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
|
||||||
|
case HttpTransportMode.StreamableHttp:
|
||||||
|
return new StreamableHttpClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);
|
||||||
|
case HttpTransportMode.Sse:
|
||||||
|
return await ConnectSseTransportAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unsupported transport mode: {_options.TransportMode}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
|
private async Task<ITransport> ConnectSseTransportAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sessionTransport = new SseClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,19 @@ public record SseClientTransportOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether to use "Streamable HTTP" for the transport rather than "HTTP with SSE". Defaults to false.
|
/// 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/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>.
|
/// <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">HTTP with SSE transport specification</see>.
|
||||||
/// </summary>
|
/// </para>
|
||||||
public bool UseStreamableHttp { get; init; }
|
/// </remarks>
|
||||||
|
public HttpTransportMode TransportMode { get; init; } = HttpTransportMode.AutoDetect;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a transport identifier used for logging purposes.
|
/// Gets a transport identifier used for logging purposes.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ internal class StreamClientSessionTransport : TransportBase
|
||||||
/// Initializes a new instance of the <see cref="StreamClientSessionTransport"/> class.
|
/// Initializes a new instance of the <see cref="StreamClientSessionTransport"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="serverInput">
|
/// <param name="serverInput">
|
||||||
/// The text writer connected to the server's input stream.
|
/// The text writer connected to the server's input stream.
|
||||||
/// Messages written to this writer will be sent to the server.
|
/// Messages written to this writer will be sent to the server.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="serverOutput">
|
/// <param name="serverOutput">
|
||||||
|
|
@ -41,17 +41,17 @@ internal class StreamClientSessionTransport : TransportBase
|
||||||
_serverOutput = serverOutput;
|
_serverOutput = serverOutput;
|
||||||
_serverInput = serverInput;
|
_serverInput = serverInput;
|
||||||
|
|
||||||
|
SetConnected();
|
||||||
|
|
||||||
// Start reading messages in the background. We use the rarer pattern of new Task + Start
|
// 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.
|
// 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.
|
// It is then able to reliably null it out on completion.
|
||||||
var readTask = new Task<Task>(
|
var readTask = new Task<Task>(
|
||||||
thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
|
thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token),
|
||||||
this,
|
this,
|
||||||
TaskCreationOptions.DenyChildAttach);
|
TaskCreationOptions.DenyChildAttach);
|
||||||
_readTask = readTask.Unwrap();
|
_readTask = readTask.Unwrap();
|
||||||
readTask.Start();
|
readTask.Start();
|
||||||
|
|
||||||
SetConnected();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|
@ -80,7 +80,7 @@ internal class StreamClientSessionTransport : TransportBase
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override ValueTask DisposeAsync() =>
|
public override ValueTask DisposeAsync() =>
|
||||||
CleanupAsync(cancellationToken: CancellationToken.None);
|
CleanupAsync(cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ using System.Net.Http.Headers;
|
||||||
using System.Net.ServerSentEvents;
|
using System.Net.ServerSentEvents;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
#if NET
|
#if NET
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
#else
|
#else
|
||||||
|
|
@ -28,8 +30,13 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
private string? _mcpSessionId;
|
private string? _mcpSessionId;
|
||||||
private Task? _getReceiveTask;
|
private Task? _getReceiveTask;
|
||||||
|
|
||||||
public StreamableHttpClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
|
public StreamableHttpClientSessionTransport(
|
||||||
: base(endpointName, loggerFactory)
|
string endpointName,
|
||||||
|
SseClientTransportOptions transportOptions,
|
||||||
|
HttpClient httpClient,
|
||||||
|
Channel<JsonRpcMessage>? messageChannel,
|
||||||
|
ILoggerFactory? loggerFactory)
|
||||||
|
: base(endpointName, messageChannel, loggerFactory)
|
||||||
{
|
{
|
||||||
Throw.IfNull(transportOptions);
|
Throw.IfNull(transportOptions);
|
||||||
Throw.IfNull(httpClient);
|
Throw.IfNull(httpClient);
|
||||||
|
|
@ -46,9 +53,15 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task SendMessageAsync(
|
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||||
JsonRpcMessage message,
|
{
|
||||||
CancellationToken cancellationToken = default)
|
// Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it.
|
||||||
|
using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is used by the auto transport so it can fall back and try SSE given a non-200 response without catching an exception.
|
||||||
|
internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token);
|
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token);
|
||||||
cancellationToken = sendCts.Token;
|
cancellationToken = sendCts.Token;
|
||||||
|
|
@ -73,9 +86,14 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
};
|
};
|
||||||
|
|
||||||
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, _mcpSessionId);
|
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, _mcpSessionId);
|
||||||
using var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// We'll let the caller decide whether to throw or fall back given an unsuccessful response.
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
var rpcRequest = message as JsonRpcRequest;
|
var rpcRequest = message as JsonRpcRequest;
|
||||||
JsonRpcMessage? rpcResponseCandidate = null;
|
JsonRpcMessage? rpcResponseCandidate = null;
|
||||||
|
|
@ -93,7 +111,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
|
|
||||||
if (rpcRequest is null)
|
if (rpcRequest is null)
|
||||||
{
|
{
|
||||||
return;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rpcResponseCandidate is not JsonRpcMessageWithId messageWithId || messageWithId.Id != rpcRequest.Id)
|
if (rpcResponseCandidate is not JsonRpcMessageWithId messageWithId || messageWithId.Id != rpcRequest.Id)
|
||||||
|
|
@ -111,6 +129,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
|
|
||||||
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
|
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
|
|
@ -136,7 +156,12 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
SetDisconnected();
|
// If we're auto-detecting the transport and failed to connect, leave the message Channel open for the SSE transport.
|
||||||
|
// This class isn't directly exposed to public callers, so we don't have to worry about changing the _state in this case.
|
||||||
|
if (_options.TransportMode is not HttpTransportMode.AutoDetect || _getReceiveTask is not null)
|
||||||
|
{
|
||||||
|
SetDisconnected();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,20 @@ public abstract partial class TransportBase : ITransport
|
||||||
/// Initializes a new instance of the <see cref="TransportBase"/> class.
|
/// Initializes a new instance of the <see cref="TransportBase"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected TransportBase(string name, ILoggerFactory? loggerFactory)
|
protected TransportBase(string name, ILoggerFactory? loggerFactory)
|
||||||
|
: this(name, null, loggerFactory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TransportBase"/> class with a specified channel to back <see cref="MessageReader"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal TransportBase(string name, Channel<JsonRpcMessage>? messageChannel, ILoggerFactory? loggerFactory)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
_logger = loggerFactory?.CreateLogger(GetType()) ?? NullLogger.Instance;
|
_logger = loggerFactory?.CreateLogger(GetType()) ?? NullLogger.Instance;
|
||||||
|
|
||||||
// Unbounded channel to prevent blocking on writes
|
// Unbounded channel to prevent blocking on writes. Ensure AutoDetectingClientSessionTransport matches this.
|
||||||
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
|
_messageChannel = messageChannel ?? Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
|
||||||
{
|
{
|
||||||
SingleReader = true,
|
SingleReader = true,
|
||||||
SingleWriter = false,
|
SingleWriter = false,
|
||||||
|
|
@ -112,7 +120,7 @@ public abstract partial class TransportBase : ITransport
|
||||||
|
|
||||||
case StateConnected:
|
case StateConnected:
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case StateDisconnected:
|
case StateDisconnected:
|
||||||
throw new IOException("Transport is already disconnected and can't be reconnected.");
|
throw new IOException("Transport is already disconnected and can't be reconnected.");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListTools_Sse_TestServer()
|
public async Task ListTools_Sse_TestServer()
|
||||||
{
|
{
|
||||||
// arrange
|
// arrange
|
||||||
|
|
||||||
// act
|
// act
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
|
||||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||||
|
|
||||||
|
|
@ -34,4 +35,112 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
||||||
|
|
||||||
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
|
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamableHttpMode_Works_WithRootEndpoint()
|
||||||
|
{
|
||||||
|
Builder.Services.AddMcpServer(options =>
|
||||||
|
{
|
||||||
|
options.ServerInfo = new()
|
||||||
|
{
|
||||||
|
Name = "StreamableHttpTestServer",
|
||||||
|
Version = "1.0.0",
|
||||||
|
};
|
||||||
|
}).WithHttpTransport(ConfigureStateless);
|
||||||
|
await using var app = Builder.Build();
|
||||||
|
|
||||||
|
app.MapMcp();
|
||||||
|
|
||||||
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var mcpClient = await ConnectAsync("/", new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost/"),
|
||||||
|
TransportMode = HttpTransportMode.AutoDetect
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("StreamableHttpTestServer", mcpClient.ServerInfo.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoDetectMode_Works_WithRootEndpoint()
|
||||||
|
{
|
||||||
|
Builder.Services.AddMcpServer(options =>
|
||||||
|
{
|
||||||
|
options.ServerInfo = new()
|
||||||
|
{
|
||||||
|
Name = "AutoDetectTestServer",
|
||||||
|
Version = "1.0.0",
|
||||||
|
};
|
||||||
|
}).WithHttpTransport(ConfigureStateless);
|
||||||
|
await using var app = Builder.Build();
|
||||||
|
|
||||||
|
app.MapMcp();
|
||||||
|
|
||||||
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var mcpClient = await ConnectAsync("/", new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost/"),
|
||||||
|
TransportMode = HttpTransportMode.AutoDetect
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("AutoDetectTestServer", mcpClient.ServerInfo.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoDetectMode_Works_WithSseEndpoint()
|
||||||
|
{
|
||||||
|
Assert.SkipWhen(Stateless, "SSE endpoint is disabled in stateless mode.");
|
||||||
|
|
||||||
|
Builder.Services.AddMcpServer(options =>
|
||||||
|
{
|
||||||
|
options.ServerInfo = new()
|
||||||
|
{
|
||||||
|
Name = "AutoDetectSseTestServer",
|
||||||
|
Version = "1.0.0",
|
||||||
|
};
|
||||||
|
}).WithHttpTransport(ConfigureStateless);
|
||||||
|
await using var app = Builder.Build();
|
||||||
|
|
||||||
|
app.MapMcp();
|
||||||
|
|
||||||
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var mcpClient = await ConnectAsync("/sse", new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost/sse"),
|
||||||
|
TransportMode = HttpTransportMode.AutoDetect
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("AutoDetectSseTestServer", mcpClient.ServerInfo.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SseMode_Works_WithSseEndpoint()
|
||||||
|
{
|
||||||
|
Assert.SkipWhen(Stateless, "SSE endpoint is disabled in stateless mode.");
|
||||||
|
|
||||||
|
Builder.Services.AddMcpServer(options =>
|
||||||
|
{
|
||||||
|
options.ServerInfo = new()
|
||||||
|
{
|
||||||
|
Name = "SseTestServer",
|
||||||
|
Version = "1.0.0",
|
||||||
|
};
|
||||||
|
}).WithHttpTransport(ConfigureStateless);
|
||||||
|
await using var app = Builder.Build();
|
||||||
|
|
||||||
|
app.MapMcp();
|
||||||
|
|
||||||
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var mcpClient = await ConnectAsync(options: new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost/sse"),
|
||||||
|
TransportMode = HttpTransportMode.Sse
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("SseTestServer", mcpClient.ServerInfo.Name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,17 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
|
||||||
options.Stateless = Stateless;
|
options.Stateless = Stateless;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task<IMcpClient> ConnectAsync(string? path = null)
|
protected async Task<IMcpClient> ConnectAsync(string? path = null, SseClientTransportOptions? options = null)
|
||||||
{
|
{
|
||||||
|
// Default behavior when no options are provided
|
||||||
path ??= UseStreamableHttp ? "/" : "/sse";
|
path ??= UseStreamableHttp ? "/" : "/sse";
|
||||||
|
|
||||||
var sseClientTransportOptions = new SseClientTransportOptions()
|
await using var transport = new SseClientTransport(options ?? new SseClientTransportOptions()
|
||||||
{
|
{
|
||||||
Endpoint = new Uri($"http://localhost{path}"),
|
Endpoint = new Uri($"http://localhost{path}"),
|
||||||
UseStreamableHttp = UseStreamableHttp,
|
TransportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse,
|
||||||
};
|
}, HttpClient, LoggerFactory);
|
||||||
await using var transport = new SseClientTransport(sseClientTransportOptions, HttpClient, LoggerFactory);
|
|
||||||
return await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
return await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,6 @@ public class StatelessServerIntegrationTests(SseServerIntegrationTestFixture fix
|
||||||
{
|
{
|
||||||
Endpoint = new Uri("http://localhost/stateless"),
|
Endpoint = new Uri("http://localhost/stateless"),
|
||||||
Name = "In-memory Streamable HTTP Client",
|
Name = "In-memory Streamable HTTP Client",
|
||||||
UseStreamableHttp = true,
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
||||||
{
|
{
|
||||||
Endpoint = new Uri("http://localhost/"),
|
Endpoint = new Uri("http://localhost/"),
|
||||||
Name = "In-memory Streamable HTTP Client",
|
Name = "In-memory Streamable HTTP Client",
|
||||||
UseStreamableHttp = true,
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
};
|
};
|
||||||
|
|
||||||
private async Task StartAsync()
|
private async Task StartAsync()
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
|
||||||
await using var transport = new SseClientTransport(new()
|
await using var transport = new SseClientTransport(new()
|
||||||
{
|
{
|
||||||
Endpoint = new("http://localhost/mcp"),
|
Endpoint = new("http://localhost/mcp"),
|
||||||
UseStreamableHttp = true,
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
}, HttpClient, LoggerFactory);
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
@ -118,7 +118,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
|
||||||
await using var transport = new SseClientTransport(new()
|
await using var transport = new SseClientTransport(new()
|
||||||
{
|
{
|
||||||
Endpoint = new("http://localhost/mcp"),
|
Endpoint = new("http://localhost/mcp"),
|
||||||
UseStreamableHttp = true,
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
}, HttpClient, LoggerFactory);
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ public class StreamableHttpServerIntegrationTests(SseServerIntegrationTestFixtur
|
||||||
{
|
{
|
||||||
Endpoint = new Uri("http://localhost/"),
|
Endpoint = new Uri("http://localhost/"),
|
||||||
Name = "In-memory Streamable HTTP Client",
|
Name = "In-memory Streamable HTTP Client",
|
||||||
UseStreamableHttp = true,
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
};
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Tests.Utils;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Transport;
|
||||||
|
|
||||||
|
public class SseClientTransportAutoDetectTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoDetectMode_UsesStreamableHttp_WhenServerSupportsIt()
|
||||||
|
{
|
||||||
|
var options = new SseClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost"),
|
||||||
|
TransportMode = HttpTransportMode.AutoDetect,
|
||||||
|
Name = "AutoDetect test client"
|
||||||
|
};
|
||||||
|
|
||||||
|
using var mockHttpHandler = new MockHttpHandler();
|
||||||
|
using var httpClient = new HttpClient(mockHttpHandler);
|
||||||
|
await using var transport = new SseClientTransport(options, httpClient, LoggerFactory);
|
||||||
|
|
||||||
|
// Simulate successful Streamable HTTP response for initialize
|
||||||
|
mockHttpHandler.RequestHandler = (request) =>
|
||||||
|
{
|
||||||
|
if (request.Method == HttpMethod.Post)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":\"init-id\",\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{}}}}"),
|
||||||
|
Headers =
|
||||||
|
{
|
||||||
|
{ "Content-Type", "application/json" },
|
||||||
|
{ "mcp-session-id", "test-session" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shouldn't reach here for successful Streamable HTTP
|
||||||
|
throw new InvalidOperationException("Unexpected request");
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// The auto-detecting transport should be returned
|
||||||
|
Assert.NotNull(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails()
|
||||||
|
{
|
||||||
|
var options = new SseClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost"),
|
||||||
|
TransportMode = HttpTransportMode.AutoDetect,
|
||||||
|
Name = "AutoDetect test client"
|
||||||
|
};
|
||||||
|
|
||||||
|
using var mockHttpHandler = new MockHttpHandler();
|
||||||
|
using var httpClient = new HttpClient(mockHttpHandler);
|
||||||
|
await using var transport = new SseClientTransport(options, httpClient, LoggerFactory);
|
||||||
|
|
||||||
|
var requestCount = 0;
|
||||||
|
|
||||||
|
mockHttpHandler.RequestHandler = (request) =>
|
||||||
|
{
|
||||||
|
requestCount++;
|
||||||
|
|
||||||
|
if (request.Method == HttpMethod.Post && requestCount == 1)
|
||||||
|
{
|
||||||
|
// First POST (Streamable HTTP) fails
|
||||||
|
return Task.FromResult(new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.NotFound,
|
||||||
|
Content = new StringContent("Streamable HTTP not supported")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Method == HttpMethod.Get)
|
||||||
|
{
|
||||||
|
// SSE connection request
|
||||||
|
return Task.FromResult(new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent("event: endpoint\r\ndata: /sse-endpoint\r\n\r\n"),
|
||||||
|
Headers = { { "Content-Type", "text/event-stream" } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Method == HttpMethod.Post && requestCount > 1)
|
||||||
|
{
|
||||||
|
// Subsequent POST to SSE endpoint succeeds
|
||||||
|
return Task.FromResult(new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent("accepted")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Unexpected request: {request.Method}, count: {requestCount}");
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// The auto-detecting transport should be returned
|
||||||
|
Assert.NotNull(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ public class SseClientTransportTests : LoggedTest
|
||||||
Endpoint = new Uri("http://localhost:8080"),
|
Endpoint = new Uri("http://localhost:8080"),
|
||||||
ConnectionTimeout = TimeSpan.FromSeconds(2),
|
ConnectionTimeout = TimeSpan.FromSeconds(2),
|
||||||
Name = "Test Server",
|
Name = "Test Server",
|
||||||
|
TransportMode = HttpTransportMode.Sse,
|
||||||
AdditionalHeaders = new Dictionary<string, string>
|
AdditionalHeaders = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["test"] = "header"
|
["test"] = "header"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Tests.Utils;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests.Transport;
|
namespace ModelContextProtocol.Tests.Transport;
|
||||||
|
|
||||||
public class StdioClientTransportTests
|
public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_ValidProcessInvalidServer_Throws()
|
public async Task CreateAsync_ValidProcessInvalidServer_Throws()
|
||||||
|
|
@ -11,10 +12,10 @@ public class StdioClientTransportTests
|
||||||
string id = Guid.NewGuid().ToString("N");
|
string id = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
|
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
|
||||||
new(new() { Command = "cmd", Arguments = ["/C", $"echo \"{id}\" >&2"] }) :
|
new(new() { Command = "cmd", Arguments = ["/C", $"echo \"{id}\" >&2"] }, LoggerFactory) :
|
||||||
new(new() { Command = "ls", Arguments = [id] });
|
new(new() { Command = "ls", Arguments = [id] }, LoggerFactory);
|
||||||
|
|
||||||
IOException e = await Assert.ThrowsAsync<IOException>(() => McpClientFactory.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken));
|
IOException e = await Assert.ThrowsAsync<IOException>(() => McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||||
Assert.Contains(id, e.ToString());
|
Assert.Contains(id, e.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue