Add diagnostics for messages dropped on the GET SSE stream (#1609)
This commit is contained in:
parent
acb4cc9fd1
commit
ed07fdb6f4
1 changed files with 255 additions and 4 deletions
|
|
@ -384,7 +384,8 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
public async Task SendNotificationAsync_DoesNotThrow_WhenNoGetRequestHasBeenMade()
|
||||
{
|
||||
// Clients are not required to make a GET request for unsolicited messages.
|
||||
// If no GET request has been made, the messages should be dropped rather than throwing.
|
||||
// If no GET request has been made, the messages should be dropped rather than throwing,
|
||||
// and the drop should be visible as a Debug-level log so it can be diagnosed.
|
||||
McpServer? server = null;
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
|
|
@ -409,6 +410,156 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
var exception = await Record.ExceptionAsync(() =>
|
||||
server.SendNotificationAsync("test-method", TestContext.Current.CancellationToken));
|
||||
Assert.Null(exception);
|
||||
|
||||
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||
log.Category == typeof(StreamableHttpServerTransport).FullName &&
|
||||
log.LogLevel == LogLevel.Debug &&
|
||||
log.Message.Contains("test-method") &&
|
||||
log.Message.Contains("no GET SSE stream"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendRequestAsync_Throws_WhenNoGetRequestHasBeenMade()
|
||||
{
|
||||
// A server-to-client request sent before any GET SSE stream is opened can never
|
||||
// receive a response, so the transport should fail fast with InvalidOperationException
|
||||
// instead of silently dropping the message and leaving the caller hanging on the TCS
|
||||
// registered by SendRequestAsync.
|
||||
McpServer? server = null;
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
#pragma warning disable MCPEXP002 // RunSessionHandler is experimental
|
||||
options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
server = mcpServer;
|
||||
return mcpServer.RunAsync(cancellationToken);
|
||||
};
|
||||
#pragma warning restore MCPEXP002
|
||||
});
|
||||
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
Assert.NotNull(server);
|
||||
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Method = "roots/list",
|
||||
Id = new RequestId(42),
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
server.SendRequestAsync(request, TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains("roots/list", ex.Message);
|
||||
Assert.Contains("no GET SSE stream", ex.Message);
|
||||
Assert.Contains("RequestContext", ex.Message);
|
||||
Assert.Contains("RelatedTransport", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_LogsWarning_OnUnexpectedResponse_WhenNoGetRequestHasBeenMade()
|
||||
{
|
||||
// Responses normally ride the originating POST response stream via RelatedTransport, so
|
||||
// receiving one through the GET path without an open GET is unexpected. The message is
|
||||
// dropped (preserving best-effort semantics) but a warning is logged so the situation is
|
||||
// visible.
|
||||
McpServer? server = null;
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
#pragma warning disable MCPEXP002 // RunSessionHandler is experimental
|
||||
options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
server = mcpServer;
|
||||
return mcpServer.RunAsync(cancellationToken);
|
||||
};
|
||||
#pragma warning restore MCPEXP002
|
||||
});
|
||||
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
Assert.NotNull(server);
|
||||
|
||||
var response = new JsonRpcResponse
|
||||
{
|
||||
Id = new RequestId(7),
|
||||
Result = new JsonObject(),
|
||||
};
|
||||
|
||||
var exception = await Record.ExceptionAsync(() =>
|
||||
server.SendMessageAsync(response, TestContext.Current.CancellationToken));
|
||||
Assert.Null(exception);
|
||||
|
||||
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||
log.Category == typeof(StreamableHttpServerTransport).FullName &&
|
||||
log.LogLevel == LogLevel.Warning &&
|
||||
log.Message.Contains(nameof(JsonRpcResponse)) &&
|
||||
log.Message.Contains("no GET SSE stream"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendRequestAsync_LogsWarning_WhenGetRequestIsOpen()
|
||||
{
|
||||
// Even when the GET SSE stream is open and the request is delivered, server-to-client
|
||||
// requests sent via the GET path are fragile (no per-request correlation, depend on a
|
||||
// long-lived GET, race with startup/teardown). A warning is logged to direct callers at
|
||||
// the RequestContext.RelatedTransport channel instead, without changing behavior.
|
||||
McpServer? server = null;
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
#pragma warning disable MCPEXP002 // RunSessionHandler is experimental
|
||||
options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
server = mcpServer;
|
||||
return mcpServer.RunAsync(cancellationToken);
|
||||
};
|
||||
#pragma warning restore MCPEXP002
|
||||
});
|
||||
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
Assert.NotNull(server);
|
||||
|
||||
using var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
|
||||
// Send a request via the GET stream and assert it lands on the wire (proving behavior is unchanged).
|
||||
// SendRequestAsync awaits a response that the test never produces, so use a CTS to cancel after
|
||||
// confirming wire delivery.
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Method = "roots/list",
|
||||
Id = new RequestId(99),
|
||||
};
|
||||
|
||||
using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var sendTask = server.SendRequestAsync(request, requestCts.Token);
|
||||
|
||||
await foreach (var sseEvent in ReadSseAsync(getResponse.Content))
|
||||
{
|
||||
var received = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcRequest>());
|
||||
Assert.NotNull(received);
|
||||
Assert.Equal("roots/list", received.Method);
|
||||
break;
|
||||
}
|
||||
|
||||
// Cancel the awaited response so SendRequestAsync completes — the wire delivery has already happened.
|
||||
requestCts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => sendTask);
|
||||
|
||||
Assert.Contains(MockLoggerProvider.LogMessages, log =>
|
||||
log.Category == typeof(StreamableHttpServerTransport).FullName &&
|
||||
log.LogLevel == LogLevel.Warning &&
|
||||
log.Message.Contains("roots/list") &&
|
||||
log.Message.Contains("RequestContext"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Reference in a new issue