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)