Skip to content

SDK Client: ConnectAsync discover→initialize fallback unreachable when AutoDetect transport fails with HttpRequestException #1848

Description

@anushree1808

Summary

SDK v2.0 defaults to probing servers with server/discover (a 2026-07-28 method). When a server on an older protocol version rejects this probe with a non-2xx response, the SDK should fall back to the initialize handshake. This fallback is unreachable for servers that return non-application/json error responses and don't support GET/SSE, because:

  1. TryReadJsonRpcErrorAsync requires Content-Type: application/json — servers returning text/plain (or other types) with valid JSON-RPC error bodies are not recognized
  2. AutoDetect falls through to SSE (GET), which also fails for POST-only servers
  3. The combined HttpRequestException is not caught by ConnectAsync — only McpProtocolException and OperationCanceledException trigger fallbackToInitialize

The connection fails without ever attempting initialize, which would have succeeded via POST.

Note: In SDK v1.4.0, this didn't happen because v1.4.0 sent initialize directly (no server/discover probe). The entire discover→initialize fallback path is new in v2.0.


Root cause analysis

Two layers are involved

Layer 1 — ConnectAsync (protocol negotiation): With ProtocolVersion = null, sends server/discover. If it fails, certain exceptions trigger fallbackToInitialize = truePerformInitializeHandshakeAsync.

Layer 2 — AutoDetectingClientSessionTransport (transport negotiation): Delivers the first message (happens to be server/discover) by trying POST, then falling back to GET/SSE if POST fails.

The failure chain

ConnectAsync sends server/discover
  │
  └→ AutoDetect.InitializeAsync receives it as the first message
       │
       ├─ POST server/discover → server returns 400 with text/plain JSON-RPC body
       │
       ├─ TryReadJsonRpcErrorAsync checks Content-Type:
       │    Content-Type: text/plain ≠ application/json → returns null
       │    (valid JSON-RPC body is never parsed)
       │
       ├─ Falls to else branch → SSE fallback
       │    GET → server returns 405 (POST-only, no SSE support)
       │
       ├─ InitializeSseTransportAsync wraps both errors:
       │    new HttpRequestException(postError.Message, sseError, postError.StatusCode)
       │
       └→ HttpRequestException propagates to ConnectAsync
            │
            ├─ catch (McpProtocolException) → NOT MATCHED
            ├─ catch (OperationCanceledException) → NOT MATCHED
            └─ No HttpRequestException catch exists → ESCAPES ❌

initialize POST is never attempted. If it were, it would succeed (the server supports initialize just fine — it only rejects the unknown server/discover method).

What works correctly (for contrast)

When a server returns errors with Content-Type: application/json:

POST server/discover → 400 with application/json JSON-RPC body
  → TryReadJsonRpcErrorAsync succeeds → McpProtocolException
  → ConnectAsync catches McpProtocolException → fallbackToInitialize = true
  → PerformInitializeHandshakeAsync → POST initialize → 200 ✅

This path works because the SDK recognizes the JSON-RPC error and throws McpProtocolException instead of HttpRequestException.


Example: gitmcp.io

gitmcp.io is a public MCP server providing GitHub repository documentation as MCP resources. It is a POST-only Streamable HTTP server on protocol version 2025-03-26.

Server behavior (verified via curl):

Request Response
POST initialize 200 OK (text/event-stream, returns Mcp-Session-Id)
POST server/discover (without session) 400 Bad Request, Content-Type: text/plain, body: {"jsonrpc":"2.0","error":{"code":-32000,...}}
GET (any) 405 Method Not Allowed, body: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"}}

Key detail: The 400 error response has Content-Type: text/plain;charset=UTF-8 despite the body being valid JSON-RPC. This is the trigger for the bug — TryReadJsonRpcErrorAsync returns null because of the content-type check.

Observed SDK v2.0.0 behavior:

  1. SDK sends server/discover via POST
  2. gitmcp returns 400 with Content-Type: text/plain + JSON-RPC error body
  3. TryReadJsonRpcErrorAsync → null (content-type is not application/json)
  4. AutoDetect falls to SSE → GET → 405
  5. Combined HttpRequestException(400) escapes ConnectAsync
  6. Connection fails — initialize POST is never attempted

Error output:

System.Net.Http.HttpRequestException: Response status code does not indicate success: 400 (Bad Request).
Response body: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}
  ---> System.Net.Http.HttpRequestException: Response status code does not indicate success: 405 (Method Not Allowed).
Response body: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"},"id":null}

Expected behavior: SDK should fall back to initialize POST, which succeeds.


Proposed fixes

Fix 1: Catch HttpRequestException in ConnectAsync discover probe

ConnectAsync currently catches McpProtocolException and OperationCanceledException from the discover probe, both setting fallbackToInitialize = true. Add HttpRequestException to this list:

// Existing catches:
catch (McpProtocolException) { fallbackToInitialize = true; }
catch (OperationCanceledException) when (...) { fallbackToInitialize = true; }

// Proposed addition:
catch (HttpRequestException) { fallbackToInitialize = true; }

Rationale: When the discover probe fails at the transport layer (both POST and GET/SSE fail), this is strong evidence the server doesn't support server/discover. Falling back to initialize is the correct behavior — same as when a McpProtocolException indicates an unknown method.

Fix 2: Relax TryReadJsonRpcErrorAsync content-type check

TryReadJsonRpcErrorAsync currently requires exactly application/json:

if (response.Content.Headers.ContentType?.MediaType != "application/json")
    return null;

Many servers return valid JSON-RPC error bodies with text/plain or other content types. Consider:

  • Attempting to parse JSON-RPC from any text-based content type
  • Or at minimum, accepting text/plain alongside application/json

This would allow the SDK to correctly identify the server as Streamable HTTP and throw McpProtocolException (which ConnectAsync already catches), preventing the unnecessary SSE fallback.

Fix 3: These are independent and complementary

Fix 1 is a safety net — catches all transport failures regardless of content-type. Fix 2 is more precise — correctly identifies Streamable HTTP servers that use non-standard content types. Both should be applied.


Separate issue: Standalone GET stream failure treated as fatal for POST-only servers

Summary: After a successful initialize POST handshake, the Streamable HTTP transport opens a standalone GET SSE stream for unsolicited server notifications (EnableStandaloneGetStream defaults to true). If the server rejects GET (e.g., 405), the SDK treats this as a fatal connection error — even though initialize succeeded and the MCP spec treats the GET notification stream as optional.

Example: gitmcp.io accepts POST (initialize → 200, tools/list → 200) but rejects GET with 405. The connection fails despite a fully working POST channel.

Expected behavior: A failed GET notification stream should degrade gracefully — log a warning, skip unsolicited notifications, and continue operating via POST request/response. The connection should not be torn down when the core POST channel is functional.

Impact: Any POST-only Streamable HTTP server (serverless deployments, simple implementations, servers behind proxies that don't support long-lived GET) will fail to connect with default SDK settings.

Proposed fix: When the standalone GET stream fails with 405 or similar, catch the error in ReceiveUnsolicitedMessagesAsync, log it, and mark the stream as unavailable rather than propagating as a fatal connection error.


Impact

This is not gitmcp-specific. Any MCP server that meets ALL three conditions will fail:

  1. Returns non-application/json content-type on JSON-RPC error responses (e.g., text/plain)
  2. Does not support GET/SSE (POST-only Streamable HTTP)
  3. Is on an older protocol version that doesn't recognize server/discover

As the SDK defaults to probing with server/discover (a 2026-07-28 method), the number of servers hitting this failure will grow — many servers in the ecosystem are still on 2025-03-26 or 2025-11-25.


Environment

  • SDK version: ModelContextProtocol 2.0.0 (C# SDK)
  • Server: gitmcp.io (POST-only Streamable HTTP, 2025-03-26 protocol)
  • Transport: AutoDetectingClientSessionTransport (default, ProtocolVersion = null)
  • OS: Linux / Windows (not platform-specific)
  • Note: In SDK v1.4.0, this scenario didn't arise because v1.4.0 sent initialize directly (no server/discover probe). The discover→initialize fallback path is new in v2.0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions