csharp-sdk/tests/ModelContextProtocol.Tests/Utils/InMemoryTestSseServer.cs
Stephen Toub c452dc8e26
Reduce dependencies to 8.x versions of packages (#195)
* Reduce dependencies to 8.x versions of packages

When building for netstandard2.0 or net8.0, the ModelContextProtocol library now only depends on 8.x versions of nuget packages, rather than using the newer 9.x versions. The exception to this are packages that don't have 8.x versions.

This also introduces a net9.0 tfm. When building for net9.0, it'll use 9.x versions of packages.

The PR temporarily removes the Microsoft.Extensions.AI.nupkg reference (the Microsoft.Extensions.AI.Abstractions reference is still very much there). The latest Microsoft.Extensions.AI package on nuget has a System.Text.Json 9.x reference, but the next build to be published drops that down to 8.x as well. Once that build is published, the dependency will be added back, and temporary polyfills (in particular TemporaryAIFunctionFactory) will be deleted.

* Fix StdioClientSessionTransport shutdown

StreamClientSessionTransport's ReadMessageAsync method does await CleanupAsync in its finally. CleanupAsync awaits _readTask. _readTask _is_ ReadMessageAsync. So it's waiting for itself to complete, which will then always timeout. We may want to refactor how this works more in the future, but for now, make sure it doesn't wait for itself.

* Fix dangling requests when server unexpectedly shuts down

If the client makes a request to the server and while waiting for the response the server shuts down, the client hangs. Make sure that when the client's message loops ends due to the server's output it's reading from ends, any pending requests are notified.

* Add net9.0 testing

* Avoid docker port conflicts from test suites running concurrently
2025-04-03 14:01:46 -04:00

382 lines
13 KiB
C#

using System.Collections.Concurrent;
using System.Net;
using System.Text.Json;
using ModelContextProtocol.Protocol.Messages;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol.Types;
namespace ModelContextProtocol.Tests.Utils;
public sealed class InMemoryTestSseServer : IAsyncDisposable
{
private readonly HttpListener _listener;
private readonly CancellationTokenSource _cts;
private readonly ILogger<InMemoryTestSseServer> _logger;
private Task? _serverTask;
private readonly TaskCompletionSource _connectionEstablished = new();
// SSE endpoint for GET
private readonly string _endpointPath;
// POST endpoint
private readonly string _messagePath;
// Keep track of all open SSE connections (StreamWriters).
private readonly ConcurrentBag<StreamWriter> _sseClients = [];
public InMemoryTestSseServer(int port = 5000, ILogger<InMemoryTestSseServer>? logger = null)
{
Port = port;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/");
_cts = new CancellationTokenSource();
_logger = logger ?? NullLogger<InMemoryTestSseServer>.Instance;
_endpointPath = "/sse";
_messagePath = "/message";
}
public int Port { get; }
/// <summary>
/// This is to be able to use the full URL for the endpoint event.
/// </summary>
public bool UseFullUrlForEndpointEvent { get; set; }
/// <summary>
/// Full URL for the SSE endpoint, e.g. "http://localhost:5000/sse"
/// </summary>
public string SseEndpoint
=> $"http://localhost:{_listener.Prefixes.First().Split(':')[2].TrimEnd('/')}{_endpointPath}";
/// <summary>
/// Full URL for the message endpoint, e.g. "http://localhost:5000/message"
/// </summary>
public string MessageEndpoint
=> $"http://localhost:{_listener.Prefixes.First().Split(':')[2].TrimEnd('/')}{_messagePath}";
/// <summary>
/// Starts the server so it can accept incoming connections and POST requests.
/// </summary>
public async Task StartAsync()
{
_listener.Start();
_serverTask = HandleConnectionsAsync(_cts.Token);
_logger.LogInformation("Test SSE server started on {Endpoint}", SseEndpoint);
await Task.CompletedTask;
}
private async Task HandleConnectionsAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context, ct), ct);
}
}
catch (OperationCanceledException)
{
// Ignore, we are shutting down
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
_logger.LogError(ex, "Error in SSE server connection handling");
}
}
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken ct)
{
var request = context.Request;
var response = context.Response;
// Handle SSE endpoint
if (request.HttpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase)
&& request.Url?.AbsolutePath.Equals(_endpointPath, StringComparison.OrdinalIgnoreCase) == true)
{
await HandleSseConnectionAsync(context, ct);
}
// Handle POST /message
else if (request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase)
&& request.Url?.AbsolutePath.Equals(_messagePath, StringComparison.OrdinalIgnoreCase) == true)
{
await HandlePostMessageAsync(context, ct);
}
else
{
response.StatusCode = 404;
response.Close();
}
}
/// <summary>
/// Handle Server-Sent Events (SSE) connection.
/// Send the initial event: endpoint with the full POST URL.
/// Keep the connection open until the server is disposed or the client disconnects.
/// </summary>
private async Task HandleSseConnectionAsync(HttpListenerContext context, CancellationToken ct)
{
var response = context.Response;
response.ContentType = "text/event-stream";
response.Headers["Cache-Control"] = "no-cache";
// Ensures the response is never chunked away by the framework
response.SendChunked = true;
response.StatusCode = (int)HttpStatusCode.OK;
using var writer = new StreamWriter(response.OutputStream);
_sseClients.Add(writer);
// Immediately send the "endpoint" event with the POST URL
await writer.WriteLineAsync("event: endpoint");
if (UseFullUrlForEndpointEvent)
{
await writer.WriteLineAsync($"data: {MessageEndpoint}");
}
else
{
await writer.WriteLineAsync($"data: {_messagePath}");
}
await writer.WriteLineAsync(); // blank line to end an SSE message
await writer.FlushAsync(ct);
_logger.LogInformation("New SSE client connected.");
_connectionEstablished.TrySetResult(); // Signal connection is ready
try
{
// Keep the connection open by "pinging" or just waiting
// until the client disconnects or the server is canceled.
while (!ct.IsCancellationRequested && response.OutputStream.CanWrite)
{
_logger.LogDebug("SSE connection alive check");
// Optionally do a periodic no-op to keep connection alive:
await writer.WriteLineAsync(": keep-alive");
await writer.FlushAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
catch (TaskCanceledException)
{
// This is expected on shutdown
_logger.LogInformation("SSE connection cancelled (expected on shutdown)");
}
catch (IOException)
{
// Client likely disconnected
_logger.LogInformation("SSE client disconnected");
}
finally
{
// Remove this writer from bag (we're disposing it anyway)
_sseClients.TryTake(out _);
_logger.LogInformation("SSE client disconnected.");
}
}
// Add method to wait for connection
public Task WaitForConnectionAsync(TimeSpan timeout) =>
_connectionEstablished.Task.WaitAsync(timeout);
/// <summary>
/// Handle POST /message endpoint.
/// Echo the content back to the caller and broadcast it over SSE as well.
/// </summary>
private async Task HandlePostMessageAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var request = context.Request;
var response = context.Response;
try
{
using var reader = new StreamReader(request.InputStream);
string content = await reader.ReadToEndAsync(cancellationToken);
var jsonRpcNotification = JsonSerializer.Deserialize<JsonRpcNotification>(content);
if (jsonRpcNotification != null && jsonRpcNotification.Method != RequestMethods.Initialize)
{
// Test server so just ignore notifications
// Set status code to 202 Accepted
response.StatusCode = 202;
// Write "accepted" as the response body
using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync("accepted");
await writer.FlushAsync(cancellationToken);
return;
}
var jsonRpcRequest = JsonSerializer.Deserialize<JsonRpcRequest>(content);
if (jsonRpcRequest != null)
{
if (jsonRpcRequest.Method == RequestMethods.Initialize)
{
await HandleInitializationRequest(response, jsonRpcRequest);
}
else
{
// Set status code to 202 Accepted
response.StatusCode = 202;
// Write "accepted" as the response body
using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync("accepted");
await writer.FlushAsync(cancellationToken);
// Then process the message and send the response via SSE
if (jsonRpcRequest != null)
{
// Process the request and generate a response
var responseMessage = await ProcessRequestAsync(jsonRpcRequest);
// Send the response via the SSE connection instead of HTTP response
await SendSseMessageAsync(responseMessage);
}
}
}
}
catch (Exception ex)
{
await SendJsonRpcErrorAsync(response, null, -32603, "Internal error", ex.Message);
response.StatusCode = 500;
}
finally
{
response.Close();
}
}
private static Task<JsonRpcResponse> ProcessRequestAsync(JsonRpcRequest jsonRpcRequest)
{
// This is a test server so we just echo back the request
return Task.FromResult(new JsonRpcResponse
{
Id = jsonRpcRequest.Id,
Result = jsonRpcRequest.Params!
});
}
private async Task SendSseMessageAsync(JsonRpcResponse jsonRpcResponse)
{
// This is a test server so we just send to all connected clients
await BroadcastMessageAsync(JsonSerializer.Serialize(jsonRpcResponse));
}
private static async Task SendJsonRpcErrorAsync(HttpListenerResponse response, RequestId? id, int code, string message, string? data = null)
{
var errorResponse = new JsonRpcError
{
Id = id ?? new RequestId("error"),
JsonRpc = "2.0",
Error = new JsonRpcErrorDetail
{
Code = code,
Message = message,
Data = data
}
};
response.StatusCode = 200; // Always 200 for JSON-RPC
response.ContentType = "application/json";
await using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync(JsonSerializer.Serialize(errorResponse));
}
private static async Task HandleInitializationRequest(HttpListenerResponse response, JsonRpcRequest jsonRpcRequest)
{
// We don't need to validate the client's initialization request for the test
// Just send back a valid server initialization response
var jsonRpcResponse = new JsonRpcResponse()
{
Id = jsonRpcRequest.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "2024-11-05",
Capabilities = new(),
ServerInfo = new()
{
Name = "ExampleServer",
Version = "1.0.0"
}
})
};
// Echo back to the HTTP response
response.StatusCode = 200;
response.ContentType = "application/json";
await using var writer = new StreamWriter(response.OutputStream);
await writer.WriteAsync(JsonSerializer.Serialize(jsonRpcResponse));
}
/// <summary>
/// Broadcast a message to all currently connected SSE clients.
/// </summary>
/// <param name="message">The raw string to send</param>
public async Task BroadcastMessageAsync(string message)
{
foreach (var client in _sseClients.ToArray()) // ToArray to avoid mutation issues
{
try
{
// SSE requires "event: <name>" + "data: <payload>" + blank line
await client.WriteLineAsync("event: message");
await client.WriteLineAsync($"data: {message}");
await client.WriteLineAsync();
await client.FlushAsync();
}
catch (IOException)
{
// Client may have disconnected. We let them get cleaned up on next iteration.
}
catch (ObjectDisposedException)
{
// Stream is disposed, ignore.
}
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_serverTask != null)
{
try
{
await Task.WhenAny(_serverTask, Task.Delay(2000));
}
catch (TaskCanceledException)
{
// ignore
}
}
_listener.Close();
_cts.Dispose();
_logger.LogInformation("Test SSE server stopped");
}
/// <summary>
/// Send a test notification to all connected SSE clients.
/// </summary>
/// <param name="content"></param>
/// <returns></returns>
public async Task SendTestNotificationAsync(string content)
{
var notification = new JsonRpcNotification
{
JsonRpc = "2.0",
Method = "test/notification",
Params = JsonSerializer.SerializeToNode(new { message = content }),
};
var serialized = JsonSerializer.Serialize(notification);
await BroadcastMessageAsync(serialized);
}
}