Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 792
Add server-side Streamable HTTP transport support#330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
cd33249963cf3fd875bde7dd167ed2ed83b14ed9255668986d72a3779baf883File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,61 @@ | ||
| using ModelContextProtocol.Protocol.Transport; | ||
| using ModelContextProtocol.Server; | ||
| using System.Security.Claims; | ||
| namespace ModelContextProtocol.AspNetCore; | ||
| internal class HttpMcpSession | ||
| internal sealed class HttpMcpSession<TTransport>(string sessionId, TTransport transport, ClaimsPrincipal user, TimeProvider timeProvider) : IAsyncDisposable | ||
| where TTransport : ITransport | ||
| { | ||
| public HttpMcpSession(SseResponseStreamTransport transport, ClaimsPrincipal user) | ||
| private int _referenceCount; | ||
| private int _getRequestStarted; | ||
| private CancellationTokenSource _disposeCts = new(); | ||
| public string Id { get; } = sessionId; | ||
| public TTransport Transport { get; } = transport; | ||
| public (string Type, string Value, string Issuer)? UserIdClaim { get; } = GetUserIdClaim(user); | ||
| public CancellationToken SessionClosed => _disposeCts.Token; | ||
halter73 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| public bool IsActive => !SessionClosed.IsCancellationRequested && _referenceCount > 0; | ||
| public long LastActivityTicks { get; private set; } = timeProvider.GetTimestamp(); | ||
| public IMcpServer? Server { get; set; } | ||
| public Task? ServerRunTask { get; set; } | ||
| public IDisposable AcquireReference() | ||
| { | ||
| Transport = transport; | ||
| UserIdClaim = GetUserIdClaim(user); | ||
| Interlocked.Increment(ref _referenceCount); | ||
halter73 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return new UnreferenceDisposable(this, timeProvider); | ||
| } | ||
| public SseResponseStreamTransport Transport { get; } | ||
| public (string Type, string Value, string Issuer)? UserIdClaim { get; } | ||
| 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 | ||
| { | ||
| if (Server is not null) | ||
| { | ||
| await Server.DisposeAsync(); | ||
| } | ||
| await Transport.DisposeAsync(); | ||
| _disposeCts.Dispose(); | ||
| } | ||
| } | ||
| public bool HasSameUserId(ClaimsPrincipal user) | ||
| => UserIdClaim == GetUserIdClaim(user); | ||
| @@ -36,4 +79,15 @@ private static (string Type, string Value, string Issuer)? GetUserIdClaim(Claims | ||
| return null; | ||
| } | ||
| private sealed class UnreferenceDisposable(HttpMcpSession<TTransport> session, TimeProvider timeProvider) : IDisposable | ||
| { | ||
| public void Dispose() | ||
| { | ||
| if (Interlocked.Decrement(ref session._referenceCount) == 0) | ||
halter73 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| { | ||
| session.LastActivityTicks = timeProvider.GetTimestamp(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -21,4 +21,17 @@ public class HttpServerTransportOptions | ||
| /// This is useful for running logic before a sessions starts and after it completes. | ||
| /// </summary> | ||
| public Func<HttpContext, IMcpServer, CancellationToken, Task>? RunSessionHandler { get; set; } | ||
| /// <summary> | ||
| /// Represents the duration of time the server will wait between any active requests before timing out an | ||
| /// MCP session. This is checked in background every 5 seconds. A client trying to resume a session will | ||
| /// receive a 404 status code and should restart their session. A client can keep their session open by | ||
| /// keeping a GET request open. The default value is set to 2 minutes. | ||
| /// </summary> | ||
| public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(2); | ||
| /// <summary> | ||
| /// Used for testing the <see cref="IdleTimeout"/>. | ||
| /// </summary> | ||
| public TimeProvider TimeProvider { get; set; } = TimeProvider.System; | ||
halter73 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| using Microsoft.Extensions.Hosting; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Options; | ||
| using ModelContextProtocol.Protocol.Transport; | ||
| namespace ModelContextProtocol.AspNetCore; | ||
| internal sealed partial class IdleTrackingBackgroundService( | ||
halter73 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| StreamableHttpHandler handler, | ||
| IOptions<HttpServerTransportOptions> options, | ||
| ILogger<IdleTrackingBackgroundService> logger) : BackgroundService | ||
| { | ||
| // The compiler will complain about the parameter being unused otherwise despite the source generator. | ||
halter73 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| private ILogger _logger = logger; | ||
| // We can make this configurable once we properly harden the MCP server. In the meantime, anyone running | ||
| // this should be taking a cattle not pets approach to their servers and be able to launch more processes | ||
| // to handle more than 10,000 idle sessions at a time. | ||
| private const int MaxIdleSessionCount = 10_000; | ||
| protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||
| { | ||
| var timeProvider = options.Value.TimeProvider; | ||
| using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider); | ||
| try | ||
| { | ||
| while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) | ||
| { | ||
| var idleActivityCutoff = timeProvider.GetTimestamp() - options.Value.IdleTimeout.Ticks; | ||
| var idleCount = 0; | ||
| 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; | ||
| } | ||
| idleCount++; | ||
| if (idleCount == MaxIdleSessionCount) | ||
| { | ||
| // Emit critical log at most once every 5 seconds the idle count it exceeded, | ||
| //since the IdleTimeout will no longer be respected. | ||
| LogMaxSessionIdleCountExceeded(); | ||
| } | ||
| else if (idleCount < MaxIdleSessionCount && session.LastActivityTicks > idleActivityCutoff) | ||
| { | ||
| continue; | ||
| } | ||
| if (handler.Sessions.TryRemove(session.Id, out var removedSession)) | ||
| { | ||
| LogSessionIdle(removedSession.Id); | ||
| // Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown. | ||
| _ = DisposeSessionAsync(removedSession); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) | ||
| { | ||
| } | ||
| finally | ||
| { | ||
| if (stoppingToken.IsCancellationRequested) | ||
| { | ||
| List<Task> disposeSessionTasks = []; | ||
| foreach (var (sessionKey, _) in handler.Sessions) | ||
| { | ||
| if (handler.Sessions.TryRemove(sessionKey, out var session)) | ||
| { | ||
| disposeSessionTasks.Add(DisposeSessionAsync(session)); | ||
| } | ||
| } | ||
| await Task.WhenAll(disposeSessionTasks); | ||
| } | ||
| } | ||
| } | ||
| private async Task DisposeSessionAsync(HttpMcpSession<StreamableHttpServerTransport> 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.Critical, Message = "Exceeded static maximum of 10,000 idle connections. Now clearing all inactive connections regardless of timeout.")] | ||
| private partial void LogMaxSessionIdleCountExceeded(); | ||
| [LoggerMessage(Level = LogLevel.Error, Message = "Error disposing the IMcpServer for session {sessionId}.")] | ||
| private partial void LogSessionDisposeError(string sessionId, Exception ex); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.