Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;

Expand Down Expand Up @@ -387,6 +388,17 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
// never reach here.
fallbackToInitialize = true;
}
catch (HttpRequestException ex) when (
Comment thread
PranavSenthilnathan marked this conversation as resolved.
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)
{
// Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Tests.Utils;
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Channels;
Expand Down Expand Up @@ -207,6 +209,117 @@ public void DiscoverProbeTimeout_Setter_Accepts_PositiveAndInfiniteValues()
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>
/// Minimal in-memory transport that simulates an initialize-handshake server: rejects
/// <c>server/discover</c> (with a configurable JSON-RPC error code, or by
Expand Down