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,
|
ILoggerFactory loggerFactory,
|
||||||
IServiceProvider applicationServices)
|
IServiceProvider applicationServices)
|
||||||
{
|
{
|
||||||
private static JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
|
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
|
||||||
private static MediaTypeHeaderValue ApplicationJsonMediaType = new("application/json");
|
private static readonly MediaTypeHeaderValue s_applicationJsonMediaType = new("application/json");
|
||||||
private static MediaTypeHeaderValue TextEventStreamMediaType = new("text/event-stream");
|
private static readonly MediaTypeHeaderValue s_textEventStreamMediaType = new("text/event-stream");
|
||||||
|
|
||||||
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
|
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,
|
// 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.
|
// but it's probably good to at least start out trying to be strict.
|
||||||
var acceptHeaders = context.Request.GetTypedHeaders().Accept;
|
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,
|
await WriteJsonRpcErrorAsync(context,
|
||||||
"Not Acceptable: Client must accept both application/json and text/event-stream",
|
"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)
|
public async Task HandleGetRequestAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var acceptHeaders = context.Request.GetTypedHeaders().Accept;
|
var acceptHeaders = context.Request.GetTypedHeaders().Accept;
|
||||||
if (!acceptHeaders.Contains(TextEventStreamMediaType))
|
if (!acceptHeaders.Contains(s_textEventStreamMediaType))
|
||||||
{
|
{
|
||||||
await WriteJsonRpcErrorAsync(context,
|
await WriteJsonRpcErrorAsync(context,
|
||||||
"Not Acceptable: Client must accept text/event-stream",
|
"Not Acceptable: Client must accept text/event-stream",
|
||||||
|
|
|
||||||
|
|
@ -87,40 +87,17 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
||||||
messageId = messageWithId.Id.ToString();
|
messageId = messageWithId.Id.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
|
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
|
||||||
{
|
{
|
||||||
Content = content,
|
Content = content,
|
||||||
};
|
};
|
||||||
CopyAdditionalHeaders(httpRequestMessage.Headers);
|
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders);
|
||||||
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
|
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
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))
|
if (string.IsNullOrEmpty(responseContent) || responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
LogAcceptedPost(Name, messageId);
|
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)
|
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||||
CopyAdditionalHeaders(request.Headers);
|
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders);
|
||||||
|
|
||||||
using var response = await _httpClient.SendAsync(
|
using var response = await _httpClient.SendAsync(
|
||||||
request,
|
request,
|
||||||
|
|
@ -251,15 +224,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string messageId = "(no id)";
|
|
||||||
if (message is JsonRpcMessageWithId messageWithId)
|
|
||||||
{
|
|
||||||
messageId = messageWithId.Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
LogTransportReceivedMessage(Name, messageId);
|
|
||||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
LogTransportMessageWritten(Name, messageId);
|
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
|
|
@ -290,20 +255,6 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
||||||
_connectionEstablished.TrySetResult(true);
|
_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}'.")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} accepted SSE transport POST for message ID '{MessageId}'.")]
|
||||||
private partial void LogAcceptedPost(string endpointName, string messageId);
|
private partial void LogAcceptedPost(string endpointName, string messageId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
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);
|
var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
|
||||||
|
|
||||||
try
|
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>
|
/// <summary>
|
||||||
/// Gets a transport identifier used for logging purposes.
|
/// Gets a transport identifier used for logging purposes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This timeout controls how long the client waits for:
|
/// 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)));
|
var message = (JsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)));
|
||||||
if (message != null)
|
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);
|
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
LogTransportMessageWritten(Name, messageId);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -111,15 +111,7 @@ public class StreamServerTransport : TransportBase
|
||||||
{
|
{
|
||||||
if (JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))) is JsonRpcMessage message)
|
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);
|
await WriteMessageAsync(message, shutdownToken).ConfigureAwait(false);
|
||||||
LogTransportMessageWritten(Name, messageId);
|
|
||||||
}
|
}
|
||||||
else
|
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;
|
yield return message;
|
||||||
|
|
||||||
if (message.Data is JsonRpcResponse response)
|
if (message.Data is JsonRpcMessageWithId response)
|
||||||
{
|
{
|
||||||
if (_pendingRequests.Remove(response.Id) && _pendingRequests.Count == 0)
|
if (_pendingRequests.Remove(response.Id) && _pendingRequests.Count == 0)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ public abstract partial class TransportBase : ITransport
|
||||||
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
|
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
|
||||||
{
|
{
|
||||||
SingleReader = true,
|
SingleReader = true,
|
||||||
SingleWriter = true,
|
SingleWriter = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +76,12 @@ public abstract partial class TransportBase : ITransport
|
||||||
throw new InvalidOperationException("Transport is not connected");
|
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);
|
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}'.")]
|
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} transport received message with ID '{MessageId}'.")]
|
||||||
private protected partial void LogTransportReceivedMessage(string endpointName, string 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}'.")]
|
[LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} transport received unexpected message. Message: '{Message}'.")]
|
||||||
private protected partial void LogTransportMessageParseUnexpectedTypeSensitive(string endpointName, string 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;
|
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()
|
var sseClientTransportOptions = new SseClientTransportOptions()
|
||||||
{
|
{
|
||||||
Endpoint = new Uri($"http://localhost{path}"),
|
Endpoint = new Uri($"http://localhost{path}"),
|
||||||
|
UseStreamableHttp = UseStreamableHttp,
|
||||||
};
|
};
|
||||||
await using var transport = new SseClientTransport(sseClientTransportOptions, HttpClient, LoggerFactory);
|
await using var transport = new SseClientTransport(sseClientTransportOptions, HttpClient, LoggerFactory);
|
||||||
return await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
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('?')]);
|
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]
|
[Fact]
|
||||||
public async Task Messages_FromNewUser_AreRejected()
|
public async Task Messages_FromNewUser_AreRejected()
|
||||||
{
|
{
|
||||||
|
|
@ -144,13 +90,13 @@ public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTe
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ClaimsPrincipal CreateUser(string name)
|
protected ClaimsPrincipal CreateUser(string name)
|
||||||
=> new ClaimsPrincipal(new ClaimsIdentity(
|
=> new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
|
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
|
||||||
"TestAuthType", "name", "role"));
|
"TestAuthType", "name", "role"));
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
private class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
|
protected class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
|
||||||
{
|
{
|
||||||
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
|
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
|
||||||
public string EchoWithUserName(string message)
|
public string EchoWithUserName(string message)
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,9 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
|
||||||
// multiple tests, so this dispatches the output to the current test.
|
// multiple tests, so this dispatches the output to the current test.
|
||||||
private readonly DelegatingTestOutputHelper _delegatingTestOutputHelper = new();
|
private readonly DelegatingTestOutputHelper _delegatingTestOutputHelper = new();
|
||||||
|
|
||||||
private SseClientTransportOptions DefaultTransportOptions { get; } = new()
|
private SseClientTransportOptions DefaultTransportOptions { get; set; } = new()
|
||||||
{
|
{
|
||||||
Endpoint = new Uri("http://localhost/sse"),
|
Endpoint = new("http://localhost/"),
|
||||||
Name = "TestServer",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public SseServerIntegrationTestFixture()
|
public SseServerIntegrationTestFixture()
|
||||||
|
|
@ -37,8 +36,9 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
|
||||||
|
|
||||||
HttpClient = new HttpClient(socketsHttpHandler)
|
HttpClient = new HttpClient(socketsHttpHandler)
|
||||||
{
|
{
|
||||||
BaseAddress = DefaultTransportOptions.Endpoint,
|
BaseAddress = new("http://localhost/"),
|
||||||
};
|
};
|
||||||
|
|
||||||
_serverTask = Program.MainAsync([], new XunitLoggerProvider(_delegatingTestOutputHelper), _inMemoryTransport, _stopCts.Token);
|
_serverTask = Program.MainAsync([], new XunitLoggerProvider(_delegatingTestOutputHelper), _inMemoryTransport, _stopCts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,9 +53,10 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(ITestOutputHelper output)
|
public void Initialize(ITestOutputHelper output, SseClientTransportOptions clientTransportOptions)
|
||||||
{
|
{
|
||||||
_delegatingTestOutputHelper.CurrentTestOutputHelper = output;
|
_delegatingTestOutputHelper.CurrentTestOutputHelper = output;
|
||||||
|
DefaultTransportOptions = clientTransportOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void TestCompleted()
|
public void TestCompleted()
|
||||||
|
|
|
||||||
|
|
@ -1,279 +1,23 @@
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
using ModelContextProtocol.Protocol.Types;
|
|
||||||
using ModelContextProtocol.Tests.Utils;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||||
|
|
||||||
public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
|
public class SseServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
|
||||||
|
: HttpServerIntegrationTests(fixture, testOutputHelper)
|
||||||
|
|
||||||
{
|
{
|
||||||
private readonly SseServerIntegrationTestFixture _fixture;
|
protected override SseClientTransportOptions ClientTransportOptions => new()
|
||||||
|
|
||||||
public SseServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
|
|
||||||
: base(testOutputHelper)
|
|
||||||
{
|
{
|
||||||
_fixture = fixture;
|
Endpoint = new Uri("http://localhost/sse"),
|
||||||
_fixture.Initialize(testOutputHelper);
|
Name = "TestServer",
|
||||||
}
|
};
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EventSourceResponse_Includes_ExpectedHeaders()
|
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();
|
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"
|
// 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.
|
// 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);
|
using var streamReader = new StreamReader(sseResponse);
|
||||||
|
|
||||||
var endpointEvent = await streamReader.ReadLineAsync(TestContext.Current.CancellationToken);
|
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"))
|
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);
|
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"))
|
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);
|
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);
|
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]
|
[Fact]
|
||||||
public async Task ReceiveMessagesAsync_Handles_Messages()
|
public async Task ReceiveMessagesAsync_Handles_Messages()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue