- Renamed all directories: OpenHarbor.MCP.* → Svrnty.MCP.* - Updated all namespaces in 179 C# files - Renamed 20 .csproj files and 3 .sln files - Updated 193 documentation references - Updated 33 references in main CODEX codebase - Updated Codex.sln with new paths - Build verified: 0 errors Preparing for extraction to standalone repositories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
448 lines
14 KiB
C#
448 lines
14 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using Moq;
|
|
using Moq.Protected;
|
|
using Xunit;
|
|
using Svrnty.MCP.Client.Core.Exceptions;
|
|
using Svrnty.MCP.Client.Core.Models;
|
|
using Svrnty.MCP.Client.Infrastructure;
|
|
|
|
namespace Svrnty.MCP.Client.Core.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for HttpServerConnection following TDD approach.
|
|
/// Tests HTTP transport implementation for MCP client connections.
|
|
/// </summary>
|
|
public class HttpServerConnectionTests
|
|
{
|
|
private readonly McpServerConfig _httpConfig;
|
|
private readonly Mock<HttpMessageHandler> _mockHttpHandler;
|
|
private readonly HttpClient _mockHttpClient;
|
|
|
|
public HttpServerConnectionTests()
|
|
{
|
|
_httpConfig = new McpServerConfig
|
|
{
|
|
Name = "test-server",
|
|
Transport = new HttpTransportConfig
|
|
{
|
|
Type = "Http",
|
|
BaseUrl = "http://localhost:5050"
|
|
},
|
|
Timeout = TimeSpan.FromSeconds(30)
|
|
};
|
|
|
|
_mockHttpHandler = new Mock<HttpMessageHandler>();
|
|
_mockHttpClient = new HttpClient(_mockHttpHandler.Object)
|
|
{
|
|
BaseAddress = new Uri("http://localhost:5050")
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WithValidConfig_CreatesConnection()
|
|
{
|
|
// Arrange & Act
|
|
var connection = new HttpServerConnection(_httpConfig);
|
|
|
|
// Assert
|
|
Assert.Equal("test-server", connection.ServerName);
|
|
Assert.False(connection.IsConnected);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WithNullConfig_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange & Act & Assert
|
|
Assert.Throws<ArgumentNullException>(() => new HttpServerConnection(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectAsync_WithValidServer_EstablishesConnection()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
|
|
// Act
|
|
await connection.ConnectAsync();
|
|
|
|
// Assert
|
|
Assert.True(connection.IsConnected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectAsync_WhenAlreadyConnected_DoesNothing()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act
|
|
await connection.ConnectAsync(); // Second call
|
|
|
|
// Assert
|
|
Assert.True(connection.IsConnected);
|
|
_mockHttpHandler.Protected().Verify(
|
|
"SendAsync",
|
|
Times.Once(), // Only called once
|
|
ItExpr.IsAny<HttpRequestMessage>(),
|
|
ItExpr.IsAny<CancellationToken>()
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectAsync_WithNonHttpConfig_ThrowsMcpConnectionException()
|
|
{
|
|
// Arrange
|
|
var stdioConfig = new McpServerConfig
|
|
{
|
|
Name = "stdio-server",
|
|
Transport = new StdioTransportConfig
|
|
{
|
|
Type = "Stdio",
|
|
Command = "dotnet"
|
|
}
|
|
};
|
|
var connection = new HttpServerConnection(stdioConfig, _mockHttpClient);
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<McpConnectionException>(
|
|
() => connection.ConnectAsync());
|
|
Assert.Contains("not configured for HTTP transport", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectAsync_WithFailedHealthCheck_ThrowsMcpConnectionException()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.ServiceUnavailable, "");
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<McpConnectionException>(
|
|
() => connection.ConnectAsync());
|
|
Assert.Contains("health check failed", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectAsync_WithApiKey_AddsAuthorizationHeader()
|
|
{
|
|
// Arrange
|
|
var configWithApiKey = new McpServerConfig
|
|
{
|
|
Name = "secure-server",
|
|
Transport = new HttpTransportConfig
|
|
{
|
|
Type = "Http",
|
|
BaseUrl = "http://localhost:5050",
|
|
ApiKey = "test-api-key-123"
|
|
}
|
|
};
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var connection = new HttpServerConnection(configWithApiKey, _mockHttpClient);
|
|
|
|
// Act
|
|
await connection.ConnectAsync();
|
|
|
|
// Assert
|
|
Assert.NotNull(_mockHttpClient.DefaultRequestHeaders.Authorization);
|
|
Assert.Equal("Bearer", _mockHttpClient.DefaultRequestHeaders.Authorization.Scheme);
|
|
Assert.Equal("test-api-key-123", _mockHttpClient.DefaultRequestHeaders.Authorization.Parameter);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisconnectAsync_WhenConnected_SetsIsConnectedToFalse()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act
|
|
await connection.DisconnectAsync();
|
|
|
|
// Assert
|
|
Assert.False(connection.IsConnected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisconnectAsync_WhenNotConnected_DoesNothing()
|
|
{
|
|
// Arrange
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
|
|
// Act & Assert (should not throw)
|
|
await connection.DisconnectAsync();
|
|
Assert.False(connection.IsConnected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListToolsAsync_WhenNotConnected_ThrowsMcpConnectionException()
|
|
{
|
|
// Arrange
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<McpConnectionException>(
|
|
() => connection.ListToolsAsync());
|
|
Assert.Contains("not connected", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListToolsAsync_WithValidResponse_ReturnsTools()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var responseJson = @"{
|
|
""jsonrpc"": ""2.0"",
|
|
""id"": ""1"",
|
|
""result"": {
|
|
""tools"": [
|
|
{
|
|
""name"": ""search_codex"",
|
|
""description"": ""Search the CODEX knowledge base""
|
|
},
|
|
{
|
|
""name"": ""get_document"",
|
|
""description"": ""Get a document by ID""
|
|
}
|
|
]
|
|
}
|
|
}";
|
|
SetupMockJsonRpcResponse(HttpStatusCode.OK, responseJson);
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act
|
|
var tools = (await connection.ListToolsAsync()).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(2, tools.Count);
|
|
Assert.Equal("search_codex", tools[0].Name);
|
|
Assert.Equal("Search the CODEX knowledge base", tools[0].Description);
|
|
Assert.Equal("get_document", tools[1].Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListToolsAsync_WithErrorResponse_ThrowsMcpConnectionException()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var errorJson = @"{
|
|
""jsonrpc"": ""2.0"",
|
|
""id"": ""1"",
|
|
""error"": {
|
|
""code"": -32600,
|
|
""message"": ""Invalid Request""
|
|
}
|
|
}";
|
|
SetupMockJsonRpcResponse(HttpStatusCode.OK, errorJson);
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<McpConnectionException>(
|
|
() => connection.ListToolsAsync());
|
|
Assert.Contains("returned error", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallToolAsync_WhenNotConnected_ThrowsMcpConnectionException()
|
|
{
|
|
// Arrange
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<McpConnectionException>(
|
|
() => connection.CallToolAsync("search_codex"));
|
|
Assert.Contains("not connected", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallToolAsync_WithEmptyToolName_ThrowsArgumentException()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentException>(
|
|
() => connection.CallToolAsync(string.Empty));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallToolAsync_WithValidResponse_ReturnsSuccess()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var responseJson = @"{
|
|
""jsonrpc"": ""2.0"",
|
|
""id"": ""2"",
|
|
""result"": {
|
|
""content"": ""Search results: Found 5 documents""
|
|
}
|
|
}";
|
|
SetupMockJsonRpcResponse(HttpStatusCode.OK, responseJson);
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act
|
|
var result = await connection.CallToolAsync(
|
|
"search_codex",
|
|
new Dictionary<string, object> { ["query"] = "test" }
|
|
);
|
|
|
|
// Assert
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("Search results: Found 5 documents", result.Content);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallToolAsync_WithErrorResponse_ReturnsFailure()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var errorJson = @"{
|
|
""jsonrpc"": ""2.0"",
|
|
""id"": ""2"",
|
|
""error"": {
|
|
""code"": -32602,
|
|
""message"": ""Invalid parameters""
|
|
}
|
|
}";
|
|
SetupMockJsonRpcResponse(HttpStatusCode.OK, errorJson);
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act
|
|
var result = await connection.CallToolAsync("search_codex");
|
|
|
|
// Assert
|
|
Assert.False(result.IsSuccess);
|
|
Assert.Contains("Invalid parameters", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallToolAsync_WithoutArguments_SendsValidRequest()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var responseJson = @"{
|
|
""jsonrpc"": ""2.0"",
|
|
""id"": ""2"",
|
|
""result"": {
|
|
""content"": ""Tool executed successfully""
|
|
}
|
|
}";
|
|
SetupMockJsonRpcResponse(HttpStatusCode.OK, responseJson);
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act
|
|
var result = await connection.CallToolAsync("list_tools");
|
|
|
|
// Assert
|
|
Assert.True(result.IsSuccess);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PingAsync_WhenNotConnected_ThrowsMcpConnectionException()
|
|
{
|
|
// Arrange
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<McpConnectionException>(
|
|
() => connection.PingAsync());
|
|
Assert.Contains("not connected", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PingAsync_WithHealthyServer_Succeeds()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act & Assert (should not throw)
|
|
await connection.PingAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PingAsync_WithUnhealthyServer_ThrowsMcpConnectionException()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Setup ping to fail
|
|
_mockHttpHandler.Protected()
|
|
.Setup<Task<HttpResponseMessage>>(
|
|
"SendAsync",
|
|
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.PathAndQuery.Contains("/health")),
|
|
ItExpr.IsAny<CancellationToken>()
|
|
)
|
|
.ReturnsAsync(new HttpResponseMessage
|
|
{
|
|
StatusCode = HttpStatusCode.ServiceUnavailable
|
|
});
|
|
|
|
// Act & Assert
|
|
var exception = await Assert.ThrowsAsync<McpConnectionException>(
|
|
() => connection.PingAsync());
|
|
Assert.Contains("Ping", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisposeAsync_WhenConnected_DisconnectsAndDisposesHttpClient()
|
|
{
|
|
// Arrange
|
|
SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}");
|
|
var connection = new HttpServerConnection(_httpConfig, _mockHttpClient);
|
|
await connection.ConnectAsync();
|
|
|
|
// Act
|
|
await connection.DisposeAsync();
|
|
|
|
// Assert
|
|
Assert.False(connection.IsConnected);
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
private void SetupMockHealthCheck(HttpStatusCode statusCode, string content)
|
|
{
|
|
_mockHttpHandler.Protected()
|
|
.Setup<Task<HttpResponseMessage>>(
|
|
"SendAsync",
|
|
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.PathAndQuery.Contains("/health")),
|
|
ItExpr.IsAny<CancellationToken>()
|
|
)
|
|
.ReturnsAsync(new HttpResponseMessage
|
|
{
|
|
StatusCode = statusCode,
|
|
Content = new StringContent(content, Encoding.UTF8, "application/json")
|
|
});
|
|
}
|
|
|
|
private void SetupMockJsonRpcResponse(HttpStatusCode statusCode, string json)
|
|
{
|
|
_mockHttpHandler.Protected()
|
|
.Setup<Task<HttpResponseMessage>>(
|
|
"SendAsync",
|
|
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.PathAndQuery.Contains("/mcp/invoke")),
|
|
ItExpr.IsAny<CancellationToken>()
|
|
)
|
|
.ReturnsAsync(new HttpResponseMessage
|
|
{
|
|
StatusCode = statusCode,
|
|
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
|
});
|
|
}
|
|
}
|