From d126a53a287d7bd5eeb1eee3534b359e213228bc Mon Sep 17 00:00:00 2001
From: Sayali Kulkarni <144113494+sk-20231@users.noreply.github.com>
Date: Tue, 10 Nov 2026 11:57:19 +0530
Subject: [PATCH] Return 400 for unreadable strings in Streamable HTTP POST
validation
The JSON reader does not validate UTF-8 inside string tokens. That only
happens when a token is converted to a .NET string, which throws
InvalidOperationException. Converting a non-string value to a string
throws the same exception. HandlePostRequestAsync only catches
JsonException, so these requests returned 500.
Read these values with a helper that returns false instead of throwing,
and reject them with the error each check already uses:
- initialize params.protocolVersion: 400, HeaderMismatch (-32020)
- _meta protocolVersion: treated as missing, 400, InvalidParams (-32602)
- Mcp-Name body value (invalid UTF-8 or not a string): 400, HeaderMismatch
- x-mcp-header argument values: 400, HeaderMismatch
All responses echo the request id.
Fixes #1984
---
.../StreamableHttpHandler.cs | 81 +++++++++---
.../HttpHeaderConformanceTests.cs | 119 ++++++++++++++++++
2 files changed, 185 insertions(+), 15 deletions(-)
diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index 3808a43fa..958f074a9 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -895,13 +895,23 @@ private static bool ValidateProtocolVersionEnvelope(
if (message is JsonRpcRequest { Method: RequestMethods.Initialize, Params: JsonObject initializeParams } &&
initializeParams["protocolVersion"] is JsonValue initializeProtocolVersionValue &&
- initializeProtocolVersionValue.TryGetValue(out string? initializeProtocolVersion) &&
- !string.IsNullOrEmpty(protocolVersionHeader) &&
- !string.Equals(protocolVersionHeader, initializeProtocolVersion, StringComparison.Ordinal))
+ initializeProtocolVersionValue.GetValueKind() == JsonValueKind.String &&
+ !string.IsNullOrEmpty(protocolVersionHeader))
{
- errorDetail = CreateHeaderMismatchError(
- $"Bad Request: The {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}' does not match body params.protocolVersion value '{initializeProtocolVersion}'.");
- return false;
+ // A string that is not valid UTF-8 cannot equal any header value, so it is a mismatch too.
+ if (!TryGetStringValue(initializeProtocolVersionValue, out var initializeProtocolVersion))
+ {
+ errorDetail = CreateHeaderMismatchError(
+ $"Bad Request: The body params.protocolVersion value is not valid UTF-8, so it does not match the {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}'.");
+ return false;
+ }
+
+ if (!string.Equals(protocolVersionHeader, initializeProtocolVersion, StringComparison.Ordinal))
+ {
+ errorDetail = CreateHeaderMismatchError(
+ $"Bad Request: The {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}' does not match body params.protocolVersion value '{initializeProtocolVersion}'.");
+ return false;
+ }
}
// Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve
@@ -965,8 +975,7 @@ private static bool TryGetProtocolVersionMeta(JsonRpcMessage message, [NotNullWh
string? value = null;
if (parameters is JsonObject paramsObj &&
paramsObj["_meta"] is JsonObject metaObj &&
- metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue &&
- protocolVersionValue.TryGetValue(out value) &&
+ TryGetStringValue(metaObj[MetaKeys.ProtocolVersion], out value) &&
!string.IsNullOrEmpty(value))
{
protocolVersion = value;
@@ -1097,7 +1106,11 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess
JsonRpcNotification notification => notification.Params,
_ => null,
};
- var mcpNameInBody = GetJsonNodeStringProperty(bodyParams, routingNameParameter);
+ if (!TryGetJsonNodeStringProperty(bodyParams, routingNameParameter, out var mcpNameInBody))
+ {
+ errorMessage = $"Header mismatch: body value '{routingNameParameter}' is not a valid string, so it cannot match the Mcp-Name header value '{mcpNameInHeader}'.";
+ return false;
+ }
// Check that the header value matches the body value if the body value is present.
if (!string.Equals(decodedMcpNameInHeader, mcpNameInBody, StringComparison.Ordinal))
@@ -1167,8 +1180,9 @@ private static bool ValidateCustomParamHeaders(
}
// Look up the tool to check for x-mcp-header annotations in the schema
- var toolName = GetJsonNodeStringProperty(bodyParams, "name");
- if (toolName is null || toolCollection is null || !toolCollection.TryGetPrimitive(toolName, out var tool))
+ // An unreadable name was already rejected by the Mcp-Name check, so it can be skipped here.
+ if (!TryGetJsonNodeStringProperty(bodyParams, "name", out var toolName) ||
+ toolName is null || toolCollection is null || !toolCollection.TryGetPrimitive(toolName, out var tool))
{
errorMessage = null;
return true;
@@ -1281,6 +1295,13 @@ argForMissing is not null &&
argsObj.TryGetPropertyValue(property.Name, out var argNode) &&
argNode is not null)
{
+ // ConvertToHeaderValue converts string arguments to .NET strings, which throws for invalid UTF-8.
+ if (argNode.GetValueKind() == JsonValueKind.String && !TryGetStringValue(argNode, out _))
+ {
+ errorMessage = $"Header mismatch: body argument '{property.Name}' is not valid UTF-8, so it cannot match the {fullHeaderName} header.";
+ return false;
+ }
+
var expectedHeaderValue = McpHeaderEncoder.ConvertToHeaderValue(argNode);
if (expectedHeaderValue is not null)
{
@@ -1302,14 +1323,44 @@ argForMissing is not null &&
return true;
}
- private static string? GetJsonNodeStringProperty(System.Text.Json.Nodes.JsonNode? node, string propertyName)
+ ///
+ /// Reads an optional string property of a JSON object without throwing. Returns with a
+ /// value when the node is not an object, or when the property is absent or JSON null.
+ /// Returns when the property is present but is not a readable string.
+ ///
+ private static bool TryGetJsonNodeStringProperty(JsonNode? node, string propertyName, out string? value)
+ {
+ if (node is JsonObject obj && obj.TryGetPropertyValue(propertyName, out var propertyValue) && propertyValue is not null)
+ {
+ return TryGetStringValue(propertyValue, out value);
+ }
+
+ value = null;
+ return true;
+ }
+
+ ///
+ /// Reads a JSON string value without throwing. Returns when the node is not a JSON string,
+ /// or when the string is not valid UTF-8. System.Text.Json validates the UTF-8 inside a string token only when it
+ /// converts the token to a .NET string, and it throws at that point.
+ ///
+ private static bool TryGetStringValue(JsonNode? node, [NotNullWhen(true)] out string? value)
{
- if (node is System.Text.Json.Nodes.JsonObject obj && obj.TryGetPropertyValue(propertyName, out var value))
+ if (node is JsonValue jsonValue && jsonValue.GetValueKind() == JsonValueKind.String)
{
- return value?.GetValue();
+ try
+ {
+ value = jsonValue.GetValue();
+ return true;
+ }
+ catch (InvalidOperationException)
+ {
+ // The string is not valid UTF-8. Treat it the same as a value that is not a string.
+ }
}
- return null;
+ value = null;
+ return false;
}
// Valid HTTP header field-value characters per RFC 9110: horizontal tab (0x09),
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs
index 914b98db0..40fb57dd0 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs
@@ -519,6 +519,102 @@ public async Task Server_RejectsInvalidUtf8EncodedHeaderValue()
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
+ [Fact]
+ public async Task Server_RejectsInvalidUtf8InProtocolVersionMeta_WithBadRequest()
+ {
+ await StartAsync();
+ await ProbeWithJuly2026ProtocolVersionAsync();
+
+ // The 0xFF byte can never appear in valid UTF-8. The JSON reader accepts it inside a string token,
+ // and the failure only surfaces when the server converts the _meta protocol version to a string.
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = JsonContentWithInvalidUtf8(
+ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28",
+ "\",\"io.modelcontextprotocol/clientInfo\":{\"name\":\"TestClient\",\"version\":\"1.0\"},\"io.modelcontextprotocol/clientCapabilities\":{}}}}");
+ request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
+ request.Headers.Add("Mcp-Method", "tools/list");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ await AssertJsonRpcErrorAsync(response, McpErrorCode.InvalidParams, expectedId: 2);
+ }
+
+ [Fact]
+ public async Task Server_RejectsInvalidUtf8InInitializeProtocolVersion_WithBadRequest()
+ {
+ await StartAsync();
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = JsonContentWithInvalidUtf8(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25",
+ "\",\"capabilities\":{},\"clientInfo\":{\"name\":\"TestClient\",\"version\":\"1.0\"}}}");
+ request.Headers.Add("MCP-Protocol-Version", "2025-11-25");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ await AssertJsonRpcErrorAsync(response, McpErrorCode.HeaderMismatch, expectedId: 1);
+ }
+
+ [Fact]
+ public async Task Server_RejectsInvalidUtf8InMcpNameBodyValue_WithBadRequest()
+ {
+ await StartAsync();
+ await ProbeWithJuly2026ProtocolVersionAsync();
+
+ // The Mcp-Name header is compared with params.name, which is converted to a string first.
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = JsonContentWithInvalidUtf8(
+ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"header_test",
+ "\",\"arguments\":{},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientInfo\":{\"name\":\"TestClient\",\"version\":\"1.0\"},\"io.modelcontextprotocol/clientCapabilities\":{}}}}");
+ request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "header_test");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ await AssertJsonRpcErrorAsync(response, McpErrorCode.HeaderMismatch, expectedId: 2);
+ }
+
+ [Fact]
+ public async Task Server_RejectsInvalidUtf8InCustomParamHeaderArgument_WithBadRequest()
+ {
+ await StartAsync();
+ await ProbeWithJuly2026ProtocolVersionAsync();
+
+ // The region argument is annotated with x-mcp-header, so it is converted to a string and compared
+ // with the Mcp-Param-Region header.
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = JsonContentWithInvalidUtf8(
+ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"header_test\",\"arguments\":{\"region\":\"us-west1",
+ "\",\"priority\":42,\"verbose\":false,\"emptyVal\":\"\"},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientInfo\":{\"name\":\"TestClient\",\"version\":\"1.0\"},\"io.modelcontextprotocol/clientCapabilities\":{}}}}");
+ request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "header_test");
+ request.Headers.Add("Mcp-Param-Region", "us-west1");
+ request.Headers.Add("Mcp-Param-Priority", "42");
+ request.Headers.Add("Mcp-Param-Verbose", "false");
+ request.Headers.Add("Mcp-Param-EmptyVal", "");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ await AssertJsonRpcErrorAsync(response, McpErrorCode.HeaderMismatch, expectedId: 2);
+ }
+
+ [Fact]
+ public async Task Server_RejectsNonStringMcpNameBodyValue_WithBadRequest()
+ {
+ await StartAsync();
+ await ProbeWithJuly2026ProtocolVersionAsync();
+
+ // params.name is a number, so converting it to a string for the Mcp-Name comparison fails.
+ using var request = new HttpRequestMessage(HttpMethod.Post, "");
+ request.Content = JsonContent("""
+ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":5,"arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ """);
+ request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "5");
+
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ await AssertJsonRpcErrorAsync(response, McpErrorCode.HeaderMismatch, expectedId: 2);
+ }
+
#endregion
#region Client-side encoding tests (unit tests for McpHeaderEncoder)
@@ -629,6 +725,29 @@ private async Task InitializeWithInitializeHandshakeVersionAsync()
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
+ ///
+ /// Asserts a 400 response whose JSON-RPC error has the expected code and echoes the request id.
+ ///
+ private static async Task AssertJsonRpcErrorAsync(HttpResponseMessage response, McpErrorCode expectedCode, long expectedId)
+ {
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!;
+ Assert.Equal((int)expectedCode, json["error"]!["code"]!.GetValue());
+ Assert.Equal(expectedId, json["id"]!.GetValue());
+ }
+
+ ///
+ /// Builds a JSON body that is + a single 0xFF byte + ,
+ /// so the body contains a string value that is not valid UTF-8.
+ ///
+ private static ByteArrayContent JsonContentWithInvalidUtf8(string before, string after)
+ {
+ byte[] body = [.. Encoding.UTF8.GetBytes(before), 0xFF, .. Encoding.UTF8.GetBytes(after)];
+ var content = new ByteArrayContent(body);
+ content.Headers.ContentType = new("application/json") { CharSet = "utf-8" };
+ return content;
+ }
+
private long _lastRequestId = 1;
private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true)