Automatically fall back from Streamable HTTP to SSE on the client by default (#456)
This commit is contained in:
parent
adb2098e4d
commit
317f468807
3 changed files with 489 additions and 49 deletions
|
|
@ -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"),
|
||||
ConnectionTimeout = TimeSpan.FromSeconds(2),
|
||||
Name = "Test Server",
|
||||
TransportMode = HttpTransportMode.Sse,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["test"] = "header"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue