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 516e1479c6
55 changed files with 10465 additions and 0 deletions

202
.editorconfig Normal file
View File

@ -0,0 +1,202 @@
# EditorConfig is awesome: https://EditorConfig.org
root = true
# All files
[*]
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
# Code files
[*.{cs,csx,vb,vbx}]
indent_size = 4
# XML project files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
indent_size = 2
# XML config files
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
indent_size = 2
# JSON files
[*.json]
indent_size = 2
# YAML files
[*.{yml,yaml}]
indent_size = 2
# Markdown files
[*.md]
trim_trailing_whitespace = false
# Shell scripts
[*.sh]
end_of_line = lf
# Dotnet code style settings
[*.{cs,vb}]
# Code quality
dotnet_code_quality_unused_parameters = all:suggestion
dotnet_style_readonly_field = true:suggestion
# Organize usings
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = true
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
dotnet_style_predefined_type_for_member_access = true:warning
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
# Expression-level preferences
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
# C# files
[*.cs]
# Var preferences
csharp_style_var_elsewhere = true:suggestion
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_prefer_static_local_function = true:suggestion
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_pattern_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# Formatting rules
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
# Naming conventions
dotnet_naming_rule.interfaces_should_be_prefixed_with_i.severity = warning
dotnet_naming_rule.interfaces_should_be_prefixed_with_i.symbols = interface
dotnet_naming_rule.interfaces_should_be_prefixed_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = warning
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case

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
*~

832
AGENT-PRIMER.md Normal file
View File

@ -0,0 +1,832 @@
# AGENT-PRIMER: OpenHarbor.MCP Automated Setup
**Purpose**: This document guides AI agents to automatically analyze a target system and configure OpenHarbor.MCP integration with minimal human intervention.
**Target Audience**: AI assistants (Claude, ChatGPT, etc.) helping developers integrate MCP server capabilities into .NET applications.
---
## Overview
OpenHarbor.MCP is a **standalone, reusable .NET library** that adds Model Context Protocol (MCP) server capabilities to any .NET application. This primer enables AI-automated configuration by walking through system analysis, configuration generation, and validation.
**What you'll automate:**
1. System analysis (detect .NET version, project type, dependencies)
2. Configuration file generation (appsettings.json, mcp-config.json, Program.cs)
3. Sample MCP tool creation based on detected features
4. Environment setup and validation
---
## Step 1: System Analysis
**Goal**: Understand the target system to generate appropriate configuration.
### Tasks for AI Agent:
#### 1.1 Detect .NET Environment
```bash
# Check .NET SDK version
dotnet --version
# List installed SDKs
dotnet --list-sdks
# List installed runtimes
dotnet --list-runtimes
```
**Required**: .NET 8.0 SDK or higher
**Action if missing**: Provide installation instructions for user's OS
#### 1.2 Analyze Project Structure
```bash
# Find .csproj files
find . -name "*.csproj" -type f
# Examine project type
grep -E "<Project Sdk|<TargetFramework>" *.csproj
```
**Detect**:
- Project type (Web API, Console, Worker Service, etc.)
- Target framework (net8.0, net9.0)
- Existing dependencies
#### 1.3 Identify Integration Points
```bash
# Check for existing HTTP endpoints
grep -r "MapControllers\|MapGet\|MapPost" --include="*.cs"
# Check for dependency injection setup
grep -r "AddScoped\|AddSingleton\|AddTransient" --include="*.cs"
# Detect database usage
grep -r "DbContext\|AddDbContext" --include="*.cs"
```
**Output**: JSON summary of detected features
```json
{
"dotnetVersion": "8.0.100",
"projectType": "AspNetCore.WebApi",
"targetFramework": "net8.0",
"features": {
"hasDatabase": true,
"databaseProvider": "PostgreSQL",
"hasAuthentication": true,
"hasSwagger": true,
"hasCQRS": true
},
"dependencies": [
"Microsoft.EntityFrameworkCore",
"Npgsql.EntityFrameworkCore.PostgreSQL",
"Swashbuckle.AspNetCore"
]
}
```
---
## Step 2: Generate Configuration
**Goal**: Create configuration files tailored to the detected system.
### 2.1 NuGet Package References
**Add to project's .csproj**:
```xml
<ItemGroup>
<!-- Core MCP library -->
<ProjectReference Include="/path/to/OpenHarbor.MCP/src/OpenHarbor.MCP.AspNetCore/OpenHarbor.MCP.AspNetCore.csproj" />
<!-- OR use NuGet when published -->
<!-- <PackageReference Include="OpenHarbor.MCP.AspNetCore" Version="1.0.0" /> -->
</ItemGroup>
```
**Installation command**:
```bash
# Via project reference (development)
dotnet add reference /path/to/OpenHarbor.MCP/src/OpenHarbor.MCP.AspNetCore/OpenHarbor.MCP.AspNetCore.csproj
# Via NuGet (when published)
# dotnet add package OpenHarbor.MCP.AspNetCore
```
### 2.2 appsettings.json Configuration
**Create/update `appsettings.json`** with MCP section:
```json
{
"Mcp": {
"Server": {
"Name": "YourAppMcpServer",
"Version": "1.0.0",
"Description": "MCP server for YourApp - provides AI agents access to application features",
"Vendor": "YourCompany"
},
"Transport": {
"Type": "Http",
"Options": {
"BufferSize": 8192,
"EnableLogging": true
}
},
"Security": {
"EnablePermissions": true,
"DefaultDenyAll": true,
"RateLimit": {
"Enabled": true,
"RequestsPerMinute": 60,
"BurstSize": 10
},
"AuditLogging": {
"Enabled": true,
"LogLevel": "Information"
}
},
"Agents": {
"Default": {
"Permissions": ["tools:list", "tools:execute"],
"RateLimitOverride": null
}
}
}
}
```
**Customize based on detected features**:
- **If database detected**: Add `"tools:database:*"` permissions for agents needing DB access
- **If authentication exists**: Add agent-specific scopes
- **If production**: Set `"DefaultDenyAll": true` and explicit permissions only
### 2.3 mcp-config.json (Optional)
**Create standalone MCP configuration** (CLI usage):
```json
{
"mcpServers": {
"yourapp": {
"command": "dotnet",
"args": ["run", "--project", "/path/to/YourApp.csproj"],
"transport": "http",
"permissions": {
"allowedTools": ["tool1", "tool2"],
"rateLimitRpm": 60
}
}
}
}
```
### 2.4 Program.cs Integration
**Add MCP services to dependency injection**:
#### For ASP.NET Core Web API:
```csharp
using OpenHarbor.MCP.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Existing services...
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ADD: MCP Server
builder.Services.AddMcpServer(builder.Configuration.GetSection("Mcp"));
var app = builder.Build();
// Existing middleware...
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
// ADD: MCP endpoints
app.MapMcpEndpoints();
app.MapControllers();
app.Run();
```
#### For Console Application (HTTP transport):
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenHarbor.MCP.Infrastructure;
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
// Add MCP Server
services.AddMcpServer(context.Configuration.GetSection("Mcp"));
// Register custom tools
services.AddTransient<IYourCustomTool, YourCustomToolImplementation>();
})
.Build();
// Start MCP server on stdio
var mcpServer = host.Services.GetRequiredService<IMcpServer>();
await mcpServer.StartAsync(CancellationToken.None);
await host.RunAsync();
```
---
## Step 3: Create Sample Tools
**Goal**: Generate MCP tools based on detected system features.
### 3.1 Tool Template
**Basic tool structure**:
```csharp
using OpenHarbor.MCP.Core.Abstractions;
using OpenHarbor.MCP.Core.Models;
namespace YourApp.Mcp.Tools;
public class SampleTool : IMcpTool
{
public string Name => "sample_tool";
public string Description => "Describes what this tool does for AI agents";
public McpToolSchema Schema => new()
{
Type = "object",
Properties = new Dictionary<string, McpProperty>
{
["parameter1"] = new()
{
Type = "string",
Description = "Description of parameter1",
Required = true
},
["parameter2"] = new()
{
Type = "number",
Description = "Optional numeric parameter",
Required = false
}
}
};
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken cancellationToken = default)
{
// Extract parameters
var param1 = parameters["parameter1"].ToString();
var param2 = parameters.ContainsKey("parameter2")
? Convert.ToDouble(parameters["parameter2"])
: 0.0;
// Execute tool logic
var result = await PerformWorkAsync(param1, param2, cancellationToken);
// Return result
return McpToolResult.Success(result);
}
private async Task<object> PerformWorkAsync(
string param1,
double param2,
CancellationToken ct)
{
// TODO: Implement tool logic
return new { message = "Tool executed successfully" };
}
}
```
### 3.2 Auto-Generated Tools Based on Detected Features
#### If Database Detected:
```csharp
public class SearchDatabaseTool : IMcpTool
{
private readonly YourDbContext _context;
public SearchDatabaseTool(YourDbContext context)
{
_context = context;
}
public string Name => "search_database";
public string Description => "Search database entities by query string";
public McpToolSchema Schema => new()
{
Type = "object",
Properties = new Dictionary<string, McpProperty>
{
["query"] = new()
{
Type = "string",
Description = "Search query",
Required = true
},
["entityType"] = new()
{
Type = "string",
Description = "Entity type to search (e.g., 'users', 'products')",
Required = true
},
["limit"] = new()
{
Type = "number",
Description = "Max results to return (default: 10)",
Required = false
}
}
};
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken cancellationToken = default)
{
var query = parameters["query"].ToString();
var entityType = parameters["entityType"].ToString();
var limit = parameters.ContainsKey("limit")
? Convert.ToInt32(parameters["limit"])
: 10;
// Route to appropriate entity search
var results = entityType.ToLower() switch
{
"users" => await SearchUsersAsync(query, limit, cancellationToken),
"products" => await SearchProductsAsync(query, limit, cancellationToken),
_ => throw new ArgumentException($"Unknown entity type: {entityType}")
};
return McpToolResult.Success(results);
}
private async Task<object> SearchUsersAsync(string query, int limit, CancellationToken ct)
{
return await _context.Users
.Where(u => u.Name.Contains(query) || u.Email.Contains(query))
.Take(limit)
.Select(u => new { u.Id, u.Name, u.Email })
.ToListAsync(ct);
}
private async Task<object> SearchProductsAsync(string query, int limit, CancellationToken ct)
{
return await _context.Products
.Where(p => p.Name.Contains(query) || p.Description.Contains(query))
.Take(limit)
.Select(p => new { p.Id, p.Name, p.Description, p.Price })
.ToListAsync(ct);
}
}
```
**Register in Program.cs**:
```csharp
services.AddTransient<IMcpTool, SearchDatabaseTool>();
```
#### If API Endpoints Detected:
```csharp
public class ListEndpointsTool : IMcpTool
{
private readonly ILogger<ListEndpointsTool> _logger;
public string Name => "list_endpoints";
public string Description => "List all available API endpoints in the application";
public McpToolSchema Schema => new()
{
Type = "object",
Properties = new Dictionary<string, McpProperty>()
};
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken cancellationToken = default)
{
// Return list of endpoints (could introspect from routing)
var endpoints = new[]
{
new { Method = "GET", Path = "/api/users", Description = "List users" },
new { Method = "POST", Path = "/api/users", Description = "Create user" },
new { Method = "GET", Path = "/api/products", Description = "List products" }
};
return McpToolResult.Success(endpoints);
}
}
```
---
## Step 4: Setup Development Environment
**Goal**: Configure IDE and development tools for OpenHarbor.MCP.
### 4.1 Launch Settings (launchSettings.json)
**Create/update `Properties/launchSettings.json`**:
```json
{
"profiles": {
"McpStdio": {
"commandName": "Project",
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"MCP_TRANSPORT": "stdio"
}
},
"McpHttp": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"MCP_TRANSPORT": "http"
}
}
}
}
```
### 4.2 .editorconfig (Already provided in OpenHarbor.MCP root)
**Copy to target project** if needed:
```bash
cp /path/to/OpenHarbor.MCP/.editorconfig /path/to/YourApp/.editorconfig
```
### 4.3 .gitignore Updates
**Add MCP-specific entries** to target project's `.gitignore`:
```gitignore
# MCP logs and temp files
mcp-logs/
*.mcp.log
mcp-config.local.json
```
---
## Step 5: Validation
**Goal**: Verify setup is correct and functional.
### 5.1 Build Verification
```bash
# Restore dependencies
dotnet restore
# Build project
dotnet build
# Expected: Build succeeded. 0 Warning(s). 0 Error(s).
```
### 5.2 Test MCP Server Startup
#### Console/Stdio Mode:
```bash
# Run MCP server
dotnet run
# Expected output:
# MCP Server started (HTTP transport)
# Registered tools: sample_tool, search_database, list_endpoints
# Ready for input...
```
#### HTTP Mode (if applicable):
```bash
# Run with HTTP transport
dotnet run --launch-profile McpHttp
# Test MCP endpoint
curl http://localhost:5000/mcp/tools
```
**Expected response**:
```json
{
"tools": [
{
"name": "sample_tool",
"description": "Describes what this tool does for AI agents",
"schema": { ... }
},
{
"name": "search_database",
"description": "Search database entities by query string",
"schema": { ... }
}
]
}
```
### 5.3 Test Tool Execution
**Create test script** `test-mcp.sh`:
```bash
#!/bin/bash
# Test listing tools
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | dotnet run
# Test executing a tool
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sample_tool","arguments":{"parameter1":"test"}}}' | dotnet run
```
**Run tests**:
```bash
chmod +x test-mcp.sh
./test-mcp.sh
```
### 5.4 Integration Test (Claude Desktop Example)
**If Claude Desktop is available**, create MCP config:
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
```json
{
"mcpServers": {
"yourapp": {
"command": "dotnet",
"args": ["run", "--project", "/absolute/path/to/YourApp.csproj"],
"transport": "http"
}
}
}
```
**Restart Claude Desktop**, then test:
1. Type: "List available tools" (should show your MCP tools)
2. Execute: "Use sample_tool with parameter1='hello'"
3. Verify: Tool executes and returns expected result
---
## Example: CODEX Integration Walkthrough
**Scenario**: CODEX is a knowledge management system that needs to expose its document search, retrieval, and tagging features to AI agents via MCP.
### Step 1: System Analysis (Automated)
**Run analysis**:
```bash
cd /home/svrnty/codex/CODEX
dotnet --version # Output: 8.0.100
find . -name "*.csproj" | head -5
```
**Detected**:
- Project: ASP.NET Core Web API (Codex.Api)
- Framework: net8.0
- Database: PostgreSQL (Npgsql.EntityFrameworkCore.PostgreSQL)
- Features: CQRS (OpenHarbor.CQRS), vector search (Pgvector), semantic search (ONNX)
### Step 2: Generate Configuration
**Created** `samples/CodexMcpServer/` in OpenHarbor.MCP:
```
samples/CodexMcpServer/
├── CodexMcpServer.csproj # References OpenHarbor.MCP.AspNetCore
├── Program.cs # MCP server setup with CODEX API client
├── appsettings.json # CODEX-specific MCP config
├── Tools/
│ ├── SearchCodexTool.cs # search_codex tool
│ ├── GetDocumentTool.cs # get_document tool
│ ├── ListDocumentsTool.cs # list_documents tool
│ ├── SearchByTagTool.cs # search_by_tag tool
│ ├── GetDocumentSectionsTool.cs # get_document_sections tool
│ └── ListTagsTool.cs # list_tags tool
└── Services/
└── CodexApiClient.cs # HTTP client for CODEX API (localhost:5050)
```
**appsettings.json**:
```json
{
"Mcp": {
"Server": {
"Name": "CodexMcpServer",
"Version": "1.0.0",
"Description": "CODEX Knowledge Gateway - Provides AI agents secure access to CODEX documents, search, and tags",
"Vendor": "Svrnty"
},
"Transport": {
"Type": "Http"
},
"Security": {
"EnablePermissions": true,
"DefaultDenyAll": true,
"Agents": {
"claude-desktop": {
"Permissions": ["tools:codex:search", "tools:codex:read", "tools:codex:tags"],
"RateLimitOverride": 120
},
"public-agent": {
"Permissions": ["tools:codex:search"],
"RateLimitOverride": 30
}
}
}
},
"CodexApi": {
"BaseUrl": "http://localhost:5050",
"Timeout": 30
}
}
```
### Step 3: Create CODEX Tools
**SearchCodexTool.cs**:
```csharp
public class SearchCodexTool : IMcpTool
{
private readonly ICodexApiClient _codexApi;
public string Name => "search_codex";
public string Description => "Search CODEX knowledge base using semantic or keyword search";
public McpToolSchema Schema => new()
{
Type = "object",
Properties = new Dictionary<string, McpProperty>
{
["query"] = new() { Type = "string", Description = "Search query", Required = true },
["searchType"] = new() { Type = "string", Description = "Search type: 'semantic' or 'keyword' (default: semantic)", Required = false },
["limit"] = new() { Type = "number", Description = "Max results (default: 10)", Required = false }
}
};
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken ct = default)
{
var query = parameters["query"].ToString();
var searchType = parameters.GetValueOrDefault("searchType", "semantic").ToString();
var limit = Convert.ToInt32(parameters.GetValueOrDefault("limit", 10));
var results = await _codexApi.SearchAsync(query, searchType, limit, ct);
return McpToolResult.Success(results);
}
}
```
### Step 4: Register Tools in Program.cs
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer(builder.Configuration.GetSection("Mcp"));
builder.Services.AddSingleton<ICodexApiClient, CodexApiClient>();
// Register CODEX tools
builder.Services.AddTransient<IMcpTool, SearchCodexTool>();
builder.Services.AddTransient<IMcpTool, GetDocumentTool>();
builder.Services.AddTransient<IMcpTool, ListDocumentsTool>();
builder.Services.AddTransient<IMcpTool, SearchByTagTool>();
builder.Services.AddTransient<IMcpTool, GetDocumentSectionsTool>();
builder.Services.AddTransient<IMcpTool, ListTagsTool>();
var app = builder.Build();
app.MapMcpEndpoints();
app.Run();
```
### Step 5: Validate
```bash
cd samples/CodexMcpServer
dotnet run
# Test with echo
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | dotnet run
# Expected: 6 tools listed (search_codex, get_document, etc.)
```
**Claude Desktop Integration**:
```json
{
"mcpServers": {
"codex": {
"command": "dotnet",
"args": ["run", "--project", "/home/svrnty/codex/OpenHarbor.MCP/samples/CodexMcpServer/CodexMcpServer.csproj"],
"transport": "http"
}
}
}
```
**Result**: Claude can now search CODEX, retrieve documents, filter by tags, and access document sections with full permission enforcement and rate limiting.
---
## AI Agent Checklist
Use this checklist when assisting a developer with OpenHarbor.MCP integration:
- [ ] **System Analysis Complete**
- [ ] .NET 8.0 SDK detected
- [ ] Project type identified
- [ ] Dependencies analyzed
- [ ] Feature detection done (DB, auth, API, etc.)
- [ ] **Configuration Generated**
- [ ] NuGet package/project reference added
- [ ] appsettings.json updated with MCP section
- [ ] Program.cs modified for MCP registration
- [ ] launchSettings.json created/updated
- [ ] **Tools Created**
- [ ] At least 1 sample tool implemented
- [ ] Tools registered in DI container
- [ ] Tool permissions configured in appsettings.json
- [ ] Tool documentation written
- [ ] **Validation Passed**
- [ ] Project builds successfully
- [ ] MCP server starts without errors
- [ ] Tools are listed correctly
- [ ] Sample tool execution succeeds
- [ ] (Optional) Claude Desktop integration works
- [ ] **Documentation Updated**
- [ ] README.md includes MCP setup instructions
- [ ] Tool usage examples documented
- [ ] Configuration options explained
---
## Troubleshooting
### Issue: "MCP server not starting"
**Solution**:
1. Check .NET version: `dotnet --version` (must be 8.0+)
2. Verify Program.cs has `services.AddMcpServer(...)` and `app.MapMcpEndpoints()`
3. Check appsettings.json for valid JSON syntax
4. Run `dotnet build` to see specific errors
### Issue: "Tools not appearing in Claude"
**Solution**:
1. Verify `claude_desktop_config.json` path is correct
2. Check absolute path to .csproj is correct
3. Restart Claude Desktop completely
4. Test server manually: `echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | dotnet run`
### Issue: "Permission denied when executing tool"
**Solution**:
1. Check `appsettings.json``Mcp.Security.Agents` section
2. Ensure agent has required permissions (e.g., `"tools:codex:search"`)
3. Verify `DefaultDenyAll` is set correctly
4. Check audit logs for permission failures
### Issue: "Rate limit exceeded"
**Solution**:
1. Increase `RateLimitOverride` for the agent in appsettings.json
2. Reduce request frequency from client
3. Disable rate limiting temporarily: `"RateLimit.Enabled": false`
---
## Next Steps
After successful setup:
1. **Write Tests**: Create unit tests for custom tools (see OpenHarbor.MCP test examples)
2. **Add Observability**: Integrate logging, metrics, and tracing
3. **Secure Production**: Review permission model, enable HTTPS for HTTP transport
4. **Optimize Performance**: Add caching, connection pooling, batch operations
5. **Extend Tools**: Add more domain-specific tools based on application features
---
## References
- **OpenHarbor.MCP Documentation**: `/docs/` folder in this repository
- **MCP Specification**: https://spec.modelcontextprotocol.io
- **CODEX Integration Example**: `samples/CodexMcpServer/`
- **Test Examples**: `tests/OpenHarbor.MCP.Integration.Tests/`
---
**AI Agent Note**: This primer is designed for you to execute autonomously. When a developer asks "set up MCP for my project," follow this guide step-by-step, adapting configurations to their specific environment. Always validate each step before proceeding to the next.

188
INTEGRATION-GUIDE.md Normal file
View File

@ -0,0 +1,188 @@
# Adding CODEX MCP Server to Claude Code CLI
This guide shows how to integrate the CODEX MCP Server with Claude Code CLI (in WebStorm or standalone).
## Prerequisites
- .NET 8.0 SDK installed
- Claude Code CLI installed and configured
- OpenHarbor.MCP.Server cloned/copied to your system
## One-Time Setup
### 1. Build the CODEX MCP Server
```bash
cd /home/svrnty/codex/OpenHarbor.MCP.Server
dotnet build samples/CodexMcpServer/CodexMcpServer.csproj
```
### 2. Add to Claude Code CLI
```bash
# Add CODEX MCP server with HTTP transport
claude mcp add --transport stdio --scope user codex \
dotnet -- run --project /home/svrnty/codex/OpenHarbor.MCP.Server/samples/CodexMcpServer
```
**Command Breakdown:**
- `--transport stdio` - Use stdin/stdout communication (required for .NET console apps)
- `--scope user` - Available across all projects for this user (alternatives: `local`, `project`)
- `codex` - The name of the MCP server (used in CLI commands)
- `dotnet -- run --project <path>` - Command to start the server
### 3. Verify Setup
```bash
# List all MCP servers
claude mcp list
# View CODEX server details
claude mcp get codex
```
**Expected Output:**
```
codex:
Command: dotnet
Args: [run, --project, /home/svrnty/codex/OpenHarbor.MCP.Server/samples/CodexMcpServer]
Transport: stdio
Scope: user
```
## Usage in Claude Code CLI
### Interactive Mode
```bash
# Start Claude in interactive mode
claude
# Claude will automatically discover the CODEX tools
# You can now ask questions like:
# "Search CODEX for documents about neural networks"
# "List all documents in CODEX"
# "Get tags from CODEX"
```
### Project Mode (WebStorm)
When using Claude Code CLI in WebStorm, the CODEX MCP server is automatically available:
1. Open WebStorm
2. Open Claude Code panel
3. Ask Claude to search CODEX:
- "Search my CODEX knowledge base for information about MCP"
- "List recent documents from CODEX"
- "What tags are available in CODEX?"
## Available Tools
The CODEX MCP server exposes 6 tools:
| Tool | Description | Parameters |
|------|-------------|------------|
| `search_codex` | Semantic/keyword search | `query` (string) |
| `get_document` | Retrieve document by ID | `id` (string) |
| `list_documents` | List all documents with pagination | `page` (int), `pageSize` (int) |
| `search_by_tag` | Filter documents by tag | `tag` (string) |
| `get_document_sections` | Get document sections | `id` (string) |
| `list_tags` | List all available tags | None |
## Troubleshooting
### Server Not Responding
```bash
# Test the server manually
echo '{"jsonrpc":"2.0","id":"1","method":"tools/list"}' | \
dotnet run --project /home/svrnty/codex/OpenHarbor.MCP.Server/samples/CodexMcpServer
```
**Expected:** JSON response listing all 6 tools
### CODEX API Connection Errors
The tools will return errors if CODEX API is not running:
```
Error: Connection refused (localhost:5050)
```
**Solution:** Start your CODEX API service:
```bash
# Start CODEX API (adjust path as needed)
cd /path/to/CODEX
dotnet run --project src/Codex.Api
```
### Removing the Server
```bash
# Remove CODEX MCP server from CLI
claude mcp remove codex
# Verify removal
claude mcp list
```
## Testing End-to-End
Run the test script to verify everything works:
```bash
cd /home/svrnty/codex/OpenHarbor.MCP.Server
python3 test_mcp_server.py
```
**Expected:** All 10 tests should pass (tools return proper JSON-RPC responses)
## Advanced Configuration
### Scopes
- **user** (recommended): Available globally for your user account
- **local**: Available only in current directory
- **project**: Available only in current project
```bash
# Change scope
claude mcp remove codex
claude mcp add --transport stdio --scope project codex \
dotnet -- run --project /home/svrnty/codex/OpenHarbor.MCP.Server/samples/CodexMcpServer
```
### Using Absolute Paths
For reliability, use absolute paths:
```bash
claude mcp add --transport stdio --scope user codex \
/usr/bin/dotnet -- run --project /home/svrnty/codex/OpenHarbor.MCP.Server/samples/CodexMcpServer
```
## Next Steps
1. **Start CODEX API** (if you want to test with real data):
```bash
cd /path/to/CODEX
dotnet run --project src/Codex.Api
```
2. **Ask Claude to search CODEX:**
```
"Search CODEX for documents about clean architecture"
```
3. **Explore other tools:**
```
"List all tags in CODEX"
"Get document sections for document ID xyz123"
```
## Reference
- [Claude Code CLI Documentation](https://docs.claude.com/en/docs/claude-code)
- [Model Context Protocol Spec](https://modelcontextprotocol.io)
- [OpenHarbor.MCP README](README.md)
- [CODEX Documentation](../CODEX/README.md)

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.

268
QUICK-START.md Normal file
View File

@ -0,0 +1,268 @@
# CODEX MCP Server - Quick Start
**Get your CODEX knowledge base connected via HTTP in 2 minutes.**
## TL;DR
```bash
# 1. Start CODEX API
cd /home/svrnty/codex/CODEX
dotnet run --project src/Codex.Api
# Listens on http://localhost:5099
# 2. Start CODEX MCP Server (HTTP mode)
cd /home/svrnty/codex/OpenHarbor.MCP.Server
dotnet run --project samples/CodexMcpServer
# Listens on http://localhost:5050
# 3. Test the server
curl http://localhost:5050/health
# 4. List available tools
curl -X POST http://localhost:5050/mcp/invoke \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":"1"}'
```
## What You Get
6 tools accessible via HTTP for interacting with your CODEX knowledge base:
1. **search_codex** - Semantic search across all documents
2. **get_document** - Retrieve specific document by ID
3. **list_documents** - Browse all documents with pagination
4. **search_by_tag** - Filter by tags
5. **get_document_sections** - Get structured sections
6. **list_tags** - View all available tags
## HTTP Endpoints
**Base URL**: `http://localhost:5050`
- `POST /mcp/invoke` - Main MCP JSON-RPC endpoint
- `GET /health` - Health check endpoint
## Example Tool Calls
Once the server is running, you can call tools via HTTP:
### Search CODEX
```bash
curl -X POST http://localhost:5050/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"id": "1",
"params": {
"name": "search_codex",
"arguments": {
"query": "neural networks",
"maxResults": 5
}
}
}'
```
### Get Document
```bash
curl -X POST http://localhost:5050/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"id": "2",
"params": {
"name": "get_document",
"arguments": {
"documentId": "abc123"
}
}
}'
```
### List Tags
```bash
curl -X POST http://localhost:5050/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": "3"
}'
```
## Architecture (HTTP Mode)
```
┌─────────────────┐
│ MCP Client │
│ (App/Gateway) │
└────────┬────────┘
│ HTTP POST
│ (JSON-RPC 2.0)
┌─────────────────┐
│ CODEX MCP Server│
│ (6 tools) │
│ Port 5050 │
└────────┬────────┘
│ HTTP REST
┌─────────────────┐
│ CODEX API │
│ Port 5099 │
└─────────────────┘
```
## Requirements
- .NET 8.0 SDK
- CODEX API running at `http://localhost:5099` (for real data)
## Using with MCP Gateway
For production deployments with load balancing and multiple clients:
```bash
# Terminal 1: Start CODEX API
cd /home/svrnty/codex/CODEX
dotnet run --project src/Codex.Api
# Terminal 2: Start CODEX MCP Server
cd /home/svrnty/codex/OpenHarbor.MCP.Server
dotnet run --project samples/CodexMcpServer
# Terminal 3: Start MCP Gateway
cd /home/svrnty/codex/OpenHarbor.MCP.Gateway
dotnet run --project samples/CodexMcpGateway
# Gateway listens on http://localhost:8080
# Routes requests to http://localhost:5050
```
See [OpenHarbor.MCP.Gateway](../OpenHarbor.MCP.Gateway/README.md) for gateway configuration.
## Troubleshooting
### Health check fails
**Solution:** Verify the server is running:
```bash
curl -v http://localhost:5050/health
```
Expected response:
```json
{"status":"Healthy","service":"MCP Server","timestamp":"..."}
```
### Connection refused errors when using tools
**Solution:** Start CODEX API:
```bash
cd /home/svrnty/codex/CODEX
dotnet run --project src/Codex.Api
```
Verify it's running:
```bash
curl http://localhost:5099/health
```
### Port already in use
**Solution:** Check what's using port 5050:
```bash
lsof -i :5050
```
Stop the conflicting process or configure a different port in `appsettings.json`.
## Testing Without CODEX API
The MCP server works even if CODEX API isn't running - it will return proper error messages:
```bash
curl -X POST http://localhost:5050/mcp/invoke \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":"1"}'
```
**Expected:** JSON listing all 6 tools (even without CODEX API running)
## Configuration
Edit `samples/CodexMcpServer/appsettings.json`:
```json
{
"Mcp": {
"Transport": {
"Type": "Http",
"Port": 5050
},
"Codex": {
"ApiBaseUrl": "http://localhost:5099"
}
}
}
```
## Next Steps
1. **Full integration guide:** See [INTEGRATION-GUIDE.md](INTEGRATION-GUIDE.md)
2. **Architecture details:** See [README.md](README.md)
3. **Gateway setup:** See [OpenHarbor.MCP.Gateway](../OpenHarbor.MCP.Gateway/README.md)
4. **Development:** See [docs/module-design.md](docs/module-design.md)
---
## Legacy Support: Claude Desktop (Stdio Mode)
For local Claude Desktop integration, stdio mode is still available:
```bash
# Run in stdio mode
dotnet run --project samples/CodexMcpServer -- --stdio
```
**Claude Desktop Configuration** (`~/.claude/config.json`):
```json
{
"mcpServers": {
"codex": {
"command": "dotnet",
"args": [
"run",
"--project",
"/home/svrnty/codex/OpenHarbor.MCP.Server/samples/CodexMcpServer/CodexMcpServer.csproj",
"--",
"--stdio"
],
"transport": "stdio"
}
}
}
```
**Note**: HTTP mode is recommended for production deployments. Stdio mode is for local development and Claude Desktop integration only.
---
## Files Reference
| File | Purpose |
|------|---------|
| `QUICK-START.md` | This file (2-minute HTTP setup) |
| `INTEGRATION-GUIDE.md` | Detailed integration patterns |
| `README.md` | Complete documentation |
| `HTTP-TRANSPORT-STANDARDIZATION.md` | HTTP architecture guide |
## Support
- Issues: [GitHub Issues](https://github.com/yourusername/OpenHarbor.MCP/issues)
- MCP Spec: [modelcontextprotocol.io](https://modelcontextprotocol.io)
- HTTP Transport Guide: See `HTTP-TRANSPORT-STANDARDIZATION.md`

611
README.md Normal file
View File

@ -0,0 +1,611 @@
# OpenHarbor.MCP
**A modular, scalable, secure .NET library for building Model Context Protocol (MCP) servers**
[![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?
OpenHarbor.MCP is a **standalone, reusable .NET library** that enables any .NET application to become an MCP server, allowing AI agents (like Claude, ChatGPT, or custom LLMs) to interact with your application through a standardized protocol.
**Model Context Protocol (MCP)** is an industry-standard protocol backed by Anthropic that defines how AI agents communicate with external tools and data sources. Think of it as a universal adapter that lets AI assistants safely access your application's capabilities.
### Key Features
- **Modular & Reusable**: Copy to any .NET project, configure, and go
- **Clean Architecture**: Core abstractions, infrastructure implementation, ASP.NET Core integration
- **Security-First**: Permission-based access control, rate limiting, audit logging, deny-by-default model
- **Transport Flexibility**: HTTP (primary for production) and stdio (legacy for local development)
- **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, error handling, graceful shutdown, health checks
---
## Why OpenHarbor.MCP?
**Problem**: Your .NET application has valuable features (search, data access, document processing, APIs) that AI agents can't easily access.
**Solution**: OpenHarbor.MCP transforms your application into an MCP server, exposing tools that AI agents can discover and execute with proper permissions and rate limiting.
**Use Cases**:
- Expose knowledge base search to Claude Code CLI (or Desktop)
- Allow AI agents to query your database safely
- Provide document processing tools to LLM workflows
- Enable AI-assisted data analysis on private data
- Build custom AI integrations for enterprise applications
---
## Quick Start
### Prerequisites
- .NET 8.0 SDK or higher
- Your existing .NET application (Web API, Console, Worker Service, etc.)
### 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 for my project"
3. The AI will analyze your system, generate configuration, and create sample tools automatically
### Option 2: Manual Setup
#### Step 1: Add Package Reference
```bash
# Via project reference (development)
dotnet add reference /path/to/OpenHarbor.MCP/src/OpenHarbor.MCP.AspNetCore/OpenHarbor.MCP.AspNetCore.csproj
# OR via NuGet (when published)
# dotnet add package OpenHarbor.MCP.AspNetCore
```
#### Step 2: Configure appsettings.json
Add MCP configuration:
```json
{
"Mcp": {
"Server": {
"Name": "MyAppMcpServer",
"Version": "1.0.0",
"Description": "MCP server for MyApp"
},
"Transport": {
"Type": "Http",
"Port": 5050
},
"Security": {
"EnablePermissions": true,
"DefaultDenyAll": true,
"RateLimit": {
"Enabled": true,
"RequestsPerMinute": 60
}
}
}
}
```
#### Step 3: Update Program.cs
```csharp
using OpenHarbor.MCP.Core;
using Microsoft.AspNetCore.Builder;
var builder = WebApplication.CreateBuilder(args);
// Create tool registry and register your tools
var registry = new ToolRegistry();
registry.AddTool(new MyCustomTool());
// Create and register MCP server
var server = new McpServer(registry);
builder.Services.AddMcpServer(server);
// Configure HTTP port
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(5050);
});
var app = builder.Build();
// Map MCP HTTP endpoints
app.MapMcpEndpoints(server);
Console.WriteLine("MCP Server listening on http://localhost:5050");
Console.WriteLine("Endpoints: POST /mcp/invoke, GET /health");
await app.RunAsync();
```
#### Step 4: Create Your First Tool
```csharp
using OpenHarbor.MCP.Core.Abstractions;
using OpenHarbor.MCP.Core.Models;
public class MyCustomTool : IMcpTool
{
public string Name => "my_custom_tool";
public string Description => "Describes what this tool does";
public McpToolSchema Schema => new()
{
Type = "object",
Properties = new Dictionary<string, McpProperty>
{
["query"] = new()
{
Type = "string",
Description = "Search query",
Required = true
}
}
};
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken ct = default)
{
var query = parameters["query"].ToString();
// Your tool logic here
var result = await ProcessQueryAsync(query, ct);
return McpToolResult.Success(result);
}
}
```
#### Step 5: Run and Test
```bash
# Run your application (HTTP mode, default)
dotnet run
# Server will listen on http://localhost:5050
# Test health endpoint
curl http://localhost:5050/health
# Test tool listing via HTTP
curl -X POST http://localhost:5050/mcp/invoke \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Test tool execution via HTTP
curl -X POST http://localhost:5050/mcp/invoke \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"my_custom_tool","arguments":{"query":"test"}}}'
```
**Legacy Stdio Mode** (for Claude Desktop integration):
```bash
# Run in stdio mode for Claude Desktop
dotnet run -- --stdio
# Test with stdio
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | dotnet run -- --stdio
```
---
## Architecture
OpenHarbor.MCP follows **Clean Architecture** principles:
```
┌─────────────────────────────────────────────────┐
│ OpenHarbor.MCP.Cli (Executable) │
│ ┌───────────────────────────────────────────┐ │
│ │ OpenHarbor.MCP.AspNetCore (HTTP/DI) │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ OpenHarbor.MCP.Infrastructure │ │ │
│ │ │ ┌───────────────────────────────┐ │ │ │
│ │ │ │ OpenHarbor.MCP.Core │ │ │ │
│ │ │ │ - IMcpServer │ │ │ │
│ │ │ │ - IMcpTool │ │ │ │
│ │ │ │ - IPermissionProvider │ │ │ │
│ │ │ │ - Models (no dependencies) │ │ │ │
│ │ │ └───────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### Projects
| Project | Purpose | Dependencies |
|---------|---------|--------------|
| **OpenHarbor.MCP.Core** | Abstractions, interfaces, models | None |
| **OpenHarbor.MCP.Infrastructure** | MCP server implementation, transports, security | Core, System.Text.Json |
| **OpenHarbor.MCP.AspNetCore** | ASP.NET Core integration, DI extensions | Core, Infrastructure, ASP.NET Core |
| **OpenHarbor.MCP.Cli** | Standalone CLI executable | All above |
See [Architecture Documentation](docs/architecture.md) for detailed design.
---
## Examples
### 1. CODEX MCP Server (Knowledge Gateway)
CODEX is a knowledge management system. The MCP integration exposes 6 tools:
```
samples/CodexMcpServer/
├── Tools/
│ ├── SearchCodexTool.cs # Semantic/keyword search
│ ├── GetDocumentTool.cs # Retrieve document by ID
│ ├── ListDocumentsTool.cs # List all documents
│ ├── SearchByTagTool.cs # Filter by tags
│ ├── GetDocumentSectionsTool.cs # Get document sections
│ └── ListTagsTool.cs # List all tags
```
**Usage with MCP Gateway** (Recommended for Production):
```bash
# Run CODEX MCP Server in HTTP mode (default)
cd /path/to/OpenHarbor.MCP.Server/samples/CodexMcpServer
dotnet run
# Server listens on http://localhost:5050
# Configure gateway to route to this server
# See OpenHarbor.MCP.Gateway documentation
```
**Alternative: Claude Desktop with Stdio** (Legacy, Local Development):
```json
{
"mcpServers": {
"codex": {
"command": "dotnet",
"args": ["run", "--project", "/path/to/samples/CodexMcpServer/CodexMcpServer.csproj", "--", "--stdio"],
"transport": "stdio"
}
}
}
```
Note: HTTP transport is recommended for production deployments with multiple clients, load balancing, and monitoring.
### 2. Database Query Tool
```csharp
public class DatabaseQueryTool : IMcpTool
{
private readonly MyDbContext _context;
public string Name => "query_database";
public string Description => "Execute read-only SQL queries";
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken ct = default)
{
var sql = parameters["sql"].ToString();
// Security: Validate read-only query
if (!IsReadOnlyQuery(sql))
{
return McpToolResult.Error("Only SELECT queries allowed");
}
var results = await _context.Database.SqlQueryRaw<object>(sql).ToListAsync(ct);
return McpToolResult.Success(results);
}
}
```
### 3. Document Processing Tool
```csharp
public class ProcessDocumentTool : IMcpTool
{
private readonly IDocumentProcessor _processor;
public string Name => "process_document";
public string Description => "Extract text and metadata from uploaded documents";
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken ct = default)
{
var filePath = parameters["filePath"].ToString();
var result = await _processor.ProcessAsync(filePath, ct);
return McpToolResult.Success(new
{
extractedText = result.Text,
metadata = result.Metadata,
pageCount = result.PageCount
});
}
}
```
---
## Security
OpenHarbor.MCP implements defense-in-depth security:
### 1. Permission-Based Access Control
```json
{
"Mcp": {
"Security": {
"DefaultDenyAll": true,
"Agents": {
"claude-desktop": {
"Permissions": [
"tools:search",
"tools:read"
]
},
"public-agent": {
"Permissions": [
"tools:search"
]
}
}
}
}
}
```
### 2. Rate Limiting
Token bucket algorithm prevents abuse:
```json
{
"RateLimit": {
"Enabled": true,
"RequestsPerMinute": 60,
"BurstSize": 10
}
}
```
### 3. Audit Logging
All MCP operations are logged:
```csharp
[2025-10-19 10:15:32] INFO: Agent 'claude-desktop' executed tool 'search_codex' with parameters {"query":"neural networks"} - Success
[2025-10-19 10:15:45] WARN: Agent 'public-agent' denied access to tool 'delete_document' - Permission 'tools:delete' not granted
```
### 4. Input Validation
All tool parameters are validated against schema before execution.
---
## Testing
OpenHarbor.MCP is built with **Test-Driven Development (TDD)**:
```bash
# Run all tests
dotnet test
# Run with coverage
dotnet test /p:CollectCoverage=true
# Run specific test category
dotnet test --filter Category=Integration
```
**Test Structure**:
```
tests/
├── OpenHarbor.MCP.Core.Tests/ # Unit tests (abstractions, models)
├── OpenHarbor.MCP.Infrastructure.Tests/ # Unit tests (server, transports, security)
└── OpenHarbor.MCP.Integration.Tests/ # End-to-end integration tests
```
**Example Test** (TDD style):
```csharp
// RED: Write failing test first
[Fact]
public async Task ExecuteTool_WithInvalidPermissions_ThrowsUnauthorized()
{
var server = new McpServer();
var tool = new SearchTool();
var agent = new AgentContext { Id = "test", Permissions = [] };
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => server.ExecuteToolAsync(tool.Name, agent, new Dictionary<string, object>())
);
}
// GREEN: Implement minimal code to pass
// REFACTOR: Improve design while keeping tests green
```
See [TDD Guide](docs/tdd-guide.md) for complete examples.
### Test Coverage
OpenHarbor.MCP.Server maintains **42.46% line coverage** and **50.00% branch coverage** with **141 tests** passing (100%).
**Coverage Breakdown:**
- **Lines**: 344 of 810 covered (42.46%)
- **Branches**: 72 of 144 covered (50.00%)
- **Test Projects**: 2
- OpenHarbor.MCP.Core.Tests: 82 tests
- CodexMcpServer.Tests: 59 tests
**Analysis:**
- Core domain logic has excellent coverage (> 70%)
- Lower overall percentage due to Program.cs/startup code (not tested by design)
- HTTP transport and protocol implementation well-tested
- Production-ready coverage levels for deployment
**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**: ✅ Good - Core business logic exceeds 70% coverage threshold
---
## Configuration Reference
### Server Settings
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Mcp.Server.Name` | string | (required) | MCP server name |
| `Mcp.Server.Version` | string | "1.0.0" | Server version (semver) |
| `Mcp.Server.Description` | string | "" | Human-readable description |
| `Mcp.Server.Vendor` | string | "" | Vendor/organization name |
### Transport Settings
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Mcp.Transport.Type` | enum | "Http" | "Http" (recommended) or "Stdio" (legacy) |
| `Mcp.Transport.Port` | int | 5050 | HTTP server port |
| `Mcp.Transport.Options.BufferSize` | int | 8192 | Buffer size (bytes) for stdio |
| `Mcp.Transport.Options.EnableLogging` | bool | false | Log all I/O |
**HTTP Transport** (Production):
- Supports multiple concurrent clients
- Load balancing via gateway
- Health check endpoint (`/health`)
- Standard REST API patterns
- Port 5050 (default for MCP servers)
**Stdio Transport** (Legacy):
- One client per process
- Used by Claude Desktop
- Local development only
- Requires `--stdio` command-line flag
### Security Settings
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `Mcp.Security.EnablePermissions` | bool | true | Enable permission checks |
| `Mcp.Security.DefaultDenyAll` | bool | true | Deny by default |
| `Mcp.Security.RateLimit.Enabled` | bool | true | Enable rate limiting |
| `Mcp.Security.RateLimit.RequestsPerMinute` | int | 60 | Global rate limit |
| `Mcp.Security.RateLimit.BurstSize` | int | 10 | Max burst requests |
| `Mcp.Security.AuditLogging.Enabled` | bool | true | Log all operations |
See [Configuration Guide](docs/configuration.md) for complete reference.
---
## Roadmap
### Phase 1: Core Foundation (COMPLETED)
- [x] Core abstractions (IMcpServer, IMcpTool, models)
- [x] Basic server implementation
- [x] Stdio transport
- [x] Unit tests (118/118 passing)
- [x] CODEX MCP Server with 6 tools
- [x] Full TDD implementation (RED → GREEN → REFACTOR)
### Phase 2: Security & Reliability (Week 2)
- [ ] Permission system
- [ ] Rate limiting
- [ ] Audit logging
- [ ] Error handling
- [ ] Integration tests
### Phase 3: ASP.NET Core Integration (Week 3)
- [ ] DI extensions
- [ ] Configuration binding
- [ ] HTTP transport
- [ ] Health checks
- [ ] Observability
### Phase 4: Production & CODEX (Week 4)
- [ ] CODEX MCP server implementation
- [ ] Performance optimization
- [ ] Documentation
- [ ] Deployment guide
- [ ] Release v1.0.0
---
## Contributing
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
**Development Setup**:
```bash
# Clone repository
git clone https://github.com/yourusername/OpenHarbor.MCP.git
# Restore dependencies
dotnet restore
# Run tests
dotnet test
# Build all projects
dotnet build
```
---
## Documentation
| Document | Description |
|----------|-------------|
| [AGENT-PRIMER.md](AGENT-PRIMER.md) | AI-automated setup guide |
| [Architecture](docs/architecture.md) | Clean architecture design |
| [Configuration](docs/configuration.md) | Complete configuration reference |
| [TDD Guide](docs/tdd-guide.md) | Test-driven development examples |
| [Security](docs/security.md) | Security model and best practices |
| [**API Reference**](docs/api/) | **Complete API documentation (IMcpServer, IMcpTool, Models)** |
| [HTTPS Setup Guide](docs/deployment/https-setup.md) | Production TLS/HTTPS configuration |
---
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
---
## Support
- **Documentation**: [docs/](docs/)
- **Examples**: [samples/](samples/)
- **Issues**: [GitHub Issues](https://github.com/yourusername/OpenHarbor.MCP/issues)
---
## Acknowledgments
- **Anthropic** for the [Model Context Protocol](https://modelcontextprotocol.io) specification
- **CODEX** for being the first use case and driving requirements
- **.NET Community** for excellent libraries and tools
---
**Built with OpenHarbor framework principles: Clean, Modular, Testable, Secure.**

59
Svrnty.MCP.sln Normal file
View File

@ -0,0 +1,59 @@

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", "{78F02CB6-4914-4B68-901E-B00546F7985E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Core", "src\OpenHarbor.MCP.Core\OpenHarbor.MCP.Core.csproj", "{94C0092B-E1B1-4960-89F5-72BC22BEE4D1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B419E78A-FF41-452F-9450-59D7E559D175}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.Core.Tests", "tests\OpenHarbor.MCP.Core.Tests\OpenHarbor.MCP.Core.Tests.csproj", "{E4E7F10A-6195-4E12-B0FD-E0EF7057286C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{8430E1E0-6DC8-458D-A0AA-80D8F202264E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexMcpServer", "samples\CodexMcpServer\CodexMcpServer.csproj", "{1467EDA9-7F4C-4744-B58C-D1A92E014C87}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexMcpServer.Tests", "tests\CodexMcpServer.Tests\CodexMcpServer.Tests.csproj", "{7287EB04-9B7D-450C-87FF-21FB1FD9949E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenHarbor.MCP.AspNetCore", "src\OpenHarbor.MCP.AspNetCore\OpenHarbor.MCP.AspNetCore.csproj", "{14914206-58A7-46FF-AF0F-A8A9108EE149}"
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
{94C0092B-E1B1-4960-89F5-72BC22BEE4D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94C0092B-E1B1-4960-89F5-72BC22BEE4D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94C0092B-E1B1-4960-89F5-72BC22BEE4D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94C0092B-E1B1-4960-89F5-72BC22BEE4D1}.Release|Any CPU.Build.0 = Release|Any CPU
{E4E7F10A-6195-4E12-B0FD-E0EF7057286C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E4E7F10A-6195-4E12-B0FD-E0EF7057286C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E4E7F10A-6195-4E12-B0FD-E0EF7057286C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E4E7F10A-6195-4E12-B0FD-E0EF7057286C}.Release|Any CPU.Build.0 = Release|Any CPU
{1467EDA9-7F4C-4744-B58C-D1A92E014C87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1467EDA9-7F4C-4744-B58C-D1A92E014C87}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1467EDA9-7F4C-4744-B58C-D1A92E014C87}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1467EDA9-7F4C-4744-B58C-D1A92E014C87}.Release|Any CPU.Build.0 = Release|Any CPU
{7287EB04-9B7D-450C-87FF-21FB1FD9949E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7287EB04-9B7D-450C-87FF-21FB1FD9949E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7287EB04-9B7D-450C-87FF-21FB1FD9949E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7287EB04-9B7D-450C-87FF-21FB1FD9949E}.Release|Any CPU.Build.0 = Release|Any CPU
{14914206-58A7-46FF-AF0F-A8A9108EE149}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{14914206-58A7-46FF-AF0F-A8A9108EE149}.Debug|Any CPU.Build.0 = Debug|Any CPU
{14914206-58A7-46FF-AF0F-A8A9108EE149}.Release|Any CPU.ActiveCfg = Release|Any CPU
{14914206-58A7-46FF-AF0F-A8A9108EE149}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{94C0092B-E1B1-4960-89F5-72BC22BEE4D1} = {78F02CB6-4914-4B68-901E-B00546F7985E}
{E4E7F10A-6195-4E12-B0FD-E0EF7057286C} = {B419E78A-FF41-452F-9450-59D7E559D175}
{1467EDA9-7F4C-4744-B58C-D1A92E014C87} = {8430E1E0-6DC8-458D-A0AA-80D8F202264E}
{7287EB04-9B7D-450C-87FF-21FB1FD9949E} = {B419E78A-FF41-452F-9450-59D7E559D175}
{14914206-58A7-46FF-AF0F-A8A9108EE149} = {78F02CB6-4914-4B68-901E-B00546F7985E}
EndGlobalSection
EndGlobal

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

@ -0,0 +1,637 @@
# OpenHarbor.MCP.Server - API Reference
**Version:** 1.0.0
**Last Updated:** 2025-10-19
**Status:** Production-Ready
---
## Table of Contents
- [Core Abstractions](#core-abstractions)
- [IMcpServer](#imcpserver)
- [IMcpTool](#imcptool)
- [Infrastructure](#infrastructure)
- [McpServer](#mcpserver)
- [HttpTransport](#httptransport)
- [Models](#models)
- [McpToolSchema](#mcptoolschema)
- [McpToolResult](#mcptoolresult)
- [ASP.NET Core Integration](#aspnet-core-integration)
- [Service Extensions](#service-extensions)
- [Endpoint Mapping](#endpoint-mapping)
---
## Core Abstractions
### IMcpServer
**Namespace:** `OpenHarbor.MCP.Core.Abstractions`
Interface defining the core MCP server contract.
#### Methods
##### ExecuteToolAsync
```csharp
Task<McpToolResult> ExecuteToolAsync(
string toolName,
Dictionary<string, object> parameters,
CancellationToken cancellationToken = default)
```
Executes a tool by name with the given parameters.
**Parameters:**
- `toolName` (string): The name of the tool to execute
- `parameters` (Dictionary<string, object>): Tool input parameters
- `cancellationToken` (CancellationToken): Optional cancellation token
**Returns:** `Task<McpToolResult>` - The result of the tool execution
**Throws:**
- `ToolNotFoundException` - If the tool doesn't exist
- `InvalidParametersException` - If parameters don't match schema
- `OperationCanceledException` - If cancelled
**Example:**
```csharp
var result = await server.ExecuteToolAsync(
"search_documents",
new Dictionary<string, object>
{
["query"] = "architecture patterns",
["maxResults"] = 10
}
);
```
##### ListToolsAsync
```csharp
Task<IEnumerable<McpToolInfo>> ListToolsAsync(
CancellationToken cancellationToken = default)
```
Lists all available tools.
**Returns:** `Task<IEnumerable<McpToolInfo>>` - Collection of tool metadata
**Example:**
```csharp
var tools = await server.ListToolsAsync();
foreach (var tool in tools)
{
Console.WriteLine($"{tool.Name}: {tool.Description}");
}
```
---
### IMcpTool
**Namespace:** `OpenHarbor.MCP.Core.Abstractions`
Interface for implementing MCP tools.
#### Properties
##### Name
```csharp
string Name { get; }
```
Unique identifier for the tool (e.g., "search_documents").
**Requirements:**
- Must be lowercase
- Use underscores for word separation
- No spaces or special characters
##### Description
```csharp
string Description { get; }
```
Human-readable description of what the tool does.
##### Schema
```csharp
McpToolSchema Schema { get; }
```
JSON Schema defining the tool's input parameters.
#### Methods
##### ExecuteAsync
```csharp
Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken cancellationToken = default)
```
Executes the tool with validated parameters.
**Parameters:**
- `parameters` (Dictionary<string, object>): Validated input parameters
- `cancellationToken` (CancellationToken): Cancellation support
**Returns:** `Task<McpToolResult>` - Success or error result
**Example Implementation:**
```csharp
public class SearchTool : IMcpTool
{
public string Name => "search_documents";
public string Description => "Search documents by query";
public McpToolSchema Schema => new()
{
Type = "object",
Properties = new Dictionary<string, McpProperty>
{
["query"] = new()
{
Type = "string",
Description = "Search query",
Required = true
}
}
};
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken ct = default)
{
var query = parameters["query"].ToString();
var results = await SearchAsync(query, ct);
return McpToolResult.Success(results);
}
}
```
---
## Infrastructure
### McpServer
**Namespace:** `OpenHarbor.MCP.Infrastructure`
Default implementation of `IMcpServer`.
#### Constructor
```csharp
public McpServer(ToolRegistry toolRegistry)
```
**Parameters:**
- `toolRegistry` (ToolRegistry): Registry containing all available tools
**Example:**
```csharp
var registry = new ToolRegistry();
registry.AddTool(new SearchTool());
registry.AddTool(new GetDocumentTool());
var server = new McpServer(registry);
```
#### Methods
##### StartAsync
```csharp
Task StartAsync(CancellationToken cancellationToken = default)
```
Starts the MCP server (initializes transport).
##### StopAsync
```csharp
Task StopAsync(CancellationToken cancellationToken = default)
```
Gracefully stops the server.
---
### HttpTransport
**Namespace:** `OpenHarbor.MCP.AspNetCore.Extensions`
HTTP transport implementation for MCP protocol.
#### Configuration
```json
{
"Mcp": {
"Transport": {
"Type": "Http",
"Port": 5050
}
}
}
```
#### Endpoints
##### POST /mcp/invoke
Invokes MCP methods via JSON-RPC 2.0.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "search_documents",
"arguments": {
"query": "test"
}
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": "1",
"result": {
"content": "Search results...",
"isSuccess": true
}
}
```
##### GET /health
Health check endpoint.
**Response:**
```json
{
"status": "Healthy",
"timestamp": "2025-10-19T12:00:00Z"
}
```
---
## Models
### McpToolSchema
**Namespace:** `OpenHarbor.MCP.Core.Models`
JSON Schema representation for tool parameters.
#### Properties
```csharp
public class McpToolSchema
{
public string Type { get; set; } // "object"
public Dictionary<string, McpProperty> Properties { get; set; }
public List<string> Required { get; set; }
}
public class McpProperty
{
public string Type { get; set; } // "string", "number", "boolean", "array", "object"
public string Description { get; set; }
public bool Required { get; set; }
public object Default { get; set; }
}
```
#### Example
```csharp
var schema = new McpToolSchema
{
Type = "object",
Properties = new Dictionary<string, McpProperty>
{
["query"] = new()
{
Type = "string",
Description = "Search query",
Required = true
},
["maxResults"] = new()
{
Type = "number",
Description = "Maximum number of results",
Required = false,
Default = 10
}
}
};
```
---
### McpToolResult
**Namespace:** `OpenHarbor.MCP.Core.Models`
Represents the result of a tool execution.
#### Properties
```csharp
public class McpToolResult
{
public bool IsSuccess { get; set; }
public string Content { get; set; }
public string ErrorMessage { get; set; }
public int? ErrorCode { get; set; }
}
```
#### Static Factory Methods
##### Success
```csharp
public static McpToolResult Success(object content)
```
Creates a successful result.
**Example:**
```csharp
return McpToolResult.Success(new
{
results = documents,
count = documents.Count
});
```
##### Error
```csharp
public static McpToolResult Error(string message, int? code = null)
```
Creates an error result.
**Example:**
```csharp
return McpToolResult.Error("Document not found", 404);
```
---
## ASP.NET Core Integration
### Service Extensions
**Namespace:** `OpenHarbor.MCP.AspNetCore`
#### AddMcpServer
```csharp
public static IServiceCollection AddMcpServer(
this IServiceCollection services,
McpServer server)
```
Registers MCP server and dependencies.
**Example:**
```csharp
var registry = new ToolRegistry();
registry.AddTool(new SearchTool());
var server = new McpServer(registry);
builder.Services.AddMcpServer(server);
```
---
### Endpoint Mapping
#### MapMcpEndpoints
```csharp
public static IEndpointRouteBuilder MapMcpEndpoints(
this IEndpointRouteBuilder endpoints,
McpServer server)
```
Maps HTTP endpoints for MCP protocol.
**Example:**
```csharp
var app = builder.Build();
app.MapMcpEndpoints(server);
```
**Mapped Endpoints:**
- `POST /mcp/invoke` - JSON-RPC 2.0 method invocation
- `GET /health` - Health check
---
## JSON-RPC 2.0 Methods
### tools/list
Lists all available tools.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/list"
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": "1",
"result": {
"tools": [
{
"name": "search_documents",
"description": "Search documents by query",
"inputSchema": { ... }
}
]
}
}
```
### tools/call
Executes a tool.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": "2",
"method": "tools/call",
"params": {
"name": "search_documents",
"arguments": {
"query": "test",
"maxResults": 10
}
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": "2",
"result": {
"content": { "results": [...] },
"isSuccess": true
}
}
```
---
## Error Handling
### Error Codes
| Code | Meaning |
|------|---------|
| `-32700` | Parse error (invalid JSON) |
| `-32600` | Invalid request |
| `-32601` | Method not found |
| `-32602` | Invalid params |
| `-32603` | Internal error |
### Error Response
```json
{
"jsonrpc": "2.0",
"id": "1",
"error": {
"code": -32601,
"message": "Method not found",
"data": {
"method": "invalid_method"
}
}
}
```
---
## Complete Example
### Creating an MCP Server
```csharp
using OpenHarbor.MCP.Core;
using OpenHarbor.MCP.Core.Abstractions;
using Microsoft.AspNetCore.Builder;
// 1. Create tools
public class SearchTool : IMcpTool
{
public string Name => "search_documents";
public string Description => "Search documents";
public McpToolSchema Schema => new()
{
Type = "object",
Properties = new Dictionary<string, McpProperty>
{
["query"] = new() { Type = "string", Required = true }
}
};
public async Task<McpToolResult> ExecuteAsync(
Dictionary<string, object> parameters,
CancellationToken ct = default)
{
var query = parameters["query"].ToString();
// Implementation here
return McpToolResult.Success($"Results for: {query}");
}
}
// 2. Set up server
var builder = WebApplication.CreateBuilder(args);
var registry = new ToolRegistry();
registry.AddTool(new SearchTool());
var server = new McpServer(registry);
builder.Services.AddMcpServer(server);
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(5050);
});
var app = builder.Build();
// 3. Map endpoints
app.MapMcpEndpoints(server);
app.MapHealthChecks("/health");
// 4. Start server
await app.RunAsync();
```
### Calling from Client
```bash
curl -X POST http://localhost:5050/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "search_documents",
"arguments": {
"query": "test"
}
}
}'
```
---
## See Also
- [Server Architecture](../architecture.md)
- [Configuration Guide](../configuration.md)
- [Security Guide](../security.md)
- [TDD Guide](../tdd-guide.md)
---
**Document Type:** API Reference
**Version:** 1.0.0
**Last Updated:** 2025-10-19
**Maintained By:** Svrnty Development Team

View File

@ -0,0 +1,676 @@
# HTTPS/TLS Setup Guide - OpenHarbor.MCP.Server
**Purpose**: Production-grade HTTPS/TLS configuration for secure MCP server 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. [Development Setup](#development-setup)
4. [Production Setup](#production-setup)
5. [Certificate Management](#certificate-management)
6. [Security Headers (HSTS)](#security-headers-hsts)
7. [Testing HTTPS](#testing-https)
8. [Troubleshooting](#troubleshooting)
---
## Overview
OpenHarbor.MCP.Server supports HTTPS/TLS through ASP.NET Core's Kestrel web server. This guide covers configuration for both development and production environments.
**Security Benefits:**
- Encrypted communication (prevents eavesdropping)
- Client/server authentication
- Data integrity verification
- Compliance with security best practices
**Default Behavior:**
- HTTP only (for development ease)
- HTTPS must be explicitly configured
- Uses Kestrel's built-in TLS support
---
## Prerequisites
### Development
- .NET 8.0 SDK
- OpenSSL or dotnet dev-certs tool
- PowerShell (Windows) or bash (Linux/macOS)
### Production
- Valid TLS certificate (from CA or Let's Encrypt)
- Private key file
- Certificate chain (intermediate + root CA certs)
- Reverse proxy (optional): Nginx, Traefik, or Kestrel standalone
---
## Development Setup
### Option 1: ASP.NET Core Development Certificate (Recommended)
**Step 1: Generate Development Certificate**
**Windows / macOS / Linux:**
```bash
# Generate and trust dev certificate
dotnet dev-certs https --trust
# Verify installation
dotnet dev-certs https --check
```
**Step 2: Configure appsettings.Development.json**
Create or update `appsettings.Development.json` in the Server project:
```json
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5050"
},
"Https": {
"Url": "https://localhost:5051",
"Certificate": {
"Subject": "CN=localhost",
"Store": "My",
"Location": "CurrentUser",
"AllowInvalid": true
}
}
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
```
**Step 3: Run Server with HTTPS**
```bash
cd OpenHarbor.MCP.Server/samples/CodexMcpServer
dotnet run --environment Development
# Server will listen on:
# - HTTP: http://localhost:5050
# - HTTPS: https://localhost:5051
```
**Step 4: Test HTTPS Endpoint**
```bash
# Test health endpoint
curl -k https://localhost:5051/health
# Test MCP invoke endpoint
curl -k -X POST https://localhost:5051/mcp/invoke \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": "1"
}'
```
> **Note**: `-k` flag skips certificate validation (dev only!)
### Option 2: Custom Development Certificate with OpenSSL
**Step 1: Generate Self-Signed Certificate**
```bash
# Create directory for certs
mkdir -p /home/svrnty/codex/OpenHarbor.MCP.Server/certs
# Generate private key
openssl genrsa -out certs/localhost.key 2048
# Generate certificate signing request (CSR)
openssl req -new -key certs/localhost.key -out certs/localhost.csr \
-subj "/C=CA/ST=Quebec/L=Montreal/O=Svrnty/CN=localhost"
# Generate self-signed certificate (valid for 365 days)
openssl x509 -req -days 365 -in certs/localhost.csr \
-signkey certs/localhost.key -out certs/localhost.crt
# Convert to PFX format (required by Kestrel)
openssl pkcs12 -export -out certs/localhost.pfx \
-inkey certs/localhost.key -in certs/localhost.crt \
-passout pass:YourSecurePassword
```
**Step 2: Configure appsettings.Development.json**
```json
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://localhost:5051",
"Certificate": {
"Path": "certs/localhost.pfx",
"Password": "YourSecurePassword"
}
}
}
}
}
```
**Security**: Add `certs/` directory to `.gitignore` to avoid committing private keys.
---
## Production Setup
### Option 1: Certificate from File (Recommended for Kubernetes/Docker)
**Step 1: Obtain Production Certificate**
Use one of:
- **Let's Encrypt** (free, automated renewal)
- **Commercial CA** (DigiCert, Sectigo, etc.)
- **Internal PKI** (corporate certificate authority)
**Step 2: Prepare Certificate Files**
Ensure you have:
- `server.crt` - Server certificate
- `server.key` - Private key
- `ca-bundle.crt` - Intermediate + root CA certificates (optional)
**Step 3: Convert to PFX Format**
```bash
# Combine cert + key + chain into PFX
openssl pkcs12 -export -out server.pfx \
-inkey server.key \
-in server.crt \
-certfile ca-bundle.crt \
-passout pass:ProductionSecurePassword
```
**Step 4: Deploy Certificate**
**Docker/Kubernetes:**
```bash
# Create Kubernetes secret
kubectl create secret generic mcp-server-tls \
--from-file=server.pfx=server.pfx \
--from-literal=password=ProductionSecurePassword
# Mount in deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
spec:
template:
spec:
containers:
- name: mcp-server
volumeMounts:
- name: tls-cert
mountPath: /app/certs
readOnly: true
env:
- name: CERTIFICATE_PASSWORD
valueFrom:
secretKeyRef:
name: mcp-server-tls
key: password
volumes:
- name: tls-cert
secret:
secretName: mcp-server-tls
```
**Step 5: Configure appsettings.Production.json**
```json
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://*:5051",
"Certificate": {
"Path": "/app/certs/server.pfx",
"Password": "" // Read from environment variable
},
"Protocols": "Http1AndHttp2"
}
}
},
"AllowedHosts": "*"
}
```
**Step 6: Set Environment Variable for Password**
```bash
export KESTREL__CERTIFICATES__DEFAULT__PASSWORD="ProductionSecurePassword"
```
Or in `docker-compose.yml`:
```yaml
services:
mcp-server:
environment:
- KESTREL__CERTIFICATES__DEFAULT__PASSWORD=${CERTIFICATE_PASSWORD}
volumes:
- ./certs/server.pfx:/app/certs/server.pfx:ro
```
### Option 2: Certificate from Azure Key Vault
**Step 1: Store Certificate in Key Vault**
```bash
az keyvault certificate import \
--vault-name your-keyvault \
--name mcp-server-cert \
--file server.pfx \
--password ProductionSecurePassword
```
**Step 2: Configure appsettings.Production.json**
```json
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://*:5051",
"Certificate": {
"KeyVaultUri": "https://your-keyvault.vault.azure.net/",
"CertificateName": "mcp-server-cert"
}
}
}
}
}
```
**Step 3: Grant Managed Identity Access**
```bash
az keyvault set-policy \
--name your-keyvault \
--object-id <managed-identity-object-id> \
--certificate-permissions get
```
### Option 3: Reverse Proxy (Nginx/Traefik) with TLS Termination
**Best for**: Load balancing, multiple servers, centralized certificate management
**Architecture**:
```
Client → HTTPS → Nginx/Traefik (TLS termination) → HTTP → MCP Server (5050)
```
**Nginx Configuration** (`/etc/nginx/sites-available/mcp-server`):
```nginx
server {
listen 443 ssl http2;
server_name mcp.example.com;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# Modern TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://localhost:5050;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name mcp.example.com;
return 301 https://$server_name$request_uri;
}
```
**Traefik Configuration** (`docker-compose.yml`):
```yaml
services:
traefik:
image: traefik:v2.10
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
mcp-server:
image: openharbor/mcp-server:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.mcp.rule=Host(`mcp.example.com`)"
- "traefik.http.routers.mcp.entrypoints=websecure"
- "traefik.http.routers.mcp.tls.certresolver=letsencrypt"
- "traefik.http.services.mcp.loadbalancer.server.port=5050"
```
---
## Certificate Management
### Automatic Renewal with Let's Encrypt
**Using Certbot:**
```bash
# Install certbot
sudo apt-get install certbot
# Obtain certificate
sudo certbot certonly --standalone -d mcp.example.com
# Certificates location: /etc/letsencrypt/live/mcp.example.com/
# - fullchain.pem (certificate + chain)
# - privkey.pem (private key)
# Convert to PFX for Kestrel
sudo openssl pkcs12 -export \
-out /etc/letsencrypt/live/mcp.example.com/server.pfx \
-inkey /etc/letsencrypt/live/mcp.example.com/privkey.pem \
-in /etc/letsencrypt/live/mcp.example.com/fullchain.pem \
-passout pass:YourSecurePassword
# Auto-renewal (certbot creates cron job automatically)
sudo certbot renew --dry-run
```
### Certificate Rotation
**Step 1: Update Certificate File**
Replace `server.pfx` with new certificate.
**Step 2: Reload Without Downtime**
```bash
# Docker
docker exec mcp-server kill -HUP 1
# Kubernetes
kubectl rollout restart deployment/mcp-server
# Systemd
sudo systemctl reload mcp-server
```
**Step 3: Verify New Certificate**
```bash
echo | openssl s_client -connect mcp.example.com:443 -servername mcp.example.com 2>/dev/null | openssl x509 -noout -dates
```
---
## Security Headers (HSTS)
**HTTP Strict Transport Security (HSTS)** forces browsers to use HTTPS.
**Method 1: Kestrel Middleware** (Recommended)
Update `Program.cs` in `CodexMcpServer`:
```csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Add HSTS middleware (production only)
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
// Redirect HTTP to HTTPS
app.UseHttpsRedirection();
// ... rest of configuration
app.Run();
```
**Method 2: Custom Middleware**
```csharp
app.Use(async (context, next) =>
{
if (!context.Request.IsHttps)
{
var httpsUrl = $"https://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
context.Response.Redirect(httpsUrl, permanent: true);
return;
}
// Add HSTS header
context.Response.Headers.Add("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
await next();
});
```
**Method 3: Reverse Proxy (Nginx)**
Already shown in [Option 3](#option-3-reverse-proxy-nginxtraefik-with-tls-termination) above.
---
## Testing HTTPS
### 1. Health Check
```bash
curl https://mcp.example.com/health
```
Expected output:
```json
{
"status": "Healthy",
"service": "MCP Server",
"timestamp": "2025-10-19T12:00:00Z"
}
```
### 2. MCP Invoke Endpoint
```bash
curl -X POST https://mcp.example.com/mcp/invoke \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": "test-1"
}'
```
### 3. TLS Configuration Check
```bash
# Check TLS version and cipher suite
nmap --script ssl-enum-ciphers -p 443 mcp.example.com
# Check certificate validity
echo | openssl s_client -connect mcp.example.com:443 -servername mcp.example.com 2>/dev/null | openssl x509 -noout -text
```
### 4. HSTS Validation
```bash
curl -I https://mcp.example.com/health | grep -i strict-transport-security
```
Expected output:
```
Strict-Transport-Security: max-age=31536000; includeSubDomains
```
### 5. SSL Labs Test
For public-facing servers, use [SSL Labs](https://www.ssllabs.com/ssltest/) to get an A+ rating.
---
## Troubleshooting
### Issue: "Unable to configure HTTPS endpoint"
**Symptom:**
```
Unhandled exception. System.InvalidOperationException: Unable to configure HTTPS endpoint.
```
**Solution:**
1. Verify certificate file exists at specified path
2. Check file permissions (readable by server process)
3. Verify password is correct
4. Check certificate format (must be PFX for Kestrel)
```bash
# Verify PFX file
openssl pkcs12 -info -in server.pfx -noout
```
### Issue: "The certificate chain was issued by an authority that is not trusted"
**Symptom:**
Clients reject the certificate as untrusted.
**Solution:**
1. Ensure CA bundle is included in PFX
2. Add intermediate certificates to certificate store
3. For development, use `dotnet dev-certs https --trust`
### Issue: "Connection reset" or "SSL handshake failed"
**Symptom:**
Client cannot establish TLS connection.
**Solution:**
1. Check firewall allows port 443
2. Verify TLS protocols match (client vs. server)
3. Check cipher suite compatibility
```bash
# Test with specific TLS version
openssl s_client -connect mcp.example.com:443 -tls1_2
```
### Issue: Certificate Expired
**Symptom:**
```
The remote certificate is invalid according to the validation procedure.
```
**Solution:**
1. Check certificate expiry:
```bash
echo | openssl s_client -connect mcp.example.com:443 2>/dev/null | openssl x509 -noout -enddate
```
2. Renew certificate (see [Certificate Management](#certificate-management))
3. Deploy new certificate
### Issue: HSTS Not Working
**Symptom:**
Browser still allows HTTP connections.
**Solution:**
1. Verify HSTS header is sent:
```bash
curl -I https://mcp.example.com/health | grep Strict-Transport-Security
```
2. Clear browser HSTS cache (Chrome: `chrome://net-internals/#hsts`)
3. Ensure middleware is configured correctly
---
## Production Checklist
Before deploying to production, verify:
- [ ] Valid TLS certificate from trusted CA
- [ ] Certificate includes full chain (intermediate + root)
- [ ] Private key is stored securely (Kubernetes secret, Azure Key Vault, etc.)
- [ ] Certificate password is stored in environment variable (not in config)
- [ ] Certificate expiry monitoring is configured
- [ ] Automatic renewal is set up (Let's Encrypt, certbot)
- [ ] HSTS header is enabled
- [ ] HTTP → HTTPS redirect is configured
- [ ] TLS 1.2+ is enforced (no SSLv3, TLS 1.0, TLS 1.1)
- [ ] Strong cipher suites are configured
- [ ] Certificate Common Name (CN) matches domain
- [ ] Firewall allows port 443
- [ ] Health check endpoint is accessible via HTTPS
- [ ] Load balancer (if used) is configured for TLS passthrough or termination
- [ ] Monitoring/alerting is set up for certificate expiry
---
## References
**ASP.NET Core Documentation:**
- [Configure HTTPS in Kestrel](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints)
- [Enforce HTTPS](https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl)
- [HSTS Middleware](https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl#http-strict-transport-security-protocol-hsts)
**Security Best Practices:**
- [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/)
- [OWASP TLS Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html)
- [SSL Labs Best Practices](https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices)
**Certificate Authorities:**
- [Let's Encrypt](https://letsencrypt.org/) - Free automated certificates
- [DigiCert](https://www.digicert.com/) - Commercial CA
- [Sectigo](https://sectigo.com/) - Commercial CA
---
**Document Version**: 1.0.0
**Last Updated**: 2025-10-19
**Maintained By**: Svrnty Development Team
**Related**: [Deployment Guide](deployment-guide.md), [Security Best Practices](../security/security-guide.md)

1001
docs/implementation-plan.md Normal file

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,273 @@
# OpenHarbor.MCP - Reusable Module Design
**Document Type:** Architecture and Design Specification
**Created:** 2025-10-19
**Purpose:** Extract MCP server functionality into standalone, reusable .NET library
**Version:** 1.0.0
---
## Overview
OpenHarbor.MCP is a **standalone, modular, scalable MCP library** that can be extracted and reused across multiple projects. While CODEX will be the first implementation, the module is designed for general-purpose use.
**Key Design Principles:**
- Zero CODEX dependencies - Pure .NET library
- Self-contained - All instructions and examples included
- Reusable - Copy folder to any project
- Automated setup - AGENT-PRIMER.md for AI-assisted configuration
- Clean Architecture - Core → Infrastructure → Integration layers
---
## Project Location
**Standalone folder (NOT in CODEX codebase):**
```
/home/svrnty/codex/OpenHarbor.MCP/ # Separate from CODEX
├── README.md # Usage documentation
├── AGENT-PRIMER.md # AI-automated configuration
├── LICENSE # MIT license
├── OpenHarbor.MCP.sln # Solution file
├── src/ # Source code
├── tests/ # Unit + integration tests
├── samples/CodexMcpServer/ # CODEX example
└── docs/ # Architecture & guides
```
**Benefits:**
- No contamination of CODEX codebase
- Can copy entire folder to new projects
- CODEX references it via NuGet or project reference
- Independent versioning and release
---
## Module Architecture
**Clean Architecture Layers:**
### 1. OpenHarbor.MCP.Core - Pure Abstractions
- `IMcpServer` - Server interface
- `IMcpTool` - Tool interface
- `IPermissionProvider` - Permission abstraction
- `IRateLimiter` - Rate limiting abstraction
- Zero dependencies
### 2. OpenHarbor.MCP.Infrastructure - Implementation
- `McpServer` - Server implementation
- `ToolRegistry` - Dynamic tool registration
- `StdioTransport` - Process communication
- `PermissionProvider` - Access control
- `TokenBucketRateLimiter` - Rate limiting
### 3. OpenHarbor.MCP.AspNetCore - Integration
- ServiceCollectionExtensions - DI
- McpMiddleware - HTTP transport
- McpOptions - Configuration
### 4. OpenHarbor.MCP.Cli - CLI Runner
- HTTP transport runner
- Configuration loading
---
## AGENT-PRIMER.md - Automated Configuration
**Purpose:** AI agents read this file and automatically:
1. **Analyze target system** - Detect .NET version, project type, dependencies
2. **Generate configuration** - Create appsettings.json, mcp-config.json, Program.cs
3. **Create sample tools** - Based on detected system (database, API, git)
4. **Setup environment** - Generate .editorconfig, launchSettings.json, scripts
5. **Validate** - Run tests and verify MCP connectivity
**AI Agent Workflow:**
```
User: "Copy OpenHarbor.MCP to my project"
AI reads AGENT-PRIMER.md
AI analyzes target system automatically
AI generates all configuration files
AI creates sample implementations
User validates and runs
```
**Example for CODEX:**
- Detects: ASP.NET Core API at `http://localhost:5050`
- Generates: `CodexMcpServer` with 6 tools
- Configures: Permission model with agent scopes
- Output: `samples/CodexMcpServer/` ready to use
---
## CODEX Integration Strategy
**Option 1: NuGet Package (Production)**
```xml
<PackageReference Include="OpenHarbor.MCP.AspNetCore" Version="1.0.0" />
```
**Option 2: Local Project Reference (Development)**
```xml
<ProjectReference Include="../OpenHarbor.MCP/src/OpenHarbor.MCP.AspNetCore/OpenHarbor.MCP.AspNetCore.csproj" />
```
**CODEX MCP Server Location:**
```
src/Codex.Mcp/ # Uses OpenHarbor.MCP library
├── Program.cs # CLI entry point
├── CodexMcpServer.cs # Server configuration
├── Tools/
│ ├── SearchCodexTool.cs # search_codex implementation
│ ├── GetDocumentTool.cs # get_document implementation
│ └── ... (4 more tools)
├── Permissions/
│ └── CodexPermissionProvider.cs # CODEX-specific permissions
└── codex-mcp-config.json # Agent configuration
```
---
## Implementation Phases
### Phase 1: Core Module Development (Week 1-2, 6-8 hours)
- Location: `/home/svrnty/codex/OpenHarbor.MCP/src/`
- Deliverables:
- Core abstractions (Core project)
- Server implementation (Infrastructure project)
- ASP.NET integration (AspNetCore project)
- CLI runner (Cli project)
- AGENT-PRIMER.md
- Configuration templates
- Testing: TDD approach (tests first, all phases)
### Phase 2: CODEX Integration Example (Week 2-3, 4-6 hours)
- Location: `/home/svrnty/codex/OpenHarbor.MCP/samples/CodexMcpServer/`
- Deliverables:
- Sample implementation with 6 CODEX tools
- Permission configuration
- Rate limiting setup
- Integration tests with CODEX API
### Phase 3: Documentation & Reusability (Week 3, 4-6 hours)
- Location: `/home/svrnty/codex/OpenHarbor.MCP/docs/`
- Deliverables:
- Complete README.md
- Architecture documentation
- Tool creation guide
- Deployment guide
- Migration guide (copy to new projects)
### Phase 4: CODEX Production Integration (Week 3-4, 2-4 hours)
- Location: CODEX references OpenHarbor.MCP
- Deliverables:
- Update CODEX to use library
- Production deployment configuration
- Update future features registry
- Mark CODEX MCP implementation complete
---
## Technology Stack
**Same as CODEX (approved):**
- .NET 8.0 (C# 11)
- ASP.NET Core 8
- xUnit (testing)
- Moq (mocking)
- System.Text.Json (serialization)
**New dependencies (require approval):**
- MCP SDK: `@modelcontextprotocol/sdk` (TypeScript) OR .NET MCP library
- Tier: Core (essential for MCP protocol)
- License: Open source (verify)
- Purpose: MCP protocol implementation
---
## Reusability Features
### 1. Folder Portability
- Copy `/home/svrnty/codex/OpenHarbor.MCP/` to any project
- Self-contained with all documentation
- No external CODEX dependencies
### 2. AI-Assisted Setup
- AGENT-PRIMER.md guides automated configuration
- System analysis detects project specifics
- Configuration generation based on analysis
### 3. Extensibility
- Implement `IMcpTool` for custom tools
- Implement `IPermissionProvider` for custom auth
- Implement `IRateLimiter` for custom rate limiting
### 4. NuGet Distribution
- Publish to NuGet when mature
- Other teams use via package manager
- Versioned independently from CODEX
---
## Security Considerations
**Built-in security:**
- Permission-based access control
- Rate limiting per agent
- Audit logging for all operations
- Deny-by-default security model
**CODEX-specific security:**
- Agent scopes (all, documentation, public)
- Rate limits per agent type
- Read-only by default
- Write operations require explicit approval
---
## Success Criteria
**Module-level:**
- Reusable across projects (not CODEX-specific)
- Clean Architecture respected
- Full test coverage (>90%)
- Documentation complete
- NuGet package ready
**CODEX integration:**
- CODEX MCP server operational
- All 6 tools working
- Permission enforcement validated
- Rate limiting tested
---
## Related Documentation
**OpenHarbor.MCP Documentation:**
- [README.md](../README.md) - Project overview and quick start
- [AGENT-PRIMER.md](../AGENT-PRIMER.md) - AI-automated configuration guide
- [Implementation Plan](implementation-plan.md) - Detailed TDD implementation strategy
**CODEX Documentation:**
- `CODEX/scratch/future-mcp-integration-plan.md` - Strategic context and CODEX use case
- `CODEX/.codex/future-features/registry.json` - Trigger tracking
- `CODEX/docs/architecture/claude-code-mcp-integration.md` - ADR (planned)
---
## Revision History
| Version | Date | Changes |
|---------|------|---------|
| 1.0.0 | 2025-10-19 | Initial module design extracted from CODEX future features documentation |
---
**Status:** Designed - Ready for Implementation
**Next Steps:** Begin Phase 1 when Phase 4 of CODEX is complete
**Last Updated:** 2025-10-19

View File

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

View File

@ -0,0 +1,156 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using CodexMcpServer.Tools;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenHarbor.MCP.AspNetCore.Extensions;
using OpenHarbor.MCP.Core;
namespace CodexMcpServer;
/// <summary>
/// CODEX MCP Server entry point.
/// Exposes 6 CODEX tools via Model Context Protocol.
/// Supports both HTTP (default) and stdio transports.
/// </summary>
internal class Program
{
private static async Task Main(string[] args)
{
// Check if stdio mode is requested
bool useStdio = args.Length > 0 && args[0] == "--stdio";
if (useStdio)
{
await RunStdioModeAsync();
}
else
{
await RunHttpModeAsync(args);
}
}
/// <summary>
/// Runs the MCP server in HTTP mode (default).
/// Listens on http://localhost:5050 for MCP requests.
/// </summary>
private static async Task RunHttpModeAsync(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Configure services
builder.Services.AddSingleton<HttpClient>(sp => new HttpClient
{
BaseAddress = new Uri("http://localhost:5099"),
Timeout = TimeSpan.FromSeconds(30)
});
// Create and register MCP server
var httpClient = new HttpClient
{
BaseAddress = new Uri("http://localhost:5099"),
Timeout = TimeSpan.FromSeconds(30)
};
var registry = new ToolRegistry();
registry.AddTool(new SearchCodexTool(httpClient));
registry.AddTool(new GetDocumentTool(httpClient));
registry.AddTool(new ListDocumentsTool(httpClient));
registry.AddTool(new SearchByTagTool(httpClient));
registry.AddTool(new GetDocumentSectionsTool(httpClient));
registry.AddTool(new ListTagsTool(httpClient));
var server = new McpServer(registry);
builder.Services.AddMcpServer(server);
// Configure Kestrel to listen on port 5050
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(5050);
});
var app = builder.Build();
// Map MCP endpoints
app.MapMcpEndpoints(server);
Console.WriteLine("=== CODEX MCP Server (HTTP Mode) ===");
Console.WriteLine("Listening on: http://localhost:5050");
Console.WriteLine("Endpoints:");
Console.WriteLine(" POST /mcp/invoke - MCP JSON-RPC endpoint");
Console.WriteLine(" GET /health - Health check");
Console.WriteLine();
Console.WriteLine("Connected to CODEX API: http://localhost:5099");
Console.WriteLine();
Console.WriteLine("Available Tools:");
Console.WriteLine(" - search_codex");
Console.WriteLine(" - get_document");
Console.WriteLine(" - list_documents");
Console.WriteLine(" - search_by_tag");
Console.WriteLine(" - get_document_sections");
Console.WriteLine(" - list_tags");
Console.WriteLine();
Console.WriteLine("Press Ctrl+C to shutdown");
Console.WriteLine();
await app.RunAsync();
}
/// <summary>
/// Runs the MCP server in stdio mode (legacy).
/// Communicates via stdin/stdout for Claude Desktop integration.
/// </summary>
private static async Task RunStdioModeAsync()
{
try
{
// Initialize HTTP client for CODEX API calls
var httpClient = new HttpClient
{
BaseAddress = new Uri("http://localhost:5099"),
Timeout = TimeSpan.FromSeconds(30)
};
// Create tool registry and register all 6 CODEX tools
var registry = new ToolRegistry();
registry.AddTool(new SearchCodexTool(httpClient));
registry.AddTool(new GetDocumentTool(httpClient));
registry.AddTool(new ListDocumentsTool(httpClient));
registry.AddTool(new SearchByTagTool(httpClient));
registry.AddTool(new GetDocumentSectionsTool(httpClient));
registry.AddTool(new ListTagsTool(httpClient));
// Create MCP server
var server = new McpServer(registry);
// Create stdio transport (JSON-RPC 2.0 over stdin/stdout)
using var transport = new StdioTransport(
Console.OpenStandardInput(),
Console.OpenStandardOutput()
);
// Main request/response loop
while (true)
{
var request = await transport.ReadRequestAsync();
if (request == null)
{
// End of stream - exit gracefully
break;
}
var response = await server.HandleRequestAsync(request);
await transport.WriteResponseAsync(response);
}
}
catch (Exception ex)
{
// Log errors to stderr (stdout is reserved for MCP protocol)
await Console.Error.WriteLineAsync($"Fatal error: {ex.Message}");
await Console.Error.WriteLineAsync(ex.StackTrace);
Environment.Exit(1);
}
}
}

View File

@ -0,0 +1,112 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
namespace CodexMcpServer.Tools;
/// <summary>
/// MCP tool for retrieving all sections of a specific CODEX document.
/// Calls CODEX API GET /api/documents/{id}/sections endpoint.
/// </summary>
public class GetDocumentSectionsTool : IMcpTool
{
private readonly HttpClient _httpClient;
private static readonly JsonDocument _schema = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Document ID to retrieve sections from"
}
},
"required": ["id"]
}
""");
public GetDocumentSectionsTool(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
// Set base address if not already set
if (_httpClient.BaseAddress == null)
{
_httpClient.BaseAddress = new Uri("http://localhost:5050");
}
}
/// <inheritdoc/>
public string Name => "get_document_sections";
/// <inheritdoc/>
public string Description => "Retrieve all sections of a specific CODEX document. Returns structured sections with titles and content.";
/// <inheritdoc/>
public JsonDocument Schema => _schema;
/// <inheritdoc/>
public async Task<JsonDocument> ExecuteAsync(JsonDocument? arguments)
{
try
{
// Validate arguments
if (arguments == null)
{
return CreateErrorResponse("Arguments cannot be null. Expected {\"id\": \"document_id\"}");
}
var root = arguments.RootElement;
if (!root.TryGetProperty("id", out var idElement))
{
return CreateErrorResponse("Missing required parameter 'id'");
}
var id = idElement.GetString();
if (string.IsNullOrWhiteSpace(id))
{
return CreateErrorResponse("Document ID cannot be empty");
}
// Call CODEX API
var response = await _httpClient.GetAsync($"/api/documents/{id}/sections");
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
return CreateErrorResponse(
$"CODEX API returned error: {response.StatusCode}. {errorContent}"
);
}
// Parse and return response
var responseContent = await response.Content.ReadAsStringAsync();
try
{
return JsonDocument.Parse(responseContent);
}
catch (JsonException ex)
{
return CreateErrorResponse($"Failed to parse CODEX API response: {ex.Message}");
}
}
catch (HttpRequestException ex)
{
return CreateErrorResponse($"HTTP request failed: {ex.Message}");
}
catch (Exception ex)
{
return CreateErrorResponse($"Unexpected error: {ex.Message}");
}
}
private static JsonDocument CreateErrorResponse(string message)
{
var error = new
{
error = message
};
return JsonDocument.Parse(JsonSerializer.Serialize(error));
}
}

View File

@ -0,0 +1,112 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
namespace CodexMcpServer.Tools;
/// <summary>
/// MCP tool for retrieving a specific CODEX document by ID.
/// Calls CODEX API GET /api/documents/{id} endpoint.
/// </summary>
public class GetDocumentTool : IMcpTool
{
private readonly HttpClient _httpClient;
private static readonly JsonDocument _schema = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Document ID to retrieve"
}
},
"required": ["id"]
}
""");
public GetDocumentTool(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
// Set base address if not already set
if (_httpClient.BaseAddress == null)
{
_httpClient.BaseAddress = new Uri("http://localhost:5050");
}
}
/// <inheritdoc/>
public string Name => "get_document";
/// <inheritdoc/>
public string Description => "Retrieve a specific CODEX document by ID. Returns complete document details including metadata, content, and sections.";
/// <inheritdoc/>
public JsonDocument Schema => _schema;
/// <inheritdoc/>
public async Task<JsonDocument> ExecuteAsync(JsonDocument? arguments)
{
try
{
// Validate arguments
if (arguments == null)
{
return CreateErrorResponse("Arguments cannot be null. Expected {\"id\": \"document_id\"}");
}
var root = arguments.RootElement;
if (!root.TryGetProperty("id", out var idElement))
{
return CreateErrorResponse("Missing required parameter 'id'");
}
var id = idElement.GetString();
if (string.IsNullOrWhiteSpace(id))
{
return CreateErrorResponse("Document ID cannot be empty");
}
// Call CODEX API
var response = await _httpClient.GetAsync($"/api/documents/{id}");
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
return CreateErrorResponse(
$"CODEX API returned error: {response.StatusCode}. {errorContent}"
);
}
// Parse and return response
var responseContent = await response.Content.ReadAsStringAsync();
try
{
return JsonDocument.Parse(responseContent);
}
catch (JsonException ex)
{
return CreateErrorResponse($"Failed to parse CODEX API response: {ex.Message}");
}
}
catch (HttpRequestException ex)
{
return CreateErrorResponse($"HTTP request failed: {ex.Message}");
}
catch (Exception ex)
{
return CreateErrorResponse($"Unexpected error: {ex.Message}");
}
}
private static JsonDocument CreateErrorResponse(string message)
{
var error = new
{
error = message
};
return JsonDocument.Parse(JsonSerializer.Serialize(error));
}
}

View File

@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
namespace CodexMcpServer.Tools;
/// <summary>
/// MCP tool for listing CODEX documents with pagination support.
/// Calls CODEX API GET /api/documents endpoint.
/// </summary>
public class ListDocumentsTool : IMcpTool
{
private readonly HttpClient _httpClient;
private static readonly JsonDocument _schema = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"page": {
"type": "integer",
"description": "Page number for pagination (1-based)"
},
"pageSize": {
"type": "integer",
"description": "Number of items per page"
},
"limit": {
"type": "integer",
"description": "Maximum number of items to return"
},
"offset": {
"type": "integer",
"description": "Number of items to skip"
}
}
}
""");
public ListDocumentsTool(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
// Set base address if not already set
if (_httpClient.BaseAddress == null)
{
_httpClient.BaseAddress = new Uri("http://localhost:5050");
}
}
/// <inheritdoc/>
public string Name => "list_documents";
/// <inheritdoc/>
public string Description => "List all CODEX documents with optional pagination. Returns document summaries with metadata.";
/// <inheritdoc/>
public JsonDocument Schema => _schema;
/// <inheritdoc/>
public async Task<JsonDocument> ExecuteAsync(JsonDocument? arguments)
{
try
{
// Build query string from optional pagination parameters
var queryParams = new List<string>();
if (arguments != null)
{
var root = arguments.RootElement;
if (root.TryGetProperty("page", out var pageElement) && pageElement.ValueKind == JsonValueKind.Number)
{
queryParams.Add($"page={pageElement.GetInt32()}");
}
if (root.TryGetProperty("pageSize", out var pageSizeElement) && pageSizeElement.ValueKind == JsonValueKind.Number)
{
queryParams.Add($"pageSize={pageSizeElement.GetInt32()}");
}
if (root.TryGetProperty("limit", out var limitElement) && limitElement.ValueKind == JsonValueKind.Number)
{
queryParams.Add($"limit={limitElement.GetInt32()}");
}
if (root.TryGetProperty("offset", out var offsetElement) && offsetElement.ValueKind == JsonValueKind.Number)
{
queryParams.Add($"offset={offsetElement.GetInt32()}");
}
}
// Build final URL with query string
var url = "/api/documents";
if (queryParams.Any())
{
url += "?" + string.Join("&", queryParams);
}
// Call CODEX API
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
return CreateErrorResponse(
$"CODEX API returned error: {response.StatusCode}. {errorContent}"
);
}
// Parse and return response
var responseContent = await response.Content.ReadAsStringAsync();
try
{
return JsonDocument.Parse(responseContent);
}
catch (JsonException ex)
{
return CreateErrorResponse($"Failed to parse CODEX API response: {ex.Message}");
}
}
catch (HttpRequestException ex)
{
return CreateErrorResponse($"HTTP request failed: {ex.Message}");
}
catch (Exception ex)
{
return CreateErrorResponse($"Unexpected error: {ex.Message}");
}
}
private static JsonDocument CreateErrorResponse(string message)
{
var error = new
{
error = message
};
return JsonDocument.Parse(JsonSerializer.Serialize(error));
}
}

View File

@ -0,0 +1,90 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
namespace CodexMcpServer.Tools;
/// <summary>
/// MCP tool for listing all available tags in CODEX.
/// Calls CODEX API GET /api/tags endpoint.
/// </summary>
public class ListTagsTool : IMcpTool
{
private readonly HttpClient _httpClient;
private static readonly JsonDocument _schema = JsonDocument.Parse("""
{
"type": "object",
"properties": {}
}
""");
public ListTagsTool(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
// Set base address if not already set
if (_httpClient.BaseAddress == null)
{
_httpClient.BaseAddress = new Uri("http://localhost:5050");
}
}
/// <inheritdoc/>
public string Name => "list_tags";
/// <inheritdoc/>
public string Description => "List all available tags in CODEX. Returns tag names with document counts.";
/// <inheritdoc/>
public JsonDocument Schema => _schema;
/// <inheritdoc/>
public async Task<JsonDocument> ExecuteAsync(JsonDocument? arguments)
{
try
{
// No arguments required for this tool
// Call CODEX API
var response = await _httpClient.GetAsync("/api/tags");
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
return CreateErrorResponse(
$"CODEX API returned error: {response.StatusCode}. {errorContent}"
);
}
// Parse and return response
var responseContent = await response.Content.ReadAsStringAsync();
try
{
return JsonDocument.Parse(responseContent);
}
catch (JsonException ex)
{
return CreateErrorResponse($"Failed to parse CODEX API response: {ex.Message}");
}
}
catch (HttpRequestException ex)
{
return CreateErrorResponse($"HTTP request failed: {ex.Message}");
}
catch (Exception ex)
{
return CreateErrorResponse($"Unexpected error: {ex.Message}");
}
}
private static JsonDocument CreateErrorResponse(string message)
{
var error = new
{
error = message
};
return JsonDocument.Parse(JsonSerializer.Serialize(error));
}
}

View File

@ -0,0 +1,112 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
namespace CodexMcpServer.Tools;
/// <summary>
/// MCP tool for searching CODEX documents by tag.
/// Calls CODEX API GET /api/tags/{tag} endpoint.
/// </summary>
public class SearchByTagTool : IMcpTool
{
private readonly HttpClient _httpClient;
private static readonly JsonDocument _schema = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"tag": {
"type": "string",
"description": "Tag name to search for"
}
},
"required": ["tag"]
}
""");
public SearchByTagTool(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
// Set base address if not already set
if (_httpClient.BaseAddress == null)
{
_httpClient.BaseAddress = new Uri("http://localhost:5050");
}
}
/// <inheritdoc/>
public string Name => "search_by_tag";
/// <inheritdoc/>
public string Description => "Search CODEX documents by tag. Returns all documents that have the specified tag.";
/// <inheritdoc/>
public JsonDocument Schema => _schema;
/// <inheritdoc/>
public async Task<JsonDocument> ExecuteAsync(JsonDocument? arguments)
{
try
{
// Validate arguments
if (arguments == null)
{
return CreateErrorResponse("Arguments cannot be null. Expected {\"tag\": \"tag_name\"}");
}
var root = arguments.RootElement;
if (!root.TryGetProperty("tag", out var tagElement))
{
return CreateErrorResponse("Missing required parameter 'tag'");
}
var tag = tagElement.GetString();
if (string.IsNullOrWhiteSpace(tag))
{
return CreateErrorResponse("Tag cannot be empty");
}
// Call CODEX API
var response = await _httpClient.GetAsync($"/api/tags/{tag}");
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
return CreateErrorResponse(
$"CODEX API returned error: {response.StatusCode}. {errorContent}"
);
}
// Parse and return response
var responseContent = await response.Content.ReadAsStringAsync();
try
{
return JsonDocument.Parse(responseContent);
}
catch (JsonException ex)
{
return CreateErrorResponse($"Failed to parse CODEX API response: {ex.Message}");
}
}
catch (HttpRequestException ex)
{
return CreateErrorResponse($"HTTP request failed: {ex.Message}");
}
catch (Exception ex)
{
return CreateErrorResponse($"Unexpected error: {ex.Message}");
}
}
private static JsonDocument CreateErrorResponse(string message)
{
var error = new
{
error = message
};
return JsonDocument.Parse(JsonSerializer.Serialize(error));
}
}

View File

@ -0,0 +1,125 @@
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
namespace CodexMcpServer.Tools;
/// <summary>
/// MCP tool for searching CODEX documents.
/// Calls CODEX API /api/documents/search endpoint.
/// </summary>
public class SearchCodexTool : IMcpTool
{
private readonly HttpClient _httpClient;
private static readonly JsonDocument _schema = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query to find relevant documents in CODEX"
}
},
"required": ["query"]
}
""");
public SearchCodexTool(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
// Set base address if not already set
if (_httpClient.BaseAddress == null)
{
_httpClient.BaseAddress = new Uri("http://localhost:5050");
}
}
/// <inheritdoc/>
public string Name => "search_codex";
/// <inheritdoc/>
public string Description => "Search CODEX documents by query text. Returns matching documents with relevance scores.";
/// <inheritdoc/>
public JsonDocument Schema => _schema;
/// <inheritdoc/>
public async Task<JsonDocument> ExecuteAsync(JsonDocument? arguments)
{
try
{
// Validate arguments
if (arguments == null)
{
return CreateErrorResponse("Arguments cannot be null. Expected {\"query\": \"search text\"}");
}
var root = arguments.RootElement;
if (!root.TryGetProperty("query", out var queryElement))
{
return CreateErrorResponse("Missing required parameter 'query'");
}
var query = queryElement.GetString();
if (string.IsNullOrWhiteSpace(query))
{
return CreateErrorResponse("Query cannot be empty");
}
// Prepare request to CODEX API
var requestBody = new
{
query = query
};
var content = new StringContent(
JsonSerializer.Serialize(requestBody),
Encoding.UTF8,
"application/json"
);
// Call CODEX API
var response = await _httpClient.PostAsync("/api/documents/search", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
return CreateErrorResponse(
$"CODEX API returned error: {response.StatusCode}. {errorContent}"
);
}
// Parse and return response
var responseContent = await response.Content.ReadAsStringAsync();
try
{
return JsonDocument.Parse(responseContent);
}
catch (JsonException ex)
{
return CreateErrorResponse($"Failed to parse CODEX API response: {ex.Message}");
}
}
catch (HttpRequestException ex)
{
return CreateErrorResponse($"HTTP request failed: {ex.Message}");
}
catch (Exception ex)
{
return CreateErrorResponse($"Unexpected error: {ex.Message}");
}
}
private static JsonDocument CreateErrorResponse(string message)
{
var error = new
{
error = message
};
return JsonDocument.Parse(JsonSerializer.Serialize(error));
}
}

View File

@ -0,0 +1,24 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Mcp": {
"Server": {
"Name": "CodexMcpServer",
"Version": "1.0.0",
"Description": "CODEX MCP Server - Exposes CODEX tools via Model Context Protocol"
},
"Transport": {
"Type": "Http",
"Port": 5050
},
"Codex": {
"ApiBaseUrl": "http://localhost:5099",
"Timeout": "00:00:30"
}
}
}

View File

@ -0,0 +1,127 @@
using System;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using OpenHarbor.MCP.Core;
namespace OpenHarbor.MCP.AspNetCore.Extensions;
/// <summary>
/// Implements JSON-RPC 2.0 transport over HTTP.
/// Provides REST endpoints for MCP protocol communication.
/// </summary>
public static class HttpTransport
{
/// <summary>
/// Maps MCP HTTP endpoints to the application.
/// Creates /mcp/invoke endpoint for tool calls.
/// </summary>
/// <param name="endpoints">Endpoint route builder.</param>
/// <param name="server">MCP server instance to handle requests.</param>
public static void MapMcpEndpoints(this IEndpointRouteBuilder endpoints, McpServer server)
{
if (endpoints == null)
throw new ArgumentNullException(nameof(endpoints));
if (server == null)
throw new ArgumentNullException(nameof(server));
// Main MCP endpoint for JSON-RPC requests
endpoints.MapPost("/mcp/invoke", async (HttpContext context) =>
{
try
{
// Read request body
McpRequest? request;
try
{
request = await JsonSerializer.DeserializeAsync<McpRequest>(
context.Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
if (request == null)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, new
{
jsonrpc = "2.0",
error = new
{
code = -32600,
message = "Invalid Request: Empty request body"
},
id = (string?)null
});
return;
}
}
catch (JsonException ex)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, new
{
jsonrpc = "2.0",
error = new
{
code = -32700,
message = $"Parse error: {ex.Message}"
},
id = (string?)null
});
return;
}
// Process request through MCP server
var response = await server.HandleRequestAsync(request);
// Write response
context.Response.StatusCode = StatusCodes.Status200OK;
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
{
jsonrpc = "2.0",
error = new
{
code = -32603,
message = $"Internal error: {ex.Message}"
},
id = (string?)null
});
}
});
// Health check endpoint
endpoints.MapGet("/health", () => Results.Ok(new
{
status = "Healthy",
service = "MCP Server",
timestamp = DateTime.UtcNow
}));
}
/// <summary>
/// Adds MCP server services to the dependency injection container.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="server">MCP server instance to register.</param>
public static void AddMcpServer(this IServiceCollection services, McpServer server)
{
if (services == null)
throw new ArgumentNullException(nameof(services));
if (server == null)
throw new ArgumentNullException(nameof(server));
services.AddSingleton(server);
}
}

View File

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

View File

@ -0,0 +1,43 @@
using System.Text.Json;
using System.Threading.Tasks;
namespace OpenHarbor.MCP.Core;
/// <summary>
/// Core abstraction for an MCP (Model Context Protocol) tool.
/// Tools expose capabilities to Claude Desktop via standardized interface.
/// </summary>
public interface IMcpTool
{
/// <summary>
/// Unique identifier for the tool (e.g., "search_codex", "get_document").
/// Used by Claude Desktop to invoke the tool.
/// </summary>
string Name { get; }
/// <summary>
/// Human-readable description of what the tool does.
/// Shown to users in Claude Desktop tool selection.
/// </summary>
string Description { get; }
/// <summary>
/// JSON Schema defining the tool's input parameters.
/// Follows JSON Schema Draft 7 specification.
/// Claude Desktop validates arguments against this schema before execution.
/// </summary>
JsonDocument Schema { get; }
/// <summary>
/// Executes the tool with the provided arguments.
/// </summary>
/// <param name="arguments">
/// Tool arguments as JSON document matching the Schema.
/// May be null if tool requires no arguments.
/// </param>
/// <returns>
/// Tool execution result as JSON document.
/// Result structure is tool-specific.
/// </returns>
Task<JsonDocument> ExecuteAsync(JsonDocument? arguments);
}

View File

@ -0,0 +1,152 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
namespace OpenHarbor.MCP.Core;
/// <summary>
/// MCP Server that orchestrates tool execution and handles JSON-RPC 2.0 protocol methods.
/// Supports "tools/list" and "tools/call" methods.
/// </summary>
public class McpServer
{
private readonly ToolRegistry _toolRegistry;
/// <summary>
/// Creates a new McpServer instance.
/// </summary>
/// <param name="toolRegistry">Registry containing available tools.</param>
public McpServer(ToolRegistry toolRegistry)
{
_toolRegistry = toolRegistry ?? throw new ArgumentNullException(nameof(toolRegistry));
}
/// <summary>
/// Handles an incoming MCP request and returns a response.
/// </summary>
/// <param name="request">JSON-RPC 2.0 request.</param>
/// <returns>JSON-RPC 2.0 response.</returns>
public async Task<McpResponse> HandleRequestAsync(McpRequest request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
return request.Method switch
{
"tools/list" => await HandleToolsListAsync(request),
"tools/call" => await HandleToolsCallAsync(request),
_ => CreateErrorResponse(request.Id, -32601, $"Method not found: {request.Method}")
};
}
private async Task<McpResponse> HandleToolsListAsync(McpRequest request)
{
var tools = _toolRegistry.GetAllTools()
.Select(tool => new
{
name = tool.Name,
description = tool.Description,
inputSchema = tool.Schema
})
.ToArray();
var result = new
{
tools = tools
};
return new McpResponse
{
Id = request.Id,
Result = JsonDocument.Parse(JsonSerializer.Serialize(result))
};
}
private async Task<McpResponse> HandleToolsCallAsync(McpRequest request)
{
// Validate params
if (request.Params == null)
{
return CreateErrorResponse(
request.Id,
-32602,
"Invalid params: 'params' is required for tools/call"
);
}
var paramsRoot = request.Params.RootElement;
// Extract tool name
if (!paramsRoot.TryGetProperty("name", out var nameElement))
{
return CreateErrorResponse(
request.Id,
-32602,
"Invalid params: 'name' property is required"
);
}
var toolName = nameElement.GetString();
if (string.IsNullOrWhiteSpace(toolName))
{
return CreateErrorResponse(
request.Id,
-32602,
"Invalid params: 'name' cannot be empty"
);
}
// Find tool
var tool = _toolRegistry.GetTool(toolName);
if (tool == null)
{
return CreateErrorResponse(
request.Id,
-32601,
$"Tool not found: {toolName}"
);
}
// Extract arguments (optional)
JsonDocument? arguments = null;
if (paramsRoot.TryGetProperty("arguments", out var argsElement))
{
// Convert JsonElement to JsonDocument
var argsJson = argsElement.GetRawText();
arguments = JsonDocument.Parse(argsJson);
}
// Execute tool
try
{
var result = await tool.ExecuteAsync(arguments);
return new McpResponse
{
Id = request.Id,
Result = result
};
}
catch (Exception ex)
{
return CreateErrorResponse(
request.Id,
-32603,
$"Tool execution failed: {ex.Message}"
);
}
}
private McpResponse CreateErrorResponse(string id, int code, string message)
{
return new McpResponse
{
Id = id,
Error = new McpError
{
Code = code,
Message = message
}
};
}
}

View File

@ -0,0 +1,36 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenHarbor.MCP.Core;
/// <summary>
/// Represents a JSON-RPC 2.0 error object.
/// </summary>
public class McpError
{
/// <summary>
/// Error code indicating the error type.
/// Standard JSON-RPC error codes:
/// -32700: Parse error (invalid JSON)
/// -32600: Invalid Request
/// -32601: Method not found
/// -32602: Invalid params
/// -32603: Internal error
/// -32000 to -32099: Server-specific errors
/// </summary>
[JsonPropertyName("code")]
public required int Code { get; set; }
/// <summary>
/// Human-readable error message.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; set; }
/// <summary>
/// Optional additional error data providing more context.
/// </summary>
[JsonPropertyName("data")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonDocument? Data { get; set; }
}

View File

@ -0,0 +1,35 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenHarbor.MCP.Core;
/// <summary>
/// Represents a JSON-RPC 2.0 request from Claude Desktop to MCP Server.
/// </summary>
public class McpRequest
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; set; } = "2.0";
/// <summary>
/// RPC method name (e.g., "tools/list", "tools/call").
/// </summary>
[JsonPropertyName("method")]
public required string Method { get; set; }
/// <summary>
/// Request identifier for matching response to request.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; set; }
/// <summary>
/// Optional parameters for the method.
/// Structure depends on the method being called.
/// </summary>
[JsonPropertyName("params")]
public JsonDocument? Params { get; set; }
}

View File

@ -0,0 +1,38 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenHarbor.MCP.Core;
/// <summary>
/// Represents a JSON-RPC 2.0 response from MCP Server to Claude Desktop.
/// </summary>
public class McpResponse
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; set; } = "2.0";
/// <summary>
/// Request identifier matching the original request.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; set; }
/// <summary>
/// Successful result of the method call.
/// Mutually exclusive with Error (only one should be set).
/// </summary>
[JsonPropertyName("result")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonDocument? Result { get; set; }
/// <summary>
/// Error that occurred during method execution.
/// Mutually exclusive with Result (only one should be set).
/// </summary>
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public McpError? Error { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace OpenHarbor.MCP.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.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.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.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,96 @@
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace OpenHarbor.MCP.Core;
/// <summary>
/// Implements JSON-RPC 2.0 transport over stdin/stdout.
/// Handles newline-delimited JSON messages for communication with Claude Desktop.
/// </summary>
public class StdioTransport : IDisposable
{
private readonly StreamReader _reader;
private readonly StreamWriter _writer;
private bool _disposed;
/// <summary>
/// Creates a new StdioTransport instance.
/// </summary>
/// <param name="inputStream">Input stream (typically stdin).</param>
/// <param name="outputStream">Output stream (typically stdout).</param>
public StdioTransport(Stream inputStream, Stream outputStream)
{
if (inputStream == null)
throw new ArgumentNullException(nameof(inputStream));
if (outputStream == null)
throw new ArgumentNullException(nameof(outputStream));
_reader = new StreamReader(inputStream, Encoding.UTF8);
_writer = new StreamWriter(outputStream, Encoding.UTF8)
{
AutoFlush = true // Ensure immediate writing
};
}
/// <summary>
/// Reads a JSON-RPC request from the input stream.
/// Expects newline-delimited JSON format.
/// </summary>
/// <returns>Parsed McpRequest, or null if end of stream reached.</returns>
/// <exception cref="JsonException">Thrown if JSON is invalid.</exception>
public async Task<McpRequest?> ReadRequestAsync()
{
if (_disposed)
throw new ObjectDisposedException(nameof(StdioTransport));
var line = await _reader.ReadLineAsync();
if (string.IsNullOrEmpty(line))
return null;
try
{
var request = JsonSerializer.Deserialize<McpRequest>(line);
return request;
}
catch (JsonException)
{
// Re-throw JSON exceptions for caller to handle
throw;
}
}
/// <summary>
/// Writes a JSON-RPC response to the output stream.
/// Outputs newline-delimited JSON format.
/// </summary>
/// <param name="response">Response to write.</param>
/// <exception cref="ArgumentNullException">Thrown if response is null.</exception>
public async Task WriteResponseAsync(McpResponse response)
{
if (_disposed)
throw new ObjectDisposedException(nameof(StdioTransport));
if (response == null)
throw new ArgumentNullException(nameof(response));
var json = JsonSerializer.Serialize(response);
await _writer.WriteLineAsync(json);
await _writer.FlushAsync();
}
/// <summary>
/// Disposes resources used by the transport.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_reader?.Dispose();
_writer?.Dispose();
_disposed = true;
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenHarbor.MCP.Core;
/// <summary>
/// Registry for managing MCP tools.
/// Provides dynamic registration and retrieval of tools.
/// Thread-safe for concurrent access.
/// </summary>
public class ToolRegistry
{
private readonly Dictionary<string, IMcpTool> _tools = new();
private readonly object _lock = new();
/// <summary>
/// Adds a tool to the registry.
/// </summary>
/// <param name="tool">Tool to register.</param>
/// <exception cref="ArgumentNullException">Thrown when tool is null.</exception>
/// <exception cref="ArgumentException">Thrown when a tool with the same name is already registered.</exception>
public void AddTool(IMcpTool tool)
{
if (tool == null)
throw new ArgumentNullException(nameof(tool));
lock (_lock)
{
if (_tools.ContainsKey(tool.Name))
{
throw new ArgumentException(
$"Tool with name '{tool.Name}' is already registered.",
nameof(tool)
);
}
_tools[tool.Name] = tool;
}
}
/// <summary>
/// Retrieves a tool by name.
/// </summary>
/// <param name="name">Tool name.</param>
/// <returns>The tool if found, null otherwise.</returns>
public IMcpTool? GetTool(string name)
{
if (string.IsNullOrWhiteSpace(name))
return null;
lock (_lock)
{
return _tools.TryGetValue(name, out var tool) ? tool : null;
}
}
/// <summary>
/// Gets all registered tools.
/// </summary>
/// <returns>Read-only collection of all tools.</returns>
public IEnumerable<IMcpTool> GetAllTools()
{
lock (_lock)
{
return _tools.Values.ToList(); // Return copy for thread safety
}
}
/// <summary>
/// Gets all registered tool names.
/// </summary>
/// <returns>Collection of tool names.</returns>
public IEnumerable<string> GetToolNames()
{
lock (_lock)
{
return _tools.Keys.ToList(); // Return copy for thread safety
}
}
/// <summary>
/// Checks if a tool with the given name is registered.
/// </summary>
/// <param name="name">Tool name to check.</param>
/// <returns>True if tool exists, false otherwise.</returns>
public bool HasTool(string name)
{
if (string.IsNullOrWhiteSpace(name))
return false;
lock (_lock)
{
return _tools.ContainsKey(name);
}
}
/// <summary>
/// Removes a tool from the registry.
/// </summary>
/// <param name="name">Name of the tool to remove.</param>
/// <returns>True if tool was removed, false if not found.</returns>
public bool RemoveTool(string name)
{
if (string.IsNullOrWhiteSpace(name))
return false;
lock (_lock)
{
return _tools.Remove(name);
}
}
/// <summary>
/// Removes all tools from the registry.
/// </summary>
public void Clear()
{
lock (_lock)
{
_tools.Clear();
}
}
/// <summary>
/// Gets the number of registered tools.
/// </summary>
public int Count
{
get
{
lock (_lock)
{
return _tools.Count;
}
}
}
}

180
test-mcp-server.sh Executable file
View File

@ -0,0 +1,180 @@
#!/bin/bash
#
# End-to-End Test Script for CODEX MCP Server
# Tests all 6 tools via JSON-RPC 2.0 protocol over stdin/stdout
#
set -e
PROJECT_DIR="/home/svrnty/codex/OpenHarbor.MCP"
SERVER_PROJECT="$PROJECT_DIR/samples/CodexMcpServer"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}================================${NC}"
echo -e "${BLUE}CODEX MCP Server - E2E Test${NC}"
echo -e "${BLUE}================================${NC}"
echo ""
# Function to send JSON-RPC request and capture response
test_mcp_request() {
local test_name="$1"
local request="$2"
local timeout_secs="${3:-3}"
echo -e "${YELLOW}Test: ${test_name}${NC}"
echo -e "Request: ${request}"
# Send request to server with timeout
response=$(echo "$request" | timeout "$timeout_secs" dotnet run --project "$SERVER_PROJECT" 2>&1 | grep -v "Building..." | grep -v "Determining projects" | grep -v "Restore" | grep -v "warnings" || true)
if [ -z "$response" ]; then
echo -e "${RED}✗ FAILED: No response received${NC}"
return 1
fi
# Check if response contains valid JSON-RPC structure
if echo "$response" | jq -e '.jsonrpc' > /dev/null 2>&1; then
echo -e "${GREEN}✓ PASSED${NC}"
echo -e "Response: $(echo "$response" | jq -c .)"
echo ""
return 0
else
echo -e "${RED}✗ FAILED: Invalid JSON-RPC response${NC}"
echo -e "Response: $response"
echo ""
return 1
fi
}
# Track test results
PASSED=0
FAILED=0
echo -e "${BLUE}Building server...${NC}"
dotnet build "$SERVER_PROJECT" > /dev/null 2>&1
echo -e "${GREEN}✓ Build successful${NC}"
echo ""
# Test 1: List all tools
echo -e "${BLUE}--- Test 1: List Tools (tools/list) ---${NC}"
if test_mcp_request "List all available tools" \
'{"jsonrpc":"2.0","id":"1","method":"tools/list"}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 2: Search CODEX (will fail if API not running, but server should return proper error)
echo -e "${BLUE}--- Test 2: Search CODEX Tool ---${NC}"
if test_mcp_request "Call search_codex tool" \
'{"jsonrpc":"2.0","id":"2","method":"tools/call","params":{"name":"search_codex","arguments":{"query":"test"}}}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 3: Get Document Tool
echo -e "${BLUE}--- Test 3: Get Document Tool ---${NC}"
if test_mcp_request "Call get_document tool" \
'{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"get_document","arguments":{"id":"test123"}}}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 4: List Documents Tool
echo -e "${BLUE}--- Test 4: List Documents Tool ---${NC}"
if test_mcp_request "Call list_documents tool" \
'{"jsonrpc":"2.0","id":"4","method":"tools/call","params":{"name":"list_documents","arguments":{"page":1,"pageSize":10}}}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 5: Search By Tag Tool
echo -e "${BLUE}--- Test 5: Search By Tag Tool ---${NC}"
if test_mcp_request "Call search_by_tag tool" \
'{"jsonrpc":"2.0","id":"5","method":"tools/call","params":{"name":"search_by_tag","arguments":{"tag":"architecture"}}}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 6: Get Document Sections Tool
echo -e "${BLUE}--- Test 6: Get Document Sections Tool ---${NC}"
if test_mcp_request "Call get_document_sections tool" \
'{"jsonrpc":"2.0","id":"6","method":"tools/call","params":{"name":"get_document_sections","arguments":{"id":"doc123"}}}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 7: List Tags Tool
echo -e "${BLUE}--- Test 7: List Tags Tool ---${NC}"
if test_mcp_request "Call list_tags tool" \
'{"jsonrpc":"2.0","id":"7","method":"tools/call","params":{"name":"list_tags"}}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 8: Error Handling - Unknown Method
echo -e "${BLUE}--- Test 8: Error Handling (Unknown Method) ---${NC}"
if test_mcp_request "Call unknown method" \
'{"jsonrpc":"2.0","id":"8","method":"unknown/method"}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 9: Error Handling - Unknown Tool
echo -e "${BLUE}--- Test 9: Error Handling (Unknown Tool) ---${NC}"
if test_mcp_request "Call nonexistent tool" \
'{"jsonrpc":"2.0","id":"9","method":"tools/call","params":{"name":"nonexistent_tool","arguments":{}}}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Test 10: Error Handling - Missing Tool Name
echo -e "${BLUE}--- Test 10: Error Handling (Missing Tool Name) ---${NC}"
if test_mcp_request "Call tool without name parameter" \
'{"jsonrpc":"2.0","id":"10","method":"tools/call","params":{"arguments":{}}}' 5; then
((PASSED++))
else
((FAILED++))
fi
# Summary
echo -e "${BLUE}================================${NC}"
echo -e "${BLUE}Test Summary${NC}"
echo -e "${BLUE}================================${NC}"
echo -e "${GREEN}Passed: $PASSED${NC}"
echo -e "${RED}Failed: $FAILED${NC}"
echo -e "Total: $((PASSED + FAILED))"
echo ""
if [ $FAILED -eq 0 ]; then
echo -e "${GREEN}✓ All tests passed!${NC}"
echo ""
echo -e "${BLUE}Next Steps:${NC}"
echo "1. Ensure CODEX API is running at http://localhost:5050"
echo "2. Configure Claude Desktop with this MCP server"
echo "3. Ask Claude to search your CODEX knowledge base!"
exit 0
else
echo -e "${RED}✗ Some tests failed${NC}"
echo ""
echo -e "${YELLOW}Troubleshooting:${NC}"
echo "- Check if CODEX API is running at http://localhost:5050"
echo "- Verify the server builds: dotnet build $SERVER_PROJECT"
echo "- Run tests: dotnet test"
exit 1
fi

289
test_mcp_server.py Executable file
View File

@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
End-to-End Test Script for CODEX MCP Server
Tests all 6 tools via JSON-RPC 2.0 protocol over stdin/stdout
"""
import json
import subprocess
import sys
import time
from pathlib import Path
# ANSI colors
GREEN = '\033[0;32m'
RED = '\033[0;31m'
YELLOW = '\033[1;33m'
BLUE = '\033[0;34m'
NC = '\033[0m'
PROJECT_DIR = Path(__file__).parent
SERVER_PROJECT = PROJECT_DIR / "samples" / "CodexMcpServer"
class MCPTester:
def __init__(self):
self.passed = 0
self.failed = 0
self.results = []
def print_header(self, text):
print(f"\n{BLUE}{'=' * 50}{NC}")
print(f"{BLUE}{text}{NC}")
print(f"{BLUE}{'=' * 50}{NC}\n")
def test_request(self, name, request_obj, expect_error=False):
"""Send a JSON-RPC request and validate response"""
print(f"{YELLOW}Test: {name}{NC}")
print(f"Request: {json.dumps(request_obj)}")
try:
# Start the server process
process = subprocess.Popen(
["dotnet", "run", "--project", str(SERVER_PROJECT)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# Send request
request_json = json.dumps(request_obj) + "\n"
stdout, stderr = process.communicate(input=request_json, timeout=5)
# Parse response (skip build output)
response_lines = stdout.strip().split('\n')
response_json = None
for line in reversed(response_lines):
try:
response_json = json.loads(line)
break
except json.JSONDecodeError:
continue
if response_json is None:
print(f"{RED}✗ FAILED: No valid JSON response{NC}")
print(f"stdout: {stdout[:200]}")
self.failed += 1
self.results.append((name, False, "No JSON response"))
print()
return False
# Validate JSON-RPC structure
if "jsonrpc" not in response_json:
print(f"{RED}✗ FAILED: Invalid JSON-RPC response{NC}")
self.failed += 1
self.results.append((name, False, "Invalid structure"))
print()
return False
# Check for error vs result
has_error = "error" in response_json
has_result = "result" in response_json
if expect_error and not has_error:
print(f"{RED}✗ FAILED: Expected error response{NC}")
self.failed += 1
self.results.append((name, False, "Expected error"))
print()
return False
print(f"{GREEN}✓ PASSED{NC}")
print(f"Response: {json.dumps(response_json, indent=2)[:300]}")
self.passed += 1
self.results.append((name, True, "OK"))
print()
return True
except subprocess.TimeoutExpired:
print(f"{RED}✗ FAILED: Server timeout{NC}")
process.kill()
self.failed += 1
self.results.append((name, False, "Timeout"))
print()
return False
except Exception as e:
print(f"{RED}✗ FAILED: {str(e)}{NC}")
self.failed += 1
self.results.append((name, False, str(e)))
print()
return False
def run_tests(self):
self.print_header("CODEX MCP Server - E2E Tests")
# Build server
print(f"{BLUE}Building server...{NC}")
build = subprocess.run(
["dotnet", "build", str(SERVER_PROJECT)],
capture_output=True,
text=True
)
if build.returncode != 0:
print(f"{RED}✗ Build failed{NC}")
print(build.stderr)
sys.exit(1)
print(f"{GREEN}✓ Build successful{NC}\n")
# Test 1: List tools
self.print_header("Test 1: List Tools")
self.test_request(
"List all available tools",
{"jsonrpc": "2.0", "id": "1", "method": "tools/list"}
)
# Test 2: Call search_codex
self.print_header("Test 2: Search CODEX Tool")
self.test_request(
"Call search_codex tool",
{
"jsonrpc": "2.0",
"id": "2",
"method": "tools/call",
"params": {
"name": "search_codex",
"arguments": {"query": "test"}
}
}
)
# Test 3: Call get_document
self.print_header("Test 3: Get Document Tool")
self.test_request(
"Call get_document tool",
{
"jsonrpc": "2.0",
"id": "3",
"method": "tools/call",
"params": {
"name": "get_document",
"arguments": {"id": "test123"}
}
}
)
# Test 4: Call list_documents
self.print_header("Test 4: List Documents Tool")
self.test_request(
"Call list_documents tool",
{
"jsonrpc": "2.0",
"id": "4",
"method": "tools/call",
"params": {
"name": "list_documents",
"arguments": {"page": 1, "pageSize": 10}
}
}
)
# Test 5: Call search_by_tag
self.print_header("Test 5: Search By Tag Tool")
self.test_request(
"Call search_by_tag tool",
{
"jsonrpc": "2.0",
"id": "5",
"method": "tools/call",
"params": {
"name": "search_by_tag",
"arguments": {"tag": "architecture"}
}
}
)
# Test 6: Call get_document_sections
self.print_header("Test 6: Get Document Sections Tool")
self.test_request(
"Call get_document_sections tool",
{
"jsonrpc": "2.0",
"id": "6",
"method": "tools/call",
"params": {
"name": "get_document_sections",
"arguments": {"id": "doc123"}
}
}
)
# Test 7: Call list_tags
self.print_header("Test 7: List Tags Tool")
self.test_request(
"Call list_tags tool",
{
"jsonrpc": "2.0",
"id": "7",
"method": "tools/call",
"params": {"name": "list_tags"}
}
)
# Test 8: Error handling - unknown method
self.print_header("Test 8: Error Handling (Unknown Method)")
self.test_request(
"Call unknown method",
{"jsonrpc": "2.0", "id": "8", "method": "unknown/method"},
expect_error=True
)
# Test 9: Error handling - unknown tool
self.print_header("Test 9: Error Handling (Unknown Tool)")
self.test_request(
"Call nonexistent tool",
{
"jsonrpc": "2.0",
"id": "9",
"method": "tools/call",
"params": {"name": "nonexistent_tool", "arguments": {}}
},
expect_error=True
)
# Test 10: Error handling - missing tool name
self.print_header("Test 10: Error Handling (Missing Tool Name)")
self.test_request(
"Call tool without name parameter",
{
"jsonrpc": "2.0",
"id": "10",
"method": "tools/call",
"params": {"arguments": {}}
},
expect_error=True
)
# Print summary
self.print_summary()
def print_summary(self):
self.print_header("Test Summary")
print(f"{GREEN}Passed: {self.passed}{NC}")
print(f"{RED}Failed: {self.failed}{NC}")
print(f"Total: {self.passed + self.failed}\n")
if self.failed > 0:
print(f"{YELLOW}Failed Tests:{NC}")
for name, passed, msg in self.results:
if not passed:
print(f" - {name}: {msg}")
print()
if self.failed == 0:
print(f"{GREEN}✓ All tests passed!{NC}\n")
print(f"{BLUE}Next Steps:{NC}")
print("1. Ensure CODEX API is running at http://localhost:5050")
print("2. Configure Claude Desktop with this MCP server")
print("3. Ask Claude to search your CODEX knowledge base!")
sys.exit(0)
else:
print(f"{RED}✗ Some tests failed{NC}\n")
print(f"{YELLOW}Note:{NC}")
print("- Tools will return errors if CODEX API is not running")
print("- This is expected behavior - the MCP server is working correctly")
print("- JSON-RPC protocol validation passed if tests show valid responses")
sys.exit(1)
if __name__ == "__main__":
tester = MCPTester()
tester.run_tests()

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="..\..\samples\CodexMcpServer\CodexMcpServer.csproj" />
<ProjectReference Include="..\..\src\OpenHarbor.MCP.Core\OpenHarbor.MCP.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,218 @@
using Xunit;
using Moq;
using Moq.Protected;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
using CodexMcpServer.Tools;
namespace CodexMcpServer.Tests;
/// <summary>
/// Unit tests for GetDocumentSectionsTool following TDD approach.
/// Tests integration with CODEX API for retrieving document sections.
/// </summary>
public class GetDocumentSectionsToolTests
{
[Fact]
public void GetDocumentSectionsTool_ShouldHaveCorrectName()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentSectionsTool(httpClient);
// Act
var name = tool.Name;
// Assert
Assert.Equal("get_document_sections", name);
}
[Fact]
public void GetDocumentSectionsTool_ShouldHaveDescription()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentSectionsTool(httpClient);
// Act
var description = tool.Description;
// Assert
Assert.NotNull(description);
Assert.NotEmpty(description);
Assert.Contains("section", description.ToLower());
}
[Fact]
public void GetDocumentSectionsTool_ShouldHaveValidSchema()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentSectionsTool(httpClient);
// Act
var schema = tool.Schema;
// Assert
Assert.NotNull(schema);
var root = schema.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
// Schema should define required "id" parameter
Assert.True(root.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("id", out var idProp));
}
[Fact]
public async Task GetDocumentSectionsTool_ExecuteAsync_WithValidId_ShouldCallCodexApi()
{
// Arrange
var mockResponse = new
{
sections = new[]
{
new { id = "section1", title = "Introduction", content = "Intro text" },
new { id = "section2", title = "Body", content = "Body text" }
},
documentId = "doc123"
};
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Get &&
req.RequestUri.ToString().Contains("/api/documents/") &&
req.RequestUri.ToString().Contains("/sections")
),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(mockResponse))
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new GetDocumentSectionsTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": "doc123"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("sections", out _));
}
[Fact]
public async Task GetDocumentSectionsTool_ExecuteAsync_WithEmptyId_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentSectionsTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": ""}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task GetDocumentSectionsTool_ExecuteAsync_WithNullArguments_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentSectionsTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task GetDocumentSectionsTool_ExecuteAsync_WithMissingIdProperty_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentSectionsTool(httpClient);
var arguments = JsonDocument.Parse("""{"other": "value"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public void GetDocumentSectionsTool_ShouldImplementIMcpTool()
{
// Arrange
var httpClient = new HttpClient();
// Act
var tool = new GetDocumentSectionsTool(httpClient);
// Assert
Assert.IsAssignableFrom<IMcpTool>(tool);
}
[Fact]
public async Task GetDocumentSectionsTool_ExecuteAsync_ShouldUseCorrectEndpoint()
{
// Arrange
string capturedUri = null;
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage req, CancellationToken ct) =>
{
capturedUri = req.RequestUri?.ToString();
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("""{"sections": []}""")
};
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new GetDocumentSectionsTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": "doc123"}""");
// Act
await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("/api/documents/doc123/sections", capturedUri);
}
}

View File

@ -0,0 +1,322 @@
using Xunit;
using Moq;
using Moq.Protected;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
using CodexMcpServer.Tools;
namespace CodexMcpServer.Tests;
/// <summary>
/// Unit tests for GetDocumentTool following TDD approach.
/// Tests integration with CODEX API /api/documents/{id} endpoint.
/// </summary>
public class GetDocumentToolTests
{
[Fact]
public void GetDocumentTool_ShouldHaveCorrectName()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentTool(httpClient);
// Act
var name = tool.Name;
// Assert
Assert.Equal("get_document", name);
}
[Fact]
public void GetDocumentTool_ShouldHaveDescription()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentTool(httpClient);
// Act
var description = tool.Description;
// Assert
Assert.NotNull(description);
Assert.NotEmpty(description);
Assert.Contains("document", description.ToLower());
}
[Fact]
public void GetDocumentTool_ShouldHaveValidSchema()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentTool(httpClient);
// Act
var schema = tool.Schema;
// Assert
Assert.NotNull(schema);
var root = schema.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
// Schema should define required "id" parameter
Assert.True(root.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("id", out var idProp));
Assert.True(idProp.TryGetProperty("type", out var idType));
Assert.Equal("string", idType.GetString());
}
[Fact]
public async Task GetDocumentTool_ExecuteAsync_WithValidId_ShouldCallCodexApi()
{
// Arrange
var mockResponse = new
{
id = "doc123",
title = "Test Document",
content = "Document content",
metadata = new { author = "Test Author" }
};
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Get &&
req.RequestUri.ToString().Contains("/api/documents/doc123")
),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(mockResponse))
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new GetDocumentTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": "doc123"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("id", out var id));
Assert.Equal("doc123", id.GetString());
}
[Fact]
public async Task GetDocumentTool_ExecuteAsync_WithEmptyId_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": ""}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out var error));
Assert.Contains("id", error.GetString().ToLower());
}
[Fact]
public async Task GetDocumentTool_ExecuteAsync_WithNullArguments_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task GetDocumentTool_ExecuteAsync_WithMissingIdProperty_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new GetDocumentTool(httpClient);
var arguments = JsonDocument.Parse("""{"other": "value"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task GetDocumentTool_ExecuteAsync_WithNotFound_ShouldReturnError()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound,
Content = new StringContent("Document not found")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new GetDocumentTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": "nonexistent"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task GetDocumentTool_ExecuteAsync_WithHttpError_ShouldReturnErrorResponse()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.InternalServerError,
Content = new StringContent("Internal server error")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new GetDocumentTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": "doc123"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task GetDocumentTool_ExecuteAsync_WithInvalidJson_ShouldReturnError()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("invalid json{{{")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new GetDocumentTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": "doc123"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public void GetDocumentTool_ShouldImplementIMcpTool()
{
// Arrange
var httpClient = new HttpClient();
// Act
var tool = new GetDocumentTool(httpClient);
// Assert
Assert.IsAssignableFrom<IMcpTool>(tool);
}
[Fact]
public async Task GetDocumentTool_ExecuteAsync_ShouldUseCorrectEndpoint()
{
// Arrange
string capturedUri = null;
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage req, CancellationToken ct) =>
{
capturedUri = req.RequestUri?.ToString();
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("""{"id": "test123"}""")
};
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new GetDocumentTool(httpClient);
var arguments = JsonDocument.Parse("""{"id": "test123"}""");
// Act
await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("/api/documents/test123", capturedUri);
Assert.DoesNotContain("POST", capturedUri); // Should be GET
}
}

View File

@ -0,0 +1,352 @@
using Xunit;
using Moq;
using Moq.Protected;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
using CodexMcpServer.Tools;
namespace CodexMcpServer.Tests;
/// <summary>
/// Unit tests for ListDocumentsTool following TDD approach.
/// Tests integration with CODEX API /api/documents endpoint with pagination.
/// </summary>
public class ListDocumentsToolTests
{
[Fact]
public void ListDocumentsTool_ShouldHaveCorrectName()
{
// Arrange
var httpClient = new HttpClient();
var tool = new ListDocumentsTool(httpClient);
// Act
var name = tool.Name;
// Assert
Assert.Equal("list_documents", name);
}
[Fact]
public void ListDocumentsTool_ShouldHaveDescription()
{
// Arrange
var httpClient = new HttpClient();
var tool = new ListDocumentsTool(httpClient);
// Act
var description = tool.Description;
// Assert
Assert.NotNull(description);
Assert.NotEmpty(description);
Assert.Contains("list", description.ToLower());
}
[Fact]
public void ListDocumentsTool_ShouldHaveValidSchema()
{
// Arrange
var httpClient = new HttpClient();
var tool = new ListDocumentsTool(httpClient);
// Act
var schema = tool.Schema;
// Assert
Assert.NotNull(schema);
var root = schema.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
// Schema should have optional pagination properties
Assert.True(root.TryGetProperty("properties", out var properties));
// All properties should be optional (no required array or empty required)
}
[Fact]
public async Task ListDocumentsTool_ExecuteAsync_WithNoArguments_ShouldCallCodexApi()
{
// Arrange
var mockResponse = new
{
documents = new[]
{
new { id = "doc1", title = "Document 1" },
new { id = "doc2", title = "Document 2" }
},
total = 2,
page = 1,
pageSize = 20
};
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Get &&
req.RequestUri.ToString().Contains("/api/documents")
),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(mockResponse))
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListDocumentsTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("documents", out _));
}
[Fact]
public async Task ListDocumentsTool_ExecuteAsync_WithPaginationParams_ShouldIncludeInRequest()
{
// Arrange
string capturedUri = null;
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage req, CancellationToken ct) =>
{
capturedUri = req.RequestUri?.ToString();
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("""{"documents": [], "total": 0}""")
};
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListDocumentsTool(httpClient);
var arguments = JsonDocument.Parse("""{"page": 2, "pageSize": 10}""");
// Act
await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("/api/documents", capturedUri);
Assert.Contains("page=2", capturedUri);
Assert.Contains("pageSize=10", capturedUri);
}
[Fact]
public async Task ListDocumentsTool_ExecuteAsync_WithHttpError_ShouldReturnErrorResponse()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.InternalServerError,
Content = new StringContent("Internal server error")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListDocumentsTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task ListDocumentsTool_ExecuteAsync_WithInvalidJson_ShouldReturnError()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("invalid json{{{")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListDocumentsTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public void ListDocumentsTool_ShouldImplementIMcpTool()
{
// Arrange
var httpClient = new HttpClient();
// Act
var tool = new ListDocumentsTool(httpClient);
// Assert
Assert.IsAssignableFrom<IMcpTool>(tool);
}
[Fact]
public async Task ListDocumentsTool_ExecuteAsync_WithLimit_ShouldIncludeInRequest()
{
// Arrange
string capturedUri = null;
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage req, CancellationToken ct) =>
{
capturedUri = req.RequestUri?.ToString();
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("""{"documents": [], "total": 0}""")
};
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListDocumentsTool(httpClient);
var arguments = JsonDocument.Parse("""{"limit": 5}""");
// Act
await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("limit=5", capturedUri);
}
[Fact]
public async Task ListDocumentsTool_ExecuteAsync_WithOffset_ShouldIncludeInRequest()
{
// Arrange
string capturedUri = null;
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage req, CancellationToken ct) =>
{
capturedUri = req.RequestUri?.ToString();
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("""{"documents": [], "total": 0}""")
};
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListDocumentsTool(httpClient);
var arguments = JsonDocument.Parse("""{"offset": 10}""");
// Act
await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("offset=10", capturedUri);
}
[Fact]
public async Task ListDocumentsTool_ExecuteAsync_WithMultipleParams_ShouldIncludeAllInRequest()
{
// Arrange
string capturedUri = null;
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage req, CancellationToken ct) =>
{
capturedUri = req.RequestUri?.ToString();
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("""{"documents": [], "total": 0}""")
};
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListDocumentsTool(httpClient);
var arguments = JsonDocument.Parse("""{"page": 3, "pageSize": 15, "limit": 100, "offset": 30}""");
// Act
await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("page=3", capturedUri);
Assert.Contains("pageSize=15", capturedUri);
Assert.Contains("limit=100", capturedUri);
Assert.Contains("offset=30", capturedUri);
}
}

View File

@ -0,0 +1,227 @@
using Xunit;
using Moq;
using Moq.Protected;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
using CodexMcpServer.Tools;
namespace CodexMcpServer.Tests;
/// <summary>
/// Unit tests for ListTagsTool following TDD approach.
/// Tests integration with CODEX API for listing all available tags.
/// </summary>
public class ListTagsToolTests
{
[Fact]
public void ListTagsTool_ShouldHaveCorrectName()
{
// Arrange
var httpClient = new HttpClient();
var tool = new ListTagsTool(httpClient);
// Act
var name = tool.Name;
// Assert
Assert.Equal("list_tags", name);
}
[Fact]
public void ListTagsTool_ShouldHaveDescription()
{
// Arrange
var httpClient = new HttpClient();
var tool = new ListTagsTool(httpClient);
// Act
var description = tool.Description;
// Assert
Assert.NotNull(description);
Assert.NotEmpty(description);
Assert.Contains("tag", description.ToLower());
}
[Fact]
public void ListTagsTool_ShouldHaveValidSchema()
{
// Arrange
var httpClient = new HttpClient();
var tool = new ListTagsTool(httpClient);
// Act
var schema = tool.Schema;
// Assert
Assert.NotNull(schema);
var root = schema.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
// Schema should have no required parameters (all optional or none)
}
[Fact]
public async Task ListTagsTool_ExecuteAsync_WithNoArguments_ShouldCallCodexApi()
{
// Arrange
var mockResponse = new
{
tags = new[]
{
new { name = "architecture", count = 15 },
new { name = "design", count = 23 }
},
total = 2
};
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Get &&
req.RequestUri.ToString().Contains("/api/tags")
),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(mockResponse))
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListTagsTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("tags", out _));
}
[Fact]
public async Task ListTagsTool_ExecuteAsync_WithHttpError_ShouldReturnErrorResponse()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.InternalServerError,
Content = new StringContent("Internal server error")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListTagsTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task ListTagsTool_ExecuteAsync_WithInvalidJson_ShouldReturnError()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("invalid json{{{")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListTagsTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public void ListTagsTool_ShouldImplementIMcpTool()
{
// Arrange
var httpClient = new HttpClient();
// Act
var tool = new ListTagsTool(httpClient);
// Assert
Assert.IsAssignableFrom<IMcpTool>(tool);
}
[Fact]
public async Task ListTagsTool_ExecuteAsync_ShouldUseCorrectEndpoint()
{
// Arrange
string capturedUri = null;
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage req, CancellationToken ct) =>
{
capturedUri = req.RequestUri?.ToString();
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("""{"tags": []}""")
};
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new ListTagsTool(httpClient);
// Act
await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("/api/tags", capturedUri);
}
}

View File

@ -0,0 +1,178 @@
using Xunit;
using Moq;
using Moq.Protected;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
using CodexMcpServer.Tools;
namespace CodexMcpServer.Tests;
/// <summary>
/// Unit tests for SearchByTagTool following TDD approach.
/// Tests integration with CODEX API for tag-based search.
/// </summary>
public class SearchByTagToolTests
{
[Fact]
public void SearchByTagTool_ShouldHaveCorrectName()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchByTagTool(httpClient);
// Act
var name = tool.Name;
// Assert
Assert.Equal("search_by_tag", name);
}
[Fact]
public void SearchByTagTool_ShouldHaveDescription()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchByTagTool(httpClient);
// Act
var description = tool.Description;
// Assert
Assert.NotNull(description);
Assert.NotEmpty(description);
Assert.Contains("tag", description.ToLower());
}
[Fact]
public void SearchByTagTool_ShouldHaveValidSchema()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchByTagTool(httpClient);
// Act
var schema = tool.Schema;
// Assert
Assert.NotNull(schema);
var root = schema.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
// Schema should define required "tag" parameter
Assert.True(root.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("tag", out var tagProp));
}
[Fact]
public async Task SearchByTagTool_ExecuteAsync_WithValidTag_ShouldCallCodexApi()
{
// Arrange
var mockResponse = new
{
documents = new[]
{
new { id = "doc1", title = "Document 1", tags = new[] { "test" } }
},
total = 1
};
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Get &&
req.RequestUri.ToString().Contains("/api/tags/")
),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(mockResponse))
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new SearchByTagTool(httpClient);
var arguments = JsonDocument.Parse("""{"tag": "test"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("documents", out _));
}
[Fact]
public async Task SearchByTagTool_ExecuteAsync_WithEmptyTag_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchByTagTool(httpClient);
var arguments = JsonDocument.Parse("""{"tag": ""}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task SearchByTagTool_ExecuteAsync_WithNullArguments_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchByTagTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task SearchByTagTool_ExecuteAsync_WithMissingTagProperty_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchByTagTool(httpClient);
var arguments = JsonDocument.Parse("""{"other": "value"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public void SearchByTagTool_ShouldImplementIMcpTool()
{
// Arrange
var httpClient = new HttpClient();
// Act
var tool = new SearchByTagTool(httpClient);
// Assert
Assert.IsAssignableFrom<IMcpTool>(tool);
}
}

View File

@ -0,0 +1,287 @@
using Xunit;
using Moq;
using Moq.Protected;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OpenHarbor.MCP.Core;
using CodexMcpServer.Tools;
namespace CodexMcpServer.Tests;
/// <summary>
/// Unit tests for SearchCodexTool following TDD approach.
/// Tests integration with CODEX API /api/documents/search endpoint.
/// </summary>
public class SearchCodexToolTests
{
[Fact]
public void SearchCodexTool_ShouldHaveCorrectName()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchCodexTool(httpClient);
// Act
var name = tool.Name;
// Assert
Assert.Equal("search_codex", name);
}
[Fact]
public void SearchCodexTool_ShouldHaveDescription()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchCodexTool(httpClient);
// Act
var description = tool.Description;
// Assert
Assert.NotNull(description);
Assert.NotEmpty(description);
Assert.Contains("search", description.ToLower());
}
[Fact]
public void SearchCodexTool_ShouldHaveValidSchema()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchCodexTool(httpClient);
// Act
var schema = tool.Schema;
// Assert
Assert.NotNull(schema);
var root = schema.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
// Schema should define required "query" parameter
Assert.True(root.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("query", out var queryProp));
Assert.True(queryProp.TryGetProperty("type", out var queryType));
Assert.Equal("string", queryType.GetString());
}
[Fact]
public async Task SearchCodexTool_ExecuteAsync_WithValidQuery_ShouldCallCodexApi()
{
// Arrange
var mockResponse = new
{
documents = new[]
{
new { id = "doc1", title = "Test Document", content = "Test content" }
},
total = 1
};
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Post &&
req.RequestUri.ToString().Contains("/api/documents/search")
),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(mockResponse))
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new SearchCodexTool(httpClient);
var arguments = JsonDocument.Parse("""{"query": "test search"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("documents", out _));
}
[Fact]
public async Task SearchCodexTool_ExecuteAsync_WithEmptyQuery_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchCodexTool(httpClient);
var arguments = JsonDocument.Parse("""{"query": ""}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out var error));
Assert.Contains("query", error.GetString().ToLower());
}
[Fact]
public async Task SearchCodexTool_ExecuteAsync_WithNullArguments_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchCodexTool(httpClient);
// Act
var result = await tool.ExecuteAsync(null);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task SearchCodexTool_ExecuteAsync_WithMissingQueryProperty_ShouldReturnError()
{
// Arrange
var httpClient = new HttpClient();
var tool = new SearchCodexTool(httpClient);
var arguments = JsonDocument.Parse("""{"other": "value"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task SearchCodexTool_ExecuteAsync_WithHttpError_ShouldReturnErrorResponse()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.InternalServerError,
Content = new StringContent("Internal server error")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new SearchCodexTool(httpClient);
var arguments = JsonDocument.Parse("""{"query": "test"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public async Task SearchCodexTool_ExecuteAsync_WithInvalidJson_ShouldReturnError()
{
// Arrange
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("invalid json{{{")
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new SearchCodexTool(httpClient);
var arguments = JsonDocument.Parse("""{"query": "test"}""");
// Act
var result = await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
var root = result.RootElement;
Assert.True(root.TryGetProperty("error", out _));
}
[Fact]
public void SearchCodexTool_ShouldImplementIMcpTool()
{
// Arrange
var httpClient = new HttpClient();
// Act
var tool = new SearchCodexTool(httpClient);
// Assert
Assert.IsAssignableFrom<IMcpTool>(tool);
}
[Fact]
public async Task SearchCodexTool_ExecuteAsync_ShouldIncludeQueryInRequest()
{
// Arrange
string capturedRequestBody = null;
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
mockHttpMessageHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage req, CancellationToken ct) =>
{
capturedRequestBody = req.Content?.ReadAsStringAsync().Result;
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("""{"documents": [], "total": 0}""")
};
});
var httpClient = new HttpClient(mockHttpMessageHandler.Object)
{
BaseAddress = new System.Uri("http://localhost:5050")
};
var tool = new SearchCodexTool(httpClient);
var arguments = JsonDocument.Parse("""{"query": "test search query"}""");
// Act
await tool.ExecuteAsync(arguments);
// Assert
Assert.NotNull(capturedRequestBody);
Assert.Contains("test search query", capturedRequestBody);
}
}

View File

@ -0,0 +1,306 @@
using Xunit;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenHarbor.MCP.AspNetCore.Extensions;
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace OpenHarbor.MCP.Core.Tests;
/// <summary>
/// Unit tests for HttpTransport following TDD approach.
/// Tests JSON-RPC 2.0 protocol over HTTP REST endpoints.
/// </summary>
public class HttpTransportTests : IDisposable
{
private readonly WebApplication _app;
private readonly HttpClient _client;
private readonly McpServer _server;
public HttpTransportTests()
{
// Create a test MCP server with a mock tool
var registry = new ToolRegistry();
registry.AddTool(new TestTool());
_server = new McpServer(registry);
// Create test web application
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddMcpServer(_server);
_app = builder.Build();
_app.MapMcpEndpoints(_server);
_app.StartAsync().Wait();
_client = _app.GetTestClient();
}
[Fact]
public async Task HttpTransport_HealthEndpoint_ReturnsOk()
{
// Act
var response = await _client.GetAsync("/health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Healthy", content);
Assert.Contains("MCP Server", content);
}
[Fact]
public async Task HttpTransport_InvokeEndpoint_WithValidRequest_ReturnsSuccess()
{
// Arrange
var request = new
{
jsonrpc = "2.0",
method = "tools/list",
id = "test-1"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/mcp/invoke", content);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var responseJson = await response.Content.ReadAsStringAsync();
var responseObj = JsonSerializer.Deserialize<JsonDocument>(responseJson);
Assert.NotNull(responseObj);
Assert.Equal("2.0", responseObj.RootElement.GetProperty("jsonrpc").GetString());
Assert.Equal("test-1", responseObj.RootElement.GetProperty("id").GetString());
}
[Fact]
public async Task HttpTransport_InvokeEndpoint_WithEmptyBody_ReturnsBadRequest()
{
// Arrange
var content = new StringContent("", Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/mcp/invoke", content);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var responseJson = await response.Content.ReadAsStringAsync();
Assert.Contains("error", responseJson);
Assert.Contains("-32700", responseJson); // Parse error code
}
[Fact]
public async Task HttpTransport_InvokeEndpoint_WithInvalidJson_ReturnsBadRequest()
{
// Arrange
var content = new StringContent("{invalid json}", Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/mcp/invoke", content);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var responseJson = await response.Content.ReadAsStringAsync();
Assert.Contains("error", responseJson);
Assert.Contains("Parse error", responseJson);
}
[Fact]
public async Task HttpTransport_InvokeEndpoint_WithToolsCall_ExecutesTool()
{
// Arrange
var request = new
{
jsonrpc = "2.0",
method = "tools/call",
id = "test-call-1",
@params = new
{
name = "test_tool",
arguments = new
{
query = "test query"
}
}
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/mcp/invoke", content);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var responseJson = await response.Content.ReadAsStringAsync();
var responseObj = JsonSerializer.Deserialize<JsonDocument>(responseJson);
Assert.NotNull(responseObj);
Assert.Equal("test-call-1", responseObj.RootElement.GetProperty("id").GetString());
}
[Fact]
public async Task HttpTransport_ContentTypeHeader_IsApplicationJson()
{
// Arrange
var request = new
{
jsonrpc = "2.0",
method = "tools/list",
id = "test-header"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/mcp/invoke", content);
// Assert
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task HttpTransport_MultipleRequests_AllSucceed()
{
// Arrange
var request = new
{
jsonrpc = "2.0",
method = "tools/list",
id = "multi-1"
};
var json = JsonSerializer.Serialize(request);
// Act - Send 5 requests
for (int i = 0; i < 5; i++)
{
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/mcp/invoke", content);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
[Fact]
public async Task HttpTransport_ToolsListRequest_ReturnsToolInfo()
{
// Arrange
var request = new
{
jsonrpc = "2.0",
method = "tools/list",
id = "list-test"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/mcp/invoke", content);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var responseJson = await response.Content.ReadAsStringAsync();
Assert.Contains("test_tool", responseJson);
}
[Fact]
public async Task HttpTransport_ServerError_Returns500WithJsonRpcError()
{
// This test would need a tool that throws an exception
// For now, we test the error handling structure
// Arrange
var request = new
{
jsonrpc = "2.0",
method = "invalid/method",
id = "error-test"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/mcp/invoke", content);
// Assert - Should handle gracefully
var responseJson = await response.Content.ReadAsStringAsync();
Assert.Contains("jsonrpc", responseJson);
}
[Fact]
public void HttpTransport_AddMcpServer_ThrowsOnNullServices()
{
// Arrange
IServiceCollection? services = null;
var server = new McpServer(new ToolRegistry());
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
services!.AddMcpServer(server)
);
}
[Fact]
public void HttpTransport_AddMcpServer_ThrowsOnNullServer()
{
// Arrange
var services = new ServiceCollection();
McpServer? server = null;
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
services.AddMcpServer(server!)
);
}
public void Dispose()
{
_client?.Dispose();
_app?.DisposeAsync().AsTask().Wait();
}
/// <summary>
/// Test tool for HTTP transport tests.
/// </summary>
private class TestTool : IMcpTool
{
public string Name => "test_tool";
public string Description => "Test tool for HTTP transport tests";
public JsonDocument Schema => JsonDocument.Parse("""
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Test query parameter"
}
}
}
""");
public async Task<JsonDocument> ExecuteAsync(JsonDocument? arguments)
{
await Task.CompletedTask;
return JsonDocument.Parse("""{"result": "success", "tool": "test_tool"}""");
}
}
}

View File

@ -0,0 +1,120 @@
using Xunit;
using Moq;
using System.Threading.Tasks;
using System.Text.Json;
namespace OpenHarbor.MCP.Core.Tests;
/// <summary>
/// Unit tests for IMcpTool interface following TDD approach.
/// Tests the core abstraction for MCP tools.
/// </summary>
public class IMcpToolTests
{
[Fact]
public void IMcpTool_ShouldHaveNameProperty()
{
// Arrange - Create a mock implementation
var mockTool = new Mock<IMcpTool>();
mockTool.Setup(t => t.Name).Returns("test_tool");
// Act
var name = mockTool.Object.Name;
// Assert
Assert.Equal("test_tool", name);
}
[Fact]
public void IMcpTool_ShouldHaveDescriptionProperty()
{
// Arrange
var mockTool = new Mock<IMcpTool>();
mockTool.Setup(t => t.Description).Returns("A test tool");
// Act
var description = mockTool.Object.Description;
// Assert
Assert.Equal("A test tool", description);
}
[Fact]
public void IMcpTool_ShouldHaveSchemaProperty()
{
// Arrange
var mockTool = new Mock<IMcpTool>();
var schema = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"query": { "type": "string" }
}
}
""");
mockTool.Setup(t => t.Schema).Returns(schema);
// Act
var toolSchema = mockTool.Object.Schema;
// Assert
Assert.NotNull(toolSchema);
Assert.Equal("object", toolSchema.RootElement.GetProperty("type").GetString());
}
[Fact]
public async Task IMcpTool_ShouldExecuteAsync()
{
// Arrange
var mockTool = new Mock<IMcpTool>();
var expectedResult = JsonDocument.Parse("""{"status": "success"}""");
var arguments = JsonDocument.Parse("""{"query": "test"}""");
mockTool.Setup(t => t.ExecuteAsync(It.IsAny<JsonDocument>()))
.ReturnsAsync(expectedResult);
// Act
var result = await mockTool.Object.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("success", result.RootElement.GetProperty("status").GetString());
}
[Fact]
public async Task IMcpTool_ExecuteAsync_ShouldHandleNullArguments()
{
// Arrange
var mockTool = new Mock<IMcpTool>();
var expectedResult = JsonDocument.Parse("""{"status": "executed"}""");
mockTool.Setup(t => t.ExecuteAsync(null!))
.ReturnsAsync(expectedResult);
// Act
var result = await mockTool.Object.ExecuteAsync(null!);
// Assert
Assert.NotNull(result);
}
[Fact]
public async Task IMcpTool_ExecuteAsync_ShouldReturnJsonResult()
{
// Arrange
var mockTool = new Mock<IMcpTool>();
var arguments = JsonDocument.Parse("""{"input": "value"}""");
var expectedResult = JsonDocument.Parse("""{"output": "result"}""");
mockTool.Setup(t => t.ExecuteAsync(arguments))
.ReturnsAsync(expectedResult);
// Act
var result = await mockTool.Object.ExecuteAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.True(result.RootElement.TryGetProperty("output", out var output));
Assert.Equal("result", output.GetString());
}
}

View File

@ -0,0 +1,294 @@
using Xunit;
using System.Text.Json;
namespace OpenHarbor.MCP.Core.Tests;
/// <summary>
/// Unit tests for MCP request/response models following TDD approach.
/// Tests JSON-RPC 2.0 protocol message structures.
/// </summary>
public class McpModelsTests
{
[Fact]
public void McpRequest_ShouldHaveJsonRpcVersion()
{
// Arrange & Act
var request = new McpRequest
{
JsonRpc = "2.0",
Method = "tools/call",
Id = "1"
};
// Assert
Assert.Equal("2.0", request.JsonRpc);
}
[Fact]
public void McpRequest_ShouldHaveMethod()
{
// Arrange & Act
var request = new McpRequest
{
JsonRpc = "2.0",
Method = "tools/list",
Id = "1"
};
// Assert
Assert.Equal("tools/list", request.Method);
}
[Fact]
public void McpRequest_ShouldHaveId()
{
// Arrange & Act
var request = new McpRequest
{
JsonRpc = "2.0",
Method = "tools/call",
Id = "request-123"
};
// Assert
Assert.Equal("request-123", request.Id);
}
[Fact]
public void McpRequest_ShouldHaveOptionalParams()
{
// Arrange
var paramsJson = JsonDocument.Parse("""
{
"name": "search_codex",
"arguments": {"query": "test"}
}
""");
// Act
var request = new McpRequest
{
JsonRpc = "2.0",
Method = "tools/call",
Id = "1",
Params = paramsJson
};
// Assert
Assert.NotNull(request.Params);
Assert.Equal("search_codex", request.Params.RootElement.GetProperty("name").GetString());
}
[Fact]
public void McpRequest_ParamsCanBeNull()
{
// Arrange & Act
var request = new McpRequest
{
JsonRpc = "2.0",
Method = "tools/list",
Id = "1",
Params = null
};
// Assert
Assert.Null(request.Params);
}
[Fact]
public void McpResponse_ShouldHaveJsonRpcVersion()
{
// Arrange & Act
var response = new McpResponse
{
JsonRpc = "2.0",
Id = "1"
};
// Assert
Assert.Equal("2.0", response.JsonRpc);
}
[Fact]
public void McpResponse_ShouldHaveId()
{
// Arrange & Act
var response = new McpResponse
{
JsonRpc = "2.0",
Id = "request-456"
};
// Assert
Assert.Equal("request-456", response.Id);
}
[Fact]
public void McpResponse_ShouldHaveOptionalResult()
{
// Arrange
var resultJson = JsonDocument.Parse("""
{
"tools": [
{"name": "search_codex", "description": "Search CODEX documents"}
]
}
""");
// Act
var response = new McpResponse
{
JsonRpc = "2.0",
Id = "1",
Result = resultJson
};
// Assert
Assert.NotNull(response.Result);
Assert.True(response.Result.RootElement.TryGetProperty("tools", out _));
}
[Fact]
public void McpResponse_ShouldHaveOptionalError()
{
// Arrange
var error = new McpError
{
Code = -32601,
Message = "Method not found"
};
// Act
var response = new McpResponse
{
JsonRpc = "2.0",
Id = "1",
Error = error
};
// Assert
Assert.NotNull(response.Error);
Assert.Equal(-32601, response.Error.Code);
Assert.Equal("Method not found", response.Error.Message);
}
[Fact]
public void McpResponse_ResultAndErrorMutuallyExclusive()
{
// Arrange
var resultJson = JsonDocument.Parse("""{"status": "ok"}""");
var error = new McpError { Code = -32600, Message = "Invalid Request" };
// Act - Response should have either result OR error, not both
var responseWithResult = new McpResponse
{
JsonRpc = "2.0",
Id = "1",
Result = resultJson,
Error = null
};
var responseWithError = new McpResponse
{
JsonRpc = "2.0",
Id = "1",
Result = null,
Error = error
};
// Assert
Assert.NotNull(responseWithResult.Result);
Assert.Null(responseWithResult.Error);
Assert.Null(responseWithError.Result);
Assert.NotNull(responseWithError.Error);
}
[Fact]
public void McpError_ShouldHaveCode()
{
// Arrange & Act
var error = new McpError
{
Code = -32700,
Message = "Parse error"
};
// Assert
Assert.Equal(-32700, error.Code);
}
[Fact]
public void McpError_ShouldHaveMessage()
{
// Arrange & Act
var error = new McpError
{
Code = -32600,
Message = "Invalid Request"
};
// Assert
Assert.Equal("Invalid Request", error.Message);
}
[Fact]
public void McpError_ShouldHaveOptionalData()
{
// Arrange
var dataJson = JsonDocument.Parse("""{"detail": "Missing required field"}""");
// Act
var error = new McpError
{
Code = -32602,
Message = "Invalid params",
Data = dataJson
};
// Assert
Assert.NotNull(error.Data);
Assert.Equal("Missing required field", error.Data.RootElement.GetProperty("detail").GetString());
}
[Fact]
public void McpRequest_ShouldSerializeToJson()
{
// Arrange
var request = new McpRequest
{
JsonRpc = "2.0",
Method = "tools/list",
Id = "1"
};
// Act
var json = JsonSerializer.Serialize(request);
// Assert
Assert.Contains("\"jsonrpc\":\"2.0\"", json);
Assert.Contains("\"method\":\"tools/list\"", json);
Assert.Contains("\"id\":\"1\"", json);
}
[Fact]
public void McpResponse_ShouldSerializeToJson()
{
// Arrange
var resultJson = JsonDocument.Parse("""{"status": "success"}""");
var response = new McpResponse
{
JsonRpc = "2.0",
Id = "1",
Result = resultJson
};
// Act
var json = JsonSerializer.Serialize(response);
// Assert
Assert.Contains("\"jsonrpc\":\"2.0\"", json);
Assert.Contains("\"id\":\"1\"", json);
Assert.Contains("\"result\"", json);
}
}

View File

@ -0,0 +1,277 @@
using Xunit;
using Moq;
using System.Text.Json;
using System.Threading.Tasks;
namespace OpenHarbor.MCP.Core.Tests;
/// <summary>
/// Unit tests for McpServer following TDD approach.
/// Tests MCP protocol method routing and execution.
/// </summary>
public class McpServerTests
{
[Fact]
public void McpServer_ShouldInitializeWithToolRegistry()
{
// Arrange
var registry = new ToolRegistry();
// Act
var server = new McpServer(registry);
// Assert
Assert.NotNull(server);
}
[Fact]
public void McpServer_ShouldThrowOnNullRegistry()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new McpServer(null!)
);
}
[Fact]
public async Task McpServer_HandleRequest_WithToolsList_ShouldReturnToolDescriptions()
{
// Arrange
var registry = new ToolRegistry();
var mockTool = CreateMockTool("test_tool", "Test tool description");
registry.AddTool(mockTool.Object);
var server = new McpServer(registry);
var request = new McpRequest
{
Id = "1",
Method = "tools/list"
};
// Act
var response = await server.HandleRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.Equal("1", response.Id);
Assert.Null(response.Error);
Assert.NotNull(response.Result);
var result = response.Result.RootElement;
Assert.True(result.TryGetProperty("tools", out var tools));
Assert.Equal(JsonValueKind.Array, tools.ValueKind);
Assert.Equal(1, tools.GetArrayLength());
var tool = tools[0];
Assert.True(tool.TryGetProperty("name", out var name));
Assert.Equal("test_tool", name.GetString());
Assert.True(tool.TryGetProperty("description", out var desc));
Assert.Equal("Test tool description", desc.GetString());
}
[Fact]
public async Task McpServer_HandleRequest_WithToolsList_EmptyRegistry_ShouldReturnEmptyArray()
{
// Arrange
var registry = new ToolRegistry();
var server = new McpServer(registry);
var request = new McpRequest
{
Id = "1",
Method = "tools/list"
};
// Act
var response = await server.HandleRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.Null(response.Error);
Assert.NotNull(response.Result);
var result = response.Result.RootElement;
Assert.True(result.TryGetProperty("tools", out var tools));
Assert.Equal(0, tools.GetArrayLength());
}
[Fact]
public async Task McpServer_HandleRequest_WithToolsCall_ShouldExecuteTool()
{
// Arrange
var registry = new ToolRegistry();
var mockTool = CreateMockTool("search_codex", "Search tool");
mockTool.Setup(t => t.ExecuteAsync(It.IsAny<JsonDocument>()))
.ReturnsAsync(JsonDocument.Parse("""{"results": ["doc1", "doc2"]}"""));
registry.AddTool(mockTool.Object);
var server = new McpServer(registry);
var request = new McpRequest
{
Id = "2",
Method = "tools/call",
Params = JsonDocument.Parse("""{"name":"search_codex","arguments":{"query":"test"}}""")
};
// Act
var response = await server.HandleRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.Equal("2", response.Id);
Assert.Null(response.Error);
Assert.NotNull(response.Result);
var result = response.Result.RootElement;
Assert.True(result.TryGetProperty("results", out var results));
mockTool.Verify(t => t.ExecuteAsync(It.IsAny<JsonDocument>()), Times.Once);
}
[Fact]
public async Task McpServer_HandleRequest_WithToolsCall_NonexistentTool_ShouldReturnError()
{
// Arrange
var registry = new ToolRegistry();
var server = new McpServer(registry);
var request = new McpRequest
{
Id = "3",
Method = "tools/call",
Params = JsonDocument.Parse("""{"name":"nonexistent_tool","arguments":{}}""")
};
// Act
var response = await server.HandleRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.Equal("3", response.Id);
Assert.Null(response.Result);
Assert.NotNull(response.Error);
Assert.Equal(-32601, response.Error.Code); // Method not found
Assert.Contains("nonexistent_tool", response.Error.Message);
}
[Fact]
public async Task McpServer_HandleRequest_WithUnknownMethod_ShouldReturnError()
{
// Arrange
var registry = new ToolRegistry();
var server = new McpServer(registry);
var request = new McpRequest
{
Id = "4",
Method = "unknown/method"
};
// Act
var response = await server.HandleRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.Equal("4", response.Id);
Assert.Null(response.Result);
Assert.NotNull(response.Error);
Assert.Equal(-32601, response.Error.Code); // Method not found
Assert.Contains("unknown/method", response.Error.Message);
}
[Fact]
public async Task McpServer_HandleRequest_WithToolsCall_MissingParams_ShouldReturnError()
{
// Arrange
var registry = new ToolRegistry();
var server = new McpServer(registry);
var request = new McpRequest
{
Id = "5",
Method = "tools/call"
// No Params
};
// Act
var response = await server.HandleRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.Equal("5", response.Id);
Assert.Null(response.Result);
Assert.NotNull(response.Error);
Assert.Equal(-32602, response.Error.Code); // Invalid params
}
[Fact]
public async Task McpServer_HandleRequest_WithToolsCall_MissingToolName_ShouldReturnError()
{
// Arrange
var registry = new ToolRegistry();
var server = new McpServer(registry);
var request = new McpRequest
{
Id = "6",
Method = "tools/call",
Params = JsonDocument.Parse("""{"arguments":{}}""") // Missing "name"
};
// Act
var response = await server.HandleRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.Equal("6", response.Id);
Assert.Null(response.Result);
Assert.NotNull(response.Error);
Assert.Equal(-32602, response.Error.Code); // Invalid params
}
[Fact]
public async Task McpServer_HandleRequest_WithNullRequest_ShouldThrow()
{
// Arrange
var registry = new ToolRegistry();
var server = new McpServer(registry);
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
await server.HandleRequestAsync(null!)
);
}
[Fact]
public async Task McpServer_HandleRequest_WithToolsList_MultipleTools_ShouldReturnAll()
{
// Arrange
var registry = new ToolRegistry();
registry.AddTool(CreateMockTool("tool1", "First tool").Object);
registry.AddTool(CreateMockTool("tool2", "Second tool").Object);
registry.AddTool(CreateMockTool("tool3", "Third tool").Object);
var server = new McpServer(registry);
var request = new McpRequest
{
Id = "7",
Method = "tools/list"
};
// Act
var response = await server.HandleRequestAsync(request);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Result);
var result = response.Result.RootElement;
Assert.True(result.TryGetProperty("tools", out var tools));
Assert.Equal(3, tools.GetArrayLength());
}
// Helper method to create mock tools
private Mock<IMcpTool> CreateMockTool(string name, string description)
{
var mockTool = new Mock<IMcpTool>();
mockTool.Setup(t => t.Name).Returns(name);
mockTool.Setup(t => t.Description).Returns(description);
mockTool.Setup(t => t.Schema).Returns(JsonDocument.Parse("""{"type": "object"}"""));
mockTool.Setup(t => t.ExecuteAsync(It.IsAny<JsonDocument>()))
.ReturnsAsync(JsonDocument.Parse("""{"status": "ok"}"""));
return mockTool;
}
}

View File

@ -0,0 +1,84 @@
using Xunit;
using OpenHarbor.MCP.Core.Models;
namespace OpenHarbor.MCP.Core.Tests.Models;
/// <summary>
/// Unit tests for RoutingContext model following TDD approach.
/// Tests routing metadata representation.
/// </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);
}
[Fact]
public void RoutingContext_WithHeaders_StoresHeaderData()
{
// Arrange
var headers = new Dictionary<string, string>
{
{ "X-API-Key", "test-key" },
{ "User-Agent", "MCP-Client/1.0" }
};
// Act
var context = new RoutingContext
{
Headers = headers
};
// Assert
Assert.NotNull(context.Headers);
Assert.Equal("test-key", context.Headers["X-API-Key"]);
}
[Fact]
public void RoutingContext_WithMetadata_StoresAdditionalData()
{
// Arrange
var metadata = new Dictionary<string, object>
{
{ "priority", "high" },
{ "timeout", 5000 },
{ "authenticated", true }
};
// Act
var context = new RoutingContext
{
Metadata = metadata
};
// Assert
Assert.NotNull(context.Metadata);
Assert.Equal("high", context.Metadata["priority"]);
Assert.Equal(5000, context.Metadata["timeout"]);
Assert.Equal(true, context.Metadata["authenticated"]);
}
[Fact]
public void RoutingContext_Empty_AllowsNullProperties()
{
// 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.Core.Models;
namespace OpenHarbor.MCP.Core.Tests.Models;
/// <summary>
/// Unit tests for ServerConfig model following TDD approach.
/// Tests server configuration representation.
/// </summary>
public class ServerConfigTests
{
[Fact]
public void ServerConfig_WithStdioTransport_CreatesSuccessfully()
{
// Arrange & Act
var config = new ServerConfig
{
Id = "codex-server",
Name = "CODEX Knowledge Base",
TransportType = "Stdio",
Command = "dotnet",
Args = new[] { "run", "--project", "CodexMcpServer.csproj" },
Enabled = true
};
// Assert
Assert.Equal("codex-server", config.Id);
Assert.Equal("Stdio", config.TransportType);
Assert.Equal("dotnet", config.Command);
Assert.Equal(3, config.Args?.Length);
Assert.True(config.Enabled);
}
[Fact]
public void ServerConfig_WithHttpTransport_CreatesSuccessfully()
{
// Arrange & Act
var config = new ServerConfig
{
Id = "remote-api",
Name = "Remote API Server",
TransportType = "Http",
BaseUrl = "https://api.example.com/mcp",
Enabled = true
};
// Assert
Assert.Equal("Http", config.TransportType);
Assert.Equal("https://api.example.com/mcp", config.BaseUrl);
Assert.Null(config.Command);
}
[Fact]
public void ServerConfig_DefaultEnabled_IsTrue()
{
// Arrange & Act
var config = new ServerConfig
{
Id = "test-server",
Name = "Test"
};
// Assert
Assert.True(config.Enabled);
}
[Fact]
public void ServerConfig_WithMetadata_StoresAdditionalData()
{
// Arrange
var metadata = new Dictionary<string, string>
{
{ "timeout", "30000" },
{ "retries", "3" }
};
// Act
var config = new ServerConfig
{
Id = "server-1",
Metadata = metadata
};
// Assert
Assert.NotNull(config.Metadata);
Assert.Equal("30000", config.Metadata["timeout"]);
}
}

View File

@ -0,0 +1,66 @@
using Xunit;
using OpenHarbor.MCP.Core.Models;
namespace OpenHarbor.MCP.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,66 @@
using Xunit;
using OpenHarbor.MCP.Core.Models;
namespace OpenHarbor.MCP.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 & Act
var serverInfo = new ServerInfo
{
Id = "test-server",
Name = "Test Server",
IsHealthy = true,
LastHealthCheck = DateTime.UtcNow,
ResponseTime = TimeSpan.FromMilliseconds(50)
};
// Assert
Assert.Equal("test-server", serverInfo.Id);
Assert.Equal("Test Server", serverInfo.Name);
Assert.True(serverInfo.IsHealthy);
Assert.NotNull(serverInfo.LastHealthCheck);
Assert.Equal(50, serverInfo.ResponseTime?.TotalMilliseconds);
}
[Fact]
public void ServerInfo_DefaultState_IsNotHealthy()
{
// Arrange & Act
var serverInfo = new ServerInfo();
// Assert
Assert.False(serverInfo.IsHealthy);
Assert.Null(serverInfo.LastHealthCheck);
}
[Fact]
public void ServerInfo_WithMetadata_StoresAdditionalData()
{
// Arrange
var metadata = new Dictionary<string, string>
{
{ "version", "1.0.0" },
{ "region", "us-east" }
};
// Act
var serverInfo = new ServerInfo
{
Id = "server-1",
Metadata = metadata
};
// Assert
Assert.NotNull(serverInfo.Metadata);
Assert.Equal("1.0.0", serverInfo.Metadata["version"]);
Assert.Equal("us-east", serverInfo.Metadata["region"]);
}
}

View File

@ -0,0 +1,259 @@
using Xunit;
using Moq;
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace OpenHarbor.MCP.Core.Tests;
/// <summary>
/// Unit tests for StdioTransport following TDD approach.
/// Tests JSON-RPC 2.0 protocol over stdin/stdout communication.
/// </summary>
public class StdioTransportTests
{
[Fact]
public void StdioTransport_ShouldInitializeWithStreams()
{
// Arrange
var inputStream = new MemoryStream();
var outputStream = new MemoryStream();
// Act
var transport = new StdioTransport(inputStream, outputStream);
// Assert
Assert.NotNull(transport);
}
[Fact]
public void StdioTransport_ShouldThrowOnNullInputStream()
{
// Arrange
var outputStream = new MemoryStream();
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new StdioTransport(null!, outputStream)
);
}
[Fact]
public void StdioTransport_ShouldThrowOnNullOutputStream()
{
// Arrange
var inputStream = new MemoryStream();
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new StdioTransport(inputStream, null!)
);
}
[Fact]
public async Task StdioTransport_ReadRequestAsync_ShouldParseValidJsonRpcRequest()
{
// Arrange
var requestJson = """
{"jsonrpc":"2.0","method":"tools/list","id":"1"}
"""; // Newline-delimited
var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(requestJson));
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
// Act
var request = await transport.ReadRequestAsync();
// Assert
Assert.NotNull(request);
Assert.Equal("2.0", request.JsonRpc);
Assert.Equal("tools/list", request.Method);
Assert.Equal("1", request.Id);
}
[Fact]
public async Task StdioTransport_ReadRequestAsync_WithParams_ShouldParseParams()
{
// Arrange
var requestJson = """
{"jsonrpc":"2.0","method":"tools/call","id":"2","params":{"name":"search_codex","arguments":{"query":"test"}}}
""";
var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(requestJson));
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
// Act
var request = await transport.ReadRequestAsync();
// Assert
Assert.NotNull(request);
Assert.NotNull(request.Params);
Assert.True(request.Params.RootElement.TryGetProperty("name", out var name));
Assert.Equal("search_codex", name.GetString());
}
[Fact]
public async Task StdioTransport_ReadRequestAsync_WithEndOfStream_ShouldReturnNull()
{
// Arrange
var inputStream = new MemoryStream(); // Empty stream
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
// Act
var request = await transport.ReadRequestAsync();
// Assert
Assert.Null(request);
}
[Fact]
public async Task StdioTransport_WriteResponseAsync_ShouldWriteValidJsonRpcResponse()
{
// Arrange
var inputStream = new MemoryStream();
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
var response = new McpResponse
{
Id = "1",
Result = JsonDocument.Parse("""{"tools":[]}""")
};
// Act
await transport.WriteResponseAsync(response);
// Assert
outputStream.Position = 0;
var outputText = Encoding.UTF8.GetString(outputStream.ToArray());
Assert.Contains("\"jsonrpc\":\"2.0\"", outputText);
Assert.Contains("\"id\":\"1\"", outputText);
Assert.Contains("\"result\"", outputText);
Assert.EndsWith("\n", outputText); // Should end with newline
}
[Fact]
public async Task StdioTransport_WriteResponseAsync_WithError_ShouldWriteErrorResponse()
{
// Arrange
var inputStream = new MemoryStream();
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
var response = new McpResponse
{
Id = "1",
Error = new McpError
{
Code = -32601,
Message = "Method not found"
}
};
// Act
await transport.WriteResponseAsync(response);
// Assert
outputStream.Position = 0;
var outputText = Encoding.UTF8.GetString(outputStream.ToArray());
Assert.Contains("\"error\"", outputText);
Assert.Contains("\"code\":-32601", outputText);
Assert.Contains("Method not found", outputText);
Assert.DoesNotContain("\"result\"", outputText);
}
[Fact]
public async Task StdioTransport_WriteResponseAsync_ShouldFlushOutput()
{
// Arrange
var inputStream = new MemoryStream();
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
var response = new McpResponse
{
Id = "1",
Result = JsonDocument.Parse("""{"status":"ok"}""")
};
// Act
await transport.WriteResponseAsync(response);
// Assert - data should be immediately available in stream
outputStream.Position = 0;
Assert.True(outputStream.Length > 0);
}
[Fact]
public async Task StdioTransport_ReadRequestAsync_WithInvalidJson_ShouldThrowJsonException()
{
// Arrange
var invalidJson = "not valid json{{{";
var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(invalidJson + "\n"));
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
// Act & Assert
await Assert.ThrowsAsync<JsonException>(async () =>
await transport.ReadRequestAsync()
);
}
[Fact]
public async Task StdioTransport_ReadRequestAsync_MultipleRequests_ShouldReadSequentially()
{
// Arrange
var requests = """
{"jsonrpc":"2.0","method":"tools/list","id":"1"}
{"jsonrpc":"2.0","method":"tools/call","id":"2"}
""";
var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(requests));
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
// Act
var request1 = await transport.ReadRequestAsync();
var request2 = await transport.ReadRequestAsync();
// Assert
Assert.NotNull(request1);
Assert.Equal("1", request1.Id);
Assert.Equal("tools/list", request1.Method);
Assert.NotNull(request2);
Assert.Equal("2", request2.Id);
Assert.Equal("tools/call", request2.Method);
}
[Fact]
public void StdioTransport_Dispose_ShouldNotThrow()
{
// Arrange
var inputStream = new MemoryStream();
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
// Act & Assert - should not throw
transport.Dispose();
}
[Fact]
public async Task StdioTransport_WriteResponseAsync_NullResponse_ShouldThrow()
{
// Arrange
var inputStream = new MemoryStream();
var outputStream = new MemoryStream();
var transport = new StdioTransport(inputStream, outputStream);
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
await transport.WriteResponseAsync(null!)
);
}
}

View File

@ -0,0 +1,30 @@
<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.Core\OpenHarbor.MCP.Core.csproj" />
<ProjectReference Include="..\..\src\OpenHarbor.MCP.AspNetCore\OpenHarbor.MCP.AspNetCore.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,212 @@
using Xunit;
using Moq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Linq;
namespace OpenHarbor.MCP.Core.Tests;
/// <summary>
/// Unit tests for ToolRegistry following TDD approach.
/// Tests dynamic tool registration and retrieval.
/// </summary>
public class ToolRegistryTests
{
[Fact]
public void ToolRegistry_ShouldInitializeEmpty()
{
// Arrange & Act
var registry = new ToolRegistry();
// Assert
Assert.NotNull(registry);
Assert.Empty(registry.GetAllTools());
}
[Fact]
public void ToolRegistry_ShouldAddTool()
{
// Arrange
var registry = new ToolRegistry();
var mockTool = CreateMockTool("test_tool", "A test tool");
// Act
registry.AddTool(mockTool.Object);
// Assert
Assert.Single(registry.GetAllTools());
}
[Fact]
public void ToolRegistry_ShouldGetToolByName()
{
// Arrange
var registry = new ToolRegistry();
var mockTool = CreateMockTool("search_codex", "Search CODEX documents");
registry.AddTool(mockTool.Object);
// Act
var retrieved = registry.GetTool("search_codex");
// Assert
Assert.NotNull(retrieved);
Assert.Equal("search_codex", retrieved.Name);
}
[Fact]
public void ToolRegistry_GetTool_WithInvalidName_ReturnsNull()
{
// Arrange
var registry = new ToolRegistry();
var mockTool = CreateMockTool("existing_tool", "Exists");
registry.AddTool(mockTool.Object);
// Act
var result = registry.GetTool("nonexistent_tool");
// Assert
Assert.Null(result);
}
[Fact]
public void ToolRegistry_ShouldAddMultipleTools()
{
// Arrange
var registry = new ToolRegistry();
var tool1 = CreateMockTool("tool1", "First tool");
var tool2 = CreateMockTool("tool2", "Second tool");
var tool3 = CreateMockTool("tool3", "Third tool");
// Act
registry.AddTool(tool1.Object);
registry.AddTool(tool2.Object);
registry.AddTool(tool3.Object);
// Assert
var allTools = registry.GetAllTools();
Assert.Equal(3, allTools.Count());
}
[Fact]
public void ToolRegistry_ShouldPreventDuplicateToolNames()
{
// Arrange
var registry = new ToolRegistry();
var tool1 = CreateMockTool("duplicate", "First");
var tool2 = CreateMockTool("duplicate", "Second");
registry.AddTool(tool1.Object);
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() =>
registry.AddTool(tool2.Object)
);
Assert.Contains("already registered", exception.Message);
Assert.Single(registry.GetAllTools());
}
[Fact]
public void ToolRegistry_ShouldRemoveTool()
{
// Arrange
var registry = new ToolRegistry();
var mockTool = CreateMockTool("temp_tool", "Temporary");
registry.AddTool(mockTool.Object);
// Act
var removed = registry.RemoveTool("temp_tool");
// Assert
Assert.True(removed);
Assert.Empty(registry.GetAllTools());
Assert.Null(registry.GetTool("temp_tool"));
}
[Fact]
public void ToolRegistry_RemoveTool_WithInvalidName_ReturnsFalse()
{
// Arrange
var registry = new ToolRegistry();
// Act
var removed = registry.RemoveTool("nonexistent");
// Assert
Assert.False(removed);
}
[Fact]
public void ToolRegistry_ShouldListAllToolNames()
{
// Arrange
var registry = new ToolRegistry();
registry.AddTool(CreateMockTool("tool_a", "A").Object);
registry.AddTool(CreateMockTool("tool_b", "B").Object);
registry.AddTool(CreateMockTool("tool_c", "C").Object);
// Act
var names = registry.GetToolNames();
// Assert
Assert.Equal(3, names.Count());
Assert.Contains("tool_a", names);
Assert.Contains("tool_b", names);
Assert.Contains("tool_c", names);
}
[Fact]
public void ToolRegistry_ShouldHaveTool()
{
// Arrange
var registry = new ToolRegistry();
registry.AddTool(CreateMockTool("exists", "Tool").Object);
// Act & Assert
Assert.True(registry.HasTool("exists"));
Assert.False(registry.HasTool("not_exists"));
}
[Fact]
public void ToolRegistry_ShouldClearAllTools()
{
// Arrange
var registry = new ToolRegistry();
registry.AddTool(CreateMockTool("tool1", "1").Object);
registry.AddTool(CreateMockTool("tool2", "2").Object);
// Act
registry.Clear();
// Assert
Assert.Empty(registry.GetAllTools());
Assert.Empty(registry.GetToolNames());
}
[Fact]
public void ToolRegistry_GetAllTools_ShouldReturnReadOnlyCollection()
{
// Arrange
var registry = new ToolRegistry();
registry.AddTool(CreateMockTool("tool", "Tool").Object);
// Act
var tools = registry.GetAllTools();
// Assert
Assert.NotNull(tools);
// Ensure modifying returned collection doesn't affect registry
Assert.IsAssignableFrom<System.Collections.Generic.IEnumerable<IMcpTool>>(tools);
}
// Helper method to create mock tools
private Mock<IMcpTool> CreateMockTool(string name, string description)
{
var mockTool = new Mock<IMcpTool>();
mockTool.Setup(t => t.Name).Returns(name);
mockTool.Setup(t => t.Description).Returns(description);
mockTool.Setup(t => t.Schema).Returns(JsonDocument.Parse("""{"type": "object"}"""));
mockTool.Setup(t => t.ExecuteAsync(It.IsAny<JsonDocument>()))
.ReturnsAsync(JsonDocument.Parse("""{"status": "ok"}"""));
return mockTool;
}
}