diff --git a/colaflow-api/ColaFlow.sln b/colaflow-api/ColaFlow.sln
index 1223dba..7543624 100644
--- a/colaflow-api/ColaFlow.sln
+++ b/colaflow-api/ColaFlow.sln
@@ -55,6 +55,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Identity.I
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Identity.IntegrationTests", "tests\Modules\Identity\ColaFlow.Modules.Identity.IntegrationTests\ColaFlow.Modules.Identity.IntegrationTests.csproj", "{86D74CD1-A0F7-467B-899B-82641451A8C4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Mcp.Application", "src\Modules\Mcp\ColaFlow.Modules.Mcp.Application\ColaFlow.Modules.Mcp.Application.csproj", "{D07B22E9-2C46-5425-4076-2E0D5E128488}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mcp", "Mcp", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Mcp.Contracts", "src\Modules\Mcp\ColaFlow.Modules.Mcp.Contracts\ColaFlow.Modules.Mcp.Contracts.csproj", "{9B021F2B-646E-3639-D365-19BA2E4693D7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Mcp.Domain", "src\Modules\Mcp\ColaFlow.Modules.Mcp.Domain\ColaFlow.Modules.Mcp.Domain.csproj", "{C26E375D-DE7C-134E-9846-F87FA19AFEAD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.Mcp.Infrastructure", "src\Modules\Mcp\ColaFlow.Modules.Mcp.Infrastructure\ColaFlow.Modules.Mcp.Infrastructure.csproj", "{31D96779-9DDF-04D6-B22B-6F0FBCB6E846}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -281,6 +291,54 @@ Global
{86D74CD1-A0F7-467B-899B-82641451A8C4}.Release|x64.Build.0 = Release|Any CPU
{86D74CD1-A0F7-467B-899B-82641451A8C4}.Release|x86.ActiveCfg = Release|Any CPU
{86D74CD1-A0F7-467B-899B-82641451A8C4}.Release|x86.Build.0 = Release|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Debug|x64.Build.0 = Debug|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Debug|x86.Build.0 = Debug|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Release|x64.ActiveCfg = Release|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Release|x64.Build.0 = Release|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Release|x86.ActiveCfg = Release|Any CPU
+ {D07B22E9-2C46-5425-4076-2E0D5E128488}.Release|x86.Build.0 = Release|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Debug|x64.Build.0 = Debug|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Debug|x86.Build.0 = Debug|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Release|x64.ActiveCfg = Release|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Release|x64.Build.0 = Release|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Release|x86.ActiveCfg = Release|Any CPU
+ {9B021F2B-646E-3639-D365-19BA2E4693D7}.Release|x86.Build.0 = Release|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Debug|x64.Build.0 = Debug|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Debug|x86.Build.0 = Debug|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Release|x64.ActiveCfg = Release|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Release|x64.Build.0 = Release|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Release|x86.ActiveCfg = Release|Any CPU
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD}.Release|x86.Build.0 = Release|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Debug|x64.Build.0 = Debug|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Debug|x86.Build.0 = Debug|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Release|Any CPU.Build.0 = Release|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Release|x64.ActiveCfg = Release|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Release|x64.Build.0 = Release|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Release|x86.ActiveCfg = Release|Any CPU
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -310,6 +368,11 @@ Global
{18EA8D3B-8570-4D51-B410-580F0782A61C} = {ACB2D19B-6984-27D8-539C-F209B7C78BA5}
{6401A1D7-2E1E-4FE1-B2F6-3DC82C2948DA} = {ACB2D19B-6984-27D8-539C-F209B7C78BA5}
{86D74CD1-A0F7-467B-899B-82641451A8C4} = {ACB2D19B-6984-27D8-539C-F209B7C78BA5}
+ {D07B22E9-2C46-5425-4076-2E0D5E128488} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
+ {9B021F2B-646E-3639-D365-19BA2E4693D7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {C26E375D-DE7C-134E-9846-F87FA19AFEAD} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {31D96779-9DDF-04D6-B22B-6F0FBCB6E846} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3A6D2E28-927B-49D8-BABA-B5D2FC6D416E}
diff --git a/colaflow-api/src/ColaFlow.API/Controllers/McpApiKeysController.cs b/colaflow-api/src/ColaFlow.API/Controllers/McpApiKeysController.cs
new file mode 100644
index 0000000..ac94eff
--- /dev/null
+++ b/colaflow-api/src/ColaFlow.API/Controllers/McpApiKeysController.cs
@@ -0,0 +1,264 @@
+using ColaFlow.Modules.Mcp.Application.DTOs;
+using ColaFlow.Modules.Mcp.Application.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System.Security.Claims;
+
+namespace ColaFlow.API.Controllers;
+
+///
+/// Controller for managing MCP API Keys
+/// Requires JWT authentication (not API Key auth)
+///
+[ApiController]
+[Route("api/mcp/keys")]
+[Authorize] // Requires JWT authentication
+public class McpApiKeysController : ControllerBase
+{
+ private readonly IMcpApiKeyService _apiKeyService;
+ private readonly ILogger _logger;
+
+ public McpApiKeysController(
+ IMcpApiKeyService apiKeyService,
+ ILogger logger)
+ {
+ _apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Create a new API Key
+ /// IMPORTANT: The plain API key is only returned once at creation!
+ ///
+ ///
+ /// Sample request:
+ ///
+ /// POST /api/mcp/keys
+ /// {
+ /// "name": "Claude Desktop",
+ /// "description": "API key for Claude Desktop integration",
+ /// "read": true,
+ /// "write": true,
+ /// "expirationDays": 90
+ /// }
+ ///
+ /// Sample response:
+ ///
+ /// {
+ /// "id": "...",
+ /// "name": "Claude Desktop",
+ /// "plainKey": "cola_abc123...xyz", // SAVE THIS - shown only once!
+ /// "keyPrefix": "cola_abc123...",
+ /// "expiresAt": "2025-03-01T00:00:00Z",
+ /// "permissions": {
+ /// "read": true,
+ /// "write": true,
+ /// "allowedResources": [],
+ /// "allowedTools": []
+ /// }
+ /// }
+ ///
+ ///
+ [HttpPost]
+ [ProducesResponseType(typeof(CreateApiKeyResponse), 200)]
+ [ProducesResponseType(400)]
+ [ProducesResponseType(401)]
+ public async Task CreateApiKey([FromBody] CreateApiKeyRequestDto request)
+ {
+ try
+ {
+ // Extract user and tenant from JWT claims
+ var userId = Guid.Parse(User.FindFirstValue("user_id")!);
+ var tenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
+
+ var createRequest = new CreateApiKeyRequest
+ {
+ Name = request.Name,
+ Description = request.Description,
+ TenantId = tenantId,
+ UserId = userId,
+ Read = request.Read,
+ Write = request.Write,
+ AllowedResources = request.AllowedResources,
+ AllowedTools = request.AllowedTools,
+ IpWhitelist = request.IpWhitelist,
+ ExpirationDays = request.ExpirationDays
+ };
+
+ var response = await _apiKeyService.CreateAsync(createRequest, HttpContext.RequestAborted);
+
+ _logger.LogInformation("API Key created: {Name} by User {UserId}", request.Name, userId);
+
+ return Ok(response);
+ }
+ catch (ArgumentException ex)
+ {
+ _logger.LogWarning(ex, "Invalid API Key creation request");
+ return BadRequest(new { message = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create API Key");
+ return StatusCode(500, new { message = "Failed to create API Key" });
+ }
+ }
+
+ ///
+ /// Get all API Keys for the current tenant
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(List), 200)]
+ [ProducesResponseType(401)]
+ public async Task GetApiKeys()
+ {
+ try
+ {
+ var tenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
+
+ var apiKeys = await _apiKeyService.GetByTenantIdAsync(tenantId, HttpContext.RequestAborted);
+
+ return Ok(apiKeys);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get API Keys");
+ return StatusCode(500, new { message = "Failed to get API Keys" });
+ }
+ }
+
+ ///
+ /// Get API Key by ID
+ ///
+ [HttpGet("{id}")]
+ [ProducesResponseType(typeof(ApiKeyResponse), 200)]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(401)]
+ public async Task GetApiKeyById(Guid id)
+ {
+ try
+ {
+ var tenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
+
+ var apiKey = await _apiKeyService.GetByIdAsync(id, tenantId, HttpContext.RequestAborted);
+ if (apiKey == null)
+ {
+ return NotFound(new { message = "API Key not found" });
+ }
+
+ return Ok(apiKey);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get API Key {ApiKeyId}", id);
+ return StatusCode(500, new { message = "Failed to get API Key" });
+ }
+ }
+
+ ///
+ /// Update API Key metadata (name, description)
+ ///
+ [HttpPatch("{id}/metadata")]
+ [ProducesResponseType(typeof(ApiKeyResponse), 200)]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(401)]
+ public async Task UpdateMetadata(Guid id, [FromBody] UpdateApiKeyMetadataRequest request)
+ {
+ try
+ {
+ var tenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
+
+ var apiKey = await _apiKeyService.UpdateMetadataAsync(id, tenantId, request, HttpContext.RequestAborted);
+
+ _logger.LogInformation("API Key metadata updated: {ApiKeyId}", id);
+
+ return Ok(apiKey);
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogWarning(ex, "API Key not found: {ApiKeyId}", id);
+ return NotFound(new { message = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to update API Key metadata: {ApiKeyId}", id);
+ return StatusCode(500, new { message = "Failed to update API Key" });
+ }
+ }
+
+ ///
+ /// Update API Key permissions
+ ///
+ [HttpPatch("{id}/permissions")]
+ [ProducesResponseType(typeof(ApiKeyResponse), 200)]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(401)]
+ public async Task UpdatePermissions(Guid id, [FromBody] UpdateApiKeyPermissionsRequest request)
+ {
+ try
+ {
+ var tenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
+
+ var apiKey = await _apiKeyService.UpdatePermissionsAsync(id, tenantId, request, HttpContext.RequestAborted);
+
+ _logger.LogInformation("API Key permissions updated: {ApiKeyId}", id);
+
+ return Ok(apiKey);
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogWarning(ex, "API Key not found: {ApiKeyId}", id);
+ return NotFound(new { message = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to update API Key permissions: {ApiKeyId}", id);
+ return StatusCode(500, new { message = "Failed to update API Key" });
+ }
+ }
+
+ ///
+ /// Revoke an API Key (soft delete)
+ ///
+ [HttpDelete("{id}")]
+ [ProducesResponseType(204)]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(401)]
+ public async Task RevokeApiKey(Guid id)
+ {
+ try
+ {
+ var userId = Guid.Parse(User.FindFirstValue("user_id")!);
+ var tenantId = Guid.Parse(User.FindFirstValue("tenant_id")!);
+
+ await _apiKeyService.RevokeAsync(id, tenantId, userId, HttpContext.RequestAborted);
+
+ _logger.LogInformation("API Key revoked: {ApiKeyId} by User {UserId}", id, userId);
+
+ return NoContent();
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogWarning(ex, "API Key not found: {ApiKeyId}", id);
+ return NotFound(new { message = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to revoke API Key: {ApiKeyId}", id);
+ return StatusCode(500, new { message = "Failed to revoke API Key" });
+ }
+ }
+}
+
+///
+/// Request DTO for creating API Key (simplified for API consumers)
+///
+public record CreateApiKeyRequestDto(
+ string Name,
+ string? Description = null,
+ bool Read = true,
+ bool Write = false,
+ List? AllowedResources = null,
+ List? AllowedTools = null,
+ List? IpWhitelist = null,
+ int ExpirationDays = 90
+);
diff --git a/colaflow-api/src/ColaFlow.API/Program.cs b/colaflow-api/src/ColaFlow.API/Program.cs
index e817a03..5a14f35 100644
--- a/colaflow-api/src/ColaFlow.API/Program.cs
+++ b/colaflow-api/src/ColaFlow.API/Program.cs
@@ -27,7 +27,7 @@ builder.Services.AddIdentityApplication();
builder.Services.AddIdentityInfrastructure(builder.Configuration, builder.Environment);
// Register MCP Module
-builder.Services.AddMcpModule();
+builder.Services.AddMcpModule(builder.Configuration);
// Add Response Caching
builder.Services.AddResponseCaching();
@@ -244,6 +244,11 @@ if (app.Environment.IsDevelopment())
app.Logger.LogWarning("⚠️ IssueManagement module not found, skipping migrations");
}
+ // Migrate MCP module
+ var mcpDbContext = services.GetRequiredService();
+ await mcpDbContext.Database.MigrateAsync();
+ app.Logger.LogInformation("✅ MCP module migrations applied successfully");
+
app.Logger.LogInformation("🎉 All database migrations completed successfully!");
}
catch (Exception ex)
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyPermissionsDto.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyPermissionsDto.cs
new file mode 100644
index 0000000..c882d29
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyPermissionsDto.cs
@@ -0,0 +1,12 @@
+namespace ColaFlow.Modules.Mcp.Application.DTOs;
+
+///
+/// DTO for API Key permissions
+///
+public class ApiKeyPermissionsDto
+{
+ public bool Read { get; set; }
+ public bool Write { get; set; }
+ public List AllowedResources { get; set; } = new();
+ public List AllowedTools { get; set; } = new();
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyResponse.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyResponse.cs
new file mode 100644
index 0000000..190ef56
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyResponse.cs
@@ -0,0 +1,23 @@
+namespace ColaFlow.Modules.Mcp.Application.DTOs;
+
+///
+/// Response DTO for API Key (without plain key)
+///
+public class ApiKeyResponse
+{
+ public Guid Id { get; set; }
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+ public required string Name { get; set; }
+ public string? Description { get; set; }
+ public required string KeyPrefix { get; set; }
+ public required string Status { get; set; }
+ public required ApiKeyPermissionsDto Permissions { get; set; }
+ public List? IpWhitelist { get; set; }
+ public DateTime? LastUsedAt { get; set; }
+ public long UsageCount { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime ExpiresAt { get; set; }
+ public DateTime? RevokedAt { get; set; }
+ public Guid? RevokedBy { get; set; }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyValidationResult.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyValidationResult.cs
new file mode 100644
index 0000000..9fc2752
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/ApiKeyValidationResult.cs
@@ -0,0 +1,45 @@
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+namespace ColaFlow.Modules.Mcp.Application.DTOs;
+
+///
+/// Result of API Key validation
+///
+public class ApiKeyValidationResult
+{
+ public bool IsValid { get; private set; }
+ public string? ErrorMessage { get; private set; }
+ public Guid ApiKeyId { get; private set; }
+ public Guid TenantId { get; private set; }
+ public Guid UserId { get; private set; }
+ public ApiKeyPermissions? Permissions { get; private set; }
+
+ private ApiKeyValidationResult()
+ {
+ }
+
+ public static ApiKeyValidationResult Valid(
+ Guid apiKeyId,
+ Guid tenantId,
+ Guid userId,
+ ApiKeyPermissions permissions)
+ {
+ return new ApiKeyValidationResult
+ {
+ IsValid = true,
+ ApiKeyId = apiKeyId,
+ TenantId = tenantId,
+ UserId = userId,
+ Permissions = permissions
+ };
+ }
+
+ public static ApiKeyValidationResult Invalid(string errorMessage)
+ {
+ return new ApiKeyValidationResult
+ {
+ IsValid = false,
+ ErrorMessage = errorMessage
+ };
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/CreateApiKeyRequest.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/CreateApiKeyRequest.cs
new file mode 100644
index 0000000..3c84b3c
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/CreateApiKeyRequest.cs
@@ -0,0 +1,57 @@
+namespace ColaFlow.Modules.Mcp.Application.DTOs;
+
+///
+/// Request DTO for creating a new API Key
+///
+public class CreateApiKeyRequest
+{
+ ///
+ /// Friendly name for the API key
+ ///
+ public required string Name { get; set; }
+
+ ///
+ /// Optional description
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Tenant ID
+ ///
+ public required Guid TenantId { get; set; }
+
+ ///
+ /// User ID who creates the key
+ ///
+ public required Guid UserId { get; set; }
+
+ ///
+ /// Allow read access
+ ///
+ public bool Read { get; set; } = true;
+
+ ///
+ /// Allow write access
+ ///
+ public bool Write { get; set; } = false;
+
+ ///
+ /// List of allowed resource URIs (empty = all allowed)
+ ///
+ public List? AllowedResources { get; set; }
+
+ ///
+ /// List of allowed tool names (empty = all allowed)
+ ///
+ public List? AllowedTools { get; set; }
+
+ ///
+ /// Optional IP whitelist
+ ///
+ public List? IpWhitelist { get; set; }
+
+ ///
+ /// Number of days until expiration (default: 90)
+ ///
+ public int ExpirationDays { get; set; } = 90;
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/CreateApiKeyResponse.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/CreateApiKeyResponse.cs
new file mode 100644
index 0000000..82d0370
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/CreateApiKeyResponse.cs
@@ -0,0 +1,21 @@
+namespace ColaFlow.Modules.Mcp.Application.DTOs;
+
+///
+/// Response DTO for created API Key
+/// IMPORTANT: PlainKey is only shown once at creation!
+///
+public class CreateApiKeyResponse
+{
+ public Guid Id { get; set; }
+ public required string Name { get; set; }
+
+ ///
+ /// IMPORTANT: Plain API Key - shown only once at creation!
+ /// Save this securely - it cannot be retrieved later.
+ ///
+ public required string PlainKey { get; set; }
+
+ public required string KeyPrefix { get; set; }
+ public DateTime ExpiresAt { get; set; }
+ public required ApiKeyPermissionsDto Permissions { get; set; }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/UpdateApiKeyMetadataRequest.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/UpdateApiKeyMetadataRequest.cs
new file mode 100644
index 0000000..619c19e
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/UpdateApiKeyMetadataRequest.cs
@@ -0,0 +1,10 @@
+namespace ColaFlow.Modules.Mcp.Application.DTOs;
+
+///
+/// Request DTO for updating API Key metadata
+///
+public class UpdateApiKeyMetadataRequest
+{
+ public string? Name { get; set; }
+ public string? Description { get; set; }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/UpdateApiKeyPermissionsRequest.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/UpdateApiKeyPermissionsRequest.cs
new file mode 100644
index 0000000..f2fe839
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/UpdateApiKeyPermissionsRequest.cs
@@ -0,0 +1,13 @@
+namespace ColaFlow.Modules.Mcp.Application.DTOs;
+
+///
+/// Request DTO for updating API Key permissions
+///
+public class UpdateApiKeyPermissionsRequest
+{
+ public bool Read { get; set; }
+ public bool Write { get; set; }
+ public List? AllowedResources { get; set; }
+ public List? AllowedTools { get; set; }
+ public List? IpWhitelist { get; set; }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/IMcpApiKeyService.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/IMcpApiKeyService.cs
new file mode 100644
index 0000000..0edcc72
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/IMcpApiKeyService.cs
@@ -0,0 +1,49 @@
+using ColaFlow.Modules.Mcp.Application.DTOs;
+
+namespace ColaFlow.Modules.Mcp.Application.Services;
+
+///
+/// Service interface for MCP API Key management
+///
+public interface IMcpApiKeyService
+{
+ ///
+ /// Create a new API Key
+ ///
+ Task CreateAsync(CreateApiKeyRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Validate an API Key
+ ///
+ Task ValidateAsync(string plainKey, string? ipAddress = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get API Key by ID
+ ///
+ Task GetByIdAsync(Guid id, Guid tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all API Keys for a tenant
+ ///
+ Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all API Keys for a user
+ ///
+ Task> GetByUserIdAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Update API Key metadata
+ ///
+ Task UpdateMetadataAsync(Guid id, Guid tenantId, UpdateApiKeyMetadataRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Update API Key permissions
+ ///
+ Task UpdatePermissionsAsync(Guid id, Guid tenantId, UpdateApiKeyPermissionsRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Revoke an API Key
+ ///
+ Task RevokeAsync(Guid id, Guid tenantId, Guid revokedBy, CancellationToken cancellationToken = default);
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/McpApiKeyService.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/McpApiKeyService.cs
new file mode 100644
index 0000000..f4c5f6e
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/McpApiKeyService.cs
@@ -0,0 +1,255 @@
+using ColaFlow.Modules.Mcp.Application.DTOs;
+using ColaFlow.Modules.Mcp.Domain.Entities;
+using ColaFlow.Modules.Mcp.Domain.Repositories;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+using Microsoft.Extensions.Logging;
+
+namespace ColaFlow.Modules.Mcp.Application.Services;
+
+///
+/// Service implementation for MCP API Key management
+///
+public class McpApiKeyService : IMcpApiKeyService
+{
+ private readonly IMcpApiKeyRepository _repository;
+ private readonly ILogger _logger;
+
+ public McpApiKeyService(
+ IMcpApiKeyRepository repository,
+ ILogger logger)
+ {
+ _repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task CreateAsync(CreateApiKeyRequest request, CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation("Creating API Key '{Name}' for Tenant {TenantId} User {UserId}",
+ request.Name, request.TenantId, request.UserId);
+
+ // Create permissions
+ var permissions = ApiKeyPermissions.Custom(
+ request.Read,
+ request.Write,
+ request.AllowedResources,
+ request.AllowedTools);
+
+ // Create API Key entity
+ var (apiKey, plainKey) = McpApiKey.Create(
+ request.Name,
+ request.TenantId,
+ request.UserId,
+ permissions,
+ request.ExpirationDays,
+ request.IpWhitelist);
+
+ // Add description if provided
+ if (!string.IsNullOrWhiteSpace(request.Description))
+ {
+ apiKey.UpdateMetadata(description: request.Description);
+ }
+
+ // Save to database
+ await _repository.AddAsync(apiKey, cancellationToken);
+
+ _logger.LogInformation("API Key created successfully: {ApiKeyId}", apiKey.Id);
+
+ return new CreateApiKeyResponse
+ {
+ Id = apiKey.Id,
+ Name = apiKey.Name,
+ PlainKey = plainKey, // IMPORTANT: Only returned once!
+ KeyPrefix = apiKey.KeyPrefix,
+ ExpiresAt = apiKey.ExpiresAt,
+ Permissions = new ApiKeyPermissionsDto
+ {
+ Read = apiKey.Permissions.Read,
+ Write = apiKey.Permissions.Write,
+ AllowedResources = apiKey.Permissions.AllowedResources,
+ AllowedTools = apiKey.Permissions.AllowedTools
+ }
+ };
+ }
+
+ public async Task ValidateAsync(string plainKey, string? ipAddress = null, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(plainKey))
+ {
+ return ApiKeyValidationResult.Invalid("API Key is empty");
+ }
+
+ // Extract prefix for fast lookup
+ if (plainKey.Length < 12)
+ {
+ return ApiKeyValidationResult.Invalid("Invalid API Key format");
+ }
+
+ var keyPrefix = plainKey.Substring(0, 12);
+
+ // Lookup by prefix
+ var apiKey = await _repository.GetByPrefixAsync(keyPrefix, cancellationToken);
+ if (apiKey == null)
+ {
+ _logger.LogWarning("API Key not found for prefix: {KeyPrefix}", keyPrefix);
+ return ApiKeyValidationResult.Invalid("Invalid API Key");
+ }
+
+ // Verify hash
+ if (!apiKey.VerifyKey(plainKey))
+ {
+ _logger.LogWarning("API Key hash verification failed for {ApiKeyId}", apiKey.Id);
+ return ApiKeyValidationResult.Invalid("Invalid API Key");
+ }
+
+ // Check status
+ if (apiKey.Status != ApiKeyStatus.Active)
+ {
+ _logger.LogWarning("API Key {ApiKeyId} is revoked", apiKey.Id);
+ return ApiKeyValidationResult.Invalid("API Key has been revoked");
+ }
+
+ // Check expiration
+ if (apiKey.IsExpired())
+ {
+ _logger.LogWarning("API Key {ApiKeyId} is expired", apiKey.Id);
+ return ApiKeyValidationResult.Invalid("API Key has expired");
+ }
+
+ // Check IP whitelist
+ if (!string.IsNullOrWhiteSpace(ipAddress) && !apiKey.IsIpAllowed(ipAddress))
+ {
+ _logger.LogWarning("API Key {ApiKeyId} rejected - IP {IpAddress} not whitelisted", apiKey.Id, ipAddress);
+ return ApiKeyValidationResult.Invalid("IP address not allowed");
+ }
+
+ // Record usage (async, don't block)
+ try
+ {
+ apiKey.RecordUsage();
+ await _repository.UpdateAsync(apiKey, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to record API Key usage for {ApiKeyId}", apiKey.Id);
+ // Continue - don't block auth for usage tracking failures
+ }
+
+ _logger.LogInformation("API Key {ApiKeyId} validated successfully for Tenant {TenantId}",
+ apiKey.Id, apiKey.TenantId);
+
+ return ApiKeyValidationResult.Valid(
+ apiKey.Id,
+ apiKey.TenantId,
+ apiKey.UserId,
+ apiKey.Permissions);
+ }
+
+ public async Task GetByIdAsync(Guid id, Guid tenantId, CancellationToken cancellationToken = default)
+ {
+ var apiKey = await _repository.GetByIdAsync(id, cancellationToken);
+ if (apiKey == null || apiKey.TenantId != tenantId)
+ {
+ return null;
+ }
+
+ return MapToResponse(apiKey);
+ }
+
+ public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default)
+ {
+ var apiKeys = await _repository.GetByTenantIdAsync(tenantId, cancellationToken);
+ return apiKeys.Select(MapToResponse).ToList();
+ }
+
+ public async Task> GetByUserIdAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default)
+ {
+ var apiKeys = await _repository.GetByUserIdAsync(userId, tenantId, cancellationToken);
+ return apiKeys.Select(MapToResponse).ToList();
+ }
+
+ public async Task UpdateMetadataAsync(Guid id, Guid tenantId, UpdateApiKeyMetadataRequest request, CancellationToken cancellationToken = default)
+ {
+ var apiKey = await _repository.GetByIdAsync(id, cancellationToken);
+ if (apiKey == null || apiKey.TenantId != tenantId)
+ {
+ throw new InvalidOperationException("API Key not found");
+ }
+
+ apiKey.UpdateMetadata(request.Name, request.Description);
+ await _repository.UpdateAsync(apiKey, cancellationToken);
+
+ _logger.LogInformation("API Key {ApiKeyId} metadata updated", apiKey.Id);
+
+ return MapToResponse(apiKey);
+ }
+
+ public async Task UpdatePermissionsAsync(Guid id, Guid tenantId, UpdateApiKeyPermissionsRequest request, CancellationToken cancellationToken = default)
+ {
+ var apiKey = await _repository.GetByIdAsync(id, cancellationToken);
+ if (apiKey == null || apiKey.TenantId != tenantId)
+ {
+ throw new InvalidOperationException("API Key not found");
+ }
+
+ var permissions = ApiKeyPermissions.Custom(
+ request.Read,
+ request.Write,
+ request.AllowedResources,
+ request.AllowedTools);
+
+ apiKey.UpdatePermissions(permissions);
+
+ if (request.IpWhitelist != null)
+ {
+ apiKey.UpdateIpWhitelist(request.IpWhitelist);
+ }
+
+ await _repository.UpdateAsync(apiKey, cancellationToken);
+
+ _logger.LogInformation("API Key {ApiKeyId} permissions updated", apiKey.Id);
+
+ return MapToResponse(apiKey);
+ }
+
+ public async Task RevokeAsync(Guid id, Guid tenantId, Guid revokedBy, CancellationToken cancellationToken = default)
+ {
+ var apiKey = await _repository.GetByIdAsync(id, cancellationToken);
+ if (apiKey == null || apiKey.TenantId != tenantId)
+ {
+ throw new InvalidOperationException("API Key not found");
+ }
+
+ apiKey.Revoke(revokedBy);
+ await _repository.UpdateAsync(apiKey, cancellationToken);
+
+ _logger.LogInformation("API Key {ApiKeyId} revoked by {RevokedBy}", apiKey.Id, revokedBy);
+ }
+
+ private static ApiKeyResponse MapToResponse(McpApiKey apiKey)
+ {
+ return new ApiKeyResponse
+ {
+ Id = apiKey.Id,
+ TenantId = apiKey.TenantId,
+ UserId = apiKey.UserId,
+ Name = apiKey.Name,
+ Description = apiKey.Description,
+ KeyPrefix = apiKey.KeyPrefix,
+ Status = apiKey.Status.ToString(),
+ Permissions = new ApiKeyPermissionsDto
+ {
+ Read = apiKey.Permissions.Read,
+ Write = apiKey.Permissions.Write,
+ AllowedResources = apiKey.Permissions.AllowedResources,
+ AllowedTools = apiKey.Permissions.AllowedTools
+ },
+ IpWhitelist = apiKey.IpWhitelist,
+ LastUsedAt = apiKey.LastUsedAt,
+ UsageCount = apiKey.UsageCount,
+ CreatedAt = apiKey.CreatedAt,
+ ExpiresAt = apiKey.ExpiresAt,
+ RevokedAt = apiKey.RevokedAt,
+ RevokedBy = apiKey.RevokedBy
+ };
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ColaFlow.Modules.Mcp.Domain.csproj b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ColaFlow.Modules.Mcp.Domain.csproj
index 3bc0e5b..9c6cb18 100644
--- a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ColaFlow.Modules.Mcp.Domain.csproj
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ColaFlow.Modules.Mcp.Domain.csproj
@@ -10,6 +10,11 @@
+
+
+
+
+
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/McpApiKey.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/McpApiKey.cs
new file mode 100644
index 0000000..da94476
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/McpApiKey.cs
@@ -0,0 +1,245 @@
+using System.Security.Cryptography;
+using ColaFlow.Shared.Kernel.Common;
+using ColaFlow.Modules.Mcp.Domain.Events;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+namespace ColaFlow.Modules.Mcp.Domain.Entities;
+
+///
+/// MCP API Key aggregate root - manages API keys for AI agent authentication
+///
+public sealed class McpApiKey : AggregateRoot
+{
+ // Multi-tenant isolation
+ public Guid TenantId { get; private set; }
+ public Guid UserId { get; private set; }
+
+ // Security fields
+ public string KeyHash { get; private set; } = null!;
+ public string KeyPrefix { get; private set; } = null!;
+
+ // Metadata
+ public string Name { get; private set; } = null!;
+ public string? Description { get; private set; }
+
+ // Permissions
+ public ApiKeyPermissions Permissions { get; private set; } = null!;
+ public List? IpWhitelist { get; private set; }
+
+ // Status tracking
+ public ApiKeyStatus Status { get; private set; }
+ public DateTime? LastUsedAt { get; private set; }
+ public long UsageCount { get; private set; }
+
+ // Lifecycle
+ public DateTime CreatedAt { get; private set; }
+ public DateTime ExpiresAt { get; private set; }
+ public DateTime? RevokedAt { get; private set; }
+ public Guid? RevokedBy { get; private set; }
+
+ // Private constructor for EF Core
+ private McpApiKey() : base()
+ {
+ }
+
+ ///
+ /// Factory method to create a new API Key
+ ///
+ /// Friendly name for the API key
+ /// Tenant ID for multi-tenant isolation
+ /// User ID who created the key
+ /// Permission configuration
+ /// Number of days until expiration (default: 90)
+ /// Optional list of allowed IP addresses
+ /// Tuple of (apiKey entity, plaintext key) - plaintext key shown only once!
+ public static (McpApiKey ApiKey, string PlainKey) Create(
+ string name,
+ Guid tenantId,
+ Guid userId,
+ ApiKeyPermissions permissions,
+ int expirationDays = 90,
+ List? ipWhitelist = null)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new ArgumentException("API Key name cannot be empty", nameof(name));
+
+ if (tenantId == Guid.Empty)
+ throw new ArgumentException("Tenant ID cannot be empty", nameof(tenantId));
+
+ if (userId == Guid.Empty)
+ throw new ArgumentException("User ID cannot be empty", nameof(userId));
+
+ if (permissions == null)
+ throw new ArgumentNullException(nameof(permissions));
+
+ if (expirationDays <= 0 || expirationDays > 365)
+ throw new ArgumentException("Expiration days must be between 1 and 365", nameof(expirationDays));
+
+ // Generate cryptographically secure API key
+ var plainKey = GenerateApiKey();
+ var keyHash = BCrypt.Net.BCrypt.HashPassword(plainKey, workFactor: 12);
+ var keyPrefix = plainKey.Substring(0, 12); // "cola_abc123..."
+
+ var apiKey = new McpApiKey
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ UserId = userId,
+ KeyHash = keyHash,
+ KeyPrefix = keyPrefix,
+ Name = name,
+ Permissions = permissions,
+ Status = ApiKeyStatus.Active,
+ CreatedAt = DateTime.UtcNow,
+ ExpiresAt = DateTime.UtcNow.AddDays(expirationDays),
+ UsageCount = 0,
+ IpWhitelist = ipWhitelist
+ };
+
+ apiKey.AddDomainEvent(new ApiKeyCreatedEvent(apiKey.Id, apiKey.Name, apiKey.TenantId, apiKey.UserId));
+
+ return (apiKey, plainKey);
+ }
+
+ ///
+ /// Revoke the API key (soft delete)
+ ///
+ /// User ID who revoked the key
+ public void Revoke(Guid revokedBy)
+ {
+ if (Status == ApiKeyStatus.Revoked)
+ throw new InvalidOperationException("API Key is already revoked");
+
+ if (revokedBy == Guid.Empty)
+ throw new ArgumentException("Revoked by user ID cannot be empty", nameof(revokedBy));
+
+ Status = ApiKeyStatus.Revoked;
+ RevokedAt = DateTime.UtcNow;
+ RevokedBy = revokedBy;
+
+ AddDomainEvent(new ApiKeyRevokedEvent(Id, Name, TenantId, revokedBy));
+ }
+
+ ///
+ /// Record successful usage of the API key
+ ///
+ public void RecordUsage()
+ {
+ if (Status != ApiKeyStatus.Active)
+ throw new InvalidOperationException("Cannot record usage for inactive API key");
+
+ if (IsExpired())
+ throw new InvalidOperationException("Cannot record usage for expired API key");
+
+ LastUsedAt = DateTime.UtcNow;
+ UsageCount++;
+ }
+
+ ///
+ /// Update API key metadata
+ ///
+ public void UpdateMetadata(string? name = null, string? description = null)
+ {
+ if (Status == ApiKeyStatus.Revoked)
+ throw new InvalidOperationException("Cannot update revoked API key");
+
+ if (!string.IsNullOrWhiteSpace(name))
+ Name = name;
+
+ Description = description;
+ }
+
+ ///
+ /// Update API key permissions
+ ///
+ public void UpdatePermissions(ApiKeyPermissions permissions)
+ {
+ if (Status == ApiKeyStatus.Revoked)
+ throw new InvalidOperationException("Cannot update permissions for revoked API key");
+
+ if (permissions == null)
+ throw new ArgumentNullException(nameof(permissions));
+
+ Permissions = permissions;
+ }
+
+ ///
+ /// Update IP whitelist
+ ///
+ public void UpdateIpWhitelist(List? ipWhitelist)
+ {
+ if (Status == ApiKeyStatus.Revoked)
+ throw new InvalidOperationException("Cannot update IP whitelist for revoked API key");
+
+ IpWhitelist = ipWhitelist;
+ }
+
+ ///
+ /// Check if the API key is expired
+ ///
+ public bool IsExpired()
+ {
+ return DateTime.UtcNow > ExpiresAt;
+ }
+
+ ///
+ /// Check if the API key is valid for use
+ ///
+ public bool IsValid()
+ {
+ return Status == ApiKeyStatus.Active && !IsExpired();
+ }
+
+ ///
+ /// Verify the provided plain key against the stored hash
+ ///
+ public bool VerifyKey(string plainKey)
+ {
+ if (string.IsNullOrWhiteSpace(plainKey))
+ return false;
+
+ try
+ {
+ return BCrypt.Net.BCrypt.Verify(plainKey, KeyHash);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Check if the provided IP address is whitelisted
+ ///
+ public bool IsIpAllowed(string ipAddress)
+ {
+ // If no whitelist configured, allow all IPs
+ if (IpWhitelist == null || IpWhitelist.Count == 0)
+ return true;
+
+ return IpWhitelist.Contains(ipAddress);
+ }
+
+ ///
+ /// Generate a cryptographically secure API key
+ /// Format: cola_<36 random characters>
+ ///
+ private static string GenerateApiKey()
+ {
+ const int byteLength = 30; // Generates 40+ chars in base64
+ var bytes = new byte[byteLength];
+
+ using var rng = RandomNumberGenerator.Create();
+ rng.GetBytes(bytes);
+
+ var base64 = Convert.ToBase64String(bytes)
+ .Replace("+", "")
+ .Replace("/", "")
+ .Replace("=", "");
+
+ // Take first 36 characters
+ var randomPart = base64.Length >= 36 ? base64.Substring(0, 36) : base64.PadRight(36, '0');
+
+ return $"cola_{randomPart}";
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/ApiKeyCreatedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/ApiKeyCreatedEvent.cs
new file mode 100644
index 0000000..c88cd1f
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/ApiKeyCreatedEvent.cs
@@ -0,0 +1,13 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when an API Key is created
+///
+public sealed record ApiKeyCreatedEvent(
+ Guid ApiKeyId,
+ string Name,
+ Guid TenantId,
+ Guid UserId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/ApiKeyRevokedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/ApiKeyRevokedEvent.cs
new file mode 100644
index 0000000..10e003c
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/ApiKeyRevokedEvent.cs
@@ -0,0 +1,13 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when an API Key is revoked
+///
+public sealed record ApiKeyRevokedEvent(
+ Guid ApiKeyId,
+ string Name,
+ Guid TenantId,
+ Guid RevokedBy
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IMcpApiKeyRepository.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IMcpApiKeyRepository.cs
new file mode 100644
index 0000000..75ff9a3
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IMcpApiKeyRepository.cs
@@ -0,0 +1,49 @@
+using ColaFlow.Modules.Mcp.Domain.Entities;
+
+namespace ColaFlow.Modules.Mcp.Domain.Repositories;
+
+///
+/// Repository interface for MCP API Keys
+///
+public interface IMcpApiKeyRepository
+{
+ ///
+ /// Get API Key by ID
+ ///
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get API Key by key prefix (for fast lookup)
+ ///
+ Task GetByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all API Keys for a tenant
+ ///
+ Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all API Keys for a user
+ ///
+ Task> GetByUserIdAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Add a new API Key
+ ///
+ Task AddAsync(McpApiKey apiKey, CancellationToken cancellationToken = default);
+
+ ///
+ /// Update an existing API Key
+ ///
+ Task UpdateAsync(McpApiKey apiKey, CancellationToken cancellationToken = default);
+
+ ///
+ /// Delete an API Key (physical delete - use Revoke for soft delete)
+ ///
+ Task DeleteAsync(McpApiKey apiKey, CancellationToken cancellationToken = default);
+
+ ///
+ /// Check if an API Key prefix already exists
+ ///
+ Task ExistsByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default);
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/ApiKeyPermissions.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/ApiKeyPermissions.cs
new file mode 100644
index 0000000..c67f92b
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/ApiKeyPermissions.cs
@@ -0,0 +1,106 @@
+namespace ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+///
+/// Value object representing API Key permissions
+///
+public sealed class ApiKeyPermissions
+{
+ ///
+ /// Allow read access to MCP resources
+ ///
+ public bool Read { get; set; } = true;
+
+ ///
+ /// Allow write access via MCP tools
+ ///
+ public bool Write { get; set; } = false;
+
+ ///
+ /// List of allowed resource URIs (empty = all allowed)
+ /// Example: ["project://123", "epic://456"]
+ ///
+ public List AllowedResources { get; set; } = new();
+
+ ///
+ /// List of allowed tool names (empty = all allowed)
+ /// Example: ["create_task", "update_story"]
+ ///
+ public List AllowedTools { get; set; } = new();
+
+ ///
+ /// Private constructor for EF Core
+ ///
+ private ApiKeyPermissions()
+ {
+ }
+
+ ///
+ /// Create a read-only permission set
+ ///
+ public static ApiKeyPermissions ReadOnly()
+ {
+ return new ApiKeyPermissions
+ {
+ Read = true,
+ Write = false,
+ AllowedResources = new(),
+ AllowedTools = new()
+ };
+ }
+
+ ///
+ /// Create a read-write permission set
+ ///
+ public static ApiKeyPermissions ReadWrite()
+ {
+ return new ApiKeyPermissions
+ {
+ Read = true,
+ Write = true,
+ AllowedResources = new(),
+ AllowedTools = new()
+ };
+ }
+
+ ///
+ /// Create a custom permission set
+ ///
+ public static ApiKeyPermissions Custom(
+ bool read,
+ bool write,
+ List? allowedResources = null,
+ List? allowedTools = null)
+ {
+ return new ApiKeyPermissions
+ {
+ Read = read,
+ Write = write,
+ AllowedResources = allowedResources ?? new(),
+ AllowedTools = allowedTools ?? new()
+ };
+ }
+
+ ///
+ /// Check if the permission allows the specified resource
+ ///
+ public bool CanAccessResource(string resourceUri)
+ {
+ // If no restrictions, allow all
+ if (AllowedResources.Count == 0)
+ return Read;
+
+ return AllowedResources.Contains(resourceUri);
+ }
+
+ ///
+ /// Check if the permission allows the specified tool
+ ///
+ public bool CanUseTool(string toolName)
+ {
+ // If no restrictions, allow all (if write enabled)
+ if (AllowedTools.Count == 0)
+ return Write;
+
+ return AllowedTools.Contains(toolName);
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/ApiKeyStatus.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/ApiKeyStatus.cs
new file mode 100644
index 0000000..3d4957e
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/ApiKeyStatus.cs
@@ -0,0 +1,17 @@
+namespace ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+///
+/// API Key status enumeration
+///
+public enum ApiKeyStatus
+{
+ ///
+ /// API Key is active and can be used
+ ///
+ Active = 1,
+
+ ///
+ /// API Key has been revoked and cannot be used
+ ///
+ Revoked = 2
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/ColaFlow.Modules.Mcp.Infrastructure.csproj b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/ColaFlow.Modules.Mcp.Infrastructure.csproj
index 36cbf83..c91d253 100644
--- a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/ColaFlow.Modules.Mcp.Infrastructure.csproj
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/ColaFlow.Modules.Mcp.Infrastructure.csproj
@@ -16,4 +16,13 @@
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Extensions/McpServiceExtensions.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Extensions/McpServiceExtensions.cs
index 28da6ea..f9cf3e8 100644
--- a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Extensions/McpServiceExtensions.cs
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Extensions/McpServiceExtensions.cs
@@ -1,6 +1,12 @@
using ColaFlow.Modules.Mcp.Application.Handlers;
+using ColaFlow.Modules.Mcp.Application.Services;
+using ColaFlow.Modules.Mcp.Domain.Repositories;
using ColaFlow.Modules.Mcp.Infrastructure.Middleware;
+using ColaFlow.Modules.Mcp.Infrastructure.Persistence;
+using ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
using Microsoft.AspNetCore.Builder;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ColaFlow.Modules.Mcp.Infrastructure.Extensions;
@@ -13,8 +19,21 @@ public static class McpServiceExtensions
///
/// Registers MCP module services
///
- public static IServiceCollection AddMcpModule(this IServiceCollection services)
+ public static IServiceCollection AddMcpModule(this IServiceCollection services, IConfiguration configuration)
{
+ // Register DbContext
+ services.AddDbContext(options =>
+ {
+ var connectionString = configuration.GetConnectionString("DefaultConnection");
+ options.UseNpgsql(connectionString);
+ });
+
+ // Register repositories
+ services.AddScoped();
+
+ // Register application services
+ services.AddScoped();
+
// Register protocol handler
services.AddScoped();
@@ -29,10 +48,16 @@ public static class McpServiceExtensions
///
/// Adds MCP middleware to the application pipeline
+ /// IMPORTANT: Add authentication middleware BEFORE MCP middleware
///
public static IApplicationBuilder UseMcpMiddleware(this IApplicationBuilder app)
{
+ // Authentication middleware MUST come first
+ app.UseMiddleware();
+
+ // Then the MCP protocol handler
app.UseMiddleware();
+
return app;
}
}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Middleware/McpApiKeyAuthenticationMiddleware.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Middleware/McpApiKeyAuthenticationMiddleware.cs
new file mode 100644
index 0000000..92d6eef
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Middleware/McpApiKeyAuthenticationMiddleware.cs
@@ -0,0 +1,124 @@
+using System.Text.Json;
+using ColaFlow.Modules.Mcp.Application.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
+
+///
+/// Middleware for authenticating MCP requests using API Keys
+/// Only applies to /mcp endpoints
+///
+public class McpApiKeyAuthenticationMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public McpApiKeyAuthenticationMiddleware(
+ RequestDelegate next,
+ ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context, IMcpApiKeyService apiKeyService)
+ {
+ // Only apply to /mcp endpoints
+ if (!context.Request.Path.StartsWithSegments("/mcp"))
+ {
+ await _next(context);
+ return;
+ }
+
+ // Extract API Key from Authorization header
+ var apiKey = ExtractApiKey(context.Request.Headers);
+ if (string.IsNullOrEmpty(apiKey))
+ {
+ _logger.LogWarning("MCP request rejected - Missing API Key");
+ await WriteUnauthorizedResponse(context, "Missing API Key. Please provide Authorization: Bearer header.");
+ return;
+ }
+
+ // Get client IP address
+ var ipAddress = context.Connection.RemoteIpAddress?.ToString();
+
+ // Validate API Key
+ var validationResult = await apiKeyService.ValidateAsync(apiKey, ipAddress, context.RequestAborted);
+ if (!validationResult.IsValid)
+ {
+ _logger.LogWarning("MCP request rejected - Invalid API Key: {ErrorMessage}", validationResult.ErrorMessage);
+ await WriteUnauthorizedResponse(context, validationResult.ErrorMessage ?? "Invalid API Key");
+ return;
+ }
+
+ // Set authentication context for downstream handlers
+ context.Items["McpAuthType"] = "ApiKey";
+ context.Items["McpApiKeyId"] = validationResult.ApiKeyId;
+ context.Items["McpTenantId"] = validationResult.TenantId;
+ context.Items["McpUserId"] = validationResult.UserId;
+ context.Items["McpPermissions"] = validationResult.Permissions;
+
+ _logger.LogDebug("MCP request authenticated - ApiKey: {ApiKeyId}, Tenant: {TenantId}, User: {UserId}",
+ validationResult.ApiKeyId, validationResult.TenantId, validationResult.UserId);
+
+ await _next(context);
+ }
+
+ ///
+ /// Extract API Key from Authorization header
+ /// Supports: Authorization: Bearer
+ ///
+ private static string? ExtractApiKey(IHeaderDictionary headers)
+ {
+ if (!headers.TryGetValue("Authorization", out var authHeader))
+ {
+ return null;
+ }
+
+ var authHeaderValue = authHeader.ToString();
+ if (string.IsNullOrWhiteSpace(authHeaderValue))
+ {
+ return null;
+ }
+
+ // Support "Bearer " format
+ const string bearerPrefix = "Bearer ";
+ if (authHeaderValue.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return authHeaderValue.Substring(bearerPrefix.Length).Trim();
+ }
+
+ // Also support direct API key without "Bearer " prefix (for compatibility)
+ return authHeaderValue.Trim();
+ }
+
+ ///
+ /// Write 401 Unauthorized response in MCP JSON-RPC format
+ ///
+ private static async Task WriteUnauthorizedResponse(HttpContext context, string message)
+ {
+ context.Response.StatusCode = 401;
+ context.Response.ContentType = "application/json";
+
+ var errorResponse = new
+ {
+ jsonrpc = "2.0",
+ error = new
+ {
+ code = -32001, // Custom error code for authentication failure
+ message = "Unauthorized",
+ data = new { details = message }
+ },
+ id = (object?)null // We don't have request id yet
+ };
+
+ var json = JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ });
+
+ await context.Response.WriteAsync(json);
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/McpApiKeyConfiguration.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/McpApiKeyConfiguration.cs
new file mode 100644
index 0000000..6ec17ac
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/McpApiKeyConfiguration.cs
@@ -0,0 +1,120 @@
+using ColaFlow.Modules.Mcp.Domain.Entities;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using System.Text.Json;
+
+namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Configurations;
+
+///
+/// EF Core configuration for McpApiKey entity
+///
+public class McpApiKeyConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("mcp_api_keys", "mcp");
+
+ builder.HasKey(k => k.Id);
+
+ // Tenant and User IDs
+ builder.Property(k => k.TenantId)
+ .HasColumnName("tenant_id")
+ .IsRequired();
+
+ builder.Property(k => k.UserId)
+ .HasColumnName("user_id")
+ .IsRequired();
+
+ // Security fields
+ builder.Property(k => k.KeyHash)
+ .HasColumnName("key_hash")
+ .HasMaxLength(64)
+ .IsRequired();
+
+ builder.Property(k => k.KeyPrefix)
+ .HasColumnName("key_prefix")
+ .HasMaxLength(16)
+ .IsRequired();
+
+ // Metadata
+ builder.Property(k => k.Name)
+ .HasColumnName("name")
+ .HasMaxLength(255)
+ .IsRequired();
+
+ builder.Property(k => k.Description)
+ .HasColumnName("description")
+ .HasColumnType("text")
+ .IsRequired(false);
+
+ // Permissions - stored as JSONB
+ builder.Property(k => k.Permissions)
+ .HasColumnName("permissions")
+ .HasColumnType("jsonb")
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!)
+ .IsRequired();
+
+ // IP Whitelist - stored as JSONB
+ builder.Property(k => k.IpWhitelist)
+ .HasColumnName("ip_whitelist")
+ .HasColumnType("jsonb")
+ .HasConversion(
+ v => v != null ? JsonSerializer.Serialize(v, (JsonSerializerOptions?)null) : null,
+ v => v != null ? JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) : null)
+ .IsRequired(false);
+
+ // Status
+ builder.Property(k => k.Status)
+ .HasColumnName("status")
+ .HasConversion()
+ .IsRequired();
+
+ builder.Property(k => k.LastUsedAt)
+ .HasColumnName("last_used_at")
+ .IsRequired(false);
+
+ builder.Property(k => k.UsageCount)
+ .HasColumnName("usage_count")
+ .IsRequired();
+
+ // Lifecycle
+ builder.Property(k => k.CreatedAt)
+ .HasColumnName("created_at")
+ .IsRequired();
+
+ builder.Property(k => k.ExpiresAt)
+ .HasColumnName("expires_at")
+ .IsRequired();
+
+ builder.Property(k => k.RevokedAt)
+ .HasColumnName("revoked_at")
+ .IsRequired(false);
+
+ builder.Property(k => k.RevokedBy)
+ .HasColumnName("revoked_by")
+ .IsRequired(false);
+
+ // Indexes for performance
+ builder.HasIndex(k => k.KeyPrefix)
+ .HasDatabaseName("ix_mcp_api_keys_key_prefix")
+ .IsUnique();
+
+ builder.HasIndex(k => k.TenantId)
+ .HasDatabaseName("ix_mcp_api_keys_tenant_id");
+
+ builder.HasIndex(k => new { k.TenantId, k.UserId })
+ .HasDatabaseName("ix_mcp_api_keys_tenant_user");
+
+ builder.HasIndex(k => k.ExpiresAt)
+ .HasDatabaseName("ix_mcp_api_keys_expires_at");
+
+ builder.HasIndex(k => k.Status)
+ .HasDatabaseName("ix_mcp_api_keys_status");
+
+ // Ignore domain events (not persisted)
+ builder.Ignore(k => k.DomainEvents);
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContext.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContext.cs
new file mode 100644
index 0000000..9aef0fa
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContext.cs
@@ -0,0 +1,28 @@
+using ColaFlow.Modules.Mcp.Domain.Entities;
+using ColaFlow.Modules.Mcp.Infrastructure.Persistence.Configurations;
+using Microsoft.EntityFrameworkCore;
+
+namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence;
+
+///
+/// DbContext for MCP module
+///
+public class McpDbContext : DbContext
+{
+ public McpDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet ApiKeys => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // Apply configurations
+ modelBuilder.ApplyConfiguration(new McpApiKeyConfiguration());
+
+ // Set default schema
+ modelBuilder.HasDefaultSchema("mcp");
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContextFactory.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContextFactory.cs
new file mode 100644
index 0000000..e68d3a4
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContextFactory.cs
@@ -0,0 +1,35 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+using Microsoft.Extensions.Configuration;
+
+namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence;
+
+///
+/// Design-time factory for McpDbContext (used by EF Core migrations)
+///
+public class McpDbContextFactory : IDesignTimeDbContextFactory
+{
+ public McpDbContext CreateDbContext(string[] args)
+ {
+ // Build configuration
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json", optional: false)
+ .AddJsonFile("appsettings.Development.json", optional: true)
+ .AddEnvironmentVariables()
+ .Build();
+
+ // Get connection string
+ var connectionString = configuration.GetConnectionString("DefaultConnection");
+ if (string.IsNullOrEmpty(connectionString))
+ {
+ throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
+ }
+
+ // Build DbContextOptions
+ var optionsBuilder = new DbContextOptionsBuilder();
+ optionsBuilder.UseNpgsql(connectionString);
+
+ return new McpDbContext(optionsBuilder.Options);
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251108173654_AddMcpApiKeys.Designer.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251108173654_AddMcpApiKeys.Designer.cs
new file mode 100644
index 0000000..2340398
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251108173654_AddMcpApiKeys.Designer.cs
@@ -0,0 +1,125 @@
+//
+using System;
+using ColaFlow.Modules.Mcp.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(McpDbContext))]
+ [Migration("20251108173654_AddMcpApiKeys")]
+ partial class AddMcpApiKeys
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("mcp")
+ .HasAnnotation("ProductVersion", "9.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ColaFlow.Modules.Mcp.Domain.Entities.McpApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("IpWhitelist")
+ .HasColumnType("jsonb")
+ .HasColumnName("ip_whitelist");
+
+ b.Property("KeyHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("key_hash");
+
+ b.Property("KeyPrefix")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("key_prefix");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("name");
+
+ b.Property("Permissions")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("permissions");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("RevokedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("revoked_by");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UsageCount")
+ .HasColumnType("bigint")
+ .HasColumnName("usage_count");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_mcp_api_keys_expires_at");
+
+ b.HasIndex("KeyPrefix")
+ .IsUnique()
+ .HasDatabaseName("ix_mcp_api_keys_key_prefix");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_mcp_api_keys_status");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_mcp_api_keys_tenant_id");
+
+ b.HasIndex("TenantId", "UserId")
+ .HasDatabaseName("ix_mcp_api_keys_tenant_user");
+
+ b.ToTable("mcp_api_keys", "mcp");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251108173654_AddMcpApiKeys.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251108173654_AddMcpApiKeys.cs
new file mode 100644
index 0000000..ce023ba
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251108173654_AddMcpApiKeys.cs
@@ -0,0 +1,84 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddMcpApiKeys : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "mcp");
+
+ migrationBuilder.CreateTable(
+ name: "mcp_api_keys",
+ schema: "mcp",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ tenant_id = table.Column(type: "uuid", nullable: false),
+ user_id = table.Column(type: "uuid", nullable: false),
+ key_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ key_prefix = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ description = table.Column(type: "text", nullable: true),
+ permissions = table.Column(type: "jsonb", nullable: false),
+ ip_whitelist = table.Column(type: "jsonb", nullable: true),
+ status = table.Column(type: "integer", nullable: false),
+ last_used_at = table.Column(type: "timestamp with time zone", nullable: true),
+ usage_count = table.Column(type: "bigint", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ expires_at = table.Column(type: "timestamp with time zone", nullable: false),
+ revoked_at = table.Column(type: "timestamp with time zone", nullable: true),
+ revoked_by = table.Column(type: "uuid", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_mcp_api_keys", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_mcp_api_keys_expires_at",
+ schema: "mcp",
+ table: "mcp_api_keys",
+ column: "expires_at");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_mcp_api_keys_key_prefix",
+ schema: "mcp",
+ table: "mcp_api_keys",
+ column: "key_prefix",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_mcp_api_keys_status",
+ schema: "mcp",
+ table: "mcp_api_keys",
+ column: "status");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_mcp_api_keys_tenant_id",
+ schema: "mcp",
+ table: "mcp_api_keys",
+ column: "tenant_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_mcp_api_keys_tenant_user",
+ schema: "mcp",
+ table: "mcp_api_keys",
+ columns: new[] { "tenant_id", "user_id" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "mcp_api_keys",
+ schema: "mcp");
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/McpDbContextModelSnapshot.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/McpDbContextModelSnapshot.cs
new file mode 100644
index 0000000..59563a5
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/McpDbContextModelSnapshot.cs
@@ -0,0 +1,122 @@
+//
+using System;
+using ColaFlow.Modules.Mcp.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(McpDbContext))]
+ partial class McpDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("mcp")
+ .HasAnnotation("ProductVersion", "9.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ColaFlow.Modules.Mcp.Domain.Entities.McpApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("IpWhitelist")
+ .HasColumnType("jsonb")
+ .HasColumnName("ip_whitelist");
+
+ b.Property("KeyHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("key_hash");
+
+ b.Property("KeyPrefix")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("key_prefix");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("name");
+
+ b.Property("Permissions")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("permissions");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("RevokedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("revoked_by");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UsageCount")
+ .HasColumnType("bigint")
+ .HasColumnName("usage_count");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_mcp_api_keys_expires_at");
+
+ b.HasIndex("KeyPrefix")
+ .IsUnique()
+ .HasDatabaseName("ix_mcp_api_keys_key_prefix");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_mcp_api_keys_status");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_mcp_api_keys_tenant_id");
+
+ b.HasIndex("TenantId", "UserId")
+ .HasDatabaseName("ix_mcp_api_keys_tenant_user");
+
+ b.ToTable("mcp_api_keys", "mcp");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/McpApiKeyRepository.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/McpApiKeyRepository.cs
new file mode 100644
index 0000000..2ff4168
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/McpApiKeyRepository.cs
@@ -0,0 +1,70 @@
+using ColaFlow.Modules.Mcp.Domain.Entities;
+using ColaFlow.Modules.Mcp.Domain.Repositories;
+using Microsoft.EntityFrameworkCore;
+
+namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
+
+///
+/// Repository implementation for MCP API Keys
+///
+public class McpApiKeyRepository : IMcpApiKeyRepository
+{
+ private readonly McpDbContext _context;
+
+ public McpApiKeyRepository(McpDbContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ return await _context.ApiKeys
+ .FirstOrDefaultAsync(k => k.Id == id, cancellationToken);
+ }
+
+ public async Task GetByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default)
+ {
+ return await _context.ApiKeys
+ .FirstOrDefaultAsync(k => k.KeyPrefix == keyPrefix, cancellationToken);
+ }
+
+ public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default)
+ {
+ return await _context.ApiKeys
+ .Where(k => k.TenantId == tenantId)
+ .OrderByDescending(k => k.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetByUserIdAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default)
+ {
+ return await _context.ApiKeys
+ .Where(k => k.UserId == userId && k.TenantId == tenantId)
+ .OrderByDescending(k => k.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task AddAsync(McpApiKey apiKey, CancellationToken cancellationToken = default)
+ {
+ await _context.ApiKeys.AddAsync(apiKey, cancellationToken);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task UpdateAsync(McpApiKey apiKey, CancellationToken cancellationToken = default)
+ {
+ _context.ApiKeys.Update(apiKey);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task DeleteAsync(McpApiKey apiKey, CancellationToken cancellationToken = default)
+ {
+ _context.ApiKeys.Remove(apiKey);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task ExistsByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default)
+ {
+ return await _context.ApiKeys
+ .AnyAsync(k => k.KeyPrefix == keyPrefix, cancellationToken);
+ }
+}
diff --git a/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/ApiKeyPermissionsTests.cs b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/ApiKeyPermissionsTests.cs
new file mode 100644
index 0000000..38ba7ce
--- /dev/null
+++ b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/ApiKeyPermissionsTests.cs
@@ -0,0 +1,102 @@
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+using FluentAssertions;
+using Xunit;
+
+namespace ColaFlow.Modules.Mcp.Tests.Domain;
+
+public class ApiKeyPermissionsTests
+{
+ [Fact]
+ public void ReadOnly_ShouldCreateReadOnlyPermissions()
+ {
+ // Act
+ var permissions = ApiKeyPermissions.ReadOnly();
+
+ // Assert
+ permissions.Read.Should().BeTrue();
+ permissions.Write.Should().BeFalse();
+ permissions.AllowedResources.Should().BeEmpty();
+ permissions.AllowedTools.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ReadWrite_ShouldCreateReadWritePermissions()
+ {
+ // Act
+ var permissions = ApiKeyPermissions.ReadWrite();
+
+ // Assert
+ permissions.Read.Should().BeTrue();
+ permissions.Write.Should().BeTrue();
+ permissions.AllowedResources.Should().BeEmpty();
+ permissions.AllowedTools.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Custom_ShouldCreateCustomPermissions()
+ {
+ // Arrange
+ var allowedResources = new List { "project://123" };
+ var allowedTools = new List { "create_task" };
+
+ // Act
+ var permissions = ApiKeyPermissions.Custom(true, true, allowedResources, allowedTools);
+
+ // Assert
+ permissions.Read.Should().BeTrue();
+ permissions.Write.Should().BeTrue();
+ permissions.AllowedResources.Should().BeEquivalentTo(allowedResources);
+ permissions.AllowedTools.Should().BeEquivalentTo(allowedTools);
+ }
+
+ [Fact]
+ public void CanAccessResource_WithNoRestrictions_ShouldReturnTrue()
+ {
+ // Arrange
+ var permissions = ApiKeyPermissions.ReadOnly();
+
+ // Act
+ var result = permissions.CanAccessResource("project://123");
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void CanAccessResource_WithRestrictions_ShouldValidateCorrectly()
+ {
+ // Arrange
+ var allowedResources = new List { "project://123", "epic://456" };
+ var permissions = ApiKeyPermissions.Custom(true, false, allowedResources);
+
+ // Act & Assert
+ permissions.CanAccessResource("project://123").Should().BeTrue();
+ permissions.CanAccessResource("epic://456").Should().BeTrue();
+ permissions.CanAccessResource("task://789").Should().BeFalse();
+ }
+
+ [Fact]
+ public void CanUseTool_WithNoRestrictions_ShouldReturnWritePermission()
+ {
+ // Arrange
+ var readOnlyPermissions = ApiKeyPermissions.ReadOnly();
+ var readWritePermissions = ApiKeyPermissions.ReadWrite();
+
+ // Act & Assert
+ readOnlyPermissions.CanUseTool("create_task").Should().BeFalse();
+ readWritePermissions.CanUseTool("create_task").Should().BeTrue();
+ }
+
+ [Fact]
+ public void CanUseTool_WithRestrictions_ShouldValidateCorrectly()
+ {
+ // Arrange
+ var allowedTools = new List { "create_task", "update_story" };
+ var permissions = ApiKeyPermissions.Custom(true, true, allowedTools: allowedTools);
+
+ // Act & Assert
+ permissions.CanUseTool("create_task").Should().BeTrue();
+ permissions.CanUseTool("update_story").Should().BeTrue();
+ permissions.CanUseTool("delete_project").Should().BeFalse();
+ }
+}
diff --git a/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/McpApiKeyTests.cs b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/McpApiKeyTests.cs
new file mode 100644
index 0000000..2b66510
--- /dev/null
+++ b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/McpApiKeyTests.cs
@@ -0,0 +1,210 @@
+using ColaFlow.Modules.Mcp.Domain.Entities;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+using FluentAssertions;
+using Xunit;
+
+namespace ColaFlow.Modules.Mcp.Tests.Domain;
+
+public class McpApiKeyTests
+{
+ [Fact]
+ public void Create_ShouldGenerateValidApiKey()
+ {
+ // Arrange
+ var name = "Test API Key";
+ var tenantId = Guid.NewGuid();
+ var userId = Guid.NewGuid();
+ var permissions = ApiKeyPermissions.ReadOnly();
+
+ // Act
+ var (apiKey, plainKey) = McpApiKey.Create(name, tenantId, userId, permissions);
+
+ // Assert
+ apiKey.Should().NotBeNull();
+ apiKey.Name.Should().Be(name);
+ apiKey.TenantId.Should().Be(tenantId);
+ apiKey.UserId.Should().Be(userId);
+ apiKey.Status.Should().Be(ApiKeyStatus.Active);
+ plainKey.Should().StartWith("cola_");
+ plainKey.Length.Should().Be(41); // "cola_" (5) + 36 random chars
+ apiKey.KeyPrefix.Should().Be(plainKey.Substring(0, 12));
+ }
+
+ [Fact]
+ public void Create_ShouldHashApiKey()
+ {
+ // Arrange
+ var name = "Test API Key";
+ var tenantId = Guid.NewGuid();
+ var userId = Guid.NewGuid();
+ var permissions = ApiKeyPermissions.ReadOnly();
+
+ // Act
+ var (apiKey, plainKey) = McpApiKey.Create(name, tenantId, userId, permissions);
+
+ // Assert
+ apiKey.KeyHash.Should().NotBeNullOrEmpty();
+ apiKey.KeyHash.Should().NotBe(plainKey); // Hash should not equal plain key
+ apiKey.VerifyKey(plainKey).Should().BeTrue(); // But should verify correctly
+ }
+
+ [Fact]
+ public void Create_WithInvalidName_ShouldThrowArgumentException()
+ {
+ // Arrange
+ var tenantId = Guid.NewGuid();
+ var userId = Guid.NewGuid();
+ var permissions = ApiKeyPermissions.ReadOnly();
+
+ // Act & Assert
+ Assert.Throws(() =>
+ McpApiKey.Create("", tenantId, userId, permissions));
+ }
+
+ [Fact]
+ public void Create_WithInvalidTenantId_ShouldThrowArgumentException()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var permissions = ApiKeyPermissions.ReadOnly();
+
+ // Act & Assert
+ Assert.Throws(() =>
+ McpApiKey.Create("Test", Guid.Empty, userId, permissions));
+ }
+
+ [Fact]
+ public void VerifyKey_WithCorrectKey_ShouldReturnTrue()
+ {
+ // Arrange
+ var (apiKey, plainKey) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly());
+
+ // Act
+ var result = apiKey.VerifyKey(plainKey);
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void VerifyKey_WithIncorrectKey_ShouldReturnFalse()
+ {
+ // Arrange
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly());
+
+ // Act
+ var result = apiKey.VerifyKey("cola_wrongkey123456789012345678901234");
+
+ // Assert
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Revoke_ShouldMarkAsRevoked()
+ {
+ // Arrange
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly());
+ var revokedBy = Guid.NewGuid();
+
+ // Act
+ apiKey.Revoke(revokedBy);
+
+ // Assert
+ apiKey.Status.Should().Be(ApiKeyStatus.Revoked);
+ apiKey.RevokedAt.Should().NotBeNull();
+ apiKey.RevokedBy.Should().Be(revokedBy);
+ }
+
+ [Fact]
+ public void Revoke_WhenAlreadyRevoked_ShouldThrowInvalidOperationException()
+ {
+ // Arrange
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly());
+ apiKey.Revoke(Guid.NewGuid());
+
+ // Act & Assert
+ Assert.Throws(() => apiKey.Revoke(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void IsExpired_WhenNotExpired_ShouldReturnFalse()
+ {
+ // Arrange
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly(), expirationDays: 90);
+
+ // Act
+ var result = apiKey.IsExpired();
+
+ // Assert
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsValid_WhenActiveAndNotExpired_ShouldReturnTrue()
+ {
+ // Arrange
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly());
+
+ // Act
+ var result = apiKey.IsValid();
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsValid_WhenRevoked_ShouldReturnFalse()
+ {
+ // Arrange
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly());
+ apiKey.Revoke(Guid.NewGuid());
+
+ // Act
+ var result = apiKey.IsValid();
+
+ // Assert
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void RecordUsage_ShouldIncrementUsageCount()
+ {
+ // Arrange
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly());
+ var initialCount = apiKey.UsageCount;
+
+ // Act
+ apiKey.RecordUsage();
+
+ // Assert
+ apiKey.UsageCount.Should().Be(initialCount + 1);
+ apiKey.LastUsedAt.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void IsIpAllowed_WithNoWhitelist_ShouldReturnTrue()
+ {
+ // Arrange
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), ApiKeyPermissions.ReadOnly());
+
+ // Act
+ var result = apiKey.IsIpAllowed("192.168.1.1");
+
+ // Assert
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsIpAllowed_WithWhitelist_ShouldValidateCorrectly()
+ {
+ // Arrange
+ var permissions = ApiKeyPermissions.ReadOnly();
+ var ipWhitelist = new List { "192.168.1.1", "10.0.0.1" };
+ var (apiKey, _) = McpApiKey.Create("Test", Guid.NewGuid(), Guid.NewGuid(), permissions, ipWhitelist: ipWhitelist);
+
+ // Act & Assert
+ apiKey.IsIpAllowed("192.168.1.1").Should().BeTrue();
+ apiKey.IsIpAllowed("10.0.0.1").Should().BeTrue();
+ apiKey.IsIpAllowed("172.16.0.1").Should().BeFalse();
+ }
+}