From f53e0d2132d91301c743c6b4051036a5e6024f97 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 17 Jul 2025 17:39:05 -0700 Subject: [PATCH 01/12] Respect HandleResponse() and SkipHandler() calls in OnResourceMetadataRequest (#607) --- .../McpAuthenticationHandler.cs | 26 ++++-- .../Authentication/ClientOAuthProvider.cs | 44 +++++----- .../AuthEventTests.cs | 81 +++++++++++++++++++ 3 files changed, 125 insertions(+), 26 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index f8c6f41cd..46b8e898b 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -43,8 +43,7 @@ public async Task HandleRequestAsync() return false; } - await HandleResourceMetadataRequestAsync(); - return true; + return await HandleResourceMetadataRequestAsync(); } /// @@ -78,10 +77,7 @@ private string GetAbsoluteResourceMetadataUri() return absoluteUri.ToString(); } - /// - /// Handles the resource metadata request. - /// - private async Task HandleResourceMetadataRequestAsync() + private async Task HandleResourceMetadataRequestAsync() { var resourceMetadata = Options.ResourceMetadata; @@ -93,6 +89,23 @@ private async Task HandleResourceMetadataRequestAsync() }; await Options.Events.OnResourceMetadataRequest(context); + + if (context.Result is not null) + { + if (context.Result.Handled) + { + return true; + } + else if (context.Result.Skipped) + { + return false; + } + else if (context.Result.Failure is not null) + { + throw new AuthenticationFailureException("An error occurred from the OnResourceMetadataRequest event.", context.Result.Failure); + } + } + resourceMetadata = context.ResourceMetadata; } @@ -104,6 +117,7 @@ private async Task HandleResourceMetadataRequestAsync() } await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context); + return true; } /// diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 96356028f..686d749ff 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -212,11 +212,6 @@ private async Task PerformOAuthAuthorizationAsync( // Get auth server metadata var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false); - if (authServerMetadata is null) - { - ThrowFailedToHandleUnauthorizedResponse($"Failed to retrieve metadata for authorization server: '{selectedAuthServer}'"); - } - // Store auth server metadata for future refresh operations _authServerMetadata = authServerMetadata; @@ -238,7 +233,7 @@ private async Task PerformOAuthAuthorizationAsync( LogOAuthAuthorizationCompleted(); } - private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) + private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) { if (!authServerUri.OriginalString.EndsWith("/")) { @@ -249,7 +244,9 @@ private async Task PerformOAuthAuthorizationAsync( { try { - var response = await _httpClient.GetAsync(new Uri(authServerUri, path), cancellationToken).ConfigureAwait(false); + var wellKnownEndpoint = new Uri(authServerUri, path); + + var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { continue; @@ -258,15 +255,28 @@ private async Task PerformOAuthAuthorizationAsync( using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var metadata = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, cancellationToken).ConfigureAwait(false); - if (metadata != null) + if (metadata is null) + { + continue; + } + + if (metadata.AuthorizationEndpoint is null) { - metadata.ResponseTypesSupported ??= ["code"]; - metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"]; - metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"]; - metadata.CodeChallengeMethodsSupported ??= ["S256"]; + ThrowFailedToHandleUnauthorizedResponse($"No authorization_endpoint was provided via '{wellKnownEndpoint}'."); + } - return metadata; + if (metadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttp && + metadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttps) + { + ThrowFailedToHandleUnauthorizedResponse($"AuthorizationEndpoint must use HTTP or HTTPS. '{metadata.AuthorizationEndpoint}' does not meet this requirement."); } + + metadata.ResponseTypesSupported ??= ["code"]; + metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"]; + metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"]; + metadata.CodeChallengeMethodsSupported ??= ["S256"]; + + return metadata; } catch (Exception ex) { @@ -274,7 +284,7 @@ private async Task PerformOAuthAuthorizationAsync( } } - return null; + throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'"); } private async Task RefreshTokenAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) @@ -320,12 +330,6 @@ private Uri BuildAuthorizationUrl( AuthorizationServerMetadata authServerMetadata, string codeChallenge) { - if (authServerMetadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttp && - authServerMetadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttps) - { - throw new ArgumentException("AuthorizationEndpoint must use HTTP or HTTPS.", nameof(authServerMetadata)); - } - var queryParamsDictionary = new Dictionary { ["client_id"] = GetClientIdOrThrow(), diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs index 6a48c21d2..c993edd4c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthEventTests.cs @@ -289,6 +289,87 @@ public async Task ResourceMetadataEndpoint_ThrowsException_WhenNoMetadataProvide Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); } + [Fact] + public async Task ResourceMetadataEndpoint_HandlesResponse_WhenHandleResponseCalled() + { + Builder.Services.AddMcpServer().WithHttpTransport(); + + // Override the configuration to test HandleResponse behavior + Builder.Services.Configure( + McpAuthenticationDefaults.AuthenticationScheme, + options => + { + options.ResourceMetadata = null; + options.Events.OnResourceMetadataRequest = async context => + { + // Call HandleResponse() to discontinue processing and return to client + context.HandleResponse(); + await Task.CompletedTask; + }; + } + ); + + await using var app = Builder.Build(); + + app.MapMcp().RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + // Make a direct request to the resource metadata endpoint + using var response = await HttpClient.GetAsync( + "/.well-known/oauth-protected-resource", + TestContext.Current.CancellationToken + ); + + // The request should be handled by the event handler without returning metadata + // Since HandleResponse() was called, the handler should have taken responsibility + // for generating the response, which in this case means an empty response + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // The response should be empty since the event handler called HandleResponse() + // but didn't write any content to the response + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Empty(content); + } + + [Fact] + public async Task ResourceMetadataEndpoint_SkipsHandler_WhenSkipHandlerCalled() + { + Builder.Services.AddMcpServer().WithHttpTransport(); + + // Override the configuration to test SkipHandler behavior + Builder.Services.Configure( + McpAuthenticationDefaults.AuthenticationScheme, + options => + { + options.ResourceMetadata = null; + options.Events.OnResourceMetadataRequest = async context => + { + // Call SkipHandler() to discontinue processing in the current handler + context.SkipHandler(); + await Task.CompletedTask; + }; + } + ); + + await using var app = Builder.Build(); + + app.MapMcp().RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + // Make a direct request to the resource metadata endpoint + using var response = await HttpClient.GetAsync( + "/.well-known/oauth-protected-resource", + TestContext.Current.CancellationToken + ); + + // When SkipHandler() is called, the authentication handler should skip processing + // and let other handlers in the pipeline handle the request. Since there are no + // other handlers configured for this endpoint, this should result in a 404 + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + private async Task HandleAuthorizationUrlAsync( Uri authorizationUri, Uri redirectUri, From bd5aef6020782ab97e9b4e7340cf27281ae0cb78 Mon Sep 17 00:00:00 2001 From: Szymon Kulec Date: Fri, 18 Jul 2025 20:36:08 +0200 Subject: [PATCH 02/12] UnreferenceDisposable made slimmer (#627) --- src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs index c34aba6c7..1456ce565 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs @@ -25,13 +25,15 @@ internal sealed class HttpMcpSession( public bool IsActive => !SessionClosed.IsCancellationRequested && _referenceCount > 0; public long LastActivityTicks { get; private set; } = timeProvider.GetTimestamp(); + private TimeProvider TimeProvider => timeProvider; + public IMcpServer? Server { get; set; } public Task? ServerRunTask { get; set; } public IDisposable AcquireReference() { Interlocked.Increment(ref _referenceCount); - return new UnreferenceDisposable(this, timeProvider); + return new UnreferenceDisposable(this); } public bool TryStartGetRequest() => Interlocked.Exchange(ref _getRequestStarted, 1) == 0; @@ -70,13 +72,13 @@ public async ValueTask DisposeAsync() public bool HasSameUserId(ClaimsPrincipal user) => UserIdClaim == StreamableHttpHandler.GetUserIdClaim(user); - private sealed class UnreferenceDisposable(HttpMcpSession session, TimeProvider timeProvider) : IDisposable + private sealed class UnreferenceDisposable(HttpMcpSession session) : IDisposable { public void Dispose() { if (Interlocked.Decrement(ref session._referenceCount) == 0) { - session.LastActivityTicks = timeProvider.GetTimestamp(); + session.LastActivityTicks = session.TimeProvider.GetTimestamp(); } } } From 929ae58906cbb90a9cbd1b4b94f448cccf772df7 Mon Sep 17 00:00:00 2001 From: Szymon Kulec Date: Fri, 18 Jul 2025 20:36:21 +0200 Subject: [PATCH 03/12] IdleTracking uses lists instead of SortedSet (#629) --- .../IdleTrackingBackgroundService.cs | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index 26ffd44bb..c4a5f11ee 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Hosting; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol.Server; @@ -21,6 +22,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.IdleTimeout, TimeSpan.Zero); } + ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0); try @@ -31,8 +33,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var idleTimeoutTicks = options.Value.IdleTimeout.Ticks; var maxIdleSessionCount = options.Value.MaxIdleSessionCount; - // The default ValueTuple Comparer will check the first item then the second which preserves both order and uniqueness. - var idleSessions = new SortedSet<(long Timestamp, string SessionId)>(); + // Create two lists that will be reused between runs. + // This assumes that the number of idle sessions is not breached frequently. + // If the idle sessions often breach the maximum, a priority queue could be considered. + var idleSessionsTimestamps = new List(); + var idleSessionSessionIds = new List(); while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { @@ -56,26 +61,34 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) continue; } - idleSessions.Add((session.LastActivityTicks, session.Id)); + // Add the timestamp and the session + idleSessionsTimestamps.Add(session.LastActivityTicks); + idleSessionSessionIds.Add(session.Id); // Emit critical log at most once every 5 seconds the idle count it exceeded, // since the IdleTimeout will no longer be respected. - if (idleSessions.Count == maxIdleSessionCount + 1) + if (idleSessionsTimestamps.Count == maxIdleSessionCount + 1) { LogMaxSessionIdleCountExceeded(maxIdleSessionCount); } } - if (idleSessions.Count > maxIdleSessionCount) + if (idleSessionsTimestamps.Count > maxIdleSessionCount) { - var sessionsToPrune = idleSessions.ToArray()[..^maxIdleSessionCount]; - foreach (var (_, id) in sessionsToPrune) + var timestamps = CollectionsMarshal.AsSpan(idleSessionsTimestamps); + + // Sort only if the maximum is breached and sort solely by the timestamp. Sort both collections. + timestamps.Sort(CollectionsMarshal.AsSpan(idleSessionSessionIds)); + + var sessionsToPrune = CollectionsMarshal.AsSpan(idleSessionSessionIds)[..^maxIdleSessionCount]; + foreach (var id in sessionsToPrune) { RemoveAndCloseSession(id); } } - idleSessions.Clear(); + idleSessionsTimestamps.Clear(); + idleSessionSessionIds.Clear(); } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -145,4 +158,4 @@ private async Task DisposeSessionAsync(HttpMcpSession Date: Fri, 18 Jul 2025 11:37:58 -0700 Subject: [PATCH 04/12] Bump version to 0.3.0-preview.4 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7859ba39a..3936b7064 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,7 +6,7 @@ https://github.com/modelcontextprotocol/csharp-sdk git 0.3.0 - preview.3 + preview.4 ModelContextProtocolOfficial © Anthropic and Contributors. ModelContextProtocol;mcp;ai;llm From 650df631947f9026b5c65d6f1fc0f6f3dd9a77ad Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:52:45 +0300 Subject: [PATCH 05/12] Fix ResourceLinkBlock deserialization by adding missing "name" case (#645) * Initial plan * Initial analysis and plan for ResourceLinkBlock deserialization fix Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Fix ResourceLinkBlock deserialization by adding missing "name" case Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Remove unnecessary dotnet-install.sh file Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Use const for JSON string variables and improve indentation Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Remove temporary backup file Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Change JSON variable names to use PascalCase (Json) as requested Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Protocol/ContentBlock.cs | 4 + .../Protocol/ContentBlockTests.cs | 83 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index 516ea2446..04de39db4 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -103,6 +103,10 @@ public class Converter : JsonConverter text = reader.GetString(); break; + case "name": + name = reader.GetString(); + break; + case "data": data = reader.GetString(); break; diff --git a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs new file mode 100644 index 000000000..c5ab88b3a --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests.Protocol; + +public class ContentBlockTests +{ + [Fact] + public void ResourceLinkBlock_SerializationRoundTrip_PreservesAllProperties() + { + // Arrange + var original = new ResourceLinkBlock + { + Uri = "https://example.com/resource", + Name = "Test Resource", + Description = "A test resource for validation", + MimeType = "text/plain", + Size = 1024 + }; + + // Act - Serialize to JSON + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + // Act - Deserialize back from JSON + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var resourceLink = Assert.IsType(deserialized); + + Assert.Equal(original.Uri, resourceLink.Uri); + Assert.Equal(original.Name, resourceLink.Name); + Assert.Equal(original.Description, resourceLink.Description); + Assert.Equal(original.MimeType, resourceLink.MimeType); + Assert.Equal(original.Size, resourceLink.Size); + Assert.Equal("resource_link", resourceLink.Type); + } + + [Fact] + public void ResourceLinkBlock_DeserializationWithMinimalProperties_Succeeds() + { + // Arrange - JSON with only required properties + const string Json = """ + { + "type": "resource_link", + "uri": "https://example.com/minimal", + "name": "Minimal Resource" + } + """; + + // Act + var deserialized = JsonSerializer.Deserialize(Json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var resourceLink = Assert.IsType(deserialized); + + Assert.Equal("https://example.com/minimal", resourceLink.Uri); + Assert.Equal("Minimal Resource", resourceLink.Name); + Assert.Null(resourceLink.Description); + Assert.Null(resourceLink.MimeType); + Assert.Null(resourceLink.Size); + Assert.Equal("resource_link", resourceLink.Type); + } + + [Fact] + public void ResourceLinkBlock_DeserializationWithoutName_ThrowsJsonException() + { + // Arrange - JSON missing the required "name" property + const string Json = """ + { + "type": "resource_link", + "uri": "https://example.com/missing-name" + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(Json, McpJsonUtilities.DefaultOptions)); + + Assert.Contains("Name must be provided for 'resource_link' type", exception.Message); + } +} \ No newline at end of file From da86d522418c20ed2aecc6f358807ef41a0cbf33 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 31 Jul 2025 21:37:49 -0400 Subject: [PATCH 06/12] Add an in-memory transport sample (#664) * Add an in-memory transport sample * Rename sample --- ModelContextProtocol.slnx | 1 + .../InMemoryTransport.csproj | 15 +++++++ samples/InMemoryTransport/Program.cs | 40 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 samples/InMemoryTransport/InMemoryTransport.csproj create mode 100644 samples/InMemoryTransport/Program.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 5ed8ba0d6..d4fed7ea9 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -12,6 +12,7 @@ + diff --git a/samples/InMemoryTransport/InMemoryTransport.csproj b/samples/InMemoryTransport/InMemoryTransport.csproj new file mode 100644 index 000000000..7c1161ce9 --- /dev/null +++ b/samples/InMemoryTransport/InMemoryTransport.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + + + diff --git a/samples/InMemoryTransport/Program.cs b/samples/InMemoryTransport/Program.cs new file mode 100644 index 000000000..67e2d320c --- /dev/null +++ b/samples/InMemoryTransport/Program.cs @@ -0,0 +1,40 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.IO.Pipelines; + +Pipe clientToServerPipe = new(), serverToClientPipe = new(); + +// Create a server using a stream-based transport over an in-memory pipe. +await using IMcpServer server = McpServerFactory.Create( + new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), + new McpServerOptions() + { + Capabilities = new() + { + Tools = new() + { + ToolCollection = [McpServerTool.Create((string arg) => $"Echo: {arg}", new() { Name = "Echo" })] + } + } + }); +_ = server.RunAsync(); + +// Connect a client using a stream-based transport over the same in-memory pipe. +await using IMcpClient client = await McpClientFactory.CreateAsync( + new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream())); + +// List all tools. +var tools = await client.ListToolsAsync(); +foreach (var tool in tools) +{ + Console.WriteLine($"Tool Name: {tool.Name}"); +} +Console.WriteLine(); + +// Invoke a tool. +var echo = tools.First(t => t.Name == "Echo"); +Console.WriteLine(await echo.InvokeAsync(new() +{ + ["arg"] = "Hello World" +})); \ No newline at end of file From 5e5b1af29b2e35e435244fc4e845611cdba43031 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 1 Aug 2025 10:47:11 -0700 Subject: [PATCH 07/12] Remove 'Sse' from AspNetCoreSseServer sample name (#665) --- ModelContextProtocol.slnx | 6 +++--- .../AspNetCoreMcpServer.csproj} | 0 .../Program.cs | 4 ++-- .../Properties/launchSettings.json | 4 ++-- .../Resources/SimpleResourceType.cs | 3 +-- .../Tools/EchoTool.cs | 2 +- .../Tools/SampleLlmTool.cs | 2 +- .../appsettings.Development.json | 0 .../appsettings.json | 0 .../Program.cs | 0 .../ProtectedMcpClient.csproj} | 0 .../README.md | 10 +++++----- .../Program.cs | 2 +- .../Properties/launchSettings.json | 2 +- .../ProtectedMcpServer.csproj} | 0 .../README.md | 6 +++--- .../Tools/HttpClientExt.cs | 0 .../Tools/WeatherTools.cs | 2 +- tests/ModelContextProtocol.TestOAuthServer/Program.cs | 2 +- 19 files changed, 22 insertions(+), 23 deletions(-) rename samples/{AspNetCoreSseServer/AspNetCoreSseServer.csproj => AspNetCoreMcpServer/AspNetCoreMcpServer.csproj} (100%) rename samples/{AspNetCoreSseServer => AspNetCoreMcpServer}/Program.cs (89%) rename samples/{AspNetCoreSseServer => AspNetCoreMcpServer}/Properties/launchSettings.json (83%) rename samples/{AspNetCoreSseServer => AspNetCoreMcpServer}/Resources/SimpleResourceType.cs (76%) rename samples/{AspNetCoreSseServer => AspNetCoreMcpServer}/Tools/EchoTool.cs (88%) rename samples/{AspNetCoreSseServer => AspNetCoreMcpServer}/Tools/SampleLlmTool.cs (96%) rename samples/{AspNetCoreSseServer => AspNetCoreMcpServer}/appsettings.Development.json (100%) rename samples/{AspNetCoreSseServer => AspNetCoreMcpServer}/appsettings.json (100%) rename samples/{ProtectedMCPClient => ProtectedMcpClient}/Program.cs (100%) rename samples/{ProtectedMCPClient/ProtectedMCPClient.csproj => ProtectedMcpClient/ProtectedMcpClient.csproj} (100%) rename samples/{ProtectedMCPClient => ProtectedMcpClient}/README.md (92%) rename samples/{ProtectedMCPServer => ProtectedMcpServer}/Program.cs (99%) rename samples/{ProtectedMCPServer => ProtectedMcpServer}/Properties/launchSettings.json (89%) rename samples/{ProtectedMCPServer/ProtectedMCPServer.csproj => ProtectedMcpServer/ProtectedMcpServer.csproj} (100%) rename samples/{ProtectedMCPServer => ProtectedMcpServer}/README.md (96%) rename samples/{ProtectedMCPServer => ProtectedMcpServer}/Tools/HttpClientExt.cs (100%) rename samples/{ProtectedMCPServer => ProtectedMcpServer}/Tools/WeatherTools.cs (98%) diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index d4fed7ea9..0850150be 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -9,12 +9,12 @@ - + - - + + diff --git a/samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj similarity index 100% rename from samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj rename to samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj diff --git a/samples/AspNetCoreSseServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs similarity index 89% rename from samples/AspNetCoreSseServer/Program.cs rename to samples/AspNetCoreMcpServer/Program.cs index c21b328f6..824cd9997 100644 --- a/samples/AspNetCoreSseServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -1,8 +1,8 @@ using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -using TestServerWithHosting.Tools; -using TestServerWithHosting.Resources; +using AspNetCoreMcpServer.Tools; +using AspNetCoreMcpServer.Resources; var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() diff --git a/samples/AspNetCoreSseServer/Properties/launchSettings.json b/samples/AspNetCoreMcpServer/Properties/launchSettings.json similarity index 83% rename from samples/AspNetCoreSseServer/Properties/launchSettings.json rename to samples/AspNetCoreMcpServer/Properties/launchSettings.json index c789fb474..a5b8a22f6 100644 --- a/samples/AspNetCoreSseServer/Properties/launchSettings.json +++ b/samples/AspNetCoreMcpServer/Properties/launchSettings.json @@ -7,7 +7,7 @@ "applicationUrl": "http://localhost:3001/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", - "OTEL_SERVICE_NAME": "sse-server", + "OTEL_SERVICE_NAME": "aspnetcore-mcp-server", } }, "https": { @@ -16,7 +16,7 @@ "applicationUrl": "https://localhost:7133;http://localhost:3001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", - "OTEL_SERVICE_NAME": "sse-server", + "OTEL_SERVICE_NAME": "aspnetcore-mcp-server", } } } diff --git a/samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs b/samples/AspNetCoreMcpServer/Resources/SimpleResourceType.cs similarity index 76% rename from samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs rename to samples/AspNetCoreMcpServer/Resources/SimpleResourceType.cs index e73ce133c..aaf6d11a5 100644 --- a/samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs +++ b/samples/AspNetCoreMcpServer/Resources/SimpleResourceType.cs @@ -1,8 +1,7 @@ -using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; -namespace TestServerWithHosting.Resources; +namespace AspNetCoreMcpServer.Resources; [McpServerResourceType] public class SimpleResourceType diff --git a/samples/AspNetCoreSseServer/Tools/EchoTool.cs b/samples/AspNetCoreMcpServer/Tools/EchoTool.cs similarity index 88% rename from samples/AspNetCoreSseServer/Tools/EchoTool.cs rename to samples/AspNetCoreMcpServer/Tools/EchoTool.cs index 7913b73e4..a9dc0a665 100644 --- a/samples/AspNetCoreSseServer/Tools/EchoTool.cs +++ b/samples/AspNetCoreMcpServer/Tools/EchoTool.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Server; using System.ComponentModel; -namespace TestServerWithHosting.Tools; +namespace AspNetCoreMcpServer.Tools; [McpServerToolType] public sealed class EchoTool diff --git a/samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs b/samples/AspNetCoreMcpServer/Tools/SampleLlmTool.cs similarity index 96% rename from samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs rename to samples/AspNetCoreMcpServer/Tools/SampleLlmTool.cs index 247619dbb..3ac7f567d 100644 --- a/samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs +++ b/samples/AspNetCoreMcpServer/Tools/SampleLlmTool.cs @@ -2,7 +2,7 @@ using ModelContextProtocol.Server; using System.ComponentModel; -namespace TestServerWithHosting.Tools; +namespace AspNetCoreMcpServer.Tools; /// /// This tool uses dependency injection and async method diff --git a/samples/AspNetCoreSseServer/appsettings.Development.json b/samples/AspNetCoreMcpServer/appsettings.Development.json similarity index 100% rename from samples/AspNetCoreSseServer/appsettings.Development.json rename to samples/AspNetCoreMcpServer/appsettings.Development.json diff --git a/samples/AspNetCoreSseServer/appsettings.json b/samples/AspNetCoreMcpServer/appsettings.json similarity index 100% rename from samples/AspNetCoreSseServer/appsettings.json rename to samples/AspNetCoreMcpServer/appsettings.json diff --git a/samples/ProtectedMCPClient/Program.cs b/samples/ProtectedMcpClient/Program.cs similarity index 100% rename from samples/ProtectedMCPClient/Program.cs rename to samples/ProtectedMcpClient/Program.cs diff --git a/samples/ProtectedMCPClient/ProtectedMCPClient.csproj b/samples/ProtectedMcpClient/ProtectedMcpClient.csproj similarity index 100% rename from samples/ProtectedMCPClient/ProtectedMCPClient.csproj rename to samples/ProtectedMcpClient/ProtectedMcpClient.csproj diff --git a/samples/ProtectedMCPClient/README.md b/samples/ProtectedMcpClient/README.md similarity index 92% rename from samples/ProtectedMCPClient/README.md rename to samples/ProtectedMcpClient/README.md index 977331a04..81ae67cee 100644 --- a/samples/ProtectedMCPClient/README.md +++ b/samples/ProtectedMcpClient/README.md @@ -14,7 +14,7 @@ The Protected MCP Client sample shows how to: - .NET 9.0 or later - A running TestOAuthServer (for OAuth authentication) -- A running ProtectedMCPServer (for MCP services) +- A running ProtectedMcpServer (for MCP services) ## Setup and Running @@ -31,10 +31,10 @@ The OAuth server will start at `https://localhost:7029` ### Step 2: Start the Protected MCP Server -Next, start the ProtectedMCPServer which provides the weather tools: +Next, start the ProtectedMcpServer which provides the weather tools: ```bash -cd samples\ProtectedMCPServer +cd samples\ProtectedMcpServer dotnet run ``` @@ -45,7 +45,7 @@ The protected server will start at `http://localhost:7071` Finally, run this client: ```bash -cd samples\ProtectedMCPClient +cd samples\ProtectedMcpClient dotnet run ``` @@ -90,4 +90,4 @@ Once authenticated, the client can access weather tools including: ## Key Files - `Program.cs`: Main client application with OAuth flow implementation -- `ProtectedMCPClient.csproj`: Project file with dependencies \ No newline at end of file +- `ProtectedMcpClient.csproj`: Project file with dependencies \ No newline at end of file diff --git a/samples/ProtectedMCPServer/Program.cs b/samples/ProtectedMcpServer/Program.cs similarity index 99% rename from samples/ProtectedMCPServer/Program.cs rename to samples/ProtectedMcpServer/Program.cs index ef70fe731..a36e0367f 100644 --- a/samples/ProtectedMCPServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using ModelContextProtocol.AspNetCore.Authentication; -using ProtectedMCPServer.Tools; +using ProtectedMcpServer.Tools; using System.Net.Http.Headers; using System.Security.Claims; diff --git a/samples/ProtectedMCPServer/Properties/launchSettings.json b/samples/ProtectedMcpServer/Properties/launchSettings.json similarity index 89% rename from samples/ProtectedMCPServer/Properties/launchSettings.json rename to samples/ProtectedMcpServer/Properties/launchSettings.json index 31b04db83..dbc9a1147 100644 --- a/samples/ProtectedMCPServer/Properties/launchSettings.json +++ b/samples/ProtectedMcpServer/Properties/launchSettings.json @@ -1,6 +1,6 @@ { "profiles": { - "ProtectedMCPServer": { + "ProtectedMcpServer": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { diff --git a/samples/ProtectedMCPServer/ProtectedMCPServer.csproj b/samples/ProtectedMcpServer/ProtectedMcpServer.csproj similarity index 100% rename from samples/ProtectedMCPServer/ProtectedMCPServer.csproj rename to samples/ProtectedMcpServer/ProtectedMcpServer.csproj diff --git a/samples/ProtectedMCPServer/README.md b/samples/ProtectedMcpServer/README.md similarity index 96% rename from samples/ProtectedMCPServer/README.md rename to samples/ProtectedMcpServer/README.md index f0ac708a0..ecbfee633 100644 --- a/samples/ProtectedMCPServer/README.md +++ b/samples/ProtectedMcpServer/README.md @@ -34,7 +34,7 @@ The OAuth server will start at `https://localhost:7029` Run this protected server: ```bash -cd samples\ProtectedMCPServer +cd samples\ProtectedMcpServer dotnet run ``` @@ -42,10 +42,10 @@ The protected server will start at `http://localhost:7071` ### Step 3: Test with Protected MCP Client -You can test the server using the ProtectedMCPClient sample: +You can test the server using the ProtectedMcpClient sample: ```bash -cd samples\ProtectedMCPClient +cd samples\ProtectedMcpClient dotnet run ``` diff --git a/samples/ProtectedMCPServer/Tools/HttpClientExt.cs b/samples/ProtectedMcpServer/Tools/HttpClientExt.cs similarity index 100% rename from samples/ProtectedMCPServer/Tools/HttpClientExt.cs rename to samples/ProtectedMcpServer/Tools/HttpClientExt.cs diff --git a/samples/ProtectedMCPServer/Tools/WeatherTools.cs b/samples/ProtectedMcpServer/Tools/WeatherTools.cs similarity index 98% rename from samples/ProtectedMCPServer/Tools/WeatherTools.cs rename to samples/ProtectedMcpServer/Tools/WeatherTools.cs index 7c8c08514..477463c8d 100644 --- a/samples/ProtectedMCPServer/Tools/WeatherTools.cs +++ b/samples/ProtectedMcpServer/Tools/WeatherTools.cs @@ -4,7 +4,7 @@ using System.Globalization; using System.Text.Json; -namespace ProtectedMCPServer.Tools; +namespace ProtectedMcpServer.Tools; [McpServerToolType] public sealed class WeatherTools diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 3970394b6..bb251035d 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -14,7 +14,7 @@ public sealed class Program private const int _port = 7029; private static readonly string _url = $"https://localhost:{_port}"; - // Port 5000 is used by tests and port 7071 is used by the ProtectedMCPServer sample + // Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample private static readonly string[] ValidResources = ["http://localhost:5000/", "http://localhost:7071/"]; private readonly ConcurrentDictionary _authCodes = new(); From e1a2564a4ea7cff538a2bec410879e709cb34958 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:36:11 -0700 Subject: [PATCH 08/12] Fix NotSupportedException when returning IEnumerable (#675) --- .../McpJsonUtilities.cs | 1 + .../McpJsonUtilitiesTests.cs | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 21e2468d9..8bc9e21b0 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -146,6 +146,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(AudioContentBlock))] [JsonSerializable(typeof(EmbeddedResourceBlock))] [JsonSerializable(typeof(ResourceLinkBlock))] + [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(PromptReference))] [JsonSerializable(typeof(ResourceTemplateReference))] [JsonSerializable(typeof(BlobResourceContents))] diff --git a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs index e0af61eed..cc55746fe 100644 --- a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs +++ b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Tests; @@ -43,6 +44,38 @@ public static void DefaultOptions_UnknownEnumHandling() } } + [Fact] + public static void DefaultOptions_CanSerializeIEnumerableOfContentBlock() + { + var options = McpJsonUtilities.DefaultOptions; + + // Create an IEnumerable with different content types + IEnumerable contentBlocks = new List + { + new TextContentBlock { Text = "Hello World" }, + new TextContentBlock { Text = "Test message" } + }; + + // Should not throw NotSupportedException + string json = JsonSerializer.Serialize(contentBlocks, options); + + Assert.NotNull(json); + Assert.Contains("Hello World", json); + Assert.Contains("Test message", json); + Assert.Contains("\"type\":\"text\"", json); + + // Should also be able to deserialize back + var deserialized = JsonSerializer.Deserialize>(json, options); + Assert.NotNull(deserialized); + var deserializedList = deserialized.ToList(); + Assert.Equal(2, deserializedList.Count); + Assert.All(deserializedList, cb => Assert.Equal("text", cb.Type)); + + var textBlocks = deserializedList.Cast().ToArray(); + Assert.Equal("Hello World", textBlocks[0].Text); + Assert.Equal("Test message", textBlocks[1].Text); + } + public enum EnumWithoutAnnotation { A = 1, B = 2, C = 3 } [JsonConverter(typeof(JsonStringEnumConverter))] From 329f848db6bd7f9f17262ba9a817ab667725974f Mon Sep 17 00:00:00 2001 From: Theo <162511770+theojiang25@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:34:07 -0700 Subject: [PATCH 09/12] Enhance HTTP and MCP session logging (#608) --- src/ModelContextProtocol.Core/McpSession.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 06b2894b0..da9542055 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -95,6 +95,7 @@ public McpSession( _requestHandlers = requestHandlers; _notificationHandlers = notificationHandlers; _logger = logger ?? NullLogger.Instance; + LogSessionCreated(EndpointName, _sessionId, _transportKind); } /// @@ -701,6 +702,7 @@ public void Dispose() } _pendingRequests.Clear(); + LogSessionDisposed(EndpointName, _sessionId, _transportKind); } #if !NET @@ -783,4 +785,10 @@ private static TimeSpan GetElapsed(long startingTimestamp) => [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} sending message. Message: '{Message}'.")] private partial void LogSendingMessageSensitive(string endpointName, string message); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} session {SessionId} created with transport {TransportKind}")] + private partial void LogSessionCreated(string endpointName, string sessionId, string transportKind); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} session {SessionId} disposed with transport {TransportKind}")] + private partial void LogSessionDisposed(string endpointName, string sessionId, string transportKind); } From 70960e1f1b1eb35295cf9831638d1a4fc986f70d Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 13 Aug 2025 13:05:42 -0400 Subject: [PATCH 10/12] Remove special-casing of string enumerables in McpServerTool (#699) We special-case string enumerables, translating them to an array of text content blocks, but other enumerables just get serialized, and there's a reasonable expectation that returning a string[] would produce a JSON array of strings. Just delete the special-casing. --- .../Server/AIFunctionMcpServerTool.cs | 6 ------ .../Configuration/McpServerBuilderExtensionsToolsTests.cs | 3 +-- .../ModelContextProtocol.Tests/Server/McpServerToolTests.cs | 5 ++--- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index afd3912b6..664ede5ab 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -270,12 +270,6 @@ public override async ValueTask InvokeAsync( StructuredContent = structuredContent, }, - IEnumerable texts => new() - { - Content = [.. texts.Select(x => new TextContentBlock { Text = x ?? string.Empty })], - StructuredContent = structuredContent, - }, - IEnumerable contentItems => ConvertAIContentEnumerableToCallToolResult(contentItems, structuredContent), IEnumerable contents => new() diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index d2080e1fc..35f833d50 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -254,8 +254,7 @@ public async Task Can_Call_Registered_Tool_With_Array_Result() Assert.NotNull(result.Content); Assert.NotEmpty(result.Content); - Assert.Equal("hello Peter", (result.Content[0] as TextContentBlock)?.Text); - Assert.Equal("hello2 Peter", (result.Content[1] as TextContentBlock)?.Text); + Assert.Equal("""["hello Peter","hello2 Peter"]""", (result.Content[0] as TextContentBlock)?.Text); result = await client.CallToolAsync( "SecondCustomTool", diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index bd0ca5ef9..f961eef34 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -356,9 +356,8 @@ public async Task CanReturnCollectionOfStrings() var result = await tool.InvokeAsync( new RequestContext(mockServer.Object), TestContext.Current.CancellationToken); - Assert.Equal(2, result.Content.Count); - Assert.Equal("42", Assert.IsType(result.Content[0]).Text); - Assert.Equal("43", Assert.IsType(result.Content[1]).Text); + Assert.Single(result.Content); + Assert.Equal("""["42","43"]""", Assert.IsType(result.Content[0]).Text); } [Fact] From b067261ca73f9cf9b7436a25b1b7faf7aea8ded2 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 18 Aug 2025 11:04:27 -0700 Subject: [PATCH 11/12] Prune idle sessions before starting new ones (#701) --- .../ProtectedMcpServer/Tools/HttpClientExt.cs | 13 - .../ProtectedMcpServer/Tools/WeatherTools.cs | 19 +- .../Tools/WeatherTools.cs | 6 +- .../HttpMcpServerBuilderExtensions.cs | 1 + .../HttpMcpSession.cs | 85 ------ .../HttpServerTransportOptions.cs | 4 +- .../IdleTrackingBackgroundService.cs | 111 +------- .../SseHandler.cs | 18 +- .../StatefulSessionManager.cs | 243 ++++++++++++++++++ .../StreamableHttpHandler.cs | 73 ++---- .../StreamableHttpSession.cs | 164 ++++++++++++ .../{Stateless => }/UserIdClaim.cs | 2 +- .../Client/SseClientSessionTransport.cs | 2 + .../StreamableHttpClientSessionTransport.cs | 2 + tests/Common/Utils/MockLoggerProvider.cs | 4 +- .../StreamableHttpServerConformanceTests.cs | 6 +- 16 files changed, 472 insertions(+), 281 deletions(-) delete mode 100644 samples/ProtectedMcpServer/Tools/HttpClientExt.cs delete mode 100644 src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs create mode 100644 src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs create mode 100644 src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs rename src/ModelContextProtocol.AspNetCore/{Stateless => }/UserIdClaim.cs (58%) diff --git a/samples/ProtectedMcpServer/Tools/HttpClientExt.cs b/samples/ProtectedMcpServer/Tools/HttpClientExt.cs deleted file mode 100644 index f7b2b5499..000000000 --- a/samples/ProtectedMcpServer/Tools/HttpClientExt.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text.Json; - -namespace ModelContextProtocol; - -internal static class HttpClientExt -{ - public static async Task ReadJsonDocumentAsync(this HttpClient client, string requestUri) - { - using var response = await client.GetAsync(requestUri); - response.EnsureSuccessStatusCode(); - return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); - } -} \ No newline at end of file diff --git a/samples/ProtectedMcpServer/Tools/WeatherTools.cs b/samples/ProtectedMcpServer/Tools/WeatherTools.cs index 477463c8d..94cc03892 100644 --- a/samples/ProtectedMcpServer/Tools/WeatherTools.cs +++ b/samples/ProtectedMcpServer/Tools/WeatherTools.cs @@ -21,9 +21,10 @@ public async Task GetAlerts( [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state) { var client = _httpClientFactory.CreateClient("WeatherApi"); - using var jsonDocument = await client.ReadJsonDocumentAsync($"/alerts/active/area/{state}"); - var jsonElement = jsonDocument.RootElement; - var alerts = jsonElement.GetProperty("features").EnumerateArray(); + using var jsonDocument = await client.GetFromJsonAsync($"/alerts/active/area/{state}") + ?? throw new McpException("No JSON returned from alerts endpoint"); + + var alerts = jsonDocument.RootElement.GetProperty("features").EnumerateArray(); if (!alerts.Any()) { @@ -50,12 +51,14 @@ public async Task GetForecast( { var client = _httpClientFactory.CreateClient("WeatherApi"); var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}"); - using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl); - var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString() - ?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}"); - using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl); - var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray(); + using var locationDocument = await client.GetFromJsonAsync(pointUrl); + var forecastUrl = locationDocument?.RootElement.GetProperty("properties").GetProperty("forecast").GetString() + ?? throw new McpException($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}"); + + using var forecastDocument = await client.GetFromJsonAsync(forecastUrl); + var periods = forecastDocument?.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray() + ?? throw new McpException("No JSON returned from forecast endpoint"); return string.Join("\n---\n", periods.Select(period => $""" {period.GetProperty("name").GetString()} diff --git a/samples/QuickstartWeatherServer/Tools/WeatherTools.cs b/samples/QuickstartWeatherServer/Tools/WeatherTools.cs index e02d4c327..61dc0a0ee 100644 --- a/samples/QuickstartWeatherServer/Tools/WeatherTools.cs +++ b/samples/QuickstartWeatherServer/Tools/WeatherTools.cs @@ -43,9 +43,9 @@ public static async Task GetForecast( [Description("Longitude of the location.")] double longitude) { var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}"); - using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl); - var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString() - ?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}"); + using var locationDocument = await client.ReadJsonDocumentAsync(pointUrl); + var forecastUrl = locationDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString() + ?? throw new McpException($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}"); using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl); var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray(); diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 0cdc4e37b..2d6b29fd9 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -23,6 +23,7 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder { ArgumentNullException.ThrowIfNull(builder); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.AddHostedService(); diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs deleted file mode 100644 index 1456ce565..000000000 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs +++ /dev/null @@ -1,85 +0,0 @@ -using ModelContextProtocol.AspNetCore.Stateless; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using System.Security.Claims; - -namespace ModelContextProtocol.AspNetCore; - -internal sealed class HttpMcpSession( - string sessionId, - TTransport transport, - UserIdClaim? userId, - TimeProvider timeProvider) : IAsyncDisposable - where TTransport : ITransport -{ - private int _referenceCount; - private int _getRequestStarted; - private CancellationTokenSource _disposeCts = new(); - - public string Id { get; } = sessionId; - public TTransport Transport { get; } = transport; - public UserIdClaim? UserIdClaim { get; } = userId; - - public CancellationToken SessionClosed => _disposeCts.Token; - - public bool IsActive => !SessionClosed.IsCancellationRequested && _referenceCount > 0; - public long LastActivityTicks { get; private set; } = timeProvider.GetTimestamp(); - - private TimeProvider TimeProvider => timeProvider; - - public IMcpServer? Server { get; set; } - public Task? ServerRunTask { get; set; } - - public IDisposable AcquireReference() - { - Interlocked.Increment(ref _referenceCount); - return new UnreferenceDisposable(this); - } - - public bool TryStartGetRequest() => Interlocked.Exchange(ref _getRequestStarted, 1) == 0; - - public async ValueTask DisposeAsync() - { - try - { - await _disposeCts.CancelAsync(); - - if (ServerRunTask is not null) - { - await ServerRunTask; - } - } - catch (OperationCanceledException) - { - } - finally - { - try - { - if (Server is not null) - { - await Server.DisposeAsync(); - } - } - finally - { - await Transport.DisposeAsync(); - _disposeCts.Dispose(); - } - } - } - - public bool HasSameUserId(ClaimsPrincipal user) - => UserIdClaim == StreamableHttpHandler.GetUserIdClaim(user); - - private sealed class UnreferenceDisposable(HttpMcpSession session) : IDisposable - { - public void Dispose() - { - if (Interlocked.Decrement(ref session._referenceCount) == 0) - { - session.LastActivityTicks = session.TimeProvider.GetTimestamp(); - } - } - } -} diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 2a34a17a1..94de9cb99 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -66,9 +66,9 @@ public class HttpServerTransportOptions /// Past this limit, the server will log a critical error and terminate the oldest idle sessions even if they have not reached /// their until the idle session count is below this limit. Clients that keep their session open by /// keeping a GET request open will not count towards this limit. - /// Defaults to 100,000 sessions. + /// Defaults to 10,000 sessions. /// - public int MaxIdleSessionCount { get; set; } = 100_000; + public int MaxIdleSessionCount { get; set; } = 10_000; /// /// Used for testing the . diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index c4a5f11ee..a4ae569ba 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -1,18 +1,16 @@ -using System.Runtime.InteropServices; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using ModelContextProtocol.Server; namespace ModelContextProtocol.AspNetCore; internal sealed partial class IdleTrackingBackgroundService( - StreamableHttpHandler handler, + StatefulSessionManager sessions, IOptions options, IHostApplicationLifetime appLifetime, ILogger logger) : BackgroundService { - // The compiler will complain about the parameter being unused otherwise despite the source generator. + // Workaround for https://github.com/dotnet/runtime/issues/91121. This is fixed in .NET 9 and later. private readonly ILogger _logger = logger; protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -30,65 +28,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var timeProvider = options.Value.TimeProvider; using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider); - var idleTimeoutTicks = options.Value.IdleTimeout.Ticks; - var maxIdleSessionCount = options.Value.MaxIdleSessionCount; - - // Create two lists that will be reused between runs. - // This assumes that the number of idle sessions is not breached frequently. - // If the idle sessions often breach the maximum, a priority queue could be considered. - var idleSessionsTimestamps = new List(); - var idleSessionSessionIds = new List(); - while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { - var idleActivityCutoff = idleTimeoutTicks switch - { - < 0 => long.MinValue, - var ticks => timeProvider.GetTimestamp() - ticks, - }; - - foreach (var (_, session) in handler.Sessions) - { - if (session.IsActive || session.SessionClosed.IsCancellationRequested) - { - // There's a request currently active or the session is already being closed. - continue; - } - - if (session.LastActivityTicks < idleActivityCutoff) - { - RemoveAndCloseSession(session.Id); - continue; - } - - // Add the timestamp and the session - idleSessionsTimestamps.Add(session.LastActivityTicks); - idleSessionSessionIds.Add(session.Id); - - // Emit critical log at most once every 5 seconds the idle count it exceeded, - // since the IdleTimeout will no longer be respected. - if (idleSessionsTimestamps.Count == maxIdleSessionCount + 1) - { - LogMaxSessionIdleCountExceeded(maxIdleSessionCount); - } - } - - if (idleSessionsTimestamps.Count > maxIdleSessionCount) - { - var timestamps = CollectionsMarshal.AsSpan(idleSessionsTimestamps); - - // Sort only if the maximum is breached and sort solely by the timestamp. Sort both collections. - timestamps.Sort(CollectionsMarshal.AsSpan(idleSessionSessionIds)); - - var sessionsToPrune = CollectionsMarshal.AsSpan(idleSessionSessionIds)[..^maxIdleSessionCount]; - foreach (var id in sessionsToPrune) - { - RemoveAndCloseSession(id); - } - } - - idleSessionsTimestamps.Clear(); - idleSessionSessionIds.Clear(); + await sessions.PruneIdleSessionsAsync(stoppingToken); } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -98,17 +40,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - List disposeSessionTasks = []; - - foreach (var (sessionKey, _) in handler.Sessions) - { - if (handler.Sessions.TryRemove(sessionKey, out var session)) - { - disposeSessionTasks.Add(DisposeSessionAsync(session)); - } - } - - await Task.WhenAll(disposeSessionTasks); + await sessions.DisposeAllSessionsAsync(); } finally { @@ -123,39 +55,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } - private void RemoveAndCloseSession(string sessionId) - { - if (!handler.Sessions.TryRemove(sessionId, out var session)) - { - return; - } - - LogSessionIdle(session.Id); - // Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown. - _ = DisposeSessionAsync(session); - } - - private async Task DisposeSessionAsync(HttpMcpSession session) - { - try - { - await session.DisposeAsync(); - } - catch (Exception ex) - { - LogSessionDisposeError(session.Id, ex); - } - } - - [LoggerMessage(Level = LogLevel.Information, Message = "Closing idle session {sessionId}.")] - private partial void LogSessionIdle(string sessionId); - - [LoggerMessage(Level = LogLevel.Error, Message = "Error disposing session {sessionId}.")] - private partial void LogSessionDisposeError(string sessionId, Exception ex); - - [LoggerMessage(Level = LogLevel.Critical, Message = "Exceeded maximum of {maxIdleSessionCount} idle sessions. Now closing sessions active more recently than configured IdleTimeout.")] - private partial void LogMaxSessionIdleCountExceeded(int maxIdleSessionCount); - [LoggerMessage(Level = LogLevel.Critical, Message = "The IdleTrackingBackgroundService has stopped unexpectedly.")] private partial void IdleTrackingBackgroundServiceStoppedUnexpectedly(); } \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/SseHandler.cs b/src/ModelContextProtocol.AspNetCore/SseHandler.cs index c5ac5a948..6ed72fb64 100644 --- a/src/ModelContextProtocol.AspNetCore/SseHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/SseHandler.cs @@ -16,7 +16,7 @@ internal sealed class SseHandler( IHostApplicationLifetime hostApplicationLifetime, ILoggerFactory loggerFactory) { - private readonly ConcurrentDictionary> _sessions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); public async Task HandleSseRequestAsync(HttpContext context) { @@ -34,9 +34,9 @@ public async Task HandleSseRequestAsync(HttpContext context) await using var transport = new SseResponseStreamTransport(context.Response.Body, $"{endpointPattern}message?sessionId={sessionId}", sessionId); var userIdClaim = StreamableHttpHandler.GetUserIdClaim(context.User); - await using var httpMcpSession = new HttpMcpSession(sessionId, transport, userIdClaim, httpMcpServerOptions.Value.TimeProvider); + var sseSession = new SseSession(transport, userIdClaim); - if (!_sessions.TryAdd(sessionId, httpMcpSession)) + if (!_sessions.TryAdd(sessionId, sseSession)) { throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created."); } @@ -55,12 +55,10 @@ public async Task HandleSseRequestAsync(HttpContext context) try { await using var mcpServer = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, context.RequestServices); - httpMcpSession.Server = mcpServer; context.Features.Set(mcpServer); var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? StreamableHttpHandler.RunSessionAsync; - httpMcpSession.ServerRunTask = runSessionAsync(context, mcpServer, cancellationToken); - await httpMcpSession.ServerRunTask; + await runSessionAsync(context, mcpServer, cancellationToken); } finally { @@ -87,13 +85,13 @@ public async Task HandleMessageRequestAsync(HttpContext context) return; } - if (!_sessions.TryGetValue(sessionId.ToString(), out var httpMcpSession)) + if (!_sessions.TryGetValue(sessionId.ToString(), out var sseSession)) { await Results.BadRequest($"Session ID not found.").ExecuteAsync(context); return; } - if (!httpMcpSession.HasSameUserId(context.User)) + if (sseSession.UserId != StreamableHttpHandler.GetUserIdClaim(context.User)) { await Results.Forbid().ExecuteAsync(context); return; @@ -106,8 +104,10 @@ public async Task HandleMessageRequestAsync(HttpContext context) return; } - await httpMcpSession.Transport.OnMessageReceivedAsync(message, context.RequestAborted); + await sseSession.Transport.OnMessageReceivedAsync(message, context.RequestAborted); context.Response.StatusCode = StatusCodes.Status202Accepted; await context.Response.WriteAsync("Accepted"); } + + private record SseSession(SseResponseStreamTransport Transport, UserIdClaim? UserId); } diff --git a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs new file mode 100644 index 000000000..960488af7 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs @@ -0,0 +1,243 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ModelContextProtocol.AspNetCore; + +internal sealed partial class StatefulSessionManager( + IOptions httpServerTransportOptions, + ILogger logger) +{ + // Workaround for https://github.com/dotnet/runtime/issues/91121. This is fixed in .NET 9 and later. + private readonly ILogger _logger = logger; + + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + + private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider; + private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout; + private readonly long _idleTimeoutTicks = httpServerTransportOptions.Value.IdleTimeout.Ticks; + private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount; + + private readonly object _idlePruningLock = new(); + private readonly List _idleTimestamps = []; + private readonly List _idleSessionIds = []; + private int _nextIndexToPrune; + + private long _currentIdleSessionCount; + + public TimeProvider TimeProvider => _timeProvider; + + public void IncrementIdleSessionCount() => Interlocked.Increment(ref _currentIdleSessionCount); + public void DecrementIdleSessionCount() => Interlocked.Decrement(ref _currentIdleSessionCount); + + public bool TryGetValue(string key, [NotNullWhen(true)] out StreamableHttpSession? value) => _sessions.TryGetValue(key, out value); + public bool TryRemove(string key, [NotNullWhen(true)] out StreamableHttpSession? value) => _sessions.TryRemove(key, out value); + + public async ValueTask StartNewSessionAsync(StreamableHttpSession newSession, CancellationToken cancellationToken) + { + while (!TryAddSessionImmediately(newSession)) + { + StreamableHttpSession? sessionToPrune = null; + + lock (_idlePruningLock) + { + EnsureIdleSessionsSortedUnsynchronized(); + + while (_nextIndexToPrune < _idleSessionIds.Count) + { + var pruneId = _idleSessionIds[_nextIndexToPrune++]; + if (_sessions.TryRemove(pruneId, out sessionToPrune)) + { + LogIdleSessionLimit(pruneId, _maxIdleSessionCount); + break; + } + } + + if (sessionToPrune is null) + { + // If we couldn't find any active idle sessions to dispose, start another full prune to repopulate _idleSessionIds. + PruneIdleSessionsUnsynchronized(); + + if (_idleSessionIds.Count > 0) + { + continue; + } + else + { + // This indicates all idle sessions are in the process of being disposed which should not happen during normal operation. + // Since there are no idle sessions to prune right now, log a critical error and create the new session anyway. + LogTooManyIdleSessionsClosingConcurrently(newSession.Id, _maxIdleSessionCount, Volatile.Read(ref _currentIdleSessionCount)); + AddSession(newSession); + return; + } + } + } + + try + { + // Since we're at or above the maximum idle session count, we're intentionally waiting for the idle session to be disposed + // before adding a new session to the dictionary to ensure sessions not created faster than they're removed. + await DisposeSessionAsync(sessionToPrune); + + // Take one last chance to check if the initialize request was aborted before we incur the cost of managing a new session. + cancellationToken.ThrowIfCancellationRequested(); + AddSession(newSession); + return; + } + catch + { + await newSession.DisposeAsync(); + throw; + } + } + } + + /// + /// Performs a single pass of idle session pruning, removing sessions that exceed the idle timeout + /// or when the maximum idle session count is exceeded. + /// + public async Task PruneIdleSessionsAsync(CancellationToken cancellationToken) + { + lock (_idlePruningLock) + { + PruneIdleSessionsUnsynchronized(); + } + } + + private void PruneIdleSessionsUnsynchronized() + { + var idleActivityCutoff = _idleTimeoutTicks switch + { + < 0 => long.MinValue, + var ticks => _timeProvider.GetTimestamp() - ticks, + }; + + // We clear the lists at the start of pruning rather than the end so we can use them between runs + // to find the most idle sessions to remove one-at-a-time if necessary to make room for new sessions. + _idleTimestamps.Clear(); + _idleSessionIds.Clear(); + _nextIndexToPrune = -1; + + foreach (var (_, session) in _sessions) + { + if (session.IsActive || session.SessionClosed.IsCancellationRequested) + { + // There's a request currently active or the session is already being closed. + continue; + } + + if (session.LastActivityTicks < idleActivityCutoff) + { + LogIdleSessionTimeout(session.Id, _idleTimeout); + RemoveAndCloseSession(session.Id); + continue; + } + + // Add the timestamp and the session + _idleTimestamps.Add(session.LastActivityTicks); + _idleSessionIds.Add(session.Id); + } + + if (_idleTimestamps.Count > _maxIdleSessionCount) + { + // Sort only if the maximum is breached and sort solely by the timestamp. + EnsureIdleSessionsSortedUnsynchronized(); + + var sessionsToPrune = CollectionsMarshal.AsSpan(_idleSessionIds)[..^_maxIdleSessionCount]; + foreach (var id in sessionsToPrune) + { + LogIdleSessionLimit(id, _maxIdleSessionCount); + RemoveAndCloseSession(id); + } + _nextIndexToPrune = _maxIdleSessionCount; + } + } + + private void EnsureIdleSessionsSortedUnsynchronized() + { + if (_nextIndexToPrune > -1) + { + // Already sorted. + return; + } + + var timestamps = CollectionsMarshal.AsSpan(_idleTimestamps); + timestamps.Sort(CollectionsMarshal.AsSpan(_idleSessionIds)); + _nextIndexToPrune = 0; + } + + /// + /// Disposes all sessions in the manager, typically called during graceful shutdown. + /// + public async Task DisposeAllSessionsAsync() + { + List disposeSessionTasks = []; + + foreach (var (sessionKey, _) in _sessions) + { + if (_sessions.TryRemove(sessionKey, out var session)) + { + disposeSessionTasks.Add(DisposeSessionAsync(session)); + } + } + + await Task.WhenAll(disposeSessionTasks); + } + + private bool TryAddSessionImmediately(StreamableHttpSession session) + { + if (Volatile.Read(ref _currentIdleSessionCount) < _maxIdleSessionCount) + { + AddSession(session); + return true; + } + + return false; + } + + private void AddSession(StreamableHttpSession session) + { + if (!_sessions.TryAdd(session.Id, session)) + { + throw new UnreachableException($"Unreachable given good entropy! Session with ID '{session.Id}' has already been created."); + } + } + + private void RemoveAndCloseSession(string sessionId) + { + if (!_sessions.TryRemove(sessionId, out var session)) + { + return; + } + + // Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown. + _ = DisposeSessionAsync(session); + } + + private async Task DisposeSessionAsync(StreamableHttpSession session) + { + try + { + await session.DisposeAsync(); + } + catch (Exception ex) + { + LogSessionDisposeError(session.Id, ex); + } + } + + [LoggerMessage(Level = LogLevel.Information, Message = "IdleTimeout of {IdleTimeout} exceeded. Closing idle session {SessionId}.")] + private partial void LogIdleSessionTimeout(string sessionId, TimeSpan idleTimeout); + + [LoggerMessage(Level = LogLevel.Information, Message = "MaxIdleSessionCount of {MaxIdleSessionCount} exceeded. Closing idle session {SessionId} despite it being active more recently than the configured IdleTimeout to make room for new sessions.")] + private partial void LogIdleSessionLimit(string sessionId, int maxIdleSessionCount); + + [LoggerMessage(Level = LogLevel.Error, Message = "Error disposing session {SessionId}.")] + private partial void LogSessionDisposeError(string sessionId, Exception ex); + + [LoggerMessage(Level = LogLevel.Critical, Message = "MaxIdleSessionCount of {MaxIdleSessionCount} exceeded, and {CurrentIdleSessionCount} sessions are currently in the process of closing. Creating new session {SessionId} anyway.")] + private partial void LogTooManyIdleSessionsClosingConcurrently(string sessionId, int maxIdleSessionCount, long currentIdleSessionCount); +} diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 6dac1c3e4..bfbd805de 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -8,8 +8,6 @@ using ModelContextProtocol.AspNetCore.Stateless; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using System.Collections.Concurrent; -using System.Diagnostics; using System.IO.Pipelines; using System.Security.Claims; using System.Security.Cryptography; @@ -22,6 +20,7 @@ internal sealed class StreamableHttpHandler( IOptions mcpServerOptionsSnapshot, IOptionsFactory mcpServerOptionsFactory, IOptions httpServerTransportOptions, + StatefulSessionManager sessionManager, IDataProtectionProvider dataProtection, ILoggerFactory loggerFactory, IServiceProvider applicationServices) @@ -29,8 +28,6 @@ internal sealed class StreamableHttpHandler( private const string McpSessionIdHeaderName = "Mcp-Session-Id"; private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); - public ConcurrentDictionary> Sessions { get; } = new(StringComparer.Ordinal); - public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value; private IDataProtector Protector { get; } = dataProtection.CreateProtector("Microsoft.AspNetCore.StreamableHttpHandler.StatelessSessionId"); @@ -56,28 +53,15 @@ await WriteJsonRpcErrorAsync(context, return; } - try - { - using var _ = session.AcquireReference(); + await using var _ = await session.AcquireReferenceAsync(context.RequestAborted); - InitializeSseResponse(context); - var wroteResponse = await session.Transport.HandlePostRequest(new HttpDuplexPipe(context), context.RequestAborted); - if (!wroteResponse) - { - // We wound up writing nothing, so there should be no Content-Type response header. - context.Response.Headers.ContentType = (string?)null; - context.Response.StatusCode = StatusCodes.Status202Accepted; - } - } - finally + InitializeSseResponse(context); + var wroteResponse = await session.Transport.HandlePostRequest(new HttpDuplexPipe(context), context.RequestAborted); + if (!wroteResponse) { - // Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the Mcp-Session-Id. - // Non-stateless sessions are 1:1 with the Mcp-Session-Id and outlive the POST request. - // Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService. - if (HttpServerTransportOptions.Stateless) - { - await session.DisposeAsync(); - } + // We wound up writing nothing, so there should be no Content-Type response header. + context.Response.Headers.ContentType = (string?)null; + context.Response.StatusCode = StatusCodes.Status202Accepted; } } @@ -106,7 +90,7 @@ await WriteJsonRpcErrorAsync(context, return; } - using var _ = session.AcquireReference(); + await using var _ = await session.AcquireReferenceAsync(context.RequestAborted); InitializeSseResponse(context); // We should flush headers to indicate a 200 success quickly, because the initialization response @@ -119,17 +103,22 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleDeleteRequestAsync(HttpContext context) { var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - if (Sessions.TryRemove(sessionId, out var session)) + if (sessionManager.TryRemove(sessionId, out var session)) { await session.DisposeAsync(); } } - private async ValueTask?> GetSessionAsync(HttpContext context, string sessionId) + private async ValueTask GetSessionAsync(HttpContext context, string sessionId) { - HttpMcpSession? session; + StreamableHttpSession? session; - if (HttpServerTransportOptions.Stateless) + if (string.IsNullOrEmpty(sessionId)) + { + 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); @@ -140,7 +129,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context) }; session = await CreateSessionAsync(context, transport, sessionId, statelessSessionId); } - else if (!Sessions.TryGetValue(sessionId, out session)) + else if (!sessionManager.TryGetValue(sessionId, out session)) { // -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does. // One of the few other usages I found was from some Ethereum JSON-RPC documentation and this @@ -163,7 +152,7 @@ await WriteJsonRpcErrorAsync(context, return session; } - private async ValueTask?> GetOrCreateSessionAsync(HttpContext context) + private async ValueTask GetOrCreateSessionAsync(HttpContext context) { var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); @@ -177,7 +166,7 @@ await WriteJsonRpcErrorAsync(context, } } - private async ValueTask> StartNewSessionAsync(HttpContext context) + private async ValueTask StartNewSessionAsync(HttpContext context) { string sessionId; StreamableHttpServerTransport transport; @@ -204,21 +193,10 @@ private async ValueTask> StartNewS ScheduleStatelessSessionIdWrite(context, transport); } - var session = await CreateSessionAsync(context, transport, sessionId); - - // The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the MCP-Session-Id. - if (!HttpServerTransportOptions.Stateless) - { - if (!Sessions.TryAdd(sessionId, session)) - { - throw new UnreachableException($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created."); - } - } - - return session; + return await CreateSessionAsync(context, transport, sessionId); } - private async ValueTask> CreateSessionAsync( + private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, string sessionId, @@ -248,10 +226,7 @@ private async ValueTask> CreateSes context.Features.Set(server); var userIdClaim = statelessId?.UserIdClaim ?? GetUserIdClaim(context.User); - var session = new HttpMcpSession(sessionId, transport, userIdClaim, HttpServerTransportOptions.TimeProvider) - { - Server = server, - }; + var session = new StreamableHttpSession(sessionId, transport, server, userIdClaim, sessionManager); var runSessionAsync = HttpServerTransportOptions.RunSessionHandler ?? RunSessionAsync; session.ServerRunTask = runSessionAsync(context, server, session.SessionClosed); diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs new file mode 100644 index 000000000..ffeafada7 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs @@ -0,0 +1,164 @@ +using ModelContextProtocol.Server; +using System.Diagnostics; +using System.Security.Claims; + +namespace ModelContextProtocol.AspNetCore; + +internal sealed class StreamableHttpSession( + string sessionId, + StreamableHttpServerTransport transport, + IMcpServer server, + UserIdClaim? userId, + StatefulSessionManager sessionManager) : IAsyncDisposable +{ + private int _referenceCount; + private SessionState _state; + private readonly object _stateLock = new(); + + private int _getRequestStarted; + private readonly CancellationTokenSource _disposeCts = new(); + + public string Id => sessionId; + public StreamableHttpServerTransport Transport => transport; + public IMcpServer Server => server; + private StatefulSessionManager SessionManager => sessionManager; + + public CancellationToken SessionClosed => _disposeCts.Token; + public bool IsActive => !SessionClosed.IsCancellationRequested && _referenceCount > 0; + public long LastActivityTicks { get; private set; } = sessionManager.TimeProvider.GetTimestamp(); + + public Task ServerRunTask { get; set; } = Task.CompletedTask; + + public async ValueTask AcquireReferenceAsync(CancellationToken cancellationToken) + { + // The StreamableHttpSession is not stored between requests in stateless mode. Instead, the session is recreated from the MCP-Session-Id. + // Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the Mcp-Session-Id. + // Non-stateless sessions are 1:1 with the Mcp-Session-Id and outlive the POST request. + // Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService. + if (transport.Stateless) + { + return this; + } + + SessionState startingState; + + lock (_stateLock) + { + startingState = _state; + _referenceCount++; + + switch (startingState) + { + case SessionState.Uninitialized: + Debug.Assert(_referenceCount == 1, "The _referenceCount should start at 1 when the StreamableHttpSession is uninitialized."); + _state = SessionState.Started; + break; + case SessionState.Started: + if (_referenceCount == 1) + { + sessionManager.DecrementIdleSessionCount(); + } + break; + case SessionState.Disposed: + throw new ObjectDisposedException(nameof(StreamableHttpSession)); + } + } + + if (startingState == SessionState.Uninitialized) + { + await sessionManager.StartNewSessionAsync(this, cancellationToken); + } + + return new UnreferenceDisposable(this); + } + + public bool TryStartGetRequest() => Interlocked.Exchange(ref _getRequestStarted, 1) == 0; + public bool HasSameUserId(ClaimsPrincipal user) => userId == StreamableHttpHandler.GetUserIdClaim(user); + + public async ValueTask DisposeAsync() + { + var wasIdle = false; + + lock (_stateLock) + { + switch (_state) + { + case SessionState.Uninitialized: + break; + case SessionState.Started: + if (_referenceCount == 0) + { + wasIdle = true; + } + break; + case SessionState.Disposed: + return; + } + + _state = SessionState.Disposed; + } + + try + { + await _disposeCts.CancelAsync(); + + try + { + await ServerRunTask; + } + finally + { + await DisposeServerThenTransportAsync(); + } + } + catch (OperationCanceledException) + { + } + finally + { + if (wasIdle) + { + sessionManager.DecrementIdleSessionCount(); + } + _disposeCts.Dispose(); + } + } + + private async ValueTask DisposeServerThenTransportAsync() + { + try + { + await server.DisposeAsync(); + } + finally + { + await transport.DisposeAsync(); + } + } + + private sealed class UnreferenceDisposable(StreamableHttpSession session) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + lock (session._stateLock) + { + Debug.Assert(session._state != SessionState.Uninitialized, "The session should have been initialized."); + if (session._state != SessionState.Disposed && --session._referenceCount == 0) + { + var sessionManager = session.SessionManager; + session.LastActivityTicks = sessionManager.TimeProvider.GetTimestamp(); + sessionManager.IncrementIdleSessionCount(); + } + } + + return default; + } + } + + private enum SessionState + { + Uninitialized, + Started, + Disposed + } +} diff --git a/src/ModelContextProtocol.AspNetCore/Stateless/UserIdClaim.cs b/src/ModelContextProtocol.AspNetCore/UserIdClaim.cs similarity index 58% rename from src/ModelContextProtocol.AspNetCore/Stateless/UserIdClaim.cs rename to src/ModelContextProtocol.AspNetCore/UserIdClaim.cs index f18c1c5ff..5b5951d3d 100644 --- a/src/ModelContextProtocol.AspNetCore/Stateless/UserIdClaim.cs +++ b/src/ModelContextProtocol.AspNetCore/UserIdClaim.cs @@ -1,3 +1,3 @@ -namespace ModelContextProtocol.AspNetCore.Stateless; +namespace ModelContextProtocol.AspNetCore; internal sealed record UserIdClaim(string Type, string Value, string Issuer); diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index aba7bbcfb..479a76279 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -193,6 +193,8 @@ private async Task ProcessSseMessage(string data, CancellationToken cancellation return; } + LogTransportReceivedMessageSensitive(Name, data); + try { var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 190bec0b2..c4014ed71 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -211,6 +211,8 @@ private async Task ReceiveUnsolicitedMessagesAsync() private async Task ProcessMessageAsync(string data, JsonRpcRequest? relatedRpcRequest, CancellationToken cancellationToken) { + LogTransportReceivedMessageSensitive(Name, data); + try { var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); diff --git a/tests/Common/Utils/MockLoggerProvider.cs b/tests/Common/Utils/MockLoggerProvider.cs index f5264edc4..14a0f401a 100644 --- a/tests/Common/Utils/MockLoggerProvider.cs +++ b/tests/Common/Utils/MockLoggerProvider.cs @@ -5,7 +5,7 @@ namespace ModelContextProtocol.Tests.Utils; public class MockLoggerProvider() : ILoggerProvider { - public ConcurrentQueue<(string Category, LogLevel LogLevel, string Message, Exception? Exception)> LogMessages { get; } = []; + public ConcurrentQueue<(string Category, LogLevel LogLevel, EventId EventId, string Message, Exception? Exception)> LogMessages { get; } = []; public ILogger CreateLogger(string categoryName) { @@ -21,7 +21,7 @@ private class MockLogger(MockLoggerProvider mockProvider, string category) : ILo public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - mockProvider.LogMessages.Enqueue((category, logLevel, formatter(state, exception), exception)); + mockProvider.LogMessages.Enqueue((category, logLevel, eventId, formatter(state, exception), exception)); } public bool IsEnabled(LogLevel logLevel) => true; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 0b3ae4c2a..bb184034c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -505,7 +505,6 @@ public async Task IdleSessionsPastMaxIdleSessionCount_ArePruned_LongestIdleFirst Assert.NotEqual(secondSessionId, thirdSessionId); // Pruning of the second session results in a 404 since we used the first session more recently. - fakeTimeProvider.Advance(TimeSpan.FromSeconds(10)); SetSessionId(secondSessionId); using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -517,8 +516,9 @@ public async Task IdleSessionsPastMaxIdleSessionCount_ArePruned_LongestIdleFirst SetSessionId(thirdSessionId); await CallEchoAndValidateAsync(); - var logMessage = Assert.Single(mockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Critical); - Assert.StartsWith("Exceeded maximum of 2 idle sessions.", logMessage.Message); + var idleLimitLogMessage = Assert.Single(mockLoggerProvider.LogMessages, m => m.EventId.Name == "LogIdleSessionLimit"); + Assert.Equal(LogLevel.Information, idleLimitLogMessage.LogLevel); + Assert.StartsWith("MaxIdleSessionCount of 2 exceeded. Closing idle session", idleLimitLogMessage.Message); } private static StringContent JsonContent(string json) => new StringContent(json, Encoding.UTF8, "application/json"); From 15f8e89ac3aedb2bb4fa9232f6093f092b2ce956 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Mon, 18 Aug 2025 22:06:52 -0700 Subject: [PATCH 12/12] Add framework for conceptual docs (#708) Add framework for conceptual docs plus article on elicitation Co-authored-by: Stephen Halter --- .github/workflows/markdown-link-check.yml | 4 +- docs/concepts/elicitation/elicitation.md | 51 +++++++ .../samples/client/ElicitationClient.csproj | 14 ++ .../elicitation/samples/client/Program.cs | 121 +++++++++++++++++ .../samples/server/Elicitation.csproj | 13 ++ .../samples/server/Elicitation.http | 58 ++++++++ .../elicitation/samples/server/Program.cs | 24 ++++ .../server/Properties/launchSettings.json | 21 +++ .../samples/server/Tools/InteractiveTools.cs | 126 ++++++++++++++++++ docs/concepts/index.md | 2 + docs/concepts/toc.yml | 5 + docs/docfx.json | 1 + docs/toc.yml | 4 +- 13 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 docs/concepts/elicitation/elicitation.md create mode 100644 docs/concepts/elicitation/samples/client/ElicitationClient.csproj create mode 100644 docs/concepts/elicitation/samples/client/Program.cs create mode 100644 docs/concepts/elicitation/samples/server/Elicitation.csproj create mode 100644 docs/concepts/elicitation/samples/server/Elicitation.http create mode 100644 docs/concepts/elicitation/samples/server/Program.cs create mode 100644 docs/concepts/elicitation/samples/server/Properties/launchSettings.json create mode 100644 docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs create mode 100644 docs/concepts/index.md create mode 100644 docs/concepts/toc.yml diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index 6a49bec6a..b69bbc440 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -21,5 +21,5 @@ jobs: - name: Markup Link Checker (mlc) uses: becheran/mlc@c925f90a9a25e16e4c4bfa29058f6f9ffa9f0d8c # v0.21.0 with: - # Ignore external links that result in 403 errors during CI. Do not warn for redirects where we want to keep the vanity URL in the markdown or for GitHub links that redirect to the login. - args: --ignore-links "https://www.anthropic.com/*,https://hackerone.com/anthropic-vdp/*" --do-not-warn-for-redirect-to "https://modelcontextprotocol.io/*,https://github.com/login?*" ./ + # Ignore external links that result in 403 errors during CI. Do not warn for redirects where we want to keep the vanity URL in the markdown or for GitHub links that redirect to the login, and DocFX snippet links. + args: --ignore-links "https://www.anthropic.com/*,https://hackerone.com/anthropic-vdp/*" --do-not-warn-for-redirect-to "https://modelcontextprotocol.io/*,https://github.com/login?*" --ignore-links "*samples/*?name=snippet_*" ./docs diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md new file mode 100644 index 000000000..4f0b00cff --- /dev/null +++ b/docs/concepts/elicitation/elicitation.md @@ -0,0 +1,51 @@ +--- +title: Elicitation +author: mikekistler +description: Learn about the telemetry collected by the HttpRepl. +uid: elicitation +--- + +The **elicitation** feature allows servers to request additional information from users during interactions. This enables more dynamic and interactive AI experiences, making it easier to gather necessary context before executing tasks. + +## Server Support for Elicitation + +Servers request structured data from users with the [ElicitAsync] extension method on [IMcpServer]. +The C# SDK registers an instance of [IMcpServer] with the dependency injection container, +so tools can simply add a parameter of type [IMcpServer] to their method signature to access it. + +[ElicitAsync]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Server.McpServerExtensions.html#ModelContextProtocol_Server_McpServerExtensions_ElicitAsync_ModelContextProtocol_Server_IMcpServer_ModelContextProtocol_Protocol_ElicitRequestParams_System_Threading_CancellationToken_ +[IMcpServer]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Server.IMcpServer.html + +The MCP Server must specify the schema of each input value it is requesting from the user. +Only primitive types (string, number, boolean) are supported for elicitation requests. +The schema may include a description to help the user understand what is being requested. + +The server can request a single input or multiple inputs at once. +To help distinguish multiple inputs, each input has a unique name. + +The following example demonstrates how a server could request a boolean response from the user. + +[!code-csharp[](samples/server/Tools/InteractiveTools.cs?name=snippet_GuessTheNumber)] + +## Client Support for Elicitation + +Elicitation is an optional feature so clients declare their support for it in their capabilities as part of the `initialize` request. In the MCP C# SDK, this is done by configuring an [ElicitationHandler] in the [McpClientOptions]: + +[ElicitationHandler]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Protocol.ElicitationCapability.html#ModelContextProtocol_Protocol_ElicitationCapability_ElicitationHandler +[McpClientOptions]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Client.McpClientOptions.html + +[!code-csharp[](samples/client/Program.cs?name=snippet_McpInitialize)] + +The ElicitationHandler is an asynchronous method that will be called when the server requests additional information. +The ElicitationHandler must request input from the user and return the data in a format that matches the requested schema. +This will be highly dependent on the client application and how it interacts with the user. + +If the user provides the requested information, the ElicitationHandler should return an [ElicitResult] with the action set to "accept" and the content containing the user's input. +If the user does not provide the requested information, the ElicitationHandler should return an [ElicitResult] with the action set to "reject" and no content. + +[ElicitResult]: https://modelcontextprotocol.github.io/csharp-sdk/api/ModelContextProtocol.Protocol.ElicitResult.html + +Below is an example of how a console application might handle elicitation requests. +Here's an example implementation: + +[!code-csharp[](samples/client/Program.cs?name=snippet_ElicitationHandler)] diff --git a/docs/concepts/elicitation/samples/client/ElicitationClient.csproj b/docs/concepts/elicitation/samples/client/ElicitationClient.csproj new file mode 100644 index 000000000..e8e10376e --- /dev/null +++ b/docs/concepts/elicitation/samples/client/ElicitationClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/docs/concepts/elicitation/samples/client/Program.cs b/docs/concepts/elicitation/samples/client/Program.cs new file mode 100644 index 000000000..5405a61ba --- /dev/null +++ b/docs/concepts/elicitation/samples/client/Program.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +var endpoint = Environment.GetEnvironmentVariable("ENDPOINT") ?? "http://localhost:3001/"; + +var clientTransport = new SseClientTransport(new() +{ + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.StreamableHttp, +}); + +// +McpClientOptions options = new() +{ + ClientInfo = new() + { + Name = "ElicitationClient", + Version = "1.0.0" + }, + Capabilities = new() + { + Elicitation = new() + { + ElicitationHandler = HandleElicitationAsync + } + } +}; + +await using var mcpClient = await McpClientFactory.CreateAsync(clientTransport, options); +// + +var tools = await mcpClient.ListToolsAsync(); +foreach (var tool in tools) +{ + Console.WriteLine($"Connected to server with tools: {tool.Name}"); +} + +Console.WriteLine($"Calling tool: {tools.First().Name}"); + +var result = await mcpClient.CallToolAsync(toolName: tools.First().Name); + +foreach (var block in result.Content) +{ + if (block is TextContentBlock textBlock) + { + Console.WriteLine(textBlock.Text); + } + else + { + Console.WriteLine($"Received unexpected result content of type {block.GetType()}"); + } +} + +// +async ValueTask HandleElicitationAsync(ElicitRequestParams? requestParams, CancellationToken token) +{ + // Bail out if the requestParams is null or if the requested schema has no properties + if (requestParams?.RequestedSchema?.Properties == null) + { + return new ElicitResult(); + } + + // Process the elicitation request + if (requestParams?.Message is not null) + { + Console.WriteLine(requestParams.Message); + } + + var content = new Dictionary(); + + // Loop through requestParams.requestSchema.Properties dictionary requesting values for each property + foreach (var property in requestParams.RequestedSchema.Properties) + { + if (property.Value is ElicitRequestParams.BooleanSchema booleanSchema) + { + Console.Write($"{booleanSchema.Description}: "); + var clientInput = Console.ReadLine(); + bool parsedBool; + + // Try standard boolean parsing first + if (bool.TryParse(clientInput, out parsedBool)) + { + content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(parsedBool)); + } + // Also accept "yes"/"no" as valid boolean inputs + else if (string.Equals(clientInput?.Trim(), "yes", StringComparison.OrdinalIgnoreCase)) + { + content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(true)); + } + else if (string.Equals(clientInput?.Trim(), "no", StringComparison.OrdinalIgnoreCase)) + { + content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(false)); + } + } + else if (property.Value is ElicitRequestParams.NumberSchema numberSchema) + { + Console.Write($"{numberSchema.Description}: "); + var clientInput = Console.ReadLine(); + double parsedNumber; + if (double.TryParse(clientInput, out parsedNumber)) + { + content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(parsedNumber)); + } + } + else if (property.Value is ElicitRequestParams.StringSchema stringSchema) + { + Console.Write($"{stringSchema.Description}: "); + var clientInput = Console.ReadLine(); + content[property.Key] = JsonSerializer.Deserialize(JsonSerializer.Serialize(clientInput)); + } + } + + // Return the user's input + return new ElicitResult + { + Action = "accept", + Content = content + }; +} +// diff --git a/docs/concepts/elicitation/samples/server/Elicitation.csproj b/docs/concepts/elicitation/samples/server/Elicitation.csproj new file mode 100644 index 000000000..a27101aa0 --- /dev/null +++ b/docs/concepts/elicitation/samples/server/Elicitation.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/docs/concepts/elicitation/samples/server/Elicitation.http b/docs/concepts/elicitation/samples/server/Elicitation.http new file mode 100644 index 000000000..04dcdb343 --- /dev/null +++ b/docs/concepts/elicitation/samples/server/Elicitation.http @@ -0,0 +1,58 @@ +@HostAddress = http://localhost:3001 + +# No session ID, so elicitation capabilities not declared. + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json +MCP-Protocol-Version: 2025-06-18 + +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "guess_the_number" + } +} + +### + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "RestClient", + "version": "0.1.0" + }, + "capabilities": { + "elicitation": {} + }, + "protocolVersion": "2025-06-18" + } +} + +### + +@SessionId = lgEu87uKTy8kLffZayO5rQ + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json +Mcp-Session-Id: {{SessionId}} +MCP-Protocol-Version: 2025-06-18 + +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "guess_the_number" + } +} diff --git a/docs/concepts/elicitation/samples/server/Program.cs b/docs/concepts/elicitation/samples/server/Program.cs new file mode 100644 index 000000000..8c6862464 --- /dev/null +++ b/docs/concepts/elicitation/samples/server/Program.cs @@ -0,0 +1,24 @@ +using Elicitation.Tools; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddMcpServer() + .WithHttpTransport(options => + options.IdleTimeout = Timeout.InfiniteTimeSpan // Never timeout + ) + .WithTools(); + +builder.Logging.AddConsole(options => +{ + options.LogToStandardErrorThreshold = LogLevel.Information; +}); + +var app = builder.Build(); + +app.UseHttpsRedirection(); + +app.MapMcp(); + +app.Run(); diff --git a/docs/concepts/elicitation/samples/server/Properties/launchSettings.json b/docs/concepts/elicitation/samples/server/Properties/launchSettings.json new file mode 100644 index 000000000..74cf457ef --- /dev/null +++ b/docs/concepts/elicitation/samples/server/Properties/launchSettings.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:3001/", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7133;http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + } + } + } +} \ No newline at end of file diff --git a/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs b/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs new file mode 100644 index 000000000..b6a75e005 --- /dev/null +++ b/docs/concepts/elicitation/samples/server/Tools/InteractiveTools.cs @@ -0,0 +1,126 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using static ModelContextProtocol.Protocol.ElicitRequestParams; + +namespace Elicitation.Tools; + +[McpServerToolType] +public sealed class InteractiveTools +{ + // + [McpServerTool, Description("A simple game where the user has to guess a number between 1 and 10.")] + public async Task GuessTheNumber( + IMcpServer server, // Get the McpServer from DI container + CancellationToken token + ) + { + // Check if the client supports elicitation + if (server.ClientCapabilities?.Elicitation == null) + { + // fail the tool call + throw new McpException("Client does not support elicitation"); + } + + // First ask the user if they want to play + var playSchema = new RequestSchema + { + Properties = + { + ["Answer"] = new BooleanSchema() + } + }; + + var playResponse = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Do you want to play a game?", + RequestedSchema = playSchema + }, token); + + // Check if user wants to play + if (playResponse.Action != "accept" || playResponse.Content?["Answer"].ValueKind != JsonValueKind.True) + { + return "Maybe next time!"; + } + // + + // Now ask the user to enter their name + var nameSchema = new RequestSchema + { + Properties = + { + ["Name"] = new StringSchema() + { + Description = "Name of the player", + MinLength = 2, + MaxLength = 50, + } + } + }; + + var nameResponse = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = nameSchema + }, token); + + if (nameResponse.Action != "accept") + { + return "Maybe next time!"; + } + string? playerName = nameResponse.Content?["Name"].GetString(); + + // Generate a random number between 1 and 10 + Random random = new Random(); + int targetNumber = random.Next(1, 11); // 1 to 10 inclusive + int attempts = 0; + + var message = "Guess a number between 1 and 10"; + + while (true) + { + attempts++; + + var guessSchema = new RequestSchema + { + Properties = + { + ["Guess"] = new NumberSchema() + { + Type = "integer", + Minimum = 1, + Maximum = 10, + } + } + }; + + var guessResponse = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = guessSchema + }, token); + + if (guessResponse.Action != "accept") + { + return "Maybe next time!"; + } + int guess = (int)(guessResponse.Content?["Guess"].GetInt32())!; + + // Check if the guess is correct + if (guess == targetNumber) + { + return $"Congratulations {playerName}! You guessed the number {targetNumber} in {attempts} attempts!"; + } + else if (guess < targetNumber) + { + message = $"Your guess is too low! Try again (Attempt #{attempts}):"; + } + else + { + message = $"Your guess is too high! Try again (Attempt #{attempts}):"; + } + } + } +} \ No newline at end of file diff --git a/docs/concepts/index.md b/docs/concepts/index.md new file mode 100644 index 000000000..e038c8996 --- /dev/null +++ b/docs/concepts/index.md @@ -0,0 +1,2 @@ + +Welcome to the conceptual documentation for the Model Context Protocol SDK. Here you'll find high-level overviews, explanations, and guides to help you understand how the SDK implements the Model Context Protocol. diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml new file mode 100644 index 000000000..46598cc61 --- /dev/null +++ b/docs/concepts/toc.yml @@ -0,0 +1,5 @@ +items: +- name: Overview + href: index.md +- name: Elicitation + uid: elicitation diff --git a/docs/docfx.json b/docs/docfx.json index 6b4feb833..fe8a18d95 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -42,6 +42,7 @@ "_appLogoPath": "images/mcp.svg", "_appFaviconPath": "images/favicon.ico", "_enableSearch": true, + "_disableNextArticle": true, "pdf": false } } diff --git a/docs/toc.yml b/docs/toc.yml index f63a01348..350a2ae3b 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,5 +1,7 @@ items: -- name: API Docs +- name: Documentation + href: concepts/index.md +- name: API Reference href: api/ModelContextProtocol.yml - name: Github href: https://github.com/ModelContextProtocol/csharp-sdk \ No newline at end of file