Add client-side Streamable HTTP transport support (#356)
This commit is contained in:
parent
7c507bc1d7
commit
e913d80f05
19 changed files with 908 additions and 464 deletions
|
|
@ -23,9 +23,9 @@ internal sealed class StreamableHttpHandler(
|
|||
ILoggerFactory loggerFactory,
|
||||
IServiceProvider applicationServices)
|
||||
{
|
||||
private static JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
|
||||
private static MediaTypeHeaderValue ApplicationJsonMediaType = new("application/json");
|
||||
private static MediaTypeHeaderValue TextEventStreamMediaType = new("text/event-stream");
|
||||
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);
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ internal sealed class StreamableHttpHandler(
|
|||
// 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(ApplicationJsonMediaType) || !acceptHeaders.Contains(TextEventStreamMediaType))
|
||||
if (!acceptHeaders.Contains(s_applicationJsonMediaType) || !acceptHeaders.Contains(s_textEventStreamMediaType))
|
||||
{
|
||||
await WriteJsonRpcErrorAsync(context,
|
||||
"Not Acceptable: Client must accept both application/json and text/event-stream",
|
||||
|
|
@ -64,7 +64,7 @@ internal sealed class StreamableHttpHandler(
|
|||
public async Task HandleGetRequestAsync(HttpContext context)
|
||||
{
|
||||
var acceptHeaders = context.Request.GetTypedHeaders().Accept;
|
||||
if (!acceptHeaders.Contains(TextEventStreamMediaType))
|
||||
if (!acceptHeaders.Contains(s_textEventStreamMediaType))
|
||||
{
|
||||
await WriteJsonRpcErrorAsync(context,
|
||||
"Not Acceptable: Client must accept text/event-stream",
|
||||
|
|
|
|||
|
|
@ -87,40 +87,17 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
|
||||
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
CopyAdditionalHeaders(httpRequestMessage.Headers);
|
||||
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders);
|
||||
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check if the message was an initialize request
|
||||
if (message is JsonRpcRequest request && request.Method == RequestMethods.Initialize)
|
||||
{
|
||||
// If the response is not a JSON-RPC response, it is an SSE message
|
||||
if (string.IsNullOrEmpty(responseContent) || responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogAcceptedPost(Name, messageId);
|
||||
// The response will arrive as an SSE message
|
||||
}
|
||||
else
|
||||
{
|
||||
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize(responseContent, McpJsonUtilities.JsonContext.Default.JsonRpcResponse) ??
|
||||
throw new InvalidOperationException("Failed to initialize client");
|
||||
|
||||
LogTransportReceivedMessage(Name, messageId);
|
||||
await WriteMessageAsync(initializeResponse, cancellationToken).ConfigureAwait(false);
|
||||
LogTransportMessageWritten(Name, messageId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, check if the response was accepted (the response will come as an SSE message)
|
||||
if (string.IsNullOrEmpty(responseContent) || responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogAcceptedPost(Name, messageId);
|
||||
|
|
@ -177,17 +154,13 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
}
|
||||
}
|
||||
|
||||
internal Uri? MessageEndpoint => _messageEndpoint;
|
||||
|
||||
internal SseClientTransportOptions Options => _options;
|
||||
|
||||
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
CopyAdditionalHeaders(request.Headers);
|
||||
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders);
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
|
|
@ -251,15 +224,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
return;
|
||||
}
|
||||
|
||||
string messageId = "(no id)";
|
||||
if (message is JsonRpcMessageWithId messageWithId)
|
||||
{
|
||||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
LogTransportReceivedMessage(Name, messageId);
|
||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
LogTransportMessageWritten(Name, messageId);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
|
|
@ -290,20 +255,6 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
_connectionEstablished.TrySetResult(true);
|
||||
}
|
||||
|
||||
private void CopyAdditionalHeaders(HttpRequestHeaders headers)
|
||||
{
|
||||
if (_options.AdditionalHeaders is not null)
|
||||
{
|
||||
foreach (var header in _options.AdditionalHeaders)
|
||||
{
|
||||
if (!headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to add header '{header.Key}' with value '{header.Value}' from {nameof(SseClientTransportOptions.AdditionalHeaders)}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} accepted SSE transport POST for message ID '{MessageId}'.")]
|
||||
private partial void LogAcceptedPost(string endpointName, string messageId);
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
|||
/// <inheritdoc />
|
||||
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_options.UseStreamableHttp)
|
||||
{
|
||||
return new StreamableHttpClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
|
||||
}
|
||||
|
||||
var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
|
||||
|
||||
try
|
||||
|
|
|
|||
|
|
@ -30,13 +30,20 @@ public record SseClientTransportOptions
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use "Streamable HTTP" for the transport rather than "HTTP with SSE". Defaults to false.
|
||||
/// <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable HTTP transport specification</see>.
|
||||
/// <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">HTTP with SSE transport specification</see>.
|
||||
/// </summary>
|
||||
public bool UseStreamableHttp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a transport identifier used for logging purposes.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a timeout used to establish the initial connection to the SSE server.
|
||||
/// Gets or sets a timeout used to establish the initial connection to the SSE server. Defaults to 30 seconds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This timeout controls how long the client waits for:
|
||||
|
|
|
|||
|
|
@ -146,15 +146,7 @@ internal class StreamClientSessionTransport : TransportBase
|
|||
var message = (JsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)));
|
||||
if (message != null)
|
||||
{
|
||||
string messageId = "(no id)";
|
||||
if (message is JsonRpcMessageWithId messageWithId)
|
||||
{
|
||||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
LogTransportReceivedMessage(Name, messageId);
|
||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
LogTransportMessageWritten(Name, messageId);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -111,15 +111,7 @@ public class StreamServerTransport : TransportBase
|
|||
{
|
||||
if (JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))) is JsonRpcMessage message)
|
||||
{
|
||||
string messageId = "(no id)";
|
||||
if (message is JsonRpcMessageWithId messageWithId)
|
||||
{
|
||||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
LogTransportReceivedMessage(Name, messageId);
|
||||
await WriteMessageAsync(message, shutdownToken).ConfigureAwait(false);
|
||||
LogTransportMessageWritten(Name, messageId);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Utils;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Text.Json;
|
||||
|
||||
#if NET
|
||||
using System.Net.Http.Json;
|
||||
#else
|
||||
using System.Text;
|
||||
#endif
|
||||
|
||||
namespace ModelContextProtocol.Protocol.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// The Streamable HTTP client transport implementation
|
||||
/// </summary>
|
||||
internal sealed partial class StreamableHttpClientSessionTransport : TransportBase
|
||||
{
|
||||
private static readonly MediaTypeWithQualityHeaderValue s_applicationJsonMediaType = new("application/json");
|
||||
private static readonly MediaTypeWithQualityHeaderValue s_textEventStreamMediaType = new("text/event-stream");
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SseClientTransportOptions _options;
|
||||
private readonly CancellationTokenSource _connectionCts;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private string? _mcpSessionId;
|
||||
private Task? _getReceiveTask;
|
||||
|
||||
public StreamableHttpClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
|
||||
: base(endpointName, loggerFactory)
|
||||
{
|
||||
Throw.IfNull(transportOptions);
|
||||
Throw.IfNull(httpClient);
|
||||
|
||||
_options = transportOptions;
|
||||
_httpClient = httpClient;
|
||||
_connectionCts = new CancellationTokenSource();
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<SseClientTransport>() ?? NullLogger.Instance;
|
||||
|
||||
// We connect with the initialization request with the MCP transport. This means that any errors won't be observed
|
||||
// until the first call to SendMessageAsync. Fortunately, that happens internally in McpClientFactory.ConnectAsync
|
||||
// so we still throw any connection-related Exceptions from there and never expose a pre-connected client to the user.
|
||||
SetConnected(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task SendMessageAsync(
|
||||
JsonRpcMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token);
|
||||
cancellationToken = sendCts.Token;
|
||||
|
||||
#if NET
|
||||
using var content = JsonContent.Create(message, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcMessage>());
|
||||
#else
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
#endif
|
||||
|
||||
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _options.Endpoint)
|
||||
{
|
||||
Content = content,
|
||||
Headers =
|
||||
{
|
||||
Accept = { s_applicationJsonMediaType, s_textEventStreamMediaType },
|
||||
},
|
||||
};
|
||||
|
||||
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, _mcpSessionId);
|
||||
using var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var rpcRequest = message as JsonRpcRequest;
|
||||
JsonRpcMessage? rpcResponseCandidate = null;
|
||||
|
||||
if (response.Content.Headers.ContentType?.MediaType == "application/json")
|
||||
{
|
||||
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
rpcResponseCandidate = await ProcessMessageAsync(responseContent, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (response.Content.Headers.ContentType?.MediaType == "text/event-stream")
|
||||
{
|
||||
using var responseBodyStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
rpcResponseCandidate = await ProcessSseResponseAsync(responseBodyStream, rpcRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (rpcRequest is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (rpcResponseCandidate is not JsonRpcMessageWithId messageWithId || messageWithId.Id != rpcRequest.Id)
|
||||
{
|
||||
throw new McpException($"Streamable HTTP POST response completed without a reply to request with ID: {rpcRequest.Id}");
|
||||
}
|
||||
|
||||
if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseCandidate is JsonRpcResponse)
|
||||
{
|
||||
// We've successfully initialized! Copy session-id and start GET request if any.
|
||||
if (response.Headers.TryGetValues("mcp-session-id", out var sessionIdValues))
|
||||
{
|
||||
_mcpSessionId = sessionIdValues.FirstOrDefault();
|
||||
}
|
||||
|
||||
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connectionCts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (_getReceiveTask != null)
|
||||
{
|
||||
await _getReceiveTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveUnsolicitedMessagesAsync()
|
||||
{
|
||||
// Send a GET request to handle any unsolicited messages not sent over a POST response.
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint);
|
||||
request.Headers.Accept.Add(s_textEventStreamMediaType);
|
||||
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, _mcpSessionId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _connectionCts.Token).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Server support for the GET request is optional. If it fails, we don't care. It just means we won't receive unsolicited messages.
|
||||
return;
|
||||
}
|
||||
|
||||
using var responseStream = await response.Content.ReadAsStreamAsync(_connectionCts.Token).ConfigureAwait(false);
|
||||
await ProcessSseResponseAsync(responseStream, relatedRpcRequest: null, _connectionCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<JsonRpcMessageWithId?> ProcessSseResponseAsync(Stream responseStream, JsonRpcRequest? relatedRpcRequest, CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (SseItem<string> sseEvent in SseParser.Create(responseStream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (sseEvent.EventType != "message")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var message = await ProcessMessageAsync(sseEvent.Data, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// The server SHOULD end the response here anyway, but we won't leave it to chance. This transport makes
|
||||
// a GET request for any notifications that might need to be sent after the completion of each POST.
|
||||
if (message is JsonRpcMessageWithId messageWithId && relatedRpcRequest?.Id == messageWithId.Id)
|
||||
{
|
||||
return messageWithId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<JsonRpcMessage?> ProcessMessageAsync(string data, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
|
||||
if (message is null)
|
||||
{
|
||||
LogTransportMessageParseUnexpectedTypeSensitive(Name, data);
|
||||
return null;
|
||||
}
|
||||
|
||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
return message;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
LogJsonException(ex, data);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void LogJsonException(JsonException ex, string data)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
LogTransportMessageParseFailedSensitive(Name, data, ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogTransportMessageParseFailed(Name, ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void CopyAdditionalHeaders(HttpRequestHeaders headers, Dictionary<string, string>? additionalHeaders, string? sessionId = null)
|
||||
{
|
||||
if (sessionId is not null)
|
||||
{
|
||||
headers.Add("mcp-session-id", sessionId);
|
||||
}
|
||||
|
||||
if (additionalHeaders is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var header in additionalHeaders)
|
||||
{
|
||||
if (!headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to add header '{header.Key}' with value '{header.Value}' from {nameof(SseClientTransportOptions.AdditionalHeaders)}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
|
|||
{
|
||||
yield return message;
|
||||
|
||||
if (message.Data is JsonRpcResponse response)
|
||||
if (message.Data is JsonRpcMessageWithId response)
|
||||
{
|
||||
if (_pendingRequests.Remove(response.Id) && _pendingRequests.Count == 0)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public abstract partial class TransportBase : ITransport
|
|||
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +76,12 @@ public abstract partial class TransportBase : ITransport
|
|||
throw new InvalidOperationException("Transport is not connected");
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var messageId = (message as JsonRpcMessageWithId)?.Id.ToString() ?? "(no id)";
|
||||
LogTransportReceivedMessage(Name, messageId);
|
||||
}
|
||||
|
||||
await _messageChannel.Writer.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -115,9 +121,6 @@ public abstract partial class TransportBase : ITransport
|
|||
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} transport received message with ID '{MessageId}'.")]
|
||||
private protected partial void LogTransportReceivedMessage(string endpointName, string messageId);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} transport sent message with ID '{MessageId}'.")]
|
||||
private protected partial void LogTransportMessageWritten(string endpointName, string messageId);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} transport received unexpected message. Message: '{Message}'.")]
|
||||
private protected partial void LogTransportMessageParseUnexpectedTypeSensitive(string endpointName, string message);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
|
||||
{
|
||||
protected readonly SseServerIntegrationTestFixture _fixture;
|
||||
|
||||
public HttpServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.Initialize(testOutputHelper, ClientTransportOptions);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_fixture.TestCompleted();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
protected abstract SseClientTransportOptions ClientTransportOptions { get; }
|
||||
|
||||
private Task<IMcpClient> GetClientAsync(McpClientOptions? options = null)
|
||||
{
|
||||
return _fixture.ConnectMcpClientAsync(options, LoggerFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAndPing_Sse_TestServer()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
await using var client = await GetClientAsync();
|
||||
await client.PingAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_TestServer_ShouldProvideServerFields()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
await using var client = await GetClientAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client.ServerCapabilities);
|
||||
Assert.NotNull(client.ServerInfo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTools_Sse_TestServer()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallTool_Sse_EchoServer()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var result = await client.CallToolAsync(
|
||||
"echo",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["message"] = "Hello MCP!"
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsError);
|
||||
var textContent = Assert.Single(result.Content, c => c.Type == "text");
|
||||
Assert.Equal("Echo: Hello MCP!", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListResources_Sse_TestServer()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
|
||||
IList<Resource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// The everything server provides 100 test resources
|
||||
Assert.Equal(100, allResources.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResource_Sse_TextResource()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
// Odd numbered resources are text in the everything server (despite the docs saying otherwise)
|
||||
// 1 is index 0, which is "even" in the 0-based index
|
||||
// We copied this oddity to the test server
|
||||
var result = await client.ReadResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Contents);
|
||||
|
||||
TextResourceContents textContent = Assert.IsType<TextResourceContents>(result.Contents[0]);
|
||||
Assert.NotNull(textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResource_Sse_BinaryResource()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
// Even numbered resources are binary in the everything server (despite the docs saying otherwise)
|
||||
// 2 is index 1, which is "odd" in the 0-based index
|
||||
// We copied this oddity to the test server
|
||||
var result = await client.ReadResourceAsync("test://static/resource/2", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Contents);
|
||||
|
||||
BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
|
||||
Assert.NotNull(blobContent.Blob);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPrompts_Sse_TestServer()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(prompts);
|
||||
Assert.NotEmpty(prompts);
|
||||
// We could add specific assertions for the known prompts
|
||||
Assert.Contains(prompts, p => p.Name == "simple_prompt");
|
||||
Assert.Contains(prompts, p => p.Name == "complex_prompt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrompt_Sse_SimplePrompt()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var result = await client.GetPromptAsync("simple_prompt", null, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrompt_Sse_ComplexPrompt()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var arguments = new Dictionary<string, object?>
|
||||
{
|
||||
{ "temperature", "0.7" },
|
||||
{ "style", "formal" }
|
||||
};
|
||||
var result = await client.GetPromptAsync("complex_prompt", arguments, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrompt_Sse_NonExistent_ThrowsException()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
await Assert.ThrowsAsync<McpException>(() =>
|
||||
client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sampling_Sse_TestServer()
|
||||
{
|
||||
// arrange
|
||||
// Set up the sampling handler
|
||||
int samplingHandlerCalls = 0;
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
McpClientOptions options = new();
|
||||
options.Capabilities = new();
|
||||
options.Capabilities.Sampling ??= new();
|
||||
options.Capabilities.Sampling.SamplingHandler = async (_, _, _) =>
|
||||
{
|
||||
samplingHandlerCalls++;
|
||||
return new CreateMessageResult
|
||||
{
|
||||
Model = "test-model",
|
||||
Role = Role.Assistant,
|
||||
Content = new Content
|
||||
{
|
||||
Type = "text",
|
||||
Text = "Test response"
|
||||
}
|
||||
};
|
||||
};
|
||||
await using var client = await GetClientAsync(options);
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
// 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 CallTool_Sse_EchoServer_Concurrently()
|
||||
{
|
||||
await using var client1 = await GetClientAsync();
|
||||
await using var client2 = await GetClientAsync();
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var client = (i % 2 == 0) ? client1 : client2;
|
||||
var result = await client.CallToolAsync(
|
||||
"echo",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["message"] = $"Hello MCP! {i}"
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsError);
|
||||
var textContent = Assert.Single(result.Content, c => c.Type == "text");
|
||||
Assert.Equal($"Echo: Hello MCP! {i}", textContent.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(outputHelper)
|
||||
{
|
||||
protected override bool UseStreamableHttp => false;
|
||||
|
||||
[Theory]
|
||||
[InlineData("/a", "/a/sse")]
|
||||
[InlineData("/a/", "/a/sse")]
|
||||
[InlineData("/a/b", "/a/b/sse")]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpTests(outputHelper)
|
||||
{
|
||||
protected override bool UseStreamableHttp => true;
|
||||
|
||||
[Theory]
|
||||
[InlineData("/a", "/a")]
|
||||
[InlineData("/a", "/a/")]
|
||||
[InlineData("/a/", "/a/")]
|
||||
[InlineData("/a/", "/a")]
|
||||
[InlineData("/a/b", "/a/b")]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,13 +11,18 @@ using System.Security.Claims;
|
|||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
|
||||
public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
|
||||
{
|
||||
private async Task<IMcpClient> ConnectAsync(string? path = "/sse")
|
||||
protected abstract bool UseStreamableHttp { get; }
|
||||
|
||||
protected async Task<IMcpClient> ConnectAsync(string? path = null)
|
||||
{
|
||||
path ??= UseStreamableHttp ? "/" : "/sse";
|
||||
|
||||
var sseClientTransportOptions = new SseClientTransportOptions()
|
||||
{
|
||||
Endpoint = new Uri($"http://localhost{path}"),
|
||||
UseStreamableHttp = UseStreamableHttp,
|
||||
};
|
||||
await using var transport = new SseClientTransport(sseClientTransportOptions, HttpClient, LoggerFactory);
|
||||
return await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
|
@ -56,65 +61,6 @@ public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTe
|
|||
Assert.Equal($"data: {pattern}/message", dataLine[..dataLine.IndexOf('?')]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/a", "/a/sse")]
|
||||
[InlineData("/a/", "/a/sse")]
|
||||
[InlineData("/a/b", "/a/b/sse")]
|
||||
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()
|
||||
{
|
||||
|
|
@ -144,13 +90,13 @@ public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTe
|
|||
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
|
||||
}
|
||||
|
||||
private ClaimsPrincipal CreateUser(string name)
|
||||
protected 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)
|
||||
protected class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
|
||||
{
|
||||
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
|
||||
public string EchoWithUserName(string message)
|
||||
|
|
|
|||
|
|
@ -18,10 +18,9 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
|
|||
// multiple tests, so this dispatches the output to the current test.
|
||||
private readonly DelegatingTestOutputHelper _delegatingTestOutputHelper = new();
|
||||
|
||||
private SseClientTransportOptions DefaultTransportOptions { get; } = new()
|
||||
private SseClientTransportOptions DefaultTransportOptions { get; set; } = new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/sse"),
|
||||
Name = "TestServer",
|
||||
Endpoint = new("http://localhost/"),
|
||||
};
|
||||
|
||||
public SseServerIntegrationTestFixture()
|
||||
|
|
@ -37,8 +36,9 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
|
|||
|
||||
HttpClient = new HttpClient(socketsHttpHandler)
|
||||
{
|
||||
BaseAddress = DefaultTransportOptions.Endpoint,
|
||||
BaseAddress = new("http://localhost/"),
|
||||
};
|
||||
|
||||
_serverTask = Program.MainAsync([], new XunitLoggerProvider(_delegatingTestOutputHelper), _inMemoryTransport, _stopCts.Token);
|
||||
}
|
||||
|
||||
|
|
@ -53,9 +53,10 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
|
|||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
public void Initialize(ITestOutputHelper output)
|
||||
public void Initialize(ITestOutputHelper output, SseClientTransportOptions clientTransportOptions)
|
||||
{
|
||||
_delegatingTestOutputHelper.CurrentTestOutputHelper = output;
|
||||
DefaultTransportOptions = clientTransportOptions;
|
||||
}
|
||||
|
||||
public void TestCompleted()
|
||||
|
|
|
|||
|
|
@ -1,279 +1,23 @@
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
|
||||
public class SseServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
|
||||
: HttpServerIntegrationTests(fixture, testOutputHelper)
|
||||
|
||||
{
|
||||
private readonly SseServerIntegrationTestFixture _fixture;
|
||||
|
||||
public SseServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper)
|
||||
protected override SseClientTransportOptions ClientTransportOptions => new()
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.Initialize(testOutputHelper);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_fixture.TestCompleted();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
private Task<IMcpClient> GetClientAsync(McpClientOptions? options = null)
|
||||
{
|
||||
return _fixture.ConnectMcpClientAsync(options, LoggerFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAndPing_Sse_TestServer()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
await using var client = await GetClientAsync();
|
||||
await client.PingAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_TestServer_ShouldProvideServerFields()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
await using var client = await GetClientAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client.ServerCapabilities);
|
||||
Assert.NotNull(client.ServerInfo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTools_Sse_TestServer()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallTool_Sse_EchoServer()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var result = await client.CallToolAsync(
|
||||
"echo",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["message"] = "Hello MCP!"
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsError);
|
||||
var textContent = Assert.Single(result.Content, c => c.Type == "text");
|
||||
Assert.Equal("Echo: Hello MCP!", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListResources_Sse_TestServer()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
|
||||
IList<Resource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// The everything server provides 100 test resources
|
||||
Assert.Equal(100, allResources.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResource_Sse_TextResource()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
// Odd numbered resources are text in the everything server (despite the docs saying otherwise)
|
||||
// 1 is index 0, which is "even" in the 0-based index
|
||||
// We copied this oddity to the test server
|
||||
var result = await client.ReadResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Contents);
|
||||
|
||||
TextResourceContents textContent = Assert.IsType<TextResourceContents>(result.Contents[0]);
|
||||
Assert.NotNull(textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResource_Sse_BinaryResource()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
// Even numbered resources are binary in the everything server (despite the docs saying otherwise)
|
||||
// 2 is index 1, which is "odd" in the 0-based index
|
||||
// We copied this oddity to the test server
|
||||
var result = await client.ReadResourceAsync("test://static/resource/2", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Contents);
|
||||
|
||||
BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
|
||||
Assert.NotNull(blobContent.Blob);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPrompts_Sse_TestServer()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(prompts);
|
||||
Assert.NotEmpty(prompts);
|
||||
// We could add specific assertions for the known prompts
|
||||
Assert.Contains(prompts, p => p.Name == "simple_prompt");
|
||||
Assert.Contains(prompts, p => p.Name == "complex_prompt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrompt_Sse_SimplePrompt()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var result = await client.GetPromptAsync("simple_prompt", null, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrompt_Sse_ComplexPrompt()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
var arguments = new Dictionary<string, object?>
|
||||
{
|
||||
{ "temperature", "0.7" },
|
||||
{ "style", "formal" }
|
||||
};
|
||||
var result = await client.GetPromptAsync("complex_prompt", arguments, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrompt_Sse_NonExistent_ThrowsException()
|
||||
{
|
||||
// arrange
|
||||
|
||||
// act
|
||||
await using var client = await GetClientAsync();
|
||||
await Assert.ThrowsAsync<McpException>(() =>
|
||||
client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sampling_Sse_TestServer()
|
||||
{
|
||||
// arrange
|
||||
// Set up the sampling handler
|
||||
int samplingHandlerCalls = 0;
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
McpClientOptions options = new();
|
||||
options.Capabilities = new();
|
||||
options.Capabilities.Sampling ??= new();
|
||||
options.Capabilities.Sampling.SamplingHandler = async (_, _, _) =>
|
||||
{
|
||||
samplingHandlerCalls++;
|
||||
return new CreateMessageResult
|
||||
{
|
||||
Model = "test-model",
|
||||
Role = Role.Assistant,
|
||||
Content = new Content
|
||||
{
|
||||
Type = "text",
|
||||
Text = "Test response"
|
||||
}
|
||||
};
|
||||
};
|
||||
await using var client = await GetClientAsync(options);
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
// 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 CallTool_Sse_EchoServer_Concurrently()
|
||||
{
|
||||
await using var client1 = await GetClientAsync();
|
||||
await using var client2 = await GetClientAsync();
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var client = (i % 2 == 0) ? client1 : client2;
|
||||
var result = await client.CallToolAsync(
|
||||
"echo",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["message"] = $"Hello MCP! {i}"
|
||||
},
|
||||
cancellationToken: TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsError);
|
||||
var textContent = Assert.Single(result.Content, c => c.Type == "text");
|
||||
Assert.Equal($"Echo: Hello MCP! {i}", textContent.Text);
|
||||
}
|
||||
}
|
||||
Endpoint = new Uri("http://localhost/sse"),
|
||||
Name = "TestServer",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EventSourceResponse_Includes_ExpectedHeaders()
|
||||
{
|
||||
using var sseResponse = await _fixture.HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
using var sseResponse = await _fixture.HttpClient.GetAsync("/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
|
||||
sseResponse.EnsureSuccessStatusCode();
|
||||
|
||||
|
|
@ -289,7 +33,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
|
|||
{
|
||||
// Simulate our own MCP client handshake using a plain HttpClient so we can look for "event: message"
|
||||
// in the raw SSE response stream which is not exposed by the real MCP client.
|
||||
await using var sseResponse = await _fixture.HttpClient.GetStreamAsync("", TestContext.Current.CancellationToken);
|
||||
await using var sseResponse = await _fixture.HttpClient.GetStreamAsync("/sse", TestContext.Current.CancellationToken);
|
||||
using var streamReader = new StreamReader(sseResponse);
|
||||
|
||||
var endpointEvent = await streamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
|
|
@ -305,7 +49,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
|
|||
""";
|
||||
using (var initializeRequestBody = new StringContent(initializeRequest, Encoding.UTF8, "application/json"))
|
||||
{
|
||||
var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializeRequestBody, TestContext.Current.CancellationToken);
|
||||
using var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializeRequestBody, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
|
|
@ -314,7 +58,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
|
|||
""";
|
||||
using (var initializedNotificationBody = new StringContent(initializedNotification, Encoding.UTF8, "application/json"))
|
||||
{
|
||||
var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializedNotificationBody, TestContext.Current.CancellationToken);
|
||||
using var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializedNotificationBody, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
|
||||
{
|
||||
private WebApplication? _app;
|
||||
|
||||
private async Task StartAsync()
|
||||
{
|
||||
Builder.Services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
});
|
||||
_app = Builder.Build();
|
||||
|
||||
var echoTool = McpServerTool.Create(Echo, new()
|
||||
{
|
||||
Services = _app.Services,
|
||||
});
|
||||
|
||||
_app.MapPost("/mcp", async (JsonRpcMessage message) =>
|
||||
{
|
||||
if (message is not JsonRpcRequest request)
|
||||
{
|
||||
// Ignore all non-request notifications.
|
||||
return Results.Accepted();
|
||||
}
|
||||
|
||||
if (request.Method == "initialize")
|
||||
{
|
||||
return Results.Json(new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
ProtocolVersion = "2024-11-05",
|
||||
Capabilities = new()
|
||||
{
|
||||
Tools = new(),
|
||||
},
|
||||
ServerInfo = new Implementation
|
||||
{
|
||||
Name = "my-mcp",
|
||||
Version = "0.0.1",
|
||||
},
|
||||
}, McpJsonUtilities.DefaultOptions)
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Method == "tools/list")
|
||||
{
|
||||
return Results.Json(new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new ListToolsResult
|
||||
{
|
||||
Tools = [echoTool.ProtocolTool]
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Method == "tools/call")
|
||||
{
|
||||
var parameters = JsonSerializer.Deserialize(request.Params, GetJsonTypeInfo<CallToolRequestParams>());
|
||||
Assert.NotNull(parameters?.Arguments);
|
||||
|
||||
return Results.Json(new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new CallToolResponse()
|
||||
{
|
||||
Content = [new() { Text = parameters.Arguments["message"].ToString() }],
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
});
|
||||
}
|
||||
|
||||
throw new Exception("Unexpected message!");
|
||||
});
|
||||
|
||||
await _app.StartAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanCallToolOnSessionlessStreamableHttpServer()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await using var transport = new SseClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost/mcp"),
|
||||
UseStreamableHttp = true,
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var echoTool = Assert.Single(tools);
|
||||
Assert.Equal("echo", echoTool.Name);
|
||||
await CallEchoAndValidateAsync(echoTool);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task CanCallToolConcurrently()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await using var transport = new SseClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost/mcp"),
|
||||
UseStreamableHttp = true,
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var echoTool = Assert.Single(tools);
|
||||
Assert.Equal("echo", echoTool.Name);
|
||||
|
||||
var echoTasks = new Task[100];
|
||||
for (int i = 0; i < echoTasks.Length; i++)
|
||||
{
|
||||
echoTasks[i] = CallEchoAndValidateAsync(echoTool);
|
||||
}
|
||||
|
||||
await Task.WhenAll(echoTasks);
|
||||
}
|
||||
|
||||
private static async Task CallEchoAndValidateAsync(McpClientTool echoTool)
|
||||
{
|
||||
var response = await echoTool.CallAsync(new Dictionary<string, object?>() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(response);
|
||||
var content = Assert.Single(response.Content);
|
||||
Assert.Equal("text", content.Type);
|
||||
Assert.Equal("Hello world!", content.Text);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
|
||||
|
||||
[McpServerTool(Name = "echo")]
|
||||
private static string Echo(string message)
|
||||
{
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,638 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.Net;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
|
||||
{
|
||||
private static McpServerTool[] Tools { get; } = [
|
||||
McpServerTool.Create(EchoAsync),
|
||||
McpServerTool.Create(LongRunningAsync),
|
||||
McpServerTool.Create(Progress),
|
||||
McpServerTool.Create(Throw),
|
||||
];
|
||||
|
||||
private WebApplication? _app;
|
||||
|
||||
private async Task StartAsync()
|
||||
{
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.ServerInfo = new Implementation
|
||||
{
|
||||
Name = nameof(StreamableHttpServerConformanceTests),
|
||||
Version = "73",
|
||||
};
|
||||
}).WithTools(Tools).WithHttpTransport();
|
||||
|
||||
_app = Builder.Build();
|
||||
|
||||
_app.MapMcp();
|
||||
|
||||
await _app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NegativeNonInfiniteIdleTimeout_Throws_ArgumentOutOfRangeException()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options =>
|
||||
{
|
||||
options.IdleTimeout = TimeSpan.MinValue;
|
||||
});
|
||||
|
||||
var ex = await Assert.ThrowsAnyAsync<ArgumentOutOfRangeException>(StartAsync);
|
||||
Assert.Contains("IdleTimeout", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NegativeMaxIdleSessionCount_Throws_ArgumentOutOfRangeException()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options =>
|
||||
{
|
||||
options.MaxIdleSessionCount = -1;
|
||||
});
|
||||
|
||||
var ex = await Assert.ThrowsAnyAsync<ArgumentOutOfRangeException>(StartAsync);
|
||||
Assert.Contains("MaxIdleSessionCount", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitialPostResponse_Includes_McpSessionIdHeader()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Single(response.Headers.GetValues("mcp-session-id"));
|
||||
Assert.Equal("text/event-stream", Assert.Single(response.Content.Headers.GetValues("content-type")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostRequest_IsUnsupportedMediaType_WithoutJsonContentType()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
using var response = await HttpClient.PostAsync("", new StringContent(InitializeRequest, Encoding.UTF8, "text/javascript"), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("text/event-stream")]
|
||||
[InlineData("application/json")]
|
||||
[InlineData("application/json-text/event-stream")]
|
||||
public async Task PostRequest_IsNotAcceptable_WithSingleAcceptHeader(string singleAcceptValue)
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
HttpClient.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Accept, singleAcceptValue);
|
||||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRequest_IsNotAcceptable_WithoutTextEventStreamAcceptHeader()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
|
||||
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "")
|
||||
{
|
||||
Content = JsonContent(EchoRequest),
|
||||
Headers =
|
||||
{
|
||||
{ "mcp-session-id", "fakeSession" },
|
||||
},
|
||||
};
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeRequest_Matches_CustomRoute()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport();
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp("/custom-route");
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
|
||||
using var response = await HttpClient.PostAsync("/custom-route", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostWithSingleNotification_IsAccepted_WithEmptyResponse()
|
||||
{
|
||||
await StartAsync();
|
||||
await CallInitializeAndValidateAsync();
|
||||
|
||||
var response = await HttpClient.PostAsync("", JsonContent(ProgressNotification("1")), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Equal("", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeJsonRpcRequest_IsHandled_WithCompleteSseResponse()
|
||||
{
|
||||
await StartAsync();
|
||||
await CallInitializeAndValidateAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchedJsonRpcRequests_IsHandled_WithCompleteSseResponse()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent($"[{InitializeRequest},{EchoRequest}]"), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var eventCount = 0;
|
||||
await foreach (var sseEvent in ReadSseAsync(response.Content))
|
||||
{
|
||||
var jsonRpcResponse = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcResponse>());
|
||||
Assert.NotNull(jsonRpcResponse);
|
||||
var responseId = Assert.IsType<long>(jsonRpcResponse.Id.Id);
|
||||
|
||||
switch (responseId)
|
||||
{
|
||||
case 1:
|
||||
AssertServerInfo(jsonRpcResponse);
|
||||
break;
|
||||
case 2:
|
||||
AssertEchoResponse(jsonRpcResponse);
|
||||
break;
|
||||
default:
|
||||
throw new Exception($"Unexpected response ID: {jsonRpcResponse.Id}");
|
||||
}
|
||||
|
||||
eventCount++;
|
||||
}
|
||||
|
||||
Assert.Equal(2, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleJsonRpcRequest_ThatThrowsIsHandled_WithCompleteSseResponse()
|
||||
{
|
||||
await StartAsync();
|
||||
await CallInitializeAndValidateAsync();
|
||||
|
||||
var response = await HttpClient.PostAsync("", JsonContent(CallTool("throw")), TestContext.Current.CancellationToken);
|
||||
var rpcError = await AssertSingleSseResponseAsync(response);
|
||||
|
||||
var error = AssertType<CallToolResponse>(rpcError.Result);
|
||||
var content = Assert.Single(error.Content);
|
||||
Assert.Contains("'throw'", content.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleSerialJsonRpcRequests_IsHandled_OneAtATime()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
await CallEchoAndValidateAsync();
|
||||
await CallEchoAndValidateAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleConcurrentJsonRpcRequests_IsHandled_InParallel()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
|
||||
var echoTasks = new Task[100];
|
||||
for (int i = 0; i < echoTasks.Length; i++)
|
||||
{
|
||||
echoTasks[i] = CallEchoAndValidateAsync();
|
||||
}
|
||||
|
||||
await Task.WhenAll(echoTasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRequest_Receives_UnsolicitedNotifications()
|
||||
{
|
||||
IMcpServer? server = null;
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) =>
|
||||
{
|
||||
server = mcpServer;
|
||||
return mcpServer.RunAsync(cancellationToken);
|
||||
};
|
||||
});
|
||||
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
Assert.NotNull(server);
|
||||
|
||||
// Headers should be sent even before any messages are ready on the GET endpoint.
|
||||
using var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
async Task<string> GetFirstNotificationAsync()
|
||||
{
|
||||
await foreach (var sseEvent in ReadSseAsync(getResponse.Content))
|
||||
{
|
||||
var notification = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcNotification>());
|
||||
Assert.NotNull(notification);
|
||||
return notification.Method;
|
||||
}
|
||||
|
||||
throw new Exception("No notifications received.");
|
||||
}
|
||||
|
||||
await server.SendNotificationAsync("test-method", TestContext.Current.CancellationToken);
|
||||
Assert.Equal("test-method", await GetFirstNotificationAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondGetRequests_IsRejected_AsBadRequest()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
using var getResponse1 = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
using var getResponse2 = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse1.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, getResponse2.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRequest_CompletesSession_WhichIsNoLongerFound()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
await CallEchoAndValidateAsync();
|
||||
await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRequest_CompletesSession_WhichCancelsLongRunningToolCalls()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
|
||||
Task<HttpResponseMessage> CallLongRunningToolAsync() =>
|
||||
HttpClient.PostAsync("", JsonContent(CallTool("long-running")), TestContext.Current.CancellationToken);
|
||||
|
||||
var longRunningToolTasks = new Task<HttpResponseMessage>[10];
|
||||
for (int i = 0; i < longRunningToolTasks.Length; i++)
|
||||
{
|
||||
longRunningToolTasks[i] = CallLongRunningToolAsync();
|
||||
}
|
||||
|
||||
var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
|
||||
for (int i = 0; i < longRunningToolTasks.Length; i++)
|
||||
{
|
||||
Assert.False(longRunningToolTasks[i].IsCompleted);
|
||||
}
|
||||
|
||||
await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
// Get request should complete gracefully.
|
||||
var sseResponseBody = await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Empty(sseResponseBody);
|
||||
|
||||
// Currently, the OCE thrown by the canceled session is unhandled and turned into a 500 error by Kestrel.
|
||||
// The spec suggests sending CancelledNotifications. That would be good, but we can do that later.
|
||||
// For now, the important thing is that request completes without indicating success.
|
||||
await Task.WhenAll(longRunningToolTasks);
|
||||
foreach (var task in longRunningToolTasks)
|
||||
{
|
||||
var response = await task;
|
||||
Assert.False(response.IsSuccessStatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progress_IsReported_InSameSseResponseAsRpcResponse()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
await CallInitializeAndValidateAsync();
|
||||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(CallToolWithProgressToken("progress")), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var currentSseItem = 0;
|
||||
await foreach (var sseEvent in ReadSseAsync(response.Content))
|
||||
{
|
||||
currentSseItem++;
|
||||
|
||||
if (currentSseItem <= 10)
|
||||
{
|
||||
var notification = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcNotification>());
|
||||
var progressNotification = AssertType<ProgressNotification>(notification?.Params);
|
||||
Assert.Equal($"Progress {currentSseItem - 1}", progressNotification.Progress.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
var rpcResponse = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo<JsonRpcResponse>());
|
||||
var callToolResponse = AssertType<CallToolResponse>(rpcResponse?.Result);
|
||||
var callToolContent = Assert.Single(callToolResponse.Content);
|
||||
Assert.Equal("text", callToolContent.Type);
|
||||
Assert.Equal("done", callToolContent.Text);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(11, currentSseItem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdleSessions_ArePruned_AfterIdleTimeout()
|
||||
{
|
||||
var fakeTimeProvider = new FakeTimeProvider();
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options =>
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromHours(2), options.IdleTimeout);
|
||||
options.TimeProvider = fakeTimeProvider;
|
||||
});
|
||||
|
||||
await StartAsync();
|
||||
await CallInitializeAndValidateAsync();
|
||||
await CallEchoAndValidateAsync();
|
||||
|
||||
// Add 5 seconds to idle timeout to account for the interval of the PeriodicTimer.
|
||||
fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
|
||||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdleSessions_AreNotPruned_WithInfiniteIdleTimeoutWhileUnderMaxIdleSessionCount()
|
||||
{
|
||||
var fakeTimeProvider = new FakeTimeProvider();
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options =>
|
||||
{
|
||||
options.IdleTimeout = Timeout.InfiniteTimeSpan;
|
||||
options.TimeProvider = fakeTimeProvider;
|
||||
});
|
||||
|
||||
await StartAsync();
|
||||
await CallInitializeAndValidateAsync();
|
||||
await CallEchoAndValidateAsync();
|
||||
|
||||
fakeTimeProvider.Advance(TimeSpan.FromDays(1));
|
||||
|
||||
// Echo still works because the session has not been pruned.
|
||||
await CallEchoAndValidateAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdleSessionsPastMaxIdleSessionCount_ArePruned_LongestIdleFirstDespiteIdleTimeout()
|
||||
{
|
||||
var fakeTimeProvider = new FakeTimeProvider();
|
||||
Builder.Services.AddMcpServer().WithHttpTransport(options =>
|
||||
{
|
||||
options.IdleTimeout = Timeout.InfiniteTimeSpan;
|
||||
options.MaxIdleSessionCount = 2;
|
||||
options.TimeProvider = fakeTimeProvider;
|
||||
});
|
||||
|
||||
var mockLoggerProvider = new MockLoggerProvider();
|
||||
Builder.Logging.AddProvider(mockLoggerProvider);
|
||||
|
||||
await StartAsync();
|
||||
|
||||
// Start first session.
|
||||
var firstSessionId = await CallInitializeAndValidateAsync();
|
||||
|
||||
// Start a second session to trigger pruning of the original session.
|
||||
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
|
||||
var secondSessionId = await CallInitializeAndValidateAsync();
|
||||
|
||||
Assert.NotEqual(firstSessionId, secondSessionId);
|
||||
|
||||
// First session ID still works, since we allow up to 2 idle sessions.
|
||||
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
|
||||
SetSessionId(firstSessionId);
|
||||
await CallEchoAndValidateAsync();
|
||||
|
||||
// Start a third session to trigger pruning of the first session.
|
||||
fakeTimeProvider.Advance(TimeSpan.FromTicks(1));
|
||||
var thirdSessionId = await CallInitializeAndValidateAsync();
|
||||
|
||||
Assert.NotEqual(secondSessionId, thirdSessionId);
|
||||
|
||||
// Pruning of the second session results in a 404 since we used the first session more recently.
|
||||
fakeTimeProvider.Advance(TimeSpan.FromSeconds(10));
|
||||
SetSessionId(secondSessionId);
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
|
||||
// But the first and third session IDs should still work.
|
||||
SetSessionId(firstSessionId);
|
||||
await CallEchoAndValidateAsync();
|
||||
|
||||
SetSessionId(thirdSessionId);
|
||||
await CallEchoAndValidateAsync();
|
||||
|
||||
var logMessage = Assert.Single(mockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Critical);
|
||||
Assert.StartsWith("Exceeded maximum of 2 idle sessions.", logMessage.Message);
|
||||
}
|
||||
|
||||
private static StringContent JsonContent(string json) => new StringContent(json, Encoding.UTF8, "application/json");
|
||||
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
|
||||
|
||||
private static T AssertType<T>(JsonNode? jsonNode)
|
||||
{
|
||||
var type = JsonSerializer.Deserialize<T>(jsonNode, GetJsonTypeInfo<T>());
|
||||
Assert.NotNull(type);
|
||||
return type;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<string> ReadSseAsync(HttpContent responseContent)
|
||||
{
|
||||
var responseStream = await responseContent.ReadAsStreamAsync(TestContext.Current.CancellationToken);
|
||||
await foreach (var sseItem in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken))
|
||||
{
|
||||
Assert.Equal("message", sseItem.EventType);
|
||||
yield return sseItem.Data;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<JsonRpcResponse> AssertSingleSseResponseAsync(HttpResponseMessage response)
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
var sseItem = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken));
|
||||
var jsonRpcResponse = JsonSerializer.Deserialize(sseItem, GetJsonTypeInfo<JsonRpcResponse>());
|
||||
|
||||
Assert.NotNull(jsonRpcResponse);
|
||||
return jsonRpcResponse;
|
||||
}
|
||||
|
||||
private static string InitializeRequest => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
|
||||
""";
|
||||
|
||||
private long _lastRequestId = 1;
|
||||
private string EchoRequest
|
||||
{
|
||||
get
|
||||
{
|
||||
var id = Interlocked.Increment(ref _lastRequestId);
|
||||
return $$$$"""
|
||||
{"jsonrpc":"2.0","id":{{{{id}}}},"method":"tools/call","params":{"name":"echo","arguments":{"message":"Hello world! ({{{{id}}}})"}}}
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
private string ProgressNotification(string progress)
|
||||
{
|
||||
return $$$"""
|
||||
{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"","progress":{{{progress}}}}}
|
||||
""";
|
||||
}
|
||||
|
||||
private string Request(string method, string parameters = "{}")
|
||||
{
|
||||
var id = Interlocked.Increment(ref _lastRequestId);
|
||||
return $$"""
|
||||
{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}}
|
||||
""";
|
||||
}
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}") =>
|
||||
Request("tools/call", $$"""
|
||||
{"name":"{{toolName}}","arguments":{{arguments}}}
|
||||
""");
|
||||
|
||||
private string CallToolWithProgressToken(string toolName, string arguments = "{}") =>
|
||||
Request("tools/call", $$$"""
|
||||
{"name":"{{{toolName}}}","arguments":{{{arguments}}},"_meta":{"progressToken":"abc123"}}
|
||||
""");
|
||||
|
||||
private static InitializeResult AssertServerInfo(JsonRpcResponse rpcResponse)
|
||||
{
|
||||
var initializeResult = AssertType<InitializeResult>(rpcResponse.Result);
|
||||
Assert.Equal(nameof(StreamableHttpServerConformanceTests), initializeResult.ServerInfo.Name);
|
||||
Assert.Equal("73", initializeResult.ServerInfo.Version);
|
||||
return initializeResult;
|
||||
}
|
||||
|
||||
private static CallToolResponse AssertEchoResponse(JsonRpcResponse rpcResponse)
|
||||
{
|
||||
var callToolResponse = AssertType<CallToolResponse>(rpcResponse.Result);
|
||||
var callToolContent = Assert.Single(callToolResponse.Content);
|
||||
Assert.Equal("text", callToolContent.Type);
|
||||
Assert.Equal($"Hello world! ({rpcResponse.Id})", callToolContent.Text);
|
||||
return callToolResponse;
|
||||
}
|
||||
|
||||
private async Task<string> CallInitializeAndValidateAsync()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
|
||||
var rpcResponse = await AssertSingleSseResponseAsync(response);
|
||||
AssertServerInfo(rpcResponse);
|
||||
|
||||
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
|
||||
SetSessionId(sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private void SetSessionId(string sessionId)
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
|
||||
}
|
||||
|
||||
private async Task CallEchoAndValidateAsync()
|
||||
{
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
|
||||
var rpcResponse = await AssertSingleSseResponseAsync(response);
|
||||
AssertEchoResponse(rpcResponse);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "echo")]
|
||||
private static async Task<string> EchoAsync(string message)
|
||||
{
|
||||
// McpSession.ProcessMessagesAsync() already yields before calling any handlers, but this makes it even
|
||||
// more explicit that we're not relying on synchronous execution of the tool.
|
||||
await Task.Yield();
|
||||
return message;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "long-running")]
|
||||
private static async Task LongRunningAsync(CancellationToken cancellation)
|
||||
{
|
||||
// McpSession.ProcessMessagesAsync() already yields before calling any handlers, but this makes it even
|
||||
// more explicit that we're not relying on synchronous execution of the tool.
|
||||
await Task.Delay(Timeout.Infinite, cancellation);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "progress")]
|
||||
public static string Progress(IProgress<ProgressNotificationValue> progress)
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
progress.Report(new() { Progress = i, Total = 10, Message = $"Progress {i}" });
|
||||
}
|
||||
|
||||
return "done";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "throw")]
|
||||
private static void Throw()
|
||||
{
|
||||
throw new Exception();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using ModelContextProtocol.Protocol.Transport;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
public class StreamableHttpServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
|
||||
: HttpServerIntegrationTests(fixture, testOutputHelper)
|
||||
|
||||
{
|
||||
private const string InitializeRequest = """
|
||||
{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
|
||||
""";
|
||||
|
||||
protected override SseClientTransportOptions ClientTransportOptions => new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost/"),
|
||||
Name = "TestServer",
|
||||
UseStreamableHttp = true,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EventSourceResponse_Includes_ExpectedHeaders()
|
||||
{
|
||||
using var initializeRequestBody = new StringContent(InitializeRequest, Encoding.UTF8, "application/json");
|
||||
using var postRequest = new HttpRequestMessage(HttpMethod.Post, "/")
|
||||
{
|
||||
Headers =
|
||||
{
|
||||
Accept = { new("application/json"), new("text/event-stream")}
|
||||
},
|
||||
Content = initializeRequestBody,
|
||||
};
|
||||
using var sseResponse = await _fixture.HttpClient.SendAsync(postRequest, TestContext.Current.CancellationToken);
|
||||
|
||||
sseResponse.EnsureSuccessStatusCode();
|
||||
|
||||
Assert.Equal("text/event-stream", sseResponse.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Equal("identity", sseResponse.Content.Headers.ContentEncoding.ToString());
|
||||
Assert.NotNull(sseResponse.Headers.CacheControl);
|
||||
Assert.True(sseResponse.Headers.CacheControl.NoStore);
|
||||
Assert.True(sseResponse.Headers.CacheControl.NoCache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventSourceStream_Includes_MessageEventType()
|
||||
{
|
||||
using var initializeRequestBody = new StringContent(InitializeRequest, Encoding.UTF8, "application/json");
|
||||
using var postRequest = new HttpRequestMessage(HttpMethod.Post, "/")
|
||||
{
|
||||
Headers =
|
||||
{
|
||||
Accept = { new("application/json"), new("text/event-stream")}
|
||||
},
|
||||
Content = initializeRequestBody,
|
||||
};
|
||||
using var sseResponse = await _fixture.HttpClient.SendAsync(postRequest, TestContext.Current.CancellationToken);
|
||||
using var sseResponseStream = await sseResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
|
||||
using var streamReader = new StreamReader(sseResponseStream);
|
||||
|
||||
var messageEvent = await streamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("event: message", messageEvent);
|
||||
}
|
||||
}
|
||||
|
|
@ -120,52 +120,6 @@ public class SseClientTransportTests : LoggedTest
|
|||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_Handles_Accepted_Json_RPC_Response()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new SseClientTransport(_transportOptions, httpClient, LoggerFactory);
|
||||
|
||||
var eventSourcePipe = new Pipe();
|
||||
var eventSourceData = "event: endpoint\r\ndata: /sseendpoint\r\n\r\n"u8;
|
||||
eventSourceData.CopyTo(eventSourcePipe.Writer.GetSpan(eventSourceData.Length));
|
||||
eventSourcePipe.Writer.Advance(eventSourceData.Length);
|
||||
await eventSourcePipe.Writer.FlushAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var firstCall = true;
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == "http://localhost:8080/sseendpoint")
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("{\"jsonrpc\":\"2.0\", \"id\": \"44\", \"result\": null}")
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!firstCall)
|
||||
throw new IOException("Abort");
|
||||
else
|
||||
firstCall = false;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StreamContent(eventSourcePipe.Reader.AsStream()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await session.SendMessageAsync(new JsonRpcRequest() { Method = RequestMethods.Initialize, Id = new RequestId(44) }, CancellationToken.None);
|
||||
Assert.True(true);
|
||||
eventSourcePipe.Writer.Complete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveMessagesAsync_Handles_Messages()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue