Add stateless Streamable HTTP support (#392)
- It allows a single MCP session spanning multiple requests to be handled by different servers without sharing state - It does require the servers share data protection keys, but this is standard for ASP.NET Core cookies and antiforgery as well - It immediately throws for unsupported operations like sampling
This commit is contained in:
parent
ca3a8ab921
commit
ce9f73f43f
14 changed files with 785 additions and 230 deletions
|
|
@ -206,6 +206,8 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
|
|||
[Fact]
|
||||
public async Task Sampling_Sse_TestServer()
|
||||
{
|
||||
Assert.SkipWhen(GetType() == typeof(StatelessServerIntegrationTests), "Sampling is not supported in stateless mode.");
|
||||
|
||||
// arrange
|
||||
// Set up the sampling handler
|
||||
int samplingHandlerCalls = 0;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,31 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(outputHelper)
|
||||
{
|
||||
protected override bool UseStreamableHttp => false;
|
||||
protected override bool Stateless => false;
|
||||
|
||||
[Theory]
|
||||
[InlineData("/mcp")]
|
||||
[InlineData("/mcp/secondary")]
|
||||
public async Task Allows_Customizing_Route(string pattern)
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless);
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(pattern);
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var response = await HttpClient.GetAsync($"http://localhost{pattern}/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var sseStream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
|
||||
using var sseStreamReader = new StreamReader(sseStream, System.Text.Encoding.UTF8);
|
||||
var eventLine = await sseStreamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
var dataLine = await sseStreamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(eventLine);
|
||||
Assert.Equal("event: endpoint", eventLine);
|
||||
Assert.NotNull(dataLine);
|
||||
Assert.Equal($"data: {pattern}/message", dataLine[..dataLine.IndexOf('?')]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/a", "/a/sse")]
|
||||
|
|
@ -32,37 +57,4 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
|
|||
|
||||
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_UseHttpContextAccessor_InTool()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<EchoHttpContextUserTools>();
|
||||
|
||||
Builder.Services.AddHttpContextAccessor();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.Use(next =>
|
||||
{
|
||||
return async context =>
|
||||
{
|
||||
context.User = CreateUser("TestUser");
|
||||
await next(context);
|
||||
};
|
||||
});
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var mcpClient = await ConnectAsync();
|
||||
|
||||
var response = await mcpClient.CallToolAsync(
|
||||
"EchoWithUserName",
|
||||
new Dictionary<string, object?>() { ["message"] = "Hello world!" },
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var content = Assert.Single(response.Content);
|
||||
Assert.Equal("TestUser: Hello world!", content.Text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStreamableHttpTests(outputHelper)
|
||||
{
|
||||
protected override bool UseStreamableHttp => true;
|
||||
protected override bool Stateless => true;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpTests(outputHelper)
|
||||
{
|
||||
protected override bool UseStreamableHttp => true;
|
||||
protected override bool Stateless => false;
|
||||
|
||||
[Theory]
|
||||
[InlineData("/a", "/a")]
|
||||
|
|
@ -22,7 +23,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
|||
Name = "TestCustomRouteServer",
|
||||
Version = "1.0.0",
|
||||
};
|
||||
}).WithHttpTransport();
|
||||
}).WithHttpTransport(ConfigureStateless);
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(routePattern);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
|
||||
{
|
||||
protected abstract bool UseStreamableHttp { get; }
|
||||
protected abstract bool Stateless { get; }
|
||||
|
||||
protected void ConfigureStateless(HttpServerTransportOptions options)
|
||||
{
|
||||
options.Stateless = Stateless;
|
||||
}
|
||||
|
||||
protected async Task<IMcpClient> ConnectAsync(string? path = null)
|
||||
{
|
||||
|
|
@ -37,34 +43,49 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
|
|||
Assert.StartsWith("You must call WithHttpTransport()", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/mcp")]
|
||||
[InlineData("/mcp/secondary")]
|
||||
public async Task Allows_Customizing_Route(string pattern)
|
||||
[Fact]
|
||||
public async Task Can_UseIHttpContextAccessor_InTool()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport();
|
||||
Assert.SkipWhen(UseStreamableHttp && !Stateless,
|
||||
"""
|
||||
IHttpContextAccessor is not currently supported with non-stateless Streamable HTTP.
|
||||
TODO: Support it in stateless mode by manually capturing and flowing execution context.
|
||||
""");
|
||||
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
|
||||
|
||||
Builder.Services.AddHttpContextAccessor();
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(pattern);
|
||||
app.Use(next =>
|
||||
{
|
||||
return async context =>
|
||||
{
|
||||
context.User = CreateUser("TestUser");
|
||||
await next(context);
|
||||
};
|
||||
});
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var response = await HttpClient.GetAsync($"http://localhost{pattern}/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var sseStream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
|
||||
using var sseStreamReader = new StreamReader(sseStream, System.Text.Encoding.UTF8);
|
||||
var eventLine = await sseStreamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
var dataLine = await sseStreamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(eventLine);
|
||||
Assert.Equal("event: endpoint", eventLine);
|
||||
Assert.NotNull(dataLine);
|
||||
Assert.Equal($"data: {pattern}/message", dataLine[..dataLine.IndexOf('?')]);
|
||||
var mcpClient = await ConnectAsync();
|
||||
|
||||
var response = await mcpClient.CallToolAsync(
|
||||
"EchoWithUserName",
|
||||
new Dictionary<string, object?>() { ["message"] = "Hello world!" },
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var content = Assert.Single(response.Content);
|
||||
Assert.Equal("TestUser: Hello world!", content.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Messages_FromNewUser_AreRejected()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<EchoHttpContextUserTools>();
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
|
||||
|
||||
// Add an authentication scheme that will send a 403 Forbidden response.
|
||||
Builder.Services.AddAuthentication().AddBearerToken();
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
|
||||
public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
|
||||
{
|
||||
private SseClientTransportOptions DefaultTransportOptions = new()
|
||||
private readonly SseClientTransportOptions DefaultTransportOptions = new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/sse"),
|
||||
Name = "In-memory Test Server",
|
||||
Name = "In-memory SSE Client",
|
||||
};
|
||||
|
||||
private Task<IMcpClient> ConnectMcpClient(HttpClient? httpClient = null, SseClientTransportOptions? transportOptions = null)
|
||||
private Task<IMcpClient> ConnectMcpClientAsync(HttpClient? httpClient = null, SseClientTransportOptions? transportOptions = null)
|
||||
=> McpClientFactory.CreateAsync(
|
||||
new SseClientTransport(transportOptions ?? DefaultTransportOptions, httpClient ?? HttpClient, LoggerFactory),
|
||||
loggerFactory: LoggerFactory,
|
||||
|
|
@ -37,7 +37,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var mcpClient = await ConnectMcpClient();
|
||||
await using var mcpClient = await ConnectMcpClientAsync();
|
||||
|
||||
// Send a test message through POST endpoint
|
||||
await mcpClient.SendNotificationAsync("test/message", new Envelope { Message = "Hello, SSE!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
|
@ -52,7 +52,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
MapAbsoluteEndpointUriMcp(app);
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var mcpClient = await ConnectMcpClient();
|
||||
await using var mcpClient = await ConnectMcpClientAsync();
|
||||
|
||||
// Send a test message through POST endpoint
|
||||
await mcpClient.SendNotificationAsync("test/message", new Envelope { Message = "Hello, SSE!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
|
@ -84,7 +84,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var mcpClient = await ConnectMcpClient();
|
||||
await using var mcpClient = await ConnectMcpClientAsync();
|
||||
|
||||
mcpClient.RegisterNotificationHandler("test/notification", (args, ca) =>
|
||||
{
|
||||
|
|
@ -124,7 +124,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var mcpClient = await ConnectMcpClient();
|
||||
await using var mcpClient = await ConnectMcpClientAsync();
|
||||
|
||||
// Options can be lazily initialized, but they must be instantiated by the time an MCP client can finish connecting.
|
||||
// Callbacks can be called multiple times if configureOptionsAsync is configured, because that uses the IOptionsFactory,
|
||||
|
|
@ -184,14 +184,14 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
var sseOptions = new SseClientTransportOptions()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/sse"),
|
||||
Name = "In-memory Test Server",
|
||||
Name = "In-memory SSE Client",
|
||||
AdditionalHeaders = new()
|
||||
{
|
||||
["Authorize"] = "Bearer testToken"
|
||||
},
|
||||
};
|
||||
|
||||
await using var mcpClient = await ConnectMcpClient(transportOptions: sseOptions);
|
||||
await using var mcpClient = await ConnectMcpClientAsync(transportOptions: sseOptions);
|
||||
|
||||
Assert.True(wasGetRequest);
|
||||
Assert.True(wasPostRequest);
|
||||
|
|
@ -211,14 +211,14 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
var sseOptions = new SseClientTransportOptions()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/sse"),
|
||||
Name = "In-memory Test Server",
|
||||
Name = "In-memory SSE Client",
|
||||
AdditionalHeaders = new()
|
||||
{
|
||||
[""] = ""
|
||||
},
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => ConnectMcpClient(transportOptions: sseOptions));
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => ConnectMcpClientAsync(transportOptions: sseOptions));
|
||||
Assert.Equal("Failed to add header '' with value '' from AdditionalHeaders.", ex.Message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public class SseServerIntegrationTests(SseServerIntegrationTestFixture fixture,
|
|||
protected override SseClientTransportOptions ClientTransportOptions => new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/sse"),
|
||||
Name = "TestServer",
|
||||
Name = "In-memory SSE Client",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
using ModelContextProtocol.Protocol.Transport;
|
||||
using System.Text;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class StatelessServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
|
||||
: StreamableHttpServerIntegrationTests(fixture, testOutputHelper)
|
||||
|
||||
{
|
||||
protected override SseClientTransportOptions ClientTransportOptions => new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/stateless"),
|
||||
Name = "In-memory Streamable HTTP Client",
|
||||
UseStreamableHttp = true,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
[McpServerToolType]
|
||||
public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
|
||||
{
|
||||
private WebApplication? _app;
|
||||
|
||||
private readonly SseClientTransportOptions DefaultTransportOptions = new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/"),
|
||||
Name = "In-memory Streamable HTTP Client",
|
||||
UseStreamableHttp = true,
|
||||
};
|
||||
|
||||
private async Task StartAsync()
|
||||
{
|
||||
Builder.Services.AddMcpServer(mcpServerOptions =>
|
||||
{
|
||||
mcpServerOptions.ServerInfo = new Implementation
|
||||
{
|
||||
Name = nameof(StreamableHttpServerConformanceTests),
|
||||
Version = "73",
|
||||
};
|
||||
})
|
||||
.WithHttpTransport(httpServerTransportOptions =>
|
||||
{
|
||||
httpServerTransportOptions.Stateless = true;
|
||||
})
|
||||
.WithTools<StatelessServerTests>();
|
||||
|
||||
Builder.Services.AddScoped<ScopedService>();
|
||||
|
||||
_app = Builder.Build();
|
||||
|
||||
_app.Use(next =>
|
||||
{
|
||||
return context =>
|
||||
{
|
||||
context.RequestServices.GetRequiredService<ScopedService>().State = "From request middleware!";
|
||||
return next(context);
|
||||
};
|
||||
});
|
||||
|
||||
_app.MapMcp();
|
||||
|
||||
await _app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
|
||||
}
|
||||
|
||||
private Task<IMcpClient> ConnectMcpClientAsync(McpClientOptions? clientOptions = null)
|
||||
=> McpClientFactory.CreateAsync(
|
||||
new SseClientTransport(DefaultTransportOptions, HttpClient, LoggerFactory),
|
||||
clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnablingStatelessMode_Disables_SseEndpoints()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
using var sseResponse = await HttpClient.GetAsync("/sse", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, sseResponse.StatusCode);
|
||||
|
||||
using var messageResponse = await HttpClient.PostAsync("/message", new StringContent(""), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, messageResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnablingStatelessMode_Disables_GetAndDeleteEndpoints()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
using var getResponse = await HttpClient.GetAsync("/", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, getResponse.StatusCode);
|
||||
|
||||
using var deleteResponse = await HttpClient.DeleteAsync("/", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, deleteResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SamplingRequest_Fails_WithInvalidOperationException()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var mcpClientOptions = new McpClientOptions();
|
||||
mcpClientOptions.Capabilities = new();
|
||||
mcpClientOptions.Capabilities.Sampling ??= new();
|
||||
mcpClientOptions.Capabilities.Sampling.SamplingHandler = (_, _, _) =>
|
||||
{
|
||||
throw new UnreachableException();
|
||||
};
|
||||
|
||||
await using var client = await ConnectMcpClientAsync(mcpClientOptions);
|
||||
|
||||
var toolResponse = await client.CallToolAsync("testSamplingErrors", cancellationToken: TestContext.Current.CancellationToken);
|
||||
var toolContent = Assert.Single(toolResponse.Content);
|
||||
Assert.Equal("Server to client requests are not supported in stateless mode.", toolContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RootsRequest_Fails_WithInvalidOperationException()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var mcpClientOptions = new McpClientOptions();
|
||||
mcpClientOptions.Capabilities = new();
|
||||
mcpClientOptions.Capabilities.Roots ??= new();
|
||||
mcpClientOptions.Capabilities.Roots.RootsHandler = (_, _) =>
|
||||
{
|
||||
throw new UnreachableException();
|
||||
};
|
||||
|
||||
await using var client = await ConnectMcpClientAsync(mcpClientOptions);
|
||||
|
||||
var toolResponse = await client.CallToolAsync("testRootsErrors", cancellationToken: TestContext.Current.CancellationToken);
|
||||
var toolContent = Assert.Single(toolResponse.Content);
|
||||
Assert.Equal("Server to client requests are not supported in stateless mode.", toolContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsolicitedNotification_Fails_WithInvalidOperationException()
|
||||
{
|
||||
InvalidOperationException? unsolicitedNotificationException = null;
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
options.RunSessionHandler = async (context, server, cancellationToken) =>
|
||||
{
|
||||
unsolicitedNotificationException = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => server.SendNotificationAsync(NotificationMethods.PromptListChangedNotification, TestContext.Current.CancellationToken));
|
||||
|
||||
await server.RunAsync(cancellationToken);
|
||||
};
|
||||
});
|
||||
|
||||
await StartAsync();
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
Assert.NotNull(unsolicitedNotificationException);
|
||||
Assert.Equal("Unsolicited server to client messages are not supported in stateless mode.", unsolicitedNotificationException.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScopedServices_Resolve_FromRequestScope()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
var toolResponse = await client.CallToolAsync("testScope", cancellationToken: TestContext.Current.CancellationToken);
|
||||
var toolContent = Assert.Single(toolResponse.Content);
|
||||
Assert.Equal("From request middleware!", toolContent.Text);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "testSamplingErrors")]
|
||||
public static async Task<string> TestSamplingErrors(IMcpServer server)
|
||||
{
|
||||
const string expectedSamplingErrorMessage = "Sampling is not supported in stateless mode.";
|
||||
|
||||
// Even when the client has sampling support, it should not be advertised in stateless mode.
|
||||
Assert.Null(server.ClientCapabilities);
|
||||
|
||||
var asSamplingChatClientEx = Assert.Throws<InvalidOperationException>(() => server.AsSamplingChatClient());
|
||||
Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message);
|
||||
|
||||
var requestSamplingEx = await Assert.ThrowsAsync<InvalidOperationException>(() => server.RequestSamplingAsync([]));
|
||||
Assert.Equal(expectedSamplingErrorMessage, requestSamplingEx.Message);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => server.SendRequestAsync(new JsonRpcRequest { Method = RequestMethods.SamplingCreateMessage }));
|
||||
return ex.Message;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "testRootsErrors")]
|
||||
public static async Task<string> TestRootsErrors(IMcpServer server)
|
||||
{
|
||||
const string expectedRootsErrorMessage = "Roots are not supported in stateless mode.";
|
||||
|
||||
// Even when the client has roots support, it should not be advertised in stateless mode.
|
||||
Assert.Null(server.ClientCapabilities);
|
||||
|
||||
var requestRootsEx = Assert.Throws<InvalidOperationException>(() => server.RequestRootsAsync(new()));
|
||||
Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => server.SendRequestAsync(new JsonRpcRequest { Method = RequestMethods.RootsList }));
|
||||
return ex.Message;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "testScope")]
|
||||
public static string? TestScope(ScopedService scopedService) => scopedService.State;
|
||||
|
||||
public class ScopedService
|
||||
{
|
||||
public string? State { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
|
|||
Services = _app.Services,
|
||||
});
|
||||
|
||||
_app.MapPost("/mcp", async (JsonRpcMessage message) =>
|
||||
_app.MapPost("/mcp", (JsonRpcMessage message) =>
|
||||
{
|
||||
if (message is not JsonRpcRequest request)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
Assert.Contains("IdleTimeout", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NegativeMaxIdleSessionCount_Throws_ArgumentOutOfRangeException()
|
||||
{
|
||||
|
|
@ -360,6 +359,47 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
Assert.Equal(11, currentSseItem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsyncLocalSetInRunSessionHandlerCallback_Flows_ToAllToolCalls()
|
||||
{
|
||||
var asyncLocal = new AsyncLocal<string>();
|
||||
var totalSessionCount = 0;
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
options.RunSessionHandler = async (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
asyncLocal.Value = $"RunSessionHandler ({totalSessionCount++})";
|
||||
await mcpServer.RunAsync(cancellationToken);
|
||||
};
|
||||
});
|
||||
|
||||
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<CallToolResponse>(rpcResponse.Result);
|
||||
var callToolContent = Assert.Single(callToolResponse.Content);
|
||||
Assert.Equal("text", callToolContent.Type);
|
||||
Assert.Equal($"RunSessionHandler ({expectedSessionIndex})", 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ public class StreamableHttpServerIntegrationTests(SseServerIntegrationTestFixtur
|
|||
protected override SseClientTransportOptions ClientTransportOptions => new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/"),
|
||||
Name = "TestServer",
|
||||
Name = "In-memory Streamable HTTP Client",
|
||||
UseStreamableHttp = true,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol.Types;
|
|||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using Serilog;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
|
|
@ -16,12 +17,11 @@ public class Program
|
|||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose() // Capture all log levels
|
||||
.WriteTo.File(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "TestServer_.log"),
|
||||
.WriteTo.File(Path.Combine(AppContext.BaseDirectory, "logs", "TestServer_.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
|
||||
var logsPath = Path.Combine(AppContext.BaseDirectory, "testserver.log");
|
||||
loggingBuilder.AddSerilog();
|
||||
}
|
||||
|
||||
|
|
@ -378,6 +378,26 @@ public class Program
|
|||
};
|
||||
}
|
||||
|
||||
private static void HandleStatelessMcp(IApplicationBuilder app)
|
||||
{
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddLogging();
|
||||
serviceCollection.AddSingleton(app.ApplicationServices.GetRequiredService<ILoggerFactory>());
|
||||
serviceCollection.AddSingleton(app.ApplicationServices.GetRequiredService<DiagnosticListener>());
|
||||
serviceCollection.AddRoutingCore();
|
||||
|
||||
serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.Stateless = true);
|
||||
|
||||
var appBuilder = new ApplicationBuilder(serviceCollection.BuildServiceProvider());
|
||||
appBuilder.UseRouting();
|
||||
appBuilder.UseEndpoints(innerEndpoints =>
|
||||
{
|
||||
innerEndpoints.MapMcp("/stateless");
|
||||
});
|
||||
|
||||
app.Run(appBuilder.Build());
|
||||
}
|
||||
|
||||
public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvider = null, IConnectionListenerFactory? kestrelTransport = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine("Starting server...");
|
||||
|
|
@ -419,6 +439,9 @@ public class Program
|
|||
app.UseRouting();
|
||||
app.UseEndpoints(_ => { });
|
||||
|
||||
// Handle the /stateless endpoint if no other endpoints have been matched by the call to UseRouting above.
|
||||
HandleStatelessMcp(app);
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
await app.RunAsync(cancellationToken);
|
||||
|
|
|
|||
|
|
@ -581,6 +581,8 @@ public class McpServerTests : LoggedTest
|
|||
supportsSampling ? new ClientCapabilities { Sampling = new SamplingCapability() } :
|
||||
null;
|
||||
|
||||
public McpServerOptions ServerOptions => new();
|
||||
|
||||
public Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
CreateMessageRequestParams? rp = JsonSerializer.Deserialize<CreateMessageRequestParams>(request.Params, McpJsonUtilities.DefaultOptions);
|
||||
|
|
@ -617,7 +619,6 @@ public class McpServerTests : LoggedTest
|
|||
public ValueTask DisposeAsync() => default;
|
||||
|
||||
public Implementation? ClientInfo => throw new NotImplementedException();
|
||||
public McpServerOptions ServerOptions => throw new NotImplementedException();
|
||||
public IServiceProvider? Services => throw new NotImplementedException();
|
||||
public LoggingLevel? LoggingLevel => throw new NotImplementedException();
|
||||
public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue