Automatically fall back from Streamable HTTP to SSE on the client by default (#456)

This commit is contained in:
Stephen Halter 2025-05-28 11:06:58 -07:00 • committed by GitHub
commit 317f468807
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 489 additions and 49 deletions

View file

@ -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);
}
}

View file

@ -17,6 +17,7 @@ public class SseClientTransportTests : LoggedTest
Endpoint = new Uri("http://localhost:8080"),
ConnectionTimeout = TimeSpan.FromSeconds(2),
Name = "Test Server",
TransportMode = HttpTransportMode.Sse,
AdditionalHeaders = new Dictionary<string, string>
{
["test"] = "header"

View file

@ -1,9 +1,10 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Tests.Utils;
using System.Runtime.InteropServices;
namespace ModelContextProtocol.Tests.Transport;
public class StdioClientTransportTests
public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
{
[Fact]
public async Task CreateAsync_ValidProcessInvalidServer_Throws()
@ -11,10 +12,10 @@ public class StdioClientTransportTests
string id = Guid.NewGuid().ToString("N");
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
new(new() { Command = "cmd", Arguments = ["/C", $"echo \"{id}\" >&2"] }) :
new(new() { Command = "ls", Arguments = [id] });
new(new() { Command = "cmd", Arguments = ["/C", $"echo \"{id}\" >&2"] }, LoggerFactory) :
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());
}
}