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]