Skip to content

Commit e23be1d

Browse files
tarekghTarek Mahmoud Sayed
andauthored
Echo request id in post-parse Streamable HTTP error responses (modelcontextprotocol#1687)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
1 parent 5d00eed commit e23be1d

3 files changed

Lines changed: 82 additions & 19 deletions

File tree

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,18 @@ await WriteJsonRpcErrorAsync(context,
106106
return;
107107
}
108108

109+
// Once the body has been parsed into a request with a readable id, every JSON-RPC error response
110+
// for this request MUST echo that id (base protocol responses section; SEP-2243 error format).
111+
// Notifications carry no id, so this stays default (null), which is correct.
112+
var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default;
113+
109114
if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage))
110115
{
111-
await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch);
116+
await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId);
112117
return;
113118
}
114119

115-
var session = await GetOrCreateSessionAsync(context, message);
120+
var session = await GetOrCreateSessionAsync(context, message, requestId);
116121
if (session is null)
117122
{
118123
return;
@@ -286,15 +291,15 @@ await WriteJsonRpcErrorAsync(context,
286291
}
287292
}
288293

289-
private async ValueTask<StreamableHttpSession?> GetSessionAsync(HttpContext context, string sessionId)
294+
private async ValueTask<StreamableHttpSession?> GetSessionAsync(HttpContext context, string sessionId, RequestId requestId = default)
290295
{
291296
if (string.IsNullOrEmpty(sessionId))
292297
{
293298
await WriteJsonRpcErrorAsync(context,
294299
"Bad Request: Mcp-Session-Id header is required for GET and DELETE requests when the server is using sessions. " +
295300
"If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.Stateless = true. " +
296301
"See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.",
297-
StatusCodes.Status400BadRequest);
302+
StatusCodes.Status400BadRequest, requestId: requestId);
298303
return null;
299304
}
300305

@@ -309,7 +314,7 @@ await WriteJsonRpcErrorAsync(context,
309314
// One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
310315
// JSON-RPC library from Microsoft called StreamJsonRpc where it's called JsonRpcErrorCode.NoMarshaledObjectFound
311316
// https://learn.microsoft.com/dotnet/api/streamjsonrpc.protocol.jsonrpcerrorcode?view=streamjsonrpc-2.9#fields
312-
await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, -32001);
317+
await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, -32001, requestId);
313318
return null;
314319
}
315320
}
@@ -318,7 +323,7 @@ await WriteJsonRpcErrorAsync(context,
318323
{
319324
await WriteJsonRpcErrorAsync(context,
320325
"Forbidden: The currently authenticated user does not match the user who initiated the session.",
321-
StatusCodes.Status403Forbidden);
326+
StatusCodes.Status403Forbidden, requestId: requestId);
322327
return null;
323328
}
324329

@@ -367,7 +372,7 @@ await WriteJsonRpcErrorAsync(context,
367372
}
368373
}
369374

370-
private async ValueTask<StreamableHttpSession?> GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message)
375+
private async ValueTask<StreamableHttpSession?> GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message, RequestId requestId = default)
371376
{
372377
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
373378

@@ -380,7 +385,7 @@ await WriteJsonRpcErrorAsync(context,
380385
// A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567).
381386
await WriteJsonRpcErrorAsync(context,
382387
"Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).",
383-
StatusCodes.Status400BadRequest);
388+
StatusCodes.Status400BadRequest, requestId: requestId);
384389
return null;
385390
}
386391

@@ -389,7 +394,7 @@ await WriteJsonRpcErrorAsync(context,
389394
// The author explicitly opted into sessions (Stateless = false), which the 2026-07-28
390395
// revision cannot provide. Refuse it so a dual-era client falls back to the legacy
391396
// initialize handshake and gets the session it asked for (SEP-2575 fallback semantics).
392-
await WriteUnsupportedProtocolVersionErrorAsync(context);
397+
await WriteUnsupportedProtocolVersionErrorAsync(context, requestId);
393398
return null;
394399
}
395400

@@ -408,7 +413,7 @@ await WriteJsonRpcErrorAsync(context,
408413
"Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests, " +
409414
"or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " +
410415
"See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.",
411-
StatusCodes.Status400BadRequest);
416+
StatusCodes.Status400BadRequest, requestId: requestId);
412417
return null;
413418
}
414419

@@ -418,12 +423,12 @@ await WriteJsonRpcErrorAsync(context,
418423
{
419424
// In stateless mode, we should not be getting existing sessions via sessionId
420425
// This path should not be reached in stateless mode
421-
await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest);
426+
await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest, requestId: requestId);
422427
return null;
423428
}
424429
else
425430
{
426-
return await GetSessionAsync(context, sessionId);
431+
return await GetSessionAsync(context, sessionId, requestId);
427432
}
428433
}
429434

@@ -568,10 +573,11 @@ await WriteJsonRpcErrorAsync(context,
568573
return eventStreamReader;
569574
}
570575

571-
private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000)
576+
private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000, RequestId requestId = default)
572577
{
573578
var jsonRpcError = new JsonRpcError
574579
{
580+
Id = requestId,
575581
Error = new()
576582
{
577583
Code = errorCode,
@@ -691,7 +697,7 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW
691697
/// that excludes 2026-07-28 and later. A dual-era client then falls back to the legacy initialize handshake
692698
/// (SEP-2575).
693699
/// </summary>
694-
private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context)
700+
private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default)
695701
{
696702
var requestedProtocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
697703
var errorDetail = new JsonRpcErrorDetail
@@ -708,7 +714,7 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex
708714
GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()),
709715
};
710716

711-
return WriteJsonRpcErrorDetailAsync(context, errorDetail, StatusCodes.Status400BadRequest);
717+
return WriteJsonRpcErrorDetailAsync(context, errorDetail, StatusCodes.Status400BadRequest, requestId);
712718
}
713719

714720
/// <summary>
@@ -1150,9 +1156,9 @@ private static SafeIntegerParse ParseSafeInteger(string text, out long value)
11501156
return SafeIntegerParse.NotNumeric;
11511157
}
11521158

1153-
private static Task WriteJsonRpcErrorDetailAsync(HttpContext context, JsonRpcErrorDetail detail, int statusCode)
1159+
private static Task WriteJsonRpcErrorDetailAsync(HttpContext context, JsonRpcErrorDetail detail, int statusCode, RequestId requestId = default)
11541160
{
1155-
var jsonRpcError = new JsonRpcError { Error = detail };
1161+
var jsonRpcError = new JsonRpcError { Id = requestId, Error = detail };
11561162
return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context);
11571163
}
11581164

tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi
174174
// checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a
175175
// legacy one and falling back to the initialize handshake.
176176
var body =
177-
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" +
177+
@"{""jsonrpc"":""2.0"",""id"":4242,""method"":""server/discover"",""params"":{" +
178178
July2026ProtocolMetaFragment("2025-11-25") + "}}";
179179

180180
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
@@ -184,6 +184,33 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi
184184

185185
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
186186
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
187+
188+
// The body parsed successfully, so per the base protocol responses section (and SEP-2243's error
189+
// response format) this error MUST echo the request id rather than emitting id=null (see #1677).
190+
Assert.Equal(4242, json["id"]!.GetValue<long>());
191+
}
192+
193+
[Fact]
194+
public async Task July2026Post_MissingMcpNameHeader_ReturnsHeaderMismatch_EchoesRequestId()
195+
{
196+
await StartAsync();
197+
198+
// A well-formed tools/call whose body parses, but the required Mcp-Name header is absent. The server
199+
// rejects it with -32020 HeaderMismatch, and because the request id was readable the JSON-RPC error
200+
// MUST carry that same id (regression guard for #1677).
201+
var body =
202+
@"{""jsonrpc"":""2.0"",""id"":4242,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," +
203+
July2026ProtocolMetaFragment() + "}}";
204+
205+
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
206+
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
207+
request.Headers.Add("Mcp-Method", "tools/call");
208+
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
209+
210+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
211+
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
212+
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
213+
Assert.Equal(4242, json["id"]!.GetValue<long>());
187214
}
188215

189216
[Fact]

tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,14 +224,19 @@ public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId()
224224

225225
using var request = new HttpRequestMessage(HttpMethod.Post, "")
226226
{
227-
Content = JsonContent(EchoRequest),
227+
Content = JsonContent("""{"jsonrpc":"2.0","id":4242,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}"""),
228228
Headers =
229229
{
230230
{ "mcp-session-id", "fakeSession" },
231231
},
232232
};
233233
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
234234
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
235+
236+
// The request body parsed successfully, so the JSON-RPC error MUST echo its id rather than
237+
// emitting id=null (base protocol responses section; see #1677).
238+
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
239+
Assert.Equal(4242, doc.RootElement.GetProperty("id").GetInt64());
235240
}
236241

237242
[Fact]
@@ -245,6 +250,31 @@ public async Task PostWithoutSessionId_NonInitializeRequest_Returns400()
245250
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
246251
Assert.Contains("Mcp-Session-Id", body);
247252
Assert.Contains("Stateless", body);
253+
254+
// The request body parsed successfully, so the JSON-RPC error MUST echo its id (see #1677).
255+
using var doc = JsonDocument.Parse(body);
256+
Assert.Equal(1, doc.RootElement.GetProperty("id").GetInt64());
257+
}
258+
259+
[Fact]
260+
public async Task StatelessPostWithSessionId_Returns400_EchoesRequestId()
261+
{
262+
await StartAsync(stateless: true);
263+
264+
using var request = new HttpRequestMessage(HttpMethod.Post, "")
265+
{
266+
Content = JsonContent("""{"jsonrpc":"2.0","id":4242,"method":"tools/list","params":{}}"""),
267+
Headers =
268+
{
269+
{ "mcp-session-id", "someSession" },
270+
},
271+
};
272+
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
273+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
274+
275+
// The request body parsed successfully, so the stateless-mode rejection MUST echo its id (see #1677).
276+
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
277+
Assert.Equal(4242, doc.RootElement.GetProperty("id").GetInt64());
248278
}
249279

250280
[Fact]

0 commit comments

Comments
 (0)