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:
Stephen Halter 2025-05-14 04:51:03 -07:00 • committed by GitHub
commit ce9f73f43f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 785 additions and 230 deletions

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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();

View file

@ -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);
}

View file

@ -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]

View file

@ -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,
};
}

View file

@ -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; }
}
}

View file

@ -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)
{

View file

@ -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()
{

View file

@ -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,
};

View file

@ -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);

View file

@ -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) =>