Use Kestrel for all in-memory HTTP tests (#225)
* Add route pattern parameter to MapMcp * Add configureOptionsAsync * Use Kestrel for all in-memory HTTP tests * Use ApplicationStopping token for SSE responses * Use RegisterNotificationHandler in MapMcp tests * Work around SocketsHttpHandler bug where it doesn't call DispseAsync on the stream returned by the ConnectCallback
This commit is contained in:
parent
848c9df433
commit
faf12b6a94
1 changed files with 631 additions and 642 deletions
|
|
@ -1,238 +1,162 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
|
||||
namespace ModelContextProtocol.Tests;
|
||||
|
||||
public class SseIntegrationTests(ITestOutputHelper outputHelper) : LoggedTest(outputHelper)
|
||||
public class SseIntegrationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
|
||||
{
|
||||
/// <summary>Port number to be grabbed by the next test.</summary>
|
||||
private static int s_nextPort = 3000;
|
||||
|
||||
// If the tests run concurrently against different versions of the runtime, tests can conflict with
|
||||
// each other in the ports set up for interacting with containers. Ensure that such suites running
|
||||
// against different TFMs use different port numbers.
|
||||
private static readonly int s_portOffset = 1000 * (Environment.Version.Major switch
|
||||
private McpServerConfig DefaultServerConfig = new()
|
||||
{
|
||||
int v when v >= 8 => Environment.Version.Major - 7,
|
||||
_ => 0,
|
||||
});
|
||||
Id = "test_server",
|
||||
Name = "In-memory Test Server",
|
||||
TransportType = TransportTypes.Sse,
|
||||
TransportOptions = [],
|
||||
Location = $"http://localhost/sse"
|
||||
};
|
||||
|
||||
private Task<IMcpClient> ConnectMcpClient(HttpClient httpClient, McpClientOptions? clientOptions = null)
|
||||
=> McpClientFactory.CreateAsync(
|
||||
DefaultServerConfig,
|
||||
clientOptions,
|
||||
(_, _) => new SseClientTransport(new(), DefaultServerConfig, httpClient, LoggerFactory),
|
||||
LoggerFactory,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
private static int CreatePortNumber() => Interlocked.Increment(ref s_nextPort) + s_portOffset;
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAndReceiveMessage_InMemoryServer()
|
||||
{
|
||||
// Arrange
|
||||
await using InMemoryTestSseServer server = new(CreatePortNumber(), LoggerFactory.CreateLogger<InMemoryTestSseServer>());
|
||||
await server.StartAsync();
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var defaultConfig = new McpServerConfig
|
||||
{
|
||||
Id = "test_server",
|
||||
Name = "In-memory Test Server",
|
||||
TransportType = TransportTypes.Sse,
|
||||
TransportOptions = [],
|
||||
Location = $"http://localhost:{server.Port}/sse"
|
||||
};
|
||||
|
||||
// Act
|
||||
await using var client = await McpClientFactory.CreateAsync(
|
||||
defaultConfig,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait for SSE connection to be established
|
||||
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
|
||||
using var httpClient = CreateHttpClient();
|
||||
await using var mcpClient = await ConnectMcpClient(httpClient);
|
||||
|
||||
// Send a test message through POST endpoint
|
||||
await client.SendNotificationAsync("test/message", new { message = "Hello, SSE!" }, cancellationToken: TestContext.Current.CancellationToken);
|
||||
await mcpClient.SendNotificationAsync("test/message", new { message = "Hello, SSE!" }, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Execution", "Manual")]
|
||||
public async Task ConnectAndReceiveMessage_EverythingServerWithSse()
|
||||
{
|
||||
Assert.SkipWhen(!EverythingSseServerFixture.IsDockerAvailable, "docker is not available");
|
||||
|
||||
int port = CreatePortNumber();
|
||||
|
||||
await using var fixture = new EverythingSseServerFixture(port);
|
||||
await fixture.StartAsync();
|
||||
|
||||
var defaultOptions = new McpClientOptions
|
||||
{
|
||||
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
|
||||
};
|
||||
|
||||
var defaultConfig = new McpServerConfig
|
||||
{
|
||||
Id = "everything",
|
||||
Name = "Everything",
|
||||
TransportType = TransportTypes.Sse,
|
||||
TransportOptions = [],
|
||||
Location = $"http://localhost:{port}/sse"
|
||||
};
|
||||
|
||||
// Create client and run tests
|
||||
await using var client = await McpClientFactory.CreateAsync(
|
||||
defaultConfig,
|
||||
defaultOptions,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotEmpty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Execution", "Manual")]
|
||||
public async Task Sampling_Sse_EverythingServer()
|
||||
{
|
||||
Assert.SkipWhen(!EverythingSseServerFixture.IsDockerAvailable, "docker is not available");
|
||||
|
||||
int port = CreatePortNumber();
|
||||
|
||||
await using var fixture = new EverythingSseServerFixture(port);
|
||||
await fixture.StartAsync();
|
||||
|
||||
var defaultConfig = new McpServerConfig
|
||||
{
|
||||
Id = "everything",
|
||||
Name = "Everything",
|
||||
TransportType = TransportTypes.Sse,
|
||||
TransportOptions = [],
|
||||
Location = $"http://localhost:{port}/sse"
|
||||
};
|
||||
|
||||
int samplingHandlerCalls = 0;
|
||||
var defaultOptions = new McpClientOptions
|
||||
{
|
||||
Capabilities = new()
|
||||
{
|
||||
Sampling = new()
|
||||
{
|
||||
SamplingHandler = (_, _, _) =>
|
||||
{
|
||||
samplingHandlerCalls++;
|
||||
return Task.FromResult(new CreateMessageResult
|
||||
{
|
||||
Model = "test-model",
|
||||
Role = "assistant",
|
||||
Content = new Content
|
||||
{
|
||||
Type = "text",
|
||||
Text = "Test response"
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await using var client = await McpClientFactory.CreateAsync(
|
||||
defaultConfig,
|
||||
defaultOptions,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Call the server's sampleLLM tool which should trigger our sampling handler
|
||||
var result = await client.CallToolAsync("sampleLLM", new Dictionary<string, object?>
|
||||
{
|
||||
["prompt"] = "Test prompt",
|
||||
["maxTokens"] = 100
|
||||
}, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(result);
|
||||
var textContent = Assert.Single(result.Content);
|
||||
Assert.Equal("text", textContent.Type);
|
||||
Assert.False(string.IsNullOrEmpty(textContent.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAndReceiveMessage_InMemoryServer_WithFullEndpointEventUri()
|
||||
{
|
||||
// Arrange
|
||||
await using InMemoryTestSseServer server = new(CreatePortNumber(), LoggerFactory.CreateLogger<InMemoryTestSseServer>());
|
||||
server.UseFullUrlForEndpointEvent = true;
|
||||
await server.StartAsync();
|
||||
await using var app = Builder.Build();
|
||||
MapAbsoluteEndpointUriMcp(app);
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var defaultConfig = new McpServerConfig
|
||||
{
|
||||
Id = "test_server",
|
||||
Name = "In-memory Test Server",
|
||||
TransportType = TransportTypes.Sse,
|
||||
TransportOptions = [],
|
||||
Location = $"http://localhost:{server.Port}/sse"
|
||||
};
|
||||
|
||||
// Act
|
||||
await using var client = await McpClientFactory.CreateAsync(
|
||||
defaultConfig,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait for SSE connection to be established
|
||||
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
|
||||
using var httpClient = CreateHttpClient();
|
||||
await using var mcpClient = await ConnectMcpClient(httpClient);
|
||||
|
||||
// Send a test message through POST endpoint
|
||||
await client.SendNotificationAsync("test/message", new { message = "Hello, SSE!" }, cancellationToken: TestContext.Current.CancellationToken);
|
||||
await mcpClient.SendNotificationAsync("test/message", new { message = "Hello, SSE!" }, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAndReceiveNotification_InMemoryServer()
|
||||
{
|
||||
// Arrange
|
||||
await using InMemoryTestSseServer server = new(CreatePortNumber(), LoggerFactory.CreateLogger<InMemoryTestSseServer>());
|
||||
await server.StartAsync();
|
||||
|
||||
var defaultConfig = new McpServerConfig
|
||||
{
|
||||
Id = "test_server",
|
||||
Name = "In-memory Test Server",
|
||||
TransportType = TransportTypes.Sse,
|
||||
TransportOptions = [],
|
||||
Location = $"http://localhost:{server.Port}/sse"
|
||||
};
|
||||
|
||||
// Act
|
||||
var receivedNotification = new TaskCompletionSource<string?>();
|
||||
await using var client = await McpClientFactory.CreateAsync(
|
||||
defaultConfig,
|
||||
new()
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp(runSessionAsync: (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
mcpServer.RegisterNotificationHandler("test/notification", async (notification, cancellationToken) =>
|
||||
{
|
||||
Capabilities = new()
|
||||
{
|
||||
NotificationHandlers = [new("test/notification", (notification, cancellationToken) =>
|
||||
{
|
||||
var msg = notification.Params?["message"]?.GetValue<string>();
|
||||
receivedNotification.SetResult(msg);
|
||||
Assert.Equal("Hello from client!", notification.Params?["message"]?.GetValue<string>());
|
||||
await mcpServer.SendNotificationAsync("test/notification", new { message = "Hello from server!" }, cancellationToken: cancellationToken);
|
||||
});
|
||||
return mcpServer.RunAsync(cancellationToken);
|
||||
});
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
})],
|
||||
},
|
||||
},
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
using var httpClient = CreateHttpClient();
|
||||
await using var mcpClient = await ConnectMcpClient(httpClient);
|
||||
|
||||
// Wait for SSE connection to be established
|
||||
await server.WaitForConnectionAsync(TimeSpan.FromSeconds(10));
|
||||
mcpClient.RegisterNotificationHandler("test/notification", (args, ca) =>
|
||||
{
|
||||
var msg = args.Params?["message"]?.GetValue<string>();
|
||||
receivedNotification.SetResult(msg);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Act
|
||||
await server.SendTestNotificationAsync("Hello from server!");
|
||||
// Send a test message through POST endpoint
|
||||
await mcpClient.SendNotificationAsync("test/notification", new { message = "Hello from client!" }, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var message = await receivedNotification.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Hello from server!", message);
|
||||
}
|
||||
|
||||
private static void MapAbsoluteEndpointUriMcp(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var optionsSnapshot = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
|
||||
|
||||
var routeGroup = endpoints.MapGroup("");
|
||||
SseResponseStreamTransport? session = null;
|
||||
|
||||
routeGroup.MapGet("/sse", async context =>
|
||||
{
|
||||
var response = context.Response;
|
||||
var requestAborted = context.RequestAborted;
|
||||
|
||||
response.Headers.ContentType = "text/event-stream";
|
||||
|
||||
await using var transport = new SseResponseStreamTransport(response.Body, "http://localhost/message");
|
||||
session = transport;
|
||||
|
||||
try
|
||||
{
|
||||
var transportTask = transport.RunAsync(cancellationToken: requestAborted);
|
||||
await using var server = McpServerFactory.Create(transport, optionsSnapshot.Value, loggerFactory, endpoints.ServiceProvider);
|
||||
|
||||
try
|
||||
{
|
||||
await server.RunAsync(requestAborted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await transport.DisposeAsync();
|
||||
await transportTask;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
|
||||
{
|
||||
// RequestAborted always triggers when the client disconnects before a complete response body is written,
|
||||
// but this is how SSE connections are typically closed.
|
||||
}
|
||||
});
|
||||
|
||||
routeGroup.MapPost("/message", async context =>
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
await Results.BadRequest("Session not started.").ExecuteAsync(context);
|
||||
return;
|
||||
}
|
||||
var message = (IJsonRpcMessage?)await context.Request.ReadFromJsonAsync(McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IJsonRpcMessage)), context.RequestAborted);
|
||||
if (message is null)
|
||||
{
|
||||
await Results.BadRequest("No message in request body.").ExecuteAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await session.OnMessageReceivedAsync(message, context.RequestAborted);
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
await context.Response.WriteAsync("Accepted");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue