Add StreamableHttpHandler and WithHttpTransport (#291)

* Simplify ModelContextProtocol.AspNetCore README

* Add StreamableHttpHandler

* Use "docker info" in CheckIsDockerAvailable

* Call WithHttpTransport in samples, tests and README

* Cleanup test namespaces

* Add CanConnect_WithMcpClient_AfterCustomizingRoute test

* Simplify relative URI handling

* Handle request made directly to the MapMcp route pattern

* Add Messages_FromNewUser_AreRejected test

* Fix README

* Shorten UserIdClaim ValueTuple names

* Remove MaxReconnectAttempts and ReconnectDelay from SseClientTransportOptions

- Add proper AdditionalHeaders support
This commit is contained in:
Stephen Halter 2025-04-14 07:15:33 -07:00 • committed by GitHub
commit 9c124b1770
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 597 additions and 324 deletions

View file

@ -0,0 +1,154 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Net;
using System.Security.Claims;
namespace ModelContextProtocol.AspNetCore.Tests;
public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
{
private async Task<IMcpClient> ConnectAsync(string? path = null)
{
var sseClientTransportOptions = new SseClientTransportOptions()
{
Endpoint = new Uri($"http://localhost{path}"),
};
await using var transport = new SseClientTransport(sseClientTransportOptions, HttpClient, LoggerFactory);
return await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public async Task MapMcp_ThrowsInvalidOperationException_IfWithHttpTransportIsNotCalled()
{
Builder.Services.AddMcpServer();
await using var app = Builder.Build();
var exception = Assert.Throws<InvalidOperationException>(() => app.MapMcp());
Assert.StartsWith("You must call WithHttpTransport()", exception.Message);
}
[Fact]
public async Task Allows_Customizing_Route()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp("/mcp");
await app.StartAsync(TestContext.Current.CancellationToken);
using var response = await HttpClient.GetAsync("http://localhost/mcp/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
[Theory]
[InlineData("/a", "/a/sse")]
[InlineData("/a", "/a/")]
[InlineData("/a/", "/a/sse")]
[InlineData("/a/", "/a/")]
public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePattern, string requestPath)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new()
{
Name = "TestCustomRouteServer",
Version = "1.0.0",
};
}).WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp(routePattern);
await app.StartAsync(TestContext.Current.CancellationToken);
var mcpClient = await ConnectAsync(requestPath);
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);
}
[Fact]
public async Task Messages_FromNewUser_AreRejected()
{
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<EchoHttpContextUserTools>();
// Add an authentication scheme that will send a 403 Forbidden response.
Builder.Services.AddAuthentication().AddBearerToken();
Builder.Services.AddHttpContextAccessor();
await using var app = Builder.Build();
app.Use(next =>
{
var i = 0;
return async context =>
{
context.User = CreateUser($"TestUser{Interlocked.Increment(ref i)}");
await next(context);
};
});
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
var httpRequestException = await Assert.ThrowsAsync<HttpRequestException>(() => ConnectAsync());
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
}
private ClaimsPrincipal CreateUser(string name)
=> new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
"TestAuthType", "name", "role"));
[McpServerToolType]
private class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
{
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
public string EchoWithUserName(string message)
{
var httpContext = contextAccessor.HttpContext ?? throw new Exception("HttpContext unavailable!");
var userName = httpContext.User.Identity?.Name ?? "anonymous";
return $"{userName}: {message}";
}
}
}

View file

@ -1,19 +0,0 @@
using Microsoft.AspNetCore.Builder;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.AspNetCore.Tests.Server;
public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
{
[Fact]
public async Task Allows_Customizing_Route()
{
await using var app = Builder.Build();
app.MapMcp("/mcp");
await app.StartAsync(TestContext.Current.CancellationToken);
using var httpClient = CreateHttpClient();
using var response = await httpClient.GetAsync("http://localhost/mcp/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
}

View file

@ -4,16 +4,16 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using ModelContextProtocol.Utils.Json;
using System.Text.Json.Serialization;
using TestServerWithHosting.Tools;
namespace ModelContextProtocol.Tests;
namespace ModelContextProtocol.AspNetCore.Tests;
public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
{
@ -23,23 +23,21 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
Name = "In-memory Test Server",
};
private Task<IMcpClient> ConnectMcpClient(HttpClient httpClient, McpClientOptions? clientOptions = null)
private Task<IMcpClient> ConnectMcpClient(HttpClient? httpClient = null, SseClientTransportOptions? transportOptions = null)
=> McpClientFactory.CreateAsync(
new SseClientTransport(DefaultTransportOptions, httpClient, LoggerFactory),
clientOptions,
LoggerFactory,
TestContext.Current.CancellationToken);
new SseClientTransport(transportOptions ?? DefaultTransportOptions, httpClient ?? HttpClient, LoggerFactory),
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
[Fact]
public async Task ConnectAndReceiveMessage_InMemoryServer()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
using var httpClient = CreateHttpClient();
await using var mcpClient = await ConnectMcpClient(httpClient);
await using var mcpClient = await ConnectMcpClient();
// 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);
@ -54,8 +52,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
MapAbsoluteEndpointUriMcp(app);
await app.StartAsync(TestContext.Current.CancellationToken);
using var httpClient = CreateHttpClient();
await using var mcpClient = await ConnectMcpClient(httpClient);
await using var mcpClient = await ConnectMcpClient();
// 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);
@ -68,20 +65,26 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
{
var receivedNotification = new TaskCompletionSource<string?>();
await using var app = Builder.Build();
app.MapMcp(runSessionAsync: (httpContext, mcpServer, cancellationToken) =>
{
mcpServer.RegisterNotificationHandler("test/notification", async (notification, cancellationToken) =>
Builder.Services.AddMcpServer()
.WithHttpTransport(httpTransportOptions =>
{
Assert.Equal("Hello from client!", notification.Params?["message"]?.GetValue<string>());
await mcpServer.SendNotificationAsync("test/notification", new Envelope { Message = "Hello from server!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: cancellationToken);
httpTransportOptions.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
{
// We could also use ServerCapabilities.NotificationHandlers, but it's good to have some test coverage of RunSessionHandler.
mcpServer.RegisterNotificationHandler("test/notification", async (notification, cancellationToken) =>
{
Assert.Equal("Hello from client!", notification.Params?["message"]?.GetValue<string>());
await mcpServer.SendNotificationAsync("test/notification", new Envelope { Message = "Hello from server!" }, serializerOptions: JsonContext.Default.Options, cancellationToken: cancellationToken);
});
return mcpServer.RunAsync(cancellationToken);
};
});
return mcpServer.RunAsync(cancellationToken);
});
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
using var httpClient = CreateHttpClient();
await using var mcpClient = await ConnectMcpClient(httpClient);
await using var mcpClient = await ConnectMcpClient();
mcpClient.RegisterNotificationHandler("test/notification", (args, ca) =>
{
@ -105,13 +108,14 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
Builder.Services.AddMcpServer(options =>
{
Interlocked.Increment(ref firstOptionsCallbackCallCount);
firstOptionsCallbackCallCount++;
})
.WithHttpTransport()
.WithTools<EchoTool>();
Builder.Services.AddMcpServer(options =>
{
Interlocked.Increment(ref secondOptionsCallbackCallCount);
secondOptionsCallbackCallCount++;
})
.WithTools<SampleLlmTool>();
@ -120,8 +124,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
using var httpClient = CreateHttpClient();
await using var mcpClient = await ConnectMcpClient(httpClient);
await using var mcpClient = await ConnectMcpClient();
// 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,
@ -147,6 +150,78 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
Assert.Equal("hello from client!", textContent.Text);
}
[Fact]
public async Task AdditionalHeaders_AreSent_InGetAndPostRequests()
{
Builder.Services.AddMcpServer()
.WithHttpTransport();
await using var app = Builder.Build();
bool wasGetRequest = false;
bool wasPostRequest = false;
app.Use(next =>
{
return async context =>
{
Assert.Equal("Bearer testToken", context.Request.Headers["Authorize"]);
if (context.Request.Method == HttpMethods.Get)
{
wasGetRequest = true;
}
else if (context.Request.Method == HttpMethods.Post)
{
wasPostRequest = true;
}
await next(context);
};
});
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
var sseOptions = new SseClientTransportOptions()
{
Endpoint = new Uri("http://localhost/sse"),
Name = "In-memory Test Server",
AdditionalHeaders = new()
{
["Authorize"] = "Bearer testToken"
},
};
await using var mcpClient = await ConnectMcpClient(transportOptions: sseOptions);
Assert.True(wasGetRequest);
Assert.True(wasPostRequest);
}
[Fact]
public async Task EmptyAdditionalHeadersKey_Throws_InvalidOpearionException()
{
Builder.Services.AddMcpServer()
.WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
var sseOptions = new SseClientTransportOptions()
{
Endpoint = new Uri("http://localhost/sse"),
Name = "In-memory Test Server",
AdditionalHeaders = new()
{
[""] = ""
},
};
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => ConnectMcpClient(transportOptions: sseOptions));
Assert.Equal("Failed to add header '' with value '' from AdditionalHeaders.", ex.Message);
}
private static void MapAbsoluteEndpointUriMcp(IEndpointRouteBuilder endpoints)
{
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();

View file

@ -1,11 +1,11 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Test.Utils;
using ModelContextProtocol.Tests.Utils;
using ModelContextProtocol.TestSseServer;
namespace ModelContextProtocol.Tests;
namespace ModelContextProtocol.AspNetCore.Tests;
public class SseServerIntegrationTestFixture : IAsyncDisposable
{

View file

@ -4,7 +4,7 @@ using ModelContextProtocol.Tests.Utils;
using System.Net;
using System.Text;
namespace ModelContextProtocol.Tests;
namespace ModelContextProtocol.AspNetCore.Tests;
public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
{

View file

@ -2,7 +2,7 @@
using Microsoft.AspNetCore.Http.Features;
using System.IO.Pipelines;
namespace ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
public sealed class KestrelInMemoryConnection : ConnectionContext
{

View file

@ -2,8 +2,9 @@
using Microsoft.AspNetCore.Connections;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
public class KestrelInMemoryTest : LoggedTest
{
@ -18,21 +19,24 @@ public class KestrelInMemoryTest : LoggedTest
Builder.Services.RemoveAll<IConnectionListenerFactory>();
Builder.Services.AddSingleton<IConnectionListenerFactory>(_inMemoryTransport);
Builder.Services.AddSingleton(LoggerProvider);
}
public WebApplicationBuilder Builder { get; }
public HttpClient CreateHttpClient()
{
var socketsHttpHandler = new SocketsHttpHandler()
HttpClient = new HttpClient(new SocketsHttpHandler()
{
ConnectCallback = (context, token) =>
{
var connection = _inMemoryTransport.CreateConnection();
return new(connection.ClientStream);
},
};
});
}
return new HttpClient(socketsHttpHandler);
public WebApplicationBuilder Builder { get; }
public HttpClient HttpClient { get; }
public override void Dispose()
{
HttpClient.Dispose();
base.Dispose();
}
}

View file

@ -2,7 +2,7 @@
using System.Net;
using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
public sealed class KestrelInMemoryTransport : IConnectionListenerFactory, IConnectionListener
{

View file

@ -1,5 +1,4 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Test.Utils;
namespace ModelContextProtocol.Tests.Utils;

View file

@ -2,7 +2,7 @@
using System.Text;
using Microsoft.Extensions.Logging;
namespace ModelContextProtocol.Test.Utils;
namespace ModelContextProtocol.Tests.Utils;
public class XunitLoggerProvider(ITestOutputHelper output) : ILoggerProvider
{