Fall back to initialize when server/discover fails at the HTTP layer (#1766)
This commit is contained in:
parent
155f619441
commit
79e13b3e2c
2 changed files with 125 additions and 0 deletions
|
|
@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
|
@ -387,6 +388,17 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
// never reach here.
|
// never reach here.
|
||||||
fallbackToInitialize = true;
|
fallbackToInitialize = true;
|
||||||
}
|
}
|
||||||
|
catch (HttpRequestException ex) when (
|
||||||
|
ex.GetStatusCode() is HttpStatusCode.BadRequest or HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
// A server predating SEP-2575 can reject the session-less server/discover POST at the
|
||||||
|
// HTTP layer instead of with a JSON-RPC error: 400 when it cannot parse the request,
|
||||||
|
// 404 when it requires Mcp-Session-Id on every non-initialize POST. A 400 carrying a
|
||||||
|
// structured JSON-RPC error is surfaced as McpProtocolException and handled above, so
|
||||||
|
// anything reaching here is plain or empty. Either way this is an initialize-handshake
|
||||||
|
// server, so fall back. Other statuses stay uncaught and surface to the caller.
|
||||||
|
fallbackToInitialize = true;
|
||||||
|
}
|
||||||
catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested)
|
catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no
|
// Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ using ModelContextProtocol.Client;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Tests.Utils;
|
using ModelContextProtocol.Tests.Utils;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
|
@ -207,6 +209,117 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
||||||
Assert.Equal(Timeout.InfiniteTimeSpan, options.DiscoverProbeTimeout);
|
Assert.Equal(Timeout.InfiniteTimeSpan, options.DiscoverProbeTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.NotFound, HttpTransportMode.StreamableHttp)]
|
||||||
|
[InlineData(HttpStatusCode.NotFound, HttpTransportMode.AutoDetect)]
|
||||||
|
[InlineData(HttpStatusCode.BadRequest, HttpTransportMode.StreamableHttp)]
|
||||||
|
[InlineData(HttpStatusCode.BadRequest, HttpTransportMode.AutoDetect)]
|
||||||
|
public async Task Client_OnFallbackHttpStatusFromProbe_FallsBackTo_Initialize(
|
||||||
|
HttpStatusCode status, HttpTransportMode transportMode)
|
||||||
|
{
|
||||||
|
// A server predating SEP-2575 can reject the session-less server/discover probe at the HTTP layer
|
||||||
|
// rather than with a JSON-RPC error: 404 when it requires Mcp-Session-Id on every non-initialize
|
||||||
|
// POST, or a plain/empty 400 when it cannot parse the request. Both are initialize-handshake
|
||||||
|
// servers, so the connect must fall back instead of failing.
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var initializeReceived = false;
|
||||||
|
|
||||||
|
using var mockHttpHandler = new MockHttpHandler();
|
||||||
|
using var httpClient = new HttpClient(mockHttpHandler);
|
||||||
|
mockHttpHandler.RequestHandler = CreateProbeRejectingServer(
|
||||||
|
status, "Invalid session ID", () => initializeReceived = true);
|
||||||
|
|
||||||
|
await using var transport = CreateTransport(httpClient, transportMode);
|
||||||
|
|
||||||
|
// Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback.
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||||
|
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
Assert.True(initializeReceived);
|
||||||
|
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)]
|
||||||
|
[InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.AutoDetect)]
|
||||||
|
public async Task Client_OnOtherHttpErrorFromProbe_Surfaces_NoFallback(
|
||||||
|
HttpStatusCode status, HttpTransportMode transportMode)
|
||||||
|
{
|
||||||
|
// Only 400 and 404 are read as "this server needs the initialize handshake". Any other HTTP failure
|
||||||
|
// is a genuine transport error and must surface, so callers are not handed a misleading downstream
|
||||||
|
// error. Guards the deliberate narrowing of the status filter.
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var initializeReceived = false;
|
||||||
|
|
||||||
|
using var mockHttpHandler = new MockHttpHandler();
|
||||||
|
using var httpClient = new HttpClient(mockHttpHandler);
|
||||||
|
mockHttpHandler.RequestHandler = CreateProbeRejectingServer(
|
||||||
|
status, "nope", () => initializeReceived = true);
|
||||||
|
|
||||||
|
await using var transport = CreateTransport(httpClient, transportMode);
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||||
|
{
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||||
|
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(initializeReceived);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClientTransport CreateTransport(HttpClient httpClient, HttpTransportMode transportMode)
|
||||||
|
=> new(new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost"),
|
||||||
|
TransportMode = transportMode,
|
||||||
|
Name = "HTTP discover probe test client",
|
||||||
|
}, httpClient, LoggerFactory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock Streamable HTTP server that rejects <c>server/discover</c> with <paramref name="probeStatus"/>
|
||||||
|
/// and, if the client falls back, completes an <c>initialize</c> handshake at 2025-11-25.
|
||||||
|
/// </summary>
|
||||||
|
private static Func<HttpRequestMessage, Task<HttpResponseMessage>> CreateProbeRejectingServer(
|
||||||
|
HttpStatusCode probeStatus, string probeBody, Action onInitialize)
|
||||||
|
=> async request =>
|
||||||
|
{
|
||||||
|
// The server offers no standalone SSE stream, which the spec permits.
|
||||||
|
// net472 does not populate a default Content, so every response sets one explicitly.
|
||||||
|
if (request.Method == HttpMethod.Get)
|
||||||
|
return EmptyResponse(HttpStatusCode.MethodNotAllowed);
|
||||||
|
|
||||||
|
var body = await request.Content!.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
if (!doc.RootElement.TryGetProperty("method", out var methodElement))
|
||||||
|
return EmptyResponse(HttpStatusCode.Accepted);
|
||||||
|
|
||||||
|
switch (methodElement.GetString())
|
||||||
|
{
|
||||||
|
case RequestMethods.ServerDiscover:
|
||||||
|
return new HttpResponseMessage(probeStatus) { Content = new StringContent(probeBody) };
|
||||||
|
|
||||||
|
case RequestMethods.Initialize:
|
||||||
|
onInitialize();
|
||||||
|
var id = doc.RootElement.GetProperty("id").GetRawText();
|
||||||
|
var result = "{\"jsonrpc\":\"2.0\",\"id\":" + id
|
||||||
|
+ ",\"result\":{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion
|
||||||
|
+ "\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}";
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(result, Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
response.Headers.Add("mcp-session-id", "test-session");
|
||||||
|
return response;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return EmptyResponse(HttpStatusCode.Accepted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static HttpResponseMessage EmptyResponse(HttpStatusCode status)
|
||||||
|
=> new(status) { Content = new StringContent(string.Empty) };
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal in-memory transport that simulates an initialize-handshake server: rejects
|
/// Minimal in-memory transport that simulates an initialize-handshake server: rejects
|
||||||
/// <c>server/discover</c> (with a configurable JSON-RPC error code, or by
|
/// <c>server/discover</c> (with a configurable JSON-RPC error code, or by
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue