From f3e240e9c52267ed684e4debb633f867dca77379 Mon Sep 17 00:00:00 2001 From: ump45nose Date: Mon, 14 Sep 2026 14:29:47 +0800 Subject: [PATCH] fix(client): send POST bodies with Content-Length instead of chunked encoding On modern .NET, `McpHttpClient.CreatePostBodyContent` built the request body with `JsonContent.Create(...)`. `JsonContent` serializes lazily and cannot report a length, so `HttpClient` had no `Content-Length` to send and fell back to `Transfer-Encoding: chunked`. Hosts that reject chunked request bodies then fail the POST: the local Azure Functions Python worker answers with `400 "'Transfer-Encoding: chunked' header can not be used when content object is not specified"`, so an MCP server that works when deployed does not work when run locally. The netstandard2.0 branch already buffered the payload into a `ByteArrayContent`, which does set `Content-Length`, so the wire format also depended on the target framework. Use the buffered path on every target framework: serialize to UTF-8 bytes and wrap them in a `ByteArrayContent`. That removes the TFM split and makes the encoding uniform, at the cost of holding one serialized message in memory, which the netstandard path already did. Add a regression test that drives a real Streamable HTTP POST through `HttpClientTransport` and asserts, from the message handler, that the request declares a `Content-Length` matching the bytes actually sent and is not sent with chunked encoding. The values are captured in the handler because `McpHttpClient` disposes the content once `SendAsync` returns. Fixes #932 --- .../Client/McpHttpClient.cs | 13 ++-- .../Transport/HttpClientTransportTests.cs | 63 +++++++++++++++++++ 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs index f4df789c7..b342f2332 100644 --- a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs @@ -1,12 +1,7 @@ using ModelContextProtocol.Protocol; using System.Diagnostics; using System.Net.Http.Headers; - -#if NET -using System.Net.Http.Json; -#else using System.Text.Json; -#endif namespace ModelContextProtocol.Client; @@ -31,13 +26,13 @@ internal virtual async Task SendAsync(HttpRequestMessage re return null; } -#if NET - return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage, s_applicationJsonContentType); -#else + // Buffer the serialized message so the request carries a Content-Length rather than + // being streamed with Transfer-Encoding: chunked. JsonContent serializes lazily and + // cannot report a length, so HttpClient falls back to chunked encoding, which some + // hosts reject outright (for example the local Azure Functions Python worker). var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); var content = new ByteArrayContent(bytes); content.Headers.ContentType = s_applicationJsonContentType; return content; -#endif } } diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs index a203797b7..811c9a716 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs @@ -282,6 +282,69 @@ public async Task DisposeAsync_Should_Dispose_Resources() Assert.False(transportBase.IsConnected); } + [Fact] + public async Task SendMessageAsync_PostsBodyWithContentLength_InsteadOfChunkedEncoding() + { + // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/932 + // JsonContent serializes lazily and cannot report a length, so HttpClient falls back to + // Transfer-Encoding: chunked. Hosts reject that outright -- the local Azure Functions + // Python worker answers 400 "'Transfer-Encoding: chunked' header can not be used when + // content object is not specified" -- so the body must be buffered and sent with a + // Content-Length. + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + HttpRequestMessage? postRequest = null; + long? declaredLength = null; + bool? transferEncodingChunked = null; + byte[]? bodyBytes = null; + + // Observed from the handler, which is where the request is still live: McpHttpClient + // disposes the content once SendAsync returns, so the values have to be captured here. + mockHttpHandler.RequestHandler = async request => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.NotNull(request.Content); + + postRequest = request; + declaredLength = request.Content.Headers.ContentLength; + transferEncodingChunked = request.Headers.TransferEncodingChunked; + bodyBytes = await request.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.Accepted, + Content = new StringContent("", Encoding.UTF8, "application/json"), + }; + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + await session.SendMessageAsync( + new JsonRpcNotification { Method = "notifications/initialized" }, + TestContext.Current.CancellationToken); + + Assert.NotNull(postRequest); + + // A known length is what keeps HttpClient on the Content-Length path. With JsonContent + // this is null and the body goes out chunked instead. + Assert.True( + declaredLength.HasValue && declaredLength.Value > 0, + $"the POST body must declare a Content-Length; got {declaredLength?.ToString() ?? "null"}"); + Assert.NotEqual(true, transferEncodingChunked); + + // The declared length has to describe the bytes actually sent. + Assert.NotNull(bodyBytes); + Assert.Equal(declaredLength.GetValueOrDefault(), (long)bodyBytes.Length); + Assert.Contains("notifications/initialized", Encoding.UTF8.GetString(bodyBytes)); + } + // Strict server mock used in Content-Type tests below. // Returns 200 only for bare "application/json", otherwise 415. private static Func> StrictJsonContentTypeHandler =>