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 =>