csharp-sdk/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs

889 lines
35 KiB
C#

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Microsoft.Net.Http.Headers;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Net;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore.Tests;
public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private static McpServerTool[] Tools { get; } = [
McpServerTool.Create(EchoAsync),
McpServerTool.Create(LongRunningAsync),
McpServerTool.Create(Progress),
McpServerTool.Create(Throw),
];
private WebApplication? _app;
private async Task StartAsync()
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
{
Name = nameof(StreamableHttpServerConformanceTests),
Version = "73",
};
}).WithTools(Tools).WithHttpTransport();
_app = Builder.Build();
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
[Fact]
public async Task NegativeNonInfiniteIdleTimeout_Throws_ArgumentOutOfRangeException()
{
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.IdleTimeout = TimeSpan.MinValue;
});
var ex = await Assert.ThrowsAnyAsync<ArgumentOutOfRangeException>(StartAsync);
Assert.Contains("IdleTimeout", ex.Message);
}
[Fact]
public async Task NegativeMaxIdleSessionCount_Throws_ArgumentOutOfRangeException()
{
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.MaxIdleSessionCount = -1;
});
var ex = await Assert.ThrowsAnyAsync<ArgumentOutOfRangeException>(StartAsync);
Assert.Contains("MaxIdleSessionCount", ex.Message);
}
[Fact]
public async Task InitialPostResponse_Includes_McpSessionIdHeader()
{
await StartAsync();
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Single(response.Headers.GetValues("mcp-session-id"));
Assert.Equal("text/event-stream", Assert.Single(response.Content.Headers.GetValues("content-type")));
}
[Fact]
public async Task SseResponse_Includes_XAccelBufferingHeader()
{
await StartAsync();
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
Assert.Equal("no", Assert.Single(response.Headers.GetValues("X-Accel-Buffering")));
}
[Fact]
public async Task PostRequest_IsUnsupportedMediaType_WithoutJsonContentType()
{
await StartAsync();
using var response = await HttpClient.PostAsync("", new StringContent(InitializeRequest, Encoding.UTF8, "text/javascript"), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
}
[Theory]
[InlineData("text/event-stream")]
[InlineData("application/json")]
[InlineData("application/json-text/event-stream")]
public async Task PostRequest_IsNotAcceptable_WithSingleSpecificAcceptHeader(string singleAcceptValue)
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Accept.Clear();
HttpClient.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Accept, singleAcceptValue);
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode);
}
[Theory]
[InlineData("*/*")]
[InlineData("text/event-stream, application/json;q=0.9")]
public async Task PostRequest_IsAcceptable_WithWildcardOrAddedQualityInAcceptHeader(string acceptHeaderValue)
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Accept.Clear();
HttpClient.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Accept, acceptHeaderValue);
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetRequest_IsNotAcceptable_WithoutTextEventStreamAcceptHeader()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Accept.Clear();
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode);
}
[Theory]
[InlineData("*/*")]
[InlineData("application/json, text/event-stream;q=0.9")]
public async Task GetRequest_IsAcceptable_WithWildcardOrAddedQualityInAcceptHeader(string acceptHeaderValue)
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Accept.Clear();
HttpClient.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Accept, acceptHeaderValue);
await CallInitializeAndValidateAsync();
using var response = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Theory]
[InlineData("invalid-version")]
[InlineData("9999-01-01")]
[InlineData("not-a-date")]
public async Task PostRequest_IsBadRequest_WithInvalidProtocolVersionHeader(string invalidVersion)
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", invalidVersion);
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostRequest_Succeeds_WithoutProtocolVersionHeader()
{
await StartAsync();
// No MCP-Protocol-Version header is set - this should be accepted for backwards compatibility
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task PostRequest_Succeeds_WithValidProtocolVersionHeader()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2025-03-26");
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetRequest_IsBadRequest_WithInvalidProtocolVersionHeader()
{
await StartAsync();
await CallInitializeAndValidateAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "invalid-version");
using var response = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId()
{
await StartAsync();
using var request = new HttpRequestMessage(HttpMethod.Post, "")
{
Content = JsonContent(EchoRequest),
Headers =
{
{ "mcp-session-id", "fakeSession" },
},
};
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostWithoutSessionId_NonInitializeRequest_Returns400()
{
await StartAsync();
using var response = await HttpClient.PostAsync("", JsonContent(ListToolsRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("Mcp-Session-Id", body);
Assert.Contains("Stateless", body);
}
[Fact]
public async Task GetWithoutSessionId_Returns400_WithStatelessGuidance()
{
await StartAsync();
await CallInitializeAndValidateAsync();
// Clear session ID and send GET without it.
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Accept.Clear();
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("Mcp-Session-Id", body);
Assert.Contains("Stateless", body);
}
[Fact]
public async Task InitializeRequest_Matches_CustomRoute()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp("/custom-route");
await app.StartAsync(TestContext.Current.CancellationToken);
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
using var response = await HttpClient.PostAsync("/custom-route", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task PostWithSingleNotification_IsAccepted_WithEmptyResponse()
{
await StartAsync();
await CallInitializeAndValidateAsync();
var response = await HttpClient.PostAsync("", JsonContent(ProgressNotification("1")), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.Equal("", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task InitializeJsonRpcRequest_IsHandled_WithCompleteSseResponse()
{
await StartAsync();
await CallInitializeAndValidateAsync();
}
[Fact]
public async Task SingleJsonRpcRequest_ThatThrowsIsHandled_WithCompleteSseResponse()
{
await StartAsync();
await CallInitializeAndValidateAsync();
var response = await HttpClient.PostAsync("", JsonContent(CallTool("throw")), TestContext.Current.CancellationToken);
var rpcError = await AssertSingleSseResponseAsync(response);
var error = AssertType<CallToolResult>(rpcError.Result);
var content = Assert.Single(error.Content);
Assert.Contains("'throw'", Assert.IsType<TextContentBlock>(content).Text);
}
[Fact]
public async Task MultipleSerialJsonRpcRequests_IsHandled_OneAtATime()
{
await StartAsync();
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
await CallEchoAndValidateAsync();
}
[Fact]
public async Task MultipleConcurrentJsonRpcRequests_IsHandled_InParallel()
{
await StartAsync();
await CallInitializeAndValidateAsync();
var echoTasks = new Task[100];
for (int i = 0; i < echoTasks.Length; i++)
{
echoTasks[i] = CallEchoAndValidateAsync();
}
await Task.WhenAll(echoTasks);
}
[Fact]
public async Task GetRequest_Receives_UnsolicitedNotifications()
{
McpServer? server = null;
Builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
#pragma warning disable MCPEXP002 // RunSessionHandler is experimental
options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
{
server = mcpServer;
return mcpServer.RunAsync(cancellationToken);
};
#pragma warning restore MCPEXP002
});
await StartAsync();
await CallInitializeAndValidateAsync();
Assert.NotNull(server);
// Headers should be sent even before any messages are ready on the GET endpoint.
using var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
async Task<string> GetFirstNotificationAsync()
{
await foreach (var sseEvent in ReadSseAsync(getResponse.Content))
{
var notification = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcNotification>());
Assert.NotNull(notification);
return notification.Method;
}
throw new Exception("No notifications received.");
}
await server.SendNotificationAsync("test-method", TestContext.Current.CancellationToken);
Assert.Equal("test-method", await GetFirstNotificationAsync());
}
[Fact]
public async Task SendNotificationAsync_DoesNotThrow_WhenNoGetRequestHasBeenMade()
{
// Clients are not required to make a GET request for unsolicited messages.
// If no GET request has been made, the messages should be dropped rather than throwing.
McpServer? server = null;
Builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
#pragma warning disable MCPEXP002 // RunSessionHandler is experimental
options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
{
server = mcpServer;
return mcpServer.RunAsync(cancellationToken);
};
#pragma warning restore MCPEXP002
});
await StartAsync();
await CallInitializeAndValidateAsync();
Assert.NotNull(server);
// Calling SendNotificationAsync before a GET request should not throw.
// The notification should be silently dropped.
var exception = await Record.ExceptionAsync(() =>
server.SendNotificationAsync("test-method", TestContext.Current.CancellationToken));
Assert.Null(exception);
}
[Fact]
public async Task SecondGetRequests_IsRejected_AsBadRequest()
{
await StartAsync();
await CallInitializeAndValidateAsync();
using var getResponse1 = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
using var getResponse2 = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse1.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, getResponse2.StatusCode);
}
[Fact]
public async Task DeleteRequest_CompletesSession_WhichIsNoLongerFound()
{
await StartAsync();
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task DeleteRequest_CompletesSession_WhichCancelsLongRunningToolCalls()
{
await StartAsync();
await CallInitializeAndValidateAsync();
Task<HttpResponseMessage> CallLongRunningToolAsync() =>
HttpClient.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "")
{
Content = JsonContent(CallTool("long-running"))
},
HttpCompletionOption.ResponseHeadersRead,
TestContext.Current.CancellationToken);
var longRunningToolTasks = new Task<HttpResponseMessage>[10];
for (int i = 0; i < longRunningToolTasks.Length; i++)
{
longRunningToolTasks[i] = CallLongRunningToolAsync();
}
var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
// Wait for all long-running tool calls to receive 200 response headers before sending DELETE
var responseHeaders = await Task.WhenAll(longRunningToolTasks);
foreach (var response in responseHeaders)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
// Now send DELETE to cancel the session
await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
// Get request should complete gracefully.
var sseResponseBody = await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Empty(sseResponseBody);
// Currently, responses are flushed immediately to prevent HttpClient timeouts for long-running requests.
// This means the response starts with a 200 status code. When the session is canceled, Kestrel closes
// the connection without writing the chunk terminator, causing an HttpRequestException when reading the response body.
// The spec suggests sending CancelledNotifications. That would be good, but we can do that later.
// For now, the important thing is that reading the response body fails.
foreach (var response in responseHeaders)
{
await Assert.ThrowsAsync<HttpRequestException>(async () => await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}
}
[Fact]
public async Task Progress_IsReported_InSameSseResponseAsRpcResponse()
{
await StartAsync();
await CallInitializeAndValidateAsync();
using var response = await HttpClient.PostAsync("", JsonContent(CallToolWithProgressToken("progress")), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var currentSseItem = 0;
await foreach (var sseEvent in ReadSseAsync(response.Content))
{
currentSseItem++;
if (currentSseItem <= 10)
{
var notification = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcNotification>());
var progressNotification = AssertType<ProgressNotificationParams>(notification?.Params);
Assert.Equal($"Progress {currentSseItem - 1}", progressNotification.Progress.Message);
}
else
{
var rpcResponse = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcResponse>());
var callToolResponse = AssertType<CallToolResult>(rpcResponse?.Result);
var callToolContent = Assert.Single(callToolResponse.Content);
Assert.Equal("done", Assert.IsType<TextContentBlock>(callToolContent).Text);
}
}
Assert.Equal(11, currentSseItem);
}
[Fact]
public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls_IfPerSessionExecutionContextEnabled()
{
var asyncLocal = new AsyncLocal<string>();
var totalSessionCount = 0;
Builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.PerSessionExecutionContext = true;
#pragma warning disable MCPEXP002 // RunSessionHandler is experimental
options.RunSessionHandler = async (httpContext, mcpServer, cancellationToken) =>
{
asyncLocal.Value = $"RunSessionHandler ({totalSessionCount++})";
await mcpServer.RunAsync(cancellationToken);
};
#pragma warning restore MCPEXP002
});
Builder.Services.AddSingleton(McpServerTool.Create([McpServerTool(Name = "async-local-session")] () => asyncLocal.Value));
await StartAsync();
var firstSessionId = await CallInitializeAndValidateAsync();
async Task CallAsyncLocalToolAndValidateAsync(int expectedSessionIndex)
{
var response = await HttpClient.PostAsync("", JsonContent(CallTool("async-local-session")), TestContext.Current.CancellationToken);
var rpcResponse = await AssertSingleSseResponseAsync(response);
var callToolResponse = AssertType<CallToolResult>(rpcResponse.Result);
var callToolContent = Assert.Single(callToolResponse.Content);
Assert.Equal($"RunSessionHandler ({expectedSessionIndex})", Assert.IsType<TextContentBlock>(callToolContent).Text);
}
await CallAsyncLocalToolAndValidateAsync(expectedSessionIndex: 0);
await CallInitializeAndValidateAsync();
await CallAsyncLocalToolAndValidateAsync(expectedSessionIndex: 1);
SetSessionId(firstSessionId);
await CallAsyncLocalToolAndValidateAsync(expectedSessionIndex: 0);
}
[Fact]
public async Task IdleSessions_ArePruned_AfterIdleTimeout()
{
var fakeTimeProvider = new FakeTimeProvider();
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
Assert.Equal(TimeSpan.FromHours(2), options.IdleTimeout);
options.TimeProvider = fakeTimeProvider;
});
await StartAsync();
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
// The background IdleTrackingBackgroundService prunes sessions asynchronously after
// the PeriodicTimer (5s interval) tick fires. We advance past the 2-hour idle timeout
// then poll until the session returns NotFound. Each HTTP POST also refreshes the
// session's LastActivityTicks via AcquireReferenceAsync, so we must re-advance time
// each iteration to ensure the session appears idle again for the next prune pass.
var deadline = DateTime.UtcNow + TestConstants.DefaultTimeout;
HttpStatusCode statusCode;
do
{
fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
await Task.Delay(100, TestContext.Current.CancellationToken);
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
statusCode = response.StatusCode;
}
while (statusCode != HttpStatusCode.NotFound && DateTime.UtcNow < deadline);
Assert.Equal(HttpStatusCode.NotFound, statusCode);
}
[Fact]
public async Task IdleSessions_AreNotPruned_WithInfiniteIdleTimeoutWhileUnderMaxIdleSessionCount()
{
var fakeTimeProvider = new FakeTimeProvider();
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.IdleTimeout = Timeout.InfiniteTimeSpan;
options.TimeProvider = fakeTimeProvider;
});
await StartAsync();
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
fakeTimeProvider.Advance(TimeSpan.FromDays(1));
// Echo still works because the session has not been pruned.
await CallEchoAndValidateAsync();
}
[Fact]
public async Task IdleSessionsPastMaxIdleSessionCount_ArePruned_LongestIdleFirstDespiteIdleTimeout()
{
var fakeTimeProvider = new FakeTimeProvider();
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.IdleTimeout = Timeout.InfiniteTimeSpan;
options.MaxIdleSessionCount = 2;
options.TimeProvider = fakeTimeProvider;
});
var mockLoggerProvider = new MockLoggerProvider();
Builder.Logging.AddProvider(mockLoggerProvider);
await StartAsync();
// Start first session.
var firstSessionId = await CallInitializeAndValidateAsync();
// Start a second session to trigger pruning of the original session.
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
var secondSessionId = await CallInitializeAndValidateAsync();
Assert.NotEqual(firstSessionId, secondSessionId);
// First session ID still works, since we allow up to 2 idle sessions.
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
SetSessionId(firstSessionId);
await CallEchoAndValidateAsync();
// Start a third session to trigger pruning of the first session.
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
var thirdSessionId = await CallInitializeAndValidateAsync();
Assert.NotEqual(secondSessionId, thirdSessionId);
// Pruning of the second session results in a 404 since we used the first session more recently.
SetSessionId(secondSessionId);
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
// But the first and third session IDs should still work.
SetSessionId(firstSessionId);
await CallEchoAndValidateAsync();
SetSessionId(thirdSessionId);
await CallEchoAndValidateAsync();
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);
}
[Fact]
public async Task ActiveSession_WithPeriodicRequests_DoesNotTimeout()
{
var fakeTimeProvider = new FakeTimeProvider();
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.IdleTimeout = TimeSpan.FromHours(2);
options.TimeProvider = fakeTimeProvider;
});
await StartAsync();
await CallInitializeAndValidateAsync();
// Simulate multiple POST requests over a period longer than IdleTimeout
// Each request should update LastActivityTicks, preventing timeout
for (int i = 0; i < 5; i++)
{
// Advance time by 1 hour between requests
fakeTimeProvider.Advance(TimeSpan.FromHours(1));
await CallEchoAndValidateAsync();
}
// Total time elapsed: 5 hours (> 2 hour IdleTimeout)
// But session should still be alive because of periodic activity
await CallEchoAndValidateAsync();
}
[Fact]
public async Task McpServer_UsedOutOfScope_CanSendNotifications()
{
McpServer? capturedServer = null;
Builder.Services.AddMcpServer()
.WithHttpTransport()
.WithListResourcesHandler((_, _) => ValueTask.FromResult(new ListResourcesResult()))
.WithSubscribeToResourcesHandler((context, token) =>
{
capturedServer = context.Server;
return ValueTask.FromResult(new EmptyResult());
});
await StartAsync();
string sessionId = await CallInitializeAndValidateAsync();
SetSessionId(sessionId);
// Call the subscribe method to capture the McpServer instance.
using var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
using var response = await HttpClient.PostAsync("", JsonContent(SubscribeToResource("file:///test")), TestContext.Current.CancellationToken);
var rpcResponse = await AssertSingleSseResponseAsync(response);
AssertType<EmptyResult>(rpcResponse.Result);
Assert.NotNull(capturedServer);
// Check the captured McpServer instance can send a notification.
await capturedServer.SendNotificationAsync(NotificationMethods.ResourceUpdatedNotification, TestContext.Current.CancellationToken);
JsonRpcMessage? firstSseMessage = await ReadSseAsync(getResponse.Content)
.Select(data => JsonSerializer.Deserialize<JsonRpcMessage>(data, McpJsonUtilities.DefaultOptions))
.FirstOrDefaultAsync(TestContext.Current.CancellationToken);
var notification = Assert.IsType<JsonRpcNotification>(firstSseMessage);
Assert.Equal(NotificationMethods.ResourceUpdatedNotification, notification.Method);
}
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
private static T AssertType<T>(JsonNode? jsonNode)
{
var type = JsonSerializer.Deserialize(jsonNode, GetJsonTypeInfo<T>());
Assert.NotNull(type);
return type;
}
private static async IAsyncEnumerable<string> ReadSseAsync(HttpContent responseContent)
{
var responseStream = await responseContent.ReadAsStreamAsync(TestContext.Current.CancellationToken);
await foreach (var sseItem in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken))
{
Assert.Equal("message", sseItem.EventType);
yield return sseItem.Data;
}
}
private static async Task<JsonRpcResponse> AssertSingleSseResponseAsync(HttpResponseMessage response)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
var sseItem = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken));
var jsonRpcResponse = JsonSerializer.Deserialize(sseItem, GetJsonTypeInfo<JsonRpcResponse>());
Assert.NotNull(jsonRpcResponse);
return jsonRpcResponse;
}
private static string InitializeRequest => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
""";
private static string ListToolsRequest => """
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
""";
private long _lastRequestId = 1;
private string EchoRequest
{
get
{
var id = Interlocked.Increment(ref _lastRequestId);
return $$$$"""
{"jsonrpc":"2.0","id":{{{{id}}}},"method":"tools/call","params":{"name":"echo","arguments":{"message":"Hello world! ({{{{id}}}})"}}}
""";
}
}
private string ProgressNotification(string progress)
{
return $$$"""
{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"","progress":{{{progress}}}}}
""";
}
private string Request(string method, string parameters = "{}")
{
var id = Interlocked.Increment(ref _lastRequestId);
return $$"""
{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}}
""";
}
private string CallTool(string toolName, string arguments = "{}") =>
Request("tools/call", $$"""
{"name":"{{toolName}}","arguments":{{arguments}}}
""");
private string CallToolWithProgressToken(string toolName, string arguments = "{}") =>
Request("tools/call", $$$"""
{"name":"{{{toolName}}}","arguments":{{{arguments}}},"_meta":{"progressToken":"abc123"}}
""");
private string SubscribeToResource(string uri) =>
Request("resources/subscribe", $$"""
{"uri":"{{uri}}"}
""");
private static InitializeResult AssertServerInfo(JsonRpcResponse rpcResponse)
{
var initializeResult = AssertType<InitializeResult>(rpcResponse.Result);
Assert.Equal(nameof(StreamableHttpServerConformanceTests), initializeResult.ServerInfo.Name);
Assert.Equal("73", initializeResult.ServerInfo.Version);
return initializeResult;
}
private static CallToolResult AssertEchoResponse(JsonRpcResponse rpcResponse)
{
var callToolResponse = AssertType<CallToolResult>(rpcResponse.Result);
var callToolContent = Assert.Single(callToolResponse.Content);
Assert.Equal($"Hello world! ({rpcResponse.Id})", Assert.IsType<TextContentBlock>(callToolContent).Text);
return callToolResponse;
}
private async Task<string> CallInitializeAndValidateAsync()
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
var rpcResponse = await AssertSingleSseResponseAsync(response);
AssertServerInfo(rpcResponse);
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
SetSessionId(sessionId);
return sessionId;
}
private void SetSessionId(string sessionId)
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
}
private async Task CallEchoAndValidateAsync()
{
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
var rpcResponse = await AssertSingleSseResponseAsync(response);
AssertEchoResponse(rpcResponse);
}
[McpServerTool(Name = "echo")]
private static async Task<string> EchoAsync(string message)
{
// McpSession.ProcessMessagesAsync() already yields before calling any handlers, but this makes it even
// more explicit that we're not relying on synchronous execution of the tool.
await Task.Yield();
return message;
}
[McpServerTool(Name = "long-running")]
private static async Task LongRunningAsync(CancellationToken cancellation)
{
// McpSession.ProcessMessagesAsync() already yields before calling any handlers, but this makes it even
// more explicit that we're not relying on synchronous execution of the tool.
await Task.Delay(Timeout.Infinite, cancellation);
}
[McpServerTool(Name = "progress")]
public static string Progress(IProgress<ProgressNotificationValue> progress)
{
for (int i = 0; i < 10; i++)
{
progress.Report(new() { Progress = i, Total = 10, Message = $"Progress {i}" });
}
return "done";
}
[McpServerTool(Name = "throw")]
private static void Throw()
{
throw new Exception();
}
}