Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 66 additions & 15 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -1302,14 +1323,44 @@ argForMissing is not null &&
return true;
}

private static string? GetJsonNodeStringProperty(System.Text.Json.Nodes.JsonNode? node, string propertyName)
/// <summary>
/// Reads an optional string property of a JSON object without throwing. Returns <see langword="true"/> with a
/// <see langword="null"/> value when the node is not an object, or when the property is absent or JSON null.
/// Returns <see langword="false"/> when the property is present but is not a readable string.
/// </summary>
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;
}

/// <summary>
/// Reads a JSON string value without throwing. Returns <see langword="false"/> 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 <see cref="InvalidOperationException"/> at that point.
/// </summary>
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<string>();
try
{
value = jsonValue.GetValue<string>();
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -629,6 +725,29 @@ private async Task InitializeWithInitializeHandshakeVersionAsync()

private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");

/// <summary>
/// Asserts a 400 response whose JSON-RPC error has the expected code and echoes the request id.
/// </summary>
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<int>());
Assert.Equal(expectedId, json["id"]!.GetValue<long>());
}

/// <summary>
/// Builds a JSON body that is <paramref name="before"/> + a single 0xFF byte + <paramref name="after"/>,
/// so the body contains a string value that is not valid UTF-8.
/// </summary>
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)
Expand Down