Skip to content

Fall back to initialize when server/discover fails at the HTTP layer - #1766

Open
rbstp wants to merge 1 commit into
modelcontextprotocol:mainfrom
rbstp:fix-1765-discover-fallback-http-404
Open

Fall back to initialize when server/discover fails at the HTTP layer#1766
rbstp wants to merge 1 commit into
modelcontextprotocol:mainfrom
rbstp:fix-1765-discover-fallback-http-404

Conversation

@rbstp

@rbstp rbstp commented Jul 29, 2026

Copy link
Copy Markdown

Treat an HTTP 404 from the SEP-2575 server/discover probe as evidence of an initialize-handshake server and fall back, instead of failing the connection. Scoped to the explicit Streamable HTTP transport — see the AutoDetect note below.

Motivation and Context

Fixes #1765.

Under the 2026-07-28 revision (SEP-2575) a client with ProtocolVersion unset probes server/discover and is expected to fall back to the initialize handshake for down-level servers. That fallback is keyed entirely on McpProtocolException plus the probe timeout, so it only fires when the server answers with a JSON-RPC error.

A Streamable HTTP server that requires Mcp-Session-Id on every non-initialize POST rejects the session-less server/discover at the HTTP layer instead — Datadog's hosted server answers 404 "Invalid session ID". That surfaces as HttpRequestException from EnsureSuccessStatusCodeWithResponseBodyAsync, which no catch in ConnectAsync handles, so it escapes and the connection fails outright. The server is reachable and speaks 2025-11-25 happily; only the probe fails. This is a regression from 2.0.0 — the same endpoint and credentials connect fine on 1.4.0 and 1.4.1.

StreamableHttpClientSessionTransport deliberately surfaces non-400 statuses as HttpRequestException for back-compat, so the gap is at the connect layer and that is where this fixes it.

Scope, and what this deliberately does not fix

This PR is intentionally narrow. Three adjacent gaps are real and left for follow-ups:

1. AutoDetect is excluded. AutoDetect is the default TransportMode, but the fallback is gated off for it. Under AutoDetect a 404 causes the provisional Streamable transport to be disposed and an SSE GET attempted; when that fails, SseClientSessionTransport.SetDisconnected completes the shared message channel. An initialize sent afterwards is answered by the server but its response can never be read, so the connect stalls until InitializationTimeout and then fails. Measured locally: ClientTransportClosedException after ~6s with initializeReceived == true, versus an immediate, clear 404 without this change. Falling back there would be strictly worse than today's behavior, so it is gated out and pinned by a test. Fixing AutoDetect properly needs provisional SSE failure to leave the shared channel open, the way StreamableHttpClientSessionTransport already does — happy to open that as a separate issue/PR.

2. Plain HTTP 400 is still unhandled. docs/concepts/stateless/stateless.md states that a 400 carrying anything other than a structured -32022/-32021/-32020 error should switch to the initialize flow. A plain-text or empty 400 fails JSON-RPC parsing, becomes HttpRequestException, and escapes exactly like the 404 did. That documented case predates this PR and is not addressed here; 404 is fixed because it is the case with a reproducible public server behind it.

3. netstandard2.0 / net472 do not get the fix. HttpRequestException.StatusCode is .NET 5+, and HttpResponseMessageExtensions.CreateHttpRequestException drops the status entirely on non-NET targets, so there is nothing to read. The catch and its tests are #if NET-guarded, matching the existing pattern in AutoDetectingClientSessionTransport.cs. A portable version would need an internal exception subtype carrying the status code on all TFMs — glad to do that instead if you'd prefer the fix apply everywhere.

How Has This Been Tested?

Yes — in a real application as well as unit tests.

  • New tests in July2026ProtocolFallbackTests, all driving a real HttpClientTransport over MockHttpHandler:

    • Client_OnHttpNotFoundFromProbe_FallsBackTo_Initialize — the fix. Verified both directions: fails on unmodified main with the production error, passes with the fix.
    • Client_OnNonNotFoundHttpErrorFromProbe_Surfaces_NoFallback (500, 403) — guards the deliberate narrowing. Verified it fails if the catch is broadened to all HttpRequestException.
    • Client_OnHttpNotFoundFromProbe_UnderAutoDetect_Surfaces_NoFallback — pins the AutoDetect exclusion above.
  • Full suite, Release: 2344 passed, 1 failed. The failure is DockerEverythingServerTests.Sampling_Sse_EverythingServer, which reproduces identically on unmodified main.

  • Debug and Release both run clean on the fallback suite (15/15). Called out because an earlier iteration of this fix tripped a Debug.Assert on a closed channel.

  • Live servers, TransportMode = StreamableHttp, no pinned ProtocolVersion:

    Server before after
    Datadog 404 Invalid session ID 30 tools, negotiated 2025-11-25
    GitHub 36 tools, 2026-07-28 36 tools, 2026-07-28
    Sentry 8 tools, 2026-07-28 8 tools, 2026-07-28

    GitHub and Sentry genuinely support SEP-2575 and are unaffected.

  • Real application: a production Slack agent connecting to five MCP servers. Its Datadog provider failed to connect on stock 2.0.0 and connects with this patch, other four unchanged. A/B'd by swapping only ModelContextProtocol.Core.dll under the same application binary.

Not verified: the net472 leg (cannot build .NET Framework locally) and the net8.0/net9.0 legs (only the 10.0 runtime is installed here). All guarded code is excluded from net472 by construction, so that translation unit is unchanged from main.

Breaking Changes

None. No public API change. The only behavioral difference is that a connect over an explicit Streamable HTTP transport which previously threw HttpRequestException on a 404 from the discover probe now falls back to initialize and can succeed. Non-404 statuses, and all AutoDetect behavior, are unchanged.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The _transport is not AutoDetectingClientSessionTransport guard in the filter is a type check I'd rather not need. If you'd prefer, the alternative is to land the SSE channel-lifetime fix first and then apply the 404 fallback uniformly across both transport modes — that would be a cleaner end state, just a larger change. Happy to go that route instead.

@rbstp
rbstp force-pushed the fix-1765-discover-fallback-http-404 branch from d155944 to 5a3819d Compare July 29, 2026 02:32

@PranavSenthilnathan PranavSenthilnathan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough investigation here, particularly for identifying the AutoDetect channel-lifetime issue, the unstructured HTTP 400 case, and the cross-target status-code limitation.

I opened two prerequisite PRs:

  • #1767 preserves HTTP status codes across all target frameworks and AutoDetect failures.
  • #1768 keeps AutoDetect’s shared message channel open after a provisional SSE failure.

Please wait for those changes and then rebase this PR.

The resulting condition should fall back to initialize for plain or empty 400 Bad Request and 404 Not Found responses. Other HTTP errors should continue to surface.

Please also add plain/empty 400 fallback coverage for explicit Streamable HTTP and AutoDetect.

Note

This review comment was generated with GitHub Copilot.

fallbackToInitialize = true;
}
#if NET
catch (HttpRequestException ex) when (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After #1767 and #1768 merge, this condition can be simplified to fall back only when the HTTP status code is 400 Bad Request or 404 Not Found.

Note

Generated with GitHub Copilot.

Assert.Equal(Timeout.InfiniteTimeSpan, options.DiscoverProbeTimeout);
}

#if NET

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this #if NET guard after #1767 merges so this coverage also runs on net472.

Note

Generated with GitHub Copilot.

}

[Fact]
public async Task Client_OnHttpNotFoundFromProbe_UnderAutoDetect_Surfaces_NoFallback()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After #1768 merges, please change this AutoDetect 404 test to expect successful fallback.

Note

Generated with GitHub Copilot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client fails to connect when a server rejects the server/discover probe with HTTP 404

2 participants