diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2746f0762..0ee6e9c65 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ "image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0-jammy", "features": { "ghcr.io/devcontainers/features/dotnet:2": { - "version": "9.0" + "version": "10.0" }, "ghcr.io/devcontainers/features/node:1": {} }, diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index b29bccd53..b6e7deefc 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -43,8 +43,8 @@ jobs: uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: | + 10.0.x 9.0.x - 8.0.x # NetFX testing on non-Windows requires mono - name: Setup Mono @@ -78,7 +78,6 @@ jobs: --filter '(Execution!=Manual)' --no-build --configuration ${{ matrix.configuration }} - --logger "console;verbosity=normal" --logger "trx" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" --blame diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index dc86e65b9..55acbfa96 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -15,8 +15,8 @@ jobs: uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: | + 10.0.x 9.0.x - 8.0.x - name: Download test results uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 866b5774d..b7e466115 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -32,7 +32,9 @@ jobs: - name: .NET Setup uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: - dotnet-version: 9.x + dotnet-version: | + 10.0.x + 9.0.x - name: Generate documentation run: make generate-docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ae26f8a0..f4e9301e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,9 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: - dotnet-version: 9.0.x + dotnet-version: | + 10.0.x + 9.0.x - name: Build run: dotnet build --configuration ${{ matrix.configuration }} @@ -77,8 +79,8 @@ jobs: uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: | + 10.0.x 9.0.x - 8.0.x - name: Pack run: dotnet pack @@ -106,7 +108,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Download build artifacts uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 @@ -138,11 +140,6 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Setup .NET - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - with: - dotnet-version: 9.0.x - - name: Download build artifacts uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 @@ -166,7 +163,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Download build artifacts uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 diff --git a/Directory.Packages.props b/Directory.Packages.props index 1ae45da31..2d8f454e8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,6 +34,15 @@ + + + + + + + + + diff --git a/global.json b/global.json index 903111e2b..58c475f9f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,7 @@ { "sdk": { - "version": "9.0.204", - "rollForward": "minor" + "version": "10.0.100-rc.1", + "rollForward": "minor", + "allowPrerelease": true } } diff --git a/samples/ProtectedMcpClient/Program.cs b/samples/ProtectedMcpClient/Program.cs index 9dc2410ea..042d47713 100644 --- a/samples/ProtectedMcpClient/Program.cs +++ b/samples/ProtectedMcpClient/Program.cs @@ -134,6 +134,13 @@ /// The URL to open. static void OpenBrowser(Uri url) { + // Validate the URI scheme - only allow safe protocols + if (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps) + { + Console.WriteLine($"Error: Only HTTP and HTTPS URLs are allowed."); + return; + } + try { var psi = new ProcessStartInfo @@ -145,7 +152,7 @@ static void OpenBrowser(Uri url) } catch (Exception ex) { - Console.WriteLine($"Error opening browser. {ex.Message}"); + Console.WriteLine($"Error opening browser: {ex.Message}"); Console.WriteLine($"Please manually open this URL: {url}"); } } \ No newline at end of file diff --git a/samples/TestServerWithHosting/TestServerWithHosting.csproj b/samples/TestServerWithHosting/TestServerWithHosting.csproj index 2466a2811..7967395c0 100644 --- a/samples/TestServerWithHosting/TestServerWithHosting.csproj +++ b/samples/TestServerWithHosting/TestServerWithHosting.csproj @@ -2,7 +2,7 @@ Exe - net9.0;net8.0;net472 + net10.0;net9.0;net8.0;net472 enable enable true diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 77e400e36..c8a04ae52 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,7 +6,7 @@ https://github.com/modelcontextprotocol/csharp-sdk git 0.4.0 - preview.1 + preview.2 ModelContextProtocolOfficial © Anthropic and Contributors. ModelContextProtocol;mcp;ai;llm diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index fbceab4b1..313cbfa99 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -29,7 +29,6 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.AddHostedService(); - builder.Services.AddDataProtection(); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationFilterSetup>()); diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index a4ae569ba..d68f83e5d 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -4,16 +4,18 @@ namespace ModelContextProtocol.AspNetCore; -internal sealed partial class IdleTrackingBackgroundService( - StatefulSessionManager sessions, - IOptions options, - IHostApplicationLifetime appLifetime, - ILogger logger) : BackgroundService +internal sealed partial class IdleTrackingBackgroundService : BackgroundService { - // Workaround for https://github.com/dotnet/runtime/issues/91121. This is fixed in .NET 9 and later. - private readonly ILogger _logger = logger; + private readonly StatefulSessionManager _sessions; + private readonly IOptions _options; + private readonly IHostApplicationLifetime _appLifetime; + private readonly ILogger _logger; - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + public IdleTrackingBackgroundService( + StatefulSessionManager sessions, + IOptions options, + IHostApplicationLifetime appLifetime, + ILogger logger) { // Still run loop given infinite IdleTimeout to enforce the MaxIdleSessionCount and assist graceful shutdown. if (options.Value.IdleTimeout != Timeout.InfiniteTimeSpan) @@ -23,14 +25,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0); + _sessions = sessions; + _options = options; + _appLifetime = appLifetime; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { try { - var timeProvider = options.Value.TimeProvider; + var timeProvider = _options.Value.TimeProvider; using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider); while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { - await sessions.PruneIdleSessionsAsync(stoppingToken); + await _sessions.PruneIdleSessionsAsync(stoppingToken); } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -40,7 +50,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - await sessions.DisposeAllSessionsAsync(); + await _sessions.DisposeAllSessionsAsync(); } finally { @@ -48,7 +58,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Something went terribly wrong. A very unexpected exception must be bubbling up, but let's ensure we also stop the application, // so that it hopefully gets looked at and restarted. This shouldn't really be reachable. - appLifetime.StopApplication(); + _appLifetime.StopApplication(); IdleTrackingBackgroundServiceStoppedUnexpectedly(); } } diff --git a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs b/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs deleted file mode 100644 index 0257f6d95..000000000 --- a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionId.cs +++ /dev/null @@ -1,13 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.AspNetCore.Stateless; - -internal sealed class StatelessSessionId -{ - [JsonPropertyName("clientInfo")] - public Implementation? ClientInfo { get; init; } - - [JsonPropertyName("userIdClaim")] - public UserIdClaim? UserIdClaim { get; init; } -} diff --git a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionIdJsonContext.cs b/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionIdJsonContext.cs deleted file mode 100644 index 6963ed609..000000000 --- a/src/ModelContextProtocol.AspNetCore/Stateless/StatelessSessionIdJsonContext.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.AspNetCore.Stateless; - -[JsonSerializable(typeof(StatelessSessionId))] -internal sealed partial class StatelessSessionIdJsonContext : JsonSerializerContext; diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 14093facc..70976d44f 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -1,16 +1,13 @@ -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; -using ModelContextProtocol.AspNetCore.Stateless; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Security.Claims; using System.Security.Cryptography; -using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -20,7 +17,6 @@ internal sealed class StreamableHttpHandler( IOptionsFactory mcpServerOptionsFactory, IOptions httpServerTransportOptions, StatefulSessionManager sessionManager, - IDataProtectionProvider dataProtection, ILoggerFactory loggerFactory, IServiceProvider applicationServices) { @@ -31,8 +27,6 @@ internal sealed class StreamableHttpHandler( public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value; - private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId"); - public async Task HandlePostRequestAsync(HttpContext context) { // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. @@ -128,17 +122,6 @@ public async Task HandleDeleteRequestAsync(HttpContext context) await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest); return null; } - else if (HttpServerTransportOptions.Stateless) - { - var sessionJson = Protector.Unprotect(sessionId); - var statelessSessionId = JsonSerializer.Deserialize(sessionJson, StatelessSessionIdJsonContext.Default.StatelessSessionId); - var transport = new StreamableHttpServerTransport - { - Stateless = true, - SessionId = sessionId, - }; - session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId); - } else if (!sessionManager.TryGetValue(sessionId, out session)) { // -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does. @@ -170,6 +153,13 @@ await WriteJsonRpcErrorAsync(context, { return await StartNewSessionAsync(context); } + else if (HttpServerTransportOptions.Stateless) + { + // 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); + return null; + } else { return await GetSessionAsync(context, sessionId); @@ -193,14 +183,12 @@ private async ValueTask StartNewSessionAsync(HttpContext } else { - // "(uninitialized stateless id)" is not written anywhere. We delay writing the MCP-Session-Id - // until after we receive the initialize request with the client info we need to serialize. - sessionId = "(uninitialized stateless id)"; + // In stateless mode, each request is independent. Don't set any session ID on the transport. + sessionId = ""; transport = new() { Stateless = true, }; - ScheduleStatelessSessionIdWrite(context, transport); } return await CreateSessionAsync(context, transport, sessionId); @@ -209,21 +197,19 @@ private async ValueTask StartNewSessionAsync(HttpContext private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, - string sessionId, - StatelessSessionId? statelessId = null) + string sessionId) { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (statelessId is not null || HttpServerTransportOptions.ConfigureSessionOptions is not null) + if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (statelessId is not null) + if (HttpServerTransportOptions.Stateless) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; mcpServerOptions.ScopeRequests = false; - mcpServerOptions.KnownClientInfo = statelessId.ClientInfo; } if (HttpServerTransportOptions.ConfigureSessionOptions is { } configureSessionOptions) @@ -235,7 +221,7 @@ private async ValueTask CreateSessionAsync( var server = McpServer.Create(transport, mcpServerOptions, loggerFactory, mcpServerServices); context.Features.Set(server); - var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User); + var userIdClaim = GetUserIdClaim(context.User); var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager); var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync; @@ -273,7 +259,6 @@ internal static string MakeNewSessionId() RandomNumberGenerator.Fill(buffer); return WebEncoders.Base64UrlEncode(buffer); } - internal static async Task ReadJsonRpcMessageAsync(HttpContext context) { // Implementation for reading a JSON-RPC message from the request body @@ -290,22 +275,6 @@ internal static string MakeNewSessionId() return message; } - private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttpServerTransport transport) - { - transport.OnInitRequestReceived = initRequestParams => - { - var statelessId = new StatelessSessionId - { - ClientInfo = initRequestParams?.ClientInfo, - UserIdClaim = GetUserIdClaim(context.User), - }; - - var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId); - transport.SessionId = Protector.Protect(sessionJson); - context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId; - return ValueTask.CompletedTask; - }; - } internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted) => session.RunAsync(requestAborted); diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 00fc0a7cc..0bae663ba 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -416,7 +416,7 @@ private void ThrowIfSamplingUnsupported() { if (ClientCapabilities?.Sampling is null) { - if (ServerOptions.KnownClientInfo is not null) + if (ClientCapabilities is null) { throw new InvalidOperationException("Sampling is not supported in stateless mode."); } @@ -429,7 +429,7 @@ private void ThrowIfRootsUnsupported() { if (ClientCapabilities?.Roots is null) { - if (ServerOptions.KnownClientInfo is not null) + if (ClientCapabilities is null) { throw new InvalidOperationException("Roots are not supported in stateless mode."); } @@ -442,7 +442,7 @@ private void ThrowIfElicitationUnsupported() { if (ClientCapabilities?.Elicitation is null) { - if (ServerOptions.KnownClientInfo is not null) + if (ClientCapabilities is null) { throw new InvalidOperationException("Elicitation is not supported in stateless mode."); } diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index cd8c368a1..b20865576 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -24,7 +24,7 @@ public static class McpServerExtensions /// The client does not support sampling. /// /// This method requires the client to support sampling capabilities. - /// It allows detailed control over sampling parameters including messages, system prompt, temperature, + /// It allows detailed control over sampling parameters including messages, system prompt, temperature, /// and token limits. /// [Obsolete($"Use {nameof(McpServer)}.{nameof(McpServer.SampleAsync)} instead. This member will be removed in a subsequent release.")] // See: https://github.com/modelcontextprotocol/csharp-sdk/issues/774 diff --git a/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs b/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs index 8941e4ed6..98155213e 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 4fb7feafe..a2314e623 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 transport can determine what to do in this case (drop the message, or fall back 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 1992939de..1109c2b2b 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/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index 57283e9a2..4bbb49be9 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); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index 1d27a219e..5e3a654f9 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -54,8 +54,10 @@ public async Task Connect_TestServer_ShouldProvideServerFields() Assert.NotNull(client.ServerInfo); Assert.NotNull(client.NegotiatedProtocolVersion); - if (ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/sse")) + if (ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/sse") || + ClientTransportOptions.Endpoint.AbsolutePath.EndsWith("/stateless")) { + // In SSE and in Streamable HTTP's stateless mode, no protocol-defined session IDs are used.:w Assert.Null(client.SessionId); } else diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index f8b61aa21..0e953e4d7 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -158,7 +158,7 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia { return async context => { - if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-session-id"])) + if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-protocol-version"])) { protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]); } @@ -179,8 +179,11 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia Assert.Equal("2025-03-26", mcpClient.NegotiatedProtocolVersion); await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + await mcpClient.DisposeAsync(); + // The header should be included in the GET request, the initialized notification, the tools/list call, and the delete request. - Assert.NotEmpty(protocolVersionHeaderValues); + // The DELETE request won't be sent for Stateless mode due to the lack of an Mcp-Session-Id. + Assert.Equal(Stateless ? 3 : 4, protocolVersionHeaderValues.Count); Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v)); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index 341171c51..d7b9eaa01 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -85,6 +85,8 @@ public async Task Can_UseIHttpContextAccessor_InTool() [Fact] public async Task Messages_FromNewUser_AreRejected() { + Assert.SkipWhen(Stateless, "User validation across requests is not applicable in stateless mode."); + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); // Add an authentication scheme that will send a 403 Forbidden response. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index 34801c736..7adeb03d3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -1,7 +1,7 @@  - net9.0;net8.0 + net10.0;net9.0;net8.0 enable enable false diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 7b2be8f98..5cc7f74d8 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)); diff --git a/tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj b/tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj index 51092f564..9ea927474 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj +++ b/tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj @@ -1,7 +1,7 @@ - net9.0;net8.0 + net10.0;net9.0;net8.0 enable enable diff --git a/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj b/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj index f38a35859..c4b39bb54 100644 --- a/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj +++ b/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj @@ -2,7 +2,7 @@ Exe - net9.0;net8.0;net472 + net10.0;net9.0;net8.0;net472 enable enable TestServer diff --git a/tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj b/tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj index f56d0db9e..3296ff481 100644 --- a/tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj +++ b/tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj @@ -2,7 +2,7 @@ Exe - net9.0;net8.0 + net10.0;net9.0;net8.0 enable enable TestSseServer diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 993564bf0..3c7d631ad 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -2,13 +2,15 @@ Exe - net9.0;net8.0;net472 + net10.0;net9.0;net8.0;net472 enable enable false true ModelContextProtocol.Tests + + $(NoWarn);NU1903;NU1902