Skip to content

Surface JSON parse detail in streamable-HTTP 400 responses - #1844

Open
ump45nose wants to merge 2 commits into
modelcontextprotocol:mainfrom
ump45nose:contrib/d881e201499f
Open

ump45nose wants to merge 2 commits into
modelcontextprotocol:mainfrom
ump45nose:contrib/d881e201499f

Conversation

@ump45nose

@ump45nose ump45nose commented Aug 28, 2026 •

Copy link
Copy Markdown

Summary

Surface JSON parse detail in streamable-HTTP 400 responses: when the POST body is not a valid JSON-RPC message, the JSON-RPC error message now includes JsonException.Message (which already carries System.Text.Json's line/byte position), so a truncated or corrupted body is diagnosable from the response alone.

Verification

dotnet test tests/ModelContextProtocol.AspNetCore.Tests -f net10.0 --filter 'FullyQualifiedName~StreamableHttpServerConformanceTests' (56 passed at 9dc2eb4). PostMalformedJson_Returns400_InvalidRequest_WithNullId and the new PostTruncatedJson_Returns400_WithParserDetail theory assert the response contains the JsonException.Message produced by deserializing the same body as JsonRpcMessage.

Related to #1842

AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence.

@ump45nose

Copy link
Copy Markdown
Author

Additional verification context: the change is limited to preserving the existing 400 InvalidRequest behavior while surfacing the caught JsonException message and its line/byte position. The focused net10.0 conformance run passed 56 tests, including the malformed-JSON case and the new truncated-JSON regression coverage tied to #1842.

GitHub currently shows no completed check jobs or reviews for this commit, so the evidence above refers to the recorded local verification only.

AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence.

@ump45nose

Copy link
Copy Markdown
Author

Rebased onto current main (cb3e6e87, which brings in #1837's deterministic SEP-2575 status mapping and #1791) and re-verified on the rebased head 30aa2195:

  • ModelContextProtocol.AspNetCore.Tests (net10.0, Release): 621 passed, 30 skipped, 0 failed
  • ModelContextProtocol.Tests: 2359 passed, 5 skipped, 0 failed
  • StreamableHttpServerConformanceTests alone: 56 passed, including PostMalformedJson_Returns400_InvalidRequest_WithNullId and both PostTruncatedJson_Returns400_WithParserDetail cases

The rebase was clean, and the two changes do not overlap: #1837 edited StreamableHttpPostTransport.cs / StreamableHttpServerTransport.cs, while this PR only adds parser detail to the POST-body JsonException catch in StreamableHttpHandler.cs — the 400/InvalidRequest shape and the deterministic status mapping are unchanged.

Force-pushed to the PR branch (contrib/d881e201499f) only; the base branch was not touched, and no upstream branch was modified.

Reproduction note: build/test locally with -p:NuGetAudit=false. Microsoft.Build.Tasks.Git 8.0.0 raises NU1902 on this repository today, and it reproduces on unmodified main, so it is unrelated to this change.

AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence.

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 (modelcontextprotocol#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.
Comment on lines +88 to +95
// 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}).",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MVC already exposes JsonException.Message by default, so I'm comfortable doing that for this SDK-controlled parsing path. Can we drop the extra position suffix though? System.Text.Json already includes position information in syntax-error messages.

Suggested change
// 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}).",
// with a null id rather than letting the exception bubble up as an opaque 500.
await WriteJsonRpcErrorAsync(context,
$"Bad Request: The POST body did not contain a valid JSON-RPC message: {ex.Message}",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9dc2eb4: the message is now ...valid JSON-RPC message: {ex.Message} with no position suffix.


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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than looking for "line 0, byte position" in the response, which I've recommended we remove, I think we should do something like the following to verify the response contains the JsonException message:

using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body));
var exception = await Assert.ThrowsAsync<JsonException>(async () =>
{
    await JsonSerializer.DeserializeAsync(
        stream,
        McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcMessage>(),
        TestContext.Current.CancellationToken);
});

Assert.Contains(exception.Message, message);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9dc2eb4: both tests now deserialize the same body with McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcMessage>() and assert the response contains that JsonException.Message (small shared helper). StreamableHttpServerConformanceTests on net10.0: 56 passed.

System.Text.Json already includes the line and byte position in its
syntax-error messages, so surface JsonException.Message as-is. The tests
now compare against the JsonException produced by deserializing the same
body instead of looking for a hard-coded position string.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants