From ec614e3c41d791a1fcefad953ff1925a76ab978b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 3 Oct 2025 18:18:30 +0300 Subject: [PATCH 1/3] Fix support for sending server notifications with request-specific McpServer instances used outside their original scope. --- .../Server/SseWriter.cs | 10 +++--- .../Server/StreamableHttpPostTransport.cs | 8 ++++- .../StreamableHttpServerConformanceTests.cs | 35 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/SseWriter.cs b/src/ModelContextProtocol.Core/Server/SseWriter.cs index 4fb7feaf..5fd60a39 100644 --- a/src/ModelContextProtocol.Core/Server/SseWriter.cs +++ b/src/ModelContextProtocol.Core/Server/SseWriter.cs @@ -47,7 +47,7 @@ public Task WriteAllAsync(Stream sseResponseStream, CancellationToken cancellati return _writeTask; } - public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { Throw.IfNull(message); @@ -55,14 +55,14 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can if (_disposed) { - // Don't throw an ODE, because this is disposed internally when the transport disconnects due to an abort - // or sending all the responses for the a give given Streamable HTTP POST request, so the user might not be at fault. - // There's precedence for no-oping here similar to writing to the response body of an aborted request in ASP.NET Core. - return; + // Don't throw ObjectDisposedException here; just return false to indicate the message wasn't sent. + // The calling tranport can determine what to do in this case (drop the message, or fallback to another transport). + return false; } // Emit redundant "event: message" lines for better compatibility with other SDKs. await _messages.Writer.WriteAsync(new SseItem(message, SseParser.EventTypeDefault), cancellationToken).ConfigureAwait(false); + return true; } public async ValueTask DisposeAsync() diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs index 1992939d..1109c2b2 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs @@ -72,7 +72,13 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can throw new InvalidOperationException("Server to client requests are not supported in stateless mode."); } - await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); + bool isAccepted = await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); + if (!isAccepted) + { + // The underlying writer didn't accept the message because the underlying request has completed. + // Rather than drop the message, fall back to sending it via the parent transport. + await parentTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); + } } public async ValueTask DisposeAsync() diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 7b2be8f9..5cc7f74d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -521,6 +521,41 @@ public async Task IdleSessionsPastMaxIdleSessionCount_ArePruned_LongestIdleFirst Assert.StartsWith("MaxIdleSessionCount of 2 exceeded. Closing idle session", idleLimitLogMessage.Message); } + [Fact] + public async Task McpServer_UsedOutOfScope_CanSendNotifications() + { + McpServer? capturedServer = null; + Builder.Services.AddMcpServer() + .WithHttpTransport() + .WithListResourcesHandler((_, _) => ValueTask.FromResult(new ListResourcesResult())) + .WithSubscribeToResourcesHandler((context, token) => + { + capturedServer = context.Server; + return ValueTask.FromResult(new EmptyResult()); + }); + + await StartAsync(); + + string sessionId = await CallInitializeAndValidateAsync(); + SetSessionId(sessionId); + + // Call the subscribe method to capture the McpServer instance. + using var response = await HttpClient.PostAsync("", JsonContent(Request("resources/subscribe")), TestContext.Current.CancellationToken); + var rpcResponse = await AssertSingleSseResponseAsync(response); + AssertType(rpcResponse.Result); + Assert.NotNull(capturedServer); + + // Check the captured McpServer instance can send a notification. + await capturedServer.SendNotificationAsync(NotificationMethods.ResourceUpdatedNotification, TestContext.Current.CancellationToken); + using var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + JsonRpcMessage? firstSseMessage = await ReadSseAsync(getResponse.Content) + .Select(data => JsonSerializer.Deserialize(data, McpJsonUtilities.DefaultOptions)) + .FirstOrDefaultAsync(TestContext.Current.CancellationToken); + + var notification = Assert.IsType(firstSseMessage); + Assert.Equal(NotificationMethods.ResourceUpdatedNotification, notification.Method); + } + private static StringContent JsonContent(string json) => new StringContent(json, Encoding.UTF8, "application/json"); private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); From b0e257eab9544ff82bd02900ea9845637953b76c Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 3 Oct 2025 18:28:02 +0300 Subject: [PATCH 2/3] Address feedback. --- .../Server/SseResponseStreamTransport.cs | 1 + src/ModelContextProtocol.Core/Server/SseWriter.cs | 2 +- .../Server/StreamableHttpServerTransport.cs | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs b/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs index 8941e4ed..98155213 100644 --- a/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs +++ b/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs @@ -66,6 +66,7 @@ public async ValueTask DisposeAsync() public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { Throw.IfNull(message); + // If the underlying writer has been disposed, just drop the message. await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); } diff --git a/src/ModelContextProtocol.Core/Server/SseWriter.cs b/src/ModelContextProtocol.Core/Server/SseWriter.cs index 5fd60a39..6965c845 100644 --- a/src/ModelContextProtocol.Core/Server/SseWriter.cs +++ b/src/ModelContextProtocol.Core/Server/SseWriter.cs @@ -56,7 +56,7 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationTok if (_disposed) { // Don't throw ObjectDisposedException here; just return false to indicate the message wasn't sent. - // The calling tranport can determine what to do in this case (drop the message, or fallback to another transport). + // The calling transport can determine what to do in this case (drop the message, or fallback to another transport). return false; } diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index 57283e9a..4bbb49be 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -131,6 +131,7 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can throw new InvalidOperationException("Unsolicited server to client messages are not supported in stateless mode."); } + // If the underlying writer has been disposed, just drop the message. await _sseWriter.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); } From 63c9e87107d1f16fe70416a3e36b04812d4e6da6 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 3 Oct 2025 18:29:30 +0300 Subject: [PATCH 3/3] Update src/ModelContextProtocol.Core/Server/SseWriter.cs --- src/ModelContextProtocol.Core/Server/SseWriter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/SseWriter.cs b/src/ModelContextProtocol.Core/Server/SseWriter.cs index 6965c845..a2314e62 100644 --- a/src/ModelContextProtocol.Core/Server/SseWriter.cs +++ b/src/ModelContextProtocol.Core/Server/SseWriter.cs @@ -56,7 +56,7 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationTok if (_disposed) { // Don't throw ObjectDisposedException here; just return false to indicate the message wasn't sent. - // The calling transport can determine what to do in this case (drop the message, or fallback to another transport). + // The calling transport can determine what to do in this case (drop the message, or fall back to another transport). return false; }