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:
TryReadJsonRpcErrorAsync requires Content-Type: application/json — servers returning text/plain (or other types) with valid JSON-RPC error bodies are not recognized
- AutoDetect falls through to SSE (GET), which also fails for POST-only servers
- 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 = true → PerformInitializeHandshakeAsync.
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:
- SDK sends
server/discover via POST
- gitmcp returns 400 with
Content-Type: text/plain + JSON-RPC error body
TryReadJsonRpcErrorAsync → null (content-type is not application/json)
- AutoDetect falls to SSE → GET → 405
- Combined
HttpRequestException(400) escapes ConnectAsync
- 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:
- Returns non-
application/json content-type on JSON-RPC error responses (e.g., text/plain)
- Does not support GET/SSE (POST-only Streamable HTTP)
- 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.
Summary
SDK v2.0 defaults to probing servers with
server/discover(a2026-07-28method). When a server on an older protocol version rejects this probe with a non-2xx response, the SDK should fall back to theinitializehandshake. This fallback is unreachable for servers that return non-application/jsonerror responses and don't support GET/SSE, because:TryReadJsonRpcErrorAsyncrequiresContent-Type: application/json— servers returningtext/plain(or other types) with valid JSON-RPC error bodies are not recognizedHttpRequestExceptionis not caught byConnectAsync— onlyMcpProtocolExceptionandOperationCanceledExceptiontriggerfallbackToInitializeThe 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
initializedirectly (noserver/discoverprobe). The entire discover→initialize fallback path is new in v2.0.Root cause analysis
Two layers are involved
Layer 1 —
ConnectAsync(protocol negotiation): WithProtocolVersion = null, sendsserver/discover. If it fails, certain exceptions triggerfallbackToInitialize = true→PerformInitializeHandshakeAsync.Layer 2 —
AutoDetectingClientSessionTransport(transport negotiation): Delivers the first message (happens to beserver/discover) by trying POST, then falling back to GET/SSE if POST fails.The failure chain
initializePOST is never attempted. If it were, it would succeed (the server supportsinitializejust fine — it only rejects the unknownserver/discovermethod).What works correctly (for contrast)
When a server returns errors with
Content-Type: application/json:This path works because the SDK recognizes the JSON-RPC error and throws
McpProtocolExceptioninstead ofHttpRequestException.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):
POST initializetext/event-stream, returnsMcp-Session-Id)POST server/discover(without session)Content-Type: text/plain, body:{"jsonrpc":"2.0","error":{"code":-32000,...}}GET(any){"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"}}Key detail: The 400 error response has
Content-Type: text/plain;charset=UTF-8despite the body being valid JSON-RPC. This is the trigger for the bug —TryReadJsonRpcErrorAsyncreturns null because of the content-type check.Observed SDK v2.0.0 behavior:
server/discovervia POSTContent-Type: text/plain+ JSON-RPC error bodyTryReadJsonRpcErrorAsync→ null (content-type is notapplication/json)HttpRequestException(400)escapesConnectAsyncinitializePOST is never attemptedError output:
Expected behavior: SDK should fall back to
initializePOST, which succeeds.Proposed fixes
Fix 1: Catch
HttpRequestExceptioninConnectAsyncdiscover probeConnectAsynccurrently catchesMcpProtocolExceptionandOperationCanceledExceptionfrom the discover probe, both settingfallbackToInitialize = true. AddHttpRequestExceptionto this list: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 toinitializeis the correct behavior — same as when aMcpProtocolExceptionindicates an unknown method.Fix 2: Relax
TryReadJsonRpcErrorAsynccontent-type checkTryReadJsonRpcErrorAsynccurrently requires exactlyapplication/json:Many servers return valid JSON-RPC error bodies with
text/plainor other content types. Consider:text/plainalongsideapplication/jsonThis would allow the SDK to correctly identify the server as Streamable HTTP and throw
McpProtocolException(whichConnectAsyncalready 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
initializePOST handshake, the Streamable HTTP transport opens a standalone GET SSE stream for unsolicited server notifications (EnableStandaloneGetStreamdefaults totrue). If the server rejects GET (e.g., 405), the SDK treats this as a fatal connection error — even thoughinitializesucceeded 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:
application/jsoncontent-type on JSON-RPC error responses (e.g.,text/plain)server/discoverAs the SDK defaults to probing with
server/discover(a2026-07-28method), the number of servers hitting this failure will grow — many servers in the ecosystem are still on2025-03-26or2025-11-25.Environment
2025-03-26protocol)AutoDetectingClientSessionTransport(default,ProtocolVersion = null)initializedirectly (noserver/discoverprobe). The discover→initialize fallback path is new in v2.0.