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
20 changes: 15 additions & 5 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down