From 09233559fc0ca04deb6c2393723b5feb2482ae20 Mon Sep 17 00:00:00 2001 From: yuwk <1729065730@qq.com> Date: Fri, 28 Aug 2026 22:56:07 +0800 Subject: [PATCH] Surface JSON parse detail in streamable-HTTP 400 responses When the POST body cannot be parsed as a JSON-RPC message, the 400 InvalidRequest response was a fixed one-liner that discarded the JsonException's reason and position. A body truncated by an intermediary (proxy, gateway, or transport) is therefore indistinguishable from any other malformed body without server-side logs (#1842). The catch now includes the parser message and line/byte position in the error message, so the failure is diagnosable from the response alone. The message prefix and the conformant id=null envelope are unchanged. --- .../StreamableHttpHandler.cs | 11 +++++--- .../StreamableHttpServerConformanceTests.cs | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 50c20a792..e2f6dcf54 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -81,13 +81,18 @@ await WriteJsonRpcErrorAsync(context, { message = await ReadJsonRpcMessageAsync(context); } - catch (JsonException) + catch (JsonException ex) { // The POST body was not a well-formed JSON-RPC message (malformed JSON, or a request whose // id was explicitly null, which MCP forbids). Surface a conformant JSON-RPC error response - // with a null id rather than letting the exception bubble up as an opaque 500. + // with a null id rather than letting the exception bubble up as an opaque 500. The parser's + // position detail is included so a truncated or corrupted body (e.g. one mangled by an + // intermediary) is diagnosable from the response alone (#1842). + var position = ex.BytePositionInLine is null + ? $"line {ex.LineNumber?.ToString() ?? "unknown"}" + : $"line {ex.LineNumber?.ToString() ?? "unknown"}, byte position {ex.BytePositionInLine}"; await WriteJsonRpcErrorAsync(context, - "Bad Request: The POST body did not contain a valid JSON-RPC message.", + $"Bad Request: The POST body did not contain a valid JSON-RPC message: {ex.Message} ({position}).", StatusCodes.Status400BadRequest, (int)McpErrorCode.InvalidRequest); return; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index dd051e4d3..c413693c4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -316,6 +316,33 @@ public async Task PostMalformedJson_Returns400_InvalidRequest_WithNullId() using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("id").ValueKind); Assert.Equal((int)McpErrorCode.InvalidRequest, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + + // The parse failure itself must be diagnosable from the response alone: the message carries the + // parser's reason and position instead of an opaque one-liner (#1842). + var message = doc.RootElement.GetProperty("error").GetProperty("message").GetString(); + Assert.Contains("did not contain a valid JSON-RPC message", message); + Assert.Contains("line 0, byte position", message); + } + + [Theory] + [InlineData("""{"jsonrpc":"2.0","id":1,"method":"tools/list","para""")] + [InlineData("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"a":1""")] + public async Task PostTruncatedJson_Returns400_WithParserDetail(string body) + { + await StartAsync(); + + // A body cut in half by an intermediary (proxy, gateway, or the transport itself) is the exact + // shape reported in #1842. The 400 response must say where parsing stopped so the truncation + // is identifiable from the response without server-side logs. + using var response = await HttpClient.PostAsync("", JsonContent(body), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal((int)McpErrorCode.InvalidRequest, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + + var message = doc.RootElement.GetProperty("error").GetProperty("message").GetString(); + Assert.Contains("did not contain a valid JSON-RPC message", message); + Assert.Contains("line 0, byte position", message); } [Fact]