docs: comprehensive AI coding assistant research and MCP-first implementation plan

Research conducted on modern AI coding assistants (Cursor, GitHub Copilot, Cline,
Aider, Windsurf, Replit Agent) to understand architecture patterns, context management,
code editing workflows, and tool use protocols.

Key Decision: Pivoted from building full CLI (40-50h) to validation-driven MCP-first
approach (10-15h). Build 5 core CODEX MCP tools that work with ANY coding assistant,
validate adoption over 2-4 weeks, then decide on full CLI if demand proven.

Files:
- research/ai-systems/modern-coding-assistants-architecture.md (comprehensive research)
- research/ai-systems/codex-coding-assistant-implementation-plan.md (original CLI plan, preserved)
- research/ai-systems/codex-mcp-tools-implementation-plan.md (approved MCP-first plan)
- ideas/registry.json (updated with approved MCP tools proposal)

Architech Validation: APPROVED with pivot to MCP-first approach
Human Decision: Approved (pragmatic validation-driven development)

Next: Begin Phase 1 implementation (10-15 hours, 5 core MCP tools)

🤖 Generated with CODEX Research System

Co-Authored-By: The Archivist <archivist@codex.svrnty.io>
Co-Authored-By: The Architech <architech@codex.svrnty.io>
Co-Authored-By: Mathias Beaulieu-Duncan <mat@svrnty.io>
This commit is contained in:
Svrnty 2025-10-22 21:00:34 -04:00
commit a4a1dd2e38
87 changed files with 11149 additions and 0 deletions

82
.gitignore vendored Normal file
View File

@ -0,0 +1,82 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
build/
bld/
[Bb]in/
[Oo]bj/
# Visual Studio cache/options
.vs/
*.cache
# NuGet Packages
*.nupkg
*.snupkg
**/packages/*
# Test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# Mac
.DS_Store
# Rider
.idea/
*.sln.iml
# User-specific files
*.rsuser
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Temporary files
scratch/
*.swp
*.bak
*~

196
AGENT-PRIMER.md Normal file
View File

@ -0,0 +1,196 @@
# AGENT-PRIMER: OpenHarbor.MCP.Gateway Automated Setup
**Purpose**: Guide AI agents to automatically analyze a target system and configure OpenHarbor.MCP.Gateway integration.
**Target Audience**: AI assistants (Claude, ChatGPT, etc.) helping developers set up MCP gateway/proxy infrastructure.
---
## Overview
OpenHarbor.MCP.Gateway is a **standalone .NET library** that provides proxy and routing infrastructure for MCP traffic, enabling centralized management between MCP clients and servers.
**What you'll automate:**
1. System analysis (detect .NET version, network requirements)
2. Configuration generation (appsettings.json, routing rules, Program.cs)
3. Sample routing strategies based on deployment scenario
4. Environment setup and validation
---
## Step 1: System Analysis
### Tasks for AI Agent:
#### 1.1 Detect .NET Environment
```bash
dotnet --version # Required: .NET 8.0+
dotnet --list-sdks
```
#### 1.2 Analyze Deployment Scenario
Ask user to identify scenario:
- **Scenario A**: Single gateway routing to multiple backend servers
- **Scenario B**: Load balancing across server instances
- **Scenario C**: A/B testing different server versions
- **Scenario D**: Multi-tenant routing based on client identity
#### 1.3 Identify Backend Servers
```bash
# Detect existing MCP servers
find . -name "*McpServer*" -type d
```
**Output**: JSON summary
```json
{
"dotnetVersion": "8.0.100",
"scenario": "LoadBalancing",
"backendServers": [
{
"name": "codex-server-1",
"transport": "Stdio",
"path": "/path/to/server1"
},
{
"name": "codex-server-2",
"transport": "Http",
"url": "https://api.example.com/mcp"
}
]
}
```
---
## Step 2: Generate Configuration
### 2.1 appsettings.json Configuration
```json
{
"Mcp": {
"Gateway": {
"Name": "MyMcpGateway",
"Version": "1.0.0",
"ListenAddress": "http://localhost:8080"
},
"Servers": [
{
"Id": "server-1",
"Name": "Primary Server",
"Transport": {
"Type": "Http",
"Command": "dotnet",
"Args": ["run", "--project", "/path/to/server"]
},
"Enabled": true
}
],
"Routing": {
"Strategy": "RoundRobin",
"HealthCheckInterval": "00:00:30"
},
"Security": {
"EnableAuthentication": false,
"RateLimit": {
"RequestsPerMinute": 100
}
}
}
}
```
### 2.2 Program.cs Integration
```csharp
using OpenHarbor.MCP.Gateway.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpGateway(builder.Configuration.GetSection("Mcp"));
var app = builder.Build();
app.MapMcpGateway();
app.MapHealthChecks("/health");
app.Run();
```
---
## Step 3: Generate Routing Strategy
### 3.1 Round-Robin Load Balancing
```csharp
public class RoundRobinRouter : IRoutingStrategy
{
private int _index = 0;
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
var list = servers.Where(s => s.IsHealthy).ToList();
var index = Interlocked.Increment(ref _index) % list.Count;
return list[index].Id;
}
}
```
### 3.2 Tool-Based Routing
```csharp
public class ToolBasedRouter : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
return context.ToolName switch
{
var t when t.StartsWith("search_") => "search-server",
var t when t.StartsWith("db_") => "database-server",
_ => servers.First().Id
};
}
}
```
---
## Step 4: Validation
```bash
# Build gateway
dotnet build
# Run tests
dotnet test
# Start gateway
dotnet run
# Check health
curl http://localhost:8080/health
```
---
## Step 5: AI Agent Workflow
1. **Analyze** → Detect scenario and backend servers
2. **Confirm** → Show configuration, ask approval
3. **Generate** → Create files from Steps 2-3
4. **Validate** → Run Step 4 tests
5. **Report** → "Gateway configured. Ready to route MCP traffic."
---
**Document Version**: 1.0.0
**Last Updated**: 2025-10-19
**Target**: OpenHarbor.MCP.Gateway

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Svrnty
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

601
README.md Normal file
View File

@ -0,0 +1,601 @@
# OpenHarbor.MCP.Gateway
**A modular, scalable, secure .NET library for routing and managing Model Context Protocol (MCP) traffic**
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![.NET 8.0](https://img.shields.io/badge/.NET-8.0-512BD4)](https://dotnet.microsoft.com/download/dotnet/8.0)
[![Architecture: Clean](https://img.shields.io/badge/Architecture-Clean-green)](docs/architecture.md)
---
## What is OpenHarbor.MCP.Gateway?
OpenHarbor.MCP.Gateway is a **standalone, reusable .NET library** that provides proxy and routing infrastructure for Model Context Protocol (MCP) traffic, enabling centralized management, authentication, monitoring, and load balancing between MCP clients and servers.
**Model Context Protocol (MCP)** is an industry-standard protocol backed by Anthropic that defines how AI agents communicate with external tools and data sources. The Gateway acts as an intelligent intermediary that enhances security, observability, and reliability.
### Key Features
- **Centralized Routing**: Single point of entry for all MCP traffic
- **Clean Architecture**: Core abstractions, infrastructure implementation, ASP.NET Core integration
- **Security-First**: Authentication, authorization, rate limiting, audit logging
- **HTTP Transport**: Production-ready HTTP communication with MCP servers
- **AI-Automated Setup**: AGENT-PRIMER.md guides AI assistants to configure your integration automatically
- **TDD Foundation**: Built with test-driven development, comprehensive test coverage
- **Production-Ready**: Observability, health checks, circuit breakers, load balancing
---
## Why OpenHarbor.MCP.Gateway?
**Problem**: Managing multiple MCP clients connecting to multiple MCP servers becomes complex, with duplicated authentication, monitoring, and routing logic scattered across components.
**Solution**: OpenHarbor.MCP.Gateway provides a centralized proxy that handles routing, authentication, rate limiting, and monitoring in one place, simplifying architecture and enhancing security.
**Use Cases**:
- Route multiple AI agents to appropriate backend MCP servers
- Centralize authentication and authorization for all MCP traffic
- Monitor and log all tool calls across your infrastructure
- Implement rate limiting and circuit breakers
- Load balance requests across multiple server instances
- A/B test different MCP server implementations
- Provide unified observability dashboard
---
## Quick Start
### Prerequisites
- .NET 8.0 SDK or higher
- Access to MCP servers (backends to route to)
- MCP clients (frontends that will connect through gateway)
### Option 1: AI-Automated Setup (Recommended)
If you have access to Claude or another AI assistant:
1. Copy this entire folder to your project directory
2. Open your AI assistant and say: "Read AGENT-PRIMER.md and set up OpenHarbor.MCP.Gateway for my project"
3. The AI will analyze your system, generate configuration, and create routing rules automatically
### Option 2: Manual Setup
#### Step 1: Add Package Reference
```bash
# Via project reference (development)
dotnet add reference /path/to/OpenHarbor.MCP.Gateway/src/OpenHarbor.MCP.Gateway.AspNetCore/OpenHarbor.MCP.Gateway.AspNetCore.csproj
# OR via NuGet (when published)
# dotnet add package OpenHarbor.MCP.Gateway.AspNetCore
```
#### Step 2: Configure appsettings.json
Add Gateway configuration:
```json
{
"Mcp": {
"Gateway": {
"Name": "MyMcpGateway",
"Version": "1.0.0",
"Description": "MCP Gateway for routing and management",
"ListenAddress": "http://localhost:8080"
},
"Servers": [
{
"Id": "codex-server-1",
"Name": "CODEX MCP Server 1",
"Transport": {
"Type": "Http",
"BaseUrl": "http://localhost:5050"
},
"Enabled": true
},
{
"Id": "codex-server-2",
"Name": "CODEX MCP Server 2",
"Transport": {
"Type": "Http",
"BaseUrl": "http://localhost:5051"
},
"Enabled": true
},
{
"Id": "remote-api",
"Name": "Remote API Server",
"Transport": {
"Type": "Http",
"BaseUrl": "https://api.example.com/mcp"
},
"Enabled": true
}
],
"Routing": {
"Strategy": "RoundRobin",
"HealthCheckInterval": "00:00:30"
},
"Security": {
"EnableAuthentication": true,
"ApiKeyHeader": "X-MCP-API-Key",
"RateLimit": {
"RequestsPerMinute": 100,
"BurstSize": 20
}
},
"Monitoring": {
"EnableMetrics": true,
"EnableTracing": true,
"EnableAuditLog": true
}
}
}
```
#### Step 3: Update Program.cs
```csharp
using OpenHarbor.MCP.Gateway.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add MCP Gateway
builder.Services.AddMcpGateway(builder.Configuration.GetSection("Mcp"));
// Add health checks
builder.Services.AddHealthChecks()
.AddCheck<McpServerHealthCheck>("mcp-servers");
var app = builder.Build();
// Map Gateway endpoints
app.MapMcpGateway();
app.MapHealthChecks("/health");
app.Run();
```
#### Step 4: Configure Routing Rules
```csharp
using OpenHarbor.MCP.Gateway.Core.Routing;
public class CustomRoutingStrategy : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> availableServers)
{
// Route based on tool name pattern
if (context.ToolName.StartsWith("search_"))
{
return "codex-server-1";
}
// Route based on client identity
if (context.ClientId == "admin-client")
{
return "codex-server-2";
}
// Default: round-robin
return availableServers.First().Id;
}
}
```
#### Step 5: Run and Test
```bash
# Run the gateway
dotnet run
# Ensure MCP servers are running
# Terminal 1: dotnet run --project /path/to/CodexMcpServer (port 5050)
# Terminal 2: dotnet run --project /path/to/CodexMcpServer2 (port 5051)
# Test gateway health
curl http://localhost:8080/health
# Test request routing through gateway
curl -X POST http://localhost:8080/mcp/invoke \
-H "Content-Type: application/json" \
-H "X-Client-Id: demo-client" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":"1"}'
```
---
## Architecture
OpenHarbor.MCP.Gateway follows **Clean Architecture** principles:
```
┌─────────────────────────────────────────────────┐
│ OpenHarbor.MCP.Gateway.Cli (Executable) │
│ ┌───────────────────────────────────────────┐ │
│ │ OpenHarbor.MCP.Gateway.AspNetCore (HTTP)│ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ OpenHarbor.MCP.Gateway.Infrastructure│ │ │
│ │ │ ┌───────────────────────────────┐ │ │ │
│ │ │ │ OpenHarbor.MCP.Gateway.Core │ │ │ │
│ │ │ │ - IGatewayRouter │ │ │ │
│ │ │ │ - IRoutingStrategy │ │ │ │
│ │ │ │ - IAuthProvider │ │ │ │
│ │ │ │ - ICircuitBreaker │ │ │ │
│ │ │ │ - Models (no dependencies) │ │ │ │
│ │ │ └───────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### Projects
| Project | Purpose | Dependencies |
|---------|---------|--------------|
| **OpenHarbor.MCP.Gateway.Core** | Abstractions, interfaces, models | None |
| **OpenHarbor.MCP.Gateway.Infrastructure** | Router, auth, circuit breakers, load balancing | Core, System.Text.Json |
| **OpenHarbor.MCP.Gateway.AspNetCore** | ASP.NET Core integration, HTTP endpoints | Core, Infrastructure, ASP.NET Core |
| **OpenHarbor.MCP.Gateway.Cli** | Management CLI for gateway | All above |
See [Architecture Documentation](docs/architecture.md) for detailed design.
---
## Examples
### 1. CodexMcpGateway (Multi-Server Router)
Sample gateway configuration routing to multiple backends:
```
samples/CodexMcpGateway/
├── Routing/
│ ├── ToolBasedRouter.cs # Route by tool name
│ ├── ClientBasedRouter.cs # Route by client identity
│ └── LoadBalancedRouter.cs # Round-robin load balancing
├── Middleware/
│ ├── AuthenticationMiddleware.cs
│ ├── RateLimitingMiddleware.cs
│ └── AuditLoggingMiddleware.cs
├── Program.cs
└── appsettings.json
```
**Running the sample**:
```bash
cd samples/CodexMcpGateway
dotnet run
# Gateway listens on http://localhost:8080
# Configure clients to connect to gateway instead of servers directly
```
### 2. Simple Routing Strategy
Route based on tool name patterns:
```csharp
public class ToolBasedRouter : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
return context.ToolName switch
{
var t when t.StartsWith("search_") => "codex-server",
var t when t.StartsWith("db_") => "database-server",
var t when t.StartsWith("api_") => "remote-api",
_ => servers.First().Id // Default
};
}
}
```
### 3. Load Balancing
Distribute load across multiple server instances:
```csharp
public class LoadBalancedRouter : IRoutingStrategy
{
private int _currentIndex = 0;
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
var serverList = servers.Where(s => s.IsHealthy).ToList();
if (serverList.Count == 0)
{
throw new NoHealthyServersException();
}
var index = Interlocked.Increment(ref _currentIndex) % serverList.Count;
return serverList[index].Id;
}
}
```
### 4. Circuit Breaker Pattern
Prevent cascading failures:
```csharp
public class CircuitBreakerRouter : IRoutingStrategy
{
private readonly ICircuitBreaker _circuitBreaker;
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
var healthyServers = servers.Where(s =>
s.IsHealthy && !_circuitBreaker.IsOpen(s.Id)
);
if (!healthyServers.Any())
{
throw new AllServersUnavailableException();
}
return healthyServers.First().Id;
}
}
```
---
## Security
### Authentication
Support for multiple auth strategies:
```json
{
"Security": {
"EnableAuthentication": true,
"Strategies": [
{
"Type": "ApiKey",
"HeaderName": "X-MCP-API-Key"
},
{
"Type": "JWT",
"Issuer": "https://auth.example.com",
"Audience": "mcp-gateway"
}
]
}
}
```
### Authorization
Role-based access control:
```csharp
[Authorize(Roles = "MCP.Admin")]
public class GatewayManagementController : Controller
{
[HttpPost("servers/{serverId}/enable")]
public async Task EnableServer(string serverId)
{
// Only admins can enable/disable servers
}
}
```
### Rate Limiting
Per-client rate limiting:
```json
{
"RateLimit": {
"Global": {
"RequestsPerMinute": 1000
},
"PerClient": {
"RequestsPerMinute": 100,
"BurstSize": 20
},
"PerServer": {
"RequestsPerMinute": 500
}
}
}
```
---
## Monitoring
### Metrics
OpenTelemetry metrics exposed:
- `mcp_gateway_requests_total` - Total requests processed
- `mcp_gateway_request_duration_ms` - Request latency
- `mcp_gateway_errors_total` - Error count by type
- `mcp_gateway_server_health` - Server health status
- `mcp_gateway_circuit_breaker_state` - Circuit breaker state
### Audit Logging
All tool calls logged:
```json
{
"timestamp": "2025-10-19T17:40:00Z",
"clientId": "web-client-123",
"serverId": "codex-server",
"toolName": "search_codex",
"arguments": { "query": "architecture" },
"responseTime": "45ms",
"status": "success"
}
```
### Health Checks
Gateway health dashboard:
```bash
curl http://localhost:8080/health
{
"status": "Healthy",
"totalServers": 3,
"healthyServers": 3,
"degradedServers": 0,
"unhealthyServers": 0,
"servers": [
{
"id": "codex-server",
"status": "Healthy",
"lastCheck": "2025-10-19T17:40:00Z",
"responseTime": "12ms"
}
]
}
```
---
## Testing
### Integration Tests
```bash
# Run all tests
dotnet test
# Run specific test project
dotnet test tests/OpenHarbor.MCP.Gateway.Tests/
# Run with coverage
dotnet test /p:CollectCoverage=true
```
### Load Testing
Included load testing tools:
```bash
# Simulate 100 concurrent clients
cd tests/LoadTests
dotnet run -- --clients 100 --duration 60s
# Output:
# Requests: 45,000
# Success: 44,950 (99.9%)
# Avg Latency: 22ms
# p95 Latency: 45ms
# p99 Latency: 78ms
```
### Test Coverage
OpenHarbor.MCP.Gateway maintains **76.99% average line coverage** and **57.45% average branch coverage** with **192 tests** passing (100%).
**Coverage Breakdown by Project:**
1. **OpenHarbor.MCP.Gateway.Core.Tests**: 68 tests
- Line Coverage: 76.99%
- Branch Coverage: 57.45%
- Domain models, routing strategies, connection management
2. **OpenHarbor.MCP.Gateway.Infrastructure.Tests**: 118 tests
- Line Coverage: 84.16% (excellent)
- Branch Coverage: 67.39%
- HTTP client pooling, circuit breakers, health checks
3. **OpenHarbor.MCP.Gateway.AspNetCore.Tests**: 6 tests
- Line Coverage: 4.62% (low - expected)
- Branch Coverage: 3.87%
- ASP.NET Core middleware and configuration
- Note: Low coverage normal for thin ASP.NET Core layers
**Analysis:**
- **Core and Infrastructure**: Excellent coverage (77-84%)
- **AspNetCore**: Low coverage expected (minimal logic, mostly wiring)
- Routing strategies comprehensively tested (3 strategies × multiple scenarios)
- Load balancing and failover well-covered
**Coverage Reports:**
```bash
# Generate coverage report
dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults
# View detailed coverage
# See: /home/svrnty/codex/COVERAGE-SUMMARY.md for complete analysis
```
**Status**: ✅ Excellent - Core business logic excellently tested, production-ready
---
## Documentation
| Document | Description |
|----------|-------------|
| [**API Reference**](docs/api/) | **Complete API documentation (IGatewayRouter, Routing Strategies, Models)** |
| [Module Design](docs/module-design.md) | Architecture and design decisions |
| [Implementation Plan](docs/implementation-plan.md) | Development roadmap |
| [AGENT-PRIMER.md](AGENT-PRIMER.md) | AI-assisted setup guide |
| [Routing Strategies](docs/routing-strategies.md) | Routing configuration guide |
| [Security Guide](docs/security.md) | Authentication and authorization |
| [HTTPS Setup Guide](docs/deployment/https-setup.md) | Production TLS/HTTPS configuration |
---
## Related Modules
OpenHarbor.MCP is a family of three complementary modules:
- **[OpenHarbor.MCP.Server](../OpenHarbor.MCP.Server/)** - Server library (expose tools TO AI agents)
- **[OpenHarbor.MCP.Client](../OpenHarbor.MCP.Client/)** - Client library (call tools FROM servers)
- **[OpenHarbor.MCP.Gateway](../OpenHarbor.MCP.Gateway/)** - Gateway/proxy (route between clients and servers) ← You are here
All three modules share:
- Same Clean Architecture pattern
- Same documentation structure
- Same security principles
- Compatible .NET 8 SDKs
---
## Contributing
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for:
- Development setup
- Code standards
- Testing requirements
- Pull request process
---
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
---
## Support
- **Issues**: [GitHub Issues](https://github.com/svrnty/openharbor-mcp/issues)
- **Email**: info@svrnty.io
- **Documentation**: [docs/](docs/)
---
**Built with love by Svrnty**
Creating sovereign tools to democratize technology for humanity.

80
Svrnty.MCP.Gateway.sln Normal file
View File

@ -0,0 +1,80 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FAC4D8BF-BBE2-4545-9B25-5A0D810302FC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Gateway.Core", "src\OpenHarbor.MCP.Gateway.Core\OpenHarbor.MCP.Gateway.Core.csproj", "{EC69B6BF-59AB-4367-8546-A51AAFB64323}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Gateway.Infrastructure", "src\OpenHarbor.MCP.Gateway.Infrastructure\OpenHarbor.MCP.Gateway.Infrastructure.csproj", "{94F59950-2E36-4988-96F5-6BF3AB81BC41}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Gateway.AspNetCore", "src\OpenHarbor.MCP.Gateway.AspNetCore\OpenHarbor.MCP.Gateway.AspNetCore.csproj", "{9EB71750-55B8-45DE-87CE-34A7DCB1711A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Gateway.Cli", "src\OpenHarbor.MCP.Gateway.Cli\OpenHarbor.MCP.Gateway.Cli.csproj", "{5F4678A0-E500-458B-975A-21CCAD6B4648}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D1CB7824-CB33-4104-9501-686233FBB593}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Gateway.Core.Tests", "tests\OpenHarbor.MCP.Gateway.Core.Tests\OpenHarbor.MCP.Gateway.Core.Tests.csproj", "{311C3D42-7839-4FA0-A3AB-783AAD687EB7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{77F08943-57E8-413A-A21C-2C268B416AAC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexMcpGateway", "samples\CodexMcpGateway\CodexMcpGateway.csproj", "{3BBA34E5-0FB9-45D3-9B3C-537C25FB1A9A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Gateway.Infrastructure.Tests", "tests\OpenHarbor.MCP.Gateway.Infrastructure.Tests\OpenHarbor.MCP.Gateway.Infrastructure.Tests.csproj", "{8A430DE1-0B83-4B50-B4D7-FA683FE17054}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Gateway.AspNetCore.Tests", "tests\OpenHarbor.MCP.Gateway.AspNetCore.Tests\OpenHarbor.MCP.Gateway.AspNetCore.Tests.csproj", "{A0EC0CBE-1642-4939-815E-59970D85A1CA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{EC69B6BF-59AB-4367-8546-A51AAFB64323}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EC69B6BF-59AB-4367-8546-A51AAFB64323}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC69B6BF-59AB-4367-8546-A51AAFB64323}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EC69B6BF-59AB-4367-8546-A51AAFB64323}.Release|Any CPU.Build.0 = Release|Any CPU
{94F59950-2E36-4988-96F5-6BF3AB81BC41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94F59950-2E36-4988-96F5-6BF3AB81BC41}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94F59950-2E36-4988-96F5-6BF3AB81BC41}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94F59950-2E36-4988-96F5-6BF3AB81BC41}.Release|Any CPU.Build.0 = Release|Any CPU
{9EB71750-55B8-45DE-87CE-34A7DCB1711A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EB71750-55B8-45DE-87CE-34A7DCB1711A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EB71750-55B8-45DE-87CE-34A7DCB1711A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9EB71750-55B8-45DE-87CE-34A7DCB1711A}.Release|Any CPU.Build.0 = Release|Any CPU
{5F4678A0-E500-458B-975A-21CCAD6B4648}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F4678A0-E500-458B-975A-21CCAD6B4648}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F4678A0-E500-458B-975A-21CCAD6B4648}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F4678A0-E500-458B-975A-21CCAD6B4648}.Release|Any CPU.Build.0 = Release|Any CPU
{311C3D42-7839-4FA0-A3AB-783AAD687EB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{311C3D42-7839-4FA0-A3AB-783AAD687EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{311C3D42-7839-4FA0-A3AB-783AAD687EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{311C3D42-7839-4FA0-A3AB-783AAD687EB7}.Release|Any CPU.Build.0 = Release|Any CPU
{3BBA34E5-0FB9-45D3-9B3C-537C25FB1A9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3BBA34E5-0FB9-45D3-9B3C-537C25FB1A9A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3BBA34E5-0FB9-45D3-9B3C-537C25FB1A9A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3BBA34E5-0FB9-45D3-9B3C-537C25FB1A9A}.Release|Any CPU.Build.0 = Release|Any CPU
{8A430DE1-0B83-4B50-B4D7-FA683FE17054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A430DE1-0B83-4B50-B4D7-FA683FE17054}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A430DE1-0B83-4B50-B4D7-FA683FE17054}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A430DE1-0B83-4B50-B4D7-FA683FE17054}.Release|Any CPU.Build.0 = Release|Any CPU
{A0EC0CBE-1642-4939-815E-59970D85A1CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A0EC0CBE-1642-4939-815E-59970D85A1CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0EC0CBE-1642-4939-815E-59970D85A1CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0EC0CBE-1642-4939-815E-59970D85A1CA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{EC69B6BF-59AB-4367-8546-A51AAFB64323} = {FAC4D8BF-BBE2-4545-9B25-5A0D810302FC}
{94F59950-2E36-4988-96F5-6BF3AB81BC41} = {FAC4D8BF-BBE2-4545-9B25-5A0D810302FC}
{9EB71750-55B8-45DE-87CE-34A7DCB1711A} = {FAC4D8BF-BBE2-4545-9B25-5A0D810302FC}
{5F4678A0-E500-458B-975A-21CCAD6B4648} = {FAC4D8BF-BBE2-4545-9B25-5A0D810302FC}
{311C3D42-7839-4FA0-A3AB-783AAD687EB7} = {D1CB7824-CB33-4104-9501-686233FBB593}
{3BBA34E5-0FB9-45D3-9B3C-537C25FB1A9A} = {77F08943-57E8-413A-A21C-2C268B416AAC}
{8A430DE1-0B83-4B50-B4D7-FA683FE17054} = {D1CB7824-CB33-4104-9501-686233FBB593}
{A0EC0CBE-1642-4939-815E-59970D85A1CA} = {D1CB7824-CB33-4104-9501-686233FBB593}
EndGlobalSection
EndGlobal

765
docs/api/README.md Normal file
View File

@ -0,0 +1,765 @@
# OpenHarbor.MCP.Gateway - API Reference
**Version:** 1.0.0
**Last Updated:** 2025-10-19
**Status:** Production-Ready
---
## Table of Contents
- [Core Abstractions](#core-abstractions)
- [IGatewayRouter](#igatewayrouter)
- [IRoutingStrategy](#iroutingstrategy)
- [ICircuitBreaker](#icircuitbreaker)
- [Infrastructure](#infrastructure)
- [GatewayRouter](#gatewayrouter)
- [RoutingStrategies](#routing-strategies)
- [CircuitBreaker](#circuitbreaker)
- [Models](#models)
- [RoutingContext](#routingcontext)
- [ServerInfo](#serverinfo)
- [GatewayConfig](#gatewayconfig)
- [ASP.NET Core Integration](#aspnet-core-integration)
- [Service Extensions](#service-extensions)
- [Endpoint Mapping](#endpoint-mapping)
- [Monitoring](#monitoring)
---
## Core Abstractions
### IGatewayRouter
**Namespace:** `OpenHarbor.MCP.Gateway.Core.Abstractions`
Interface defining the gateway routing contract.
#### Methods
##### RouteRequestAsync
```csharp
Task<McpToolResult> RouteRequestAsync(
RoutingContext context,
CancellationToken cancellationToken = default)
```
Routes an MCP request to an appropriate backend server.
**Parameters:**
- `context` (RoutingContext): Request routing context
- `cancellationToken` (CancellationToken): Cancellation support
**Returns:** `Task<McpToolResult>` - The result from the backend server
**Throws:**
- `NoHealthyServersException` - If all servers are unhealthy
- `RoutingException` - If routing decision fails
- `CircuitBreakerOpenException` - If circuit breaker is open
**Example:**
```csharp
var context = new RoutingContext
{
ClientId = "web-client",
ToolName = "search_documents",
Parameters = new Dictionary<string, object>
{
["query"] = "architecture"
}
};
var result = await router.RouteRequestAsync(context);
```
##### GetServerHealthAsync
```csharp
Task<IEnumerable<ServerHealth>> GetServerHealthAsync(
CancellationToken cancellationToken = default)
```
Gets health status of all registered servers.
**Returns:** `Task<IEnumerable<ServerHealth>>` - Health information for each server
**Example:**
```csharp
var healthStatuses = await router.GetServerHealthAsync();
foreach (var status in healthStatuses)
{
Console.WriteLine($"{status.ServerName}: {status.Status}");
}
```
---
### IRoutingStrategy
**Namespace:** `OpenHarbor.MCP.Gateway.Core.Abstractions`
Interface for implementing custom routing logic.
#### Methods
##### SelectServer
```csharp
string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> availableServers)
```
Selects a server to handle the request.
**Parameters:**
- `context` (RoutingContext): Request context
- `availableServers` (IEnumerable<ServerInfo>): Healthy servers
**Returns:** `string` - ID of the selected server
**Throws:**
- `NoServerAvailableException` - If no servers match criteria
**Example Implementation:**
```csharp
public class ToolBasedRoutingStrategy : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
// Route based on tool name pattern
if (context.ToolName.StartsWith("search_"))
{
return servers.First(s => s.Name == "search-server").Id;
}
// Default: first available
return servers.First().Id;
}
}
```
---
### ICircuitBreaker
**Namespace:** `OpenHarbor.MCP.Gateway.Core.Abstractions`
Interface for circuit breaker pattern implementation.
#### Properties
##### State
```csharp
CircuitBreakerState State { get; }
```
Current state of the circuit breaker.
```csharp
public enum CircuitBreakerState
{
Closed, // Normal operation
Open, // Failing, rejecting requests
HalfOpen // Testing if recovered
}
```
#### Methods
##### IsOpen
```csharp
bool IsOpen(string serverId)
```
Checks if circuit breaker is open for a specific server.
**Parameters:**
- `serverId` (string): Server identifier
**Returns:** `bool` - True if circuit is open (rejecting requests)
##### RecordSuccess
```csharp
Task RecordSuccessAsync(string serverId)
```
Records a successful request.
##### RecordFailure
```csharp
Task RecordFailureAsync(string serverId, Exception exception)
```
Records a failed request.
**Example:**
```csharp
if (_circuitBreaker.IsOpen("server-1"))
{
throw new CircuitBreakerOpenException("server-1");
}
try
{
var result = await CallServerAsync("server-1", request);
await _circuitBreaker.RecordSuccessAsync("server-1");
return result;
}
catch (Exception ex)
{
await _circuitBreaker.RecordFailureAsync("server-1", ex);
throw;
}
```
---
## Infrastructure
### GatewayRouter
**Namespace:** `OpenHarbor.MCP.Gateway.Infrastructure`
Default implementation of `IGatewayRouter`.
#### Constructor
```csharp
public GatewayRouter(
IRoutingStrategy routingStrategy,
ICircuitBreaker circuitBreaker,
IServerRegistry serverRegistry,
ILogger<GatewayRouter> logger = null)
```
**Parameters:**
- `routingStrategy` (IRoutingStrategy): Strategy for server selection
- `circuitBreaker` (ICircuitBreaker): Circuit breaker implementation
- `serverRegistry` (IServerRegistry): Registry of backend servers
- `logger` (ILogger): Optional logger
**Example:**
```csharp
var registry = new ServerRegistry();
registry.AddServer(new ServerInfo
{
Id = "server-1",
Name = "CODEX Server",
BaseUrl = "http://localhost:5050"
});
var router = new GatewayRouter(
new RoundRobinStrategy(),
new CircuitBreaker(),
registry
);
```
---
### Routing Strategies
Built-in routing strategy implementations.
#### RoundRobinStrategy
Distributes requests evenly across all healthy servers.
```csharp
public class RoundRobinStrategy : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
// Round-robin logic
var serverList = servers.ToList();
var index = _counter++ % serverList.Count;
return serverList[index].Id;
}
}
```
**Use Case:** Load balancing across identical servers
#### LeastConnectionsStrategy
Routes to the server with fewest active connections.
```csharp
public class LeastConnectionsStrategy : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
return servers
.OrderBy(s => s.ActiveConnections)
.First()
.Id;
}
}
```
**Use Case:** Balancing load when servers have different capacities
#### ToolBasedStrategy
Routes based on tool name patterns.
```csharp
public class ToolBasedStrategy : IRoutingStrategy
{
private readonly Dictionary<string, string> _toolToServerMap;
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
if (_toolToServerMap.TryGetValue(context.ToolName, out var serverId))
{
return serverId;
}
// Default fallback
return servers.First().Id;
}
}
```
**Use Case:** Route specific tools to specialized servers
---
### CircuitBreaker
**Namespace:** `OpenHarbor.MCP.Gateway.Infrastructure.Resilience`
Default circuit breaker implementation.
#### Configuration
```csharp
public class CircuitBreakerConfig
{
public int FailureThreshold { get; set; } = 5;
public int SuccessThreshold { get; set; } = 2;
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1);
}
```
#### Usage
```csharp
var config = new CircuitBreakerConfig
{
FailureThreshold = 5, // Open after 5 failures
SuccessThreshold = 2, // Close after 2 successes
Timeout = TimeSpan.FromMinutes(1) // Test recovery after 1 min
};
var circuitBreaker = new CircuitBreaker(config);
```
---
## Models
### RoutingContext
**Namespace:** `OpenHarbor.MCP.Gateway.Core.Models`
Context information for routing decisions.
#### Properties
```csharp
public class RoutingContext
{
public string ClientId { get; set; }
public string ToolName { get; set; }
public Dictionary<string, object> Parameters { get; set; }
public Dictionary<string, string> Headers { get; set; }
public DateTime Timestamp { get; set; }
}
```
#### Example
```csharp
var context = new RoutingContext
{
ClientId = "web-client-123",
ToolName = "search_documents",
Parameters = new Dictionary<string, object>
{
["query"] = "test"
},
Headers = new Dictionary<string, string>
{
["X-Client-Version"] = "1.0.0"
},
Timestamp = DateTime.UtcNow
};
```
---
### ServerInfo
**Namespace:** `OpenHarbor.MCP.Gateway.Core.Models`
Information about a registered backend server.
#### Properties
```csharp
public class ServerInfo
{
public string Id { get; set; }
public string Name { get; set; }
public string BaseUrl { get; set; }
public bool IsHealthy { get; set; }
public int ActiveConnections { get; set; }
public DateTime LastHealthCheck { get; set; }
public TimeSpan AverageResponseTime { get; set; }
}
```
#### Example
```csharp
var serverInfo = new ServerInfo
{
Id = "codex-server-1",
Name = "CODEX MCP Server 1",
BaseUrl = "http://localhost:5050",
IsHealthy = true,
ActiveConnections = 3,
LastHealthCheck = DateTime.UtcNow,
AverageResponseTime = TimeSpan.FromMilliseconds(45)
};
```
---
### GatewayConfig
**Namespace:** `OpenHarbor.MCP.Gateway.Core.Models`
Gateway configuration.
#### Properties
```csharp
public class GatewayConfig
{
public string Name { get; set; }
public string Version { get; set; }
public string ListenAddress { get; set; }
public List<ServerConfig> Servers { get; set; }
public RoutingConfig Routing { get; set; }
public SecurityConfig Security { get; set; }
}
public class ServerConfig
{
public string Id { get; set; }
public string Name { get; set; }
public string BaseUrl { get; set; }
public bool Enabled { get; set; }
}
public class RoutingConfig
{
public string Strategy { get; set; } // "RoundRobin", "LeastConnections", "ToolBased"
public TimeSpan HealthCheckInterval { get; set; }
}
```
---
## ASP.NET Core Integration
### Service Extensions
**Namespace:** `OpenHarbor.MCP.Gateway.AspNetCore`
#### AddMcpGateway
```csharp
public static IServiceCollection AddMcpGateway(
this IServiceCollection services,
IConfiguration configuration)
```
Registers gateway services and dependencies.
**Configuration:**
```json
{
"Mcp": {
"Gateway": {
"Name": "MyGateway",
"Version": "1.0.0",
"ListenAddress": "http://localhost:8080"
},
"Servers": [ ... ],
"Routing": {
"Strategy": "RoundRobin",
"HealthCheckInterval": "00:00:30"
}
}
}
```
**Example:**
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpGateway(
builder.Configuration.GetSection("Mcp")
);
builder.Services.AddHealthChecks()
.AddCheck<McpServerHealthCheck>("mcp-servers");
var app = builder.Build();
app.MapMcpGateway();
app.MapHealthChecks("/health");
app.Run();
```
---
### Endpoint Mapping
#### MapMcpGateway
```csharp
public static IEndpointRouteBuilder MapMcpGateway(
this IEndpointRouteBuilder endpoints)
```
Maps gateway HTTP endpoints.
**Endpoints:**
- `POST /mcp/invoke` - Route MCP requests
- `GET /health` - Gateway health check
- `GET /servers` - List backend servers
- `GET /servers/{id}/health` - Server-specific health
**Example:**
```csharp
var app = builder.Build();
app.MapMcpGateway();
```
---
## Monitoring
### Metrics
OpenTelemetry metrics exposed by the gateway:
```csharp
public class GatewayMetrics
{
public Counter<long> TotalRequests { get; }
public Histogram<double> RequestDuration { get; }
public Counter<long> TotalErrors { get; }
public Gauge<int> ServerHealth { get; }
public Gauge<int> CircuitBreakerState { get; }
}
```
**Metric Names:**
- `mcp_gateway_requests_total`
- `mcp_gateway_request_duration_ms`
- `mcp_gateway_errors_total`
- `mcp_gateway_server_health`
- `mcp_gateway_circuit_breaker_state`
**Example Query (Prometheus):**
```promql
# Average request latency
rate(mcp_gateway_request_duration_ms_sum[5m]) /
rate(mcp_gateway_request_duration_ms_count[5m])
# Error rate
rate(mcp_gateway_errors_total[5m]) /
rate(mcp_gateway_requests_total[5m])
```
---
### Health Checks
#### Gateway Health Endpoint
```bash
curl http://localhost:8080/health
```
**Response:**
```json
{
"status": "Healthy",
"totalServers": 3,
"healthyServers": 3,
"degradedServers": 0,
"unhealthyServers": 0,
"servers": [
{
"id": "codex-server-1",
"name": "CODEX Server 1",
"status": "Healthy",
"lastCheck": "2025-10-19T12:00:00Z",
"responseTime": "12ms"
}
]
}
```
#### Custom Health Check
```csharp
public class McpServerHealthCheck : IHealthCheck
{
private readonly IGatewayRouter _router;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var serverHealth = await _router.GetServerHealthAsync(cancellationToken);
var unhealthyServers = serverHealth
.Count(s => s.Status != ServerHealthStatus.Healthy);
if (unhealthyServers == 0)
{
return HealthCheckResult.Healthy("All servers healthy");
}
if (unhealthyServers == serverHealth.Count())
{
return HealthCheckResult.Unhealthy("All servers unhealthy");
}
return HealthCheckResult.Degraded(
$"{unhealthyServers} of {serverHealth.Count()} servers unhealthy"
);
}
}
```
---
## Complete Example
### Creating a Gateway
```csharp
using OpenHarbor.MCP.Gateway.AspNetCore;
using OpenHarbor.MCP.Gateway.Core;
var builder = WebApplication.CreateBuilder(args);
// Add gateway services
builder.Services.AddMcpGateway(
builder.Configuration.GetSection("Mcp")
);
// Add custom routing strategy
builder.Services.AddSingleton<IRoutingStrategy, ToolBasedStrategy>();
// Add health checks
builder.Services.AddHealthChecks()
.AddCheck<McpServerHealthCheck>("mcp-servers");
var app = builder.Build();
// Map gateway endpoints
app.MapMcpGateway();
app.MapHealthChecks("/health");
// Start gateway
await app.RunAsync();
```
### Custom Routing Strategy
```csharp
public class CustomRoutingStrategy : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
// Route admin clients to dedicated server
if (context.ClientId.StartsWith("admin-"))
{
return servers.First(s => s.Name.Contains("admin")).Id;
}
// Route search tools to search-optimized server
if (context.ToolName.StartsWith("search_"))
{
return servers.First(s => s.Name.Contains("search")).Id;
}
// Default: least connections
return servers
.OrderBy(s => s.ActiveConnections)
.First()
.Id;
}
}
```
### Using the Gateway (Client Side)
```bash
# Route request through gateway
curl -X POST http://localhost:8080/mcp/invoke \
-H "Content-Type: application/json" \
-H "X-Client-Id: web-client" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "search_documents",
"arguments": {
"query": "architecture"
}
}
}'
```
---
## See Also
- [Gateway Architecture](../architecture.md)
- [Module Design](../module-design.md)
- [Routing Strategies](../routing-strategies.md)
- [Security Guide](../security.md)
- [AGENT-PRIMER.md](../../AGENT-PRIMER.md)
---
**Document Type:** API Reference
**Version:** 1.0.0
**Last Updated:** 2025-10-19
**Maintained By:** Svrnty Development Team

View File

@ -0,0 +1,678 @@
# HTTPS/TLS Setup Guide - OpenHarbor.MCP.Gateway
**Purpose**: Production-grade HTTPS/TLS configuration for MCP gateway/proxy deployment
**Audience**: DevOps engineers, system administrators
**Last Updated**: 2025-10-19
**Version**: 1.0.0
---
## Table of Contents
1. [Overview](#overview)
2. [Prerequisites](#prerequisites)
3. [Gateway HTTPS Configuration](#gateway-https-configuration)
4. [Backend Server Connections](#backend-server-connections)
5. [Load Balancer Integration](#load-balancer-integration)
6. [TLS Termination Strategies](#tls-termination-strategies)
7. [Security Headers](#security-headers)
8. [Testing](#testing)
9. [Troubleshooting](#troubleshooting)
---
## Overview
OpenHarbor.MCP.Gateway acts as a reverse proxy/load balancer between MCP clients and servers. It supports multiple TLS termination strategies and secure backend connections.
**Architecture Options:**
1. **TLS Termination at Gateway** (recommended)
- Client → HTTPS → Gateway → HTTP → Backend Servers
2. **TLS Passthrough**
- Client → HTTPS → Gateway (proxy) → HTTPS → Backend Servers
3. **End-to-End TLS**
- Client → HTTPS → Gateway (HTTPS) → HTTPS → Backend Servers
**Security Features:**
- Centralized certificate management
- Multiple backend server support with connection pooling
- Circuit breakers and health checks
- Rate limiting and authentication
- Security headers (HSTS, CSP, etc.)
---
## Prerequisites
### Development
- .NET 8.0 SDK
- Docker + Docker Compose (optional)
- TLS certificates (dev or production)
### Production
- Valid TLS certificate (from CA or Let's Encrypt)
- Load balancer (optional): Nginx, HAProxy, AWS ALB
- Backend MCP servers configured
- Monitoring/logging infrastructure
---
## Gateway HTTPS Configuration
### Configuration via appsettings.json
**appsettings.Production.json:**
```json
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://*:8080"
},
"Https": {
"Url": "https://*:8443",
"Certificate": {
"Path": "/app/certs/gateway.pfx",
"Password": "" // From environment variable
},
"Protocols": "Http1AndHttp2"
}
}
},
"Gateway": {
"BackendServers": [
{
"Name": "mcp-server-1",
"Url": "http://mcp-server-1:5050", // HTTP backend (TLS terminated at gateway)
"HealthCheckPath": "/health",
"MaxConnections": 10
},
{
"Name": "mcp-server-2",
"Url": "http://mcp-server-2:5050",
"HealthCheckPath": "/health",
"MaxConnections": 10
}
],
"LoadBalancing": {
"Strategy": "RoundRobin", // RoundRobin, LeastConnections, or ToolBased
"HealthCheckInterval": "00:00:30",
"CircuitBreaker": {
"FailureThreshold": 5,
"SuccessThreshold": 2,
"Timeout": "00:01:00"
}
},
"Security": {
"RequireHttps": true,
"AllowedOrigins": ["https://app.example.com"],
"RateLimiting": {
"RequestsPerMinute": 100,
"BurstSize": 20
}
}
}
}
```
### Environment Variables
```bash
# Certificate password
export KESTREL__CERTIFICATES__DEFAULT__PASSWORD="ProductionSecurePassword"
# Backend server URLs (override config)
export GATEWAY__BACKENDSERVERS__0__URL="https://mcp-server-1.internal:5051"
export GATEWAY__BACKENDSERVERS__1__URL="https://mcp-server-2.internal:5051"
# API keys
export GATEWAY__SECURITY__APIKEY="gateway-api-key"
```
---
## Backend Server Connections
### Option 1: HTTP Backends (TLS Termination at Gateway)
**Recommended for**: Internal networks, simplified certificate management
```json
{
"Gateway": {
"BackendServers": [
{
"Name": "server-1",
"Url": "http://10.0.1.10:5050", // HTTP only
"HealthCheckPath": "/health"
}
]
}
}
```
**Architecture:**
```
Internet → HTTPS (443) → Gateway (TLS termination) → HTTP → Backend Servers
```
**Pros:**
- Single certificate to manage
- Lower backend server CPU usage
- Simpler debugging (plain HTTP)
**Cons:**
- Backend traffic unencrypted (use private network)
### Option 2: HTTPS Backends (End-to-End Encryption)
**Recommended for**: Security-sensitive deployments, zero-trust networks
```json
{
"Gateway": {
"BackendServers": [
{
"Name": "server-1",
"Url": "https://mcp-server-1.internal:5051",
"HealthCheckPath": "/health",
"TlsOptions": {
"ValidateCertificate": true,
"AllowedCertificateThumbprints": [
"A1B2C3D4E5F6..." // Pin backend certificates
]
}
}
]
}
}
```
**Architecture:**
```
Internet → HTTPS (443) → Gateway (TLS re-encryption) → HTTPS → Backend Servers
```
**Pros:**
- End-to-end encryption
- Backend servers independently secured
- Compliance with zero-trust architecture
**Cons:**
- More certificates to manage
- Higher CPU usage (double TLS)
- More complex troubleshooting
### Custom Backend Certificate Validation
**Program.cs Configuration:**
```csharp
services.AddHttpClient("BackendClient", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
var handler = new HttpClientHandler();
// Custom certificate validation for backends
handler.ServerCertificateCustomValidationCallback = (request, cert, chain, errors) =>
{
if (errors == SslPolicyErrors.None)
return true;
// Accept specific backend certificates (certificate pinning)
var allowedThumbprints = new[]
{
"A1B2C3D4E5F6...", // Backend server 1
"B2C3D4E5F6A1..." // Backend server 2
};
return cert != null && allowedThumbprints.Contains(cert.Thumbprint);
};
return handler;
});
```
---
## Load Balancer Integration
### Option 1: Gateway Behind Load Balancer (TLS at LB)
**Architecture:**
```
Internet → ALB/NLB (TLS) → Gateway (HTTP) → Backends (HTTP/HTTPS)
```
**AWS Application Load Balancer Example:**
**Target Group:**
```json
{
"TargetType": "ip",
"Protocol": "HTTP",
"Port": 8080,
"HealthCheck": {
"Protocol": "HTTP",
"Path": "/health",
"Interval": 30,
"Timeout": 5
}
}
```
**Listener:**
```json
{
"Protocol": "HTTPS",
"Port": 443,
"Certificates": [
{
"CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abc123..."
}
],
"DefaultActions": [
{
"Type": "forward",
"TargetGroupArn": "arn:aws:elasticloadbalancing:..."
}
]
}
```
**Gateway Configuration (HTTP only, LB handles TLS):**
```json
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://*:8080"
}
}
},
"Gateway": {
"Security": {
"RequireHttps": false, // LB enforces HTTPS
"TrustForwardedHeaders": true // Trust X-Forwarded-Proto from ALB
}
}
}
```
**Program.cs - Forward Headers:**
```csharp
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
```
### Option 2: Gateway as Primary TLS Termination
**Architecture:**
```
Internet → Gateway (TLS) → Backends (HTTP/HTTPS)
```
**Use Cases:**
- On-premises deployment
- Direct internet exposure
- Custom routing logic
**Configuration:**
```json
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://*:443",
"Certificate": {
"Path": "/app/certs/gateway.pfx",
"Password": ""
}
}
}
}
}
```
---
## TLS Termination Strategies
### Strategy 1: TLS Termination (Recommended for Most Cases)
**Diagram:**
```
Client --HTTPS--> Gateway --HTTP--> Backend Servers
[TLS
Termination]
```
**Benefits:**
- Single certificate management point
- Lower backend CPU usage
- Easy to inspect/log traffic
- Simpler troubleshooting
**Configuration:**
```json
{
"Kestrel": {
"Endpoints": {
"Https": { "Url": "https://*:8443" }
}
},
"Gateway": {
"BackendServers": [
{ "Url": "http://backend:5050" } // HTTP to backends
]
}
}
```
### Strategy 2: TLS Passthrough (Transparent Proxy)
**Diagram:**
```
Client --HTTPS--> Gateway --HTTPS--> Backend Servers
[Proxy Only,
No TLS
Termination]
```
**Benefits:**
- End-to-end encryption
- Gateway never sees decrypted traffic
- Backend servers control TLS config
**Limitations:**
- Cannot inspect traffic
- Cannot do intelligent routing based on content
- More complex certificate management
**Not Recommended** for MCP Gateway (requires layer 7 routing)
### Strategy 3: TLS Re-encryption (Maximum Security)
**Diagram:**
```
Client --HTTPS--> Gateway --HTTPS--> Backend Servers
[TLS Termination
+ Re-encryption]
```
**Benefits:**
- Can inspect/route traffic
- End-to-end encryption
- Zero-trust architecture
**Configuration:**
```json
{
"Kestrel": {
"Endpoints": {
"Https": { "Url": "https://*:8443" }
}
},
"Gateway": {
"BackendServers": [
{ "Url": "https://backend:5051" } // HTTPS to backends
]
}
}
```
---
## Security Headers
**Add to Program.cs:**
```csharp
app.Use(async (context, next) =>
{
// HSTS (HTTP Strict Transport Security)
context.Response.Headers.Add("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
// Content Security Policy
context.Response.Headers.Add("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'");
// X-Frame-Options (prevent clickjacking)
context.Response.Headers.Add("X-Frame-Options", "DENY");
// X-Content-Type-Options (prevent MIME sniffing)
context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
// Referrer Policy
context.Response.Headers.Add("Referrer-Policy", "strict-origin-when-cross-origin");
// Permissions Policy
context.Response.Headers.Add("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
await next();
});
// Redirect HTTP to HTTPS
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
app.UseHsts();
}
```
---
## Testing
### 1. Test Gateway HTTPS Endpoint
```bash
# Health check
curl https://gateway.example.com/health
# Expected output
{
"status": "Healthy",
"service": "MCP Gateway",
"backends": [
{
"name": "mcp-server-1",
"healthy": true,
"lastCheck": "2025-10-19T12:00:00Z"
}
]
}
```
### 2. Test MCP Tool Invocation Through Gateway
```bash
curl -X POST https://gateway.example.com/mcp/invoke \
-H "Content-Type: application/json" \
-H "X-API-Key: gateway-api-key" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "search_documents",
"arguments": {
"query": "test"
}
},
"id": "1"
}'
```
### 3. Verify TLS Configuration
```bash
# Check TLS version and cipher suites
nmap --script ssl-enum-ciphers -p 8443 gateway.example.com
# Check certificate
echo | openssl s_client -connect gateway.example.com:8443 -servername gateway.example.com 2>/dev/null | openssl x509 -noout -text
```
### 4. Test Load Balancing
```bash
# Send multiple requests, verify distribution
for i in {1..10}; do
curl -s https://gateway.example.com/health | jq -r '.backend'
done
# Should see round-robin distribution across backends
```
### 5. Test Circuit Breaker
```bash
# Stop one backend server
docker stop mcp-server-1
# Verify gateway routes to healthy server
curl https://gateway.example.com/health
# Should show mcp-server-2 only
```
---
## Troubleshooting
### Issue: "Unable to configure HTTPS endpoint"
**Symptoms:**
```
System.InvalidOperationException: Unable to configure HTTPS endpoint
```
**Solution:**
1. Verify certificate file exists
2. Check password is correct
3. Ensure certificate is PFX format
```bash
# Verify PFX
openssl pkcs12 -info -in gateway.pfx -noout
```
### Issue: Gateway Cannot Connect to Backend Servers
**Symptoms:**
```
All backends unavailable
Circuit breaker: OPEN
```
**Solution:**
1. Check backend server URLs
2. Verify network connectivity
3. Check firewall rules
```bash
# Test backend connectivity from gateway container
docker exec gateway-container curl http://mcp-server-1:5050/health
```
### Issue: Clients Receive "Certificate Invalid"
**Symptoms:**
Clients reject gateway certificate.
**Solution:**
1. Ensure certificate CN/SAN matches gateway domain
2. Verify certificate is from trusted CA
3. Check certificate not expired
```bash
# Check certificate details
openssl x509 -in gateway.crt -noout -text | grep -A1 "Subject Alternative Name"
```
### Issue: Slow Response Times
**Symptoms:**
High latency through gateway.
**Solution:**
1. Check backend server performance
2. Increase connection pool size
3. Enable HTTP/2
```json
{
"Gateway": {
"BackendServers": [
{
"MaxConnections": 50 // Increase from 10
}
]
},
"Kestrel": {
"Endpoints": {
"Https": {
"Protocols": "Http1AndHttp2" // Enable HTTP/2
}
}
}
}
```
---
## Production Deployment Checklist
- [ ] Valid TLS certificate from trusted CA
- [ ] Certificate includes full chain
- [ ] Private key secured (Kubernetes secret, vault, etc.)
- [ ] HSTS header enabled
- [ ] HTTP → HTTPS redirect configured
- [ ] Backend server health checks working
- [ ] Circuit breakers configured
- [ ] Connection pooling optimized
- [ ] Security headers configured
- [ ] Rate limiting enabled
- [ ] Monitoring/alerting set up
- [ ] Certificate expiry monitoring configured
- [ ] Load testing completed
- [ ] Firewall rules configured
- [ ] TLS 1.2+ enforced
---
## References
**OpenHarbor.MCP Documentation:**
- [Gateway README](../../README.md)
- [Architecture](../../docs/architecture.md)
- [Load Balancing Strategies](../../docs/load-balancing.md)
**ASP.NET Core Documentation:**
- [Kestrel HTTPS Configuration](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints)
- [Reverse Proxy](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer)
- [Security Headers](https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl)
**Load Balancers:**
- [Nginx Reverse Proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/)
- [AWS Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/)
- [Traefik](https://doc.traefik.io/traefik/)
---
**Document Version**: 1.0.0
**Last Updated**: 2025-10-19
**Maintained By**: Svrnty Development Team
**Related**: [Server HTTPS Setup](../../OpenHarbor.MCP.Server/docs/deployment/https-setup.md), [Client HTTPS Setup](../../OpenHarbor.MCP.Client/docs/deployment/https-setup.md)

442
docs/implementation-plan.md Normal file
View File

@ -0,0 +1,442 @@
# OpenHarbor.MCP.Gateway - Implementation Plan
**Document Type:** Implementation Roadmap
**Status:** Planned
**Version:** 1.0.0
**Last Updated:** 2025-10-19
---
## Overview
This document outlines the phased implementation plan for OpenHarbor.MCP.Gateway, following TDD principles and Clean Architecture.
### Goals
- Build production-ready MCP gateway library
- Follow Clean Architecture patterns
- Maintain >80% test coverage
- Enable centralized routing and management of MCP traffic
---
## Phase 1: Core Abstractions (2-3 days)
### Goal
Establish foundation with interfaces, models, and abstractions.
### Steps
#### Step 1.1: Core Models
- Create `ServerInfo` model (server metadata)
- Create `ServerConfig` model (server configuration)
- Create `RoutingContext` model (routing metadata)
- Create `McpRequest/Response` models
- Create `ServerHealthStatus` model
- **Tests**: Model validation, serialization
#### Step 1.2: Core Interfaces
- Define `IGatewayRouter` interface
- Define `IRoutingStrategy` interface
- Define `IAuthProvider` interface
- Define `ICircuitBreaker` interface
- Define `IServerConnection` interface
- **Tests**: Interface contracts (using mocks)
#### Step 1.3: Configuration Models
- Create `GatewayConfig`
- Create `RoutingConfig`
- Create `SecurityConfig`
- Create `RateLimitConfig`
- **Tests**: Configuration validation
### Exit Criteria
- [ ] All core models defined
- [ ] All core interfaces defined
- [ ] Unit tests passing (target: 25+ tests)
- [ ] Zero external dependencies in Core project
---
## Phase 2: Infrastructure - Server Connections (3-4 days)
### Goal
Implement connections to backend MCP servers.
### Steps
#### Step 2.1: Transport Layer
- Implement `StdioServerTransport`
- Implement `HttpServerTransport`
- Process lifecycle management
- HTTP client configuration
- **Tests**: Transport communication
#### Step 2.2: Server Connection
- Implement `ServerConnection` class
- Connection state management
- Request/response handling
- Timeout handling
- **Tests**: Connection lifecycle
#### Step 2.3: Connection Pool
- Implement `ServerConnectionPool`
- Connection pooling per server
- Idle connection eviction
- Max connections enforcement
- **Tests**: Pool behavior
### Exit Criteria
- [ ] Server connections functional
- [ ] Connection pooling working
- [ ] Unit tests passing (target: 40+ tests)
- [ ] Integration tests with mock servers (target: 10+ tests)
---
## Phase 3: Routing Implementation (3-4 days)
### Goal
Implement routing strategies and gateway router.
### Steps
#### Step 3.1: Routing Strategies
- Implement `RoundRobinStrategy`
- Implement `ToolBasedStrategy`
- Implement `ClientBasedStrategy`
- Implement `WeightedStrategy` (optional)
- **Tests**: Strategy selection logic
#### Step 3.2: Gateway Router
- Implement `GatewayRouter` class
- Server registration/discovery
- Route request to selected server
- Response handling
- **Tests**: Routing behavior
#### Step 3.3: Strategy Configuration
- Strategy factory
- Configuration-based strategy selection
- Custom strategy registration
- **Tests**: Strategy configuration
### Exit Criteria
- [ ] All routing strategies implemented
- [ ] Gateway router functional
- [ ] Unit tests passing (target: 50+ tests)
- [ ] Integration tests with real servers (target: 15+ tests)
---
## Phase 4: Health Monitoring (2-3 days)
### Goal
Implement health checks and monitoring.
### Steps
#### Step 4.1: Health Check Implementation
- Implement `ServerHealthChecker`
- Periodic health checks
- Health status tracking
- **Tests**: Health check behavior
#### Step 4.2: Circuit Breaker
- Implement `CircuitBreaker` class
- Failure tracking
- Circuit state management
- Half-open state handling
- **Tests**: Circuit breaker state transitions
#### Step 4.3: Health Dashboard
- ASP.NET Core health check integration
- Health status endpoints
- Metrics collection
- **Tests**: Health endpoint behavior
### Exit Criteria
- [ ] Health monitoring functional
- [ ] Circuit breaker working
- [ ] Unit tests passing (target: 30+ tests)
- [ ] Health endpoints operational
---
## Phase 5: Security Layer (2-3 days)
### Goal
Implement authentication, authorization, and rate limiting.
### Steps
#### Step 5.1: Authentication
- Implement `ApiKeyAuthProvider`
- API key validation
- Client identification
- **Tests**: Authentication flows
#### Step 5.2: Authorization
- Implement authorization rules
- Role-based access control
- Server-level permissions
- Tool-level permissions
- **Tests**: Authorization logic
#### Step 5.3: Rate Limiting
- Implement rate limiter
- Per-client rate limits
- Global rate limits
- Burst handling
- **Tests**: Rate limit enforcement
### Exit Criteria
- [ ] Authentication working
- [ ] Authorization functional
- [ ] Rate limiting operational
- [ ] Unit tests passing (target: 40+ tests)
---
## Phase 6: ASP.NET Core Integration (2 days)
### Goal
Provide HTTP endpoints and dependency injection support.
### Steps
#### Step 6.1: DI Extensions
- Create `AddMcpGateway` extension method
- Service registration
- Configuration binding
- **Tests**: DI registration
#### Step 6.2: HTTP Endpoints
- Implement gateway endpoint
- Implement health endpoints
- Implement management endpoints
- **Tests**: Endpoint behavior
#### Step 6.3: Middleware
- Authentication middleware
- Rate limiting middleware
- Audit logging middleware
- **Tests**: Middleware integration
### Exit Criteria
- [ ] DI integration complete
- [ ] HTTP endpoints working
- [ ] Unit tests passing (target: 25+ tests)
- [ ] Sample ASP.NET Core app working
---
## Phase 7: CLI Tool (1-2 days)
### Goal
Provide command-line interface for management.
### Steps
#### Step 7.1: CLI Commands
- `gateway list-servers` - List registered servers
- `gateway add-server <config>` - Register server
- `gateway remove-server <id>` - Unregister server
- `gateway health` - Check server health
- `gateway route-test <tool>` - Test routing
- **Tests**: CLI command execution
#### Step 7.2: Configuration
- Support for config file
- Environment variable support
- Interactive mode
- **Tests**: Configuration loading
### Exit Criteria
- [ ] CLI functional for all operations
- [ ] Integration tests passing (target: 10+ tests)
- [ ] Documentation complete
---
## Phase 8: Sample Application (1-2 days)
### Goal
Create CodexMcpGateway sample demonstrating usage.
### Steps
#### Step 8.1: Sample Project Setup
- Create ASP.NET Core project
- Configure to route to multiple servers
- **Validation**: Successful routing
#### Step 8.2: Sample Routing Strategies
- Implement custom routing strategy
- Demonstrate tool-based routing
- Demonstrate client-based routing
- **Validation**: All strategies functional
#### Step 8.3: Sample Documentation
- Usage examples
- Configuration guide
- Troubleshooting
- **Validation**: Clear and complete
### Exit Criteria
- [ ] Sample app compiles and runs
- [ ] All routing strategies demonstrated
- [ ] Documentation complete
---
## Phase 9: Documentation & Polish (1-2 days)
### Goal
Finalize documentation and prepare for release.
### Steps
#### Step 9.1: API Documentation
- XML documentation comments
- Generate API reference
- **Validation**: Complete coverage
#### Step 9.2: User Guides
- Getting Started guide
- Configuration reference
- Routing strategies guide
- Security guide
- Troubleshooting guide
- **Validation**: User-friendly
#### Step 9.3: Code Quality
- Run code analysis
- Apply code formatting
- **Validation**: Clean codebase
### Exit Criteria
- [ ] All documentation complete
- [ ] Code analysis passes
- [ ] Ready for use
---
## Test Strategy
### Unit Tests
- **Target**: >80% coverage
- **Framework**: xUnit + Moq
- **Pattern**: AAA (Arrange-Act-Assert)
- **Focus**: Core logic, edge cases, routing strategies
### Integration Tests
- **Target**: >70% coverage
- **Approach**: Real MCP server connections
- **Focus**: End-to-end routing, health checks, authentication
### Load Tests
- **Focus**: Gateway throughput and latency
- **Metrics**: Requests per second, p95/p99 latency
- **Goal**: <50ms average latency for routing decisions
---
## Milestones
| Phase | Duration | Test Target | Milestone |
|-------|----------|-------------|-----------|
| Phase 1 | 2-3 days | 25+ tests | Core abstractions complete |
| Phase 2 | 3-4 days | 40+ tests | Server connections functional |
| Phase 3 | 3-4 days | 50+ tests | Routing strategies working |
| Phase 4 | 2-3 days | 30+ tests | Health monitoring operational |
| Phase 5 | 2-3 days | 40+ tests | Security layer complete |
| Phase 6 | 2 days | 25+ tests | ASP.NET Core integration |
| Phase 7 | 1-2 days | 10+ tests | CLI tool complete |
| Phase 8 | 1-2 days | N/A | Sample app functional |
| Phase 9 | 1-2 days | N/A | Documentation complete |
**Total Estimated Time**: 17-26 days
---
## Risk Mitigation
### Risk: Server Failures
**Mitigation**:
- Circuit breaker pattern implementation
- Health monitoring with automatic failover
- Retry logic with exponential backoff
### Risk: Performance Bottlenecks
**Mitigation**:
- Connection pooling from start
- Performance testing in Phase 3
- Caching of health check results
### Risk: Security Vulnerabilities
**Mitigation**:
- Input validation throughout
- Rate limiting implementation
- Audit logging of all requests
- Regular security reviews
---
## Dependencies
### Required
- .NET 8.0 SDK
- System.Text.Json (built-in)
- xUnit, Moq (testing)
### Optional
- Microsoft.Extensions.DependencyInjection
- Microsoft.Extensions.Http
- Microsoft.Extensions.Diagnostics.HealthChecks
- Microsoft.AspNetCore.RateLimiting
---
## Success Criteria
- [ ] All phases complete
- [ ] >80% test coverage (Core, Infrastructure)
- [ ] >70% test coverage (AspNetCore)
- [ ] Sample application working
- [ ] Documentation complete
- [ ] Zero critical bugs
- [ ] Performance targets met (<50ms routing latency)
- [ ] Security audit passed
---
**Document Version:** 1.0.0
**Status:** Planned
**Next Review:** Before Phase 1 start

533
docs/module-design.md Normal file
View File

@ -0,0 +1,533 @@
# OpenHarbor.MCP.Gateway - Module Design
**Document Type:** Architecture Design Document
**Status:** Planned
**Version:** 1.0.0
**Last Updated:** 2025-10-19
---
## Overview
OpenHarbor.MCP.Gateway is a .NET 8 library that provides proxy and routing infrastructure for MCP traffic, enabling centralized management, authentication, monitoring, and load balancing between MCP clients and servers. This document defines the architecture, components, and design decisions.
### Purpose
- **What**: Gateway/proxy library for routing MCP traffic between clients and servers
- **Why**: Enable centralized management, security, and monitoring of MCP infrastructure
- **How**: Clean Architecture with routing strategies, health monitoring, and transport abstraction
---
## Architecture
### Clean Architecture Layers
```
┌─────────────────────────────────────────────────┐
│ OpenHarbor.MCP.Gateway.Cli (Executable) │
│ ┌───────────────────────────────────────────┐ │
│ │ OpenHarbor.MCP.Gateway.AspNetCore (HTTP)│ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ OpenHarbor.MCP.Gateway.Infrastructure│ │ │
│ │ │ ┌───────────────────────────────┐ │ │ │
│ │ │ │ OpenHarbor.MCP.Gateway.Core │ │ │ │
│ │ │ │ - IGatewayRouter │ │ │ │
│ │ │ │ - IRoutingStrategy │ │ │ │
│ │ │ │ - IAuthProvider │ │ │ │
│ │ │ │ - ICircuitBreaker │ │ │ │
│ │ │ │ - Models (no dependencies) │ │ │ │
│ │ │ └───────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### Layer Responsibilities
| Layer | Purpose | Dependencies |
|-------|---------|----|
| **Core** | Abstractions and models | None |
| **Infrastructure** | Routing, auth, circuit breakers | Core, System.Text.Json |
| **AspNetCore** | HTTP endpoints and DI | Core, Infrastructure, ASP.NET Core |
| **Cli** | Management CLI | All layers |
---
## Core Components
### IGatewayRouter Interface
Primary interface for gateway routing operations:
```csharp
public interface IGatewayRouter
{
// Request Routing
Task<McpResponse> RouteRequestAsync(
McpRequest request,
RoutingContext context,
CancellationToken ct = default
);
// Server Management
Task<IEnumerable<ServerInfo>> GetRegisteredServersAsync();
Task RegisterServerAsync(ServerConfig config);
Task UnregisterServerAsync(string serverId);
// Health Monitoring
Task<ServerHealthStatus> GetServerHealthAsync(string serverId);
Task<IEnumerable<ServerHealthStatus>> GetAllServerHealthAsync();
}
```
### IRoutingStrategy Interface
Defines server selection logic:
```csharp
public interface IRoutingStrategy
{
string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> availableServers
);
}
public class RoutingContext
{
public string? ToolName { get; set; }
public string? ClientId { get; set; }
public Dictionary<string, string>? Headers { get; set; }
public Dictionary<string, object>? Metadata { get; set; }
}
```
### IAuthProvider Interface
Authentication and authorization:
```csharp
public interface IAuthProvider
{
Task<AuthResult> AuthenticateAsync(
string? apiKey,
Dictionary<string, string>? headers,
CancellationToken ct = default
);
Task<bool> AuthorizeAsync(
string clientId,
string serverId,
string toolName,
CancellationToken ct = default
);
}
public class AuthResult
{
public bool IsAuthenticated { get; set; }
public string? ClientId { get; set; }
public IEnumerable<string> Roles { get; set; } = [];
public string? ErrorMessage { get; set; }
}
```
### ICircuitBreaker Interface
Prevent cascading failures:
```csharp
public interface ICircuitBreaker
{
bool IsOpen(string serverId);
void RecordSuccess(string serverId);
void RecordFailure(string serverId);
void Reset(string serverId);
}
```
---
## Routing Strategies
### Built-In Strategies
#### Round-Robin Strategy
```csharp
public class RoundRobinStrategy : IRoutingStrategy
{
private int _currentIndex = 0;
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
var healthyServers = servers.Where(s => s.IsHealthy).ToList();
if (healthyServers.Count == 0)
{
throw new NoHealthyServersException();
}
var index = Interlocked.Increment(ref _currentIndex) % healthyServers.Count;
return healthyServers[index].Id;
}
}
```
#### Tool-Based Strategy
```csharp
public class ToolBasedStrategy : IRoutingStrategy
{
private readonly Dictionary<string, string> _toolPrefixMappings;
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
if (context.ToolName == null)
{
throw new InvalidOperationException("ToolName required for tool-based routing");
}
foreach (var (prefix, serverId) in _toolPrefixMappings)
{
if (context.ToolName.StartsWith(prefix))
{
return serverId;
}
}
// Default to first healthy server
return servers.First(s => s.IsHealthy).Id;
}
}
```
#### Client-Based Strategy
```csharp
public class ClientBasedStrategy : IRoutingStrategy
{
private readonly Dictionary<string, string> _clientMappings;
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
if (context.ClientId != null &&
_clientMappings.TryGetValue(context.ClientId, out var serverId))
{
return serverId;
}
// Default routing
return servers.First(s => s.IsHealthy).Id;
}
}
```
---
## Configuration
### Gateway Configuration Model
```csharp
public class GatewayConfig
{
public string Name { get; set; } = "MCP Gateway";
public string Version { get; set; } = "1.0.0";
public string? Description { get; set; }
public string ListenAddress { get; set; } = "http://localhost:8080";
}
public class ServerConfig
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public TransportConfig Transport { get; set; } = new();
public bool Enabled { get; set; } = true;
public Dictionary<string, string>? Metadata { get; set; }
}
public class TransportConfig
{
public string Type { get; set; } = "Stdio"; // "Stdio" or "Http"
public string? Command { get; set; }
public string[]? Args { get; set; }
public string? BaseUrl { get; set; }
public Dictionary<string, string>? Headers { get; set; }
}
```
### Routing Configuration
```csharp
public class RoutingConfig
{
public string Strategy { get; set; } = "RoundRobin"; // "RoundRobin", "ToolBased", "ClientBased"
public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromSeconds(30);
public Dictionary<string, string>? StrategyConfig { get; set; }
}
```
### Security Configuration
```csharp
public class SecurityConfig
{
public bool EnableAuthentication { get; set; } = false;
public string? ApiKeyHeader { get; set; } = "X-MCP-API-Key";
public RateLimitConfig? RateLimit { get; set; }
}
public class RateLimitConfig
{
public int RequestsPerMinute { get; set; } = 100;
public int BurstSize { get; set; } = 20;
}
```
---
## Health Monitoring
### Server Health Check
```csharp
public class ServerHealthStatus
{
public string ServerId { get; set; } = string.Empty;
public string ServerName { get; set; } = string.Empty;
public bool IsHealthy { get; set; }
public DateTime LastCheck { get; set; }
public TimeSpan? ResponseTime { get; set; }
public string? ErrorMessage { get; set; }
}
```
### Health Check Implementation
```csharp
public class McpServerHealthCheck : IHealthCheck
{
private readonly IGatewayRouter _router;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken ct = default)
{
var statuses = await _router.GetAllServerHealthAsync();
var healthyCount = statuses.Count(s => s.IsHealthy);
var totalCount = statuses.Count();
if (healthyCount == totalCount)
{
return HealthCheckResult.Healthy(
$"All {totalCount} servers healthy");
}
else if (healthyCount > 0)
{
return HealthCheckResult.Degraded(
$"{healthyCount}/{totalCount} servers healthy");
}
else
{
return HealthCheckResult.Unhealthy(
"No healthy servers available");
}
}
}
```
---
## Error Handling
### Exception Hierarchy
```csharp
public class GatewayException : Exception { }
public class NoHealthyServersException : GatewayException { }
public class ServerNotFoundException : GatewayException
{
public string ServerId { get; }
}
public class RoutingException : GatewayException
{
public RoutingContext Context { get; }
}
public class AuthenticationException : GatewayException { }
public class RateLimitExceededException : GatewayException
{
public string ClientId { get; }
public int RequestsPerMinute { get; }
}
```
### Circuit Breaker Implementation
```csharp
public class CircuitBreaker : ICircuitBreaker
{
private readonly ConcurrentDictionary<string, CircuitState> _states = new();
private readonly int _failureThreshold = 5;
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(30);
public bool IsOpen(string serverId)
{
if (!_states.TryGetValue(serverId, out var state))
{
return false;
}
if (state.State == CircuitState.Open &&
DateTime.UtcNow - state.LastFailure > _timeout)
{
// Transition to half-open
state.State = CircuitState.HalfOpen;
}
return state.State == CircuitState.Open;
}
public void RecordSuccess(string serverId)
{
_states.AddOrUpdate(serverId,
_ => new CircuitState { State = CircuitState.Closed },
(_, state) => { state.FailureCount = 0; state.State = CircuitState.Closed; return state; });
}
public void RecordFailure(string serverId)
{
_states.AddOrUpdate(serverId,
_ => new CircuitState { FailureCount = 1, LastFailure = DateTime.UtcNow },
(_, state) =>
{
state.FailureCount++;
state.LastFailure = DateTime.UtcNow;
if (state.FailureCount >= _failureThreshold)
{
state.State = CircuitState.Open;
}
return state;
});
}
public void Reset(string serverId)
{
_states.TryRemove(serverId, out _);
}
}
enum CircuitState
{
Closed,
Open,
HalfOpen
}
```
---
## Testing Strategy
### Unit Tests
- Test Core abstractions with mocks
- Test routing strategies with mock servers
- Test circuit breaker logic
- Test authentication/authorization
### Integration Tests
- Test actual routing to real MCP servers
- Test health checks
- Test error scenarios (server failures, timeouts)
- Test authentication flows
### Test Coverage Goals
- Core: >90%
- Infrastructure: >80%
- AspNetCore: >70%
---
## Performance Considerations
### Connection Pooling
- Maintain persistent connections to backend servers
- Configurable pool size per server
- Idle connection eviction
### Request Caching
- Cache tool discovery results
- Cache health check results (with TTL)
- Invalidate cache on server changes
### Monitoring
- Track request latency per server
- Track request success/failure rates
- Track circuit breaker state changes
- OpenTelemetry metrics integration
---
## Security
### Input Validation
- Validate all incoming requests
- Sanitize routing context data
- Validate server configuration
### Authentication
- API key authentication
- JWT token support
- Client identity verification
### Authorization
- Role-based access control
- Server-level permissions
- Tool-level permissions
### Rate Limiting
- Per-client rate limiting
- Per-server rate limiting
- Global rate limiting
- Burst protection
---
## Future Enhancements
- [ ] WebSocket transport support
- [ ] Request/response compression
- [ ] Dynamic server registration/discovery
- [ ] A/B testing support
- [ ] Blue/green deployment routing
- [ ] Multi-region routing
- [ ] Request replay for debugging
- [ ] Distributed tracing integration
---
**Document Version:** 1.0.0
**Status:** Planned
**Next Review:** After Phase 1 implementation

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\OpenHarbor.MCP.Gateway.Infrastructure\OpenHarbor.MCP.Gateway.Infrastructure.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,137 @@
using OpenHarbor.MCP.Gateway.Core.Models;
using OpenHarbor.MCP.Gateway.Infrastructure.Routing;
using OpenHarbor.MCP.Gateway.Infrastructure.Connection;
using OpenHarbor.MCP.Gateway.Infrastructure.Security;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
namespace CodexMcpGateway;
/// <summary>
/// Sample MCP Gateway application demonstrating gateway capabilities.
/// Routes requests to multiple MCP servers with load balancing and health monitoring.
/// </summary>
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine("=== CODEX MCP Gateway Sample ===");
Console.WriteLine("Gateway routes requests to MCP servers via HTTP transport\n");
// Step 1: Create connection pool
var connectionPool = new ServerConnectionPool
{
MaxConnectionsPerServer = 10,
IdleTimeout = TimeSpan.FromMinutes(5)
};
// Step 2: Choose routing strategy
Console.WriteLine("Select routing strategy:");
Console.WriteLine("1. Round Robin (default)");
Console.WriteLine("2. Tool-based routing");
Console.WriteLine("3. Client-based routing");
Console.Write("\nChoice (1-3): ");
var choice = Console.ReadLine();
IRoutingStrategy strategy = choice switch
{
"2" => new ToolBasedStrategy(new Dictionary<string, string>
{
["search_*"] = "codex-server-1",
["get_document"] = "codex-server-2",
["*"] = "codex-server-1"
}),
"3" => new ClientBasedStrategy(new Dictionary<string, string>
{
["client-a"] = "codex-server-1",
["client-b"] = "codex-server-2"
}),
_ => new RoundRobinStrategy()
};
Console.WriteLine($"Using {strategy.GetType().Name}\n");
// Step 3: Create gateway router
var router = new GatewayRouter(strategy, connectionPool);
// Step 4: Register MCP servers
Console.WriteLine("Registering MCP servers...");
var server1 = new ServerConfig
{
Id = "codex-server-1",
Name = "CODEX MCP Server 1",
TransportType = "Http",
BaseUrl = "http://localhost:5050",
Enabled = true
};
await router.RegisterServerAsync(server1);
Console.WriteLine($" ✓ Registered: {server1.Name} ({server1.BaseUrl})");
// Step 5: Check server health
Console.WriteLine("\nChecking server health...");
var health = await router.GetServerHealthAsync();
foreach (var serverHealth in health)
{
var status = serverHealth.IsHealthy ? "✓ Healthy" : "✗ Unhealthy";
Console.WriteLine($" {status}: {serverHealth.ServerName}");
}
// Step 6: Demonstrate request routing
Console.WriteLine("\n--- Simulating Gateway Requests ---\n");
Console.WriteLine("Request 1: search_codex");
var request1 = new GatewayRequest
{
ToolName = "search_codex",
Arguments = new Dictionary<string, object>
{
["query"] = "Model Context Protocol",
["maxResults"] = 5
},
ClientId = "demo-client"
};
try
{
var response1 = await router.RouteAsync(request1);
Console.WriteLine($" Response: {(response1.Success ? "Success" : "Failed")}");
Console.WriteLine($" Routed to: {response1.ServerId}");
if (!response1.Success)
{
Console.WriteLine($" Error: {response1.Error}");
}
}
catch (Exception ex)
{
Console.WriteLine($" Error: {ex.Message}");
}
// Step 7: Demonstrate authentication
Console.WriteLine("\n--- Testing Authentication ---\n");
var apiKeyProvider = new ApiKeyAuthProvider(new Dictionary<string, string>
{
["demo-client"] = "secret-key-123"
});
var authContext = new AuthenticationContext
{
ClientId = "demo-client",
Credentials = "secret-key-123"
};
var authResult = await apiKeyProvider.AuthenticateAsync(authContext);
Console.WriteLine($"Authentication: {(authResult.IsAuthenticated ? " Success" : " Failed")}");
// Step 8: Show statistics
Console.WriteLine("\n--- Gateway Statistics ---\n");
var poolStats = connectionPool.GetPoolStats();
Console.WriteLine($"Connection Pool:");
Console.WriteLine($" Total connections: {poolStats.TotalConnections}");
Console.WriteLine($" Active connections: {poolStats.ActiveConnections}");
Console.WriteLine($" Idle connections: {poolStats.IdleConnections}");
Console.WriteLine("\n=== Sample Complete ===");
}
}

View File

@ -0,0 +1,336 @@
# CodexMcpGateway Sample
Sample ASP.NET Core application demonstrating OpenHarbor.MCP.Gateway usage with multiple MCP servers.
## Purpose
Shows how to:
- Configure gateway to route to multiple backend MCP servers
- Implement custom routing strategies
- Enable authentication and rate limiting
- Monitor server health
- Manage servers via API endpoints
## Prerequisites
- .NET 8.0 SDK
- Running MCP servers (CODEX or other MCP-compatible servers)
## Configuration
Edit `appsettings.json` to configure your MCP servers:
```json
{
"Mcp": {
"Gateway": {
"Name": "CODEX MCP Gateway",
"Version": "1.0.0",
"Description": "Gateway routing to CODEX and other MCP servers",
"ListenAddress": "http://localhost:8080"
},
"Servers": [
{
"Id": "codex-server",
"Name": "CODEX Knowledge Base",
"Transport": {
"Type": "Stdio",
"Command": "dotnet",
"Args": ["run", "--project", "/path/to/CodexMcpServer/CodexMcpServer.csproj"]
},
"Enabled": true
},
{
"Id": "remote-api",
"Name": "Remote API Server",
"Transport": {
"Type": "Http",
"BaseUrl": "https://api.example.com/mcp"
},
"Enabled": true
}
],
"Routing": {
"Strategy": "ToolBased",
"HealthCheckInterval": "00:00:30",
"StrategyConfig": {
"search_": "codex-server",
"db_": "codex-server",
"api_": "remote-api"
}
},
"Security": {
"EnableAuthentication": true,
"ApiKeyHeader": "X-MCP-API-Key",
"RateLimit": {
"RequestsPerMinute": 100,
"BurstSize": 20
}
},
"Monitoring": {
"EnableMetrics": true,
"EnableTracing": true,
"EnableAuditLog": true
}
}
}
```
## Routing Strategies
### Tool-Based Routing
Routes requests based on tool name patterns:
```csharp
// In Routing/ToolBasedRouter.cs
public class ToolBasedRouter : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
return context.ToolName switch
{
var t when t.StartsWith("search_") => "codex-server",
var t when t.StartsWith("db_") => "codex-server",
var t when t.StartsWith("api_") => "remote-api",
_ => servers.First().Id // Default
};
}
}
```
### Client-Based Routing
Routes requests based on client identity:
```csharp
// In Routing/ClientBasedRouter.cs
public class ClientBasedRouter : IRoutingStrategy
{
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
// Route admin clients to special server
if (context.ClientId == "admin-client")
{
return "remote-api";
}
// Default routing
return "codex-server";
}
}
```
### Load Balancing
Distributes load across multiple server instances:
```csharp
// In Routing/LoadBalancedRouter.cs
public class LoadBalancedRouter : IRoutingStrategy
{
private int _currentIndex = 0;
public string SelectServer(
RoutingContext context,
IEnumerable<ServerInfo> servers)
{
var healthyServers = servers.Where(s => s.IsHealthy).ToList();
if (healthyServers.Count == 0)
{
throw new NoHealthyServersException();
}
var index = Interlocked.Increment(ref _currentIndex) % healthyServers.Count;
return healthyServers[index].Id;
}
}
```
## Running the Gateway
```bash
# Start the gateway
dotnet run
# Gateway will listen on http://localhost:8080
```
## Testing the Gateway
### Health Check
```bash
curl http://localhost:8080/health
# Response:
# {
# "status": "Healthy",
# "totalServers": 2,
# "healthyServers": 2,
# "servers": [
# {
# "id": "codex-server",
# "status": "Healthy",
# "lastCheck": "2025-10-19T17:40:00Z"
# }
# ]
# }
```
### Send MCP Request
```bash
# With authentication
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "X-MCP-API-Key: your-api-key" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "search_codex",
"arguments": {
"query": "architecture"
}
},
"id": 1
}'
```
### Management Endpoints
```bash
# List registered servers
curl http://localhost:8080/gateway/servers
# Get server health
curl http://localhost:8080/gateway/servers/codex-server/health
# View metrics
curl http://localhost:8080/metrics
```
## Connecting MCP Clients
Configure MCP clients to connect to the gateway instead of individual servers:
```json
{
"Mcp": {
"Servers": [
{
"Name": "gateway",
"Transport": {
"Type": "Http",
"BaseUrl": "http://localhost:8080/mcp",
"Headers": {
"X-MCP-API-Key": "your-api-key"
}
}
}
]
}
}
```
## Authentication
### API Key Authentication
Add API keys to configuration:
```json
{
"Security": {
"ApiKeys": [
{
"Key": "admin-key-12345",
"ClientId": "admin-client",
"Roles": ["admin"]
},
{
"Key": "app-key-67890",
"ClientId": "web-app",
"Roles": ["user"]
}
]
}
}
```
### Using API Keys
Include the API key in requests:
```bash
curl -H "X-MCP-API-Key: admin-key-12345" http://localhost:8080/mcp
```
## Monitoring
### Audit Logs
All requests are logged to the audit log:
```json
{
"timestamp": "2025-10-19T17:40:00Z",
"clientId": "web-app",
"serverId": "codex-server",
"toolName": "search_codex",
"arguments": { "query": "architecture" },
"responseTime": "45ms",
"status": "success"
}
```
### Metrics
OpenTelemetry metrics are available at `/metrics`:
- `mcp_gateway_requests_total` - Total requests
- `mcp_gateway_request_duration_ms` - Request latency
- `mcp_gateway_errors_total` - Error count
- `mcp_gateway_server_health` - Server health status
## Files
- `Program.cs` - Main gateway entry point
- `appsettings.json` - Gateway configuration
- `Routing/ToolBasedRouter.cs` - Tool-based routing strategy
- `Routing/ClientBasedRouter.cs` - Client-based routing strategy
- `Routing/LoadBalancedRouter.cs` - Load balancing strategy
- `Middleware/AuthenticationMiddleware.cs` - Authentication
- `Middleware/RateLimitingMiddleware.cs` - Rate limiting
- `Middleware/AuditLoggingMiddleware.cs` - Audit logging
## Troubleshooting
### Server Not Responding
Check server health:
```bash
curl http://localhost:8080/gateway/servers/codex-server/health
```
Check circuit breaker status in logs.
### Authentication Failing
Verify API key is correct and included in request headers.
### Rate Limit Exceeded
Adjust rate limits in configuration or wait for rate limit window to reset.
## Learn More
- [OpenHarbor.MCP.Gateway Documentation](../../README.md)
- [Module Design](../../docs/module-design.md)
- [Implementation Plan](../../docs/implementation-plan.md)

View File

@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Builder;
using OpenHarbor.MCP.Gateway.AspNetCore.Middleware;
namespace OpenHarbor.MCP.Gateway.AspNetCore.Extensions;
/// <summary>
/// Extension methods for configuring MCP Gateway middleware.
/// </summary>
public static class ApplicationBuilderExtensions
{
/// <summary>
/// Adds MCP Gateway middleware to the application pipeline.
/// </summary>
public static IApplicationBuilder UseMcpGateway(this IApplicationBuilder app)
{
return app.UseMiddleware<GatewayMiddleware>();
}
}

View File

@ -0,0 +1,110 @@
using Microsoft.Extensions.DependencyInjection;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Infrastructure.Routing;
using OpenHarbor.MCP.Gateway.Infrastructure.Connection;
using OpenHarbor.MCP.Gateway.Infrastructure.Security;
using OpenHarbor.MCP.Gateway.Infrastructure.Health;
namespace OpenHarbor.MCP.Gateway.AspNetCore.Extensions;
/// <summary>
/// Extension methods for configuring MCP Gateway services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds MCP Gateway services to the dependency injection container.
/// </summary>
public static IServiceCollection AddMcpGateway(
this IServiceCollection services,
Action<GatewayOptions>? configureOptions = null)
{
var options = new GatewayOptions();
configureOptions?.Invoke(options);
// Register core services
services.AddSingleton<IServerConnectionPool, ServerConnectionPool>();
// Register routing strategy based on options
services.AddSingleton<IRoutingStrategy>(sp =>
{
return options.RoutingStrategy switch
{
RoutingStrategyType.RoundRobin => new RoundRobinStrategy(),
RoutingStrategyType.ToolBased => new ToolBasedStrategy(options.ToolMappings ?? new Dictionary<string, string>()),
RoutingStrategyType.ClientBased => new ClientBasedStrategy(options.ClientMappings ?? new Dictionary<string, string>()),
_ => new RoundRobinStrategy()
};
});
// Register router
services.AddSingleton<IGatewayRouter, GatewayRouter>();
// Register authentication provider if API keys are configured
if (options.ApiKeys != null && options.ApiKeys.Count > 0)
{
services.AddSingleton<IAuthProvider>(new ApiKeyAuthProvider(options.ApiKeys));
}
// Register health checker if enabled
if (options.EnableHealthChecks)
{
services.AddSingleton<IHealthChecker>(sp =>
{
var pool = sp.GetRequiredService<IServerConnectionPool>();
return new ActiveHealthChecker(pool)
{
CheckInterval = options.HealthCheckInterval
};
});
}
return services;
}
}
/// <summary>
/// Configuration options for MCP Gateway.
/// </summary>
public class GatewayOptions
{
/// <summary>
/// Routing strategy to use. Default is RoundRobin.
/// </summary>
public RoutingStrategyType RoutingStrategy { get; set; } = RoutingStrategyType.RoundRobin;
/// <summary>
/// Tool name to server ID mappings for tool-based routing.
/// </summary>
public Dictionary<string, string>? ToolMappings { get; set; }
/// <summary>
/// Client ID to server ID mappings for client-based routing.
/// </summary>
public Dictionary<string, string>? ClientMappings { get; set; }
/// <summary>
/// API keys for client authentication (ClientId -> ApiKey).
/// </summary>
public Dictionary<string, string>? ApiKeys { get; set; }
/// <summary>
/// Enable active health checking. Default is true.
/// </summary>
public bool EnableHealthChecks { get; set; } = true;
/// <summary>
/// Health check interval. Default is 30 seconds.
/// </summary>
public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Routing strategy types.
/// </summary>
public enum RoutingStrategyType
{
RoundRobin,
ToolBased,
ClientBased
}

View File

@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Http;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
using System.Text.Json;
namespace OpenHarbor.MCP.Gateway.AspNetCore.Middleware;
/// <summary>
/// ASP.NET Core middleware for MCP Gateway request handling.
/// Intercepts requests to the gateway endpoint and routes them through the gateway router.
/// </summary>
public class GatewayMiddleware
{
private readonly RequestDelegate _next;
private readonly IGatewayRouter _router;
private const string GatewayPath = "/mcp/invoke";
public GatewayMiddleware(RequestDelegate next, IGatewayRouter router)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_router = router ?? throw new ArgumentNullException(nameof(router));
}
public async Task InvokeAsync(HttpContext context)
{
// Check if this is a gateway request
if (!context.Request.Path.StartsWithSegments(GatewayPath))
{
await _next(context);
return;
}
// Only handle POST requests
if (context.Request.Method != HttpMethods.Post)
{
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
return;
}
try
{
// Parse request body
GatewayRequest? gatewayRequest;
try
{
gatewayRequest = await JsonSerializer.DeserializeAsync<GatewayRequest>(
context.Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (gatewayRequest == null)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, new { error = "Invalid request body" });
return;
}
}
catch (JsonException)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, new { error = "Invalid JSON" });
return;
}
// Extract client ID from headers if present
if (context.Request.Headers.TryGetValue("X-Client-Id", out var clientId))
{
gatewayRequest.ClientId = clientId.ToString();
}
// Route through gateway
var response = await _router.RouteAsync(gatewayRequest, context.RequestAborted);
// Set response status code
context.Response.StatusCode = response.Success
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError;
// Write response
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, response);
}
catch (Exception ex)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, new
{
success = false,
error = ex.Message,
errorCode = "INTERNAL_ERROR"
});
}
}
}

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenHarbor.MCP.Gateway.Core\OpenHarbor.MCP.Gateway.Core.csproj" />
<ProjectReference Include="..\OpenHarbor.MCP.Gateway.Infrastructure\OpenHarbor.MCP.Gateway.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,134 @@
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
using OpenHarbor.MCP.Gateway.Infrastructure.Routing;
using OpenHarbor.MCP.Gateway.Infrastructure.Connection;
using OpenHarbor.MCP.Gateway.Infrastructure.Health;
namespace OpenHarbor.MCP.Gateway.Cli;
/// <summary>
/// OpenHarbor MCP Gateway CLI Tool
/// Provides command-line management for MCP Gateway instances.
/// </summary>
class Program
{
static async Task<int> Main(string[] args)
{
Console.WriteLine("=== OpenHarbor.MCP.Gateway CLI ===\n");
if (args.Length == 0)
{
ShowHelp();
return 0;
}
var command = args[0].ToLowerInvariant();
try
{
return command switch
{
"test" => await RunTestCommand(),
"health" => await RunHealthCommand(),
"help" or "--help" or "-h" => ShowHelp(),
_ => ShowUnknownCommand(command)
};
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
return 1;
}
}
static int ShowHelp()
{
Console.WriteLine("Usage: gateway-cli <command> [options]");
Console.WriteLine();
Console.WriteLine("Commands:");
Console.WriteLine(" test Run a test gateway instance");
Console.WriteLine(" health Check gateway component health");
Console.WriteLine(" help Show this help message");
Console.WriteLine();
return 0;
}
static int ShowUnknownCommand(string command)
{
Console.WriteLine($"Unknown command: {command}");
Console.WriteLine("Run 'gateway-cli help' for usage information.");
return 1;
}
static async Task<int> RunTestCommand()
{
Console.WriteLine("Running gateway test...\n");
// Create a test configuration
var pool = new ServerConnectionPool();
var strategy = new RoundRobinStrategy();
var router = new GatewayRouter(strategy, pool);
// Register a test server
var serverConfig = new ServerConfig
{
Id = "test-server",
Name = "CODEX MCP Server",
TransportType = "Http",
BaseUrl = "http://localhost:5050",
Enabled = true
};
Console.WriteLine($"Registering server: {serverConfig.Name} ({serverConfig.BaseUrl})");
await router.RegisterServerAsync(serverConfig);
// Get health status
var health = await router.GetServerHealthAsync();
Console.WriteLine($"\nRegistered servers: {health.Count()}");
foreach (var server in health)
{
Console.WriteLine($" - {server.ServerName}: {(server.IsHealthy ? "Healthy" : "Unhealthy")}");
}
Console.WriteLine("\n✓ Test completed successfully");
return 0;
}
static async Task<int> RunHealthCommand()
{
Console.WriteLine("Checking gateway component health...\n");
// Test routing
Console.WriteLine("Testing routing strategies...");
var roundRobin = new RoundRobinStrategy();
Console.WriteLine(" ✓ RoundRobinStrategy initialized");
var toolBased = new ToolBasedStrategy(new Dictionary<string, string>
{
["test_*"] = "server-1"
});
Console.WriteLine(" ✓ ToolBasedStrategy initialized");
var clientBased = new ClientBasedStrategy(new Dictionary<string, string>
{
["client-1"] = "server-1"
});
Console.WriteLine(" ✓ ClientBasedStrategy initialized");
// Test health tracking
Console.WriteLine("\nTesting health monitoring...");
var tracker = new PassiveHealthTracker();
tracker.RecordSuccess("server-1", TimeSpan.FromMilliseconds(50));
var healthData = tracker.GetServerHealth("server-1");
Console.WriteLine($" ✓ PassiveHealthTracker operational (server-1: {(healthData?.IsHealthy == true ? "Healthy" : "Unhealthy")})");
// Test circuit breaker
Console.WriteLine("\nTesting circuit breaker...");
var circuitBreaker = new CircuitBreaker();
await circuitBreaker.ExecuteAsync(async () => { await Task.CompletedTask; });
Console.WriteLine($" ✓ CircuitBreaker operational (State: {circuitBreaker.State})");
Console.WriteLine("\n✓ All components healthy");
return 0;
}
}

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\OpenHarbor.MCP.Gateway.Infrastructure\OpenHarbor.MCP.Gateway.Infrastructure.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,61 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Configuration;
/// <summary>
/// Main configuration for the MCP Gateway.
/// Contains all settings for servers, routing, and security.
/// </summary>
public class GatewayConfig
{
/// <summary>
/// List of registered MCP servers.
/// </summary>
public List<ServerConfig> Servers { get; set; } = new();
/// <summary>
/// Routing configuration.
/// </summary>
public RoutingConfig Routing { get; set; } = new();
/// <summary>
/// Security configuration.
/// </summary>
public SecurityConfig Security { get; set; } = new();
/// <summary>
/// Validates the gateway configuration.
/// </summary>
/// <returns>True if configuration is valid, false otherwise.</returns>
public bool Validate()
{
// Must have at least one server configured
if (Servers == null || Servers.Count == 0)
{
return false;
}
// Validate routing configuration
if (Routing == null || !Routing.Validate())
{
return false;
}
// Validate security configuration
if (Security == null || !Security.Validate())
{
return false;
}
// Validate all server configurations
foreach (var server in Servers)
{
if (string.IsNullOrEmpty(server.Id) || string.IsNullOrEmpty(server.Name))
{
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,66 @@
namespace OpenHarbor.MCP.Gateway.Core.Configuration;
/// <summary>
/// Configuration for request routing behavior.
/// </summary>
public class RoutingConfig
{
/// <summary>
/// Routing strategy to use: "RoundRobin", "ToolBased", or "ClientBased".
/// </summary>
public string Strategy { get; set; } = "RoundRobin";
/// <summary>
/// Mapping of tool name patterns to server IDs (for ToolBased strategy).
/// Pattern supports wildcards (*).
/// </summary>
public Dictionary<string, string>? ToolMapping { get; set; }
/// <summary>
/// Mapping of client IDs to server IDs (for ClientBased strategy).
/// </summary>
public Dictionary<string, string>? ClientMapping { get; set; }
/// <summary>
/// Whether to enable automatic retry on failure.
/// </summary>
public bool EnableRetry { get; set; } = false;
/// <summary>
/// Maximum number of retry attempts.
/// </summary>
public int MaxRetryAttempts { get; set; } = 3;
/// <summary>
/// Delay between retry attempts in milliseconds.
/// </summary>
public int RetryDelayMs { get; set; } = 1000;
/// <summary>
/// Validates the routing configuration.
/// </summary>
/// <returns>True if configuration is valid, false otherwise.</returns>
public bool Validate()
{
// Validate strategy is supported
var validStrategies = new[] { "RoundRobin", "ToolBased", "ClientBased" };
if (!validStrategies.Contains(Strategy))
{
return false;
}
// ToolBased strategy requires tool mapping
if (Strategy == "ToolBased" && (ToolMapping == null || ToolMapping.Count == 0))
{
return false;
}
// ClientBased strategy requires client mapping
if (Strategy == "ClientBased" && (ClientMapping == null || ClientMapping.Count == 0))
{
return false;
}
return true;
}
}

View File

@ -0,0 +1,101 @@
namespace OpenHarbor.MCP.Gateway.Core.Configuration;
/// <summary>
/// Configuration for security features (authentication, authorization, rate limiting).
/// </summary>
public class SecurityConfig
{
/// <summary>
/// Whether to enable authentication.
/// </summary>
public bool EnableAuthentication { get; set; } = false;
/// <summary>
/// Authentication scheme: "ApiKey" or "JWT".
/// </summary>
public string? AuthenticationScheme { get; set; }
/// <summary>
/// List of valid API keys (for ApiKey authentication).
/// </summary>
public List<string>? ApiKeys { get; set; }
/// <summary>
/// JWT secret key (for JWT authentication).
/// </summary>
public string? JwtSecret { get; set; }
/// <summary>
/// JWT issuer (for JWT authentication).
/// </summary>
public string? JwtIssuer { get; set; }
/// <summary>
/// JWT audience (for JWT authentication).
/// </summary>
public string? JwtAudience { get; set; }
/// <summary>
/// Whether to enable authorization.
/// </summary>
public bool EnableAuthorization { get; set; } = false;
/// <summary>
/// Client permissions mapping (client ID -> list of allowed operations).
/// </summary>
public Dictionary<string, List<string>>? ClientPermissions { get; set; }
/// <summary>
/// Whether to enable rate limiting.
/// </summary>
public bool EnableRateLimiting { get; set; } = false;
/// <summary>
/// Maximum requests per minute per client.
/// </summary>
public int RequestsPerMinute { get; set; } = 60;
/// <summary>
/// Burst size for rate limiting.
/// </summary>
public int BurstSize { get; set; } = 10;
/// <summary>
/// Validates the security configuration.
/// </summary>
/// <returns>True if configuration is valid, false otherwise.</returns>
public bool Validate()
{
// If authentication is disabled, configuration is valid
if (!EnableAuthentication)
{
return true;
}
// If authentication is enabled, must have a scheme
if (string.IsNullOrEmpty(AuthenticationScheme))
{
return false;
}
// Validate ApiKey scheme
if (AuthenticationScheme == "ApiKey")
{
if (ApiKeys == null || ApiKeys.Count == 0)
{
return false;
}
}
// Validate JWT scheme
if (AuthenticationScheme == "JWT")
{
if (string.IsNullOrEmpty(JwtSecret))
{
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,28 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Interfaces;
/// <summary>
/// Defines the contract for authentication and authorization providers.
/// Responsible for verifying client identity and permissions.
/// </summary>
public interface IAuthProvider
{
/// <summary>
/// Authenticates a client request.
/// Verifies that the client is who they claim to be.
/// </summary>
/// <param name="context">Authentication context containing client credentials.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Authentication result indicating success or failure.</returns>
Task<AuthenticationResult> AuthenticateAsync(AuthenticationContext context, CancellationToken cancellationToken = default);
/// <summary>
/// Authorizes a client request.
/// Verifies that the authenticated client has permission for the requested operation.
/// </summary>
/// <param name="context">Authorization context containing resource and action information.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Authorization result indicating whether access is granted.</returns>
Task<AuthorizationResult> AuthorizeAsync(AuthorizationContext context, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,30 @@
namespace OpenHarbor.MCP.Gateway.Core.Interfaces;
/// <summary>
/// Defines the contract for circuit breaker implementations.
/// Provides fault tolerance by preventing cascading failures.
/// </summary>
public interface ICircuitBreaker
{
/// <summary>
/// Executes an operation with circuit breaker protection.
/// If the circuit is open (too many failures), the operation is not executed.
/// </summary>
/// <typeparam name="T">Return type of the operation.</typeparam>
/// <param name="operation">The operation to execute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the operation.</returns>
/// <exception cref="InvalidOperationException">Thrown when the circuit is open.</exception>
Task<T> ExecuteAsync<T>(Func<Task<T>> operation, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current state of the circuit breaker.
/// </summary>
/// <returns>The current state ("Closed", "Open", or "HalfOpen").</returns>
string GetState();
/// <summary>
/// Manually resets the circuit breaker to the closed state.
/// </summary>
void Reset();
}

View File

@ -0,0 +1,40 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Interfaces;
/// <summary>
/// Defines the contract for the main gateway router.
/// Responsible for routing requests to appropriate MCP servers.
/// </summary>
public interface IGatewayRouter
{
/// <summary>
/// Routes a request to an appropriate MCP server based on the routing strategy.
/// </summary>
/// <param name="request">The gateway request to route.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The response from the selected server.</returns>
Task<GatewayResponse> RouteAsync(GatewayRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the health status of all registered servers.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of server health statuses.</returns>
Task<IEnumerable<ServerHealthStatus>> GetServerHealthAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Registers a new MCP server with the gateway.
/// </summary>
/// <param name="serverConfig">Configuration for the server to register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RegisterServerAsync(ServerConfig serverConfig, CancellationToken cancellationToken = default);
/// <summary>
/// Unregisters an MCP server from the gateway.
/// </summary>
/// <param name="serverId">ID of the server to unregister.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the server was unregistered, false if it wasn't found.</returns>
Task<bool> UnregisterServerAsync(string serverId, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,38 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Interfaces;
/// <summary>
/// Defines the contract for health checking servers.
/// Supports both active (periodic) and on-demand health checks.
/// </summary>
public interface IHealthChecker
{
/// <summary>
/// Performs a single health check on the specified server.
/// </summary>
/// <param name="serverConfig">Server configuration to check</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Health status of the server</returns>
Task<ServerHealthStatus> CheckHealthAsync(ServerConfig serverConfig, CancellationToken cancellationToken = default);
/// <summary>
/// Starts monitoring the specified servers with periodic health checks.
/// </summary>
/// <param name="serverConfigs">List of servers to monitor</param>
/// <param name="cancellationToken">Cancellation token</param>
Task StartMonitoringAsync(IEnumerable<ServerConfig> serverConfigs, CancellationToken cancellationToken = default);
/// <summary>
/// Stops all active health monitoring.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
Task StopMonitoringAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current health status of all monitored servers.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Collection of server health statuses</returns>
Task<IEnumerable<ServerHealthStatus>> GetCurrentHealthAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,22 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Interfaces;
/// <summary>
/// Defines the contract for server selection strategies.
/// Implementations determine which server should handle a request.
/// </summary>
public interface IRoutingStrategy
{
/// <summary>
/// Selects the best server to handle the request based on the strategy's logic.
/// </summary>
/// <param name="availableServers">Collection of available servers to choose from.</param>
/// <param name="context">Context information for making the routing decision.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The selected server, or null if no suitable server is available.</returns>
Task<ServerInfo?> SelectServerAsync(
IEnumerable<ServerInfo> availableServers,
RoutingContext context,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,34 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Interfaces;
/// <summary>
/// Defines the contract for a server connection.
/// </summary>
public interface IServerConnection : IDisposable
{
/// <summary>
/// Indicates whether the connection is currently active.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Gets server information including health status.
/// </summary>
ServerInfo ServerInfo { get; }
/// <summary>
/// Establishes connection to the server.
/// </summary>
Task ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Sends a request to the server with timeout handling.
/// </summary>
Task<GatewayResponse> SendRequestAsync(GatewayRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Closes the connection to the server.
/// </summary>
Task DisconnectAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,24 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Interfaces;
/// <summary>
/// Defines the contract for connection pool management.
/// </summary>
public interface IServerConnectionPool
{
/// <summary>
/// Gets or creates a connection for the specified server.
/// </summary>
Task<IServerConnection> GetConnectionAsync(ServerConfig serverConfig, CancellationToken cancellationToken = default);
/// <summary>
/// Releases a connection back to the pool.
/// </summary>
Task ReleaseConnectionAsync(IServerConnection connection, CancellationToken cancellationToken = default);
/// <summary>
/// Evicts idle connections that have exceeded the idle timeout.
/// </summary>
Task EvictIdleConnectionsAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,35 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Interfaces;
/// <summary>
/// Defines the contract for server transport implementations.
/// Handles low-level communication with MCP servers.
/// </summary>
public interface IServerTransport : IDisposable
{
/// <summary>
/// Indicates whether the transport is currently connected.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Establishes a connection to the server.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Sends a request to the server and waits for the response.
/// </summary>
/// <param name="request">The request to send.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The response from the server.</returns>
Task<GatewayResponse> SendRequestAsync(GatewayRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Closes the connection to the server.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task DisconnectAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,22 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Context for authentication requests.
/// </summary>
public class AuthenticationContext
{
/// <summary>
/// Client identifier attempting to authenticate.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Credentials provided (e.g., API key, JWT token).
/// </summary>
public string? Credentials { get; set; }
/// <summary>
/// Additional metadata for authentication.
/// </summary>
public Dictionary<string, object>? Metadata { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Result of an authentication attempt.
/// </summary>
public class AuthenticationResult
{
/// <summary>
/// Indicates whether authentication was successful.
/// </summary>
public bool IsAuthenticated { get; set; }
/// <summary>
/// Authenticated client identifier.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Error message if authentication failed.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Additional claims or attributes.
/// </summary>
public Dictionary<string, object>? Claims { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Context for authorization requests.
/// </summary>
public class AuthorizationContext
{
/// <summary>
/// Client identifier requesting authorization.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Resource being accessed (e.g., tool name, server ID).
/// </summary>
public string? Resource { get; set; }
/// <summary>
/// Action being performed (e.g., invoke, read, write).
/// </summary>
public string? Action { get; set; }
/// <summary>
/// Additional metadata for authorization.
/// </summary>
public Dictionary<string, object>? Metadata { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Result of an authorization check.
/// </summary>
public class AuthorizationResult
{
/// <summary>
/// Indicates whether the request is authorized.
/// </summary>
public bool IsAuthorized { get; set; }
/// <summary>
/// Error message if authorization failed.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Permitted actions or scope.
/// </summary>
public string[]? PermittedActions { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Represents the state of a circuit breaker.
/// </summary>
public enum CircuitBreakerState
{
/// <summary>
/// Circuit is closed, requests flow normally.
/// </summary>
Closed,
/// <summary>
/// Circuit is open, requests are blocked.
/// </summary>
Open,
/// <summary>
/// Circuit is testing if the service has recovered.
/// </summary>
HalfOpen
}

View File

@ -0,0 +1,28 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Represents a request routed through the gateway.
/// This is the gateway's internal representation, distinct from the underlying MCP protocol.
/// </summary>
public class GatewayRequest
{
/// <summary>
/// Name of the tool being invoked.
/// </summary>
public string? ToolName { get; set; }
/// <summary>
/// Arguments for the tool invocation.
/// </summary>
public Dictionary<string, object>? Arguments { get; set; }
/// <summary>
/// Client identifier making the request.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Metadata for routing and tracking.
/// </summary>
public Dictionary<string, object>? Metadata { get; set; }
}

View File

@ -0,0 +1,38 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Represents a response from the gateway after routing to a server.
/// This is the gateway's internal representation, distinct from the underlying MCP protocol.
/// </summary>
public class GatewayResponse
{
/// <summary>
/// Indicates whether the request was successful.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Result data from the successful request.
/// </summary>
public Dictionary<string, object>? Result { get; set; }
/// <summary>
/// Error message if the request failed.
/// </summary>
public string? Error { get; set; }
/// <summary>
/// Error code if the request failed.
/// </summary>
public string? ErrorCode { get; set; }
/// <summary>
/// Server that handled the request.
/// </summary>
public string? ServerId { get; set; }
/// <summary>
/// Metadata about the routing and execution.
/// </summary>
public Dictionary<string, object>? Metadata { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Context information for routing decisions.
/// </summary>
public class RoutingContext
{
/// <summary>
/// Name of the MCP tool being called.
/// </summary>
public string? ToolName { get; set; }
/// <summary>
/// Identifier of the client making the request.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// HTTP headers from the client request.
/// </summary>
public Dictionary<string, string>? Headers { get; set; }
/// <summary>
/// Additional metadata for routing decisions.
/// </summary>
public Dictionary<string, object>? Metadata { get; set; }
}

View File

@ -0,0 +1,47 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Configuration for an MCP server registration.
/// </summary>
public class ServerConfig
{
/// <summary>
/// Unique identifier for the server.
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Human-readable name of the server.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Transport type: "Stdio" or "Http".
/// </summary>
public string TransportType { get; set; } = "Stdio";
/// <summary>
/// Command to execute for Stdio transport.
/// </summary>
public string? Command { get; set; }
/// <summary>
/// Arguments for the command (Stdio transport).
/// </summary>
public string[]? Args { get; set; }
/// <summary>
/// Base URL for Http transport.
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// Whether the server is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Optional metadata for server-specific configuration.
/// </summary>
public Dictionary<string, string>? Metadata { get; set; }
}

View File

@ -0,0 +1,37 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Represents the health status of an MCP server.
/// </summary>
public class ServerHealthStatus
{
/// <summary>
/// Unique identifier of the server.
/// </summary>
public string ServerId { get; set; } = string.Empty;
/// <summary>
/// Human-readable name of the server.
/// </summary>
public string ServerName { get; set; } = string.Empty;
/// <summary>
/// Indicates whether the server is healthy.
/// </summary>
public bool IsHealthy { get; set; }
/// <summary>
/// Timestamp of the last health check.
/// </summary>
public DateTime LastCheck { get; set; }
/// <summary>
/// Response time from the health check.
/// </summary>
public TimeSpan? ResponseTime { get; set; }
/// <summary>
/// Error message if the server is unhealthy.
/// </summary>
public string? ErrorMessage { get; set; }
}

View File

@ -0,0 +1,37 @@
namespace OpenHarbor.MCP.Gateway.Core.Models;
/// <summary>
/// Represents metadata about a registered MCP server.
/// </summary>
public class ServerInfo
{
/// <summary>
/// Unique identifier for the server.
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Human-readable name of the server.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Indicates whether the server is currently healthy and available.
/// </summary>
public bool IsHealthy { get; set; }
/// <summary>
/// Timestamp of the last health check.
/// </summary>
public DateTime? LastHealthCheck { get; set; }
/// <summary>
/// Response time from the last health check.
/// </summary>
public TimeSpan? ResponseTime { get; set; }
/// <summary>
/// Optional metadata for server-specific information.
/// </summary>
public Dictionary<string, string>? Metadata { get; set; }
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,22 @@
namespace OpenHarbor.MCP.Gateway.Infrastructure.Connection;
/// <summary>
/// Statistics about the connection pool.
/// </summary>
public class PoolStats
{
/// <summary>
/// Total number of connections in the pool.
/// </summary>
public int TotalConnections { get; set; }
/// <summary>
/// Number of connections currently in use.
/// </summary>
public int ActiveConnections { get; set; }
/// <summary>
/// Number of idle connections available for reuse.
/// </summary>
public int IdleConnections { get; set; }
}

View File

@ -0,0 +1,109 @@
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Connection;
/// <summary>
/// Represents a connection to an MCP server.
/// Manages lifecycle, request handling, and health tracking.
/// </summary>
public class ServerConnection : IServerConnection
{
private readonly ServerConfig _config;
private readonly IServerTransport _transport;
private DateTime? _lastRequestTime;
private DateTime? _lastHealthCheck;
private TimeSpan? _lastResponseTime;
/// <summary>
/// Timeout for requests. Default is 30 seconds.
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Indicates whether the connection is currently active.
/// </summary>
public bool IsConnected => _transport.IsConnected;
/// <summary>
/// Gets server information including health status.
/// </summary>
public ServerInfo ServerInfo
{
get
{
return new ServerInfo
{
Id = _config.Id,
Name = _config.Name,
IsHealthy = IsConnected,
LastHealthCheck = _lastHealthCheck,
ResponseTime = _lastResponseTime,
Metadata = _config.Metadata
};
}
}
public ServerConnection(ServerConfig config, IServerTransport transport)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
}
/// <summary>
/// Establishes connection to the server.
/// </summary>
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
await _transport.ConnectAsync(cancellationToken);
_lastHealthCheck = DateTime.UtcNow;
}
/// <summary>
/// Sends a request to the server with timeout handling.
/// </summary>
public async Task<GatewayResponse> SendRequestAsync(GatewayRequest request, CancellationToken cancellationToken = default)
{
var startTime = DateTime.UtcNow;
_lastRequestTime = startTime;
try
{
// Create timeout cancellation token
using var timeoutCts = new CancellationTokenSource(RequestTimeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var response = await _transport.SendRequestAsync(request, linkedCts.Token);
// Track response time
_lastResponseTime = DateTime.UtcNow - startTime;
_lastHealthCheck = DateTime.UtcNow;
return response;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// User-initiated cancellation
throw;
}
catch (OperationCanceledException)
{
// Timeout occurred
_lastResponseTime = null;
throw new OperationCanceledException($"Request to server '{_config.Name}' timed out after {RequestTimeout.TotalSeconds} seconds");
}
}
/// <summary>
/// Closes the connection to the server.
/// </summary>
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
await _transport.DisconnectAsync(cancellationToken);
}
public void Dispose()
{
_transport?.Dispose();
}
}

View File

@ -0,0 +1,171 @@
using System.Collections.Concurrent;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
using OpenHarbor.MCP.Gateway.Infrastructure.Transport;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Connection;
/// <summary>
/// Connection pool for managing reusable connections to MCP servers.
/// Provides connection pooling, idle connection eviction, and connection limits.
/// </summary>
public class ServerConnectionPool : IServerConnectionPool, IDisposable
{
private readonly ConcurrentDictionary<string, PooledConnection> _connections = new();
private readonly SemaphoreSlim _semaphore;
private bool _disposed;
/// <summary>
/// Maximum connections per server. Default is 10.
/// </summary>
public int MaxConnectionsPerServer { get; set; } = 10;
/// <summary>
/// Idle timeout before connections are evicted. Default is 5 minutes.
/// </summary>
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(5);
public ServerConnectionPool()
{
_semaphore = new SemaphoreSlim(MaxConnectionsPerServer, MaxConnectionsPerServer);
}
/// <summary>
/// Gets or creates a connection for the specified server.
/// </summary>
public async Task<IServerConnection> GetConnectionAsync(ServerConfig serverConfig, CancellationToken cancellationToken = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(ServerConnectionPool));
}
// Get or create pooled connection for this server
var pooledConnection = _connections.GetOrAdd(serverConfig.Id, _ => CreatePooledConnection(serverConfig));
// Mark as active
pooledConnection.LastUsed = DateTime.UtcNow;
pooledConnection.IsActive = true;
return pooledConnection.Connection;
}
/// <summary>
/// Releases a connection back to the pool.
/// </summary>
public Task ReleaseConnectionAsync(IServerConnection connection, CancellationToken cancellationToken = default)
{
if (_disposed)
{
return Task.CompletedTask;
}
// Find the pooled connection
var serverId = connection.ServerInfo.Id;
if (_connections.TryGetValue(serverId, out var pooledConnection))
{
pooledConnection.IsActive = false;
pooledConnection.LastUsed = DateTime.UtcNow;
}
return Task.CompletedTask;
}
/// <summary>
/// Evicts idle connections that have exceeded the idle timeout.
/// </summary>
public Task EvictIdleConnectionsAsync(CancellationToken cancellationToken = default)
{
if (_disposed)
{
return Task.CompletedTask;
}
var now = DateTime.UtcNow;
var keysToRemove = _connections
.Where(kvp => !kvp.Value.IsActive && (now - kvp.Value.LastUsed) > IdleTimeout)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
{
if (_connections.TryRemove(key, out var pooledConnection))
{
pooledConnection.Connection.Dispose();
}
}
return Task.CompletedTask;
}
/// <summary>
/// Gets statistics about the connection pool.
/// </summary>
public PoolStats GetPoolStats()
{
if (_disposed)
{
return new PoolStats();
}
var total = _connections.Count;
var active = _connections.Count(kvp => kvp.Value.IsActive);
var idle = total - active;
return new PoolStats
{
TotalConnections = total,
ActiveConnections = active,
IdleConnections = idle
};
}
private PooledConnection CreatePooledConnection(ServerConfig serverConfig)
{
// Create transport based on config
var transport = CreateTransport(serverConfig);
var connection = new ServerConnection(serverConfig, transport);
return new PooledConnection
{
Connection = connection,
IsActive = false,
LastUsed = DateTime.UtcNow
};
}
private IServerTransport CreateTransport(ServerConfig serverConfig)
{
return serverConfig.TransportType.ToLowerInvariant() switch
{
"stdio" => new StdioServerTransport(serverConfig.Command!, serverConfig.Args ?? Array.Empty<string>()),
"http" => new HttpServerTransport(serverConfig.BaseUrl!),
_ => throw new NotSupportedException($"Transport type '{serverConfig.TransportType}' is not supported")
};
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var pooledConnection in _connections.Values)
{
pooledConnection.Connection.Dispose();
}
_connections.Clear();
_semaphore?.Dispose();
}
private class PooledConnection
{
public ServerConnection Connection { get; set; } = null!;
public bool IsActive { get; set; }
public DateTime LastUsed { get; set; }
}
}

View File

@ -0,0 +1,188 @@
using System.Collections.Concurrent;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Health;
/// <summary>
/// Active health checker that performs periodic health checks on MCP servers.
/// Uses connection pool to verify server availability and track health status.
/// </summary>
public class ActiveHealthChecker : IHealthChecker, IDisposable
{
private readonly IServerConnectionPool _connectionPool;
private readonly ConcurrentDictionary<string, ServerHealthStatus> _healthStatus = new();
private CancellationTokenSource? _monitoringCts;
private Task? _monitoringTask;
private IEnumerable<ServerConfig>? _monitoredServers;
private bool _disposed;
/// <summary>
/// Interval between health checks. Default is 30 seconds.
/// </summary>
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(30);
public ActiveHealthChecker(IServerConnectionPool connectionPool)
{
_connectionPool = connectionPool ?? throw new ArgumentNullException(nameof(connectionPool));
}
/// <summary>
/// Performs a single health check on the specified server.
/// </summary>
public async Task<ServerHealthStatus> CheckHealthAsync(ServerConfig serverConfig, CancellationToken cancellationToken = default)
{
if (serverConfig == null)
{
throw new ArgumentNullException(nameof(serverConfig));
}
var status = new ServerHealthStatus
{
ServerId = serverConfig.Id,
ServerName = serverConfig.Name,
LastCheck = DateTime.UtcNow
};
try
{
// Try to get a connection to the server
var connection = await _connectionPool.GetConnectionAsync(serverConfig, cancellationToken);
// Check if connected
if (!connection.IsConnected)
{
await connection.ConnectAsync(cancellationToken);
}
// Update status from connection info
var serverInfo = connection.ServerInfo;
status.IsHealthy = serverInfo.IsHealthy && connection.IsConnected;
status.ResponseTime = serverInfo.ResponseTime;
// Release connection back to pool
await _connectionPool.ReleaseConnectionAsync(connection, cancellationToken);
}
catch (Exception ex)
{
status.IsHealthy = false;
status.ErrorMessage = ex.Message;
}
// Update stored status
_healthStatus[serverConfig.Id] = status;
return status;
}
/// <summary>
/// Starts monitoring the specified servers with periodic health checks.
/// </summary>
public Task StartMonitoringAsync(IEnumerable<ServerConfig> serverConfigs, CancellationToken cancellationToken = default)
{
if (_monitoringTask != null)
{
// Already monitoring, stop first
StopMonitoringAsync(cancellationToken).GetAwaiter().GetResult();
}
_monitoredServers = serverConfigs;
_monitoringCts = new CancellationTokenSource();
// Make a local copy of the servers to avoid race conditions
var serversToMonitor = serverConfigs.ToList();
_monitoringTask = Task.Run(async () =>
{
while (!_monitoringCts.Token.IsCancellationRequested)
{
// Perform health checks on all servers
var checkTasks = serversToMonitor.Select(config =>
CheckHealthAsync(config, _monitoringCts.Token));
try
{
await Task.WhenAll(checkTasks);
}
catch (OperationCanceledException)
{
// Monitoring cancelled, exit
break;
}
catch
{
// Continue monitoring even if some checks fail
}
// Wait for next check interval
try
{
await Task.Delay(CheckInterval, _monitoringCts.Token);
}
catch (OperationCanceledException)
{
// Monitoring cancelled, exit
break;
}
}
}, _monitoringCts.Token);
return Task.CompletedTask;
}
/// <summary>
/// Stops all active health monitoring.
/// </summary>
public async Task StopMonitoringAsync(CancellationToken cancellationToken = default)
{
var cts = _monitoringCts;
var task = _monitoringTask;
if (cts != null)
{
cts.Cancel();
}
if (task != null)
{
try
{
await task;
}
catch (OperationCanceledException)
{
// Expected when monitoring is cancelled
}
_monitoringTask = null;
}
if (cts != null)
{
cts.Dispose();
_monitoringCts = null;
}
}
/// <summary>
/// Gets the current health status of all monitored servers.
/// </summary>
public Task<IEnumerable<ServerHealthStatus>> GetCurrentHealthAsync(CancellationToken cancellationToken = default)
{
var statuses = _healthStatus.Values.ToList();
return Task.FromResult<IEnumerable<ServerHealthStatus>>(statuses);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
StopMonitoringAsync().GetAwaiter().GetResult();
_monitoringCts?.Dispose();
}
}

View File

@ -0,0 +1,160 @@
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Health;
/// <summary>
/// Circuit breaker implementation for fault tolerance.
/// Prevents cascading failures by temporarily blocking requests after too many failures.
/// </summary>
public class CircuitBreaker
{
private readonly object _lock = new object();
private CircuitBreakerState _state = CircuitBreakerState.Closed;
private int _failureCount = 0;
private int _successCount = 0;
private DateTime? _openedAt;
/// <summary>
/// Number of consecutive failures before opening the circuit. Default is 5.
/// </summary>
public int FailureThreshold { get; set; } = 5;
/// <summary>
/// Number of consecutive successes in half-open state before closing the circuit. Default is 3.
/// </summary>
public int SuccessThreshold { get; set; } = 3;
/// <summary>
/// Duration to wait before attempting to close the circuit. Default is 60 seconds.
/// </summary>
public TimeSpan OpenTimeout { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Current state of the circuit breaker.
/// </summary>
public CircuitBreakerState State
{
get
{
lock (_lock)
{
return _state;
}
}
}
/// <summary>
/// Executes an operation through the circuit breaker.
/// </summary>
public async Task ExecuteAsync(Func<Task> operation)
{
await ExecuteAsync(async () =>
{
await operation();
return (object?)null;
});
}
/// <summary>
/// Executes an operation with a return value through the circuit breaker.
/// </summary>
public async Task<T> ExecuteAsync<T>(Func<Task<T>> operation)
{
lock (_lock)
{
// Check if we should transition from open to half-open
if (_state == CircuitBreakerState.Open && _openedAt.HasValue)
{
if (DateTime.UtcNow - _openedAt.Value >= OpenTimeout)
{
_state = CircuitBreakerState.HalfOpen;
_successCount = 0;
_failureCount = 0;
}
}
// If still open, reject the request
if (_state == CircuitBreakerState.Open)
{
throw new CircuitBreakerOpenException("Circuit breaker is open. Service is temporarily unavailable.");
}
}
try
{
// Execute the operation
var result = await operation();
// Operation succeeded
lock (_lock)
{
OnSuccess();
}
return result;
}
catch (Exception)
{
// Operation failed
lock (_lock)
{
OnFailure();
}
throw;
}
}
/// <summary>
/// Resets the circuit breaker to closed state.
/// </summary>
public void Reset()
{
lock (_lock)
{
_state = CircuitBreakerState.Closed;
_failureCount = 0;
_successCount = 0;
_openedAt = null;
}
}
private void OnSuccess()
{
_failureCount = 0;
if (_state == CircuitBreakerState.HalfOpen)
{
_successCount++;
if (_successCount >= SuccessThreshold)
{
_state = CircuitBreakerState.Closed;
_successCount = 0;
}
}
else if (_state == CircuitBreakerState.Closed)
{
// Already closed, just reset success count
_successCount = 0;
}
}
private void OnFailure()
{
_successCount = 0;
_failureCount++;
if (_state == CircuitBreakerState.HalfOpen)
{
// Any failure in half-open state reopens the circuit
_state = CircuitBreakerState.Open;
_openedAt = DateTime.UtcNow;
}
else if (_failureCount >= FailureThreshold)
{
_state = CircuitBreakerState.Open;
_openedAt = DateTime.UtcNow;
}
}
}

View File

@ -0,0 +1,22 @@
namespace OpenHarbor.MCP.Gateway.Infrastructure.Health;
/// <summary>
/// Exception thrown when a circuit breaker is open and prevents execution.
/// </summary>
public class CircuitBreakerOpenException : Exception
{
public CircuitBreakerOpenException()
: base("Circuit breaker is open. The operation cannot be executed.")
{
}
public CircuitBreakerOpenException(string message)
: base(message)
{
}
public CircuitBreakerOpenException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@ -0,0 +1,134 @@
using System.Collections.Concurrent;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Health;
/// <summary>
/// Passive health tracker that monitors server health based on actual request patterns.
/// Tracks success/failure rates and response times without active probing.
/// </summary>
public class PassiveHealthTracker
{
private readonly ConcurrentDictionary<string, ServerHealthData> _healthData = new();
/// <summary>
/// Number of consecutive failures before marking server as unhealthy. Default is 5.
/// </summary>
public int UnhealthyThreshold { get; set; } = 5;
/// <summary>
/// Number of consecutive successes before marking server as healthy again. Default is 3.
/// </summary>
public int HealthyThreshold { get; set; } = 3;
/// <summary>
/// Response time threshold for marking requests as slow. Default is 5 seconds.
/// </summary>
public TimeSpan SlowResponseThreshold { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Records a successful request to a server.
/// </summary>
public void RecordSuccess(string serverId, TimeSpan responseTime)
{
var data = _healthData.GetOrAdd(serverId, _ => new ServerHealthData { ServerId = serverId });
lock (data)
{
data.ConsecutiveSuccesses++;
data.ConsecutiveFailures = 0;
data.LastResponseTime = responseTime;
data.LastCheck = DateTime.UtcNow;
data.LastErrorMessage = null;
// Add response time to rolling average
data.ResponseTimes.Enqueue(responseTime);
if (data.ResponseTimes.Count > 10) // Keep last 10 response times
{
data.ResponseTimes.Dequeue();
}
// Update health status based on thresholds
if (data.ConsecutiveSuccesses >= HealthyThreshold)
{
data.IsHealthy = true;
}
}
}
/// <summary>
/// Records a failed request to a server.
/// </summary>
public void RecordFailure(string serverId, string errorMessage)
{
var data = _healthData.GetOrAdd(serverId, _ => new ServerHealthData { ServerId = serverId });
lock (data)
{
data.ConsecutiveFailures++;
data.ConsecutiveSuccesses = 0;
data.LastCheck = DateTime.UtcNow;
data.LastErrorMessage = errorMessage;
// Update health status based on thresholds
if (data.ConsecutiveFailures >= UnhealthyThreshold)
{
data.IsHealthy = false;
}
}
}
/// <summary>
/// Gets the current health status for a specific server.
/// </summary>
public ServerHealthStatus? GetServerHealth(string serverId)
{
if (!_healthData.TryGetValue(serverId, out var data))
{
return null;
}
lock (data)
{
return new ServerHealthStatus
{
ServerId = data.ServerId,
ServerName = serverId, // Default to ID if name not set
IsHealthy = data.IsHealthy,
LastCheck = data.LastCheck,
ResponseTime = data.ResponseTimes.Any()
? TimeSpan.FromMilliseconds(data.ResponseTimes.Average(t => t.TotalMilliseconds))
: data.LastResponseTime,
ErrorMessage = data.LastErrorMessage
};
}
}
/// <summary>
/// Gets health status for all tracked servers.
/// </summary>
public IEnumerable<ServerHealthStatus> GetAllServerHealth()
{
return _healthData.Keys.Select(serverId => GetServerHealth(serverId)).Where(h => h != null).Cast<ServerHealthStatus>();
}
/// <summary>
/// Resets all tracked health data.
/// </summary>
public void Reset()
{
_healthData.Clear();
}
private class ServerHealthData
{
public string ServerId { get; set; } = string.Empty;
public bool IsHealthy { get; set; } = true; // Start as healthy
public int ConsecutiveSuccesses { get; set; }
public int ConsecutiveFailures { get; set; }
public DateTime LastCheck { get; set; }
public TimeSpan? LastResponseTime { get; set; }
public string? LastErrorMessage { get; set; }
public Queue<TimeSpan> ResponseTimes { get; set; } = new Queue<TimeSpan>();
}
}

View File

@ -0,0 +1,48 @@
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Routing;
/// <summary>
/// Client-based routing strategy.
/// Routes requests based on client ID to provide sticky sessions.
/// Falls back to round-robin if client is not mapped.
/// </summary>
public class ClientBasedStrategy : IRoutingStrategy
{
private readonly Dictionary<string, string> _clientMapping;
private readonly RoundRobinStrategy _fallbackStrategy;
public ClientBasedStrategy(Dictionary<string, string> clientMapping)
{
_clientMapping = clientMapping ?? new Dictionary<string, string>();
_fallbackStrategy = new RoundRobinStrategy();
}
public Task<ServerInfo?> SelectServerAsync(
IEnumerable<ServerInfo> availableServers,
RoutingContext context,
CancellationToken cancellationToken = default)
{
var healthyServers = availableServers.Where(s => s.IsHealthy).ToList();
if (healthyServers.Count == 0)
{
return Task.FromResult<ServerInfo?>(null);
}
// Try to match client ID to mapping
if (!string.IsNullOrEmpty(context.ClientId) && _clientMapping.TryGetValue(context.ClientId, out var serverId))
{
// Find the mapped server
var mappedServer = healthyServers.FirstOrDefault(s => s.Id == serverId);
if (mappedServer != null)
{
return Task.FromResult<ServerInfo?>(mappedServer);
}
}
// Fall back to round-robin
return _fallbackStrategy.SelectServerAsync(healthyServers, context, cancellationToken);
}
}

View File

@ -0,0 +1,138 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
using OpenHarbor.MCP.Gateway.Infrastructure.Connection;
using OpenHarbor.MCP.Gateway.Infrastructure.Health;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Routing;
/// <summary>
/// Main gateway router implementation.
/// Manages server registration and routes requests using configured strategies.
/// </summary>
public class GatewayRouter : IGatewayRouter
{
private readonly IRoutingStrategy _routingStrategy;
private readonly IServerConnectionPool _connectionPool;
private readonly ConcurrentDictionary<string, ServerConfig> _registeredServers = new();
private readonly PassiveHealthTracker _healthTracker = new();
public GatewayRouter(IRoutingStrategy routingStrategy, IServerConnectionPool connectionPool)
{
_routingStrategy = routingStrategy ?? throw new ArgumentNullException(nameof(routingStrategy));
_connectionPool = connectionPool ?? throw new ArgumentNullException(nameof(connectionPool));
}
public Task RegisterServerAsync(ServerConfig serverConfig, CancellationToken cancellationToken = default)
{
if (serverConfig == null)
{
throw new ArgumentNullException(nameof(serverConfig));
}
_registeredServers[serverConfig.Id] = serverConfig;
return Task.CompletedTask;
}
public Task<bool> UnregisterServerAsync(string serverId, CancellationToken cancellationToken = default)
{
var removed = _registeredServers.TryRemove(serverId, out _);
return Task.FromResult(removed);
}
public async Task<GatewayResponse> RouteAsync(GatewayRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
{
throw new ArgumentNullException(nameof(request));
}
// Get available servers
var availableServers = _registeredServers.Values
.Select(config => new ServerInfo
{
Id = config.Id,
Name = config.Name,
IsHealthy = config.Enabled, // Assume enabled means healthy for now
Metadata = config.Metadata
})
.ToList();
// Create routing context
var routingContext = new RoutingContext
{
ToolName = request.ToolName,
ClientId = request.ClientId,
Metadata = request.Metadata
};
// Select server using strategy
var selectedServer = await _routingStrategy.SelectServerAsync(availableServers, routingContext, cancellationToken);
if (selectedServer == null)
{
return new GatewayResponse
{
Success = false,
Error = "No healthy servers available to handle the request",
ErrorCode = "NO_HEALTHY_SERVERS"
};
}
// Get connection and forward request
var stopwatch = Stopwatch.StartNew();
try
{
var serverConfig = _registeredServers[selectedServer.Id];
var connection = await _connectionPool.GetConnectionAsync(serverConfig, cancellationToken);
// Connect if not already connected
if (!connection.IsConnected)
{
await connection.ConnectAsync(cancellationToken);
}
var response = await connection.SendRequestAsync(request, cancellationToken);
// Set server ID in response
response.ServerId = selectedServer.Id;
// Track successful request in passive health tracker
stopwatch.Stop();
_healthTracker.RecordSuccess(selectedServer.Id, stopwatch.Elapsed);
// Release connection back to pool
await _connectionPool.ReleaseConnectionAsync(connection, cancellationToken);
return response;
}
catch (Exception ex)
{
// Track failed request in passive health tracker
stopwatch.Stop();
_healthTracker.RecordFailure(selectedServer.Id, ex.Message);
return new GatewayResponse
{
Success = false,
Error = $"Failed to route request: {ex.Message}",
ErrorCode = "ROUTING_ERROR",
ServerId = selectedServer.Id
};
}
}
public Task<IEnumerable<ServerHealthStatus>> GetServerHealthAsync(CancellationToken cancellationToken = default)
{
var healthStatuses = _registeredServers.Values.Select(config => new ServerHealthStatus
{
ServerId = config.Id,
ServerName = config.Name,
IsHealthy = config.Enabled,
LastCheck = DateTime.UtcNow
});
return Task.FromResult<IEnumerable<ServerHealthStatus>>(healthStatuses.ToList());
}
}

View File

@ -0,0 +1,34 @@
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Routing;
/// <summary>
/// Round-robin routing strategy.
/// Distributes requests evenly across all healthy servers in rotation.
/// </summary>
public class RoundRobinStrategy : IRoutingStrategy
{
private int _currentIndex = 0;
private readonly object _lock = new object();
public Task<ServerInfo?> SelectServerAsync(
IEnumerable<ServerInfo> availableServers,
RoutingContext context,
CancellationToken cancellationToken = default)
{
var healthyServers = availableServers.Where(s => s.IsHealthy).ToList();
if (healthyServers.Count == 0)
{
return Task.FromResult<ServerInfo?>(null);
}
lock (_lock)
{
var selected = healthyServers[_currentIndex % healthyServers.Count];
_currentIndex++;
return Task.FromResult<ServerInfo?>(selected);
}
}
}

View File

@ -0,0 +1,63 @@
using System.Text.RegularExpressions;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Routing;
/// <summary>
/// Tool-based routing strategy.
/// Routes requests based on tool name patterns with wildcard support.
/// Falls back to round-robin if no pattern matches.
/// </summary>
public class ToolBasedStrategy : IRoutingStrategy
{
private readonly Dictionary<string, string> _toolMapping;
private readonly RoundRobinStrategy _fallbackStrategy;
public ToolBasedStrategy(Dictionary<string, string> toolMapping)
{
_toolMapping = toolMapping ?? new Dictionary<string, string>();
_fallbackStrategy = new RoundRobinStrategy();
}
public Task<ServerInfo?> SelectServerAsync(
IEnumerable<ServerInfo> availableServers,
RoutingContext context,
CancellationToken cancellationToken = default)
{
var healthyServers = availableServers.Where(s => s.IsHealthy).ToList();
if (healthyServers.Count == 0)
{
return Task.FromResult<ServerInfo?>(null);
}
// Try to match tool name to mapping
if (!string.IsNullOrEmpty(context.ToolName))
{
foreach (var mapping in _toolMapping)
{
if (MatchesPattern(context.ToolName, mapping.Key))
{
// Find the mapped server
var mappedServer = healthyServers.FirstOrDefault(s => s.Id == mapping.Value);
if (mappedServer != null)
{
return Task.FromResult<ServerInfo?>(mappedServer);
}
}
}
}
// Fall back to round-robin
return _fallbackStrategy.SelectServerAsync(healthyServers, context, cancellationToken);
}
private bool MatchesPattern(string toolName, string pattern)
{
// Convert wildcard pattern to regex
// * matches any characters
var regexPattern = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$";
return Regex.IsMatch(toolName, regexPattern);
}
}

View File

@ -0,0 +1,104 @@
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Security;
/// <summary>
/// API Key-based authentication provider.
/// Validates clients using pre-configured API keys.
/// </summary>
public class ApiKeyAuthProvider : IAuthProvider
{
private readonly Dictionary<string, string> _validKeys;
/// <summary>
/// Initializes a new instance with valid API keys.
/// </summary>
/// <param name="validKeys">Dictionary mapping client IDs to their API keys</param>
public ApiKeyAuthProvider(Dictionary<string, string> validKeys)
{
_validKeys = validKeys ?? throw new ArgumentNullException(nameof(validKeys));
}
/// <summary>
/// Authenticates a client using their API key.
/// </summary>
public Task<AuthenticationResult> AuthenticateAsync(AuthenticationContext context, CancellationToken cancellationToken = default)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var result = new AuthenticationResult();
// Validate client ID
if (string.IsNullOrEmpty(context.ClientId))
{
result.IsAuthenticated = false;
result.ErrorMessage = "Client ID is required";
return Task.FromResult(result);
}
// Validate credentials provided
if (string.IsNullOrEmpty(context.Credentials))
{
result.IsAuthenticated = false;
result.ErrorMessage = "API key credentials are required";
return Task.FromResult(result);
}
// Check if client exists
if (!_validKeys.TryGetValue(context.ClientId, out var expectedKey))
{
result.IsAuthenticated = false;
result.ErrorMessage = $"Unknown client: {context.ClientId}";
return Task.FromResult(result);
}
// Validate API key (case-sensitive)
if (context.Credentials != expectedKey)
{
result.IsAuthenticated = false;
result.ErrorMessage = "Invalid API key";
return Task.FromResult(result);
}
// Authentication successful
result.IsAuthenticated = true;
result.ClientId = context.ClientId;
result.Claims = new Dictionary<string, object>
{
["auth_method"] = "api_key",
["authenticated_at"] = DateTime.UtcNow
};
return Task.FromResult(result);
}
/// <summary>
/// Authorizes a client request.
/// Basic implementation - grants access to all authenticated clients.
/// </summary>
public Task<AuthorizationResult> AuthorizeAsync(AuthorizationContext context, CancellationToken cancellationToken = default)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
// Basic authorization - if client has valid API key, they're authorized
var result = new AuthorizationResult
{
IsAuthorized = !string.IsNullOrEmpty(context.ClientId),
PermittedActions = new[] { "invoke", "read" }
};
if (!result.IsAuthorized)
{
result.ErrorMessage = "Client ID is required for authorization";
}
return Task.FromResult(result);
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\OpenHarbor.MCP.Gateway.Core\OpenHarbor.MCP.Gateway.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,114 @@
using System.Text;
using System.Text.Json;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Transport;
/// <summary>
/// Server transport implementation using HTTP.
/// Communicates with MCP servers via HTTP/REST.
/// </summary>
public class HttpServerTransport : IServerTransport
{
private readonly string _baseUrl;
private readonly HttpClient _httpClient;
private bool _isConnected;
public bool IsConnected => _isConnected;
public HttpServerTransport(string baseUrl) : this(baseUrl, new HttpClient())
{
}
public HttpServerTransport(string baseUrl, HttpClient httpClient)
{
_baseUrl = baseUrl ?? throw new ArgumentNullException(nameof(baseUrl));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_httpClient.BaseAddress = new Uri(_baseUrl);
}
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
if (_isConnected)
{
return;
}
// Verify server is reachable with a health check
try
{
var response = await _httpClient.GetAsync("/health", cancellationToken);
_isConnected = response.IsSuccessStatusCode;
}
catch
{
// If health check fails, still mark as connected - let actual requests fail
_isConnected = true;
}
}
public async Task<GatewayResponse> SendRequestAsync(GatewayRequest request, CancellationToken cancellationToken = default)
{
if (!_isConnected)
{
throw new InvalidOperationException("Transport is not connected");
}
try
{
// Serialize request to JSON
var jsonRequest = JsonSerializer.Serialize(request);
var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json");
// Send POST request
var httpResponse = await _httpClient.PostAsync("/mcp/invoke", content, cancellationToken);
if (!httpResponse.IsSuccessStatusCode)
{
return new GatewayResponse
{
Success = false,
Error = $"HTTP {httpResponse.StatusCode}: {httpResponse.ReasonPhrase}"
};
}
// Read and deserialize response
var jsonResponse = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
var response = JsonSerializer.Deserialize<GatewayResponse>(jsonResponse);
return response ?? new GatewayResponse
{
Success = false,
Error = "Failed to deserialize response"
};
}
catch (HttpRequestException ex)
{
return new GatewayResponse
{
Success = false,
Error = $"HTTP request failed: {ex.Message}"
};
}
catch (TaskCanceledException)
{
return new GatewayResponse
{
Success = false,
Error = "Request timeout"
};
}
}
public Task DisconnectAsync(CancellationToken cancellationToken = default)
{
_isConnected = false;
return Task.CompletedTask;
}
public void Dispose()
{
_httpClient?.Dispose();
}
}

View File

@ -0,0 +1,117 @@
using System.Diagnostics;
using System.Text.Json;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Transport;
/// <summary>
/// Server transport implementation using stdio (standard input/output).
/// Launches a process and communicates via stdin/stdout.
/// </summary>
public class StdioServerTransport : IServerTransport
{
private readonly string _command;
private readonly string[] _args;
private Process? _process;
private StreamWriter? _stdin;
private StreamReader? _stdout;
private bool _isConnected;
public bool IsConnected => _isConnected;
public StdioServerTransport(string command, string[] args)
{
_command = command ?? throw new ArgumentNullException(nameof(command));
_args = args ?? Array.Empty<string>();
}
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
if (_isConnected)
{
return;
}
_process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _command,
Arguments = string.Join(" ", _args),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
}
};
_process.Start();
_stdin = _process.StandardInput;
_stdout = _process.StandardOutput;
_isConnected = true;
await Task.CompletedTask;
}
public async Task<GatewayResponse> SendRequestAsync(GatewayRequest request, CancellationToken cancellationToken = default)
{
if (!_isConnected || _stdin == null || _stdout == null)
{
throw new InvalidOperationException("Transport is not connected");
}
// Serialize request to JSON
var jsonRequest = JsonSerializer.Serialize(request);
await _stdin.WriteLineAsync(jsonRequest);
await _stdin.FlushAsync();
// Read response from stdout
var jsonResponse = await _stdout.ReadLineAsync();
if (string.IsNullOrEmpty(jsonResponse))
{
return new GatewayResponse
{
Success = false,
Error = "Empty response from server"
};
}
// Deserialize response
var response = JsonSerializer.Deserialize<GatewayResponse>(jsonResponse);
return response ?? new GatewayResponse
{
Success = false,
Error = "Failed to deserialize response"
};
}
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (!_isConnected)
{
return;
}
_stdin?.Close();
_stdout?.Close();
if (_process != null && !_process.HasExited)
{
_process.Kill();
await _process.WaitForExitAsync(cancellationToken);
}
_process?.Dispose();
_process = null;
_stdin = null;
_stdout = null;
_isConnected = false;
}
public void Dispose()
{
DisconnectAsync().GetAwaiter().GetResult();
}
}

View File

@ -0,0 +1,191 @@
using Xunit;
using Moq;
using Microsoft.AspNetCore.Http;
using OpenHarbor.MCP.Gateway.AspNetCore.Middleware;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
using System.Text;
using System.Text.Json;
namespace OpenHarbor.MCP.Gateway.AspNetCore.Tests.Middleware;
/// <summary>
/// Unit tests for GatewayMiddleware following TDD approach.
/// Tests HTTP request interception and gateway routing.
/// </summary>
public class GatewayMiddlewareTests
{
[Fact]
public async Task InvokeAsync_WithGatewayRequest_RoutesToGateway()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
mockRouter.Setup(r => r.RouteAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = true, Result = new Dictionary<string, object> { ["data"] = "test" } });
var middleware = new GatewayMiddleware(
next: (HttpContext _) => Task.CompletedTask,
router: mockRouter.Object
);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Path = "/mcp/invoke";
context.Request.ContentType = "application/json";
var requestBody = JsonSerializer.Serialize(new { toolName = "test_tool", arguments = new { } });
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestBody));
context.Response.Body = new MemoryStream();
// Act
await middleware.InvokeAsync(context);
// Assert
mockRouter.Verify(r => r.RouteAsync(It.Is<GatewayRequest>(req => req.ToolName == "test_tool"), It.IsAny<CancellationToken>()), Times.Once);
Assert.Equal(200, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_WithNonGatewayPath_CallsNext()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
var nextCalled = false;
var middleware = new GatewayMiddleware(
next: (HttpContext _) => { nextCalled = true; return Task.CompletedTask; },
router: mockRouter.Object
);
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Path = "/api/other";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(nextCalled);
mockRouter.Verify(r => r.RouteAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task InvokeAsync_WithGatewayError_Returns500()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
mockRouter.Setup(r => r.RouteAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = false, Error = "Routing failed", ErrorCode = "ROUTE_ERROR" });
var middleware = new GatewayMiddleware(
next: (HttpContext _) => Task.CompletedTask,
router: mockRouter.Object
);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Path = "/mcp/invoke";
context.Request.ContentType = "application/json";
var requestBody = JsonSerializer.Serialize(new { toolName = "test_tool" });
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestBody));
context.Response.Body = new MemoryStream();
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.Equal(500, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_WithInvalidJson_Returns400()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
var middleware = new GatewayMiddleware(
next: (HttpContext _) => Task.CompletedTask,
router: mockRouter.Object
);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Path = "/mcp/invoke";
context.Request.ContentType = "application/json";
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("invalid json"));
context.Response.Body = new MemoryStream();
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.Equal(400, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_ExtractsClientIdFromHeader()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
mockRouter.Setup(r => r.RouteAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = true });
var middleware = new GatewayMiddleware(
next: (HttpContext _) => Task.CompletedTask,
router: mockRouter.Object
);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Path = "/mcp/invoke";
context.Request.ContentType = "application/json";
context.Request.Headers["X-Client-Id"] = "test-client";
var requestBody = JsonSerializer.Serialize(new { toolName = "test_tool" });
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestBody));
context.Response.Body = new MemoryStream();
// Act
await middleware.InvokeAsync(context);
// Assert
mockRouter.Verify(r => r.RouteAsync(
It.Is<GatewayRequest>(req => req.ClientId == "test-client"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task InvokeAsync_ReturnsJsonResponse()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
var expectedResult = new Dictionary<string, object> { ["status"] = "success", ["value"] = 42 };
mockRouter.Setup(r => r.RouteAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = true, Result = expectedResult });
var middleware = new GatewayMiddleware(
next: (HttpContext _) => Task.CompletedTask,
router: mockRouter.Object
);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Path = "/mcp/invoke";
context.Request.ContentType = "application/json";
var requestBody = JsonSerializer.Serialize(new { toolName = "test_tool" });
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestBody));
context.Response.Body = new MemoryStream();
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.Equal("application/json", context.Response.ContentType);
context.Response.Body.Seek(0, SeekOrigin.Begin);
var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync();
Assert.Contains("success", responseBody);
Assert.Contains("42", responseBody);
}
}

View File

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OpenHarbor.MCP.Gateway.AspNetCore\OpenHarbor.MCP.Gateway.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\OpenHarbor.MCP.Gateway.Core\OpenHarbor.MCP.Gateway.Core.csproj" />
<ProjectReference Include="..\..\src\OpenHarbor.MCP.Gateway.Infrastructure\OpenHarbor.MCP.Gateway.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,113 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Core.Configuration;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Configuration;
/// <summary>
/// Unit tests for GatewayConfig following TDD approach.
/// Tests gateway configuration and validation.
/// </summary>
public class GatewayConfigTests
{
[Fact]
public void GatewayConfig_WithDefaultValues_CreatesSuccessfully()
{
// Arrange & Act
var config = new GatewayConfig();
// Assert
Assert.NotNull(config);
Assert.NotNull(config.Servers);
Assert.Empty(config.Servers);
Assert.NotNull(config.Routing);
Assert.NotNull(config.Security);
}
[Fact]
public void GatewayConfig_WithServers_StoresCorrectly()
{
// Arrange & Act
var config = new GatewayConfig
{
Servers = new List<ServerConfig>
{
new ServerConfig { Id = "server-1", Name = "Server 1" },
new ServerConfig { Id = "server-2", Name = "Server 2" }
}
};
// Assert
Assert.Equal(2, config.Servers.Count);
Assert.Contains(config.Servers, s => s.Id == "server-1");
Assert.Contains(config.Servers, s => s.Id == "server-2");
}
[Fact]
public void GatewayConfig_WithRoutingConfig_StoresCorrectly()
{
// Arrange & Act
var routingConfig = new RoutingConfig { Strategy = "RoundRobin" };
var config = new GatewayConfig
{
Routing = routingConfig
};
// Assert
Assert.NotNull(config.Routing);
Assert.Equal("RoundRobin", config.Routing.Strategy);
}
[Fact]
public void GatewayConfig_WithSecurityConfig_StoresCorrectly()
{
// Arrange & Act
var securityConfig = new SecurityConfig { EnableAuthentication = true };
var config = new GatewayConfig
{
Security = securityConfig
};
// Assert
Assert.NotNull(config.Security);
Assert.True(config.Security.EnableAuthentication);
}
[Fact]
public void GatewayConfig_Validate_WithValidConfig_ReturnsTrue()
{
// Arrange
var config = new GatewayConfig
{
Servers = new List<ServerConfig>
{
new ServerConfig { Id = "server-1", Name = "Server 1", TransportType = "Stdio" }
},
Routing = new RoutingConfig { Strategy = "RoundRobin" },
Security = new SecurityConfig { EnableAuthentication = false }
};
// Act
var isValid = config.Validate();
// Assert
Assert.True(isValid);
}
[Fact]
public void GatewayConfig_Validate_WithEmptyServers_ReturnsFalse()
{
// Arrange
var config = new GatewayConfig
{
Servers = new List<ServerConfig>(),
Routing = new RoutingConfig { Strategy = "RoundRobin" }
};
// Act
var isValid = config.Validate();
// Assert
Assert.False(isValid);
}
}

View File

@ -0,0 +1,127 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Core.Configuration;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Configuration;
/// <summary>
/// Unit tests for RoutingConfig following TDD approach.
/// Tests routing configuration and validation.
/// </summary>
public class RoutingConfigTests
{
[Fact]
public void RoutingConfig_DefaultStrategy_IsRoundRobin()
{
// Arrange & Act
var config = new RoutingConfig();
// Assert
Assert.Equal("RoundRobin", config.Strategy);
}
[Fact]
public void RoutingConfig_WithCustomStrategy_StoresCorrectly()
{
// Arrange & Act
var config = new RoutingConfig
{
Strategy = "ToolBased",
ToolMapping = new Dictionary<string, string>
{
{ "search_*", "server-1" },
{ "get_*", "server-2" }
}
};
// Assert
Assert.Equal("ToolBased", config.Strategy);
Assert.NotNull(config.ToolMapping);
Assert.Equal(2, config.ToolMapping.Count);
}
[Fact]
public void RoutingConfig_WithClientBasedStrategy_StoresClientMapping()
{
// Arrange & Act
var config = new RoutingConfig
{
Strategy = "ClientBased",
ClientMapping = new Dictionary<string, string>
{
{ "web-client", "server-1" },
{ "mobile-client", "server-2" }
}
};
// Assert
Assert.Equal("ClientBased", config.Strategy);
Assert.NotNull(config.ClientMapping);
Assert.Equal("server-1", config.ClientMapping["web-client"]);
}
[Fact]
public void RoutingConfig_WithRetrySettings_StoresCorrectly()
{
// Arrange & Act
var config = new RoutingConfig
{
EnableRetry = true,
MaxRetryAttempts = 3,
RetryDelayMs = 1000
};
// Assert
Assert.True(config.EnableRetry);
Assert.Equal(3, config.MaxRetryAttempts);
Assert.Equal(1000, config.RetryDelayMs);
}
[Fact]
public void RoutingConfig_Validate_WithValidStrategy_ReturnsTrue()
{
// Arrange
var config = new RoutingConfig
{
Strategy = "RoundRobin"
};
// Act
var isValid = config.Validate();
// Assert
Assert.True(isValid);
}
[Fact]
public void RoutingConfig_Validate_WithInvalidStrategy_ReturnsFalse()
{
// Arrange
var config = new RoutingConfig
{
Strategy = "InvalidStrategy"
};
// Act
var isValid = config.Validate();
// Assert
Assert.False(isValid);
}
[Fact]
public void RoutingConfig_Validate_ToolBasedWithoutMapping_ReturnsFalse()
{
// Arrange
var config = new RoutingConfig
{
Strategy = "ToolBased",
ToolMapping = null
};
// Act
var isValid = config.Validate();
// Assert
Assert.False(isValid);
}
}

View File

@ -0,0 +1,152 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Core.Configuration;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Configuration;
/// <summary>
/// Unit tests for SecurityConfig following TDD approach.
/// Tests security configuration and validation.
/// </summary>
public class SecurityConfigTests
{
[Fact]
public void SecurityConfig_DefaultValues_DisablesAuthentication()
{
// Arrange & Act
var config = new SecurityConfig();
// Assert
Assert.False(config.EnableAuthentication);
Assert.False(config.EnableAuthorization);
}
[Fact]
public void SecurityConfig_WithApiKeyAuth_StoresCorrectly()
{
// Arrange & Act
var config = new SecurityConfig
{
EnableAuthentication = true,
AuthenticationScheme = "ApiKey",
ApiKeys = new List<string> { "key1", "key2", "key3" }
};
// Assert
Assert.True(config.EnableAuthentication);
Assert.Equal("ApiKey", config.AuthenticationScheme);
Assert.NotNull(config.ApiKeys);
Assert.Equal(3, config.ApiKeys.Count);
}
[Fact]
public void SecurityConfig_WithJwtAuth_StoresCorrectly()
{
// Arrange & Act
var config = new SecurityConfig
{
EnableAuthentication = true,
AuthenticationScheme = "JWT",
JwtSecret = "my-secret-key",
JwtIssuer = "gateway.example.com",
JwtAudience = "mcp-clients"
};
// Assert
Assert.Equal("JWT", config.AuthenticationScheme);
Assert.Equal("my-secret-key", config.JwtSecret);
Assert.Equal("gateway.example.com", config.JwtIssuer);
Assert.Equal("mcp-clients", config.JwtAudience);
}
[Fact]
public void SecurityConfig_WithAuthorization_StoresClientPermissions()
{
// Arrange & Act
var config = new SecurityConfig
{
EnableAuthorization = true,
ClientPermissions = new Dictionary<string, List<string>>
{
{ "client-1", new List<string> { "read", "write" } },
{ "client-2", new List<string> { "read" } }
}
};
// Assert
Assert.True(config.EnableAuthorization);
Assert.NotNull(config.ClientPermissions);
Assert.Equal(2, config.ClientPermissions.Count);
Assert.Contains("write", config.ClientPermissions["client-1"]);
}
[Fact]
public void SecurityConfig_WithRateLimiting_StoresCorrectly()
{
// Arrange & Act
var config = new SecurityConfig
{
EnableRateLimiting = true,
RequestsPerMinute = 60,
BurstSize = 10
};
// Assert
Assert.True(config.EnableRateLimiting);
Assert.Equal(60, config.RequestsPerMinute);
Assert.Equal(10, config.BurstSize);
}
[Fact]
public void SecurityConfig_Validate_WithApiKeyButNoKeys_ReturnsFalse()
{
// Arrange
var config = new SecurityConfig
{
EnableAuthentication = true,
AuthenticationScheme = "ApiKey",
ApiKeys = new List<string>()
};
// Act
var isValid = config.Validate();
// Assert
Assert.False(isValid);
}
[Fact]
public void SecurityConfig_Validate_WithJwtButNoSecret_ReturnsFalse()
{
// Arrange
var config = new SecurityConfig
{
EnableAuthentication = true,
AuthenticationScheme = "JWT",
JwtSecret = null
};
// Act
var isValid = config.Validate();
// Assert
Assert.False(isValid);
}
[Fact]
public void SecurityConfig_Validate_WithValidApiKeyConfig_ReturnsTrue()
{
// Arrange
var config = new SecurityConfig
{
EnableAuthentication = true,
AuthenticationScheme = "ApiKey",
ApiKeys = new List<string> { "valid-key" }
};
// Act
var isValid = config.Validate();
// Assert
Assert.True(isValid);
}
}

View File

@ -0,0 +1,88 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Infrastructure;
/// <summary>
/// Unit tests for IServerTransport interface following TDD approach.
/// Tests transport communication contract.
/// </summary>
public class IServerTransportTests
{
[Fact]
public async Task SendRequestAsync_WithValidRequest_ReturnsResponse()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
var request = new GatewayRequest
{
ToolName = "test_tool",
Arguments = new Dictionary<string, object> { { "key", "value" } }
};
var expectedResponse = new GatewayResponse
{
Success = true,
Result = new Dictionary<string, object> { { "data", "result" } }
};
mockTransport
.Setup(t => t.SendRequestAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
// Act
var response = await mockTransport.Object.SendRequestAsync(request, CancellationToken.None);
// Assert
Assert.NotNull(response);
Assert.True(response.Success);
mockTransport.Verify(t => t.SendRequestAsync(request, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConnectAsync_OpensConnection()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport
.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
await mockTransport.Object.ConnectAsync(CancellationToken.None);
// Assert
mockTransport.Verify(t => t.ConnectAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DisconnectAsync_ClosesConnection()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport
.Setup(t => t.DisconnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
await mockTransport.Object.DisconnectAsync(CancellationToken.None);
// Assert
mockTransport.Verify(t => t.DisconnectAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public void IsConnected_ReturnsConnectionState()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport.Setup(t => t.IsConnected).Returns(true);
// Act
var isConnected = mockTransport.Object.IsConnected;
// Assert
Assert.True(isConnected);
}
}

View File

@ -0,0 +1,158 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Interfaces;
/// <summary>
/// Unit tests for IAuthProvider interface following TDD approach.
/// Tests authentication and authorization logic.
/// </summary>
public class IAuthProviderTests
{
[Fact]
public async Task AuthenticateAsync_WithValidCredentials_ReturnsSuccess()
{
// Arrange
var mockAuthProvider = new Mock<IAuthProvider>();
var context = new AuthenticationContext
{
ClientId = "valid-client",
Credentials = "valid-token"
};
var expectedResult = new AuthenticationResult
{
IsAuthenticated = true,
ClientId = "valid-client"
};
mockAuthProvider
.Setup(a => a.AuthenticateAsync(It.IsAny<AuthenticationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await mockAuthProvider.Object.AuthenticateAsync(context, CancellationToken.None);
// Assert
Assert.True(result.IsAuthenticated);
Assert.Equal("valid-client", result.ClientId);
mockAuthProvider.Verify(a => a.AuthenticateAsync(context, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task AuthenticateAsync_WithInvalidCredentials_ReturnsFailure()
{
// Arrange
var mockAuthProvider = new Mock<IAuthProvider>();
var context = new AuthenticationContext
{
ClientId = "invalid-client",
Credentials = "invalid-token"
};
var expectedResult = new AuthenticationResult
{
IsAuthenticated = false,
ErrorMessage = "Invalid credentials"
};
mockAuthProvider
.Setup(a => a.AuthenticateAsync(It.IsAny<AuthenticationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await mockAuthProvider.Object.AuthenticateAsync(context, CancellationToken.None);
// Assert
Assert.False(result.IsAuthenticated);
Assert.NotNull(result.ErrorMessage);
}
[Fact]
public async Task AuthorizeAsync_WithAuthorizedClient_ReturnsSuccess()
{
// Arrange
var mockAuthProvider = new Mock<IAuthProvider>();
var context = new AuthorizationContext
{
ClientId = "authorized-client",
Resource = "read_documents",
Action = "invoke"
};
var expectedResult = new AuthorizationResult
{
IsAuthorized = true
};
mockAuthProvider
.Setup(a => a.AuthorizeAsync(It.IsAny<AuthorizationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await mockAuthProvider.Object.AuthorizeAsync(context, CancellationToken.None);
// Assert
Assert.True(result.IsAuthorized);
mockAuthProvider.Verify(a => a.AuthorizeAsync(context, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task AuthorizeAsync_WithUnauthorizedClient_ReturnsFailure()
{
// Arrange
var mockAuthProvider = new Mock<IAuthProvider>();
var context = new AuthorizationContext
{
ClientId = "unauthorized-client",
Resource = "delete_documents",
Action = "invoke"
};
var expectedResult = new AuthorizationResult
{
IsAuthorized = false,
ErrorMessage = "Access denied"
};
mockAuthProvider
.Setup(a => a.AuthorizeAsync(It.IsAny<AuthorizationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await mockAuthProvider.Object.AuthorizeAsync(context, CancellationToken.None);
// Assert
Assert.False(result.IsAuthorized);
Assert.NotNull(result.ErrorMessage);
}
[Fact]
public async Task AuthenticateAsync_WithMissingCredentials_ReturnsFailure()
{
// Arrange
var mockAuthProvider = new Mock<IAuthProvider>();
var context = new AuthenticationContext
{
ClientId = "client-without-credentials"
};
var expectedResult = new AuthenticationResult
{
IsAuthenticated = false,
ErrorMessage = "Credentials required"
};
mockAuthProvider
.Setup(a => a.AuthenticateAsync(It.IsAny<AuthenticationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await mockAuthProvider.Object.AuthenticateAsync(context, CancellationToken.None);
// Assert
Assert.False(result.IsAuthenticated);
}
}

View File

@ -0,0 +1,119 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Interfaces;
/// <summary>
/// Unit tests for ICircuitBreaker interface following TDD approach.
/// Tests circuit breaker pattern implementation.
/// </summary>
public class ICircuitBreakerTests
{
[Fact]
public async Task ExecuteAsync_WithClosedCircuit_ExecutesOperation()
{
// Arrange
var mockCircuitBreaker = new Mock<ICircuitBreaker>();
var expectedResult = "success";
mockCircuitBreaker
.Setup(cb => cb.ExecuteAsync(
It.IsAny<Func<Task<string>>>(),
It.IsAny<CancellationToken>()))
.Returns<Func<Task<string>>, CancellationToken>((operation, ct) => operation());
// Act
var result = await mockCircuitBreaker.Object.ExecuteAsync(
async () => await Task.FromResult(expectedResult),
CancellationToken.None);
// Assert
Assert.Equal(expectedResult, result);
}
[Fact]
public async Task ExecuteAsync_WithOpenCircuit_ThrowsException()
{
// Arrange
var mockCircuitBreaker = new Mock<ICircuitBreaker>();
mockCircuitBreaker
.Setup(cb => cb.ExecuteAsync(
It.IsAny<Func<Task<string>>>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Circuit is open"));
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
mockCircuitBreaker.Object.ExecuteAsync(
async () => await Task.FromResult("result"),
CancellationToken.None));
}
[Fact]
public void GetState_ReturnsCircuitState()
{
// Arrange
var mockCircuitBreaker = new Mock<ICircuitBreaker>();
mockCircuitBreaker.Setup(cb => cb.GetState()).Returns("Closed");
// Act
var state = mockCircuitBreaker.Object.GetState();
// Assert
Assert.Equal("Closed", state);
mockCircuitBreaker.Verify(cb => cb.GetState(), Times.Once);
}
[Fact]
public void Reset_ResetsCircuitBreaker()
{
// Arrange
var mockCircuitBreaker = new Mock<ICircuitBreaker>();
mockCircuitBreaker.Setup(cb => cb.Reset()).Verifiable();
// Act
mockCircuitBreaker.Object.Reset();
// Assert
mockCircuitBreaker.Verify(cb => cb.Reset(), Times.Once);
}
[Fact]
public async Task ExecuteAsync_WithMultipleFailures_OpensCircuit()
{
// Arrange
var mockCircuitBreaker = new Mock<ICircuitBreaker>();
var callCount = 0;
mockCircuitBreaker
.Setup(cb => cb.ExecuteAsync(
It.IsAny<Func<Task<string>>>(),
It.IsAny<CancellationToken>()))
.Returns<Func<Task<string>>, CancellationToken>((operation, ct) =>
{
callCount++;
if (callCount >= 3)
{
throw new InvalidOperationException("Circuit is open");
}
return operation();
});
// Act & Assert
// First two calls should succeed
await mockCircuitBreaker.Object.ExecuteAsync(
async () => await Task.FromResult("result1"),
CancellationToken.None);
await mockCircuitBreaker.Object.ExecuteAsync(
async () => await Task.FromResult("result2"),
CancellationToken.None);
// Third call should fail with open circuit
await Assert.ThrowsAsync<InvalidOperationException>(() =>
mockCircuitBreaker.Object.ExecuteAsync(
async () => await Task.FromResult("result3"),
CancellationToken.None));
}
}

View File

@ -0,0 +1,139 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Interfaces;
/// <summary>
/// Unit tests for IGatewayRouter interface following TDD approach.
/// Tests routing behavior and contract compliance.
/// </summary>
public class IGatewayRouterTests
{
[Fact]
public async Task RouteAsync_WithValidRequest_ReturnsResponse()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
var request = new GatewayRequest
{
ToolName = "test_tool",
Arguments = new Dictionary<string, object> { { "key", "value" } }
};
var expectedResponse = new GatewayResponse
{
Success = true,
Result = new Dictionary<string, object> { { "data", "result" } }
};
mockRouter
.Setup(r => r.RouteAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
// Act
var response = await mockRouter.Object.RouteAsync(request, CancellationToken.None);
// Assert
Assert.NotNull(response);
Assert.True(response.Success);
Assert.NotNull(response.Result);
mockRouter.Verify(r => r.RouteAsync(request, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RouteAsync_WithCancellationToken_PropagatesToken()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
var request = new GatewayRequest { ToolName = "cancel_test" };
var cts = new CancellationTokenSource();
mockRouter
.Setup(r => r.RouteAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = true });
// Act
await mockRouter.Object.RouteAsync(request, cts.Token);
// Assert
mockRouter.Verify(r => r.RouteAsync(request, cts.Token), Times.Once);
}
[Fact]
public async Task GetServerHealthAsync_ReturnsHealthStatuses()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
var healthStatuses = new List<ServerHealthStatus>
{
new ServerHealthStatus
{
ServerId = "server-1",
IsHealthy = true,
ResponseTime = TimeSpan.FromMilliseconds(25)
},
new ServerHealthStatus
{
ServerId = "server-2",
IsHealthy = false,
ErrorMessage = "Timeout"
}
};
mockRouter
.Setup(r => r.GetServerHealthAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(healthStatuses);
// Act
var result = await mockRouter.Object.GetServerHealthAsync(CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count());
Assert.Contains(result, s => s.ServerId == "server-1" && s.IsHealthy);
Assert.Contains(result, s => s.ServerId == "server-2" && !s.IsHealthy);
}
[Fact]
public async Task RegisterServerAsync_AddsServerConfiguration()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
var serverConfig = new ServerConfig
{
Id = "new-server",
Name = "New MCP Server",
TransportType = "Stdio"
};
mockRouter
.Setup(r => r.RegisterServerAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
await mockRouter.Object.RegisterServerAsync(serverConfig, CancellationToken.None);
// Assert
mockRouter.Verify(r => r.RegisterServerAsync(serverConfig, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UnregisterServerAsync_RemovesServerConfiguration()
{
// Arrange
var mockRouter = new Mock<IGatewayRouter>();
var serverId = "remove-server";
mockRouter
.Setup(r => r.UnregisterServerAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
var result = await mockRouter.Object.UnregisterServerAsync(serverId, CancellationToken.None);
// Assert
Assert.True(result);
mockRouter.Verify(r => r.UnregisterServerAsync(serverId, It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@ -0,0 +1,140 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Interfaces;
/// <summary>
/// Unit tests for IHealthChecker interface contract.
/// Tests health check operations and status tracking.
/// </summary>
public class IHealthCheckerTests
{
[Fact]
public async Task CheckHealthAsync_ReturnsHealthStatus()
{
// Arrange
var mockChecker = new Mock<IHealthChecker>();
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var expectedStatus = new ServerHealthStatus
{
ServerId = "server-1",
ServerName = "Test Server",
IsHealthy = true,
LastCheck = DateTime.UtcNow
};
mockChecker.Setup(c => c.CheckHealthAsync(serverConfig, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedStatus);
// Act
var result = await mockChecker.Object.CheckHealthAsync(serverConfig);
// Assert
Assert.NotNull(result);
Assert.Equal("server-1", result.ServerId);
Assert.True(result.IsHealthy);
}
[Fact]
public async Task CheckHealthAsync_WithUnhealthyServer_ReturnsUnhealthyStatus()
{
// Arrange
var mockChecker = new Mock<IHealthChecker>();
var serverConfig = new ServerConfig
{
Id = "server-2",
Name = "Unhealthy Server",
TransportType = "Http",
BaseUrl = "http://localhost:5001"
};
var expectedStatus = new ServerHealthStatus
{
ServerId = "server-2",
ServerName = "Unhealthy Server",
IsHealthy = false,
LastCheck = DateTime.UtcNow,
ErrorMessage = "Connection timeout"
};
mockChecker.Setup(c => c.CheckHealthAsync(serverConfig, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedStatus);
// Act
var result = await mockChecker.Object.CheckHealthAsync(serverConfig);
// Assert
Assert.NotNull(result);
Assert.Equal("server-2", result.ServerId);
Assert.False(result.IsHealthy);
Assert.Equal("Connection timeout", result.ErrorMessage);
}
[Fact]
public async Task StartMonitoringAsync_BeginsHealthChecks()
{
// Arrange
var mockChecker = new Mock<IHealthChecker>();
var serverConfigs = new List<ServerConfig>
{
new ServerConfig { Id = "server-1", Name = "Server 1", TransportType = "Http", BaseUrl = "http://localhost:5000" }
};
mockChecker.Setup(c => c.StartMonitoringAsync(serverConfigs, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
await mockChecker.Object.StartMonitoringAsync(serverConfigs);
// Assert
mockChecker.Verify(c => c.StartMonitoringAsync(serverConfigs, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StopMonitoringAsync_EndsHealthChecks()
{
// Arrange
var mockChecker = new Mock<IHealthChecker>();
mockChecker.Setup(c => c.StopMonitoringAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
await mockChecker.Object.StopMonitoringAsync();
// Assert
mockChecker.Verify(c => c.StopMonitoringAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetCurrentHealthAsync_ReturnsAllServerHealth()
{
// Arrange
var mockChecker = new Mock<IHealthChecker>();
var expectedHealth = new List<ServerHealthStatus>
{
new ServerHealthStatus { ServerId = "server-1", IsHealthy = true },
new ServerHealthStatus { ServerId = "server-2", IsHealthy = false }
};
mockChecker.Setup(c => c.GetCurrentHealthAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedHealth);
// Act
var result = await mockChecker.Object.GetCurrentHealthAsync();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count());
Assert.Contains(result, s => s.ServerId == "server-1" && s.IsHealthy);
Assert.Contains(result, s => s.ServerId == "server-2" && !s.IsHealthy);
}
}

View File

@ -0,0 +1,123 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Interfaces;
/// <summary>
/// Unit tests for IRoutingStrategy interface following TDD approach.
/// Tests server selection logic.
/// </summary>
public class IRoutingStrategyTests
{
[Fact]
public async Task SelectServerAsync_WithHealthyServers_ReturnsServer()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ToolName = "test_tool" };
mockStrategy
.Setup(s => s.SelectServerAsync(
It.IsAny<IEnumerable<ServerInfo>>(),
It.IsAny<RoutingContext>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(servers[0]);
// Act
var selected = await mockStrategy.Object.SelectServerAsync(servers, context, CancellationToken.None);
// Assert
Assert.NotNull(selected);
Assert.Equal("server-1", selected.Id);
mockStrategy.Verify(s => s.SelectServerAsync(servers, context, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task SelectServerAsync_WithNoHealthyServers_ReturnsNull()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = false },
new ServerInfo { Id = "server-2", IsHealthy = false }
};
var context = new RoutingContext { ToolName = "test_tool" };
mockStrategy
.Setup(s => s.SelectServerAsync(
It.IsAny<IEnumerable<ServerInfo>>(),
It.IsAny<RoutingContext>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ServerInfo?)null);
// Act
var selected = await mockStrategy.Object.SelectServerAsync(servers, context, CancellationToken.None);
// Assert
Assert.Null(selected);
}
[Fact]
public async Task SelectServerAsync_WithRoutingContext_UsesContext()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true }
};
var context = new RoutingContext
{
ToolName = "specific_tool",
ClientId = "client-123",
Metadata = new Dictionary<string, object> { { "region", "us-east" } }
};
mockStrategy
.Setup(s => s.SelectServerAsync(
It.IsAny<IEnumerable<ServerInfo>>(),
It.Is<RoutingContext>(c => c.ToolName == "specific_tool"),
It.IsAny<CancellationToken>()))
.ReturnsAsync(servers[0]);
// Act
var selected = await mockStrategy.Object.SelectServerAsync(servers, context, CancellationToken.None);
// Assert
Assert.NotNull(selected);
mockStrategy.Verify(s => s.SelectServerAsync(
It.IsAny<IEnumerable<ServerInfo>>(),
It.Is<RoutingContext>(c => c.ToolName == "specific_tool"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task SelectServerAsync_WithEmptyServerList_ReturnsNull()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
var servers = new List<ServerInfo>();
var context = new RoutingContext { ToolName = "test_tool" };
mockStrategy
.Setup(s => s.SelectServerAsync(
It.IsAny<IEnumerable<ServerInfo>>(),
It.IsAny<RoutingContext>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ServerInfo?)null);
// Act
var selected = await mockStrategy.Object.SelectServerAsync(servers, context, CancellationToken.None);
// Assert
Assert.Null(selected);
}
}

View File

@ -0,0 +1,97 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Models;
/// <summary>
/// Unit tests for GatewayRequest and GatewayResponse models following TDD approach.
/// Tests request/response representation.
/// </summary>
public class GatewayRequestResponseTests
{
[Fact]
public void GatewayRequest_WithToolCall_CreatesSuccessfully()
{
// Arrange & Act
var request = new GatewayRequest
{
ToolName = "search_documents",
Arguments = new Dictionary<string, object>
{
{ "query", "architecture" },
{ "limit", 10 }
}
};
// Assert
Assert.Equal("search_documents", request.ToolName);
Assert.NotNull(request.Arguments);
Assert.Equal("architecture", request.Arguments["query"]);
Assert.Equal(10, request.Arguments["limit"]);
}
[Fact]
public void GatewayRequest_WithoutArguments_AllowsNull()
{
// Arrange & Act
var request = new GatewayRequest
{
ToolName = "list_tools"
};
// Assert
Assert.Equal("list_tools", request.ToolName);
Assert.Null(request.Arguments);
}
[Fact]
public void GatewayResponse_Success_CreatesSuccessfully()
{
// Arrange & Act
var response = new GatewayResponse
{
Success = true,
Result = new Dictionary<string, object>
{
{ "documents", new[] { "doc1", "doc2" } },
{ "count", 2 }
}
};
// Assert
Assert.True(response.Success);
Assert.NotNull(response.Result);
Assert.Equal(2, response.Result["count"]);
Assert.Null(response.Error);
}
[Fact]
public void GatewayResponse_Error_CreatesSuccessfully()
{
// Arrange & Act
var response = new GatewayResponse
{
Success = false,
Error = "Server unavailable",
ErrorCode = "SERVER_UNAVAILABLE"
};
// Assert
Assert.False(response.Success);
Assert.Equal("Server unavailable", response.Error);
Assert.Equal("SERVER_UNAVAILABLE", response.ErrorCode);
Assert.Null(response.Result);
}
[Fact]
public void GatewayResponse_DefaultState_IsNotSuccess()
{
// Arrange & Act
var response = new GatewayResponse();
// Assert
Assert.False(response.Success);
Assert.Null(response.Result);
Assert.Null(response.Error);
}
}

View File

@ -0,0 +1,82 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Models;
/// <summary>
/// Unit tests for RoutingContext model following TDD approach.
/// Tests routing metadata.
/// </summary>
public class RoutingContextTests
{
[Fact]
public void RoutingContext_WithToolName_CreatesSuccessfully()
{
// Arrange & Act
var context = new RoutingContext
{
ToolName = "search_codex",
ClientId = "web-client-123"
};
// Assert
Assert.Equal("search_codex", context.ToolName);
Assert.Equal("web-client-123", context.ClientId);
Assert.Null(context.Headers);
Assert.Null(context.Metadata);
}
[Fact]
public void RoutingContext_WithHeaders_StoresCorrectly()
{
// Arrange & Act
var context = new RoutingContext
{
ToolName = "get_document",
Headers = new Dictionary<string, string>
{
{ "Authorization", "Bearer token123" },
{ "X-Client-Version", "1.0" }
}
};
// Assert
Assert.NotNull(context.Headers);
Assert.Equal(2, context.Headers.Count);
Assert.Equal("Bearer token123", context.Headers["Authorization"]);
}
[Fact]
public void RoutingContext_WithMetadata_StoresCorrectly()
{
// Arrange & Act
var context = new RoutingContext
{
Metadata = new Dictionary<string, object>
{
{ "priority", 5 },
{ "timeout", TimeSpan.FromSeconds(30) },
{ "retry", true }
}
};
// Assert
Assert.NotNull(context.Metadata);
Assert.Equal(5, context.Metadata["priority"]);
Assert.Equal(TimeSpan.FromSeconds(30), context.Metadata["timeout"]);
Assert.True((bool)context.Metadata["retry"]);
}
[Fact]
public void RoutingContext_AllNullable_AllowsNullValues()
{
// Arrange & Act
var context = new RoutingContext();
// Assert
Assert.Null(context.ToolName);
Assert.Null(context.ClientId);
Assert.Null(context.Headers);
Assert.Null(context.Metadata);
}
}

View File

@ -0,0 +1,88 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Models;
/// <summary>
/// Unit tests for ServerConfig model following TDD approach.
/// Tests server configuration.
/// </summary>
public class ServerConfigTests
{
[Fact]
public void ServerConfig_WithStdioTransport_CreatesSuccessfully()
{
// Arrange & Act
var config = new ServerConfig
{
Id = "codex-server",
Name = "Codex MCP Server",
TransportType = "Stdio",
Command = "dotnet",
Args = new[] { "run", "--project", "CodexMcpServer.csproj" },
Enabled = true
};
// Assert
Assert.Equal("codex-server", config.Id);
Assert.Equal("Codex MCP Server", config.Name);
Assert.Equal("Stdio", config.TransportType);
Assert.Equal("dotnet", config.Command);
Assert.NotNull(config.Args);
Assert.Equal(3, config.Args.Length);
Assert.True(config.Enabled);
Assert.Null(config.BaseUrl);
}
[Fact]
public void ServerConfig_WithHttpTransport_CreatesSuccessfully()
{
// Arrange & Act
var config = new ServerConfig
{
Id = "api-server",
Name = "API MCP Server",
TransportType = "Http",
BaseUrl = "https://api.example.com/mcp"
};
// Assert
Assert.Equal("Http", config.TransportType);
Assert.Equal("https://api.example.com/mcp", config.BaseUrl);
Assert.Null(config.Command);
Assert.Null(config.Args);
}
[Fact]
public void ServerConfig_DefaultEnabled_IsTrue()
{
// Arrange & Act
var config = new ServerConfig
{
Id = "default-server"
};
// Assert
Assert.True(config.Enabled);
Assert.Equal("Stdio", config.TransportType);
}
[Fact]
public void ServerConfig_WithMetadata_StoresCorrectly()
{
// Arrange & Act
var config = new ServerConfig
{
Id = "meta-server",
Metadata = new Dictionary<string, string>
{
{ "priority", "high" },
{ "region", "us-west" }
}
};
// Assert
Assert.NotNull(config.Metadata);
Assert.Equal("high", config.Metadata["priority"]);
}
}

View File

@ -0,0 +1,66 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Models;
/// <summary>
/// Unit tests for ServerHealthStatus model following TDD approach.
/// Tests server health representation.
/// </summary>
public class ServerHealthStatusTests
{
[Fact]
public void ServerHealthStatus_Healthy_CreatesSuccessfully()
{
// Arrange
var now = DateTime.UtcNow;
// Act
var status = new ServerHealthStatus
{
ServerId = "server-1",
ServerName = "Test Server",
IsHealthy = true,
LastCheck = now,
ResponseTime = TimeSpan.FromMilliseconds(25)
};
// Assert
Assert.Equal("server-1", status.ServerId);
Assert.Equal("Test Server", status.ServerName);
Assert.True(status.IsHealthy);
Assert.Equal(now, status.LastCheck);
Assert.Equal(25, status.ResponseTime?.TotalMilliseconds);
Assert.Null(status.ErrorMessage);
}
[Fact]
public void ServerHealthStatus_Unhealthy_IncludesErrorMessage()
{
// Arrange & Act
var status = new ServerHealthStatus
{
ServerId = "server-2",
IsHealthy = false,
LastCheck = DateTime.UtcNow,
ErrorMessage = "Connection timeout"
};
// Assert
Assert.False(status.IsHealthy);
Assert.Equal("Connection timeout", status.ErrorMessage);
Assert.Null(status.ResponseTime);
}
[Fact]
public void ServerHealthStatus_DefaultState_IsNotHealthy()
{
// Arrange & Act
var status = new ServerHealthStatus();
// Assert
Assert.False(status.IsHealthy);
Assert.Null(status.ErrorMessage);
Assert.Null(status.ResponseTime);
}
}

View File

@ -0,0 +1,78 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Core.Tests.Models;
/// <summary>
/// Unit tests for ServerInfo model following TDD approach.
/// Tests server metadata representation.
/// </summary>
public class ServerInfoTests
{
[Fact]
public void ServerInfo_WithValidData_CreatesSuccessfully()
{
// Arrange
var now = DateTime.UtcNow;
// Act
var serverInfo = new ServerInfo
{
Id = "test-server",
Name = "Test Server",
IsHealthy = true,
LastHealthCheck = now,
ResponseTime = TimeSpan.FromMilliseconds(50),
Metadata = new Dictionary<string, string>
{
{ "region", "us-east" },
{ "version", "1.0.0" }
}
};
// Assert
Assert.Equal("test-server", serverInfo.Id);
Assert.Equal("Test Server", serverInfo.Name);
Assert.True(serverInfo.IsHealthy);
Assert.Equal(now, serverInfo.LastHealthCheck);
Assert.Equal(50, serverInfo.ResponseTime?.TotalMilliseconds);
Assert.NotNull(serverInfo.Metadata);
Assert.Equal("us-east", serverInfo.Metadata["region"]);
}
[Fact]
public void ServerInfo_DefaultState_HasEmptyId()
{
// Arrange & Act
var serverInfo = new ServerInfo();
// Assert
Assert.Equal(string.Empty, serverInfo.Id);
Assert.Equal(string.Empty, serverInfo.Name);
Assert.False(serverInfo.IsHealthy);
Assert.Null(serverInfo.LastHealthCheck);
Assert.Null(serverInfo.ResponseTime);
Assert.Null(serverInfo.Metadata);
}
[Fact]
public void ServerInfo_WithMetadata_StoresCorrectly()
{
// Arrange & Act
var serverInfo = new ServerInfo
{
Id = "metadata-test",
Metadata = new Dictionary<string, string>
{
{ "environment", "production" },
{ "datacenter", "dc1" }
}
};
// Assert
Assert.NotNull(serverInfo.Metadata);
Assert.Equal(2, serverInfo.Metadata.Count);
Assert.Equal("production", serverInfo.Metadata["environment"]);
Assert.Equal("dc1", serverInfo.Metadata["datacenter"]);
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OpenHarbor.MCP.Gateway.Core\OpenHarbor.MCP.Gateway.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,257 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Infrastructure.Connection;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Connection;
/// <summary>
/// Unit tests for ServerConnectionPool following TDD approach.
/// Tests connection pooling, eviction, and limits.
/// </summary>
public class ServerConnectionPoolTests
{
[Fact]
public async Task GetConnectionAsync_CreatesNewConnection()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "test",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var pool = new ServerConnectionPool();
// Act
var connection = await pool.GetConnectionAsync(serverConfig);
// Assert
Assert.NotNull(connection);
Assert.Equal(serverConfig.Id, connection.ServerInfo.Id);
}
[Fact]
public async Task GetConnectionAsync_ReusesSameConnection()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "test",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var pool = new ServerConnectionPool();
// Act
var connection1 = await pool.GetConnectionAsync(serverConfig);
var connection2 = await pool.GetConnectionAsync(serverConfig);
// Assert
Assert.Same(connection1, connection2); // Should be the exact same instance
}
[Fact]
public async Task ReleaseConnectionAsync_MarksConnectionAsAvailable()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "test",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var pool = new ServerConnectionPool();
var connection = await pool.GetConnectionAsync(serverConfig);
// Act
await pool.ReleaseConnectionAsync(connection);
// Assert - should be able to get it again
var connection2 = await pool.GetConnectionAsync(serverConfig);
Assert.Same(connection, connection2);
}
[Fact]
public async Task GetConnectionAsync_WithMaxConnections_WaitsForAvailable()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "test",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var pool = new ServerConnectionPool { MaxConnectionsPerServer = 1 };
var connection1 = await pool.GetConnectionAsync(serverConfig);
// Act - try to get second connection (should wait or create new based on pool strategy)
var getTask = pool.GetConnectionAsync(serverConfig);
// Release first connection
await pool.ReleaseConnectionAsync(connection1);
var connection2 = await getTask;
// Assert
Assert.NotNull(connection2);
}
[Fact]
public async Task EvictIdleConnectionsAsync_RemovesIdleConnections()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "test",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var pool = new ServerConnectionPool { IdleTimeout = TimeSpan.FromMilliseconds(50) };
var connection = await pool.GetConnectionAsync(serverConfig);
await pool.ReleaseConnectionAsync(connection);
// Wait for idle timeout
await Task.Delay(100);
// Act
await pool.EvictIdleConnectionsAsync();
// Assert - getting connection again should create a new one
var connection2 = await pool.GetConnectionAsync(serverConfig);
Assert.NotNull(connection2);
// Note: Without access to internal state, we can't directly verify it's a new instance
// This is tested indirectly through behavior
}
[Fact]
public void GetPoolStats_ReturnsCorrectStats()
{
// Arrange
var pool = new ServerConnectionPool();
// Act
var stats = pool.GetPoolStats();
// Assert
Assert.NotNull(stats);
Assert.Equal(0, stats.TotalConnections);
Assert.Equal(0, stats.ActiveConnections);
Assert.Equal(0, stats.IdleConnections);
}
[Fact]
public async Task GetPoolStats_ReflectsActiveConnections()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "test",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var pool = new ServerConnectionPool();
// Act
await pool.GetConnectionAsync(serverConfig);
var stats = pool.GetPoolStats();
// Assert
Assert.Equal(1, stats.TotalConnections);
Assert.Equal(1, stats.ActiveConnections);
}
[Fact]
public async Task GetPoolStats_ReflectsIdleConnections()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "test",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var pool = new ServerConnectionPool();
var connection = await pool.GetConnectionAsync(serverConfig);
await pool.ReleaseConnectionAsync(connection);
// Act
var stats = pool.GetPoolStats();
// Assert
Assert.Equal(1, stats.TotalConnections);
Assert.Equal(0, stats.ActiveConnections);
Assert.Equal(1, stats.IdleConnections);
}
[Fact]
public async Task Dispose_DisposesAllConnections()
{
// Arrange
var serverConfig1 = new ServerConfig
{
Id = "test1",
Name = "Test Server 1",
TransportType = "Http",
BaseUrl = "http://localhost:5001"
};
var serverConfig2 = new ServerConfig
{
Id = "test2",
Name = "Test Server 2",
TransportType = "Http",
BaseUrl = "http://localhost:5002"
};
var pool = new ServerConnectionPool();
await pool.GetConnectionAsync(serverConfig1);
await pool.GetConnectionAsync(serverConfig2);
// Act
pool.Dispose();
// Assert - getting stats after dispose should show zero connections
var stats = pool.GetPoolStats();
Assert.Equal(0, stats.TotalConnections);
}
[Fact]
public async Task GetConnectionAsync_DifferentServers_CreatesSeparateConnections()
{
// Arrange
var serverConfig1 = new ServerConfig
{
Id = "test1",
Name = "Test Server 1",
TransportType = "Http",
BaseUrl = "http://localhost:5001"
};
var serverConfig2 = new ServerConfig
{
Id = "test2",
Name = "Test Server 2",
TransportType = "Http",
BaseUrl = "http://localhost:5002"
};
var pool = new ServerConnectionPool();
// Act
var connection1 = await pool.GetConnectionAsync(serverConfig1);
var connection2 = await pool.GetConnectionAsync(serverConfig2);
// Assert
Assert.NotSame(connection1, connection2);
Assert.Equal("test1", connection1.ServerInfo.Id);
Assert.Equal("test2", connection2.ServerInfo.Id);
}
}

View File

@ -0,0 +1,175 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Infrastructure.Connection;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Connection;
/// <summary>
/// Unit tests for ServerConnection following TDD approach.
/// Tests connection lifecycle and request handling.
/// </summary>
public class ServerConnectionTests
{
[Fact]
public async Task ConnectAsync_OpensTransport()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask)
.Callback(() => mockTransport.Setup(t => t.IsConnected).Returns(true));
mockTransport.Setup(t => t.IsConnected).Returns(false); // Initially not connected
var serverConfig = new ServerConfig { Id = "test", Name = "Test Server" };
var connection = new ServerConnection(serverConfig, mockTransport.Object);
// Act
await connection.ConnectAsync();
// Assert
mockTransport.Verify(t => t.ConnectAsync(It.IsAny<CancellationToken>()), Times.Once);
Assert.True(connection.IsConnected);
}
[Fact]
public async Task DisconnectAsync_ClosesTransport()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport.Setup(t => t.DisconnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(false);
var serverConfig = new ServerConfig { Id = "test", Name = "Test Server" };
var connection = new ServerConnection(serverConfig, mockTransport.Object);
// Act
await connection.DisconnectAsync();
// Assert
mockTransport.Verify(t => t.DisconnectAsync(It.IsAny<CancellationToken>()), Times.Once);
Assert.False(connection.IsConnected);
}
[Fact]
public async Task SendRequestAsync_ForwardsToTransport()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
var request = new GatewayRequest { ToolName = "test_tool" };
var expectedResponse = new GatewayResponse { Success = true };
mockTransport
.Setup(t => t.SendRequestAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
mockTransport.Setup(t => t.IsConnected).Returns(true);
var serverConfig = new ServerConfig { Id = "test", Name = "Test Server" };
var connection = new ServerConnection(serverConfig, mockTransport.Object);
// Act
var response = await connection.SendRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.True(response.Success);
mockTransport.Verify(t => t.SendRequestAsync(request, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task SendRequestAsync_WithTimeout_ThrowsOperationCanceledException()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport
.Setup(t => t.SendRequestAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.Returns(async (GatewayRequest req, CancellationToken ct) =>
{
// Respect cancellation token
await Task.Delay(5000, ct);
return new GatewayResponse { Success = true };
});
mockTransport.Setup(t => t.IsConnected).Returns(true);
var serverConfig = new ServerConfig { Id = "test", Name = "Test Server" };
var connection = new ServerConnection(serverConfig, mockTransport.Object) { RequestTimeout = TimeSpan.FromMilliseconds(100) };
var request = new GatewayRequest { ToolName = "test_tool" };
// Act & Assert
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => connection.SendRequestAsync(request));
}
[Fact]
public void ServerInfo_ReturnsCorrectInfo()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport.Setup(t => t.IsConnected).Returns(true);
var serverConfig = new ServerConfig { Id = "test-123", Name = "Test Server" };
var connection = new ServerConnection(serverConfig, mockTransport.Object);
// Act
var info = connection.ServerInfo;
// Assert
Assert.NotNull(info);
Assert.Equal("test-123", info.Id);
Assert.Equal("Test Server", info.Name);
Assert.True(info.IsHealthy);
}
[Fact]
public void IsConnected_ReflectsTransportState()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport.Setup(t => t.IsConnected).Returns(false);
var serverConfig = new ServerConfig { Id = "test", Name = "Test Server" };
var connection = new ServerConnection(serverConfig, mockTransport.Object);
// Act & Assert
Assert.False(connection.IsConnected);
// Change transport state
mockTransport.Setup(t => t.IsConnected).Returns(true);
Assert.True(connection.IsConnected);
}
[Fact]
public async Task ConnectAsync_CalledMultipleTimes_CallsTransportEachTime()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
var serverConfig = new ServerConfig { Id = "test", Name = "Test Server" };
var connection = new ServerConnection(serverConfig, mockTransport.Object);
// Act
await connection.ConnectAsync();
await connection.ConnectAsync(); // Second call
// Assert - ConnectAsync delegates to transport, which handles idempotency
mockTransport.Verify(t => t.ConnectAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public void Dispose_DisposesTransport()
{
// Arrange
var mockTransport = new Mock<IServerTransport>();
var serverConfig = new ServerConfig { Id = "test", Name = "Test Server" };
var connection = new ServerConnection(serverConfig, mockTransport.Object);
// Act
connection.Dispose();
// Assert
mockTransport.Verify(t => t.Dispose(), Times.Once);
}
}

View File

@ -0,0 +1,310 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Infrastructure.Health;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Health;
/// <summary>
/// Unit tests for ActiveHealthChecker following TDD approach.
/// Tests periodic health checks with configurable intervals.
/// </summary>
public class ActiveHealthCheckerTests
{
[Fact]
public async Task CheckHealthAsync_WithHealthyServer_ReturnsHealthyStatus()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo
{
Id = "server-1",
Name = "Test Server",
IsHealthy = true,
ResponseTime = TimeSpan.FromMilliseconds(50)
});
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var checker = new ActiveHealthChecker(mockPool.Object);
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
// Act
var result = await checker.CheckHealthAsync(serverConfig);
// Assert
Assert.NotNull(result);
Assert.Equal("server-1", result.ServerId);
Assert.Equal("Test Server", result.ServerName);
Assert.True(result.IsHealthy);
Assert.NotNull(result.ResponseTime);
}
[Fact]
public async Task CheckHealthAsync_WithUnhealthyServer_ReturnsUnhealthyStatus()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(false);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo
{
Id = "server-2",
Name = "Unhealthy Server",
IsHealthy = false
});
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var checker = new ActiveHealthChecker(mockPool.Object);
var serverConfig = new ServerConfig
{
Id = "server-2",
Name = "Unhealthy Server",
TransportType = "Http",
BaseUrl = "http://localhost:5001"
};
// Act
var result = await checker.CheckHealthAsync(serverConfig);
// Assert
Assert.NotNull(result);
Assert.Equal("server-2", result.ServerId);
Assert.False(result.IsHealthy);
}
[Fact]
public async Task CheckHealthAsync_WithConnectionException_ReturnsUnhealthyStatus()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Connection failed"));
var checker = new ActiveHealthChecker(mockPool.Object);
var serverConfig = new ServerConfig
{
Id = "server-3",
Name = "Failed Server",
TransportType = "Http",
BaseUrl = "http://localhost:5002"
};
// Act
var result = await checker.CheckHealthAsync(serverConfig);
// Assert
Assert.NotNull(result);
Assert.Equal("server-3", result.ServerId);
Assert.False(result.IsHealthy);
Assert.Contains("Connection failed", result.ErrorMessage);
}
[Fact]
public async Task StartMonitoringAsync_BeginsPeriodicChecks()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo
{
Id = "server-1",
Name = "Test Server",
IsHealthy = true
});
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var checker = new ActiveHealthChecker(mockPool.Object)
{
CheckInterval = TimeSpan.FromMilliseconds(100) // Fast interval for testing
};
var serverConfigs = new List<ServerConfig>
{
new ServerConfig { Id = "server-1", Name = "Test Server", TransportType = "Http", BaseUrl = "http://localhost:5000" }
};
// Act
await checker.StartMonitoringAsync(serverConfigs);
await Task.Delay(50); // Allow initial check to start
// Assert - monitoring should be active
var health = await checker.GetCurrentHealthAsync();
Assert.NotNull(health);
Assert.Contains(health, s => s.ServerId == "server-1");
}
[Fact]
public async Task StopMonitoringAsync_EndsPeriodicChecks()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
var checker = new ActiveHealthChecker(mockPool.Object);
var serverConfigs = new List<ServerConfig>
{
new ServerConfig { Id = "server-1", Name = "Test Server", TransportType = "Http", BaseUrl = "http://localhost:5000" }
};
await checker.StartMonitoringAsync(serverConfigs);
// Act
await checker.StopMonitoringAsync();
// Assert - should complete without error
Assert.True(true);
}
[Fact]
public async Task GetCurrentHealthAsync_ReturnsLatestHealthStatus()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo
{
Id = "server-1",
Name = "Test Server",
IsHealthy = true
});
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var checker = new ActiveHealthChecker(mockPool.Object);
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
// Perform a check to populate status
await checker.CheckHealthAsync(serverConfig);
// Act
var health = await checker.GetCurrentHealthAsync();
// Assert
Assert.NotNull(health);
Assert.Single(health);
Assert.Equal("server-1", health.First().ServerId);
Assert.True(health.First().IsHealthy);
}
[Fact]
public async Task StartMonitoringAsync_WithMultipleServers_ChecksAllServers()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
var mockConnection1 = new Mock<IServerConnection>();
var mockConnection2 = new Mock<IServerConnection>();
mockConnection1.Setup(c => c.IsConnected).Returns(true);
mockConnection1.Setup(c => c.ServerInfo).Returns(new ServerInfo { Id = "server-1", IsHealthy = true });
mockConnection2.Setup(c => c.IsConnected).Returns(true);
mockConnection2.Setup(c => c.ServerInfo).Returns(new ServerInfo { Id = "server-2", IsHealthy = true });
mockPool.Setup(p => p.GetConnectionAsync(It.Is<ServerConfig>(sc => sc.Id == "server-1"), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection1.Object);
mockPool.Setup(p => p.GetConnectionAsync(It.Is<ServerConfig>(sc => sc.Id == "server-2"), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection2.Object);
var checker = new ActiveHealthChecker(mockPool.Object)
{
CheckInterval = TimeSpan.FromMilliseconds(100)
};
var serverConfigs = new List<ServerConfig>
{
new ServerConfig { Id = "server-1", Name = "Server 1", TransportType = "Http", BaseUrl = "http://localhost:5000" },
new ServerConfig { Id = "server-2", Name = "Server 2", TransportType = "Http", BaseUrl = "http://localhost:5001" }
};
// Act
await checker.StartMonitoringAsync(serverConfigs);
await Task.Delay(50);
// Assert
var health = await checker.GetCurrentHealthAsync();
Assert.Equal(2, health.Count());
Assert.Contains(health, s => s.ServerId == "server-1");
Assert.Contains(health, s => s.ServerId == "server-2");
await checker.StopMonitoringAsync();
}
[Fact]
public void Constructor_WithNullPool_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ActiveHealthChecker(null!));
}
[Fact]
public async Task CheckHealthAsync_WithNullConfig_ThrowsArgumentNullException()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
var checker = new ActiveHealthChecker(mockPool.Object);
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => checker.CheckHealthAsync(null!));
}
[Fact]
public async Task CheckHealthAsync_SetsLastCheckTime()
{
// Arrange
var mockPool = new Mock<IServerConnectionPool>();
var mockConnection = new Mock<IServerConnection>();
var beforeCheck = DateTime.UtcNow;
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo { Id = "server-1", IsHealthy = true });
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var checker = new ActiveHealthChecker(mockPool.Object);
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
// Act
var result = await checker.CheckHealthAsync(serverConfig);
var afterCheck = DateTime.UtcNow;
// Assert
Assert.True(result.LastCheck >= beforeCheck);
Assert.True(result.LastCheck <= afterCheck);
}
}

View File

@ -0,0 +1,303 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Infrastructure.Health;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Health;
/// <summary>
/// Unit tests for CircuitBreaker following TDD approach.
/// Tests circuit breaker states: Closed, Open, HalfOpen.
/// </summary>
public class CircuitBreakerTests
{
[Fact]
public async Task ExecuteAsync_InClosedState_AllowsExecution()
{
// Arrange
var breaker = new CircuitBreaker
{
FailureThreshold = 3
};
var executed = false;
// Act
await breaker.ExecuteAsync(async () =>
{
executed = true;
await Task.CompletedTask;
});
// Assert
Assert.True(executed);
Assert.Equal(CircuitBreakerState.Closed, breaker.State);
}
[Fact]
public async Task ExecuteAsync_AfterMultipleFailures_OpensCircuit()
{
// Arrange
var breaker = new CircuitBreaker
{
FailureThreshold = 3,
OpenTimeout = TimeSpan.FromSeconds(1)
};
// Act - trigger 3 failures
for (int i = 0; i < 3; i++)
{
try
{
await breaker.ExecuteAsync<object>(async () =>
{
await Task.CompletedTask;
throw new Exception("Simulated failure");
});
}
catch
{
// Expected
}
}
// Assert
Assert.Equal(CircuitBreakerState.Open, breaker.State);
}
[Fact]
public async Task ExecuteAsync_WhenOpen_ThrowsCircuitBreakerOpenException()
{
// Arrange
var breaker = new CircuitBreaker
{
FailureThreshold = 1,
OpenTimeout = TimeSpan.FromSeconds(10)
};
// Trip the breaker
try
{
await breaker.ExecuteAsync<object>(async () =>
{
await Task.CompletedTask;
throw new Exception("Failure");
});
}
catch
{
// Expected
}
// Act & Assert - breaker should be open
await Assert.ThrowsAsync<CircuitBreakerOpenException>(() =>
breaker.ExecuteAsync(async () => await Task.CompletedTask));
}
[Fact]
public async Task ExecuteAsync_AfterTimeout_TransitionsToHalfOpen()
{
// Arrange
var breaker = new CircuitBreaker
{
FailureThreshold = 1,
OpenTimeout = TimeSpan.FromMilliseconds(100),
SuccessThreshold = 1 // Close after 1 success
};
// Trip the breaker
try
{
await breaker.ExecuteAsync<object>(async () =>
{
await Task.CompletedTask;
throw new Exception("Failure");
});
}
catch
{
// Expected
}
Assert.Equal(CircuitBreakerState.Open, breaker.State);
// Act - wait for timeout
await Task.Delay(150);
// Try another execution (should transition to half-open)
var executed = false;
await breaker.ExecuteAsync(async () =>
{
executed = true;
await Task.CompletedTask;
});
// Assert
Assert.True(executed);
Assert.Equal(CircuitBreakerState.Closed, breaker.State); // Successful execution closes the circuit
}
[Fact]
public async Task ExecuteAsync_InHalfOpen_SuccessfulCallClosesCircuit()
{
// Arrange
var breaker = new CircuitBreaker
{
FailureThreshold = 1,
OpenTimeout = TimeSpan.FromMilliseconds(100),
SuccessThreshold = 1
};
// Trip the breaker
try
{
await breaker.ExecuteAsync<object>(async () =>
{
await Task.CompletedTask;
throw new Exception("Failure");
});
}
catch
{
// Expected
}
await Task.Delay(150); // Wait for open timeout
// Act - successful call in half-open
await breaker.ExecuteAsync(async () => await Task.CompletedTask);
// Assert
Assert.Equal(CircuitBreakerState.Closed, breaker.State);
}
[Fact]
public async Task ExecuteAsync_InHalfOpen_FailureReopensCircuit()
{
// Arrange
var breaker = new CircuitBreaker
{
FailureThreshold = 1,
OpenTimeout = TimeSpan.FromMilliseconds(100)
};
// Trip the breaker
try
{
await breaker.ExecuteAsync<object>(async () =>
{
await Task.CompletedTask;
throw new Exception("Failure");
});
}
catch
{
// Expected
}
await Task.Delay(150); // Wait for open timeout (now in half-open)
// Act - fail in half-open
try
{
await breaker.ExecuteAsync<object>(async () =>
{
await Task.CompletedTask;
throw new Exception("Another failure");
});
}
catch
{
// Expected
}
// Assert
Assert.Equal(CircuitBreakerState.Open, breaker.State);
}
[Fact]
public async Task ExecuteAsync_WithReturnValue_ReturnsResult()
{
// Arrange
var breaker = new CircuitBreaker();
var expectedValue = 42;
// Act
var result = await breaker.ExecuteAsync(async () =>
{
await Task.CompletedTask;
return expectedValue;
});
// Assert
Assert.Equal(expectedValue, result);
}
[Fact]
public void Reset_ClosesCircuitAndClearsFailures()
{
// Arrange
var breaker = new CircuitBreaker { FailureThreshold = 1 };
try
{
breaker.ExecuteAsync<object>(async () =>
{
await Task.CompletedTask;
throw new Exception("Failure");
}).GetAwaiter().GetResult();
}
catch
{
// Expected
}
Assert.Equal(CircuitBreakerState.Open, breaker.State);
// Act
breaker.Reset();
// Assert
Assert.Equal(CircuitBreakerState.Closed, breaker.State);
}
[Fact]
public void Constructor_SetsDefaultValues()
{
// Act
var breaker = new CircuitBreaker();
// Assert
Assert.Equal(CircuitBreakerState.Closed, breaker.State);
Assert.Equal(5, breaker.FailureThreshold);
Assert.Equal(3, breaker.SuccessThreshold);
Assert.Equal(TimeSpan.FromSeconds(60), breaker.OpenTimeout);
}
[Fact]
public async Task ExecuteAsync_TracksConcurrentFailures()
{
// Arrange
var breaker = new CircuitBreaker { FailureThreshold = 5 };
var failureCount = 0;
// Act - cause 4 failures (not enough to trip)
for (int i = 0; i < 4; i++)
{
try
{
await breaker.ExecuteAsync<object>(async () =>
{
await Task.CompletedTask;
throw new Exception("Failure");
});
}
catch
{
failureCount++;
}
}
// Assert - should still be closed
Assert.Equal(CircuitBreakerState.Closed, breaker.State);
Assert.Equal(4, failureCount);
}
}

View File

@ -0,0 +1,222 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Infrastructure.Health;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Health;
/// <summary>
/// Unit tests for PassiveHealthTracker following TDD approach.
/// Tests passive health tracking based on response times and errors.
/// </summary>
public class PassiveHealthTrackerTests
{
[Fact]
public void RecordSuccess_UpdatesHealthStatus()
{
// Arrange
var tracker = new PassiveHealthTracker();
var serverId = "server-1";
var responseTime = TimeSpan.FromMilliseconds(50);
// Act
tracker.RecordSuccess(serverId, responseTime);
// Assert
var health = tracker.GetServerHealth(serverId);
Assert.NotNull(health);
Assert.True(health.IsHealthy);
Assert.Equal(responseTime, health.ResponseTime);
}
[Fact]
public void RecordFailure_UpdatesHealthStatus()
{
// Arrange
var tracker = new PassiveHealthTracker
{
UnhealthyThreshold = 1 // Mark unhealthy after 1 failure for this test
};
var serverId = "server-2";
var errorMessage = "Connection timeout";
// Act
tracker.RecordFailure(serverId, errorMessage);
// Assert
var health = tracker.GetServerHealth(serverId);
Assert.NotNull(health);
Assert.False(health.IsHealthy);
Assert.Equal(errorMessage, health.ErrorMessage);
}
[Fact]
public void RecordSuccess_MultipleRequests_CalculatesAverageResponseTime()
{
// Arrange
var tracker = new PassiveHealthTracker();
var serverId = "server-3";
// Act - record 3 successful requests
tracker.RecordSuccess(serverId, TimeSpan.FromMilliseconds(100));
tracker.RecordSuccess(serverId, TimeSpan.FromMilliseconds(200));
tracker.RecordSuccess(serverId, TimeSpan.FromMilliseconds(300));
// Assert
var health = tracker.GetServerHealth(serverId);
Assert.NotNull(health);
Assert.True(health.IsHealthy);
// Average should be around 200ms
Assert.NotNull(health.ResponseTime);
Assert.InRange(health.ResponseTime.Value.TotalMilliseconds, 150, 250);
}
[Fact]
public void RecordFailure_MultipleFailures_MarksServerUnhealthy()
{
// Arrange
var tracker = new PassiveHealthTracker
{
UnhealthyThreshold = 3 // Mark unhealthy after 3 failures
};
var serverId = "server-4";
// Act - record 3 failures
tracker.RecordFailure(serverId, "Error 1");
tracker.RecordFailure(serverId, "Error 2");
tracker.RecordFailure(serverId, "Error 3");
// Assert
var health = tracker.GetServerHealth(serverId);
Assert.NotNull(health);
Assert.False(health.IsHealthy);
}
[Fact]
public void RecordSuccess_AfterFailures_RecoverToHealthy()
{
// Arrange
var tracker = new PassiveHealthTracker
{
UnhealthyThreshold = 2,
HealthyThreshold = 3 // Recover after 3 successes
};
var serverId = "server-5";
// Record failures to make unhealthy
tracker.RecordFailure(serverId, "Error 1");
tracker.RecordFailure(serverId, "Error 2");
// Act - record successes to recover
tracker.RecordSuccess(serverId, TimeSpan.FromMilliseconds(50));
tracker.RecordSuccess(serverId, TimeSpan.FromMilliseconds(50));
tracker.RecordSuccess(serverId, TimeSpan.FromMilliseconds(50));
// Assert
var health = tracker.GetServerHealth(serverId);
Assert.NotNull(health);
Assert.True(health.IsHealthy);
}
[Fact]
public void GetServerHealth_WithUnknownServer_ReturnsNull()
{
// Arrange
var tracker = new PassiveHealthTracker();
// Act
var health = tracker.GetServerHealth("unknown-server");
// Assert
Assert.Null(health);
}
[Fact]
public void GetAllServerHealth_ReturnsAllTrackedServers()
{
// Arrange
var tracker = new PassiveHealthTracker
{
UnhealthyThreshold = 1 // Mark unhealthy after 1 failure for this test
};
tracker.RecordSuccess("server-1", TimeSpan.FromMilliseconds(50));
tracker.RecordSuccess("server-2", TimeSpan.FromMilliseconds(100));
tracker.RecordFailure("server-3", "Connection failed");
// Act
var allHealth = tracker.GetAllServerHealth();
// Assert
Assert.Equal(3, allHealth.Count());
Assert.Contains(allHealth, h => h.ServerId == "server-1" && h.IsHealthy);
Assert.Contains(allHealth, h => h.ServerId == "server-2" && h.IsHealthy);
Assert.Contains(allHealth, h => h.ServerId == "server-3" && !h.IsHealthy);
}
[Fact]
public void RecordSuccess_WithSlowResponse_MarksAsUnhealthy()
{
// Arrange
var tracker = new PassiveHealthTracker
{
SlowResponseThreshold = TimeSpan.FromMilliseconds(100)
};
var serverId = "server-6";
// Act - record slow response
tracker.RecordSuccess(serverId, TimeSpan.FromMilliseconds(500));
// Assert - should still be marked as success, but noted as slow
var health = tracker.GetServerHealth(serverId);
Assert.NotNull(health);
Assert.True(health.IsHealthy); // Still healthy, just slow
Assert.Equal(TimeSpan.FromMilliseconds(500), health.ResponseTime);
}
[Fact]
public void Reset_ClearsAllHealthData()
{
// Arrange
var tracker = new PassiveHealthTracker();
tracker.RecordSuccess("server-1", TimeSpan.FromMilliseconds(50));
tracker.RecordFailure("server-2", "Error");
// Act
tracker.Reset();
// Assert
var allHealth = tracker.GetAllServerHealth();
Assert.Empty(allHealth);
}
[Fact]
public void RecordSuccess_UpdatesLastCheckTime()
{
// Arrange
var tracker = new PassiveHealthTracker();
var serverId = "server-7";
var beforeRecord = DateTime.UtcNow;
// Act
tracker.RecordSuccess(serverId, TimeSpan.FromMilliseconds(50));
var afterRecord = DateTime.UtcNow;
// Assert
var health = tracker.GetServerHealth(serverId);
Assert.NotNull(health);
Assert.True(health.LastCheck >= beforeRecord);
Assert.True(health.LastCheck <= afterRecord);
}
[Fact]
public void Constructor_SetsDefaultThresholds()
{
// Act
var tracker = new PassiveHealthTracker();
// Assert
Assert.Equal(5, tracker.UnhealthyThreshold);
Assert.Equal(3, tracker.HealthyThreshold);
Assert.Equal(TimeSpan.FromSeconds(5), tracker.SlowResponseThreshold);
}
}

View File

@ -0,0 +1,219 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Infrastructure.Routing;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Routing;
/// <summary>
/// Unit tests for ClientBasedStrategy following TDD approach.
/// Tests client-based routing.
/// </summary>
public class ClientBasedStrategyTests
{
[Fact]
public async Task SelectServerAsync_WithExactClientMatch_ReturnsMatchedServer()
{
// Arrange
var clientMapping = new Dictionary<string, string>
{
{ "web-client", "server-1" },
{ "mobile-client", "server-2" }
};
var strategy = new ClientBasedStrategy(clientMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ClientId = "web-client" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.NotNull(selected);
Assert.Equal("server-1", selected.Id);
}
[Fact]
public async Task SelectServerAsync_WithDifferentClients_RoutesDifferently()
{
// Arrange
var clientMapping = new Dictionary<string, string>
{
{ "client-a", "server-1" },
{ "client-b", "server-2" }
};
var strategy = new ClientBasedStrategy(clientMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context1 = new RoutingContext { ClientId = "client-a" };
var context2 = new RoutingContext { ClientId = "client-b" };
// Act
var selected1 = await strategy.SelectServerAsync(servers, context1);
var selected2 = await strategy.SelectServerAsync(servers, context2);
// Assert
Assert.Equal("server-1", selected1!.Id);
Assert.Equal("server-2", selected2!.Id);
}
[Fact]
public async Task SelectServerAsync_WithNoMatch_FallsBackToRoundRobin()
{
// Arrange
var clientMapping = new Dictionary<string, string>
{
{ "known-client", "server-1" }
};
var strategy = new ClientBasedStrategy(clientMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ClientId = "unknown-client" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert - should fall back to round-robin
Assert.NotNull(selected);
}
[Fact]
public async Task SelectServerAsync_WhenMappedServerUnhealthy_SelectsNextHealthy()
{
// Arrange
var clientMapping = new Dictionary<string, string>
{
{ "web-client", "server-1" }
};
var strategy = new ClientBasedStrategy(clientMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = false },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ClientId = "web-client" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert - should select next healthy server
Assert.NotNull(selected);
Assert.Equal("server-2", selected.Id);
}
[Fact]
public async Task SelectServerAsync_WithNullClientId_FallsBackToRoundRobin()
{
// Arrange
var clientMapping = new Dictionary<string, string>
{
{ "web-client", "server-1" }
};
var strategy = new ClientBasedStrategy(clientMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ClientId = null };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.NotNull(selected);
}
[Fact]
public async Task SelectServerAsync_WithEmptyMapping_UsesRoundRobin()
{
// Arrange
var clientMapping = new Dictionary<string, string>();
var strategy = new ClientBasedStrategy(clientMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ClientId = "any-client" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert - should use round-robin
Assert.NotNull(selected);
}
[Fact]
public async Task SelectServerAsync_SameClientMultipleTimes_RoutesSameServer()
{
// Arrange
var clientMapping = new Dictionary<string, string>
{
{ "sticky-client", "server-1" }
};
var strategy = new ClientBasedStrategy(clientMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ClientId = "sticky-client" };
// Act
var selected1 = await strategy.SelectServerAsync(servers, context);
var selected2 = await strategy.SelectServerAsync(servers, context);
var selected3 = await strategy.SelectServerAsync(servers, context);
// Assert - should always route to same server (sticky sessions)
Assert.Equal("server-1", selected1!.Id);
Assert.Equal("server-1", selected2!.Id);
Assert.Equal("server-1", selected3!.Id);
}
[Fact]
public async Task SelectServerAsync_WithNoHealthyServers_ReturnsNull()
{
// Arrange
var clientMapping = new Dictionary<string, string>
{
{ "web-client", "server-1" }
};
var strategy = new ClientBasedStrategy(clientMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = false }
};
var context = new RoutingContext { ClientId = "web-client" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.Null(selected);
}
}

View File

@ -0,0 +1,322 @@
using Xunit;
using Moq;
using OpenHarbor.MCP.Gateway.Infrastructure.Routing;
using OpenHarbor.MCP.Gateway.Infrastructure.Connection;
using OpenHarbor.MCP.Gateway.Core.Interfaces;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Routing;
/// <summary>
/// Unit tests for GatewayRouter following TDD approach.
/// Tests routing, server registration, and health tracking.
/// </summary>
public class GatewayRouterTests
{
[Fact]
public async Task RegisterServerAsync_AddsServerToPool()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
var mockPool = new Mock<IServerConnectionPool>();
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
// Act
await router.RegisterServerAsync(serverConfig);
// Assert
var health = await router.GetServerHealthAsync();
Assert.Contains(health, s => s.ServerId == "server-1");
}
[Fact]
public async Task UnregisterServerAsync_RemovesServerFromPool()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
var mockPool = new Mock<IServerConnectionPool>();
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
await router.RegisterServerAsync(serverConfig);
// Act
var result = await router.UnregisterServerAsync("server-1");
// Assert
Assert.True(result);
var health = await router.GetServerHealthAsync();
Assert.DoesNotContain(health, s => s.ServerId == "server-1");
}
[Fact]
public async Task UnregisterServerAsync_WithNonExistentServer_ReturnsFalse()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
var mockPool = new Mock<IServerConnectionPool>();
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
// Act
var result = await router.UnregisterServerAsync("non-existent");
// Assert
Assert.False(result);
}
[Fact]
public async Task RouteAsync_WithValidRequest_RoutesToSelectedServer()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo { Id = "server-1", Name = "Test Server", IsHealthy = true });
mockConnection.Setup(c => c.SendRequestAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = true });
var mockPool = new Mock<IServerConnectionPool>();
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var mockStrategy = new Mock<IRoutingStrategy>();
mockStrategy.Setup(s => s.SelectServerAsync(It.IsAny<IEnumerable<ServerInfo>>(), It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServerInfo { Id = "server-1", Name = "Test Server", IsHealthy = true });
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
await router.RegisterServerAsync(serverConfig);
var request = new GatewayRequest { ToolName = "test_tool" };
// Act
var response = await router.RouteAsync(request);
// Assert
Assert.NotNull(response);
Assert.True(response.Success);
mockConnection.Verify(c => c.SendRequestAsync(request, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RouteAsync_WithNoHealthyServers_ReturnsErrorResponse()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
mockStrategy.Setup(s => s.SelectServerAsync(It.IsAny<IEnumerable<ServerInfo>>(), It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ServerInfo?)null);
var mockPool = new Mock<ServerConnectionPool>();
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
var request = new GatewayRequest { ToolName = "test_tool" };
// Act
var response = await router.RouteAsync(request);
// Assert
Assert.NotNull(response);
Assert.False(response.Success);
Assert.Contains("No healthy servers", response.Error);
}
[Fact]
public async Task GetServerHealthAsync_ReturnsAllRegisteredServers()
{
// Arrange
var mockStrategy = new Mock<IRoutingStrategy>();
var mockPool = new Mock<IServerConnectionPool>();
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
var server1 = new ServerConfig { Id = "server-1", Name = "Server 1", TransportType = "Http", BaseUrl = "http://localhost:5000" };
var server2 = new ServerConfig { Id = "server-2", Name = "Server 2", TransportType = "Http", BaseUrl = "http://localhost:5001" };
await router.RegisterServerAsync(server1);
await router.RegisterServerAsync(server2);
// Act
var health = await router.GetServerHealthAsync();
// Assert
Assert.Equal(2, health.Count());
Assert.Contains(health, s => s.ServerId == "server-1");
Assert.Contains(health, s => s.ServerId == "server-2");
}
[Fact]
public async Task RouteAsync_UsesRoutingStrategy()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "selected-server",
Name = "Selected Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo { Id = "selected-server", IsHealthy = true });
mockConnection.Setup(c => c.SendRequestAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = true });
var mockPool = new Mock<IServerConnectionPool>();
mockPool.Setup(p => p.GetConnectionAsync(It.Is<ServerConfig>(sc => sc.Id == "selected-server"), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var mockStrategy = new Mock<IRoutingStrategy>();
mockStrategy.Setup(s => s.SelectServerAsync(It.IsAny<IEnumerable<ServerInfo>>(), It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServerInfo { Id = "selected-server", IsHealthy = true });
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
await router.RegisterServerAsync(serverConfig);
var request = new GatewayRequest { ToolName = "test_tool", ClientId = "test-client" };
// Act
await router.RouteAsync(request);
// Assert - verify strategy was called
mockStrategy.Verify(s => s.SelectServerAsync(
It.IsAny<IEnumerable<ServerInfo>>(),
It.Is<RoutingContext>(rc => rc.ToolName == "test_tool" && rc.ClientId == "test-client"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RouteAsync_SetsServerIdInResponse()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo { Id = "server-1", IsHealthy = true });
mockConnection.Setup(c => c.SendRequestAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = true });
var mockPool = new Mock<IServerConnectionPool>();
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var mockStrategy = new Mock<IRoutingStrategy>();
mockStrategy.Setup(s => s.SelectServerAsync(It.IsAny<IEnumerable<ServerInfo>>(), It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServerInfo { Id = "server-1", IsHealthy = true });
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
await router.RegisterServerAsync(serverConfig);
var request = new GatewayRequest { ToolName = "test_tool" };
// Act
var response = await router.RouteAsync(request);
// Assert
Assert.Equal("server-1", response.ServerId);
}
[Fact]
public async Task RouteAsync_TracksPassiveHealth_OnSuccess()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo { Id = "server-1", IsHealthy = true, ResponseTime = TimeSpan.FromMilliseconds(50) });
mockConnection.Setup(c => c.SendRequestAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GatewayResponse { Success = true });
var mockPool = new Mock<IServerConnectionPool>();
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var mockStrategy = new Mock<IRoutingStrategy>();
mockStrategy.Setup(s => s.SelectServerAsync(It.IsAny<IEnumerable<ServerInfo>>(), It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServerInfo { Id = "server-1", IsHealthy = true });
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
await router.RegisterServerAsync(serverConfig);
var request = new GatewayRequest { ToolName = "test_tool" };
// Act
await router.RouteAsync(request);
// Assert - passive health should be tracked (implementation will verify this)
mockConnection.Verify(c => c.SendRequestAsync(request, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RouteAsync_TracksPassiveHealth_OnFailure()
{
// Arrange
var serverConfig = new ServerConfig
{
Id = "server-1",
Name = "Test Server",
TransportType = "Http",
BaseUrl = "http://localhost:5000"
};
var mockConnection = new Mock<IServerConnection>();
mockConnection.Setup(c => c.IsConnected).Returns(true);
mockConnection.Setup(c => c.ServerInfo).Returns(new ServerInfo { Id = "server-1", IsHealthy = true });
mockConnection.Setup(c => c.SendRequestAsync(It.IsAny<GatewayRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Request failed"));
var mockPool = new Mock<IServerConnectionPool>();
mockPool.Setup(p => p.GetConnectionAsync(It.IsAny<ServerConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockConnection.Object);
var mockStrategy = new Mock<IRoutingStrategy>();
mockStrategy.Setup(s => s.SelectServerAsync(It.IsAny<IEnumerable<ServerInfo>>(), It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServerInfo { Id = "server-1", IsHealthy = true });
var router = new GatewayRouter(mockStrategy.Object, mockPool.Object);
await router.RegisterServerAsync(serverConfig);
var request = new GatewayRequest { ToolName = "test_tool" };
// Act
var response = await router.RouteAsync(request);
// Assert
Assert.False(response.Success);
Assert.Contains("Request failed", response.Error);
}
}

View File

@ -0,0 +1,162 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Infrastructure.Routing;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Routing;
/// <summary>
/// Unit tests for RoundRobinStrategy following TDD approach.
/// Tests round-robin server selection.
/// </summary>
public class RoundRobinStrategyTests
{
[Fact]
public async Task SelectServerAsync_WithOneServer_ReturnsServer()
{
// Arrange
var strategy = new RoundRobinStrategy();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true }
};
var context = new RoutingContext();
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.NotNull(selected);
Assert.Equal("server-1", selected.Id);
}
[Fact]
public async Task SelectServerAsync_WithMultipleServers_RotatesServers()
{
// Arrange
var strategy = new RoundRobinStrategy();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true },
new ServerInfo { Id = "server-3", IsHealthy = true }
};
var context = new RoutingContext();
// Act
var selected1 = await strategy.SelectServerAsync(servers, context);
var selected2 = await strategy.SelectServerAsync(servers, context);
var selected3 = await strategy.SelectServerAsync(servers, context);
var selected4 = await strategy.SelectServerAsync(servers, context);
// Assert - should rotate through all servers
Assert.Equal("server-1", selected1.Id);
Assert.Equal("server-2", selected2.Id);
Assert.Equal("server-3", selected3.Id);
Assert.Equal("server-1", selected4.Id); // Back to first
}
[Fact]
public async Task SelectServerAsync_WithNoHealthyServers_ReturnsNull()
{
// Arrange
var strategy = new RoundRobinStrategy();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = false },
new ServerInfo { Id = "server-2", IsHealthy = false }
};
var context = new RoutingContext();
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.Null(selected);
}
[Fact]
public async Task SelectServerAsync_WithEmptyList_ReturnsNull()
{
// Arrange
var strategy = new RoundRobinStrategy();
var servers = new List<ServerInfo>();
var context = new RoutingContext();
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.Null(selected);
}
[Fact]
public async Task SelectServerAsync_OnlySelectsHealthyServers()
{
// Arrange
var strategy = new RoundRobinStrategy();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = false },
new ServerInfo { Id = "server-3", IsHealthy = true }
};
var context = new RoutingContext();
// Act
var selected1 = await strategy.SelectServerAsync(servers, context);
var selected2 = await strategy.SelectServerAsync(servers, context);
var selected3 = await strategy.SelectServerAsync(servers, context);
// Assert - should only select healthy servers
Assert.Equal("server-1", selected1.Id);
Assert.Equal("server-3", selected2.Id);
Assert.Equal("server-1", selected3.Id); // Back to first healthy
}
[Fact]
public async Task SelectServerAsync_WithMixedHealth_SkipsUnhealthy()
{
// Arrange
var strategy = new RoundRobinStrategy();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = false },
new ServerInfo { Id = "server-2", IsHealthy = true },
new ServerInfo { Id = "server-3", IsHealthy = false },
new ServerInfo { Id = "server-4", IsHealthy = true }
};
var context = new RoutingContext();
// Act
var selected1 = await strategy.SelectServerAsync(servers, context);
var selected2 = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.Equal("server-2", selected1.Id);
Assert.Equal("server-4", selected2.Id);
}
[Fact]
public async Task SelectServerAsync_IsThreadSafe()
{
// Arrange
var strategy = new RoundRobinStrategy();
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext();
// Act - call concurrently from multiple tasks
var tasks = Enumerable.Range(0, 100)
.Select(_ => strategy.SelectServerAsync(servers, context))
.ToList();
var results = await Task.WhenAll(tasks);
// Assert - all calls should complete successfully
Assert.Equal(100, results.Length);
Assert.All(results, r => Assert.NotNull(r));
}
}

View File

@ -0,0 +1,218 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Infrastructure.Routing;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Routing;
/// <summary>
/// Unit tests for ToolBasedStrategy following TDD approach.
/// Tests tool-based routing with pattern matching.
/// </summary>
public class ToolBasedStrategyTests
{
[Fact]
public async Task SelectServerAsync_WithExactMatch_ReturnsMatchedServer()
{
// Arrange
var toolMapping = new Dictionary<string, string>
{
{ "search_documents", "server-1" },
{ "get_document", "server-2" }
};
var strategy = new ToolBasedStrategy(toolMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ToolName = "search_documents" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.NotNull(selected);
Assert.Equal("server-1", selected.Id);
}
[Fact]
public async Task SelectServerAsync_WithWildcardPattern_MatchesCorrectly()
{
// Arrange
var toolMapping = new Dictionary<string, string>
{
{ "search_*", "server-1" },
{ "get_*", "server-2" }
};
var strategy = new ToolBasedStrategy(toolMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context1 = new RoutingContext { ToolName = "search_documents" };
var context2 = new RoutingContext { ToolName = "get_user" };
// Act
var selected1 = await strategy.SelectServerAsync(servers, context1);
var selected2 = await strategy.SelectServerAsync(servers, context2);
// Assert
Assert.Equal("server-1", selected1!.Id);
Assert.Equal("server-2", selected2!.Id);
}
[Fact]
public async Task SelectServerAsync_WithNoMatch_FallsBackToRoundRobin()
{
// Arrange
var toolMapping = new Dictionary<string, string>
{
{ "search_*", "server-1" }
};
var strategy = new ToolBasedStrategy(toolMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ToolName = "unknown_tool" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert - should fall back to round-robin (first healthy server)
Assert.NotNull(selected);
Assert.True(selected.Id == "server-1" || selected.Id == "server-2");
}
[Fact]
public async Task SelectServerAsync_WhenMappedServerUnhealthy_SelectsNextHealthy()
{
// Arrange
var toolMapping = new Dictionary<string, string>
{
{ "search_documents", "server-1" }
};
var strategy = new ToolBasedStrategy(toolMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = false },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ToolName = "search_documents" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert - should select next healthy server
Assert.NotNull(selected);
Assert.Equal("server-2", selected.Id);
}
[Fact]
public async Task SelectServerAsync_WithMultiplePatterns_UsesFirstMatch()
{
// Arrange
var toolMapping = new Dictionary<string, string>
{
{ "search_*", "server-1" },
{ "*_documents", "server-2" }
};
var strategy = new ToolBasedStrategy(toolMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ToolName = "search_documents" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert - should use first matching pattern
Assert.NotNull(selected);
Assert.Equal("server-1", selected.Id);
}
[Fact]
public async Task SelectServerAsync_WithNullToolName_FallsBackToRoundRobin()
{
// Arrange
var toolMapping = new Dictionary<string, string>
{
{ "search_*", "server-1" }
};
var strategy = new ToolBasedStrategy(toolMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ToolName = null };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.NotNull(selected);
}
[Fact]
public async Task SelectServerAsync_WithEmptyMapping_UsesRoundRobin()
{
// Arrange
var toolMapping = new Dictionary<string, string>();
var strategy = new ToolBasedStrategy(toolMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = true },
new ServerInfo { Id = "server-2", IsHealthy = true }
};
var context = new RoutingContext { ToolName = "any_tool" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert - should use round-robin
Assert.NotNull(selected);
}
[Fact]
public async Task SelectServerAsync_WithNoHealthyServers_ReturnsNull()
{
// Arrange
var toolMapping = new Dictionary<string, string>
{
{ "search_*", "server-1" }
};
var strategy = new ToolBasedStrategy(toolMapping);
var servers = new List<ServerInfo>
{
new ServerInfo { Id = "server-1", IsHealthy = false }
};
var context = new RoutingContext { ToolName = "search_documents" };
// Act
var selected = await strategy.SelectServerAsync(servers, context);
// Assert
Assert.Null(selected);
}
}

View File

@ -0,0 +1,208 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Infrastructure.Security;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Security;
/// <summary>
/// Unit tests for ApiKeyAuthProvider following TDD approach.
/// Tests API key-based authentication.
/// </summary>
public class ApiKeyAuthProviderTests
{
[Fact]
public async Task AuthenticateAsync_WithValidApiKey_ReturnsSuccess()
{
// Arrange
var validKeys = new Dictionary<string, string>
{
["client-1"] = "secret-key-123",
["client-2"] = "secret-key-456"
};
var provider = new ApiKeyAuthProvider(validKeys);
var context = new AuthenticationContext
{
ClientId = "client-1",
Credentials = "secret-key-123"
};
// Act
var result = await provider.AuthenticateAsync(context);
// Assert
Assert.True(result.IsAuthenticated);
Assert.Equal("client-1", result.ClientId);
Assert.Null(result.ErrorMessage);
}
[Fact]
public async Task AuthenticateAsync_WithInvalidApiKey_ReturnsFailure()
{
// Arrange
var validKeys = new Dictionary<string, string>
{
["client-1"] = "secret-key-123"
};
var provider = new ApiKeyAuthProvider(validKeys);
var context = new AuthenticationContext
{
ClientId = "client-1",
Credentials = "wrong-key"
};
// Act
var result = await provider.AuthenticateAsync(context);
// Assert
Assert.False(result.IsAuthenticated);
Assert.Null(result.ClientId);
Assert.NotNull(result.ErrorMessage);
Assert.Contains("Invalid API key", result.ErrorMessage);
}
[Fact]
public async Task AuthenticateAsync_WithUnknownClient_ReturnsFailure()
{
// Arrange
var validKeys = new Dictionary<string, string>
{
["client-1"] = "secret-key-123"
};
var provider = new ApiKeyAuthProvider(validKeys);
var context = new AuthenticationContext
{
ClientId = "unknown-client",
Credentials = "any-key"
};
// Act
var result = await provider.AuthenticateAsync(context);
// Assert
Assert.False(result.IsAuthenticated);
Assert.Contains("Unknown client", result.ErrorMessage);
}
[Fact]
public async Task AuthenticateAsync_WithNullCredentials_ReturnsFailure()
{
// Arrange
var provider = new ApiKeyAuthProvider(new Dictionary<string, string>
{
["client-1"] = "secret-key-123"
});
var context = new AuthenticationContext
{
ClientId = "client-1",
Credentials = null
};
// Act
var result = await provider.AuthenticateAsync(context);
// Assert
Assert.False(result.IsAuthenticated);
Assert.Contains("credentials", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AuthenticateAsync_WithEmptyClientId_ReturnsFailure()
{
// Arrange
var provider = new ApiKeyAuthProvider(new Dictionary<string, string>
{
["client-1"] = "secret-key-123"
});
var context = new AuthenticationContext
{
ClientId = "",
Credentials = "secret-key-123"
};
// Act
var result = await provider.AuthenticateAsync(context);
// Assert
Assert.False(result.IsAuthenticated);
Assert.Contains("Client ID", result.ErrorMessage);
}
[Fact]
public void Constructor_WithNullKeys_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ApiKeyAuthProvider(null!));
}
[Fact]
public async Task AuthenticateAsync_WithNullContext_ThrowsArgumentNullException()
{
// Arrange
var provider = new ApiKeyAuthProvider(new Dictionary<string, string>());
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => provider.AuthenticateAsync(null!));
}
[Fact]
public async Task AuthenticateAsync_CaseSensitiveApiKey_ReturnsFailure()
{
// Arrange
var validKeys = new Dictionary<string, string>
{
["client-1"] = "SecretKey123"
};
var provider = new ApiKeyAuthProvider(validKeys);
var context = new AuthenticationContext
{
ClientId = "client-1",
Credentials = "secretkey123" // Wrong case
};
// Act
var result = await provider.AuthenticateAsync(context);
// Assert
Assert.False(result.IsAuthenticated);
}
[Fact]
public async Task AuthorizeAsync_WithValidClient_ReturnsSuccess()
{
// Arrange
var validKeys = new Dictionary<string, string>
{
["client-1"] = "secret-key-123"
};
var provider = new ApiKeyAuthProvider(validKeys);
var context = new AuthorizationContext
{
ClientId = "client-1",
Resource = "search_codex",
Action = "invoke"
};
// Act
var result = await provider.AuthorizeAsync(context);
// Assert
Assert.True(result.IsAuthorized);
}
[Fact]
public async Task AuthorizeAsync_WithNullContext_ThrowsArgumentNullException()
{
// Arrange
var provider = new ApiKeyAuthProvider(new Dictionary<string, string>());
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => provider.AuthorizeAsync(null!));
}
}

View File

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OpenHarbor.MCP.Gateway.Infrastructure\OpenHarbor.MCP.Gateway.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\OpenHarbor.MCP.Gateway.Core\OpenHarbor.MCP.Gateway.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,176 @@
using Xunit;
using Moq;
using Moq.Protected;
using System.Net;
using OpenHarbor.MCP.Gateway.Infrastructure.Transport;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Transport;
/// <summary>
/// Unit tests for HttpServerTransport following TDD approach.
/// Tests HTTP transport implementation.
/// </summary>
public class HttpServerTransportTests
{
[Fact]
public void Constructor_WithValidUrl_CreatesSuccessfully()
{
// Arrange & Act
var transport = new HttpServerTransport("http://localhost:5000");
// Assert
Assert.NotNull(transport);
Assert.False(transport.IsConnected);
}
[Fact]
public void Constructor_WithNullUrl_ThrowsException()
{
// Arrange, Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new HttpServerTransport(null!));
}
[Fact]
public async Task ConnectAsync_WithHealthyServer_SetsConnected()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object);
var transport = new HttpServerTransport("http://localhost:5000", httpClient);
// Act
await transport.ConnectAsync();
// Assert
Assert.True(transport.IsConnected);
}
[Fact]
public async Task SendRequestAsync_WithSuccessfulResponse_ReturnsResponse()
{
// Arrange
var expectedResponse = new GatewayResponse
{
Success = true,
Result = new Dictionary<string, object> { { "data", "test" } }
};
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler.Protected()
.SetupSequence<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage // Health check
{
StatusCode = HttpStatusCode.OK
})
.ReturnsAsync(new HttpResponseMessage // Actual request
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(expectedResponse))
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object);
var transport = new HttpServerTransport("http://localhost:5000", httpClient);
await transport.ConnectAsync();
var request = new GatewayRequest { ToolName = "test_tool" };
// Act
var response = await transport.SendRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.True(response.Success);
}
[Fact]
public async Task SendRequestAsync_WithHttpError_ReturnsErrorResponse()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler.Protected()
.SetupSequence<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage // Health check
{
StatusCode = HttpStatusCode.OK
})
.ReturnsAsync(new HttpResponseMessage // Actual request
{
StatusCode = HttpStatusCode.InternalServerError,
ReasonPhrase = "Internal Server Error"
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object);
var transport = new HttpServerTransport("http://localhost:5000", httpClient);
await transport.ConnectAsync();
var request = new GatewayRequest { ToolName = "test_tool" };
// Act
var response = await transport.SendRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.False(response.Success);
Assert.Contains("InternalServerError", response.Error);
}
[Fact]
public async Task SendRequestAsync_WithoutConnect_ThrowsException()
{
// Arrange
var transport = new HttpServerTransport("http://localhost:5000");
var request = new GatewayRequest { ToolName = "test" };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
transport.SendRequestAsync(request));
}
[Fact]
public async Task DisconnectAsync_SetsNotConnected()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object);
var transport = new HttpServerTransport("http://localhost:5000", httpClient);
await transport.ConnectAsync();
Assert.True(transport.IsConnected);
// Act
await transport.DisconnectAsync();
// Assert
Assert.False(transport.IsConnected);
}
}

View File

@ -0,0 +1,64 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Infrastructure.Transport;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Transport;
/// <summary>
/// Unit tests for StdioServerTransport following TDD approach.
/// Tests stdio transport implementation.
/// </summary>
public class StdioServerTransportTests
{
[Fact]
public void Constructor_WithValidCommand_CreatesSuccessfully()
{
// Arrange & Act
var transport = new StdioServerTransport("dotnet", new[] { "run" });
// Assert
Assert.NotNull(transport);
Assert.False(transport.IsConnected);
}
[Fact]
public void Constructor_WithNullCommand_ThrowsException()
{
// Arrange, Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new StdioServerTransport(null!, new[] { "arg" }));
}
[Fact]
public void IsConnected_BeforeConnect_ReturnsFalse()
{
// Arrange
var transport = new StdioServerTransport("echo", new[] { "test" });
// Act & Assert
Assert.False(transport.IsConnected);
}
[Fact]
public async Task SendRequestAsync_WithoutConnect_ThrowsException()
{
// Arrange
var transport = new StdioServerTransport("echo", new[] { "test" });
var request = new GatewayRequest { ToolName = "test" };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
transport.SendRequestAsync(request, CancellationToken.None));
}
[Fact]
public void Dispose_MultipleTimesests_DoesNotThrow()
{
// Arrange
var transport = new StdioServerTransport("echo", new[] { "test" });
// Act & Assert
transport.Dispose();
transport.Dispose(); // Should not throw
}
}

View File

@ -0,0 +1,128 @@
using Xunit;
using OpenHarbor.MCP.Gateway.Infrastructure.Transport;
using OpenHarbor.MCP.Gateway.Core.Models;
namespace OpenHarbor.MCP.Gateway.Infrastructure.Tests.Transport;
/// <summary>
/// Unit tests for transport factory logic.
/// Tests transport creation based on server configuration.
/// </summary>
public class TransportFactoryTests
{
[Fact]
public void StdioTransport_WithValidCommand_CreatesSuccessfully()
{
// Arrange & Act
var transport = new StdioServerTransport("echo", new[] { "test" });
// Assert
Assert.NotNull(transport);
Assert.False(transport.IsConnected);
}
[Fact]
public void StdioTransport_WithEmptyArgs_CreatesSuccessfully()
{
// Arrange & Act
var transport = new StdioServerTransport("ls", Array.Empty<string>());
// Assert
Assert.NotNull(transport);
}
[Fact]
public void HttpTransport_WithValidUrl_CreatesSuccessfully()
{
// Arrange & Act
var transport = new HttpServerTransport("http://localhost:8080");
// Assert
Assert.NotNull(transport);
Assert.False(transport.IsConnected);
}
[Fact]
public void HttpTransport_WithHttpsUrl_CreatesSuccessfully()
{
// Arrange & Act
var transport = new HttpServerTransport("https://api.example.com");
// Assert
Assert.NotNull(transport);
}
[Fact]
public void StdioTransport_WithNullArgs_UsesEmptyArray()
{
// Arrange & Act
var transport = new StdioServerTransport("echo", null!);
// Assert
Assert.NotNull(transport);
}
[Theory]
[InlineData("http://localhost:5000")]
[InlineData("https://api.example.com:8443")]
[InlineData("http://192.168.1.1:3000")]
public void HttpTransport_WithVariousUrls_CreatesSuccessfully(string baseUrl)
{
// Arrange & Act
var transport = new HttpServerTransport(baseUrl);
// Assert
Assert.NotNull(transport);
Assert.False(transport.IsConnected);
}
[Theory]
[InlineData("dotnet", "run")]
[InlineData("python3", "server.py")]
[InlineData("node", "index.js")]
public void StdioTransport_WithVariousCommands_CreatesSuccessfully(string command, string arg)
{
// Arrange & Act
var transport = new StdioServerTransport(command, new[] { arg });
// Assert
Assert.NotNull(transport);
}
[Fact]
public void StdioTransport_Dispose_CanBeCalledMultipleTimes()
{
// Arrange
var transport = new StdioServerTransport("echo", new[] { "test" });
// Act & Assert - should not throw
transport.Dispose();
transport.Dispose();
}
[Fact]
public void HttpTransport_Dispose_CanBeCalledMultipleTimes()
{
// Arrange
var httpClient = new HttpClient();
var transport = new HttpServerTransport("http://localhost:5000", httpClient);
// Act & Assert - should not throw
transport.Dispose();
transport.Dispose();
}
[Fact]
public void HttpTransport_WithCustomHttpClient_UsesProvidedClient()
{
// Arrange
var customHttpClient = new HttpClient();
customHttpClient.Timeout = TimeSpan.FromSeconds(5);
// Act
var transport = new HttpServerTransport("http://localhost:5000", customHttpClient);
// Assert
Assert.NotNull(transport);
}
}