Skip to content

Commit 400f191

Browse files
authored
Merge branch 'main' into localden/auth
2 parents 9bf4ea3 + b743889 commit 400f191

File tree

57 files changed

+1441
-885
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+1441
-885
lines changed

Directory.Packages.props

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<Project>
22
<PropertyGroup>
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
4-
<System10Version>10.0.0-preview.2.25163.2</System10Version>
5-
<MicrosoftExtensionsAIVersion>9.4.0-preview.1.25207.5</MicrosoftExtensionsAIVersion>
4+
<System10Version>10.0.0-preview.3.25171.5</System10Version>
5+
<MicrosoftExtensionsAIVersion>9.4.3-preview.1.25230.7</MicrosoftExtensionsAIVersion>
66
</PropertyGroup>
77

88
<!-- Product dependencies netstandard -->
@@ -54,6 +54,7 @@
5454
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.4" />
5555
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.4" />
5656
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.4" />
57+
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.4.0" />
5758
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
5859
<PackageVersion Include="Moq" Version="4.20.72" />
5960
<PackageVersion Include="OpenTelemetry" Version="1.11.2" />

samples/EverythingServer/Program.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,49 @@
3838
.WithTools<TinyImageTool>()
3939
.WithPrompts<ComplexPromptType>()
4040
.WithPrompts<SimplePromptType>()
41+
.WithListResourcesHandler(async (ctx, ct) =>
42+
{
43+
return new ListResourcesResult
44+
{
45+
Resources =
46+
[
47+
new ModelContextProtocol.Protocol.Types.Resource { Name = "Direct Text Resource", Description = "A direct text resource", MimeType = "text/plain", Uri = "test://direct/text/resource" },
48+
]
49+
};
50+
})
4151
.WithListResourceTemplatesHandler(async (ctx, ct) =>
4252
{
4353
return new ListResourceTemplatesResult
4454
{
4555
ResourceTemplates =
4656
[
47-
new ResourceTemplate { Name = "Static Resource", Description = "A static resource with a numeric ID", UriTemplate = "test://static/resource/{id}" }
57+
new ResourceTemplate { Name = "Template Resource", Description = "A template resource with a numeric ID", UriTemplate = "test://template/resource/{id}" }
4858
]
4959
};
5060
})
5161
.WithReadResourceHandler(async (ctx, ct) =>
5262
{
5363
var uri = ctx.Params?.Uri;
5464

55-
if (uri is null || !uri.StartsWith("test://static/resource/"))
65+
if (uri == "test://direct/text/resource")
66+
{
67+
return new ReadResourceResult
68+
{
69+
Contents = [new TextResourceContents
70+
{
71+
Text = "This is a direct resource",
72+
MimeType = "text/plain",
73+
Uri = uri,
74+
}]
75+
};
76+
}
77+
78+
if (uri is null || !uri.StartsWith("test://template/resource/"))
5679
{
5780
throw new NotSupportedException($"Unknown resource: {uri}");
5881
}
5982

60-
int index = int.Parse(uri["test://static/resource/".Length..]) - 1;
83+
int index = int.Parse(uri["test://template/resource/".Length..]) - 1;
6184

6285
if (index < 0 || index >= ResourceGenerator.Resources.Count)
6386
{

samples/EverythingServer/ResourceGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ static class ResourceGenerator
66
{
77
private static readonly List<Resource> _resources = Enumerable.Range(1, 100).Select(i =>
88
{
9-
var uri = $"test://static/resource/{i}";
9+
var uri = $"test://template/resource/{i}";
1010
if (i % 2 != 0)
1111
{
1212
return new Resource

samples/QuickstartWeatherServer/Tools/WeatherTools.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public sealed class WeatherTools
1212
[McpServerTool, Description("Get weather alerts for a US state.")]
1313
public static async Task<string> GetAlerts(
1414
HttpClient client,
15-
[Description("The US state to get alerts for.")] string state)
15+
[Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state)
1616
{
1717
using var jsonDocument = await client.ReadJsonDocumentAsync($"/alerts/active/area/{state}");
1818
var jsonElement = jsonDocument.RootElement;

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<RepositoryUrl>https://github.com/modelcontextprotocol/csharp-sdk</RepositoryUrl>
77
<RepositoryType>git</RepositoryType>
88
<VersionPrefix>0.1.0</VersionPrefix>
9-
<VersionSuffix>preview.11</VersionSuffix>
9+
<VersionSuffix>preview.12</VersionSuffix>
1010
<Authors>ModelContextProtocolOfficial</Authors>
1111
<Copyright>© Anthropic and Contributors.</Copyright>
1212
<PackageTags>ModelContextProtocol;mcp;ai;llm</PackageTags>

src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public static class HttpMcpServerBuilderExtensions
2222
public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder, Action<HttpServerTransportOptions>? configureOptions = null)
2323
{
2424
ArgumentNullException.ThrowIfNull(builder);
25+
2526
builder.Services.TryAddSingleton<StreamableHttpHandler>();
2627
builder.Services.TryAddSingleton<SseHandler>();
2728
builder.Services.TryAddSingleton<McpAuthorizationFilterFactory>();

src/ModelContextProtocol.AspNetCore/HttpMcpSession.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,18 @@ public async ValueTask DisposeAsync()
4747
}
4848
finally
4949
{
50-
if (Server is not null)
50+
try
5151
{
52-
await Server.DisposeAsync();
52+
if (Server is not null)
53+
{
54+
await Server.DisposeAsync();
55+
}
56+
}
57+
finally
58+
{
59+
await Transport.DisposeAsync();
60+
_disposeCts.Dispose();
5361
}
54-
55-
await Transport.DisposeAsync();
56-
_disposeCts.Dispose();
5762
}
5863
}
5964

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,17 @@ public class HttpServerTransportOptions
2626
/// Represents the duration of time the server will wait between any active requests before timing out an
2727
/// MCP session. This is checked in background every 5 seconds. A client trying to resume a session will
2828
/// receive a 404 status code and should restart their session. A client can keep their session open by
29-
/// keeping a GET request open. The default value is set to 2 minutes.
29+
/// keeping a GET request open. The default value is set to 2 hours.
3030
/// </summary>
31-
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(2);
31+
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2);
32+
33+
/// <summary>
34+
/// The maximum number of idle sessions to track. This is used to limit the number of sessions that can be idle at once.
35+
/// Past this limit, the server will log a critical error and terminate the oldest idle sessions even if they have not reached
36+
/// their <see cref="IdleTimeout"/> until the idle session count is below this limit. Clients that keep their session open by
37+
/// keeping a GET request open will not count towards this limit. The default value is set to 100,000 sessions.
38+
/// </summary>
39+
public int MaxIdleSessionCount { get; set; } = 100_000;
3240

3341
/// <summary>
3442
/// Used for testing the <see cref="IdleTimeout"/>.

src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,40 @@ namespace ModelContextProtocol.AspNetCore;
88
internal sealed partial class IdleTrackingBackgroundService(
99
StreamableHttpHandler handler,
1010
IOptions<HttpServerTransportOptions> options,
11+
IHostApplicationLifetime appLifetime,
1112
ILogger<IdleTrackingBackgroundService> logger) : BackgroundService
1213
{
1314
// The compiler will complain about the parameter being unused otherwise despite the source generator.
1415
private ILogger _logger = logger;
1516

16-
// We can make this configurable once we properly harden the MCP server. In the meantime, anyone running
17-
// this should be taking a cattle not pets approach to their servers and be able to launch more processes
18-
// to handle more than 10,000 idle sessions at a time.
19-
private const int MaxIdleSessionCount = 10_000;
20-
2117
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
2218
{
23-
var timeProvider = options.Value.TimeProvider;
24-
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider);
19+
// Still run loop given infinite IdleTimeout to enforce the MaxIdleSessionCount and assist graceful shutdown.
20+
if (options.Value.IdleTimeout != Timeout.InfiniteTimeSpan)
21+
{
22+
ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.IdleTimeout, TimeSpan.Zero);
23+
}
24+
ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0);
2525

2626
try
2727
{
28+
var timeProvider = options.Value.TimeProvider;
29+
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider);
30+
31+
var idleTimeoutTicks = options.Value.IdleTimeout.Ticks;
32+
var maxIdleSessionCount = options.Value.MaxIdleSessionCount;
33+
34+
// The default ValueTuple Comparer will check the first item then the second which preserves both order and uniqueness.
35+
var idleSessions = new SortedSet<(long Timestamp, string SessionId)>();
36+
2837
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
2938
{
30-
var idleActivityCutoff = timeProvider.GetTimestamp() - options.Value.IdleTimeout.Ticks;
39+
var idleActivityCutoff = idleTimeoutTicks switch
40+
{
41+
< 0 => long.MinValue,
42+
var ticks => timeProvider.GetTimestamp() - ticks,
43+
};
3144

32-
var idleCount = 0;
3345
foreach (var (_, session) in handler.Sessions)
3446
{
3547
if (session.IsActive || session.SessionClosed.IsCancellationRequested)
@@ -38,34 +50,40 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
3850
continue;
3951
}
4052

41-
idleCount++;
42-
if (idleCount == MaxIdleSessionCount)
43-
{
44-
// Emit critical log at most once every 5 seconds the idle count it exceeded,
45-
//since the IdleTimeout will no longer be respected.
46-
LogMaxSessionIdleCountExceeded();
47-
}
48-
else if (idleCount < MaxIdleSessionCount && session.LastActivityTicks > idleActivityCutoff)
53+
if (session.LastActivityTicks < idleActivityCutoff)
4954
{
55+
RemoveAndCloseSession(session.Id);
5056
continue;
5157
}
5258

53-
if (handler.Sessions.TryRemove(session.Id, out var removedSession))
59+
idleSessions.Add((session.LastActivityTicks, session.Id));
60+
61+
// Emit critical log at most once every 5 seconds the idle count it exceeded,
62+
// since the IdleTimeout will no longer be respected.
63+
if (idleSessions.Count == maxIdleSessionCount + 1)
5464
{
55-
LogSessionIdle(removedSession.Id);
65+
LogMaxSessionIdleCountExceeded(maxIdleSessionCount);
66+
}
67+
}
5668

57-
// Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
58-
_ = DisposeSessionAsync(removedSession);
69+
if (idleSessions.Count > maxIdleSessionCount)
70+
{
71+
var sessionsToPrune = idleSessions.ToArray()[..^maxIdleSessionCount];
72+
foreach (var (_, id) in sessionsToPrune)
73+
{
74+
RemoveAndCloseSession(id);
5975
}
6076
}
77+
78+
idleSessions.Clear();
6179
}
6280
}
6381
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
6482
{
6583
}
6684
finally
6785
{
68-
if (stoppingToken.IsCancellationRequested)
86+
try
6987
{
7088
List<Task> disposeSessionTasks = [];
7189

@@ -79,7 +97,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
7997

8098
await Task.WhenAll(disposeSessionTasks);
8199
}
100+
finally
101+
{
102+
if (!stoppingToken.IsCancellationRequested)
103+
{
104+
// Something went terribly wrong. A very unexpected exception must be bubbling up, but let's ensure we also stop the application,
105+
// so that it hopefully gets looked at and restarted. This shouldn't really be reachable.
106+
appLifetime.StopApplication();
107+
IdleTrackingBackgroundServiceStoppedUnexpectedly();
108+
}
109+
}
110+
}
111+
}
112+
113+
private void RemoveAndCloseSession(string sessionId)
114+
{
115+
if (!handler.Sessions.TryRemove(sessionId, out var session))
116+
{
117+
return;
82118
}
119+
120+
LogSessionIdle(session.Id);
121+
// Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
122+
_ = DisposeSessionAsync(session);
83123
}
84124

85125
private async Task DisposeSessionAsync(HttpMcpSession<StreamableHttpServerTransport> session)
@@ -97,9 +137,12 @@ private async Task DisposeSessionAsync(HttpMcpSession<StreamableHttpServerTransp
97137
[LoggerMessage(Level = LogLevel.Information, Message = "Closing idle session {sessionId}.")]
98138
private partial void LogSessionIdle(string sessionId);
99139

100-
[LoggerMessage(Level = LogLevel.Critical, Message = "Exceeded static maximum of 10,000 idle connections. Now clearing all inactive connections regardless of timeout.")]
101-
private partial void LogMaxSessionIdleCountExceeded();
102-
103-
[LoggerMessage(Level = LogLevel.Error, Message = "Error disposing the IMcpServer for session {sessionId}.")]
140+
[LoggerMessage(Level = LogLevel.Error, Message = "Error disposing session {sessionId}.")]
104141
private partial void LogSessionDisposeError(string sessionId, Exception ex);
142+
143+
[LoggerMessage(Level = LogLevel.Critical, Message = "Exceeded maximum of {maxIdleSessionCount} idle sessions. Now closing sessions active more recently than configured IdleTimeout.")]
144+
private partial void LogMaxSessionIdleCountExceeded(int maxIdleSessionCount);
145+
146+
[LoggerMessage(Level = LogLevel.Critical, Message = "The IdleTrackingBackgroundService has stopped unexpectedly.")]
147+
private partial void IdleTrackingBackgroundServiceStoppedUnexpectedly();
105148
}

0 commit comments

Comments
 (0)