Add more tests (#51)
* Add more tests * Fix some code smells * Remove more code smells * Combine "ServerIntegrationTests" and "ClientIntegrationTests" because both are client integration tests just using different servers * Clean up resources after test * Improve kill logic for spawned child processes * Improved testability for Stdio * Fix SseIntegration tests
This commit is contained in:
parent
fe632617dc
commit
b5076263d1
2 changed files with 883 additions and 751 deletions
280
tests/mcpdotnet.Tests/Transport/SseClientTransportTests.cs
Normal file
280
tests/mcpdotnet.Tests/Transport/SseClientTransportTests.cs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
using System.Net;
|
||||
using McpDotNet.Configuration;
|
||||
using McpDotNet.Protocol.Messages;
|
||||
using McpDotNet.Protocol.Transport;
|
||||
using McpDotNet.Tests.Utils;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace McpDotNet.Tests.Transport;
|
||||
|
||||
public class SseClientTransportTests
|
||||
{
|
||||
private readonly McpServerConfig _serverConfig;
|
||||
private readonly SseClientTransportOptions _transportOptions;
|
||||
|
||||
public SseClientTransportTests()
|
||||
{
|
||||
_serverConfig = new McpServerConfig
|
||||
{
|
||||
Id = "test-server",
|
||||
Name = "Test Server",
|
||||
TransportType = TransportTypes.Sse,
|
||||
Location = "http://localhost:8080"
|
||||
};
|
||||
|
||||
_transportOptions = new SseClientTransportOptions
|
||||
{
|
||||
ConnectionTimeout = TimeSpan.FromSeconds(2),
|
||||
MaxReconnectAttempts = 3,
|
||||
ReconnectDelay = TimeSpan.FromMilliseconds(50),
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["test"] = "header"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
|
||||
{
|
||||
// Act
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(transport);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), transport.Options.ConnectionTimeout);
|
||||
Assert.Equal(3, transport.Options.MaxReconnectAttempts);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(50), transport.Options.ReconnectDelay);
|
||||
Assert.NotNull(transport.Options.AdditionalHeaders);
|
||||
Assert.Equal("header", transport.Options.AdditionalHeaders["test"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Options()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => new SseClientTransport(null!, _serverConfig, NullLoggerFactory.Instance));
|
||||
Assert.Equal("transportOptions", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Config()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => new SseClientTransport(_transportOptions, null!, NullLoggerFactory.Instance));
|
||||
Assert.Equal("serverConfig", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_HttpClientg()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => new SseClientTransport(_transportOptions, _serverConfig, null!, NullLoggerFactory.Instance));
|
||||
Assert.Equal("httpClient", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_Should_Connect_Successfully()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance);
|
||||
|
||||
bool firstCall = true;
|
||||
|
||||
mockHttpHandler.RequestHandler = async (request) =>
|
||||
{
|
||||
if (!firstCall)
|
||||
{
|
||||
Assert.True(transport.IsConnected);
|
||||
await transport.CloseAsync();
|
||||
}
|
||||
|
||||
firstCall = false;
|
||||
return new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("event: endpoint\r\ndata: http://localhost\r\n\r\n")
|
||||
};
|
||||
};
|
||||
|
||||
await transport.ConnectAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_Throws_If_Already_Connected()
|
||||
{
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance);
|
||||
transport.GetType().BaseType!.GetField("_isConnected", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.SetValue(transport, true);
|
||||
|
||||
var action = async () => await transport.ConnectAsync();
|
||||
var exception = await Assert.ThrowsAsync<McpTransportException>(action);
|
||||
Assert.Equal("Transport is already connected", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_Throws_Exception_On_Failure()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance);
|
||||
|
||||
var retries = 0;
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
retries++;
|
||||
throw new InvalidOperationException("Test exception");
|
||||
};
|
||||
|
||||
var action = async () => await transport.ConnectAsync();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<McpTransportException>(action);
|
||||
Assert.Equal("Exceeded reconnect limit", exception.Message);
|
||||
|
||||
Assert.Equal(_transportOptions.MaxReconnectAttempts, retries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_Throws_Exception_If_MessageEndpoint_Not_Set()
|
||||
{
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(transport.MessageEndpoint?.ToString()));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => transport.SendMessageAsync(new JsonRpcRequest() { Method = "test" }, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_Handles_Accepted_Response()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance);
|
||||
|
||||
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("accepted")
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!firstCall)
|
||||
throw new IOException("Abort");
|
||||
else
|
||||
firstCall = false;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n")
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await transport.ConnectAsync();
|
||||
|
||||
await transport.SendMessageAsync(new JsonRpcRequest() { Method = "initialize", Id = RequestId.FromNumber(44) }, CancellationToken.None);
|
||||
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, _serverConfig, httpClient, NullLoggerFactory.Instance);
|
||||
|
||||
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 StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n")
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await transport.ConnectAsync();
|
||||
|
||||
await transport.SendMessageAsync(new JsonRpcRequest() { Method = "initialize", Id = RequestId.FromNumber(44) }, CancellationToken.None);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveMessagesAsync_Handles_Messages()
|
||||
{
|
||||
using var mockHttpHandler = new MockHttpHandler();
|
||||
using var httpClient = new HttpClient(mockHttpHandler);
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, httpClient, NullLoggerFactory.Instance);
|
||||
|
||||
var callIndex = 0;
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
callIndex++;
|
||||
|
||||
if (callIndex == 1)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n")
|
||||
});
|
||||
}
|
||||
else if (callIndex == 2)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("event: message\r\ndata: {\"jsonrpc\":\"2.0\", \"id\": \"44\", \"method\": \"test\", \"params\": null}\r\n\r\n")
|
||||
});
|
||||
}
|
||||
|
||||
throw new IOException("Abort");
|
||||
};
|
||||
|
||||
await transport.ConnectAsync();
|
||||
Assert.True(transport.MessageReader.TryRead(out var message));
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcRequest>(message);
|
||||
Assert.Equal("44", ((JsonRpcRequest)message).Id.AsString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CloseAsync_Should_Dispose_Resources()
|
||||
{
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance);
|
||||
|
||||
await transport.CloseAsync();
|
||||
|
||||
Assert.False(transport.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_Should_Dispose_Resources()
|
||||
{
|
||||
await using var transport = new SseClientTransport(_transportOptions, _serverConfig, NullLoggerFactory.Instance);
|
||||
|
||||
await transport.DisposeAsync();
|
||||
|
||||
Assert.False(transport.IsConnected);
|
||||
}
|
||||
}
|
||||
133
tests/mcpdotnet.Tests/Transport/StdioServerTransportTests.cs
Normal file
133
tests/mcpdotnet.Tests/Transport/StdioServerTransportTests.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
using System.Text.Json;
|
||||
using McpDotNet.Protocol.Messages;
|
||||
using McpDotNet.Protocol.Transport;
|
||||
using McpDotNet.Protocol.Types;
|
||||
using McpDotNet.Server;
|
||||
using McpDotNet.Utils.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace McpDotNet.Tests.Transport;
|
||||
|
||||
public class StdioServerTransportTests
|
||||
{
|
||||
private readonly McpServerOptions _serverOptions;
|
||||
|
||||
public StdioServerTransportTests()
|
||||
{
|
||||
_serverOptions = new McpServerOptions
|
||||
{
|
||||
ServerInfo = new Implementation
|
||||
{
|
||||
Name = "Test Server",
|
||||
Version = "1.0"
|
||||
},
|
||||
ProtocolVersion = "2.0",
|
||||
InitializationTimeout = TimeSpan.FromSeconds(10),
|
||||
ServerInstructions = "Test Instructions"
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
|
||||
{
|
||||
// Act
|
||||
await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(transport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Options()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => new StdioServerTransport(serverOptions: null!, NullLoggerFactory.Instance, InputOutputStreams.Default));
|
||||
Assert.Equal("serverOptions", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_For_Null_Streams()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, null!));
|
||||
Assert.Equal("inputOutputStreams", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListeningAsync_Should_Set_Connected_State()
|
||||
{
|
||||
await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default);
|
||||
|
||||
await transport.StartListeningAsync();
|
||||
|
||||
Assert.True(transport.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_Should_Send_Message()
|
||||
{
|
||||
var streams = new InputOutputStreams { Output = new StringWriter(), Input = new StringReader("") };
|
||||
await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, streams);
|
||||
await transport.StartListeningAsync();
|
||||
|
||||
var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) };
|
||||
|
||||
|
||||
await transport.SendMessageAsync(message);
|
||||
|
||||
var result = streams.Output.ToString()?.Trim();
|
||||
var expected = JsonSerializer.Serialize(message, JsonSerializerOptionsExtensions.DefaultOptions);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessageAsync_Throws_Exception_If_Not_Connected()
|
||||
{
|
||||
await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default);
|
||||
|
||||
var message = new JsonRpcRequest { Method = "test" };
|
||||
|
||||
await Assert.ThrowsAsync<McpTransportException>(() => transport.SendMessageAsync(message));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_Should_Dispose_Resources()
|
||||
{
|
||||
await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default);
|
||||
|
||||
await transport.DisposeAsync();
|
||||
|
||||
Assert.False(transport.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadMessagesAsync_Should_Read_Messages()
|
||||
{
|
||||
var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) };
|
||||
var json = JsonSerializer.Serialize(message, JsonSerializerOptionsExtensions.DefaultOptions);
|
||||
|
||||
using var sr = new StringReader(json);
|
||||
|
||||
var streams = new InputOutputStreams { Output = new StringWriter(), Input = new StringReader(json) };
|
||||
await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, streams);
|
||||
await transport.StartListeningAsync();
|
||||
|
||||
var canRead = await transport.MessageReader.WaitToReadAsync();
|
||||
|
||||
Assert.True(canRead, "Nothing to read here from transport message reader");
|
||||
Assert.True(transport.MessageReader.TryPeek(out var readMessage));
|
||||
Assert.NotNull(readMessage);
|
||||
Assert.IsType<JsonRpcRequest>(readMessage);
|
||||
Assert.Equal(44, ((JsonRpcRequest)readMessage).Id.AsNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupAsync_Should_Cleanup_Resources()
|
||||
{
|
||||
var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance, InputOutputStreams.Default);
|
||||
await transport.StartListeningAsync();
|
||||
|
||||
await transport.DisposeAsync();
|
||||
|
||||
Assert.False(transport.IsConnected);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue