diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 3224ceda8..1d95bc850 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -108,7 +108,10 @@ await WriteJsonRpcErrorAsync(context, if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) { - await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch); + // The body was parsed before headers are validated, so the request id is known here. + // Echo it instead of returning id:null (JSON-RPC 2.0 §5; null is reserved for requests + // whose id couldn't be read). + await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, GetRequestId(message)); return; } @@ -380,7 +383,7 @@ await WriteJsonRpcErrorAsync(context, // A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567). await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).", - StatusCodes.Status400BadRequest); + StatusCodes.Status400BadRequest, requestId: GetRequestId(message)); return null; } @@ -408,7 +411,7 @@ await WriteJsonRpcErrorAsync(context, "Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests, " + "or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", - StatusCodes.Status400BadRequest); + StatusCodes.Status400BadRequest, requestId: GetRequestId(message)); return null; } @@ -418,7 +421,7 @@ await WriteJsonRpcErrorAsync(context, { // In stateless mode, we should not be getting existing sessions via sessionId // This path should not be reached in stateless mode - await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest, requestId: GetRequestId(message)); return null; } else @@ -568,10 +571,11 @@ await WriteJsonRpcErrorAsync(context, return eventStreamReader; } - private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000) + private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000, RequestId requestId = default) { var jsonRpcError = new JsonRpcError { + Id = requestId, Error = new() { Code = errorCode, @@ -581,6 +585,12 @@ private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMess return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context); } + // Returns the request id when it was parsed from the JSON-RPC body, or default (serialized as + // null) for messages that carry no id. Used to correlate error responses produced after the body + // was parsed but before the request reaches the transport. + private static RequestId GetRequestId(JsonRpcMessage? message) + => message is JsonRpcMessageWithId withId ? withId.Id : default; + internal static void InitializeSseResponse(HttpContext context) { context.Response.Headers.ContentType = "text/event-stream"; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 4da16a3eb..e2e945703 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -267,6 +267,116 @@ public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + [Fact] + public async Task Server_EchoesRequestId_WhenMcpNameHeaderMissing() + { + await StartAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); + + // The body is a well-formed JSON-RPC request with a numeric id. Header validation fails + // because the Mcp-Name header is omitted, but the id was already parsed from the body, so the + // error response MUST echo it rather than returning id:null (JSON-RPC 2.0 §5). + var callBody = """ + {"jsonrpc":"2.0","id":1001,"method":"tools/call","params":{"name":"header_test","arguments":{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}}} + """; + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callBody, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); + request.Headers.Add("Mcp-Method", "tools/call"); + // Mcp-Name intentionally omitted. + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var root = doc.RootElement; + Assert.Equal(JsonValueKind.Number, root.GetProperty("id").ValueKind); + Assert.Equal(1001, root.GetProperty("id").GetInt64()); + Assert.Equal((int)McpErrorCode.HeaderMismatch, root.GetProperty("error").GetProperty("code").GetInt32()); + } + + [Fact] + public async Task Server_EchoesRequestId_WhenMcpNameHeaderMismatchesBody() + { + await StartAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); + + // Body declares the tool name "header_test" but the Mcp-Name header says something else. + // The id was parsed from the body, so the mismatch error must still echo it. + var callBody = """ + {"jsonrpc":"2.0","id":"req-7","method":"tools/call","params":{"name":"header_test","arguments":{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}}} + """; + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callBody, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "other_tool"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var root = doc.RootElement; + Assert.Equal(JsonValueKind.String, root.GetProperty("id").ValueKind); + Assert.Equal("req-7", root.GetProperty("id").GetString()); + Assert.Equal((int)McpErrorCode.HeaderMismatch, root.GetProperty("error").GetProperty("code").GetInt32()); + } + + [Fact] + public async Task Server_EchoesRequestId_WhenSessionIdHeaderRejectedUnderJuly2026Protocol() + { + await StartAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); + + // Under the 2026-07-28 revision (SEP-2567) an Mcp-Session-Id header is rejected. The body was + // parsed successfully, so this post-parse error must echo the request id too. + var callBody = """ + {"jsonrpc":"2.0","id":2002,"method":"tools/call","params":{"name":"header_test","arguments":{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}}} + """; + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callBody, Encoding.UTF8, "application/json"); + 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", ""); + request.Headers.Add("Mcp-Session-Id", "some-session"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var root = doc.RootElement; + Assert.Equal(JsonValueKind.Number, root.GetProperty("id").ValueKind); + Assert.Equal(2002, root.GetProperty("id").GetInt64()); + } + + [Fact] + public async Task Server_ReturnsNullId_WhenRequestBodyIsMalformed() + { + await StartAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); + + // When the body can't be parsed the id can't be read, so id:null is the correct, conformant + // response. This guards against over-correcting the fix for the parsed-id case. + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent("{ not valid json", Encoding.UTF8, "application/json"); + 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); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("id").ValueKind); + } + [Fact] public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() {