diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index ec545c9..6077efa 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using ModelContextProtocol.AspNetCore.Stateless; using ModelContextProtocol.Protocol; @@ -28,9 +29,6 @@ internal sealed class StreamableHttpHandler( { private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); - private static readonly MediaTypeHeaderValue s_applicationJsonMediaType = new("application/json"); - private static readonly MediaTypeHeaderValue s_textEventStreamMediaType = new("text/event-stream"); - public ConcurrentDictionary> Sessions { get; } = new(StringComparer.Ordinal); public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value; @@ -43,8 +41,8 @@ internal sealed class StreamableHttpHandler( // ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation, // so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests, // but it's probably good to at least start out trying to be strict. - var acceptHeaders = context.Request.GetTypedHeaders().Accept; - if (!acceptHeaders.Contains(s_applicationJsonMediaType) || !acceptHeaders.Contains(s_textEventStreamMediaType)) + var typedHeaders = context.Request.GetTypedHeaders(); + if (!typedHeaders.Accept.Any(MatchesApplicationJsonMediaType) || !typedHeaders.Accept.Any(MatchesTextEventStreamMediaType)) { await WriteJsonRpcErrorAsync(context, "Not Acceptable: Client must accept both application/json and text/event-stream", @@ -85,8 +83,7 @@ internal sealed class StreamableHttpHandler( public async Task HandleGetRequestAsync(HttpContext context) { - var acceptHeaders = context.Request.GetTypedHeaders().Accept; - if (!acceptHeaders.Contains(s_textEventStreamMediaType)) + if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType)) { await WriteJsonRpcErrorAsync(context, "Not Acceptable: Client must accept text/event-stream", @@ -331,6 +328,12 @@ internal sealed class StreamableHttpHandler( private static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue) + => acceptHeaderValue.MatchesMediaType("application/json"); + + private static bool MatchesTextEventStreamMediaType(MediaTypeHeaderValue acceptHeaderValue) + => acceptHeaderValue.MatchesMediaType("text/event-stream"); + private sealed class HttpDuplexPipe(HttpContext context) : IDuplexPipe { public PipeReader Input => context.Request.BodyReader; diff --git a/src/ModelContextProtocol/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol/Client/StreamableHttpClientSessionTransport.cs index f903490..55ecb96 100644 --- a/src/ModelContextProtocol/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol/Client/StreamableHttpClientSessionTransport.cs @@ -59,7 +59,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa using var content = new StringContent( JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage), Encoding.UTF8, - "application/json" + "application/json; charset=utf-8" ); #endif diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 17b1234..3efc104 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -105,7 +105,7 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper [InlineData("text/event-stream")] [InlineData("application/json")] [InlineData("application/json-text/event-stream")] - public async Task PostRequest_IsNotAcceptable_WithSingleAcceptHeader(string singleAcceptValue) + public async Task PostRequest_IsNotAcceptable_WithSingleSpecificAcceptHeader(string singleAcceptValue) { await StartAsync(); @@ -116,6 +116,20 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper 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() { @@ -128,6 +142,22 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper 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); + } + [Fact] public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId() {