From 1d25d9bf06c07284c0f3770a124bc126507da409 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:36:13 +0000 Subject: [PATCH 1/8] Initial plan From 3686f6f3a1cce7e3dc0cb57d8fa1c31c8b90f5ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:54:33 +0000 Subject: [PATCH 2/8] Fix SendRequestAsync hang when background SSE stream delivers response before foreground transport send completes Replace `await SendToRelatedTransportAsync(request, cancellationToken)` with `Task.WhenAny(sendTask, tcs.Task)` so that if the response TCS completes first (via a concurrent background channel such as the background GET SSE stream in Streamable HTTP), execution proceeds immediately without waiting for the foreground send. If the send completes first (normal case), it is awaited directly, preserving the original behavior. The still-running send task's exceptions are observed via a fire-and-forget ContinueWith(OnlyOnFaulted) to prevent unobserved task exceptions. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../McpSessionHandler.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index b2f94fb28..7dea4d7a5 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -536,7 +536,26 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc LogSendingRequest(EndpointName, request.Method); } - await SendToRelatedTransportAsync(request, cancellationToken).ConfigureAwait(false); + // Wait for either the transport send to complete or for the response to arrive via a + // concurrent channel (e.g. the background GET SSE stream in Streamable HTTP). Without + // this, the foreground transport send could block indefinitely waiting for a response + // that was already delivered via a different stream. + Task sendTask = SendToRelatedTransportAsync(request, cancellationToken); + if (sendTask != await Task.WhenAny(sendTask, tcs.Task).ConfigureAwait(false)) + { + // The response arrived via a concurrent channel before the transport send completed. + // Observe any exception from the still-running send to prevent unobserved task exceptions. + _ = sendTask.ContinueWith( + static (t, _) => _ = t.Exception, + null, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + else + { + await sendTask.ConfigureAwait(false); + } // Now that the request has been sent, register for cancellation. If we registered before, // a cancellation request could arrive before the server knew about that request ID, in which From fbf06384ff776b5f7e97e6e31ccaa1507ad011d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:30:46 +0000 Subject: [PATCH 3/8] Cancel SendToRelatedTransportAsync when response arrives via concurrent channel Use a linked CancellationTokenSource so the foreground transport send (e.g., retry GET loop) is cancelled when the response TCS completes first via a concurrent background stream. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/ModelContextProtocol.Core/McpSessionHandler.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 7dea4d7a5..3d35ee6f1 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -540,11 +540,13 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc // concurrent channel (e.g. the background GET SSE stream in Streamable HTTP). Without // this, the foreground transport send could block indefinitely waiting for a response // that was already delivered via a different stream. - Task sendTask = SendToRelatedTransportAsync(request, cancellationToken); + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task sendTask = SendToRelatedTransportAsync(request, sendCts.Token); if (sendTask != await Task.WhenAny(sendTask, tcs.Task).ConfigureAwait(false)) { // The response arrived via a concurrent channel before the transport send completed. - // Observe any exception from the still-running send to prevent unobserved task exceptions. + // Cancel the still-running send and observe any exception to prevent unobserved task exceptions. + sendCts.Cancel(); _ = sendTask.ContinueWith( static (t, _) => _ = t.Exception, null, From 6a95b4c443e382926b8323f09b8f4cf109147245 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:53:46 +0000 Subject: [PATCH 4/8] Invert if block for common case, log send faults at debug level Address review feedback: - Invert the WhenAny check so the common case (send completes first) is in the if branch. - Replace fire-and-forget ContinueWith with ObserveSendFaults that logs the exception at Debug level using the logging source generator. Uses ConfigureAwaitOptions.SuppressThrowing on .NET and ContinueWith on downlevel. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../McpSessionHandler.cs | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 3d35ee6f1..a943dfa03 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -542,21 +542,39 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc // that was already delivered via a different stream. using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Task sendTask = SendToRelatedTransportAsync(request, sendCts.Token); - if (sendTask != await Task.WhenAny(sendTask, tcs.Task).ConfigureAwait(false)) + if (sendTask == await Task.WhenAny(sendTask, tcs.Task).ConfigureAwait(false)) { - // The response arrived via a concurrent channel before the transport send completed. - // Cancel the still-running send and observe any exception to prevent unobserved task exceptions. - sendCts.Cancel(); - _ = sendTask.ContinueWith( - static (t, _) => _ = t.Exception, - null, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted, - TaskScheduler.Default); + await sendTask.ConfigureAwait(false); } else { - await sendTask.ConfigureAwait(false); + // The response arrived via a concurrent channel before the transport send completed. + // Cancel the still-running send and log any exception at debug level. + sendCts.Cancel(); + _ = ObserveSendFaults(this, sendTask); + +#if NET + static async Task ObserveSendFaults(McpSessionHandler self, Task task) + { + await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + if (task.IsFaulted) + { + self.LogTransportSendFaulted(self.EndpointName, task.Exception); + } + } +#else + static Task ObserveSendFaults(McpSessionHandler self, Task task) => + task.ContinueWith( + static (t, s) => + { + var handler = (McpSessionHandler)s!; + handler.LogTransportSendFaulted(handler.EndpointName, t.Exception!); + }, + self, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); +#endif } // Now that the request has been sent, register for cancellation. If we registered before, @@ -1099,4 +1117,7 @@ private static McpProtocolException CreateRemoteProtocolException(JsonRpcError e [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} session {SessionId} disposed with transport {TransportKind}")] private partial void LogSessionDisposed(string endpointName, string sessionId, string transportKind); + + [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} transport send faulted after response was already received.")] + private partial void LogTransportSendFaulted(string endpointName, Exception exception); } From 69ed9fb303474d3aa2b41f96598d51bb6df6be2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:05:24 +0000 Subject: [PATCH 5/8] Guard send/race block with tcs.Task.IsCompleted check to skip send when response already arrived Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../McpSessionHandler.cs | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index a943dfa03..137b493bc 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -540,41 +540,44 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc // concurrent channel (e.g. the background GET SSE stream in Streamable HTTP). Without // this, the foreground transport send could block indefinitely waiting for a response // that was already delivered via a different stream. - using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - Task sendTask = SendToRelatedTransportAsync(request, sendCts.Token); - if (sendTask == await Task.WhenAny(sendTask, tcs.Task).ConfigureAwait(false)) + if (!tcs.Task.IsCompleted) { - await sendTask.ConfigureAwait(false); - } - else - { - // The response arrived via a concurrent channel before the transport send completed. - // Cancel the still-running send and log any exception at debug level. - sendCts.Cancel(); - _ = ObserveSendFaults(this, sendTask); + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task sendTask = SendToRelatedTransportAsync(request, sendCts.Token); + if (sendTask == await Task.WhenAny(sendTask, tcs.Task).ConfigureAwait(false)) + { + await sendTask.ConfigureAwait(false); + } + else + { + // The response arrived via a concurrent channel before the transport send completed. + // Cancel the still-running send and log any exception at debug level. + sendCts.Cancel(); + _ = ObserveSendFaults(this, sendTask); #if NET - static async Task ObserveSendFaults(McpSessionHandler self, Task task) - { - await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - if (task.IsFaulted) + static async Task ObserveSendFaults(McpSessionHandler self, Task task) { - self.LogTransportSendFaulted(self.EndpointName, task.Exception); + await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + if (task.IsFaulted) + { + self.LogTransportSendFaulted(self.EndpointName, task.Exception); + } } - } #else - static Task ObserveSendFaults(McpSessionHandler self, Task task) => - task.ContinueWith( - static (t, s) => - { - var handler = (McpSessionHandler)s!; - handler.LogTransportSendFaulted(handler.EndpointName, t.Exception!); - }, - self, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted, - TaskScheduler.Default); + static Task ObserveSendFaults(McpSessionHandler self, Task task) => + task.ContinueWith( + static (t, s) => + { + var handler = (McpSessionHandler)s!; + handler.LogTransportSendFaulted(handler.EndpointName, t.Exception!); + }, + self, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); #endif + } } // Now that the request has been sent, register for cancellation. If we registered before, From 37809cab5bf3a67e7be33ab2603f6afb9c1c282d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:15:59 +0000 Subject: [PATCH 6/8] Simplify ObserveSendFaults: remove #if NET block, inline ContinueWith at call site Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../McpSessionHandler.cs | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 137b493bc..761d17653 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -553,30 +553,16 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc // The response arrived via a concurrent channel before the transport send completed. // Cancel the still-running send and log any exception at debug level. sendCts.Cancel(); - _ = ObserveSendFaults(this, sendTask); - -#if NET - static async Task ObserveSendFaults(McpSessionHandler self, Task task) - { - await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - if (task.IsFaulted) + _ = sendTask.ContinueWith( + static (t, s) => { - self.LogTransportSendFaulted(self.EndpointName, task.Exception); - } - } -#else - static Task ObserveSendFaults(McpSessionHandler self, Task task) => - task.ContinueWith( - static (t, s) => - { - var handler = (McpSessionHandler)s!; - handler.LogTransportSendFaulted(handler.EndpointName, t.Exception!); - }, - self, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted, - TaskScheduler.Default); -#endif + var handler = (McpSessionHandler)s!; + handler.LogTransportSendFaulted(handler.EndpointName, t.Exception!); + }, + this, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); } } From 0dea2cc177e01620a451c03028d277103fd7b854 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:53:46 +0000 Subject: [PATCH 7/8] Move session ID and background GET setup before SSE processing to prevent cancellation race The McpSessionHandler.SendRequestAsync races the transport send against the response TCS using Task.WhenAny. When the TCS completes during SSE stream processing, the send task is cancelled. But SendHttpRequestAsync previously set SessionId, _negotiatedProtocolVersion, and started _getReceiveTask AFTER SSE processing (lines 136-148), so these critical side effects were lost. Fix: extract SessionId from HTTP response headers and start _getReceiveTask immediately after receiving a successful HTTP response for initialize requests, before SSE content processing begins. The protocol version (from the response body) is still set after SSE processing when possible. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpClientSessionTransport.cs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index c19283036..e79689eb4 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -100,6 +100,21 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes } var rpcRequest = message as JsonRpcRequest; + + // For initialize requests, capture session metadata from response headers immediately, + // before processing the response body. McpSessionHandler.SendRequestAsync races the + // transport send against the response TCS and may cancel this task once the response + // arrives during SSE stream processing, so session state must be set before that. + if (rpcRequest?.Method == RequestMethods.Initialize) + { + if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) + { + SessionId = sessionIdValues.FirstOrDefault(); + } + + _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); + } + JsonRpcMessageWithId? rpcResponseOrError = null; if (response.Content.Headers.ContentType?.MediaType == "application/json") @@ -135,16 +150,8 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse) { - // We've successfully initialized! Copy session-id and protocol version, then start GET request if any. - if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) - { - SessionId = sessionIdValues.FirstOrDefault(); - } - var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult); _negotiatedProtocolVersion = initializeResult?.ProtocolVersion; - - _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); } return response; From 5dc65985557d659e59af017304f2d154d1f52d03 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:42:28 +0000 Subject: [PATCH 8/8] Fix WhenAny race: await initialize send to completion, only race non-initialize requests The previous approach cancelled the transport send task via linked CTS when the response TCS completed first. This aborted SSE processing before SessionId and _getReceiveTask were set, causing subsequent requests to fail with 400 (Mcp-Session-Id header is required). Fix: - Remove the linked CTS that cancelled the send task - For initialize requests, always await the send to completion so the transport's side effects (SessionId, protocol version, background tasks) complete before subsequent messages - For non-initialize requests, use WhenAny to avoid the SSE retry hang where the foreground send blocks forever because the response arrived via a background channel - Revert StreamableHttpClientSessionTransport.cs to main's version since the send is no longer cancelled for initialize requests Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../StreamableHttpClientSessionTransport.cs | 23 +++++++------------ .../McpSessionHandler.cs | 21 +++++++++-------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index e79689eb4..c19283036 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -100,21 +100,6 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes } var rpcRequest = message as JsonRpcRequest; - - // For initialize requests, capture session metadata from response headers immediately, - // before processing the response body. McpSessionHandler.SendRequestAsync races the - // transport send against the response TCS and may cancel this task once the response - // arrives during SSE stream processing, so session state must be set before that. - if (rpcRequest?.Method == RequestMethods.Initialize) - { - if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) - { - SessionId = sessionIdValues.FirstOrDefault(); - } - - _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); - } - JsonRpcMessageWithId? rpcResponseOrError = null; if (response.Content.Headers.ContentType?.MediaType == "application/json") @@ -150,8 +135,16 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse) { + // We've successfully initialized! Copy session-id and protocol version, then start GET request if any. + if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) + { + SessionId = sessionIdValues.FirstOrDefault(); + } + var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult); _negotiatedProtocolVersion = initializeResult?.ProtocolVersion; + + _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); } return response; diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 761d17653..451837a6e 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -536,23 +536,26 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc LogSendingRequest(EndpointName, request.Method); } - // Wait for either the transport send to complete or for the response to arrive via a - // concurrent channel (e.g. the background GET SSE stream in Streamable HTTP). Without - // this, the foreground transport send could block indefinitely waiting for a response - // that was already delivered via a different stream. + // For most requests, wait for either the transport send to complete or for the + // response to arrive via a concurrent channel (e.g. the background GET SSE stream + // in Streamable HTTP). Without this, the foreground transport send could block + // indefinitely waiting for a response that was already delivered via a different stream. + // + // For the initialize request, always await the send to completion. The transport's + // SendMessageAsync has side effects (setting SessionId, starting background tasks) + // that must complete before subsequent messages are sent. if (!tcs.Task.IsCompleted) { - using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - Task sendTask = SendToRelatedTransportAsync(request, sendCts.Token); - if (sendTask == await Task.WhenAny(sendTask, tcs.Task).ConfigureAwait(false)) + Task sendTask = SendToRelatedTransportAsync(request, cancellationToken); + if (method == RequestMethods.Initialize || + sendTask == await Task.WhenAny(sendTask, tcs.Task).ConfigureAwait(false)) { await sendTask.ConfigureAwait(false); } else { // The response arrived via a concurrent channel before the transport send completed. - // Cancel the still-running send and log any exception at debug level. - sendCts.Cancel(); + // Let the send finish naturally but observe any faults at debug level. _ = sendTask.ContinueWith( static (t, s) => {