Skip to content

Commit 3778e00

Browse files
halter73Copilot
andcommitted
Recognize JSON-RPC errors in AutoDetect HTTP transport (no SSE fallback)
AutoDetectingClientSessionTransport's job is to pick between StreamableHttp (post-SEP-2243) and legacy SSE based on the server's response to the first POST. The detection logic only checked the HTTP status code, so a draft server returning `400 Bad Request` with a JSON-RPC error body (e.g. `-32004` UnsupportedProtocolVersion or `-32600` from a legacy server that didn't understand the draft `_meta` envelope) was being treated as ""this isn't StreamableHttp"" — and the transport silently fell back to SSE, breaking fallback negotiation against any HTTP/1.1 peer that follows the spec. Detect a JSON-RPC error envelope in the 400 body, adopt StreamableHttp, then re-throw the structured exception so McpClientImpl can dispatch on the error code (retry with advertised version, surface capability gap, or fall back to `initialize`). Use a deferred throw guarded by `catch when (ActiveTransport is null)` to preserve transport ownership across the deferred error path. Cross-SDK validation: against vanilla Go SDK `origin/main` HTTP everything server, default `--http-mode autodetect` now successfully adopts StreamableHttp, recognizes `-32004` with `data.supported`, retries with `2025-11-25`, and lists 10 tools. Against Python `main`'s `simple-streamablehttp-stateless`, the same flow recognizes the `-32600` returned from the draft probe, adopts StreamableHttp, and falls back to `initialize` with `2025-06-18` to complete the handshake. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 276bde4 commit 3778e00

2 files changed

Lines changed: 366 additions & 4 deletions

File tree

src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
using Microsoft.Extensions.Logging.Abstractions;
33
using ModelContextProtocol.Protocol;
44
using System.Net;
5+
using System.Net.Http;
6+
using System.Text.Json;
57
using System.Threading.Channels;
68

79
namespace ModelContextProtocol.Client;
@@ -62,6 +64,7 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
6264
{
6365
// Try StreamableHttp first
6466
var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
67+
McpProtocolException? structuredError = null;
6568

6669
try
6770
{
@@ -73,22 +76,76 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
7376
LogUsingStreamableHttp(_name);
7477
ActiveTransport = streamableHttpTransport;
7578
}
79+
else if (await TryGetJsonRpcErrorFromResponseAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError)
80+
{
81+
// A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server
82+
// — it just rejected our specific request (e.g., -32004 UnsupportedProtocolVersion,
83+
// -32003 MissingRequiredClientCapability, -32001 HeaderMismatch, or any other
84+
// application-level error). Don't fall back to SSE — that would mask the real signal
85+
// and surface a misleading "session id required" error from the SSE GET path.
86+
// Adopt the Streamable HTTP transport and surface the structured exception to the
87+
// caller so the connect-time fallback logic can react per spec PR #2844.
88+
LogUsingStreamableHttp(_name);
89+
ActiveTransport = streamableHttpTransport;
90+
structuredError = McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
91+
}
7692
else
7793
{
78-
// If the status code is not success, fall back to SSE
94+
// Non-JSON-RPC error response: either the server doesn't speak MCP at all, or this
95+
// is an older deployment that expects the SSE transport (which establishes its
96+
// protocol via GET /sse rather than POST). Fall back to SSE per the original
97+
// behavior.
7998
LogStreamableHttpFailed(_name, response.StatusCode);
8099

81100
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
82101
await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false);
83102
}
84103
}
85-
catch
104+
catch when (ActiveTransport is null)
86105
{
87-
// If nothing threw inside the try block, we've either set streamableHttpTransport as the
88-
// ActiveTransport, or else we will have disposed it in the !IsSuccessStatusCode else block.
106+
// Only dispose the Streamable HTTP transport when we didn't adopt it. If we set
107+
// ActiveTransport above (success path OR structured-error path), the transport's
108+
// lifetime is owned by the outer transport from this point on.
89109
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
90110
throw;
91111
}
112+
113+
if (structuredError is not null)
114+
{
115+
throw structuredError;
116+
}
117+
}
118+
119+
private static async Task<JsonRpcError?> TryGetJsonRpcErrorFromResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
120+
{
121+
if (response.Content.Headers.ContentType?.MediaType != "application/json")
122+
{
123+
return null;
124+
}
125+
126+
string body;
127+
try
128+
{
129+
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
130+
}
131+
catch
132+
{
133+
return null;
134+
}
135+
136+
if (string.IsNullOrEmpty(body))
137+
{
138+
return null;
139+
}
140+
141+
try
142+
{
143+
return JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.JsonRpcMessage) as JsonRpcError;
144+
}
145+
catch
146+
{
147+
return null;
148+
}
92149
}
93150

94151
private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)

0 commit comments

Comments
 (0)