Add client-side Streamable HTTP transport support (#356)

This commit is contained in:
Stephen Halter 2025-04-30 17:21:43 -07:00 • committed by GitHub
commit e913d80f05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 908 additions and 464 deletions

View file

@ -23,9 +23,9 @@ internal sealed class StreamableHttpHandler(
ILoggerFactory loggerFactory,
IServiceProvider applicationServices)
{
private static JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
private static MediaTypeHeaderValue ApplicationJsonMediaType = new("application/json");
private static MediaTypeHeaderValue TextEventStreamMediaType = new("text/event-stream");
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
private static readonly MediaTypeHeaderValue s_applicationJsonMediaType = new("application/json");
private static readonly MediaTypeHeaderValue s_textEventStreamMediaType = new("text/event-stream");
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
@ -36,7 +36,7 @@ internal sealed class StreamableHttpHandler(
// so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests,
// but it's probably good to at least start out trying to be strict.
var acceptHeaders = context.Request.GetTypedHeaders().Accept;
if (!acceptHeaders.Contains(ApplicationJsonMediaType) || !acceptHeaders.Contains(TextEventStreamMediaType))
if (!acceptHeaders.Contains(s_applicationJsonMediaType) || !acceptHeaders.Contains(s_textEventStreamMediaType))
{
await WriteJsonRpcErrorAsync(context,
"Not Acceptable: Client must accept both application/json and text/event-stream",
@ -64,7 +64,7 @@ internal sealed class StreamableHttpHandler(
public async Task HandleGetRequestAsync(HttpContext context)
{
var acceptHeaders = context.Request.GetTypedHeaders().Accept;
if (!acceptHeaders.Contains(TextEventStreamMediaType))
if (!acceptHeaders.Contains(s_textEventStreamMediaType))
{
await WriteJsonRpcErrorAsync(context,
"Not Acceptable: Client must accept text/event-stream",

View file

@ -87,40 +87,17 @@ internal sealed partial class SseClientSessionTransport : TransportBase
messageId = messageWithId.Id.ToString();
}
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
{
Content = content,
};
CopyAdditionalHeaders(httpRequestMessage.Headers);
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders);
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
// Check if the message was an initialize request
if (message is JsonRpcRequest request && request.Method == RequestMethods.Initialize)
{
// If the response is not a JSON-RPC response, it is an SSE message
if (string.IsNullOrEmpty(responseContent) || responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
{
LogAcceptedPost(Name, messageId);
// The response will arrive as an SSE message
}
else
{
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize(responseContent, McpJsonUtilities.JsonContext.Default.JsonRpcResponse) ??
throw new InvalidOperationException("Failed to initialize client");
LogTransportReceivedMessage(Name, messageId);
await WriteMessageAsync(initializeResponse, cancellationToken).ConfigureAwait(false);
LogTransportMessageWritten(Name, messageId);
}
return;
}
// Otherwise, check if the response was accepted (the response will come as an SSE message)
if (string.IsNullOrEmpty(responseContent) || responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
{
LogAcceptedPost(Name, messageId);
@ -177,17 +154,13 @@ internal sealed partial class SseClientSessionTransport : TransportBase
}
}
internal Uri? MessageEndpoint => _messageEndpoint;
internal SseClientTransportOptions Options => _options;
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
CopyAdditionalHeaders(request.Headers);
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders);
using var response = await _httpClient.SendAsync(
request,
@ -251,15 +224,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
return;
}
string messageId = "(no id)";
if (message is JsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
LogTransportReceivedMessage(Name, messageId);
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
LogTransportMessageWritten(Name, messageId);
}
catch (JsonException ex)
{
@ -290,20 +255,6 @@ internal sealed partial class SseClientSessionTransport : TransportBase
_connectionEstablished.TrySetResult(true);
}
private void CopyAdditionalHeaders(HttpRequestHeaders headers)
{
if (_options.AdditionalHeaders is not null)
{
foreach (var header in _options.AdditionalHeaders)
{
if (!headers.TryAddWithoutValidation(header.Key, header.Value))
{
throw new InvalidOperationException($"Failed to add header '{header.Key}' with value '{header.Value}' from {nameof(SseClientTransportOptions.AdditionalHeaders)}.");
}
}
}
}
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} accepted SSE transport POST for message ID '{MessageId}'.")]
private partial void LogAcceptedPost(string endpointName, string messageId);

View file

@ -57,6 +57,11 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
/// <inheritdoc />
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
{
if (_options.UseStreamableHttp)
{
return new StreamableHttpClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
}
var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
try

View file

@ -30,13 +30,20 @@ public record SseClientTransportOptions
}
}
/// <summary>
/// Gets or sets a value indicating whether to use "Streamable HTTP" for the transport rather than "HTTP with SSE". Defaults to false.
/// <see href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable HTTP transport specification</see>.
/// <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">HTTP with SSE transport specification</see>.
/// </summary>
public bool UseStreamableHttp { get; init; }
/// <summary>
/// Gets a transport identifier used for logging purposes.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets or sets a timeout used to establish the initial connection to the SSE server.
/// Gets or sets a timeout used to establish the initial connection to the SSE server. Defaults to 30 seconds.
/// </summary>
/// <remarks>
/// This timeout controls how long the client waits for:

View file

@ -146,15 +146,7 @@ internal class StreamClientSessionTransport : TransportBase
var message = (JsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)));
if (message != null)
{
string messageId = "(no id)";
if (message is JsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
LogTransportReceivedMessage(Name, messageId);
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
LogTransportMessageWritten(Name, messageId);
}
else
{

View file

@ -111,15 +111,7 @@ public class StreamServerTransport : TransportBase
{
if (JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))) is JsonRpcMessage message)
{
string messageId = "(no id)";
if (message is JsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
LogTransportReceivedMessage(Name, messageId);
await WriteMessageAsync(message, shutdownToken).ConfigureAwait(false);
LogTransportMessageWritten(Name, messageId);
}
else
{

View file

@ -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)}.");
}
}
}
}

View file

@ -61,7 +61,7 @@ internal sealed class StreamableHttpPostTransport(ChannelWriter<JsonRpcMessage>?
{
yield return message;
if (message.Data is JsonRpcResponse response)
if (message.Data is JsonRpcMessageWithId response)
{
if (_pendingRequests.Remove(response.Id) && _pendingRequests.Count == 0)
{

View file

@ -37,7 +37,7 @@ public abstract partial class TransportBase : ITransport
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = true,
SingleWriter = false,
});
}
@ -76,6 +76,12 @@ public abstract partial class TransportBase : ITransport
throw new InvalidOperationException("Transport is not connected");
}
if (_logger.IsEnabled(LogLevel.Debug))
{
var messageId = (message as JsonRpcMessageWithId)?.Id.ToString() ?? "(no id)";
LogTransportReceivedMessage(Name, messageId);
}
await _messageChannel.Writer.WriteAsync(message, cancellationToken).ConfigureAwait(false);
}
@ -115,9 +121,6 @@ public abstract partial class TransportBase : ITransport
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} transport received message with ID '{MessageId}'.")]
private protected partial void LogTransportReceivedMessage(string endpointName, string messageId);
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} transport sent message with ID '{MessageId}'.")]
private protected partial void LogTransportMessageWritten(string endpointName, string messageId);
[LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} transport received unexpected message. Message: '{Message}'.")]
private protected partial void LogTransportMessageParseUnexpectedTypeSensitive(string endpointName, string message);

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -11,13 +11,18 @@ using System.Security.Claims;
namespace ModelContextProtocol.AspNetCore.Tests;
public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
{
private async Task<IMcpClient> ConnectAsync(string? path = "/sse")
protected abstract bool UseStreamableHttp { get; }
protected async Task<IMcpClient> ConnectAsync(string? path = null)
{
path ??= UseStreamableHttp ? "/" : "/sse";
var sseClientTransportOptions = new SseClientTransportOptions()
{
Endpoint = new Uri($"http://localhost{path}"),
UseStreamableHttp = UseStreamableHttp,
};
await using var transport = new SseClientTransport(sseClientTransportOptions, HttpClient, LoggerFactory);
return await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
@ -56,65 +61,6 @@ public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTe
Assert.Equal($"data: {pattern}/message", dataLine[..dataLine.IndexOf('?')]);
}
[Theory]
[InlineData("/a", "/a/sse")]
[InlineData("/a/", "/a/sse")]
[InlineData("/a/b", "/a/b/sse")]
public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePattern, string requestPath)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new()
{
Name = "TestCustomRouteServer",
Version = "1.0.0",
};
}).WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp(routePattern);
await app.StartAsync(TestContext.Current.CancellationToken);
var mcpClient = await ConnectAsync(requestPath);
Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name);
}
[Fact]
public async Task Can_UseHttpContextAccessor_InTool()
{
Builder.Services.AddMcpServer().WithHttpTransport().WithTools<EchoHttpContextUserTools>();
Builder.Services.AddHttpContextAccessor();
await using var app = Builder.Build();
app.Use(next =>
{
return async context =>
{
context.User = CreateUser("TestUser");
await next(context);
};
});
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
var mcpClient = await ConnectAsync();
var response = await mcpClient.CallToolAsync(
"EchoWithUserName",
new Dictionary<string, object?>() { ["message"] = "Hello world!" },
cancellationToken: TestContext.Current.CancellationToken);
var content = Assert.Single(response.Content);
Assert.Equal("TestUser: Hello world!", content.Text);
}
[Fact]
public async Task Messages_FromNewUser_AreRejected()
{
@ -144,13 +90,13 @@ public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTe
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
}
private ClaimsPrincipal CreateUser(string name)
protected ClaimsPrincipal CreateUser(string name)
=> new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
"TestAuthType", "name", "role"));
[McpServerToolType]
private class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
protected class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor)
{
[McpServerTool, Description("Echoes the input back to the client with their user name.")]
public string EchoWithUserName(string message)

View file

@ -18,10 +18,9 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
// multiple tests, so this dispatches the output to the current test.
private readonly DelegatingTestOutputHelper _delegatingTestOutputHelper = new();
private SseClientTransportOptions DefaultTransportOptions { get; } = new()
private SseClientTransportOptions DefaultTransportOptions { get; set; } = new()
{
Endpoint = new Uri("http://localhost/sse"),
Name = "TestServer",
Endpoint = new("http://localhost/"),
};
public SseServerIntegrationTestFixture()
@ -37,8 +36,9 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
HttpClient = new HttpClient(socketsHttpHandler)
{
BaseAddress = DefaultTransportOptions.Endpoint,
BaseAddress = new("http://localhost/"),
};
_serverTask = Program.MainAsync([], new XunitLoggerProvider(_delegatingTestOutputHelper), _inMemoryTransport, _stopCts.Token);
}
@ -53,9 +53,10 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
TestContext.Current.CancellationToken);
}
public void Initialize(ITestOutputHelper output)
public void Initialize(ITestOutputHelper output, SseClientTransportOptions clientTransportOptions)
{
_delegatingTestOutputHelper.CurrentTestOutputHelper = output;
DefaultTransportOptions = clientTransportOptions;
}
public void TestCompleted()

View file

@ -1,279 +1,23 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Tests.Utils;
using ModelContextProtocol.Protocol.Transport;
using System.Net;
using System.Text;
namespace ModelContextProtocol.AspNetCore.Tests;
public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
public class SseServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
: HttpServerIntegrationTests(fixture, testOutputHelper)
{
private readonly SseServerIntegrationTestFixture _fixture;
public SseServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
protected override SseClientTransportOptions ClientTransportOptions => new()
{
_fixture = fixture;
_fixture.Initialize(testOutputHelper);
}
public override void Dispose()
{
_fixture.TestCompleted();
base.Dispose();
}
private Task<IMcpClient> GetClientAsync(McpClientOptions? options = null)
{
return _fixture.ConnectMcpClientAsync(options, LoggerFactory);
}
[Fact]
public async Task ConnectAndPing_Sse_TestServer()
{
// Arrange
// Act
await using var client = await GetClientAsync();
await client.PingAsync(TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(client);
}
[Fact]
public async Task Connect_TestServer_ShouldProvideServerFields()
{
// Arrange
// Act
await using var client = await GetClientAsync();
// Assert
Assert.NotNull(client.ServerCapabilities);
Assert.NotNull(client.ServerInfo);
}
[Fact]
public async Task ListTools_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(tools);
}
[Fact]
public async Task CallTool_Sse_EchoServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?>
{
["message"] = "Hello MCP!"
},
cancellationToken: TestContext.Current.CancellationToken
);
// assert
Assert.NotNull(result);
Assert.False(result.IsError);
var textContent = Assert.Single(result.Content, c => c.Type == "text");
Assert.Equal("Echo: Hello MCP!", textContent.Text);
}
[Fact]
public async Task ListResources_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
IList<Resource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
// The everything server provides 100 test resources
Assert.Equal(100, allResources.Count);
}
[Fact]
public async Task ReadResource_Sse_TextResource()
{
// arrange
// act
await using var client = await GetClientAsync();
// Odd numbered resources are text in the everything server (despite the docs saying otherwise)
// 1 is index 0, which is "even" in the 0-based index
// We copied this oddity to the test server
var result = await client.ReadResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
TextResourceContents textContent = Assert.IsType<TextResourceContents>(result.Contents[0]);
Assert.NotNull(textContent.Text);
}
[Fact]
public async Task ReadResource_Sse_BinaryResource()
{
// arrange
// act
await using var client = await GetClientAsync();
// Even numbered resources are binary in the everything server (despite the docs saying otherwise)
// 2 is index 1, which is "odd" in the 0-based index
// We copied this oddity to the test server
var result = await client.ReadResourceAsync("test://static/resource/2", TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
Assert.NotNull(blobContent.Blob);
}
[Fact]
public async Task ListPrompts_Sse_TestServer()
{
// arrange
// act
await using var client = await GetClientAsync();
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
// assert
Assert.NotNull(prompts);
Assert.NotEmpty(prompts);
// We could add specific assertions for the known prompts
Assert.Contains(prompts, p => p.Name == "simple_prompt");
Assert.Contains(prompts, p => p.Name == "complex_prompt");
}
[Fact]
public async Task GetPrompt_Sse_SimplePrompt()
{
// arrange
// act
await using var client = await GetClientAsync();
var result = await client.GetPromptAsync("simple_prompt", null, cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
Assert.NotEmpty(result.Messages);
}
[Fact]
public async Task GetPrompt_Sse_ComplexPrompt()
{
// arrange
// act
await using var client = await GetClientAsync();
var arguments = new Dictionary<string, object?>
{
{ "temperature", "0.7" },
{ "style", "formal" }
};
var result = await client.GetPromptAsync("complex_prompt", arguments, cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
Assert.NotEmpty(result.Messages);
}
[Fact]
public async Task GetPrompt_Sse_NonExistent_ThrowsException()
{
// arrange
// act
await using var client = await GetClientAsync();
await Assert.ThrowsAsync<McpException>(() =>
client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task Sampling_Sse_TestServer()
{
// arrange
// Set up the sampling handler
int samplingHandlerCalls = 0;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
McpClientOptions options = new();
options.Capabilities = new();
options.Capabilities.Sampling ??= new();
options.Capabilities.Sampling.SamplingHandler = async (_, _, _) =>
{
samplingHandlerCalls++;
return new CreateMessageResult
{
Model = "test-model",
Role = Role.Assistant,
Content = new Content
{
Type = "text",
Text = "Test response"
}
};
};
await using var client = await GetClientAsync(options);
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
// Call the server's sampleLLM tool which should trigger our sampling handler
var result = await client.CallToolAsync("sampleLLM", new Dictionary<string, object?>
{
["prompt"] = "Test prompt",
["maxTokens"] = 100
},
cancellationToken: TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
var textContent = Assert.Single(result.Content);
Assert.Equal("text", textContent.Type);
Assert.False(string.IsNullOrEmpty(textContent.Text));
}
[Fact]
public async Task CallTool_Sse_EchoServer_Concurrently()
{
await using var client1 = await GetClientAsync();
await using var client2 = await GetClientAsync();
for (int i = 0; i < 4; i++)
{
var client = (i % 2 == 0) ? client1 : client2;
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?>
{
["message"] = $"Hello MCP! {i}"
},
cancellationToken: TestContext.Current.CancellationToken
);
Assert.NotNull(result);
Assert.False(result.IsError);
var textContent = Assert.Single(result.Content, c => c.Type == "text");
Assert.Equal($"Echo: Hello MCP! {i}", textContent.Text);
}
}
Endpoint = new Uri("http://localhost/sse"),
Name = "TestServer",
};
[Fact]
public async Task EventSourceResponse_Includes_ExpectedHeaders()
{
using var sseResponse = await _fixture.HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
using var sseResponse = await _fixture.HttpClient.GetAsync("/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
sseResponse.EnsureSuccessStatusCode();
@ -289,7 +33,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
{
// Simulate our own MCP client handshake using a plain HttpClient so we can look for "event: message"
// in the raw SSE response stream which is not exposed by the real MCP client.
await using var sseResponse = await _fixture.HttpClient.GetStreamAsync("", TestContext.Current.CancellationToken);
await using var sseResponse = await _fixture.HttpClient.GetStreamAsync("/sse", TestContext.Current.CancellationToken);
using var streamReader = new StreamReader(sseResponse);
var endpointEvent = await streamReader.ReadLineAsync(TestContext.Current.CancellationToken);
@ -305,7 +49,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
""";
using (var initializeRequestBody = new StringContent(initializeRequest, Encoding.UTF8, "application/json"))
{
var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializeRequestBody, TestContext.Current.CancellationToken);
using var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializeRequestBody, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}
@ -314,7 +58,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
""";
using (var initializedNotificationBody = new StringContent(initializedNotification, Encoding.UTF8, "application/json"))
{
var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializedNotificationBody, TestContext.Current.CancellationToken);
using var response = await _fixture.HttpClient.PostAsync(messageEndpoint, initializedNotificationBody, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -120,52 +120,6 @@ public class SseClientTransportTests : LoggedTest
Assert.True(true);
}
[Fact]
public async Task SendMessageAsync_Handles_Accepted_Json_RPC_Response()
{
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new SseClientTransport(_transportOptions, httpClient, LoggerFactory);
var eventSourcePipe = new Pipe();
var eventSourceData = "event: endpoint\r\ndata: /sseendpoint\r\n\r\n"u8;
eventSourceData.CopyTo(eventSourcePipe.Writer.GetSpan(eventSourceData.Length));
eventSourcePipe.Writer.Advance(eventSourceData.Length);
await eventSourcePipe.Writer.FlushAsync(TestContext.Current.CancellationToken);
var firstCall = true;
mockHttpHandler.RequestHandler = (request) =>
{
if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == "http://localhost:8080/sseendpoint")
{
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{\"jsonrpc\":\"2.0\", \"id\": \"44\", \"result\": null}")
});
}
else
{
if (!firstCall)
throw new IOException("Abort");
else
firstCall = false;
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StreamContent(eventSourcePipe.Reader.AsStream()),
});
}
};
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
await session.SendMessageAsync(new JsonRpcRequest() { Method = RequestMethods.Initialize, Id = new RequestId(44) }, CancellationToken.None);
Assert.True(true);
eventSourcePipe.Writer.Complete();
}
[Fact]
public async Task ReceiveMessagesAsync_Handles_Messages()
{