Skip to content

Allow more accept headers #429

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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;
Expand All @@ -28,9 +29,6 @@ internal sealed class StreamableHttpHandler(
{
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();

private static readonly MediaTypeHeaderValue s_applicationJsonMediaType = new("application/json");
private static readonly MediaTypeHeaderValue s_textEventStreamMediaType = new("text/event-stream");

public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);

public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
Expand All @@ -43,8 +41,8 @@ public async Task HandlePostRequestAsync(HttpContext context)
// 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",
Expand Down Expand Up @@ -85,8 +83,7 @@ await WriteJsonRpcErrorAsync(context,

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",
Expand Down Expand Up @@ -331,6 +328,12 @@ internal static Task RunSessionAsync(HttpContext httpContext, IMcpServer session

private static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public override async Task SendMessageAsync(
using var content = new StringContent(
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
Encoding.UTF8,
"application/json"
"application/json; charset=utf-8"
);
#endif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public async Task PostRequest_IsUnsupportedMediaType_WithoutJsonContentType()
[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();

Expand All @@ -116,6 +116,20 @@ public async Task PostRequest_IsNotAcceptable_WithSingleAcceptHeader(string sing
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()
{
Expand All @@ -128,6 +142,22 @@ public async Task GetRequest_IsNotAcceptable_WithoutTextEventStreamAcceptHeader(
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()
{
Expand Down