Skip to content

Commit 48386f6

Browse files
authored
Merge branch 'main' into copilot/add-logger-factory-to-streamable-http
2 parents cff6d2e + e47f884 commit 48386f6

14 files changed

Lines changed: 349 additions & 33 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System.Threading;
2+
3+
namespace System;
4+
5+
/// <summary>
6+
/// Provides helper methods for monotonic ID generation.
7+
/// </summary>
8+
internal static class IdHelpers
9+
{
10+
private static long s_counter;
11+
12+
/// <summary>
13+
/// Creates a strictly monotonically increasing identifier string using 64-bit timestamp ticks
14+
/// and a 64-bit counter, formatted as a 32-character hexadecimal string (GUID-like).
15+
/// </summary>
16+
/// <param name="timestamp">The timestamp to embed in the identifier.</param>
17+
/// <returns>A new strictly monotonically increasing identifier string.</returns>
18+
/// <remarks>
19+
/// <para>
20+
/// This method creates a 128-bit identifier composed of two 64-bit values:
21+
/// - High 64 bits: <see cref="DateTimeOffset.Ticks"/> from the timestamp
22+
/// - Low 64 bits: A globally monotonically increasing counter
23+
/// </para>
24+
/// <para>
25+
/// The resulting string is strictly monotonically increasing when compared lexicographically,
26+
/// which is required for keyset pagination to work correctly. Unlike <c>Guid.CreateVersion7</c>,
27+
/// which uses random bits for intra-millisecond uniqueness, this implementation guarantees
28+
/// strict ordering for all identifiers regardless of when they were created.
29+
/// </para>
30+
/// </remarks>
31+
public static string CreateMonotonicId(DateTimeOffset timestamp)
32+
{
33+
long ticks = timestamp.UtcTicks;
34+
long counter = Interlocked.Increment(ref s_counter);
35+
36+
// Format as 32-character hex string (16 bytes = 128 bits)
37+
// High 64 bits: timestamp ticks, Low 64 bits: counter
38+
return $"{ticks:x16}{counter:x16}";
39+
}
40+
}

src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
<PackageId>ModelContextProtocol.Core</PackageId>
88
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
99
<PackageReadmeFile>README.md</PackageReadmeFile>
10-
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
1110
<!-- Suppress the experimental tasks warning -->
1211
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
1312
</PropertyGroup>
@@ -19,6 +18,7 @@
1918
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
2019
<!-- CS0436: Allow ObsoleteAttribute to be redefined internally -->
2120
<NoWarn>$(NoWarn);CS0436</NoWarn>
21+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
2222
</PropertyGroup>
2323

2424
<ItemGroup>

src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
using System.Diagnostics.CodeAnalysis;
44
using System.Text.Json;
55

6+
#if MCP_TEST_TIME_PROVIDER
7+
namespace ModelContextProtocol.Tests.Internal;
8+
#else
69
namespace ModelContextProtocol;
10+
#endif
711

812
/// <summary>
913
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
@@ -35,6 +39,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
3539
private readonly int _pageSize;
3640
private readonly int? _maxTasks;
3741
private readonly int? _maxTasksPerSession;
42+
#if MCP_TEST_TIME_PROVIDER
43+
private readonly TimeProvider _timeProvider;
44+
#endif
3845

3946
/// <summary>
4047
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
@@ -120,6 +127,9 @@ public InMemoryMcpTaskStore(
120127
_pageSize = pageSize;
121128
_maxTasks = maxTasks;
122129
_maxTasksPerSession = maxTasksPerSession;
130+
#if MCP_TEST_TIME_PROVIDER
131+
_timeProvider = TimeProvider.System;
132+
#endif
123133

124134
cleanupInterval ??= TimeSpan.FromMinutes(1);
125135
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
@@ -128,6 +138,26 @@ public InMemoryMcpTaskStore(
128138
}
129139
}
130140

141+
#if MCP_TEST_TIME_PROVIDER
142+
/// <summary>
143+
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class with a custom time provider.
144+
/// This constructor is only available for testing purposes.
145+
/// </summary>
146+
internal InMemoryMcpTaskStore(
147+
TimeSpan? defaultTtl,
148+
TimeSpan? maxTtl,
149+
TimeSpan? pollInterval,
150+
TimeSpan? cleanupInterval,
151+
int pageSize,
152+
int? maxTasks,
153+
int? maxTasksPerSession,
154+
TimeProvider timeProvider)
155+
: this(defaultTtl, maxTtl, pollInterval, cleanupInterval, pageSize, maxTasks, maxTasksPerSession)
156+
{
157+
_timeProvider = timeProvider ?? TimeProvider.System;
158+
}
159+
#endif
160+
131161
/// <inheritdoc/>
132162
public Task<McpTask> CreateTaskAsync(
133163
McpTaskMetadata taskParams,
@@ -155,7 +185,7 @@ public Task<McpTask> CreateTaskAsync(
155185
}
156186

157187
var taskId = GenerateTaskId();
158-
var now = DateTimeOffset.UtcNow;
188+
var now = GetUtcNow();
159189

160190
// Determine TTL: use requested, fall back to default, respect max limit
161191
var ttl = taskParams.TimeToLive ?? _defaultTtl;
@@ -242,7 +272,7 @@ public Task<McpTask> StoreTaskResultAsync(
242272
var updatedEntry = new TaskEntry(entry)
243273
{
244274
Status = status,
245-
LastUpdatedAt = DateTimeOffset.UtcNow,
275+
LastUpdatedAt = GetUtcNow(),
246276
StoredResult = result
247277
};
248278

@@ -303,7 +333,7 @@ public Task<McpTask> UpdateTaskStatusAsync(
303333
{
304334
Status = status,
305335
StatusMessage = statusMessage,
306-
LastUpdatedAt = DateTimeOffset.UtcNow,
336+
LastUpdatedAt = GetUtcNow(),
307337
};
308338

309339
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@@ -321,32 +351,22 @@ public Task<ListTasksResult> ListTasksAsync(
321351
string? sessionId = null,
322352
CancellationToken cancellationToken = default)
323353
{
324-
// Parse cursor: format is "CreatedAt|TaskId" for keyset pagination
325-
(DateTimeOffset, string)? parsedCursor = null;
326-
if (cursor != null)
327-
{
328-
var parts = cursor.Split('|');
329-
if (parts.Length == 2 &&
330-
DateTimeOffset.TryParse(parts[0], out var parsedDate))
331-
{
332-
parsedCursor = (parsedDate, parts[1]);
333-
}
334-
}
335-
336354
// Stream enumeration - filter by session, exclude expired, apply keyset pagination
337355
var query = _tasks.Values
338356
.Where(e => sessionId == null || e.SessionId == sessionId)
339357
.Where(e => !IsExpired(e));
340358

341-
// Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor
342-
if (parsedCursor is { } parsedCursorValue)
359+
// Apply keyset filter if cursor provided: TaskId > cursor
360+
// UUID v7 task IDs are monotonically increasing and inherently time-ordered
361+
if (cursor != null)
343362
{
344-
query = query.Where(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) > 0);
363+
query = query.Where(e => string.CompareOrdinal(e.TaskId, cursor) > 0);
345364
}
346365

347-
// Order by (CreatedAt, TaskId) for stable, deterministic pagination
366+
// Order by TaskId for stable, deterministic pagination
367+
// UUID v7 task IDs sort chronologically due to embedded timestamp
348368
var page = query
349-
.OrderBy(e => (e.CreatedAt, e.TaskId))
369+
.OrderBy(e => e.TaskId, StringComparer.Ordinal)
350370
.Take(_pageSize + 1) // Take one extra to check if there's a next page
351371
.Select(e => e.ToMcpTask())
352372
.ToList();
@@ -356,7 +376,7 @@ public Task<ListTasksResult> ListTasksAsync(
356376
if (page.Count > _pageSize)
357377
{
358378
var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return
359-
nextCursor = $"{lastItemInPage.CreatedAt:O}|{lastItemInPage.TaskId}";
379+
nextCursor = lastItemInPage.TaskId;
360380
page.RemoveAt(_pageSize); // Remove the extra item
361381
}
362382
else
@@ -397,7 +417,7 @@ public Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, Ca
397417
var updatedEntry = new TaskEntry(entry)
398418
{
399419
Status = McpTaskStatus.Cancelled,
400-
LastUpdatedAt = DateTimeOffset.UtcNow,
420+
LastUpdatedAt = GetUtcNow(),
401421
};
402422

403423
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@@ -417,20 +437,31 @@ public void Dispose()
417437
_cleanupTimer?.Dispose();
418438
}
419439

420-
private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
440+
private string GenerateTaskId() =>
441+
IdHelpers.CreateMonotonicId(GetUtcNow());
421442

422443
private static bool IsTerminalStatus(McpTaskStatus status) =>
423444
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
424445

446+
#if MCP_TEST_TIME_PROVIDER
447+
private DateTimeOffset GetUtcNow() => _timeProvider.GetUtcNow();
448+
#else
449+
private static DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
450+
#endif
451+
452+
#if MCP_TEST_TIME_PROVIDER
453+
private bool IsExpired(TaskEntry entry)
454+
#else
425455
private static bool IsExpired(TaskEntry entry)
456+
#endif
426457
{
427458
if (entry.TimeToLive == null)
428459
{
429460
return false; // Unlimited lifetime
430461
}
431462

432463
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
433-
return DateTimeOffset.UtcNow >= expirationTime;
464+
return GetUtcNow() >= expirationTime;
434465
}
435466

436467
private void CleanupExpiredTasks(object? state)

tests/Common/Utils/TestConstants.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,16 @@ public static class TestConstants
1010
/// Set to 60 seconds to provide sufficient buffer for slow CI environments.
1111
/// </summary>
1212
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60);
13+
14+
/// <summary>
15+
/// Timeout for HttpClient operations in tests.
16+
/// Set to 60 seconds to provide sufficient buffer for slow CI environments.
17+
/// </summary>
18+
public static readonly TimeSpan HttpClientTimeout = TimeSpan.FromSeconds(60);
19+
20+
/// <summary>
21+
/// Timeout for short-lived HTTP requests during polling operations.
22+
/// Set to 2 seconds for quick failure detection while polling.
23+
/// </summary>
24+
public static readonly TimeSpan HttpClientPollingTimeout = TimeSpan.FromSeconds(2);
1325
}

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Http;
23
using Microsoft.Extensions.DependencyInjection;
34
using Microsoft.Extensions.Primitives;
45
using ModelContextProtocol.Client;
@@ -355,4 +356,60 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenNoEvent
355356
Assert.NotNull(capturedException);
356357
Assert.Contains("event stream store", capturedException.Message, StringComparison.OrdinalIgnoreCase);
357358
}
359+
360+
[Fact]
361+
public async Task AdditionalHeaders_AreSent_InPostAndDeleteRequests()
362+
{
363+
Assert.SkipWhen(Stateless, "DELETE requests are not sent in stateless mode due to lack of session ID.");
364+
365+
bool wasPostRequest = false;
366+
bool wasDeleteRequest = false;
367+
368+
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
369+
370+
await using var app = Builder.Build();
371+
372+
app.Use(next =>
373+
{
374+
return async context =>
375+
{
376+
Assert.Equal("Bearer testToken", context.Request.Headers["Authorize"]);
377+
if (context.Request.Method == HttpMethods.Post)
378+
{
379+
wasPostRequest = true;
380+
}
381+
else if (context.Request.Method == HttpMethods.Delete)
382+
{
383+
wasDeleteRequest = true;
384+
}
385+
await next(context);
386+
};
387+
});
388+
389+
app.MapMcp();
390+
391+
await app.StartAsync(TestContext.Current.CancellationToken);
392+
393+
var transportOptions = new HttpClientTransportOptions
394+
{
395+
Endpoint = new("http://localhost:5000/"),
396+
Name = "In-memory Streamable HTTP Client",
397+
TransportMode = HttpTransportMode.StreamableHttp,
398+
AdditionalHeaders = new Dictionary<string, string>
399+
{
400+
["Authorize"] = "Bearer testToken"
401+
},
402+
};
403+
404+
await using var mcpClient = await ConnectAsync(transportOptions: transportOptions);
405+
406+
// Do a tool call to ensure there's more than just the initialize request
407+
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
408+
409+
// Dispose the client to trigger the DELETE request
410+
await mcpClient.DisposeAsync();
411+
412+
Assert.True(wasPostRequest, "POST request was not made");
413+
Assert.True(wasDeleteRequest, "DELETE request was not made");
414+
}
358415
}

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ public async ValueTask DisposeAsync()
8383

8484
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null)
8585
{
86+
// Wait for the OAuth server to be ready before starting the MCP server.
87+
// This prevents race conditions in CI where the OAuth server may not be
88+
// fully initialized when the first test request is made.
89+
await TestOAuthServer.ServerStarted.WaitAsync(TestContext.Current.CancellationToken);
90+
8691
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
8792
{
8893
options.TokenValidationParameters.ValidAudience = $"{McpServerUrl}{path}";

tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public async ValueTask InitializeAsync()
4747
// Wait for server to be ready (retry for up to 30 seconds)
4848
var timeout = TimeSpan.FromSeconds(30);
4949
var stopwatch = Stopwatch.StartNew();
50-
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
50+
using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout };
5151

5252
while (stopwatch.Elapsed < timeout)
5353
{

tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public KestrelInMemoryTest(ITestOutputHelper testOutputHelper)
4242
protected static void ConfigureHttpClient(HttpClient httpClient)
4343
{
4444
httpClient.BaseAddress = new Uri("http://localhost:5000/");
45-
httpClient.Timeout = TimeSpan.FromSeconds(10);
45+
httpClient.Timeout = TestConstants.HttpClientTimeout;
4646
}
4747

4848
public override void Dispose()

tests/ModelContextProtocol.TestOAuthServer/Program.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public sealed class Program
3333

3434
private readonly ILoggerProvider? _loggerProvider;
3535
private readonly IConnectionListenerFactory? _kestrelTransport;
36+
private readonly TaskCompletionSource _serverStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
3637

3738
/// <summary>
3839
/// Initializes a new instance of the <see cref="Program"/> class with logging and transport parameters.
@@ -47,6 +48,11 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
4748
_kestrelTransport = kestrelTransport;
4849
}
4950

51+
/// <summary>
52+
/// Gets a task that completes when the server has started and is ready to accept connections.
53+
/// </summary>
54+
public Task ServerStarted => _serverStarted.Task;
55+
5056
// Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration.
5157
public bool HasRefreshedToken { get; set; }
5258

@@ -541,7 +547,20 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
541547
Console.WriteLine($"Demo Client ID: {clientId}");
542548
Console.WriteLine($"Demo Client Secret: {clientSecret}");
543549

544-
await app.RunAsync(cancellationToken);
550+
await app.StartAsync(cancellationToken);
551+
_serverStarted.TrySetResult();
552+
553+
// Wait until cancellation is requested
554+
try
555+
{
556+
await Task.Delay(Timeout.Infinite, cancellationToken);
557+
}
558+
catch (OperationCanceledException)
559+
{
560+
// Expected when cancellation is requested
561+
}
562+
563+
await app.StopAsync();
545564
}
546565

547566
/// <summary>

0 commit comments

Comments
 (0)